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
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:
1 parent
69b3553f6d
commit
9b05fcf03a
3 files changed
+186
-304
No files matched your search
@@ -2,7 +2,7 @@ import { createFileRoute } from '@tanstack/react-router'
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { defaultAppSwitcherConfig } from '@authportal/shared'
|
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 { AppSwitcherAdminEditor } from '@/components/reui-kit/app-switcher-admin-editor'
|
||||||
import {
|
import {
|
||||||
Frame,
|
Frame,
|
||||||
@@ -47,13 +47,8 @@ function AdminAppsPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<>
|
||||||
<div className="flex flex-col gap-px">
|
<PageHead title="Ссылки приложений" sub="URL и подписи для App Switcher (CFDM, VPS Tracker, EvoBGP)" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<Frame dense spacing="sm" className="w-full">
|
<Frame dense spacing="sm" className="w-full">
|
||||||
<FrameHeader>
|
<FrameHeader>
|
||||||
@@ -84,6 +79,6 @@ function AdminAppsPage() {
|
|||||||
)}
|
)}
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
</Frame>
|
</Frame>
|
||||||
</PageShell>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,6 @@ import { createFileRoute } from '@tanstack/react-router'
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import type { AdminUser, CreateUserRequest } from '@authportal/shared'
|
import type { AdminUser, CreateUserRequest } from '@authportal/shared'
|
||||||
import { PageShell } from '@/components/page-shell'
|
|
||||||
import { AdminUsersGrid } from '@/components/reui-kit/admin-users-grid'
|
import { AdminUsersGrid } from '@/components/reui-kit/admin-users-grid'
|
||||||
import { CreateUserSheet } from '@/components/reui-kit/create-user-sheet'
|
import { CreateUserSheet } from '@/components/reui-kit/create-user-sheet'
|
||||||
import { UserAccessSheet } from '@/components/reui-kit/user-access-sheet'
|
import { UserAccessSheet } from '@/components/reui-kit/user-access-sheet'
|
||||||
@@ -175,7 +174,7 @@ function AdminUsersPage() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<>
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<p className="text-destructive text-sm">
|
<p className="text-destructive text-sm">
|
||||||
@@ -229,6 +228,6 @@ function AdminUsersPage() {
|
|||||||
if (!next) setAuditUser(null)
|
if (!next) setAuditUser(null)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</PageShell>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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 { useMemo, useState } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
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 { toast } from 'sonner'
|
||||||
import type { OidcClientCreated, OidcClientPublic } from '@authportal/shared'
|
import type { OidcClientCreated, OidcClientPublic } from '@authportal/shared'
|
||||||
import { PageShell } from '@/components/page-shell'
|
import { CodeBlock, FormFrame, KvList, PageHead, PageTab, PageTabsList, PortalSheet, SettingRow, StatusBadge, TypedConfirm } from '@/components/portal/bits'
|
||||||
import {
|
import { kpi } from '@/components/portal/kpi'
|
||||||
Frame,
|
import { KpiStrip } from '@/components/portal/kpi-strip'
|
||||||
FrameDescription,
|
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
|
||||||
FrameHeader,
|
|
||||||
FramePanel,
|
|
||||||
FrameTitle,
|
|
||||||
} from '@/components/reui/frame'
|
|
||||||
import { Button } from '@authportal/ui/components/button'
|
import { Button } from '@authportal/ui/components/button'
|
||||||
import { Skeleton } from '@authportal/ui/components/skeleton'
|
import { Skeleton } from '@authportal/ui/components/skeleton'
|
||||||
import {
|
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from '@authportal/ui/components/alert-dialog'
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
|
||||||
import { CreateOidcClientSheet } from '@/components/reui-kit/create-oidc-client-sheet'
|
import { CreateOidcClientSheet } from '@/components/reui-kit/create-oidc-client-sheet'
|
||||||
import { ApiError } from '@/lib/api-client'
|
import { ApiError } from '@/lib/api-client'
|
||||||
import {
|
import { createOidcClient, deleteOidcClient, oidcClientsQueryOptions, oidcMetaQueryOptions, rotateOidcClientSecret } from '@/queries/oidc'
|
||||||
createOidcClient,
|
|
||||||
deleteOidcClient,
|
|
||||||
oidcClientsQueryOptions,
|
|
||||||
oidcMetaQueryOptions,
|
|
||||||
rotateOidcClientSecret,
|
|
||||||
} from '@/queries/oidc'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/admin/oidc')({
|
export const Route = createFileRoute('/_auth/admin/oidc')({
|
||||||
component: AdminOidcPage,
|
component: AdminOidcPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function copy(value: string) {
|
||||||
|
void navigator.clipboard.writeText(value)
|
||||||
|
toast.success('Скопировано')
|
||||||
|
}
|
||||||
|
|
||||||
function AdminOidcPage() {
|
function AdminOidcPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data: clients, isLoading, isError, error } = useQuery(
|
const { data: clients = [], isLoading, isError, error, refetch } = useQuery(oidcClientsQueryOptions)
|
||||||
oidcClientsQueryOptions,
|
|
||||||
)
|
|
||||||
const { data: meta } = useQuery(oidcMetaQueryOptions)
|
const { data: meta } = useQuery(oidcMetaQueryOptions)
|
||||||
|
const [tab, setTab] = useState('clients')
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
const [secretOnce, setSecretOnce] = useState<OidcClientCreated | null>(null)
|
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({
|
const createMutation = useMutation({
|
||||||
mutationFn: createOidcClient,
|
mutationFn: createOidcClient,
|
||||||
onSuccess: (created) => {
|
onSuccess: (created) => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'oidc'] })
|
void invalidate()
|
||||||
setCreateOpen(false)
|
setCreateOpen(false)
|
||||||
setSecretOnce(created)
|
setSecretOnce(created)
|
||||||
toast.success('OIDC-клиент создан')
|
toast.success('OIDC-клиент создан')
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: fail('Не удалось создать клиента'),
|
||||||
toast.error(
|
|
||||||
err instanceof ApiError ? err.message : 'Не удалось создать клиента',
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: deleteOidcClient,
|
mutationFn: deleteOidcClient,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'oidc'] })
|
void invalidate()
|
||||||
setDeleteId(null)
|
setDeleteTarget(null)
|
||||||
|
setSelected(null)
|
||||||
toast.success('Клиент удалён')
|
toast.success('Клиент удалён')
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: fail('Не удалось удалить'),
|
||||||
toast.error(
|
|
||||||
err instanceof ApiError ? err.message : 'Не удалось удалить',
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const rotateMutation = useMutation({
|
const rotateMutation = useMutation({
|
||||||
mutationFn: rotateOidcClientSecret,
|
mutationFn: rotateOidcClientSecret,
|
||||||
onSuccess: (created) => {
|
onSuccess: (created) => {
|
||||||
setSecretOnce(created)
|
setSecretOnce(created)
|
||||||
toast.success('Секрет обновлён')
|
toast.success('Секрет обновлён')
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: fail('Не удалось обновить секрет'),
|
||||||
toast.error(
|
|
||||||
err instanceof ApiError ? err.message : 'Не удалось обновить секрет',
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
const columns = useMemo<GridColumn<OidcClientPublic>[]>(
|
||||||
<PageShell>
|
() => [
|
||||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
{
|
||||||
<div className="flex flex-col gap-px">
|
id: 'name',
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">OIDC-клиенты</h1>
|
title: 'Клиент',
|
||||||
<p className="text-muted-foreground text-sm">
|
size: 280,
|
||||||
Внешние сервисы (Technitium DNS и др.) через OpenID Connect
|
sortValue: (c) => c.name,
|
||||||
</p>
|
cell: (c) => <TwoLineCell dot={c.enabled ? 'ok' : 'wn'} title={c.name} sub={c.client_id} />,
|
||||||
</div>
|
},
|
||||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
{ id: 'status', title: 'Статус', size: 120, cell: (c) => <StatusBadge tone={c.enabled ? 'ok' : 'wn'}>{c.enabled ? 'enabled' : 'disabled'}</StatusBadge> },
|
||||||
<PlusIcon className="size-4" />
|
{ 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> },
|
||||||
</Button>
|
],
|
||||||
</div>
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
{meta ? (
|
const enabled = clients.filter((c) => c.enabled).length
|
||||||
<Frame dense className="w-full">
|
|
||||||
<FrameHeader>
|
return (
|
||||||
<FrameTitle>Issuer</FrameTitle>
|
<>
|
||||||
<FrameDescription>
|
<PageHead
|
||||||
Metadata для Relying Party
|
title="OIDC-клиенты"
|
||||||
</FrameDescription>
|
sub="Внешние сервисы (Technitium DNS и др.) через OpenID Connect · Authorization Code"
|
||||||
</FrameHeader>
|
actions={
|
||||||
<FramePanel className="flex flex-col gap-2 text-sm">
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
<CopyRow label="Issuer" value={meta.issuer} />
|
<PlusIcon /> Добавить клиента
|
||||||
<CopyRow label="Discovery" value={meta.discovery_url} />
|
</Button>
|
||||||
<CopyRow label="JWKS" value={meta.jwks_url} />
|
}
|
||||||
</FramePanel>
|
/>
|
||||||
</Frame>
|
{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}
|
) : null}
|
||||||
|
<KpiStrip
|
||||||
<Frame dense className="w-full">
|
aria-label="Показатели"
|
||||||
<FrameHeader>
|
items={[
|
||||||
<FrameTitle>Клиенты</FrameTitle>
|
kpi('all', 'Клиентов', String(clients.length), { icon: KeyRoundIcon, badge: 'confidential' }),
|
||||||
<FrameDescription>
|
kpi('on', 'Включено', String(enabled), { icon: PowerIcon, tone: 'ok', badge: 'enabled' }),
|
||||||
Confidential clients (Authorization Code)
|
kpi('off', 'Отключено', String(clients.length - enabled), { icon: ShieldCheckIcon, tone: clients.length - enabled ? 'wn' : 'neutral', badge: 'disabled' }),
|
||||||
</FrameDescription>
|
kpi('iss', 'Issuer', meta ? 'готов' : '—', { icon: LinkIcon, badge: 'discovery + JWKS' }),
|
||||||
</FrameHeader>
|
]}
|
||||||
<FramePanel>
|
/>
|
||||||
|
<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 ? (
|
{isLoading ? (
|
||||||
<div className="flex flex-col gap-3">
|
<Skeleton className="h-64 w-full rounded-xl" />
|
||||||
<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>
|
|
||||||
) : (
|
) : (
|
||||||
<ul className="flex flex-col gap-3">
|
<SectionGrid
|
||||||
{clients.map((c) => (
|
title="Клиенты"
|
||||||
<OidcClientRow
|
description="Клик по строке — секрет, redirect URI и удаление"
|
||||||
key={c.id}
|
rows={clients}
|
||||||
client={c}
|
columns={columns}
|
||||||
onRotate={() => rotateMutation.mutate(c.id)}
|
getRowId={(c) => c.id}
|
||||||
onDelete={() => setDeleteId(c.id)}
|
searchText={(c) => `${c.name} ${c.client_id} ${c.redirect_uris.join(' ')}`}
|
||||||
rotating={rotateMutation.isPending}
|
selectable={false}
|
||||||
/>
|
views={[
|
||||||
))}
|
{ label: 'Все', match: () => true },
|
||||||
</ul>
|
{ label: 'Включённые', match: (c) => c.enabled },
|
||||||
|
{ label: 'Отключённые', match: (c) => !c.enabled },
|
||||||
|
]}
|
||||||
|
onRowClick={setSelected}
|
||||||
|
emptyMessage="Пока нет OIDC-клиентов. Создайте клиент для Technitium DNS."
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</FramePanel>
|
</TabsContent>
|
||||||
</Frame>
|
<TabsContent value="issuer">
|
||||||
|
<FormFrame title="Issuer" description="Metadata для Relying Party" badge="OpenID Connect">
|
||||||
<CreateOidcClientSheet
|
{meta ? (
|
||||||
open={createOpen}
|
<>
|
||||||
onOpenChange={setCreateOpen}
|
{(
|
||||||
pending={createMutation.isPending}
|
[
|
||||||
onSubmit={(values) => createMutation.mutate(values)}
|
['Issuer', meta.issuer],
|
||||||
/>
|
['Discovery', meta.discovery_url],
|
||||||
|
['JWKS', meta.jwks_url],
|
||||||
<SecretRevealDialog
|
] as const
|
||||||
client={secretOnce}
|
).map(([label, value]) => (
|
||||||
onClose={() => setSecretOnce(null)}
|
<SettingRow key={label} title={label} description={value}>
|
||||||
/>
|
<Button variant="outline" onClick={() => copy(value)} aria-label={`Копировать ${label}`}>
|
||||||
|
<CopyIcon /> Копировать
|
||||||
<AlertDialog
|
</Button>
|
||||||
open={deleteId != null}
|
</SettingRow>
|
||||||
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>
|
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="warning-light" size="sm">
|
<Skeleton className="h-24 w-full" />
|
||||||
disabled
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</FormFrame>
|
||||||
<code className="text-muted-foreground text-xs">
|
</TabsContent>
|
||||||
{client.client_id}
|
</Tabs>
|
||||||
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SecretRevealDialog({
|
<PortalSheet
|
||||||
client,
|
open={selected !== null}
|
||||||
onClose,
|
onOpenChange={(o) => !o && setSelected(null)}
|
||||||
}: {
|
title={selected?.name ?? 'Клиент'}
|
||||||
client: OidcClientCreated | null
|
description="OIDC-клиент"
|
||||||
onClose: () => void
|
footer={
|
||||||
}) {
|
<>
|
||||||
const open = client != null
|
<Button variant="outline" onClick={() => setSelected(null)}>
|
||||||
const text = useMemo(() => {
|
Закрыть
|
||||||
if (!client) return ''
|
</Button>
|
||||||
return `client_id: ${client.client_id}\nclient_secret: ${client.client_secret}`
|
<Button
|
||||||
}, [client])
|
variant="outline"
|
||||||
|
disabled={!selected || rotateMutation.isPending}
|
||||||
return (
|
onClick={() => selected && rotateMutation.mutate(selected.id)}
|
||||||
<AlertDialog
|
>
|
||||||
open={open}
|
<KeyRoundIcon /> Новый секрет
|
||||||
onOpenChange={(o: boolean) => {
|
</Button>
|
||||||
if (!o) onClose()
|
<Button variant="destructive" onClick={() => selected && setDeleteTarget(selected)}>
|
||||||
}}
|
Удалить
|
||||||
>
|
</Button>
|
||||||
<AlertDialogContent size="sm">
|
</>
|
||||||
<AlertDialogHeader>
|
}
|
||||||
<AlertDialogTitle>Сохраните client_secret</AlertDialogTitle>
|
>
|
||||||
<AlertDialogDescription>
|
{selected ? (
|
||||||
Секрет показывается только один раз. Вставьте его в Technitium SSO.
|
<>
|
||||||
</AlertDialogDescription>
|
<div>
|
||||||
</AlertDialogHeader>
|
<StatusBadge tone={selected.enabled ? 'ok' : 'wn'}>{selected.enabled ? 'enabled' : 'disabled'}</StatusBadge>
|
||||||
{client ? (
|
</div>
|
||||||
<pre className="bg-muted max-h-40 overflow-auto rounded-md p-3 text-xs">
|
<KvList items={[['client_id', selected.client_id], ['Scopes', selected.scopes.join(' ')]]} />
|
||||||
{text}
|
<CodeBlock label="Redirect URI">{selected.redirect_uris.join('\n')}</CodeBlock>
|
||||||
</pre>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<AlertDialogFooter>
|
</PortalSheet>
|
||||||
<AlertDialogCancel>Закрыть</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
<CreateOidcClientSheet open={createOpen} onOpenChange={setCreateOpen} pending={createMutation.isPending} onSubmit={(values) => createMutation.mutate(values)} />
|
||||||
onClick={() => {
|
|
||||||
void navigator.clipboard.writeText(text)
|
<PortalSheet
|
||||||
toast.success('Скопировано')
|
open={secretOnce !== null}
|
||||||
}}
|
onOpenChange={(o) => !o && setSecretOnce(null)}
|
||||||
>
|
title="Сохраните client_secret"
|
||||||
Копировать
|
description="Секрет показывается только один раз · вставьте его в Technitium SSO"
|
||||||
</AlertDialogAction>
|
footer={
|
||||||
</AlertDialogFooter>
|
<>
|
||||||
</AlertDialogContent>
|
<Button variant="outline" onClick={() => setSecretOnce(null)}>
|
||||||
</AlertDialog>
|
Закрыть
|
||||||
|
</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)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user