142 lines
6.2 KiB
TypeScript
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>
|
|
</>
|
|
)
|
|
}
|