Files
IdP/apps/frontend/app/admin/oauth/page.tsx
2026-06-26 09:36:25 +03:00

337 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Copy, ExternalLink, Loader2, LockKeyhole, Plus, UserRound } from 'lucide-react';
import { AdminShell } from '@/components/id/admin-shell';
import { OAuthClientDetailDialog } from '@/components/id/oauth-client-detail-dialog';
import { useAuth } from '@/components/id/auth-provider';
import { useToast } from '@/components/id/toast-provider';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { OAuthClient, OAuthScope, apiFetch } from '@/lib/api';
import { getAdminLandingPath } from '@/lib/admin-access';
import { buildAuthorizeUrl, buildOAuthEndpoints, resolveOAuthApiBase } from '@/lib/oauth-url';
export default function AdminOAuthPage() {
const router = useRouter();
const { token, user } = useAuth();
const { showToast } = useToast();
const [clients, setClients] = useState<OAuthClient[]>([]);
const [scopes, setScopes] = useState<OAuthScope[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [selectedClient, setSelectedClient] = useState<OAuthClient | null>(null);
const [secretDialog, setSecretDialog] = useState<{ clientId: string; clientSecret: string } | null>(null);
const [oauthApiBase, setOauthApiBase] = useState('http://localhost:3000');
const [form, setForm] = useState({ name: '', redirectUris: '', type: 'CONFIDENTIAL', selectedScopes: ['openid', 'profile', 'email'] as string[] });
const oauthEndpoints = useMemo(() => buildOAuthEndpoints(oauthApiBase), [oauthApiBase]);
const loadPublicSettings = useCallback(async () => {
try {
const response = await apiFetch<{ settings: Array<{ key: string; value: string }> }>('/settings/public');
const settings = Object.fromEntries((response.settings ?? []).map((item) => [item.key, item.value]));
setOauthApiBase(resolveOAuthApiBase(settings));
} catch {
// оставляем fallback
}
}, []);
const loadData = useCallback(async () => {
if (!token || !user?.canViewOAuth) return;
setLoading(true);
try {
const clientsResponse = await apiFetch<{ clients: OAuthClient[] }>('/admin/rbac/oauth-clients', {}, token);
setClients(clientsResponse.clients ?? []);
if (user.canManageOAuth) {
const scopesResponse = await apiFetch<{ scopes: OAuthScope[] }>('/admin/rbac/oauth-scopes', {}, token);
setScopes(scopesResponse.scopes ?? []);
}
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось загрузить OAuth-приложения');
} finally {
setLoading(false);
}
}, [showToast, token, user?.canManageOAuth, user?.canViewOAuth]);
useEffect(() => {
void loadPublicSettings();
}, [loadPublicSettings]);
useEffect(() => {
if (!user) return;
if (!user.canViewOAuth && !user.canManageOAuth) {
router.replace(getAdminLandingPath(user));
return;
}
void loadData();
}, [loadData, router, user]);
async function handleCreate() {
if (!token) return;
const redirectUris = form.redirectUris
.split('\n')
.map((item) => item.trim())
.filter(Boolean);
if (!form.name.trim() || !redirectUris.length || !form.selectedScopes.length) {
showToast('Заполните название, redirect URI и scopes');
return;
}
setCreating(true);
try {
const created = await apiFetch<OAuthClient>('/admin/rbac/oauth-clients', {
method: 'POST',
body: JSON.stringify({
name: form.name.trim(),
redirectUris,
scopes: form.selectedScopes,
type: form.type
})
}, token);
showToast('OAuth-приложение создано');
setDialogOpen(false);
setForm({ name: '', redirectUris: '', type: 'CONFIDENTIAL', selectedScopes: ['openid', 'profile', 'email'] });
if (created.clientSecret) {
setSecretDialog({ clientId: created.clientId, clientSecret: created.clientSecret });
}
await loadData();
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось создать приложение');
} finally {
setCreating(false);
}
}
async function handleSaveRedirectUris(client: OAuthClient, redirectUris: string[]) {
if (!token) return;
if (!redirectUris.length) {
showToast('Укажите хотя бы один redirect URI');
return;
}
try {
const updated = await apiFetch<OAuthClient>(`/admin/rbac/oauth-clients/${client.clientId}`, {
method: 'PATCH',
body: JSON.stringify({ redirectUris })
}, token);
showToast('Redirect URI обновлены');
setSelectedClient((current) =>
current?.clientId === client.clientId
? { ...current, redirectUris: updated.redirectUris ?? redirectUris }
: current
);
await loadData();
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось сохранить redirect URI');
}
}
async function handleRotateSecret(clientId: string) {
if (!token) return;
try {
const response = await apiFetch<{ clientId: string; clientSecret: string }>(`/admin/rbac/oauth-clients/${clientId}/rotate-secret`, { method: 'POST' }, token);
setSecretDialog(response);
showToast('Новый client secret сгенерирован');
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось перевыпустить secret');
}
}
async function handleToggleActive(client: OAuthClient) {
if (!token) return;
try {
await apiFetch(`/admin/rbac/oauth-clients/${client.clientId}`, {
method: 'PATCH',
body: JSON.stringify({ isActive: !client.isActive })
}, token);
showToast(client.isActive ? 'Приложение отключено' : 'Приложение включено');
setSelectedClient((current) => (current?.id === client.id ? { ...client, isActive: !client.isActive } : current));
await loadData();
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось обновить приложение');
}
}
async function handleDeleteClient(client: OAuthClient) {
if (!token) return;
if (!window.confirm(`Удалить приложение «${client.name}»? Это действие необратимо.`)) return;
try {
await apiFetch(`/admin/rbac/oauth-clients/${client.clientId}`, { method: 'DELETE' }, token);
showToast('Приложение удалено');
setSelectedClient(null);
await loadData();
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось удалить приложение');
}
}
function copyText(value: string, label: string) {
void navigator.clipboard.writeText(value);
showToast(`${label} скопирован`);
}
return (
<AdminShell active="/admin/oauth">
<div className="mb-6 flex items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-medium">OAuth-приложения</h2>
<p className="text-sm text-[#667085]">
Issuer: <code className="rounded bg-[#f4f5f8] px-1.5 py-0.5 text-xs">{oauthEndpoints.issuer}</code>
{' · '}
<a href={oauthEndpoints.openIdConfigurationUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-[#1d4ed8] hover:underline">
OpenID Configuration
<ExternalLink className="h-3.5 w-3.5" />
</a>
</p>
</div>
<Button onClick={() => setDialogOpen(true)} disabled={!user?.canManageOAuth}>
<Plus className="h-4 w-4" />
Новое приложение
</Button>
</div>
{loading ? (
<div className="flex items-center justify-center gap-2 py-16 text-[#667085]">
<Loader2 className="h-5 w-5 animate-spin" />
Загрузка...
</div>
) : (
<div className="grid gap-4 md:grid-cols-2">
{clients.map((client) => {
const authorizePreview = buildAuthorizeUrl(oauthEndpoints.issuer, {
clientId: client.clientId,
redirectUri: client.redirectUris[0] ?? 'https://app.example.com/oauth/callback',
scope: client.scopes.join(' ')
});
return (
<Card
key={client.id}
className={`cursor-pointer transition hover:shadow-md ${client.isActive ? '' : 'opacity-70'}`}
onClick={() => setSelectedClient(client)}
>
<CardHeader>
<LockKeyhole className="h-6 w-6" />
<CardTitle>{client.name}</CardTitle>
<CardDescription>
{client.type === 'PUBLIC' ? 'Публичное приложение' : 'Confidential приложение'}
{client.createdByDisplayName ? (
<span className="mt-1 flex items-center gap-1.5 text-xs text-[#667085]">
<UserRound className="h-3.5 w-3.5" />
Создал: {client.createdByDisplayName}
</span>
) : null}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="rounded-2xl bg-[#f4f5f8] p-4 text-sm">
<div className="mb-1 font-medium">Authorization URL</div>
<code className="line-clamp-2 break-all text-xs text-[#667085]">{authorizePreview}</code>
</div>
<p className="text-xs text-[#667085]">Нажмите на карточку все endpoints и данные для интеграции</p>
</CardContent>
</Card>
);
})}
{!clients.length ? <p className="text-[#667085]">OAuth-приложения ещё не созданы</p> : null}
</div>
)}
<OAuthClientDetailDialog
client={selectedClient}
endpoints={oauthEndpoints}
open={Boolean(selectedClient)}
onOpenChange={(open) => !open && setSelectedClient(null)}
onCopy={copyText}
onRotateSecret={(clientId) => void handleRotateSecret(clientId)}
onToggleActive={(client) => void handleToggleActive(client)}
onDelete={(client) => void handleDeleteClient(client)}
onSaveRedirectUris={handleSaveRedirectUris}
/>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Новое OAuth-приложение</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium">Название</label>
<Input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} placeholder="Lendry Docs" />
</div>
<div>
<label className="mb-2 block text-sm font-medium">Тип клиента</label>
<select
value={form.type}
onChange={(event) => setForm((current) => ({ ...current, type: event.target.value }))}
className="h-11 w-full rounded-xl border border-[#eceef4] bg-white px-3 text-sm"
>
<option value="CONFIDENTIAL">Confidential (с client secret)</option>
<option value="PUBLIC">Public (без secret)</option>
</select>
</div>
<div>
<label className="mb-2 block text-sm font-medium">Redirect URI (по одному на строку)</label>
<textarea
value={form.redirectUris}
onChange={(event) => setForm((current) => ({ ...current, redirectUris: event.target.value }))}
placeholder="https://app.example.com/oauth/callback"
className="min-h-[96px] w-full rounded-xl border border-[#eceef4] bg-white px-3 py-2 text-sm"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium">Scopes</label>
<div className="space-y-2">
{scopes.map((scope) => (
<label key={scope.id} className="flex items-center gap-2 rounded-xl bg-[#f4f5f8] px-3 py-2 text-sm">
<input
type="checkbox"
checked={form.selectedScopes.includes(scope.slug)}
onChange={(event) =>
setForm((current) => ({
...current,
selectedScopes: event.target.checked
? [...current.selectedScopes, scope.slug]
: current.selectedScopes.filter((item) => item !== scope.slug)
}))
}
/>
<span className="font-medium">{scope.name}</span>
<span className="text-[#667085]">({scope.slug})</span>
</label>
))}
</div>
</div>
<Button className="w-full" disabled={creating} onClick={() => void handleCreate()}>
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Создать приложение'}
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={Boolean(secretDialog)} onOpenChange={(open) => !open && setSecretDialog(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Client Secret</DialogTitle>
</DialogHeader>
<p className="text-sm text-[#667085]">Сохраните secret сейчас он больше не будет показан полностью.</p>
<div className="rounded-2xl bg-[#f4f5f8] p-4">
<p className="mb-1 text-xs text-[#667085]">Client ID</p>
<code className="break-all text-sm">{secretDialog?.clientId}</code>
<p className="mb-1 mt-4 text-xs text-[#667085]">Client Secret</p>
<code className="break-all text-sm">{secretDialog?.clientSecret}</code>
</div>
<Button className="w-full" onClick={() => secretDialog && copyText(secretDialog.clientSecret, 'Client Secret')}>
<Copy className="h-4 w-4" />
Скопировать secret
</Button>
</DialogContent>
</Dialog>
</AdminShell>
);
}