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 <[email protected]>
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 { 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user