feat(reui): update ReUI components and documentation
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 2m30s
Build and Push Auth Portal Docker Image / create-release (push) Skipped

- Added new OIDC configuration options in `.env.example`.
- Expanded documentation in `AGENTS.md` to include OIDC endpoints and admin UI.
- Updated ReUI skill version and component count from 17 to 20 across various documentation files.
- Enhanced `README.md` and other related files to reflect the new component structure and usage guidelines.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-11 16:58:47 +07:00
co-authored by Cursor
parent cc38bf06f8
commit 0b6fa65b08
61 changed files with 2387 additions and 103 deletions
+13 -1
View File
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
import {
AppWindowIcon,
HistoryIcon,
KeyRoundIcon,
LayoutGridIcon,
LogInIcon,
UsersIcon,
@@ -68,7 +69,8 @@ export function AppSidebar() {
isActive(pathname, '/admin', false) &&
!pathname.startsWith('/admin/apps') &&
!pathname.startsWith('/admin/audit') &&
!pathname.startsWith('/admin/logins')
!pathname.startsWith('/admin/logins') &&
!pathname.startsWith('/admin/oidc')
}
render={<Link to="/admin" />}
>
@@ -106,6 +108,16 @@ export function AppSidebar() {
<span>Ссылки приложений</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
tooltip="OIDC"
isActive={isActive(pathname, '/admin/oidc', false)}
render={<Link to="/admin/oidc" />}
>
<KeyRoundIcon className="size-4" />
<span>OIDC-клиенты</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
+1 -1
View File
@@ -91,7 +91,7 @@ export function AppSwitcher() {
<DropdownMenuItem
key={app.id}
onClick={() => {
void ssoOpenApp(app.url).catch(() => {
void ssoOpenApp(app.url, app.authMode ?? 'jwt').catch(() => {
window.location.href = app.url.replace(/\/$/, '')
})
}}
@@ -6,6 +6,7 @@ import {
ServerIcon,
NetworkIcon,
ShieldIcon,
GlobeIcon,
} from 'lucide-react'
import { APPS, type AppId } from '@authportal/shared'
import { Button } from '@authportal/ui/components/button'
@@ -26,6 +27,7 @@ const APP_ICONS: Record<
vps: ServerIcon,
bgp: NetworkIcon,
fw: ShieldIcon,
dns: GlobeIcon,
}
export function AppsMenu() {
@@ -16,6 +16,9 @@ function breadcrumbs(pathname: string) {
if (pathname.startsWith('/admin/apps')) {
return [{ label: 'Ссылки приложений', href: '/admin/apps' }]
}
if (pathname.startsWith('/admin/oidc')) {
return [{ label: 'OIDC-клиенты', href: '/admin/oidc' }]
}
if (pathname.startsWith('/admin/logins')) {
return [{ label: 'Журнал входов', href: '/admin/logins' }]
}
@@ -2,7 +2,7 @@ import { useState, type FormEvent } from 'react'
import { useNavigate, useSearch } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { EyeIcon, EyeOffIcon } from 'lucide-react'
import { buildSsoRedirectUrl, isReturnToAllowed } from '@authportal/shared'
import { buildSsoRedirectUrl, isPortalOidcAuthorizeUrl, isReturnToAllowed } from '@authportal/shared'
import { Button } from '@authportal/ui/components/button'
import { Field, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input'
@@ -17,7 +17,7 @@ import {
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { ensureReturnToAllowlist, setToken } from '@/lib/auth'
import { ensureAuthConfig, setToken } from '@/lib/auth'
import { ApiError } from '@/lib/api-client'
import { login, meQueryKey } from '@/queries/auth'
import { AuthLogo } from '@/components/blocks/auth-18/components/auth-logo'
@@ -43,8 +43,12 @@ export function PortalLoginForm() {
setToken(res.access_token)
queryClient.setQueryData(meQueryKey, res.user)
const allowlist = await ensureReturnToAllowlist()
const { returnToAllowlist: allowlist, issuer } = await ensureAuthConfig()
if (returnTo && isReturnToAllowed(returnTo, allowlist)) {
if (isPortalOidcAuthorizeUrl(returnTo, issuer)) {
window.location.href = returnTo
return
}
window.location.href = buildSsoRedirectUrl(
returnTo,
res.access_token,
@@ -42,6 +42,7 @@ export const SOURCE_APP_OPTIONS: {
{ value: 'cfdm', label: 'CFDM' },
{ value: 'bgp', label: 'EvoBGP' },
{ value: 'fw', label: 'EvoFirewall' },
{ value: 'dns', label: 'Technitium DNS' },
]
export const severityVariant: Record<AuditSeverity, BadgeProps['variant']> = {
+54 -11
View File
@@ -1,8 +1,10 @@
import {
buildSsoRedirectUrl,
isJwtExpired,
isPortalOidcAuthorizeUrl,
isReturnToAllowed,
readJwtPayload,
type AppAuthMode,
type LoginResponse,
type MeResponse,
} from '@authportal/shared'
@@ -14,11 +16,23 @@ export const DEFAULT_RETURN_TO_ALLOWLIST =
'.shnt.top,localhost,private,http://localhost:5173'
let returnToAllowlist: string | null = null
let issuerUrl: string | null = null
let returnToAllowlistPromise: Promise<string> | null = null
export async function ensureReturnToAllowlist(): Promise<string> {
if (returnToAllowlist) return returnToAllowlist
if (returnToAllowlistPromise) return returnToAllowlistPromise
export async function ensureAuthConfig(): Promise<{
returnToAllowlist: string
issuer: string
}> {
if (returnToAllowlist && issuerUrl) {
return { returnToAllowlist, issuer: issuerUrl }
}
if (returnToAllowlistPromise) {
await returnToAllowlistPromise
return {
returnToAllowlist: returnToAllowlist ?? DEFAULT_RETURN_TO_ALLOWLIST,
issuer: issuerUrl ?? 'https://auth.shnt.top',
}
}
returnToAllowlistPromise = (async () => {
const fromVite = import.meta.env.VITE_RETURN_TO_ALLOWLIST as
@@ -27,22 +41,38 @@ export async function ensureReturnToAllowlist(): Promise<string> {
try {
const res = await fetch('/api/v1/auth/config')
if (res.ok) {
const data = (await res.json()) as { return_to_allowlist?: string }
const data = (await res.json()) as {
return_to_allowlist?: string
issuer?: string
}
if (data.return_to_allowlist) {
returnToAllowlist = data.return_to_allowlist
return returnToAllowlist
}
if (data.issuer) {
issuerUrl = data.issuer
}
}
} catch {
/* ignore */
}
returnToAllowlist = fromVite || DEFAULT_RETURN_TO_ALLOWLIST
returnToAllowlist =
returnToAllowlist || fromVite || DEFAULT_RETURN_TO_ALLOWLIST
issuerUrl = issuerUrl || 'https://auth.shnt.top'
return returnToAllowlist
})().finally(() => {
returnToAllowlistPromise = null
})
return returnToAllowlistPromise
await returnToAllowlistPromise
return {
returnToAllowlist: returnToAllowlist!,
issuer: issuerUrl!,
}
}
export async function ensureReturnToAllowlist(): Promise<string> {
const cfg = await ensureAuthConfig()
return cfg.returnToAllowlist
}
export function getToken(): string | null {
@@ -106,9 +136,16 @@ export async function reissueAccessToken(): Promise<LoginResponse> {
return body
}
/** SSO open: fresh JWT → app /auth/callback. */
export async function ssoOpenApp(appBaseUrl: string): Promise<void> {
/** SSO open: JWT fragment → /auth/callback, or plain URL for OIDC apps. */
export async function ssoOpenApp(
appBaseUrl: string,
authMode: AppAuthMode = 'jwt',
): Promise<void> {
const base = appBaseUrl.replace(/\/$/, '')
if (authMode === 'oidc') {
window.location.href = base
return
}
const issued = await reissueAccessToken()
const callback = `${base}/auth/callback`
window.location.href = buildSsoRedirectUrl(
@@ -118,10 +155,16 @@ export async function ssoOpenApp(appBaseUrl: string): Promise<void> {
)
}
/** SSO return_to handoff with fresh JWT. */
/** SSO return_to handoff with fresh JWT (or clean redirect for OIDC authorize). */
export async function ssoHandoffReturnTo(returnTo: string): Promise<boolean> {
const allowlist = await ensureReturnToAllowlist()
const { returnToAllowlist: allowlist, issuer } = await ensureAuthConfig()
if (!isReturnToAllowed(returnTo, allowlist)) return false
if (isPortalOidcAuthorizeUrl(returnTo, issuer)) {
window.location.href = returnTo
return true
}
const issued = await reissueAccessToken()
try {
const token = getToken()
+45
View File
@@ -0,0 +1,45 @@
import { queryOptions } from '@tanstack/react-query'
import type {
CreateOidcClientRequest,
OidcClientCreated,
OidcClientPublic,
PatchOidcClientRequest,
} from '@authportal/shared'
import { api } from '@/lib/api-client'
export const oidcClientsQueryKey = ['admin', 'oidc', 'clients'] as const
export const oidcMetaQueryKey = ['admin', 'oidc', 'meta'] as const
export const oidcClientsQueryOptions = queryOptions({
queryKey: oidcClientsQueryKey,
queryFn: () => api.get<OidcClientPublic[]>('/api/v1/admin/oidc/clients'),
})
export const oidcMetaQueryOptions = queryOptions({
queryKey: oidcMetaQueryKey,
queryFn: () =>
api.get<{
issuer: string
discovery_url: string
jwks_url: string
scopes: string[]
}>('/api/v1/admin/oidc/meta'),
})
export function createOidcClient(body: CreateOidcClientRequest) {
return api.post<OidcClientCreated>('/api/v1/admin/oidc/clients', body)
}
export function patchOidcClient(id: string, body: PatchOidcClientRequest) {
return api.patch<OidcClientPublic>(`/api/v1/admin/oidc/clients/${id}`, body)
}
export function deleteOidcClient(id: string) {
return api.delete(`/api/v1/admin/oidc/clients/${id}`)
}
export function rotateOidcClientSecret(id: string) {
return api.post<OidcClientCreated>(
`/api/v1/admin/oidc/clients/${id}/rotate-secret`,
)
}
+21
View File
@@ -18,6 +18,7 @@ import { Route as AuthAdminIndexRouteImport } from './routes/_auth.admin.index'
import { Route as AuthAdminAppsRouteImport } from './routes/_auth.admin.apps'
import { Route as AuthAdminAuditRouteImport } from './routes/_auth.admin.audit'
import { Route as AuthAdminLoginsRouteImport } from './routes/_auth.admin.logins'
import { Route as AuthAdminOidcRouteImport } from './routes/_auth.admin.oidc'
import { Route as AuthAdminUsersUserIdRouteImport } from './routes/_auth.admin.users.$userId'
const IndexRoute = IndexRouteImport.update({
@@ -64,6 +65,11 @@ const AuthAdminLoginsRoute = AuthAdminLoginsRouteImport.update({
path: '/logins',
getParentRoute: () => AuthAdminRoute,
} as any)
const AuthAdminOidcRoute = AuthAdminOidcRouteImport.update({
id: '/oidc',
path: '/oidc',
getParentRoute: () => AuthAdminRoute,
} as any)
const AuthAdminUsersUserIdRoute = AuthAdminUsersUserIdRouteImport.update({
id: '/users/$userId',
path: '/users/$userId',
@@ -78,6 +84,7 @@ export interface FileRoutesByFullPath {
'/admin/apps': typeof AuthAdminAppsRoute
'/admin/audit': typeof AuthAdminAuditRoute
'/admin/logins': typeof AuthAdminLoginsRoute
'/admin/oidc': typeof AuthAdminOidcRoute
'/admin/': typeof AuthAdminIndexRoute
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
}
@@ -88,6 +95,7 @@ export interface FileRoutesByTo {
'/admin/apps': typeof AuthAdminAppsRoute
'/admin/audit': typeof AuthAdminAuditRoute
'/admin/logins': typeof AuthAdminLoginsRoute
'/admin/oidc': typeof AuthAdminOidcRoute
'/admin': typeof AuthAdminIndexRoute
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
}
@@ -101,6 +109,7 @@ export interface FileRoutesById {
'/_auth/admin/apps': typeof AuthAdminAppsRoute
'/_auth/admin/audit': typeof AuthAdminAuditRoute
'/_auth/admin/logins': typeof AuthAdminLoginsRoute
'/_auth/admin/oidc': typeof AuthAdminOidcRoute
'/_auth/admin/': typeof AuthAdminIndexRoute
'/_auth/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
}
@@ -114,6 +123,7 @@ export interface FileRouteTypes {
| '/admin/apps'
| '/admin/audit'
| '/admin/logins'
| '/admin/oidc'
| '/admin/'
| '/admin/users/$userId'
fileRoutesByTo: FileRoutesByTo
@@ -124,6 +134,7 @@ export interface FileRouteTypes {
| '/admin/apps'
| '/admin/audit'
| '/admin/logins'
| '/admin/oidc'
| '/admin'
| '/admin/users/$userId'
id:
@@ -136,6 +147,7 @@ export interface FileRouteTypes {
| '/_auth/admin/apps'
| '/_auth/admin/audit'
| '/_auth/admin/logins'
| '/_auth/admin/oidc'
| '/_auth/admin/'
| '/_auth/admin/users/$userId'
fileRoutesById: FileRoutesById
@@ -211,6 +223,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthAdminLoginsRouteImport
parentRoute: typeof AuthAdminRoute
}
'/_auth/admin/oidc': {
id: '/_auth/admin/oidc'
path: '/oidc'
fullPath: '/admin/oidc'
preLoaderRoute: typeof AuthAdminOidcRouteImport
parentRoute: typeof AuthAdminRoute
}
'/_auth/admin/users/$userId': {
id: '/_auth/admin/users/$userId'
path: '/users/$userId'
@@ -225,6 +244,7 @@ interface AuthAdminRouteChildren {
AuthAdminAppsRoute: typeof AuthAdminAppsRoute
AuthAdminAuditRoute: typeof AuthAdminAuditRoute
AuthAdminLoginsRoute: typeof AuthAdminLoginsRoute
AuthAdminOidcRoute: typeof AuthAdminOidcRoute
AuthAdminIndexRoute: typeof AuthAdminIndexRoute
AuthAdminUsersUserIdRoute: typeof AuthAdminUsersUserIdRoute
}
@@ -233,6 +253,7 @@ const AuthAdminRouteChildren: AuthAdminRouteChildren = {
AuthAdminAppsRoute: AuthAdminAppsRoute,
AuthAdminAuditRoute: AuthAdminAuditRoute,
AuthAdminLoginsRoute: AuthAdminLoginsRoute,
AuthAdminOidcRoute: AuthAdminOidcRoute,
AuthAdminIndexRoute: AuthAdminIndexRoute,
AuthAdminUsersUserIdRoute: AuthAdminUsersUserIdRoute,
}
+440
View File
@@ -0,0 +1,440 @@
/**
* Admin OIDC clients — Frame surface.
* Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/data-grid-filtering-2
*/
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 { 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 { Button } from '@authportal/ui/components/button'
import { Field, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input'
import { Switch } from '@authportal/ui/components/switch'
import { Skeleton } from '@authportal/ui/components/skeleton'
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@authportal/ui/components/sheet'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@authportal/ui/components/alert-dialog'
import { Badge } from '@/components/reui/badge'
import { ApiError } from '@/lib/api-client'
import {
createOidcClient,
deleteOidcClient,
oidcClientsQueryOptions,
oidcMetaQueryOptions,
rotateOidcClientSecret,
} from '@/queries/oidc'
export const Route = createFileRoute('/_auth/admin/oidc')({
component: AdminOidcPage,
})
function AdminOidcPage() {
const queryClient = useQueryClient()
const { data: clients, isLoading, isError, error } = useQuery(
oidcClientsQueryOptions,
)
const { data: meta } = useQuery(oidcMetaQueryOptions)
const [createOpen, setCreateOpen] = useState(false)
const [secretOnce, setSecretOnce] = useState<OidcClientCreated | null>(null)
const [deleteId, setDeleteId] = useState<string | null>(null)
const createMutation = useMutation({
mutationFn: createOidcClient,
onSuccess: (created) => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'oidc'] })
setCreateOpen(false)
setSecretOnce(created)
toast.success('OIDC-клиент создан')
},
onError: (err) => {
toast.error(
err instanceof ApiError ? err.message : 'Не удалось создать клиента',
)
},
})
const deleteMutation = useMutation({
mutationFn: deleteOidcClient,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'oidc'] })
setDeleteId(null)
toast.success('Клиент удалён')
},
onError: (err) => {
toast.error(
err instanceof ApiError ? err.message : 'Не удалось удалить',
)
},
})
const rotateMutation = useMutation({
mutationFn: rotateOidcClientSecret,
onSuccess: (created) => {
setSecretOnce(created)
toast.success('Секрет обновлён')
},
onError: (err) => {
toast.error(
err instanceof ApiError ? err.message : 'Не удалось обновить секрет',
)
},
})
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>
{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>
) : null}
<Frame dense className="w-full">
<FrameHeader>
<FrameTitle>Клиенты</FrameTitle>
<FrameDescription>
Confidential clients (Authorization Code)
</FrameDescription>
</FrameHeader>
<FramePanel>
{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>
) : (
<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>
)}
</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>
) : (
<Badge variant="warning-light" size="sm">
disabled
</Badge>
)}
</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>
)
}
function CreateOidcClientSheet({
open,
onOpenChange,
pending,
onSubmit,
}: {
open: boolean
onOpenChange: (o: boolean) => void
pending: boolean
onSubmit: (v: {
name: string
redirect_uris: string[]
scopes: Array<'openid' | 'profile' | 'email' | 'groups'>
enabled: boolean
}) => void
}) {
const [name, setName] = useState('Technitium DNS')
const [redirectUris, setRedirectUris] = useState(
'https://dns.shnt.top/sso/callback',
)
const [enabled, setEnabled] = useState(true)
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex flex-col gap-4 sm:max-w-md">
<SheetHeader>
<SheetTitle>Новый OIDC-клиент</SheetTitle>
<SheetDescription>
Redirect URI для Technitium:{' '}
<code className="text-xs">https://&lt;host&gt;/sso/callback</code>
</SheetDescription>
</SheetHeader>
<FieldGroup className="gap-3">
<Field>
<FieldLabel htmlFor="oidc-name">Название</FieldLabel>
<Input
id="oidc-name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="oidc-redirects">
Redirect URIs (по одному на строку)
</FieldLabel>
<textarea
id="oidc-redirects"
className="border-input bg-background min-h-24 w-full rounded-md border px-3 py-2 text-sm"
value={redirectUris}
onChange={(e) => setRedirectUris(e.target.value)}
/>
</Field>
<Field className="flex flex-row items-center justify-between gap-3">
<FieldLabel htmlFor="oidc-enabled">Включён</FieldLabel>
<Switch
id="oidc-enabled"
checked={enabled}
onCheckedChange={(v: boolean) => setEnabled(v === true)}
/>
</Field>
</FieldGroup>
<SheetFooter>
<Button
type="button"
disabled={pending || !name.trim()}
onClick={() => {
const uris = redirectUris
.split('\n')
.map((s) => s.trim())
.filter(Boolean)
onSubmit({
name: name.trim(),
redirect_uris: uris,
scopes: ['openid', 'profile', 'email', 'groups'],
enabled,
})
}}
>
{pending ? 'Создание…' : 'Создать'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
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>
<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>
) : null}
<AlertDialogFooter>
<AlertDialogCancel>Закрыть</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
void navigator.clipboard.writeText(text)
toast.success('Скопировано')
}}
>
Копировать
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
+15 -4
View File
@@ -1,7 +1,7 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { LayoutGridIcon } from 'lucide-react'
import type { AppId } from '@authportal/shared'
import type { AppAuthMode, AppId } from '@authportal/shared'
import { PageShell } from '@/components/page-shell'
import { Badge } from '@/components/reui/badge'
import {
@@ -21,9 +21,13 @@ export const Route = createFileRoute('/_auth/apps')({
component: AppsPage,
})
async function openApp(_appId: AppId, baseUrl: string) {
async function openApp(
_appId: AppId,
baseUrl: string,
authMode: AppAuthMode = 'jwt',
) {
try {
await ssoOpenApp(baseUrl)
await ssoOpenApp(baseUrl, authMode)
} catch {
window.location.href = baseUrl.replace(/\/$/, '')
}
@@ -95,7 +99,14 @@ function AppsPage() {
<FrameFooter>
<Button
className="w-full"
onClick={() => void openApp(app.id, app.url)}
onClick={() =>
void openApp(
app.id,
app.url,
app.authMode ??
(app.id === 'dns' ? 'oidc' : 'jwt'),
)
}
>
Открыть
</Button>
+2
View File
@@ -28,6 +28,8 @@ export default defineConfig({
'/api': 'http://localhost:8080',
'/health': 'http://localhost:8080',
'/ready': 'http://localhost:8080',
'/.well-known': 'http://localhost:8080',
'/oauth': 'http://localhost:8080',
},
},
})