@@ -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)}
+ />
+ >
)
}