diff --git a/apps/web/src/components/portal/format.ts b/apps/web/src/components/portal/format.ts new file mode 100644 index 0000000..d825508 --- /dev/null +++ b/apps/web/src/components/portal/format.ts @@ -0,0 +1,45 @@ +const DATE = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' }) +const SHORT = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit' }) +const TIME = new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }) + +function parse(iso: string | null | undefined): Date | null { + if (!iso) return null + const d = new Date(iso) + return Number.isNaN(d.getTime()) ? null : d +} + +/** 12.09.2026 */ +export function fmtDate(iso: string | null | undefined, empty = '—') { + const d = parse(iso) + return d ? DATE.format(d) : empty +} + +/** «сегодня 09:41», «вчера 22:15», иначе «12.09 09:41». */ +export function fmtWhen(iso: string | null | undefined, empty = '—', now = Date.now()) { + const d = parse(iso) + if (!d) return empty + const dayMs = 24 * 60 * 60 * 1000 + const startOfToday = new Date(now) + startOfToday.setHours(0, 0, 0, 0) + const diff = startOfToday.getTime() - new Date(d).setHours(0, 0, 0, 0) + if (diff === 0) return `сегодня ${TIME.format(d)}` + if (diff === dayMs) return `вчера ${TIME.format(d)}` + return `${SHORT.format(d)} ${TIME.format(d)}` +} + +/** Время дня 14:02. */ +export function fmtTime(iso: string | null | undefined, empty = '—') { + const d = parse(iso) + return d ? TIME.format(d) : empty +} + +/** «5 дн назад», «3 ч назад», «только что». */ +export function fmtAgo(iso: string | null | undefined, empty = '—', now = Date.now()) { + const d = parse(iso) + if (!d) return empty + const sec = Math.max(0, Math.round((now - d.getTime()) / 1000)) + if (sec < 60) return 'только что' + if (sec < 3600) return `${Math.floor(sec / 60)} мин назад` + if (sec < 86400) return `${Math.floor(sec / 3600)} ч назад` + return `${Math.floor(sec / 86400)} дн назад` +} diff --git a/apps/web/src/components/reui-kit/index.ts b/apps/web/src/components/reui-kit/index.ts index f0b3e4b..a4d0cf6 100644 --- a/apps/web/src/components/reui-kit/index.ts +++ b/apps/web/src/components/reui-kit/index.ts @@ -16,4 +16,3 @@ export { UserAuditSheet } from './user-audit-sheet' export { CreateUserSheet } from './create-user-sheet' export { CreateOidcClientSheet } from './create-oidc-client-sheet' export { AdminUsersGrid, type AdminUsersGridProps } from './admin-users-grid' -export { PasskeySettingsPanel } from './passkey-settings-panel' diff --git a/apps/web/src/components/reui-kit/passkey-settings-panel.tsx b/apps/web/src/components/reui-kit/passkey-settings-panel.tsx deleted file mode 100644 index f09ec3e..0000000 --- a/apps/web/src/components/reui-kit/passkey-settings-panel.tsx +++ /dev/null @@ -1,213 +0,0 @@ -/** - * Passkey management — DNA settings-16 / settings-2 / settings-10. - * Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-2 · https://reui.io/preview/base/settings-10 - * Docs: https://reui.io/docs/components/base/frame - */ -import { useState } from 'react' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { FingerprintIcon, PlusIcon, Trash2Icon } from 'lucide-react' -import { - startRegistration, - browserSupportsWebAuthn, -} from '@simplewebauthn/browser' -import type { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/browser' -import type { PasskeyCredential } from '@authportal/shared' -import { - Frame, - FrameDescription, - FrameHeader, - FramePanel, - FrameTitle, -} from '@/components/reui/frame' -import { Badge } from '@/components/reui/badge' -import { Button } from '@authportal/ui/components/button' -import { - Item, - ItemActions, - ItemContent, - ItemDescription, - ItemMedia, - ItemTitle, -} from '@authportal/ui/components/item' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@authportal/ui/components/alert-dialog' -import { Skeleton } from '@authportal/ui/components/skeleton' -import { toast } from 'sonner' -import { ApiError } from '@/lib/api-client' -import { - deletePasskey, - passkeysQueryKey, - passkeysQueryOptions, - webauthnRegister, - webauthnRegisterOptions, -} from '@/queries/webauthn' - -function formatWhen(iso: string | null) { - if (!iso) return 'ещё не использовался' - const date = new Date(iso) - if (Number.isNaN(date.getTime())) return '—' - return new Intl.DateTimeFormat('ru-RU', { - day: 'numeric', - month: 'short', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - }).format(date) -} - -export function PasskeySettingsPanel() { - const queryClient = useQueryClient() - const { data: passkeys = [], isLoading } = useQuery(passkeysQueryOptions) - const [pendingDelete, setPendingDelete] = useState( - null, - ) - const supported = browserSupportsWebAuthn() - - const registerMutation = useMutation({ - mutationFn: async () => { - const { challenge_id, options } = await webauthnRegisterOptions() - const attResp = await startRegistration({ - optionsJSON: - options as unknown as PublicKeyCredentialCreationOptionsJSON, - }) - return webauthnRegister(challenge_id, attResp) - }, - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: passkeysQueryKey }) - toast.success('Passkey добавлен') - }, - onError: (err) => { - toast.error( - err instanceof ApiError ? err.message : 'Не удалось добавить passkey', - ) - }, - }) - - const deleteMutation = useMutation({ - mutationFn: (id: string) => deletePasskey(id), - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: passkeysQueryKey }) - toast.success('Passkey удалён') - setPendingDelete(null) - }, - onError: (err) => { - toast.error( - err instanceof ApiError ? err.message : 'Не удалось удалить passkey', - ) - }, - }) - - return ( - <> - - -
- Passkeys - - Вход без пароля: Windows Hello, Face ID, ключ безопасности - -
- -
- - {!supported ? ( -

- Этот браузер не поддерживает WebAuthn. -

- ) : null} - {isLoading ? ( -
- - -
- ) : passkeys.length === 0 ? ( -

- Ключи не зарегистрированы. Добавьте passkey, чтобы входить без - пароля. -

- ) : ( - passkeys.map((item) => ( - - - - - - {item.name} - {item.device_type === 'multiDevice' ? ( - - Синхронизируется - - ) : null} - - - Добавлен {formatWhen(item.created_at)} · вход{' '} - {formatWhen(item.last_used_at)} - - - - - - - )) - )} -
- - - { - if (!open) setPendingDelete(null) - }} - > - - - Удалить passkey? - - {pendingDelete - ? `«${pendingDelete.name}» больше нельзя будет использовать для входа.` - : null} - - - - Отмена - { - if (pendingDelete) deleteMutation.mutate(pendingDelete.id) - }} - > - Удалить - - - - - - ) -} diff --git a/apps/web/src/routes/_auth.account.tsx b/apps/web/src/routes/_auth.account.tsx index f5c4bcf..05027a2 100644 --- a/apps/web/src/routes/_auth.account.tsx +++ b/apps/web/src/routes/_auth.account.tsx @@ -1,25 +1,280 @@ -/** - * Account security — passkeys. - * Preview: https://reui.io/preview/base/settings-10 · https://reui.io/preview/base/settings-16 - */ +import { useMemo, useState } from 'react' import { createFileRoute } from '@tanstack/react-router' -import { PageShell } from '@/components/page-shell' -import { PasskeySettingsPanel } from '@/components/reui-kit/passkey-settings-panel' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { ClockIcon, FingerprintIcon, LayoutGridIcon, ShieldIcon, TrashIcon } from 'lucide-react' +import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser' +import type { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/browser' +import { toast } from 'sonner' +import type { PasskeyCredential } from '@authportal/shared' +import { ExplainBanner } from '@/components/portal/explain-banner' +import { KvFrame, KvList, PageHead, PageTab, PageTabsList, PortalSheet, SignalAlert, StatusBadge, TypedConfirm } from '@/components/portal/bits' +import { fmtDate, fmtWhen } from '@/components/portal/format' +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 { Field, FieldDescription, FieldLabel } from '@authportal/ui/components/field' +import { Input } from '@authportal/ui/components/input' +import { Skeleton } from '@authportal/ui/components/skeleton' +import { Tabs, TabsContent } from '@authportal/ui/components/tabs' +import { ApiError } from '@/lib/api-client' +import { meQueryOptions } from '@/queries/auth' +import { + deletePasskey, + passkeysQueryKey, + passkeysQueryOptions, + renamePasskey, + webauthnRegister, + webauthnRegisterOptions, +} from '@/queries/webauthn' export const Route = createFileRoute('/_auth/account')({ component: AccountPage, }) +const STALE_DAYS = 60 + +type Row = PasskeyCredential & { tone: 'ok' | 'wn'; status: string } + +function toRow(k: PasskeyCredential, now: number): Row { + const last = new Date(k.last_used_at ?? k.created_at).getTime() + const stale = now - last > STALE_DAYS * 24 * 60 * 60 * 1000 + return { ...k, tone: stale ? 'wn' : 'ok', status: stale ? 'Давно не использовался' : 'Активен' } +} + +function kindOf(k: PasskeyCredential) { + return k.device_type === 'multiDevice' ? 'Синхронизируемый' : k.device_type === 'singleDevice' ? 'Ключ устройства' : 'Passkey' +} + function AccountPage() { + const queryClient = useQueryClient() + const { data: me } = useQuery(meQueryOptions) + const { data: passkeys = [], isLoading, isError, refetch, dataUpdatedAt } = useQuery(passkeysQueryOptions) + const supported = browserSupportsWebAuthn() + + const [addOpen, setAddOpen] = useState(false) + const [newName, setNewName] = useState('') + const [selected, setSelected] = useState(null) + const [renameValue, setRenameValue] = useState('') + const [deleting, setDeleting] = useState(null) + + const rows = useMemo(() => passkeys.map((k) => toRow(k, dataUpdatedAt || Date.parse(k.created_at))), [passkeys, dataUpdatedAt]) + const lastUsed = rows.map((r) => r.last_used_at).filter((x): x is string => Boolean(x)).sort().at(-1) ?? null + + const registerMutation = useMutation({ + mutationFn: async (name: string) => { + const { challenge_id, options } = await webauthnRegisterOptions() + const att = await startRegistration({ optionsJSON: options as unknown as PublicKeyCredentialCreationOptionsJSON }) + return webauthnRegister(challenge_id, att, name.trim() || undefined) + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: passkeysQueryKey }) + setAddOpen(false) + setNewName('') + toast.success('Passkey добавлен') + }, + onError: (err) => toast.error(err instanceof ApiError ? err.message : 'Не удалось добавить passkey'), + }) + + const renameMutation = useMutation({ + mutationFn: ({ id, name }: { id: string; name: string }) => renamePasskey(id, name), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: passkeysQueryKey }) + setSelected(null) + toast.success('Название сохранено') + }, + onError: (err) => toast.error(err instanceof ApiError ? err.message : 'Не удалось переименовать passkey'), + }) + + const deleteMutation = useMutation({ + mutationFn: (id: string) => deletePasskey(id), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: passkeysQueryKey }) + setDeleting(null) + toast.success('Passkey удалён') + }, + onError: (err) => toast.error(err instanceof ApiError ? err.message : 'Не удалось удалить passkey'), + }) + + const columns = useMemo[]>( + () => [ + { id: 'name', title: 'Passkey', size: 280, sortValue: (r) => r.name, cell: (r) => }, + { id: 'status', title: 'Статус', size: 210, cell: (r) => {r.status} }, + { id: 'added', title: 'Добавлен', size: 120, sortValue: (r) => r.created_at, cell: (r) => {fmtDate(r.created_at)} }, + { id: 'last', title: 'Последний вход', size: 180, sortValue: (r) => r.last_used_at ?? '', cell: (r) => {r.last_used_at ? fmtWhen(r.last_used_at) : 'ещё не использовался'} }, + ], + [], + ) + + const open = (r: Row) => { + setSelected(r) + setRenameValue(r.name) + } + return ( - -
-

Безопасность

-

- Passkey как альтернатива паролю. Пароль остаётся запасным входом. -

-
- -
+ <> + setAddOpen(true)}> + Добавить passkey + + } + /> + {!supported ? ( + + Откройте портал по HTTPS в современном браузере. Вход по паролю остаётся доступным. + + ) : null} + {supported && !isLoading && rows.length <= 1 ? ( + + Совет: держите два ключа на разных устройствах, например платформенный и ключ безопасности. + + ) : null} + + + + + + + + {isLoading ? ( + + ) : isError ? ( +
+ Не удалось загрузить passkeys. Повторите запрос. + +
+ ) : ( + r.id} + searchText={(r) => `${r.name} ${kindOf(r)}`} + selectable={false} + views={[ + { label: 'Все', match: () => true }, + { label: 'Давно не используются', match: (r) => r.tone === 'wn' }, + ]} + rowMenu={(r) => ({ onOpen: () => open(r), onDelete: () => setDeleting(r) })} + onRowClick={open} + emptyMessage="Ключи не зарегистрированы. Добавьте passkey, чтобы входить без пароля." + /> + )} +
+ + + +
+ + + + + + } + > + + Название ключа + setNewName(e.target.value)} /> + Чтобы отличать устройства в списке + + + Откроется системный диалог: подтвердите отпечатком, Face ID, PIN или ключом безопасности. Закрытый ключ не покидает устройство. + + + + !o && setSelected(null)} + title="Passkey" + description="Название и сведения о ключе" + footer={ + <> + + + + + } + > + + Название + setRenameValue(e.target.value)} /> + + + + + !o && setDeleting(null)} + title="Удалить passkey?" + description={`Ключ «${deleting?.name ?? ''}» будет удалён без возможности восстановления. Войти по нему больше не получится.`} + expected="удалить" + confirmLabel="Удалить ключ" + onConfirm={() => deleting && deleteMutation.mutate(deleting.id)} + /> + ) } diff --git a/apps/web/src/routes/_auth.admin.audit.tsx b/apps/web/src/routes/_auth.admin.audit.tsx index 59ddb45..696ee77 100644 --- a/apps/web/src/routes/_auth.admin.audit.tsx +++ b/apps/web/src/routes/_auth.admin.audit.tsx @@ -1,105 +1,274 @@ +import { useMemo, useState } from 'react' import { createFileRoute } from '@tanstack/react-router' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { HistoryIcon, NetworkIcon, OctagonAlertIcon, UserCogIcon } from 'lucide-react' import { toast } from 'sonner' -import type { - AuditLogEntry, - AuditPurgeResponse, - AuditSettings, -} from '@authportal/shared' -import { PageShell } from '@/components/page-shell' -import { AuditLogTimeline } from '@/components/reui-kit/audit-log-timeline' -import { AuditRetentionPanel } from '@/components/reui-kit/audit-retention-panel' -import { api, ApiError } from '@/lib/api-client' -import { - auditQueryKey, - auditQueryOptions, - auditSettingsQueryKey, - auditSettingsQueryOptions, -} from '@/queries/audit' -import { Skeleton } from '@authportal/ui/components/skeleton' +import type { AuditLogEntry, AuditPurgeResponse, AuditSettings, AuditSeverity } from '@authportal/shared' +import { actionMeta, exportAuditCsv, severityLabel, sourceAppLabel } from '@/components/reui-kit/audit-log-helpers' +import { CodeBlock, FormFrame, KvList, PageHead, PageTab, PageTabsList, PortalSheet, SettingRow, StatusBadge, TypedConfirm } from '@/components/portal/bits' +import { ActivityFeed } from '@/components/portal/canvas-blocks' +import { fmtTime, fmtWhen } from '@/components/portal/format' +import { kpi } from '@/components/portal/kpi' +import { KpiStrip } from '@/components/portal/kpi-strip' +import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid' +import type { Tone } from '@/components/portal/tone' import { Button } from '@authportal/ui/components/button' +import { Input } from '@authportal/ui/components/input' +import { Skeleton } from '@authportal/ui/components/skeleton' +import { Tabs, TabsContent } from '@authportal/ui/components/tabs' +import { api, ApiError } from '@/lib/api-client' +import { auditQueryKey, auditQueryOptions, auditSettingsQueryKey, auditSettingsQueryOptions } from '@/queries/audit' export const Route = createFileRoute('/_auth/admin/audit')({ component: AdminAuditPage, }) +const SEVERITY_TONE: Record = { info: 'ok', warning: 'wn', critical: 'er' } + +type Row = { + id: string + action: string + summary: string + tone: Tone + label: string + source: string + who: string + ip: string + when: string + entry: AuditLogEntry +} + function AdminAuditPage() { const queryClient = useQueryClient() - const { - data: entries = [], - isLoading, - error, - refetch, - } = useQuery(auditQueryOptions({ kind: 'changes', limit: 500 })) + const [tab, setTab] = useState('feed') + const [entry, setEntry] = useState(null) + const [purgeOpen, setPurgeOpen] = useState(false) + const [daysDraft, setDaysDraft] = useState(null) + + const entriesQ = useQuery(auditQueryOptions({ kind: 'changes', limit: 500 })) const { data: settings } = useQuery(auditSettingsQueryOptions) + const retention = settings?.retention_days ?? 90 + const entries = useMemo(() => entriesQ.data ?? [], [entriesQ.data]) const saveMutation = useMutation({ - mutationFn: (retention_days: number) => - api.put('/api/v1/admin/audit/settings', { - retention_days, - }), + mutationFn: (retention_days: number) => api.put('/api/v1/admin/audit/settings', { retention_days }), onSuccess: async (data) => { await queryClient.invalidateQueries({ queryKey: auditSettingsQueryKey }) await queryClient.invalidateQueries({ queryKey: auditQueryKey }) + setDaysDraft(null) toast.success(`Срок хранения: ${data.retention_days} дн.`) }, - onError: (err) => { - toast.error( - err instanceof ApiError ? err.message : 'Не удалось сохранить', - ) - }, + onError: (err) => toast.error(err instanceof ApiError ? err.message : 'Не удалось сохранить'), }) const purgeMutation = useMutation({ - mutationFn: () => - api.post('/api/v1/admin/audit/purge'), + mutationFn: () => api.post('/api/v1/admin/audit/purge'), onSuccess: async (data) => { await queryClient.invalidateQueries({ queryKey: auditQueryKey }) + setPurgeOpen(false) toast.success(`Удалено записей: ${data.deleted}`) }, - onError: (err) => { - toast.error(err instanceof ApiError ? err.message : 'Ошибка очистки') - }, + onError: (err) => toast.error(err instanceof ApiError ? err.message : 'Ошибка очистки'), }) - return ( - -
- saveMutation.mutate(days)} - onPurge={() => purgeMutation.mutate()} - /> + const rows = useMemo( + () => + entries.map((e) => ({ + id: e.id, + action: e.action, + summary: e.summary, + tone: SEVERITY_TONE[e.severity], + label: severityLabel[e.severity], + source: sourceAppLabel(e.source_app), + who: e.actor_email?.split('@')[0] ?? 'system', + ip: e.ip ?? '—', + when: fmtWhen(e.created_at), + entry: e, + })), + [entries], + ) - {error ? ( -
-

- {error instanceof ApiError ? error.message : 'Ошибка загрузки'} -

- -
- ) : isLoading ? ( -
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
- ) : ( - - )} -
-
+ + + } + /> + {entriesQ.isError ? ( +
+ {entriesQ.error instanceof ApiError ? entriesQ.error.message : 'Не удалось загрузить журнал. Повторите запрос.'} + +
+ ) : null} + + + + + + + + + {entriesQ.isLoading ? ( + + ) : ( + setTab('table')}> + Весь журнал + + } + /> + )} + + + {entriesQ.isLoading ? ( + + ) : ( + r.id} + searchText={(r) => `${r.action} ${r.summary} ${r.who} ${r.ip}`} + selectable={false} + views={[ + { label: 'Все', match: () => true }, + { label: 'Warning и выше', match: (r) => r.tone === 'wn' || r.tone === 'er' }, + { label: 'Critical', match: (r) => r.tone === 'er' }, + ]} + onRowClick={setEntry} + emptyMessage="В журнале нет записей." + /> + )} + + + + +
+ setDaysDraft(e.target.value)} + aria-label="Срок хранения, дней" + className="w-28 tabular-nums" + /> + +
+
+ + {String(entries.length)} + +
+
+
+ + !o && setEntry(null)} + title="Событие журнала" + description="Детали записи" + footer={ + + } + > + {entry ? ( + <> + +
+ {entry.label} +
+ + + {JSON.stringify( + { target_type: entry.entry.target_type, target_id: entry.entry.target_id, details: entry.entry.details }, + null, + 2, + )} + + + ) : null} +
+ + purgeMutation.mutate()} + /> + ) } diff --git a/apps/web/src/routes/_auth.admin.logins.tsx b/apps/web/src/routes/_auth.admin.logins.tsx index 747c96b..b6b8cc1 100644 --- a/apps/web/src/routes/_auth.admin.logins.tsx +++ b/apps/web/src/routes/_auth.admin.logins.tsx @@ -1,542 +1,315 @@ -/** - * Admin logins journal — history DataGrid + active sessions. - * Preview: https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/preview/base/stats-12 - * Sessions DNA: https://reui.io/preview/base/solution-users-2 - */ import { useMemo, useState } from 'react' -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute, useNavigate } from '@tanstack/react-router' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { - type ColumnDef, - type PaginationState, - type SortingState, - useTable, -} from '@tanstack/react-table' +import { CircleCheckIcon, GlobeIcon, MonitorSmartphoneIcon, ShieldAlertIcon } from 'lucide-react' import { toast } from 'sonner' import type { AdminSession, AuditLogEntry } from '@authportal/shared' -import { - KeyRoundIcon, - LogInIcon, - ShieldAlertIcon, - Trash2Icon, -} from 'lucide-react' -import { PageShell } from '@/components/page-shell' -import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' -import { - actionMeta, - detailString, - formatEventTime, - matchesRange, - sourceAppLabel, - type AuditRange, -} from '@/components/reui-kit/audit-log-helpers' -import { Badge } from '@/components/reui/badge' -import { DATA_GRID_RU } from '@/lib/reui-ru' -import { DataGrid, dataGridFeatures, type DataGridFeatures } from '@/components/reui/data-grid/data-grid' -import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' -import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination' -import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area' -import { DataGridTable } from '@/components/reui/data-grid/data-grid-table' -import { - Frame, - FrameFooter, - FrameHeader, - FramePanel, - FrameTitle, -} from '@/components/reui/frame' -import { auditQueryOptions } from '@/queries/audit' -import { - sessionsListQueryKey, - sessionsQueryOptions, -} from '@/queries/sessions' -import { api, ApiError } from '@/lib/api-client' +import { actionMeta, detailString, exportAuditCsv, matchesRange, sourceAppLabel, type AuditRange } from '@/components/reui-kit/audit-log-helpers' +import { ExplainBanner } from '@/components/portal/explain-banner' +import { CodeBlock, KvList, PageHead, PageTab, PageTabsList, PortalSheet, Segmented, StatusBadge } from '@/components/portal/bits' +import { fmtWhen } from '@/components/portal/format' +import { kpi } from '@/components/portal/kpi' +import { KpiStrip } from '@/components/portal/kpi-strip' +import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid' +import type { Tone } from '@/components/portal/tone' import { Button } from '@authportal/ui/components/button' import { Skeleton } from '@authportal/ui/components/skeleton' -import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, -} from '@authportal/ui/components/select' -import { RANGE_OPTIONS } from '@/components/reui-kit/audit-log-helpers' +import { Tabs, TabsContent } from '@authportal/ui/components/tabs' +import { api, ApiError } from '@/lib/api-client' +import { auditQueryOptions } from '@/queries/audit' +import { sessionsListQueryKey, sessionsQueryOptions } from '@/queries/sessions' export const Route = createFileRoute('/_auth/admin/logins')({ component: AdminLoginsPage, }) -type ResultFilter = 'all' | 'success' | 'failed' +const PERIODS: Array<{ label: string; value: AuditRange }> = [ + { label: '24 ч', value: '24h' }, + { label: '7 д', value: '7d' }, + { label: '30 д', value: '30d' }, + { label: '90 д', value: '90d' }, +] -function resultVariant(action: string) { - if (action === 'auth.login_failed') return 'warning-outline' as const - if (action === 'auth.sso_handoff') return 'info-outline' as const - return 'success-outline' as const +type LoginRow = { + id: string + login: string + client: string + tone: Tone + status: string + time: string + ip: string + app: string + reason: string + entry: AuditLogEntry +} + +type SessionRow = { + id: string + user: string + note: string + ipClient: string + created: string + expires: string +} + +const FAILED = 'auth.login_failed' +const isOk = (a: string) => a === 'auth.login' || a === 'auth.sso_handoff' + +function toneOf(action: string): Tone { + if (action === FAILED) return 'er' + if (action === 'auth.sso_handoff') return 'in' + if (action === 'auth.logout') return 'wn' + return 'ok' +} + +function shortUa(ua: string | null | undefined) { + if (!ua) return '—' + const m = /(Edg|Chrome|Firefox|Safari)\/(\d+)/.exec(ua) + const os = /Windows|Mac OS X|Linux|iPhone|Android/.exec(ua)?.[0]?.replace('Mac OS X', 'macOS') + return [m ? `${m[1] === 'Edg' ? 'Edge' : m[1]} ${m[2]}` : ua.slice(0, 24), os].filter(Boolean).join(' · ') } function AdminLoginsPage() { const queryClient = useQueryClient() - const [tab, setTab] = useState<'history' | 'sessions'>('history') - const [resultFilter, setResultFilter] = useState('all') - const [range, setRange] = useState('7d') - const [pagination, setPagination] = useState({ - pageIndex: 0, - pageSize: 20, - }) - const [sorting, setSorting] = useState([ - { id: 'created_at', desc: true }, - ]) + const navigate = useNavigate() + const [range, setRange] = useState(0) + const [event, setEvent] = useState(null) - const { - data: entries = [], - isLoading, - error, - refetch, - } = useQuery(auditQueryOptions({ kind: 'logins', limit: 500 })) + const entriesQ = useQuery(auditQueryOptions({ kind: 'logins', limit: 500 })) + const sessionsQ = useQuery(sessionsQueryOptions()) + const entries = useMemo(() => entriesQ.data ?? [], [entriesQ.data]) + const sessions = useMemo(() => sessionsQ.data ?? [], [sessionsQ.data]) + const period = PERIODS[range]!.value - const { - data: sessions = [], - isLoading: sessionsLoading, - error: sessionsError, - refetch: refetchSessions, - } = useQuery(sessionsQueryOptions()) - - const revokeMutation = useMutation({ + const revoke = useMutation({ mutationFn: (id: string) => api.delete(`/api/v1/admin/sessions/${id}`), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: sessionsListQueryKey() }) - toast.success('Сессия отозвана') - }, - onError: (err) => { - toast.error( - err instanceof ApiError ? err.message : 'Не удалось отозвать сессию', - ) + toast.success('Сессия завершена') }, + onError: (err) => toast.error(err instanceof ApiError ? err.message : 'Не удалось завершить сессию'), }) - const filtered = useMemo(() => { - return entries.filter((e) => { - if (!matchesRange(e, range)) return false - if (resultFilter === 'success') { - return e.action === 'auth.login' || e.action === 'auth.sso_handoff' - } - if (resultFilter === 'failed') return e.action === 'auth.login_failed' - return true - }) - }, [entries, range, resultFilter]) + const rows = useMemo( + () => + entries + .filter((e) => matchesRange(e, period)) + .map((e) => ({ + id: e.id, + login: e.actor_email ?? '—', + client: shortUa(detailString(e.details, 'user_agent')), + tone: toneOf(e.action), + status: actionMeta(e.action).label, + time: fmtWhen(e.created_at), + ip: e.ip ?? '—', + app: sourceAppLabel(detailString(e.details, 'target_app') ?? e.source_app), + reason: detailString(e.details, 'reason') ?? '—', + entry: e, + })), + [entries, period], + ) - const kpi = useMemo(() => { + const sessionRows = useMemo( + () => + sessions.map((s: AdminSession) => ({ + id: s.id, + user: s.email, + note: s.name, + ipClient: `${s.ip ?? '—'} · ${shortUa(s.user_agent)}`, + created: fmtWhen(s.created_at), + expires: fmtWhen(s.expires_at), + })), + [sessions], + ) + + const stats = useMemo(() => { const day = entries.filter((e) => matchesRange(e, '24h')) - const ok = day.filter( - (e) => e.action === 'auth.login' || e.action === 'auth.sso_handoff', - ).length - const fail = day.filter((e) => e.action === 'auth.login_failed').length - const ips = new Set( - day.map((e) => e.ip).filter((ip): ip is string => Boolean(ip)), - ) - return { ok, fail, ips: ips.size, sessions: sessions.length } - }, [entries, sessions.length]) + return { + ok: day.filter((e) => isOk(e.action)).length, + fail: day.filter((e) => e.action === FAILED).length, + ips: new Set(day.map((e) => e.ip).filter(Boolean)).size, + users: new Set(sessions.map((s) => s.user_id)).size, + } + }, [entries, sessions]) - const historyColumns = useMemo[]>( + // Серия неудач: 5 и больше с одного IP за последние 10 минут (по времени самой свежей записи журнала). + const burst = useMemo(() => { + const latest = entries.reduce((m, e) => Math.max(m, new Date(e.created_at).getTime()), 0) + const byIp = new Map() + for (const e of entries) { + if (e.action !== FAILED || !e.ip) continue + if (latest - new Date(e.created_at).getTime() > 10 * 60 * 1000) continue + byIp.set(e.ip, [...(byIp.get(e.ip) ?? []), e]) + } + const top = [...byIp.entries()].sort((a, b) => b[1].length - a[1].length)[0] + return top && top[1].length >= 5 ? { ip: top[0], n: top[1].length, login: top[1][0]!.actor_email ?? '—' } : null + }, [entries]) + + const loginCols = useMemo[]>( () => [ - { - accessorKey: 'created_at', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - - {formatEventTime(row.original.created_at)} - - ), - size: 140, - }, - { - id: 'result', - accessorFn: (r) => r.action, - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - - {actionMeta(row.original.action).label} - - ), - size: 140, - }, - { - accessorKey: 'actor_email', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - - {row.original.actor_email ?? '—'} - - ), - size: 200, - }, - { - accessorKey: 'ip', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - - {row.original.ip ?? '—'} - - ), - size: 130, - }, - { - id: 'ua', - accessorFn: (r) => detailString(r.details, 'user_agent') ?? '', - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const ua = detailString(row.original.details, 'user_agent') - return ( - - {ua ?? '—'} - - ) - }, - size: 220, - }, - { - id: 'app', - accessorFn: (r) => - detailString(r.details, 'target_app') ?? r.source_app, - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const app = - detailString(row.original.details, 'target_app') ?? - row.original.source_app - return ( - - {sourceAppLabel(app)} - - ) - }, - size: 120, - }, - { - id: 'reason', - accessorFn: (r) => detailString(r.details, 'reason') ?? '', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - - {detailString(row.original.details, 'reason') ?? '—'} - - ), - size: 120, - }, + { id: 'login', title: 'Логин', size: 260, sortValue: (r) => r.login, cell: (r) => }, + { id: 'status', title: 'Статус', size: 130, cell: (r) => {r.status} }, + { id: 'time', title: 'Время', size: 150, sortValue: (r) => r.entry.created_at, cell: (r) => {r.time} }, + { id: 'ip', title: 'IP', size: 150, sortValue: (r) => r.ip, cell: (r) => {r.ip} }, + { id: 'app', title: 'Приложение', size: 160, cell: (r) => {r.app} }, + { id: 'reason', title: 'Причина', size: 200, cell: (r) => {r.reason} }, ], [], ) - const historyTable = useTable({ - features: dataGridFeatures, - data: filtered, - columns: historyColumns, - state: { pagination, sorting }, - onPaginationChange: setPagination, - onSortingChange: setSorting, - }) - - const sessionColumns = useMemo[]>( + const sessionCols = useMemo[]>( () => [ - { - accessorKey: 'email', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( -
- - {row.original.name} - - - {row.original.email} - -
- ), - size: 200, - }, - { - accessorKey: 'ip', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - - {row.original.ip ?? '—'} - - ), - size: 130, - }, - { - accessorKey: 'user_agent', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - - {row.original.user_agent ?? '—'} - - ), - size: 240, - }, - { - accessorKey: 'created_at', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - - {formatEventTime(row.original.created_at)} - - ), - size: 140, - }, - { - accessorKey: 'expires_at', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - - {formatEventTime(row.original.expires_at)} - - ), - size: 140, - }, - { - id: 'actions', - header: '', - cell: ({ row }) => ( - - ), - size: 120, - }, + { id: 'user', title: 'Пользователь', size: 260, sortValue: (r) => r.user, cell: (r) => }, + { id: 'status', title: 'Статус', size: 110, cell: () => Активна }, + { id: 'ip', title: 'IP · клиент', size: 280, cell: (r) => {r.ipClient} }, + { id: 'created', title: 'Создана', size: 150, cell: (r) => {r.created} }, + { id: 'exp', title: 'Истекает', size: 150, cell: (r) => {r.expires} }, ], - [revokeMutation], + [], ) - const [sessionPagination, setSessionPagination] = useState({ - pageIndex: 0, - pageSize: 20, - }) - const sessionsTable = useTable({ - features: dataGridFeatures, - data: sessions, - columns: sessionColumns, - state: { pagination: sessionPagination }, - onPaginationChange: setSessionPagination, - }) + const loading = entriesQ.isLoading || sessionsQ.isLoading + const failedQuery = entriesQ.isError || sessionsQ.isError return ( - -
- , - iconClassName: 'text-success', - hint: 'Вход и SSO', - }, - { - id: 'fail', - label: 'Неудачи (24ч)', - value: kpi.fail, - icon: , - iconClassName: 'text-warning', - variant: kpi.fail > 0 ? 'warning' : 'default', - }, - { - id: 'ips', - label: 'Уникальные IP (24ч)', - value: kpi.ips, - icon: , - }, - { - id: 'sessions', - label: 'Активные сессии', - value: kpi.sessions, - icon: , - onSelect: () => setTab('sessions'), - }, - ]} - /> - - { - if (v === 'history' || v === 'sessions') setTab(v) - }} - > - + { + exportAuditCsv(rows.map((r) => r.entry)) + toast.success(`CSV сформирован: ${rows.length} записей`) + }} > - - История - - - Активные сессии - - {sessions.length} - - - - + Экспорт CSV + + } + /> + {burst ? ( + + {burst.n} неудачных входов с {burst.ip} за 10 минут. Проверьте журнал и при необходимости отключите учётную запись или заблокируйте адрес. + + ) : null} + {failedQuery ? ( +
+ Не удалось загрузить журнал. Повторите запрос. + +
+ ) : null} + p.label)} value={range} onChange={setRange} /> + + + + + + + + {loading ? ( + + ) : ( + r.id} + searchText={(r) => `${r.login} ${r.ip} ${r.app}`} + searchPlaceholder="Логин или IP" + selectable={false} + views={[ + { label: 'Все', match: () => true }, + { label: 'Успешные', match: (r) => r.tone === 'ok' || r.tone === 'in' }, + { label: 'Неудачные', match: (r) => r.tone === 'er' || r.tone === 'wn' }, + ]} + onRowClick={setEvent} + emptyMessage="За выбранный период входов не было." + /> + )} + + + {loading ? ( + + ) : ( + r.id} + selectable={false} + emptyMessage="Нет активных сессий." + rowMenu={(r) => ({ toggleLabel: 'Завершить сессию', onToggle: () => revoke.mutate(r.id) })} + /> + )} + + - {tab === 'history' ? ( -
-
- { - if (v === 'all' || v === 'success' || v === 'failed') { - setResultFilter(v) - } - }} - > - - - Все - - - Успешные - - - Неудачи - - - - -
- - {error ? ( -
-

- {error instanceof ApiError ? error.message : 'Ошибка загрузки'} -

- -
- ) : isLoading ? ( - - ) : ( - - - - Журнал входов - - - - - - - - - - - - )} -
- ) : sessionsError ? ( -
-

- {sessionsError instanceof ApiError - ? sessionsError.message - : 'Ошибка загрузки'} -

+ !o && setEvent(null)} + title="Событие входа" + description="Детали записи журнала" + footer={ + <> + -
- ) : sessionsLoading ? ( - - ) : ( - - - - Активные сессии - - - - - - - - - - - - )} -
-
+ + } + > + {event ? ( + <> +
+ {event.status} +
+ + {`action: ${event.entry.action}\nsource_app: ${event.entry.source_app}\nseverity: ${event.entry.severity}\nip: ${event.ip}\nat: ${event.entry.created_at}`} + + ) : null} + + ) } diff --git a/apps/web/src/routes/_auth.apps.tsx b/apps/web/src/routes/_auth.apps.tsx index 6bec808..62d394e 100644 --- a/apps/web/src/routes/_auth.apps.tsx +++ b/apps/web/src/routes/_auth.apps.tsx @@ -1,23 +1,24 @@ 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 { PageShell } from '@/components/page-shell' -import { IconTile } from '@/components/reui/icon-tile' -import { - Frame, - FramePanel, -} from '@/components/reui/frame' -import { QuickActionGrid } from '@/components/reui-kit/quick-action-grid' +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' @@ -35,93 +36,106 @@ const APP_ICONS: Record = { mm: LayoutDashboardIcon, } -async function openApp( - _appId: AppId, - baseUrl: string, - authMode: AppAuthMode = 'jwt', -) { +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 } = useQuery( - catalogQueryOptions, - ) - const isLoading = meLoading || catalogLoading - const allowed = new Set(me?.apps ?? []) - const apps = (catalog?.apps ?? []).filter((app) => allowed.has(app.id)) + const { data: catalog, isLoading: catalogLoading, isError, refetch } = useQuery(catalogQueryOptions) + + if (meLoading || catalogLoading) { + return ( + <> + + + + + ) + } + + 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: , + onSelect: () => + open + ? void openApp(app.url, modeOf(app)) + : toast.warning(`Нет доступа к ${app.title}`, { description: 'Запросите права у администратора.' }), + } + } return ( - -
-
-

Приложения

-

- Открыть приложение с текущей сессией (SSO handoff). -

-
- {me?.is_admin ? ( - + ) : undefined + } + /> + {isError ? ( +
+ Не удалось загрузить каталог приложений. Повторите запрос. + - ) : null} -
- - {isLoading ? ( -
- {Array.from({ length: 3 }).map((_, i) => ( - - ))}
- ) : apps.length === 0 ? ( - - - -
-

Нет доступных приложений

-

- Обратитесь к администратору, чтобы получить доступ. -

+ ) : null} + 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' : 'доступ по правам' }), + ]} + /> + + + + + + + {allowed.length === 0 ? ( +
+ Нет доступных приложений + Обратитесь к администратору, чтобы получить доступ.
- - - ) : ( - { - const Icon = APP_ICONS[app.id] ?? LayoutGridIcon - return { - id: app.id, - title: app.title, - description: app.description, - badgeLabel: 'Открыть', - icon: , - onSelect: () => - void openApp( - app.id, - app.url, - app.authMode ?? (app.id === 'dns' ? 'oidc' : 'jwt'), - ), - } - })} - /> - )} - + ) : ( + toCard(a, true))} + /> + )} +
+ + toCard(a, false))} /> + +
+ ) }