Files
auth-portal/apps/web/src/routes/_auth.apps.tsx
T

142 lines
6.2 KiB
TypeScript

import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import {
BadgeCheckIcon,
CloudIcon,
GlobeIcon,
KeyRoundIcon,
LayoutDashboardIcon,
LayoutGridIcon,
ServerIcon,
ShieldIcon,
} from 'lucide-react'
import { toast } from 'sonner'
import type { AppAuthMode, AppId } from '@authportal/shared'
import { PageHead, PageTab, PageTabsList } from '@/components/portal/bits'
import { AppCards, type AppCardItem } from '@/components/portal/canvas-blocks'
import { kpi } from '@/components/portal/kpi'
import { KpiStrip } from '@/components/portal/kpi-strip'
import { Button } from '@authportal/ui/components/button'
import { Skeleton } from '@authportal/ui/components/skeleton'
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { ssoOpenApp } from '@/lib/auth'
import { catalogQueryOptions, meQueryOptions } from '@/queries/auth'
export const Route = createFileRoute('/_auth/apps')({
component: AppsPage,
})
const APP_ICONS: Record<AppId, typeof CloudIcon> = {
cfdm: CloudIcon,
vps: ServerIcon,
bgp: GlobeIcon,
fw: ShieldIcon,
dns: GlobeIcon,
cdn: CloudIcon,
mm: LayoutDashboardIcon,
}
async function openApp(baseUrl: string, authMode: AppAuthMode = 'jwt') {
try {
await ssoOpenApp(baseUrl, authMode)
} catch {
toast.error('Не удалось выпустить токен', { description: 'Открываем приложение по базовому адресу — войдите в нём отдельно.' })
window.location.href = baseUrl.replace(/\/$/, '')
}
}
function AppsPage() {
const { data: me, isLoading: meLoading } = useQuery(meQueryOptions)
const { data: catalog, isLoading: catalogLoading, isError, refetch } = useQuery(catalogQueryOptions)
if (meLoading || catalogLoading) {
return (
<>
<PageHead title="Приложения" sub="Открыть приложение с текущей сессией · SSO handoff без повторного ввода пароля" />
<Skeleton className="h-24 w-full rounded-xl" />
<Skeleton className="h-64 w-full rounded-xl" />
</>
)
}
const all = catalog?.apps ?? []
const allowedIds = new Set(me?.apps ?? [])
const allowed = all.filter((a) => allowedIds.has(a.id))
const denied = all.filter((a) => !allowedIds.has(a.id))
const modeOf = (a: (typeof all)[number]): AppAuthMode => a.authMode ?? (a.id === 'dns' ? 'oidc' : 'jwt')
const toCard = (app: (typeof all)[number], open: boolean): AppCardItem => {
const Icon = APP_ICONS[app.id] ?? LayoutGridIcon
const meta = `${app.url.replace(/^https?:\/\//, '')} · ${modeOf(app) === 'oidc' ? 'OIDC' : 'JWT'}`
return {
id: app.id,
title: app.title,
desc: app.description,
badge: open ? 'Открыть' : 'Нет доступа',
badgeVariant: open ? 'info-light' : 'outline',
meta,
icon: <Icon />,
onSelect: () =>
open
? void openApp(app.url, modeOf(app))
: toast.warning(`Нет доступа к ${app.title}`, { description: 'Запросите права у администратора.' }),
}
}
return (
<>
<PageHead
title="Приложения"
sub="Открыть приложение с текущей сессией · SSO handoff без повторного ввода пароля"
actions={
me?.is_admin ? (
<Button variant="outline" render={<Link to="/admin/apps" />} nativeButton={false}>
Ссылки приложений
</Button>
) : undefined
}
/>
{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>Не удалось загрузить каталог приложений. Повторите запрос.</span>
<Button variant="outline" onClick={() => void refetch()}>
Повторить
</Button>
</div>
) : null}
<KpiStrip
aria-label="Показатели"
items={[
kpi('avail', 'Доступно приложений', String(allowed.length), { icon: LayoutGridIcon, tone: 'ok', badge: `из ${all.length}` }),
kpi('jwt', 'JWT SSO', String(allowed.filter((a) => modeOf(a) === 'jwt').length), { icon: KeyRoundIcon, badge: 'fragment-токен' }),
kpi('oidc', 'OIDC', String(allowed.filter((a) => modeOf(a) === 'oidc').length), { icon: BadgeCheckIcon, badge: 'внешние RP' }),
kpi('role', 'Роль', me?.is_admin ? 'Админ' : 'Пользователь', { icon: ShieldIcon, badge: me?.is_admin ? 'is_admin' : 'доступ по правам' }),
]}
/>
<Tabs defaultValue="all" className="min-w-0">
<PageTabsList>
<PageTab value="all" label="Доступные" count={allowed.length} />
<PageTab value="no" label="Без доступа" count={denied.length} />
</PageTabsList>
<TabsContent value="all" className="min-w-0">
{allowed.length === 0 ? (
<div className="text-muted-foreground flex flex-col gap-1 rounded-xl border border-dashed px-4 py-10 text-sm">
<b className="text-foreground font-medium">Нет доступных приложений</b>
Обратитесь к администратору, чтобы получить доступ.
</div>
) : (
<AppCards
title="Мои приложения"
description="Клик открывает приложение в новой вкладке · портал заново выпускает токен с актуальными правами"
items={allowed.map((a) => toCard(a, true))}
/>
)}
</TabsContent>
<TabsContent value="no" className="min-w-0">
<AppCards title="Недоступные приложения" description="Права не выданы · запросите у администратора" items={denied.map((a) => toCard(a, false))} />
</TabsContent>
</Tabs>
</>
)
}