'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, resolveFrontendBase, resolveOAuthApiBase } from '@/lib/oauth-url'; export default function AdminOAuthPage() { const router = useRouter(); const { token, user } = useAuth(); const { showToast } = useToast(); const [clients, setClients] = useState([]); const [scopes, setScopes] = useState([]); const [loading, setLoading] = useState(true); const [creating, setCreating] = useState(false); const [dialogOpen, setDialogOpen] = useState(false); const [selectedClient, setSelectedClient] = useState(null); const [secretDialog, setSecretDialog] = useState<{ clientId: string; clientSecret: string } | null>(null); const [oauthApiBase, setOauthApiBase] = useState('http://localhost:3002/idp-api'); const [frontendBase, setFrontendBase] = useState('http://localhost:3002'); const [projectName, setProjectName] = useState('MVK ID'); const [form, setForm] = useState({ name: '', redirectUris: '', type: 'CONFIDENTIAL', selectedScopes: ['openid', 'profile', 'email'] as string[] }); const oauthEndpoints = useMemo(() => buildOAuthEndpoints(oauthApiBase, frontendBase), [oauthApiBase, frontendBase]); 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)); setFrontendBase(resolveFrontendBase(settings)); if (settings.PROJECT_NAME?.trim()) { setProjectName(settings.PROJECT_NAME.trim()); } } 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('/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(`/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 (

OAuth-приложения

Issuer: {oauthEndpoints.issuer} {' · '} Frontend: {frontendBase} {' · '} OpenID Configuration

One Tap Login настраивается в{' '} системных настройках {' '} (PUBLIC_API_URL, PUBLIC_FRONTEND_URL, ONE_TAP_ENABLED).

{loading ? (
Загрузка...
) : (
{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 ( setSelectedClient(client)} > {client.name} {client.type === 'PUBLIC' ? 'Публичное приложение' : 'Confidential приложение'} {client.createdByDisplayName ? ( Создал: {client.createdByDisplayName} ) : null}
Authorization URL
{authorizePreview}

Нажмите на карточку — все endpoints и данные для интеграции

); })} {!clients.length ?

OAuth-приложения ещё не созданы

: null}
)} !open && setSelectedClient(null)} onCopy={copyText} onRotateSecret={(clientId) => void handleRotateSecret(clientId)} onToggleActive={(client) => void handleToggleActive(client)} onDelete={(client) => void handleDeleteClient(client)} onSaveRedirectUris={handleSaveRedirectUris} /> Новое OAuth-приложение
setForm((current) => ({ ...current, name: event.target.value }))} placeholder="Lendry Docs" />