feat(web): перенести OIDC, ссылки приложений и пользователей на новый дизайн
quality / changes (push) Successful in 7s
quality / api (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 53s
CD / quality (push) Successful in 1m2s
CD / publish (push) Successful in 1m37s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-23 14:09:04 +07:00
1 parent 69b3553f6d
commit 9b05fcf03a
3 files changed
+186 -304

No files matched your search

+4 -9
View File
@@ -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 (
<PageShell>
<div className="flex flex-col gap-px">
<h1 className="text-2xl font-semibold tracking-tight">Приложения</h1>
<p className="text-muted-foreground text-sm">
URL и подписи для App Switcher (CFDM, VPS Tracker, EvoBGP)
</p>
</div>
<>
<PageHead title="Ссылки приложений" sub="URL и подписи для App Switcher (CFDM, VPS Tracker, EvoBGP)" />
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
@@ -84,6 +79,6 @@ function AdminAppsPage() {
)}
</FramePanel>
</Frame>
</PageShell>
</>
)
}
+2 -3
View File
@@ -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 (
<PageShell>
<>
{error ? (
<div className="flex flex-col gap-2">
<p className="text-destructive text-sm">
@@ -229,6 +228,6 @@ function AdminUsersPage() {
if (!next) setAuditUser(null)
}}
/>
</PageShell>
</>
)
}
+180 -292
View File
@@ -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<OidcClientCreated | null>(null)
const [deleteId, setDeleteId] = useState<string | null>(null)
const [selected, setSelected] = useState<OidcClientPublic | null>(null)
const [deleteTarget, setDeleteTarget] = useState<OidcClientPublic | null>(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 (
<PageShell>
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="flex flex-col gap-px">
<h1 className="text-2xl font-semibold tracking-tight">OIDC-клиенты</h1>
<p className="text-muted-foreground text-sm">
Внешние сервисы (Technitium DNS и др.) через OpenID Connect
</p>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<PlusIcon className="size-4" />
Добавить
</Button>
</div>
const columns = useMemo<GridColumn<OidcClientPublic>[]>(
() => [
{
id: 'name',
title: 'Клиент',
size: 280,
sortValue: (c) => c.name,
cell: (c) => <TwoLineCell dot={c.enabled ? 'ok' : 'wn'} title={c.name} sub={c.client_id} />,
},
{ id: 'status', title: 'Статус', size: 120, cell: (c) => <StatusBadge tone={c.enabled ? 'ok' : 'wn'}>{c.enabled ? 'enabled' : 'disabled'}</StatusBadge> },
{ id: 'redirects', title: 'Redirect URI', size: 340, cell: (c) => <span className="font-mono text-[12.5px]">{c.redirect_uris[0] ?? '—'}{c.redirect_uris.length > 1 ? ` +${c.redirect_uris.length - 1}` : ''}</span> },
{ id: 'scopes', title: 'Scopes', size: 200, cell: (c) => <span className="font-mono text-[12.5px]">{c.scopes.join(' ')}</span> },
],
[],
)
{meta ? (
<Frame dense className="w-full">
<FrameHeader>
<FrameTitle>Issuer</FrameTitle>
<FrameDescription>
Metadata для Relying Party
</FrameDescription>
</FrameHeader>
<FramePanel className="flex flex-col gap-2 text-sm">
<CopyRow label="Issuer" value={meta.issuer} />
<CopyRow label="Discovery" value={meta.discovery_url} />
<CopyRow label="JWKS" value={meta.jwks_url} />
</FramePanel>
</Frame>
const enabled = clients.filter((c) => c.enabled).length
return (
<>
<PageHead
title="OIDC-клиенты"
sub="Внешние сервисы (Technitium DNS и др.) через OpenID Connect · Authorization Code"
actions={
<Button onClick={() => setCreateOpen(true)}>
<PlusIcon /> Добавить клиента
</Button>
}
/>
{isError ? (
<div role="alert" className="border-destructive/40 bg-destructive/10 flex flex-wrap items-center justify-between gap-3 rounded-xl border px-4 py-3 text-sm">
<span>{error instanceof ApiError ? error.message : 'Не удалось загрузить клиентов.'}</span>
<Button variant="outline" onClick={() => void refetch()}>
Повторить
</Button>
</div>
) : null}
<Frame dense className="w-full">
<FrameHeader>
<FrameTitle>Клиенты</FrameTitle>
<FrameDescription>
Confidential clients (Authorization Code)
</FrameDescription>
</FrameHeader>
<FramePanel>
<KpiStrip
aria-label="Показатели"
items={[
kpi('all', 'Клиентов', String(clients.length), { icon: KeyRoundIcon, badge: 'confidential' }),
kpi('on', 'Включено', String(enabled), { icon: PowerIcon, tone: 'ok', badge: 'enabled' }),
kpi('off', 'Отключено', String(clients.length - enabled), { icon: ShieldCheckIcon, tone: clients.length - enabled ? 'wn' : 'neutral', badge: 'disabled' }),
kpi('iss', 'Issuer', meta ? 'готов' : '—', { icon: LinkIcon, badge: 'discovery + JWKS' }),
]}
/>
<Tabs value={tab} onValueChange={setTab} className="min-w-0">
<PageTabsList>
<PageTab value="clients" label="Клиенты" count={clients.length} />
<PageTab value="issuer" label="Issuer" />
</PageTabsList>
<TabsContent value="clients" className="min-w-0">
{isLoading ? (
<div className="flex flex-col gap-3">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
) : isError ? (
<p className="text-destructive text-sm">
{error instanceof ApiError
? error.message
: 'Не удалось загрузить'}
</p>
) : !clients?.length ? (
<p className="text-muted-foreground text-sm">
Пока нет OIDC-клиентов. Создайте клиент для Technitium DNS.
</p>
<Skeleton className="h-64 w-full rounded-xl" />
) : (
<ul className="flex flex-col gap-3">
{clients.map((c) => (
<OidcClientRow
key={c.id}
client={c}
onRotate={() => rotateMutation.mutate(c.id)}
onDelete={() => setDeleteId(c.id)}
rotating={rotateMutation.isPending}
/>
))}
</ul>
<SectionGrid
title="Клиенты"
description="Клик по строке — секрет, redirect URI и удаление"
rows={clients}
columns={columns}
getRowId={(c) => 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."
/>
)}
</FramePanel>
</Frame>
<CreateOidcClientSheet
open={createOpen}
onOpenChange={setCreateOpen}
pending={createMutation.isPending}
onSubmit={(values) => createMutation.mutate(values)}
/>
<SecretRevealDialog
client={secretOnce}
onClose={() => setSecretOnce(null)}
/>
<AlertDialog
open={deleteId != null}
onOpenChange={(o: boolean) => {
if (!o) setDeleteId(null)
}}
>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Удалить OIDC-клиент?</AlertDialogTitle>
<AlertDialogDescription>
Relying Party перестанет получать токены с этим client_id.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deleteMutation.isPending}
onClick={() => {
if (deleteId) deleteMutation.mutate(deleteId)
}}
>
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageShell>
)
}
function CopyRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground w-24 shrink-0">{label}</span>
<code className="bg-muted truncate rounded px-2 py-1 text-xs">
{value}
</code>
<Button
type="button"
size="icon"
variant="ghost"
className="size-7"
aria-label={`Копировать ${label}`}
onClick={() => {
void navigator.clipboard.writeText(value)
toast.success('Скопировано')
}}
>
<CopyIcon className="size-3.5" />
</Button>
</div>
)
}
function OidcClientRow({
client,
onRotate,
onDelete,
rotating,
}: {
client: OidcClientPublic
onRotate: () => void
onDelete: () => void
rotating: boolean
}) {
return (
<li className="border-border flex flex-col gap-2 rounded-lg border p-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{client.name}</span>
{client.enabled ? (
<Badge variant="success-light" size="sm">
enabled
</Badge>
</TabsContent>
<TabsContent value="issuer">
<FormFrame title="Issuer" description="Metadata для Relying Party" badge="OpenID Connect">
{meta ? (
<>
{(
[
['Issuer', meta.issuer],
['Discovery', meta.discovery_url],
['JWKS', meta.jwks_url],
] as const
).map(([label, value]) => (
<SettingRow key={label} title={label} description={value}>
<Button variant="outline" onClick={() => copy(value)} aria-label={`Копировать ${label}`}>
<CopyIcon /> Копировать
</Button>
</SettingRow>
))}
</>
) : (
<Badge variant="warning-light" size="sm">
disabled
</Badge>
<Skeleton className="h-24 w-full" />
)}
</div>
<code className="text-muted-foreground text-xs">
{client.client_id}
</code>
</div>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant="outline"
disabled={rotating}
onClick={onRotate}
>
<KeyRoundIcon className="size-4" />
Секрет
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={onDelete}
aria-label="Удалить"
>
<Trash2Icon className="size-4" />
</Button>
</div>
</div>
<div className="text-muted-foreground flex flex-col gap-1 text-xs">
{client.redirect_uris.map((u) => (
<span key={u}>{u}</span>
))}
<span>scopes: {client.scopes.join(' ')}</span>
</div>
</li>
)
}
</FormFrame>
</TabsContent>
</Tabs>
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 (
<AlertDialog
open={open}
onOpenChange={(o: boolean) => {
if (!o) onClose()
}}
>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Сохраните client_secret</AlertDialogTitle>
<AlertDialogDescription>
Секрет показывается только один раз. Вставьте его в Technitium SSO.
</AlertDialogDescription>
</AlertDialogHeader>
{client ? (
<pre className="bg-muted max-h-40 overflow-auto rounded-md p-3 text-xs">
{text}
</pre>
<PortalSheet
open={selected !== null}
onOpenChange={(o) => !o && setSelected(null)}
title={selected?.name ?? 'Клиент'}
description="OIDC-клиент"
footer={
<>
<Button variant="outline" onClick={() => setSelected(null)}>
Закрыть
</Button>
<Button
variant="outline"
disabled={!selected || rotateMutation.isPending}
onClick={() => selected && rotateMutation.mutate(selected.id)}
>
<KeyRoundIcon /> Новый секрет
</Button>
<Button variant="destructive" onClick={() => selected && setDeleteTarget(selected)}>
Удалить
</Button>
</>
}
>
{selected ? (
<>
<div>
<StatusBadge tone={selected.enabled ? 'ok' : 'wn'}>{selected.enabled ? 'enabled' : 'disabled'}</StatusBadge>
</div>
<KvList items={[['client_id', selected.client_id], ['Scopes', selected.scopes.join(' ')]]} />
<CodeBlock label="Redirect URI">{selected.redirect_uris.join('\n')}</CodeBlock>
</>
) : null}
<AlertDialogFooter>
<AlertDialogCancel>Закрыть</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
void navigator.clipboard.writeText(text)
toast.success('Скопировано')
}}
>
Копировать
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PortalSheet>
<CreateOidcClientSheet open={createOpen} onOpenChange={setCreateOpen} pending={createMutation.isPending} onSubmit={(values) => createMutation.mutate(values)} />
<PortalSheet
open={secretOnce !== null}
onOpenChange={(o) => !o && setSecretOnce(null)}
title="Сохраните client_secret"
description="Секрет показывается только один раз · вставьте его в Technitium SSO"
footer={
<>
<Button variant="outline" onClick={() => setSecretOnce(null)}>
Закрыть
</Button>
<Button onClick={() => secretOnce && copy(`client_id: ${secretOnce.client_id}\nclient_secret: ${secretOnce.client_secret}`)}>
<CopyIcon /> Копировать
</Button>
</>
}
>
{secretOnce ? <CodeBlock label="Учётные данные">{`client_id: ${secretOnce.client_id}\nclient_secret: ${secretOnce.client_secret}`}</CodeBlock> : null}
</PortalSheet>
<TypedConfirm
open={deleteTarget !== null}
onOpenChange={(o) => !o && setDeleteTarget(null)}
title="Удалить OIDC-клиент?"
description="Relying Party перестанет получать токены с этим client_id."
expected={deleteTarget?.name ?? 'удалить'}
confirmLabel="Удалить"
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
/>
</>
)
}