diff --git a/apps/web/src/routes/_auth.admin.apps.tsx b/apps/web/src/routes/_auth.admin.apps.tsx index 586e560..216762b 100644 --- a/apps/web/src/routes/_auth.admin.apps.tsx +++ b/apps/web/src/routes/_auth.admin.apps.tsx @@ -2,7 +2,7 @@ import { createFileRoute } from '@tanstack/react-router' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' import { defaultAppSwitcherConfig } from '@authportal/shared' -import { PageShell } from '@/components/page-shell' +import { PageHead } from '@/components/portal/bits' import { AppSwitcherAdminEditor } from '@/components/reui-kit/app-switcher-admin-editor' import { Frame, @@ -47,13 +47,8 @@ function AdminAppsPage() { }) return ( - -
-

Приложения

-

- URL и подписи для App Switcher (CFDM, VPS Tracker, EvoBGP) -

-
+ <> + @@ -84,6 +79,6 @@ function AdminAppsPage() { )} -
+ ) } diff --git a/apps/web/src/routes/_auth.admin.index.tsx b/apps/web/src/routes/_auth.admin.index.tsx index 0b38054..fa8a02c 100644 --- a/apps/web/src/routes/_auth.admin.index.tsx +++ b/apps/web/src/routes/_auth.admin.index.tsx @@ -7,7 +7,6 @@ import { createFileRoute } from '@tanstack/react-router' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' import type { AdminUser, CreateUserRequest } from '@authportal/shared' -import { PageShell } from '@/components/page-shell' import { AdminUsersGrid } from '@/components/reui-kit/admin-users-grid' import { CreateUserSheet } from '@/components/reui-kit/create-user-sheet' import { UserAccessSheet } from '@/components/reui-kit/user-access-sheet' @@ -175,7 +174,7 @@ function AdminUsersPage() { ) return ( - + <> {error ? (

