feat(reui): update ReUI components and documentation
- 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:
@@ -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>
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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`,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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://<host>/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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user