@@ -229,6 +228,6 @@ function AdminUsersPage() { if (!next) setAuditUser(null) }} /> - + ) } diff --git a/apps/web/src/routes/_auth.admin.oidc.tsx b/apps/web/src/routes/_auth.admin.oidc.tsx index 2dc7e7d..10111b3 100644 --- a/apps/web/src/routes/_auth.admin.oidc.tsx +++ b/apps/web/src/routes/_auth.admin.oidc.tsx @@ -1,345 +1,233 @@ -/** - * Admin OIDC clients — Frame surface. - * Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/sheet-8 - * Docs: https://reui.io/blocks - */ import { useMemo, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { createFileRoute } from '@tanstack/react-router' -import { PlusIcon, KeyRoundIcon, Trash2Icon, CopyIcon } from 'lucide-react' +import { CopyIcon, KeyRoundIcon, LinkIcon, PlusIcon, PowerIcon, ShieldCheckIcon } from 'lucide-react' import { toast } from 'sonner' import type { OidcClientCreated, OidcClientPublic } from '@authportal/shared' -import { PageShell } from '@/components/page-shell' -import { - Frame, - FrameDescription, - FrameHeader, - FramePanel, - FrameTitle, -} from '@/components/reui/frame' +import { CodeBlock, FormFrame, KvList, PageHead, PageTab, PageTabsList, PortalSheet, SettingRow, StatusBadge, TypedConfirm } from '@/components/portal/bits' +import { kpi } from '@/components/portal/kpi' +import { KpiStrip } from '@/components/portal/kpi-strip' +import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid' import { Button } from '@authportal/ui/components/button' import { Skeleton } from '@authportal/ui/components/skeleton' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@authportal/ui/components/alert-dialog' -import { Badge } from '@/components/reui/badge' +import { Tabs, TabsContent } from '@authportal/ui/components/tabs' import { CreateOidcClientSheet } from '@/components/reui-kit/create-oidc-client-sheet' import { ApiError } from '@/lib/api-client' -import { - createOidcClient, - deleteOidcClient, - oidcClientsQueryOptions, - oidcMetaQueryOptions, - rotateOidcClientSecret, -} from '@/queries/oidc' +import { createOidcClient, deleteOidcClient, oidcClientsQueryOptions, oidcMetaQueryOptions, rotateOidcClientSecret } from '@/queries/oidc' export const Route = createFileRoute('/_auth/admin/oidc')({ component: AdminOidcPage, }) +function copy(value: string) { + void navigator.clipboard.writeText(value) + toast.success('Скопировано') +} + function AdminOidcPage() { const queryClient = useQueryClient() - const { data: clients, isLoading, isError, error } = useQuery( - oidcClientsQueryOptions, - ) + const { data: clients = [], isLoading, isError, error, refetch } = useQuery(oidcClientsQueryOptions) const { data: meta } = useQuery(oidcMetaQueryOptions) + const [tab, setTab] = useState('clients') const [createOpen, setCreateOpen] = useState(false) const [secretOnce, setSecretOnce] = useState(null) - const [deleteId, setDeleteId] = useState(null) + const [selected, setSelected] = useState(null) + const [deleteTarget, setDeleteTarget] = useState(null) + + const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'oidc'] }) + const fail = (fallback: string) => (err: unknown) => toast.error(err instanceof ApiError ? err.message : fallback) const createMutation = useMutation({ mutationFn: createOidcClient, onSuccess: (created) => { - void queryClient.invalidateQueries({ queryKey: ['admin', 'oidc'] }) + void invalidate() setCreateOpen(false) setSecretOnce(created) toast.success('OIDC-клиент создан') }, - onError: (err) => { - toast.error( - err instanceof ApiError ? err.message : 'Не удалось создать клиента', - ) - }, + onError: fail('Не удалось создать клиента'), }) - const deleteMutation = useMutation({ mutationFn: deleteOidcClient, onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ['admin', 'oidc'] }) - setDeleteId(null) + void invalidate() + setDeleteTarget(null) + setSelected(null) toast.success('Клиент удалён') }, - onError: (err) => { - toast.error( - err instanceof ApiError ? err.message : 'Не удалось удалить', - ) - }, + onError: fail('Не удалось удалить'), }) - const rotateMutation = useMutation({ mutationFn: rotateOidcClientSecret, onSuccess: (created) => { setSecretOnce(created) toast.success('Секрет обновлён') }, - onError: (err) => { - toast.error( - err instanceof ApiError ? err.message : 'Не удалось обновить секрет', - ) - }, + onError: fail('Не удалось обновить секрет'), }) - return ( - -

-
-

OIDC-клиенты

-

- Внешние сервисы (Technitium DNS и др.) через OpenID Connect -

-
- -
+ const columns = useMemo[]>( + () => [ + { + id: 'name', + title: 'Клиент', + size: 280, + sortValue: (c) => c.name, + cell: (c) => , + }, + { id: 'status', title: 'Статус', size: 120, cell: (c) => {c.enabled ? 'enabled' : 'disabled'} }, + { id: 'redirects', title: 'Redirect URI', size: 340, cell: (c) => {c.redirect_uris[0] ?? '—'}{c.redirect_uris.length > 1 ? ` +${c.redirect_uris.length - 1}` : ''} }, + { id: 'scopes', title: 'Scopes', size: 200, cell: (c) => {c.scopes.join(' ')} }, + ], + [], + ) - {meta ? ( - - - Issuer - - Metadata для Relying Party - - - - - - - - + const enabled = clients.filter((c) => c.enabled).length + + return ( + <> + setCreateOpen(true)}> + Добавить клиента + + } + /> + {isError ? ( +
+ {error instanceof ApiError ? error.message : 'Не удалось загрузить клиентов.'} + +
) : null} - - - - Клиенты - - Confidential clients (Authorization Code) - - - + + + + + + + {isLoading ? ( -
- - -
- ) : isError ? ( -

- {error instanceof ApiError - ? error.message - : 'Не удалось загрузить'} -

- ) : !clients?.length ? ( -

- Пока нет OIDC-клиентов. Создайте клиент для Technitium DNS. -

+ ) : ( -
    - {clients.map((c) => ( - rotateMutation.mutate(c.id)} - onDelete={() => setDeleteId(c.id)} - rotating={rotateMutation.isPending} - /> - ))} -
+ c.id} + searchText={(c) => `${c.name} ${c.client_id} ${c.redirect_uris.join(' ')}`} + selectable={false} + views={[ + { label: 'Все', match: () => true }, + { label: 'Включённые', match: (c) => c.enabled }, + { label: 'Отключённые', match: (c) => !c.enabled }, + ]} + onRowClick={setSelected} + emptyMessage="Пока нет OIDC-клиентов. Создайте клиент для Technitium DNS." + /> )} -
- - - createMutation.mutate(values)} - /> - - setSecretOnce(null)} - /> - - { - if (!o) setDeleteId(null) - }} - > - - - Удалить OIDC-клиент? - - Relying Party перестанет получать токены с этим client_id. - - - - Отмена - { - if (deleteId) deleteMutation.mutate(deleteId) - }} - > - Удалить - - - - - - ) -} - -function CopyRow({ label, value }: { label: string; value: string }) { - return ( -
- {label} - - {value} - - -
- ) -} - -function OidcClientRow({ - client, - onRotate, - onDelete, - rotating, -}: { - client: OidcClientPublic - onRotate: () => void - onDelete: () => void - rotating: boolean -}) { - return ( -
  • -
    -
    -
    - {client.name} - {client.enabled ? ( - - enabled - + + + + {meta ? ( + <> + {( + [ + ['Issuer', meta.issuer], + ['Discovery', meta.discovery_url], + ['JWKS', meta.jwks_url], + ] as const + ).map(([label, value]) => ( + + + + ))} + ) : ( - - disabled - + )} -
    - - {client.client_id} - -
    -
    - - -
    -
    -
    - {client.redirect_uris.map((u) => ( - {u} - ))} - scopes: {client.scopes.join(' ')} -
    -
  • - ) -} + + + -function SecretRevealDialog({ - client, - onClose, -}: { - client: OidcClientCreated | null - onClose: () => void -}) { - const open = client != null - const text = useMemo(() => { - if (!client) return '' - return `client_id: ${client.client_id}\nclient_secret: ${client.client_secret}` - }, [client]) - - return ( - { - if (!o) onClose() - }} - > - - - Сохраните client_secret - - Секрет показывается только один раз. Вставьте его в Technitium SSO. - - - {client ? ( -
    -            {text}
    -          
    + !o && setSelected(null)} + title={selected?.name ?? 'Клиент'} + description="OIDC-клиент" + footer={ + <> + + + + + } + > + {selected ? ( + <> +
    + {selected.enabled ? 'enabled' : 'disabled'} +
    + + {selected.redirect_uris.join('\n')} + ) : null} - - Закрыть - { - void navigator.clipboard.writeText(text) - toast.success('Скопировано') - }} - > - Копировать - - -
    -
    + + + createMutation.mutate(values)} /> + + !o && setSecretOnce(null)} + title="Сохраните client_secret" + description="Секрет показывается только один раз · вставьте его в Technitium SSO" + footer={ + <> + + + + } + > + {secretOnce ? {`client_id: ${secretOnce.client_id}\nclient_secret: ${secretOnce.client_secret}`} : null} + + + !o && setDeleteTarget(null)} + title="Удалить OIDC-клиент?" + description="Relying Party перестанет получать токены с этим client_id." + expected={deleteTarget?.name ?? 'удалить'} + confirmLabel="Удалить" + onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)} + /> + ) }