fix(mcp): подготовить доступ и отзыв разрешений для приёмки

This commit is contained in:
Denozordec committed 2026-11-03 03:05:24 +07:00
commit ef7bb79d4e
311 files changed
+73119

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
import js from '@eslint/js'
import globals from 'globals'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist', 'data']),
{
files: ['**/*.ts'],
extends: [js.configs.recommended, tseslint.configs.recommended],
languageOptions: {
globals: globals.node,
},
rules: {
'@typescript-eslint/no-unused-vars': [
'error',
{ ignoreRestSiblings: true, argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
},
},
])
+44
View File
@@ -0,0 +1,44 @@
{
"name": "@authportal/api",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsup",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"start": "node dist/server.js",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@authportal/db": "workspace:*",
"@authportal/shared": "workspace:*",
"@fastify/compress": "^8.3.1",
"@fastify/cors": "^11.0.1",
"@fastify/formbody": "^9.0.0",
"@fastify/helmet": "^13.0.1",
"@fastify/jwt": "^9.1.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^8.2.0",
"@node-rs/argon2": "^2.0.2",
"@simplewebauthn/server": "^13.3.2",
"better-sqlite3": "^11.10.0",
"fastify": "^5.4.0",
"jose": "^6.2.8",
"zod": "catalog:"
},
"devDependencies": {
"@eslint/js": "catalog:",
"@types/node": "catalog:",
"@vitest/coverage-v8": "4.1.10",
"eslint": "catalog:",
"globals": "catalog:",
"tsup": "^8.5.0",
"tsx": "^4.20.3",
"typescript": "catalog:",
"typescript-eslint": "catalog:",
"vitest": "catalog:"
}
}
+293
View File
@@ -0,0 +1,293 @@
import type { FastifyError, FastifyInstance } from 'fastify'
import Fastify from 'fastify'
import compress from '@fastify/compress'
import cors from '@fastify/cors'
import helmet from '@fastify/helmet'
import rateLimit from '@fastify/rate-limit'
import formbody from '@fastify/formbody'
import fjwt from '@fastify/jwt'
import fastifyStatic from '@fastify/static'
import { existsSync, mkdirSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import {
APP_IDS,
allPermissionKeys,
} from '@authportal/shared'
import {
closeDb,
createDb,
getAppSwitcherConfig,
getUserApps,
hasAnyUser,
listAdmins,
healthCheck,
migrateSchema,
setUserAccess,
users,
type AppDb,
type Sqlite,
} from '@authportal/db'
import { hashPassword, setPasswordConcurrency } from './lib/password.js'
import { randomUUID } from 'node:crypto'
import {
assertBootstrapPassword,
parseTrustProxy,
type AppConfig,
} from './config.js'
import { authRoutes } from './routes/auth.js'
import { adminRoutes } from './routes/admin.js'
import { auditAdminRoutes } from './routes/audit.js'
import { auditIngestRoutes } from './routes/ingest-audit.js'
import { oidcRoutes } from './routes/oidc.js'
import { webauthnRoutes } from './routes/webauthn.js'
import { startAuditRetentionJob } from './services/audit-retention.js'
import { startHousekeepingJob } from './services/housekeeping.js'
import { configurePortalRevocationSender } from './services/portal-revocations.js'
import { startBackupJob } from './services/backup.js'
import {
ensureOidcSigningKey,
isOidcKeyReady,
resetOidcKeyCache,
} from './lib/oidc/keys.js'
import { createOriginChecker } from './lib/cors.js'
/** Пути сервера: для них SPA-fallback не применяется (404 JSON вместо index.html). */
const API_PREFIXES = ['/api', '/oauth', '/.well-known', '/assets/']
function boolFlag(v: string): boolean {
return v === '1' || v.toLowerCase() === 'true'
}
const HASHED_ASSET_RE = /[\\/]assets[\\/]/
const INDEX_HTML_RE = /[\\/]index\.html$/
declare module 'fastify' {
interface FastifyInstance {
config: AppConfig
db: AppDb
sqlite: Sqlite
}
}
export async function ensureBootstrapAdmin(app: FastifyInstance): Promise<void> {
if (!hasAnyUser(app.db)) {
assertBootstrapPassword(app.config)
const now = new Date().toISOString()
const passwordHash = await hashPassword(app.config.adminPassword)
const id = randomUUID()
app.db
.insert(users)
.values({
id,
email: app.config.adminEmail.toLowerCase(),
name: app.config.adminName,
passwordHash,
isAdmin: true,
disabled: false,
createdAt: now,
updatedAt: now,
})
.run()
setUserAccess(app.db, id, [...APP_IDS], allPermissionKeys())
app.log.info(`bootstrap admin created: ${app.config.adminEmail}`)
return
}
// Ensure existing admins have app tiles if access was never set
for (const user of listAdmins(app.db)) {
const apps = getUserApps(app.db, user.id)
if (apps.length === 0) {
setUserAccess(app.db, user.id, [...APP_IDS], allPermissionKeys())
app.log.info(`granted full catalog access to admin ${user.email}`)
}
}
}
export async function buildApp(opts: {
config: AppConfig
databaseUrl?: string
}): Promise<FastifyInstance> {
const config = opts.config
const dbUrl = opts.databaseUrl ?? config.databaseUrl
const path = dbUrl.replace(/^sqlite:/, '')
if (path !== ':memory:') {
mkdirSync(dirname(resolve(path)), { recursive: true })
}
setPasswordConcurrency(config.argon2Concurrency)
const { db, sqlite } = createDb(dbUrl)
migrateSchema(sqlite)
const app = Fastify({
logger: {
level: config.logLevel,
// Секреты не должны попадать в логи, даже если сериализатор начнёт отдавать заголовки.
redact: {
paths: [
'req.headers.authorization',
'req.headers.cookie',
'res.headers["set-cookie"]',
],
censor: '[redacted]',
},
},
trustProxy: parseTrustProxy(config.trustProxy),
// keepAlive выше idle-таймаута Traefik (60 с), иначе 502 на переиспользованном соединении
keepAliveTimeout: 65_000,
connectionTimeout: 0,
requestTimeout: 30_000,
bodyLimit: 1_048_576,
})
app.decorate('config', config)
app.decorate('db', db)
app.decorate('sqlite', sqlite)
// Configure before bootstrap/access writes; failed opt-in must close this owned handle.
try { configurePortalRevocationSender(app) } catch (error) { closeDb(sqlite); throw error }
app.addHook('onClose', async () => {
closeDb(sqlite)
})
app.setErrorHandler((err: FastifyError | Error, req, reply) => {
const status =
err.name === 'ZodError'
? 400
: (err as FastifyError).statusCode && (err as FastifyError).statusCode! >= 400
? (err as FastifyError).statusCode!
: 500
if (status >= 500) {
req.log.error(err)
return reply.status(500).send({
error: { code: 'INTERNAL', message: 'Internal server error' },
})
}
const code =
status === 429
? 'RATE_LIMITED'
: status === 400
? 'BAD_REQUEST'
: ((err as FastifyError).code ?? 'ERROR')
return reply.status(status).send({ error: { code, message: err.message } })
})
const originAllowed = createOriginChecker(config, () =>
getAppSwitcherConfig(db).apps.map((a) => a.url),
)
if (boolFlag(config.enableCompress)) {
// Предсжатая статика уже с Content-Encoding — плагин её не трогает.
await app.register(compress, { threshold: 1024, encodings: ['br', 'gzip'] })
}
await app.register(formbody)
await app.register(cors, {
origin: (origin, cb) => cb(null, !origin || originAllowed(origin)),
credentials: true,
})
await app.register(helmet, {
// SPA целиком на своём origin; inline-стили нужны React/Tailwind (style-атрибуты).
contentSecurityPolicy: {
useDefaults: false,
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'blob:'],
fontSrc: ["'self'", 'data:'],
connectSrc: ["'self'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"],
},
},
})
await app.register(rateLimit, {
max: config.rateLimitMax,
timeWindow: '1 minute',
// пробы Docker/Traefik не должны упираться в лимит
allowList: (req) => req.url === '/health' || req.url === '/ready',
})
await app.register(fjwt, { secret: config.jwtSecret })
// liveness: процесс жив и отвечает; БД не трогаем, чтобы пробы не рестартили
// контейнер из-за временной блокировки.
app.get('/health', async () => ({ ok: true }))
// readiness: БД отвечает и ключ подписи OIDC загружен.
app.get('/ready', async (_request, reply) => {
try {
healthCheck(sqlite)
} catch {
return reply.status(503).send({ ok: false, reason: 'db' })
}
if (!isOidcKeyReady()) {
return reply.status(503).send({ ok: false, reason: 'oidc_key' })
}
return { ok: true }
})
app.get('/api/v1/health', async () => ({ ok: true, service: 'auth-portal' }))
resetOidcKeyCache()
await ensureOidcSigningKey(app)
await ensureBootstrapAdmin(app)
await app.register(authRoutes)
await app.register(adminRoutes)
await app.register(auditAdminRoutes)
await app.register(auditIngestRoutes)
await app.register(oidcRoutes)
await app.register(webauthnRoutes)
if (process.env.NODE_ENV !== 'test') {
const stopRetention = startAuditRetentionJob(app)
const stopHousekeeping = startHousekeepingJob(app)
const stopBackup = startBackupJob(app)
app.addHook('onClose', async () => {
stopRetention()
stopHousekeeping()
stopBackup()
})
if (!config.oidcRsaPrivateKey && config.backupIntervalHours <= 0) {
app.log.warn(
'OIDC-ключ подписи хранится только в SQLite, а встроенный бэкап выключен: ' +
'задайте OIDC_RSA_PRIVATE_KEY или BACKUP_INTERVAL_HOURS / запускайте `backup` по cron',
)
}
}
if (config.staticDir && existsSync(config.staticDir)) {
await app.register(fastifyStatic, {
root: resolve(config.staticDir),
wildcard: false,
// .gz/.br для статики готовит Dockerfile на этапе сборки, здесь только отдаём.
preCompressed: true,
// Cache-Control выставляем сами (иначе плагин перезаписывает заголовок).
cacheControl: false,
setHeaders(res, filePath) {
if (HASHED_ASSET_RE.test(filePath)) {
// Vite кладёт хеш в имя файла → кэш на год без ревалидации.
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
} else if (INDEX_HTML_RE.test(filePath)) {
// Точка входа должна ревалидироваться, иначе клиент застрянет на старых чанках.
res.setHeader('Cache-Control', 'no-cache')
} else {
res.setHeader('Cache-Control', 'public, max-age=0')
}
},
})
}
const spa = Boolean(config.staticDir && existsSync(config.staticDir))
app.setNotFoundHandler((req, reply) => {
if (
spa &&
req.method === 'GET' &&
!API_PREFIXES.some((p) => req.url.startsWith(p))
) {
return reply.sendFile('index.html')
}
return reply
.status(404)
.send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
})
return app
}
+184
View File
@@ -0,0 +1,184 @@
import { z } from 'zod'
const boolFromEnv = (v: string | undefined, fallback: boolean) => {
if (v === undefined || v === '') return fallback
return v === '1' || v.toLowerCase() === 'true'
}
/** Loopback + приватные сети (Docker/Traefik). */
export const DEFAULT_TRUST_PROXY =
'127.0.0.1/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7'
export function parseTrustProxy(raw: string): boolean | string[] {
const v = raw.trim()
if (v === 'true') return true
if (v === 'false' || v === '') return false
return v.split(',').map((s) => s.trim()).filter(Boolean)
}
export const configSchema = z.object({
databaseUrl: z.string().default('sqlite:data/app.db'),
jwtSecret: z.string().min(8),
jwtTtlHours: z.coerce.number().positive().default(1),
refreshTtlDays: z.coerce.number().positive().default(14),
issuer: z.string().url().default('https://auth.shnt.top'),
/** OIDC issuer URL; defaults to `issuer`. */
oidcIssuer: z.string().url().optional(),
/** Optional PEM PKCS8 RSA private key; otherwise auto-generated & persisted. */
oidcRsaPrivateKey: z.string().optional(),
adminEmail: z.string().email().default('[email protected]'),
adminPassword: z.string().default('admin'),
adminName: z.string().default('Admin'),
returnToAllowlist: z.string().default('.shnt.top,localhost,private'),
serverPort: z.coerce.number().int().positive().default(8080),
staticDir: z.string().optional(),
logLevel: z.string().default('info'),
/** Каталог бэкапов SQLite; по умолчанию <каталог БД>/backups. */
backupDir: z.string().optional(),
/** Сколько последних копий хранить. */
backupKeep: z.coerce.number().int().positive().default(7),
/** Период встроенного бэкапа в часах; 0 — выключен (используйте cron/CLI). */
backupIntervalHours: z.coerce.number().min(0).default(0),
/** Сжатие ответов в Node (без Traefik compress). Включайте, только если перед приложением нет сжимающего прокси. */
enableCompress: z.string().default('false'),
/** Глобальный лимит запросов на IP в минуту (per-route лимиты задаются отдельно). */
rateLimitMax: z.coerce.number().int().positive().default(200),
/** Доп. разрешённые CORS-origins через запятую ('*' — все). */
corsOrigins: z.string().default(''),
/** Одновременных argon2-хешей (каждый ~19 МиБ). */
argon2Concurrency: z.coerce.number().int().positive().default(2),
/** 'true' | 'false' | список IP/CIDR доверенных прокси. */
trustProxy: z.string().default(DEFAULT_TRUST_PROXY),
/** Сколько дней хранить истёкшие/отозванные сессии и коды перед удалением. */
sessionRetentionDays: z.coerce.number().positive().default(7),
/** Максимум на graceful shutdown, затем принудительный выход. */
shutdownTimeoutMs: z.coerce.number().int().positive().default(8000),
isProd: z.boolean(),
auditIngestSecret: z.string().min(8).optional(),
portalRevocationsEnabled: z.boolean().default(false),
portalRevocationsTarget: z.string().optional(),
portalRevocationsSecret: z.string().optional(),
webauthnRpID: z.string().min(1),
webauthnRpName: z.string().min(1).default('Auth Portal'),
webauthnOrigins: z.array(z.string().url()).min(1),
})
export type AppConfig = z.infer<typeof configSchema>
const WEAK_JWT_SECRETS = new Set([
'change-me-in-production',
'dev-secret-change-me',
'secret',
'changeme',
])
/** В production запрещены известные дефолты JWT_SECRET; короткий секрет — только предупреждение. */
function assertProductionSecrets(config: AppConfig): void {
if (!config.isProd) return
if (WEAK_JWT_SECRETS.has(config.jwtSecret)) {
throw new Error(
'JWT_SECRET: в production нельзя использовать значение по умолчанию — задайте уникальный секрет',
)
}
if (config.jwtSecret.length < 16) {
// Не роняем старые установки с коротким, но уникальным секретом.
process.emitWarning(
'JWT_SECRET короче 16 символов — смените на более длинный (смена разлогинит пользователей)',
)
}
}
/** Пароль первого администратора: в production — не дефолтный и не короче 8 символов. */
export function assertBootstrapPassword(config: AppConfig): void {
if (!config.isProd) return
if (!config.adminPassword || config.adminPassword === 'admin' || config.adminPassword.length < 8) {
throw new Error(
'ADMIN_PASSWORD: для создания первого администратора в production задайте пароль не короче 8 символов (не "admin")',
)
}
}
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
const isProd = env.NODE_ENV === 'production'
const jwtSecret = env.JWT_SECRET ?? (isProd ? '' : 'dev-secret-change-me')
const auditIngestSecret =
env.AUDIT_INGEST_SECRET ??
(isProd ? undefined : 'dev-audit-ingest-secret')
const issuer = env.ISSUER ?? 'https://auth.shnt.top'
const { rpID, origins } = webauthnFromIssuer(issuer, env, isProd)
const config = configSchema.parse({
databaseUrl: env.DATABASE_URL ?? 'sqlite:data/app.db',
jwtSecret,
jwtTtlHours: env.JWT_TTL_HOURS ?? 1,
refreshTtlDays: env.REFRESH_TTL_DAYS ?? 14,
issuer,
oidcIssuer: env.OIDC_ISSUER || issuer,
oidcRsaPrivateKey: env.OIDC_RSA_PRIVATE_KEY || undefined,
adminEmail: env.ADMIN_EMAIL ?? '[email protected]',
adminPassword: env.ADMIN_PASSWORD ?? 'admin',
adminName: env.ADMIN_NAME ?? 'Admin',
returnToAllowlist:
env.RETURN_TO_ALLOWLIST ?? '.shnt.top,localhost,private',
serverPort: env.SERVER_PORT ?? 8080,
staticDir: env.STATIC_DIR || undefined,
logLevel: env.LOG_LEVEL ?? 'info',
shutdownTimeoutMs: env.SHUTDOWN_TIMEOUT_MS ?? 8000,
sessionRetentionDays: env.SESSION_RETENTION_DAYS ?? 7,
backupDir: env.BACKUP_DIR || undefined,
backupKeep: env.BACKUP_KEEP ?? 7,
backupIntervalHours: env.BACKUP_INTERVAL_HOURS ?? 0,
enableCompress: env.ENABLE_COMPRESS ?? 'false',
rateLimitMax: env.RATE_LIMIT_MAX ?? 200,
corsOrigins: env.CORS_ORIGINS ?? '',
argon2Concurrency: env.ARGON2_CONCURRENCY ?? 2,
trustProxy: env.TRUST_PROXY ?? DEFAULT_TRUST_PROXY,
isProd:
boolFromEnv(env.NODE_ENV === 'production' ? 'true' : undefined, false) ||
isProd,
auditIngestSecret,
portalRevocationsEnabled: boolFromEnv(env.MCP_REVOCATIONS_ENABLED, false),
portalRevocationsTarget: env.MCP_REVOCATIONS_TARGET || undefined,
portalRevocationsSecret: env.MCP_REVOCATIONS_SECRET || undefined,
webauthnRpID: rpID,
webauthnRpName: env.WEBAUTHN_RP_NAME || 'Auth Portal',
webauthnOrigins: origins,
})
assertProductionSecrets(config)
return config
}
function webauthnFromIssuer(
issuer: string,
env: NodeJS.ProcessEnv,
isProd: boolean,
): { rpID: string; origins: string[] } {
let issuerUrl: URL
try {
issuerUrl = new URL(issuer)
} catch {
issuerUrl = new URL('https://auth.shnt.top')
}
const rpID = (env.WEBAUTHN_RP_ID || issuerUrl.hostname).trim()
const origins = new Set<string>()
origins.add(issuerUrl.origin)
const extra = env.WEBAUTHN_ORIGINS ?? ''
for (const raw of extra.split(',')) {
const value = raw.trim().replace(/\/$/, '')
if (!value) continue
try {
origins.add(new URL(value).origin)
} catch {
/* skip invalid */
}
}
if (!isProd) {
origins.add('http://localhost:5173')
origins.add('http://localhost:8080')
}
return { rpID, origins: [...origins] }
}
export function oidcIssuerFromConfig(config: AppConfig): string {
return (config.oidcIssuer ?? config.issuer).replace(/\/$/, '')
}
+40
View File
@@ -0,0 +1,40 @@
import type { FastifyInstance, FastifyRequest } from 'fastify'
import {
appendAudit,
type AppendAuditInput,
} from '@authportal/db'
export function clientIp(request: FastifyRequest): string | null {
// request.ip учитывает X-Forwarded-For только от доверенных прокси (TRUST_PROXY).
return request.ip ?? null
}
export function safeAudit(
app: FastifyInstance,
input: AppendAuditInput,
): void {
try {
appendAudit(app.db, input)
} catch (err) {
app.log.warn({ err }, 'audit_log append failed')
}
}
export function actorFromRequest(request: FastifyRequest): Pick<
AppendAuditInput,
'actorUserId' | 'actorEmail' | 'actorName'
> {
const u = request.authUser
if (!u) {
return {
actorUserId: null,
actorEmail: null,
actorName: null,
}
}
return {
actorUserId: u.id,
actorEmail: u.email,
actorName: u.name,
}
}
+51
View File
@@ -0,0 +1,51 @@
/** Мини-кэш с TTL и вытеснением по LRU (Map сохраняет порядок вставки). */
export class TtlCache<K, V> {
private readonly map = new Map<K, { value: V; expires: number }>()
constructor(
private readonly opts: {
max: number
ttlMs: number
now?: () => number
},
) {}
private now(): number {
return (this.opts.now ?? Date.now)()
}
get(key: K): V | undefined {
const hit = this.map.get(key)
if (!hit) return undefined
if (hit.expires <= this.now()) {
this.map.delete(key)
return undefined
}
// освежаем позицию для LRU
this.map.delete(key)
this.map.set(key, hit)
return hit.value
}
set(key: K, value: V): void {
this.map.delete(key)
this.map.set(key, { value, expires: this.now() + this.opts.ttlMs })
while (this.map.size > this.opts.max) {
const oldest = this.map.keys().next()
if (oldest.done) break
this.map.delete(oldest.value)
}
}
delete(key: K): void {
this.map.delete(key)
}
clear(): void {
this.map.clear()
}
get size(): number {
return this.map.size
}
}
+35
View File
@@ -0,0 +1,35 @@
import { createHash, timingSafeEqual } from 'node:crypto'
import { verifyPassword } from './password.js'
const PREFIX = 'sha256:'
/**
* Секреты OIDC-клиентов — 32 случайных байта (высокая энтропия), поэтому
* медленный argon2 не нужен: SHA-256 + сравнение за постоянное время.
*/
export function hashClientSecret(secret: string): string {
return PREFIX + createHash('sha256').update(secret).digest('hex')
}
export type ClientSecretCheck = { ok: boolean; needsRehash: boolean }
export async function checkClientSecret(
stored: string,
secret: string,
): Promise<ClientSecretCheck> {
if (stored.startsWith(PREFIX)) {
const a = Buffer.from(stored)
const b = Buffer.from(hashClientSecret(secret))
return {
ok: a.length === b.length && timingSafeEqual(a, b),
needsRehash: false,
}
}
// Устаревший формат (argon2): проверяем и просим перехешировать при успехе.
try {
const ok = await verifyPassword(stored, secret)
return { ok, needsRehash: ok }
} catch {
return { ok: false, needsRehash: false }
}
}
+64
View File
@@ -0,0 +1,64 @@
import type { FastifyInstance, FastifyReply } from 'fastify'
import { randomBytes } from 'node:crypto'
import {
createRefreshSession,
touchLastLogin,
type UserRow,
} from '@authportal/db'
import { issueAccessToken } from './issue-access-token.js'
import { safeAudit } from './audit.js'
import { targetAppFromReturnTo } from './target-app.js'
export const REFRESH_COOKIE = 'refresh_token'
export function completeLogin(
app: FastifyInstance,
reply: FastifyReply,
user: UserRow,
opts: {
method: 'password' | 'passkey'
returnTo?: string
ip: string | null
userAgent: string | null
},
) {
const body = issueAccessToken(app, user)
const refreshRaw = randomBytes(32).toString('hex')
const refreshExpires = new Date(
Date.now() + app.config.refreshTtlDays * 24 * 60 * 60 * 1000,
)
createRefreshSession(app.db, user.id, refreshRaw, refreshExpires, {
ip: opts.ip,
userAgent: opts.userAgent,
})
touchLastLogin(app.db, user.id, opts.ip)
reply.header(
'Set-Cookie',
`${REFRESH_COOKIE}=${refreshRaw}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${app.config.refreshTtlDays * 86400}${app.config.isProd ? '; Secure' : ''}`,
)
const targetApp = targetAppFromReturnTo(opts.returnTo)
const isPasskey = opts.method === 'passkey'
safeAudit(app, {
action: isPasskey ? 'auth.passkey_login' : 'auth.login',
severity: 'info',
actorUserId: user.id,
actorEmail: user.email,
actorName: user.name,
targetType: 'session',
targetId: user.id,
summary: isPasskey
? `Вход (passkey): ${user.email}`
: `Вход: ${user.email}`,
details: {
method: opts.method,
user_agent: opts.userAgent,
return_to: opts.returnTo ?? null,
target_app: targetApp,
},
ip: opts.ip,
})
return body
}
+27
View File
@@ -0,0 +1,27 @@
/** Значение cookie без построения RegExp на каждый запрос. */
export function parseCookie(
header: string | undefined,
name: string,
): string | null {
if (!header) return null
let from = 0
while (from < header.length) {
let end = header.indexOf(';', from)
if (end === -1) end = header.length
const eq = header.indexOf('=', from)
if (eq !== -1 && eq < end) {
const key = header.slice(from, eq).trim()
if (key === name) {
const raw = header.slice(eq + 1, end).trim()
if (!raw) return null
try {
return decodeURIComponent(raw)
} catch {
return raw
}
}
}
from = end + 1
}
return null
}
+50
View File
@@ -0,0 +1,50 @@
import { isReturnToAllowed } from '@authportal/shared'
import type { AppConfig } from '../config.js'
import { oidcIssuerFromConfig } from '../config.js'
function originOf(url: string): string | null {
try {
return new URL(url).origin
} catch {
return null
}
}
/**
* Проверка Origin для CORS с credentials. Разрешены: CORS_ORIGINS, issuer,
* приложения из App Switcher и всё, что разрешает RETURN_TO_ALLOWLIST
* (та же isReturnToAllowed, что для return_to: ".suffix", localhost, полные origin, private). '*' в CORS_ORIGINS —
* разрешить всё (только осознанно).
*/
export function createOriginChecker(
config: AppConfig,
switcherUrls: () => string[],
): (origin: string) => boolean {
const explicit = new Set<string>()
let allowAll = false
for (const raw of config.corsOrigins.split(',')) {
const v = raw.trim()
if (!v) continue
if (v === '*') allowAll = true
else {
const o = originOf(v)
if (o) explicit.add(o)
}
}
for (const u of [config.issuer, oidcIssuerFromConfig(config)]) {
const o = originOf(u)
if (o) explicit.add(o)
}
return (origin: string) => {
if (allowAll) return true
const o = originOf(origin)
if (!o) return false
if (explicit.has(o)) return true
// Тот же allowlist, что у return_to (.suffix, localhost, полные origin, private).
if (isReturnToAllowed(o, config.returnToAllowlist)) return true
for (const u of switcherUrls()) {
if (originOf(u) === o) return true
}
return false
}
}
+55
View File
@@ -0,0 +1,55 @@
import type { FastifyInstance } from 'fastify'
import {
allPermissionKeys,
normalizePermissionKeys,
tenantsClaimForUser,
type LoginResponse,
} from '@authportal/shared'
import {
getAppSwitcherConfig,
getUserApps,
getUserPermissions,
type UserRow,
} from '@authportal/db'
import { toMe } from '../plugins/auth-guards.js'
import { getUserProfile } from './user-profile.js'
/** Mint access JWT + Me from current DB state (login / reissue). */
export function issueAccessToken(
app: FastifyInstance,
user: UserRow,
): LoginResponse {
// Профиль из кэша (инвалидируется записями в users/доступ); user — свежая строка вызывающего.
const profile = getUserProfile(app.db, user.id)
const apps = profile?.apps ?? getUserApps(app.db, user.id)
const permissions = user.isAdmin
? allPermissionKeys()
: (profile?.permissions ??
normalizePermissionKeys(getUserPermissions(app.db, user.id)))
const switcher = getAppSwitcherConfig(app.db)
const tenants = tenantsClaimForUser(switcher, apps)
const me = toMe(user, apps, permissions)
const expiresAt = new Date(
Date.now() + app.config.jwtTtlHours * 60 * 60 * 1000,
)
const accessToken = app.jwt.sign(
{
sub: user.id,
email: user.email,
name: user.name,
apps,
permissions,
tenants,
bgp_tenant_id: tenants.bgp,
is_admin: user.isAdmin,
iss: app.config.issuer,
},
{ expiresIn: `${app.config.jwtTtlHours}h` },
)
return {
access_token: accessToken,
expires_at: expiresAt.toISOString(),
token_type: 'Bearer',
user: me,
}
}
+232
View File
@@ -0,0 +1,232 @@
import {
exportJWK,
exportPKCS8,
generateKeyPair,
importJWK,
importPKCS8,
jwtVerify,
SignJWT,
type JWK,
} from 'jose'
import { createHash, randomUUID } from 'node:crypto'
import type { FastifyInstance } from 'fastify'
import {
getActiveOidcSigningKey,
insertOidcSigningKey,
listOidcSigningKeys,
} from '@authportal/db'
import { oidcIssuerFromConfig, type AppConfig } from '../../config.js'
export type OidcKeyMaterial = {
kid: string
privateKey: CryptoKey
publicJwk: JWK
}
let cached: OidcKeyMaterial | null = null
// importJWK — асинхронный разбор JWK; без кэша он выполнялся на каждый /oauth/userinfo.
const importedPublicKeys = new Map<string, CryptoKey>()
function publicJwkFromPrivateExport(jwk: JWK, kid: string): JWK {
return {
kty: jwk.kty,
n: jwk.n,
e: jwk.e,
alg: 'RS256',
use: 'sig',
kid,
}
}
async function materialFromPem(
kid: string,
privatePem: string,
publicJwkJson?: string,
): Promise<OidcKeyMaterial> {
// jose defaults extractable=false for private keys → exportJWK throws
const privateKey = await importPKCS8(privatePem, 'RS256', {
extractable: true,
})
if (publicJwkJson) {
try {
const stored = JSON.parse(publicJwkJson) as JWK
return {
kid,
privateKey,
publicJwk: publicJwkFromPrivateExport({ ...stored, kid }, kid),
}
} catch {
// fall through to derive from private key
}
}
const full = await exportJWK(privateKey)
return {
kid,
privateKey,
publicJwk: publicJwkFromPrivateExport(full, kid),
}
}
/**
* Ensure an active RSA signing key exists (env PEM or DB / generate).
*/
export async function ensureOidcSigningKey(
app: FastifyInstance,
): Promise<OidcKeyMaterial> {
if (cached) return cached
const envPem = app.config.oidcRsaPrivateKey?.trim()
if (envPem) {
const kid = createHash('sha256').update(envPem).digest('hex').slice(0, 16)
cached = await materialFromPem(kid, envPem)
return cached
}
const existing = getActiveOidcSigningKey(app.db)
if (existing) {
cached = await materialFromPem(
existing.kid,
existing.privatePem,
existing.publicJwkJson,
)
return cached
}
const { privateKey, publicKey } = await generateKeyPair('RS256', {
extractable: true,
})
const kid = randomUUID().replace(/-/g, '').slice(0, 16)
const privatePem = await exportPKCS8(privateKey)
const publicJwk = await exportJWK(publicKey)
publicJwk.alg = 'RS256'
publicJwk.use = 'sig'
publicJwk.kid = kid
insertOidcSigningKey(app.db, {
kid,
privatePem,
publicJwkJson: JSON.stringify(publicJwk),
})
cached = { kid, privateKey, publicJwk }
app.log.info({ kid }, 'OIDC RS256 signing key generated')
return cached
}
/** Ключ подписи загружен — приложение готово выдавать токены. */
export function isOidcKeyReady(): boolean {
return cached !== null
}
/** Reset cache (tests). */
export function resetOidcKeyCache(): void {
cached = null
importedPublicKeys.clear()
jwksCache = new WeakMap()
}
export function buildOidcDiscovery(config: AppConfig) {
const issuer = oidcIssuerFromConfig(config)
return {
issuer,
authorization_endpoint: `${issuer}/oauth/authorize`,
token_endpoint: `${issuer}/oauth/token`,
userinfo_endpoint: `${issuer}/oauth/userinfo`,
jwks_uri: `${issuer}/.well-known/jwks.json`,
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
scopes_supported: ['openid', 'profile', 'email', 'groups'],
token_endpoint_auth_methods_supported: [
'client_secret_post',
'client_secret_basic',
],
claims_supported: [
'sub',
'iss',
'aud',
'exp',
'iat',
'email',
'email_verified',
'name',
'preferred_username',
'groups',
'roles',
],
grant_types_supported: ['authorization_code'],
code_challenge_methods_supported: ['S256', 'plain'],
}
}
export async function buildJwks(
app: FastifyInstance,
): Promise<{ keys: JWK[] }> {
await ensureOidcSigningKey(app)
const hit = jwksCache.get(app.db)
if (hit && hit.kid === cached?.kid) return hit.jwks
const jwks = computeJwks(app)
jwksCache.set(app.db, { kid: cached?.kid ?? '', jwks })
return jwks
}
/** Мемоизация JWKS: набор ключей меняется только при выпуске нового (меняется kid). */
let jwksCache = new WeakMap<object, { kid: string; jwks: { keys: JWK[] } }>()
function computeJwks(app: FastifyInstance): { keys: JWK[] } {
const keys = listOidcSigningKeys(app.db)
.map((row): JWK | null => {
try {
const jwk = JSON.parse(row.publicJwkJson) as JWK
return { ...jwk, kid: row.kid, alg: 'RS256', use: 'sig' }
} catch {
return null
}
})
.filter((k): k is JWK => k !== null)
if (cached && !keys.some((k) => k.kid === cached!.kid)) {
keys.unshift(cached.publicJwk)
}
return { keys }
}
/** Принудительный сброс JWKS (после ручных правок ключей в БД). */
export function invalidateJwks(app: FastifyInstance): void {
jwksCache.delete(app.db)
}
export async function signOidcJwt(
app: FastifyInstance,
claims: Record<string, unknown>,
expiresInSeconds: number,
): Promise<string> {
const key = await ensureOidcSigningKey(app)
const issuer = oidcIssuerFromConfig(app.config)
return new SignJWT(claims)
.setProtectedHeader({ alg: 'RS256', kid: key.kid, typ: 'JWT' })
.setIssuer(issuer)
.setIssuedAt()
.setExpirationTime(`${expiresInSeconds}s`)
.sign(key.privateKey)
}
/** Verify OIDC access token (RS256) and return payload. */
export async function verifyOidcAccessToken(
app: FastifyInstance,
token: string,
): Promise<Record<string, unknown>> {
const key = await ensureOidcSigningKey(app)
const issuer = oidcIssuerFromConfig(app.config)
let publicKey: CryptoKey | undefined = importedPublicKeys.get(key.kid)
if (!publicKey) {
// Для RS256 + RSA JWK importJWK всегда возвращает CryptoKey.
publicKey = (await importJWK(key.publicJwk, 'RS256')) as CryptoKey
importedPublicKeys.set(key.kid, publicKey)
}
const { payload } = await jwtVerify(token, publicKey, {
issuer,
algorithms: ['RS256'],
})
return payload as Record<string, unknown>
}
+58
View File
@@ -0,0 +1,58 @@
import { hash, verify } from '@node-rs/argon2'
/**
* argon2 держит ~19 МиБ на вызов и занимает поток libuv; без ограничителя
* шквал логинов раздувает RSS. Семафор ограничивает число одновременных хешей.
*/
let limit = 2
let active = 0
const waiters: Array<() => void> = []
export function setPasswordConcurrency(n: number): void {
limit = Math.max(1, Math.floor(n))
}
export function passwordHashStats(): { active: number; waiting: number } {
return { active, waiting: waiters.length }
}
async function withSlot<T>(fn: () => Promise<T>): Promise<T> {
if (active >= limit) {
await new Promise<void>((resolve) => waiters.push(resolve))
} else {
active += 1
}
try {
return await fn()
} finally {
const next = waiters.shift()
if (next) next() // слот передаётся ожидающему без изменения active
else active -= 1
}
}
export function hashPassword(password: string): Promise<string> {
return withSlot(() => hash(password))
}
export function verifyPassword(
passwordHash: string,
password: string,
): Promise<boolean> {
return withSlot(() => verify(passwordHash, password))
}
let dummyHash: Promise<string> | null = null
/**
* Для неизвестного/отключённого пользователя выполняем настоящую проверку
* по фиктивному хешу: время ответа не выдаёт существование учётки.
*/
export async function verifyDummyPassword(password: string): Promise<void> {
dummyHash ??= hash('dummy-password-for-timing')
try {
await withSlot(async () => verify(await dummyHash!, password))
} catch {
/* результат не важен */
}
}
+67
View File
@@ -0,0 +1,67 @@
import type { FastifyInstance } from 'fastify'
export interface ShutdownOptions {
timeoutMs: number
exit?: (code: number) => void
signals?: NodeJS.Signals[]
proc?: Pick<NodeJS.Process, 'on' | 'off'>
}
/**
* SIGTERM/SIGINT → app.close() (дозавершает запросы, onClose закрывает БД),
* принудительный выход по таймауту, повторный сигнал — немедленный выход.
* Возвращает функцию отписки и ручной триггер (для тестов).
*/
export function registerShutdown(app: FastifyInstance, opts: ShutdownOptions) {
const exit = opts.exit ?? ((code: number) => process.exit(code))
const proc = opts.proc ?? process
const signals = opts.signals ?? ['SIGTERM', 'SIGINT']
let closing: Promise<void> | null = null
const shutdown = (reason: string, code = 0): Promise<void> => {
if (closing) {
app.log.warn(`shutdown: repeated ${reason}, forcing exit`)
exit(1)
return closing
}
app.log.info(`shutdown: ${reason}`)
const timer = setTimeout(() => {
app.log.error('shutdown: timeout, forcing exit')
exit(1)
}, opts.timeoutMs)
timer.unref()
closing = app
.close()
.then(() => {
clearTimeout(timer)
exit(code)
})
.catch((err: unknown) => {
clearTimeout(timer)
app.log.error(err, 'shutdown: close failed')
exit(1)
})
return closing
}
const handlers = signals.map((sig) => {
const h = () => void shutdown(sig)
proc.on(sig, h)
return [sig, h] as const
})
const onFatal = (err: unknown) => {
app.log.error(err, 'fatal error')
void shutdown('fatal', 1)
}
proc.on('unhandledRejection', onFatal)
proc.on('uncaughtException', onFatal)
return {
shutdown,
dispose() {
for (const [sig, h] of handlers) proc.off(sig, h)
proc.off('unhandledRejection', onFatal)
proc.off('uncaughtException', onFatal)
},
}
}
+75
View File
@@ -0,0 +1,75 @@
import type { AuditSourceApp } from '@authportal/shared'
/** Resolve SSO target app from return_to URL host/path (and OIDC authorize unwrap). */
export function targetAppFromReturnTo(
returnTo: string | undefined | null,
): AuditSourceApp {
if (!returnTo) return 'portal'
let u: URL
try {
u = new URL(returnTo)
} catch {
return 'portal'
}
const host = u.hostname.toLowerCase()
const path = u.pathname.toLowerCase()
const search = u.search
// Portal OIDC authorize as return_to → map via client's redirect_uri
if (path === '/oauth/authorize' || path.endsWith('/oauth/authorize')) {
try {
const redirectUri = new URLSearchParams(search).get('redirect_uri')
if (redirectUri) return targetAppFromReturnTo(redirectUri)
} catch {
/* ignore */
}
}
const hay = `${host} ${path}`
if (/\bvps\b/.test(hay) || host.includes('vps')) return 'vps'
if (
/\bcfdm\b/.test(hay) ||
host.includes('cfdm') ||
host.includes('domain')
) {
return 'cfdm'
}
if (/\bbgp\b/.test(hay) || host.includes('bgp')) return 'bgp'
if (
/\bfw\b/.test(hay) ||
host.includes('firewall') ||
host.includes('evofw')
) {
return 'fw'
}
if (
/\bdns\b/.test(hay) ||
host.includes('technitium') ||
path.includes('/sso/')
) {
return 'dns'
}
if (
/\bcdn\b/.test(hay) ||
host.includes('cdnmanager') ||
host.includes('cdn-manager')
) {
return 'cdn'
}
if (
/\bmm\b/.test(hay) ||
host.includes('mikrotik') ||
host.includes('mmapp')
) {
return 'mm'
}
return 'portal'
}
export function clientUserAgent(
headers: Record<string, string | string[] | undefined>,
): string | null {
const ua = headers['user-agent']
if (typeof ua === 'string' && ua.trim()) return ua.trim().slice(0, 512)
return null
}
+70
View File
@@ -0,0 +1,70 @@
import {
getUserApps,
getUserById,
getUserDataGeneration,
getUserPermissions,
type AppDb,
type UserRow,
} from '@authportal/db'
import { allPermissionKeys, normalizePermissionKeys } from '@authportal/shared'
import { TtlCache } from './cache.js'
export type UserProfile = {
row: UserRow
/** Сырые app_id из БД. */
apps: string[]
/** Нормализованные права; у админа — полный каталог. */
permissions: string[]
}
type Entry = { profile: UserProfile; generation: number }
/** Страховочный TTL: основная инвалидация — по счётчику поколения из @authportal/db. */
const PROFILE_TTL_MS = 10_000
const PROFILE_MAX = 500
const caches = new WeakMap<AppDb, TtlCache<string, Entry>>()
function cacheFor(db: AppDb): TtlCache<string, Entry> {
let c = caches.get(db)
if (!c) {
c = new TtlCache({ max: PROFILE_MAX, ttlMs: PROFILE_TTL_MS })
caches.set(db, c)
}
return c
}
export function loadUserProfile(db: AppDb, id: string): UserProfile | undefined {
const row = getUserById(db, id)
if (!row) return undefined
return profileFromRow(db, row)
}
function profileFromRow(db: AppDb, row: UserRow): UserProfile {
const apps = getUserApps(db, row.id)
const permissions = row.isAdmin
? allPermissionKeys()
: normalizePermissionKeys(getUserPermissions(db, row.id))
return { row, apps, permissions }
}
/**
* Профиль пользователя (строка + приложения + права) с кэшем: 3 SELECT на
* запрос → 0 пока данные пользователей не менялись.
*/
export function getUserProfile(
db: AppDb,
id: string,
): UserProfile | undefined {
const cache = cacheFor(db)
const generation = getUserDataGeneration()
const hit = cache.get(id)
if (hit && hit.generation === generation) return hit.profile
const profile = loadUserProfile(db, id)
if (!profile) {
cache.delete(id)
return undefined
}
cache.set(id, { profile, generation })
return profile
}
+35
View File
@@ -0,0 +1,35 @@
import type { FastifyInstance } from 'fastify'
import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers'
import type { AuthenticatorTransportFuture } from '@simplewebauthn/server'
export function webauthnRelyingParty(app: FastifyInstance): {
rpID: string
rpName: string
origins: string[]
} {
return {
rpID: app.config.webauthnRpID,
rpName: app.config.webauthnRpName,
origins: app.config.webauthnOrigins,
}
}
export function userIdToBytes(userId: string): Uint8Array<ArrayBuffer> {
// Копия через конструктор даёт Uint8Array<ArrayBuffer>, которого требует SimpleWebAuthn v13.
return new Uint8Array(isoUint8Array.fromUTF8String(userId))
}
export function encodePublicKey(publicKey: Uint8Array<ArrayBuffer>): string {
return isoBase64URL.fromBuffer(publicKey)
}
export function decodePublicKey(stored: string): Uint8Array<ArrayBuffer> {
return new Uint8Array(isoBase64URL.toBuffer(stored))
}
export function asTransports(
values: string[],
): AuthenticatorTransportFuture[] | undefined {
if (values.length === 0) return undefined
return values as AuthenticatorTransportFuture[]
}
+112
View File
@@ -0,0 +1,112 @@
import type { FastifyReply, FastifyRequest } from 'fastify'
import type { UserRow } from '@authportal/db'
import type { AppId, MeResponse } from '@authportal/shared'
import { APP_IDS } from '@authportal/shared'
import { getUserProfile, type UserProfile } from '../lib/user-profile.js'
export type AuthUser = {
id: string
email: string
name: string
isAdmin: boolean
apps: AppId[]
permissions: string[]
}
declare module '@fastify/jwt' {
interface FastifyJWT {
payload: {
sub: string
email: string
name: string
apps: string[]
permissions: string[]
tenants?: Record<string, string>
bgp_tenant_id?: string
is_admin?: boolean
iss: string
}
user: {
sub: string
email: string
name: string
apps: string[]
permissions: string[]
tenants?: Record<string, string>
bgp_tenant_id?: string
is_admin?: boolean
iss: string
}
}
}
declare module 'fastify' {
interface FastifyRequest {
authUser?: AuthUser
}
}
function asAppIds(apps: string[]): AppId[] {
return apps.filter((a): a is AppId =>
(APP_IDS as readonly string[]).includes(a),
)
}
export function toMe(user: UserRow, apps: string[], permissions: string[]): MeResponse {
return {
id: user.id,
email: user.email,
name: user.name,
is_admin: user.isAdmin,
apps: asAppIds(apps),
permissions,
}
}
export function authUserFromProfile(profile: UserProfile): AuthUser {
const { row } = profile
return {
id: row.id,
email: row.email,
name: row.name,
isAdmin: row.isAdmin,
apps: asAppIds(profile.apps),
permissions: profile.permissions,
}
}
export async function requireAuth(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
try {
await request.jwtVerify()
} catch {
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Требуется авторизация' },
})
}
const sub = request.user.sub
const profile = getUserProfile(request.server.db, sub)
if (!profile || profile.row.disabled) {
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
})
}
request.authUser = authUserFromProfile(profile)
}
export async function requireAdmin(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
await requireAuth(request, reply)
if (reply.sent) return
if (!request.authUser?.isAdmin) {
return reply.status(403).send({
error: { code: 'FORBIDDEN', message: 'Только для администраторов' },
})
}
}
+560
View File
@@ -0,0 +1,560 @@
import type { FastifyInstance } from 'fastify'
import { hashPassword } from '../lib/password.js'
import { hashClientSecret } from '../lib/client-secret.js'
import {
countWebauthnCredentials,
countWebauthnCredentialsBatch,
createOidcClient,
createUser,
deleteOidcClient,
deleteUser,
deleteWebauthnCredentialsForUser,
generateOidcClientSecret,
getAppSwitcherConfig,
getOidcClientById,
getUserApps,
getUserByEmail,
getUserById,
getUserPermissions,
listActiveSessions,
listOidcClients,
listUserAppsBatch,
listUserPermissionsBatch,
listUsers,
parseJsonStringArray,
revokeAllSessionsForUser,
revokeSessionById,
setAppSwitcherConfig,
setUserAccess,
updateOidcClient,
updateUser,
withTransaction,
} from '@authportal/db'
import {
APP_IDS,
OIDC_SCOPES,
allPermissionKeys,
appSwitcherConfigSchema,
createOidcClientRequestSchema,
createUserRequestSchema,
normalizePermissionKeys,
patchOidcClientRequestSchema,
patchUserRequestSchema,
putUserAccessRequestSchema,
type AdminUser,
type AppId,
type OidcClientPublic,
} from '@authportal/shared'
import { requireAdmin } from '../plugins/auth-guards.js'
import { actorFromRequest, clientIp, safeAudit } from '../lib/audit.js'
import { oidcIssuerFromConfig } from '../config.js'
const allowedPermissions = new Set(allPermissionKeys())
function toAdminUser(
user: NonNullable<ReturnType<typeof getUserById>>,
apps: string[],
permissions: string[],
passkeyCount: number,
): AdminUser {
return {
id: user.id,
email: user.email,
name: user.name,
is_admin: user.isAdmin,
disabled: user.disabled,
apps: apps.filter((a): a is AppId => (APP_IDS as readonly string[]).includes(a)),
permissions: normalizePermissionKeys(permissions),
last_login_at: user.lastLoginAt ?? null,
last_login_ip: user.lastLoginIp ?? null,
passkey_count: passkeyCount,
created_at: user.createdAt,
updated_at: user.updatedAt,
}
}
function mapUser(
db: FastifyInstance['db'],
user: NonNullable<ReturnType<typeof getUserById>>,
): AdminUser {
return toAdminUser(
user,
getUserApps(db, user.id),
getUserPermissions(db, user.id),
countWebauthnCredentials(db, user.id),
)
}
function validateAccess(apps: string[], permissions: string[]): string | null {
for (const app of apps) {
if (!(APP_IDS as readonly string[]).includes(app)) {
return `Неизвестное приложение: ${app}`
}
}
for (const p of permissions) {
if (!allowedPermissions.has(p)) {
return `Неизвестное право: ${p}`
}
}
return null
}
function mapOidcClient(
row: NonNullable<ReturnType<typeof getOidcClientById>>,
): OidcClientPublic {
return {
id: row.id,
client_id: row.clientId,
name: row.name,
redirect_uris: parseJsonStringArray(row.redirectUrisJson),
scopes: parseJsonStringArray(row.scopesJson),
enabled: row.enabled,
created_at: row.createdAt,
updated_at: row.updatedAt,
}
}
export async function adminRoutes(app: FastifyInstance): Promise<void> {
app.addHook('onRequest', async (request, reply) => {
if (!request.url.startsWith('/api/v1/admin')) return
await requireAdmin(request, reply)
})
app.get('/api/v1/admin/users', async () => {
// Батч-запросы вместо 3 SELECT'ов на пользователя (N+1 при N пользователях).
const usersRows = listUsers(app.db)
const appsByUser = listUserAppsBatch(app.db)
const permsByUser = listUserPermissionsBatch(app.db)
const passkeysByUser = countWebauthnCredentialsBatch(app.db)
return usersRows.map((u) =>
toAdminUser(
u,
appsByUser.get(u.id) ?? [],
permsByUser.get(u.id) ?? [],
passkeysByUser.get(u.id) ?? 0,
),
)
})
app.get<{ Params: { id: string } }>(
'/api/v1/admin/users/:id',
async (request, reply) => {
const user = getUserById(app.db, request.params.id)
if (!user) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
})
}
return mapUser(app.db, user)
},
)
app.post('/api/v1/admin/users', async (request, reply) => {
const parsed = createUserRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const data = parsed.data
const permissions = normalizePermissionKeys(data.permissions)
if (getUserByEmail(app.db, data.email)) {
return reply.status(409).send({
error: { code: 'CONFLICT', message: 'Email уже занят' },
})
}
const accessError = validateAccess(data.apps, permissions)
if (accessError) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: accessError },
})
}
const passwordHash = await hashPassword(data.password)
const user = withTransaction(app.db, () => {
const created = createUser(app.db, {
email: data.email,
name: data.name,
passwordHash,
isAdmin: data.is_admin,
})
setUserAccess(app.db, created.id, data.apps, permissions)
return created
})
const mapped = mapUser(app.db, getUserById(app.db, user.id)!)
safeAudit(app, {
action: 'user.create',
severity: 'info',
...actorFromRequest(request),
targetType: 'user',
targetId: user.id,
summary: `Создан пользователь ${mapped.email}`,
details: {
is_admin: mapped.is_admin,
apps: mapped.apps,
},
ip: clientIp(request),
})
return reply.status(201).send(mapped)
})
app.patch<{ Params: { id: string } }>(
'/api/v1/admin/users/:id',
async (request, reply) => {
const parsed = patchUserRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const existing = getUserById(app.db, request.params.id)
if (!existing) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
})
}
if (parsed.data.email && parsed.data.email !== existing.email) {
const clash = getUserByEmail(app.db, parsed.data.email)
if (clash) {
return reply.status(409).send({
error: { code: 'CONFLICT', message: 'Email уже занят' },
})
}
}
const passwordHash = parsed.data.password
? await hashPassword(parsed.data.password)
: undefined
const updated = updateUser(app.db, request.params.id, {
email: parsed.data.email,
name: parsed.data.name,
passwordHash,
isAdmin: parsed.data.is_admin,
disabled: parsed.data.disabled,
})
const mapped = mapUser(app.db, updated!)
safeAudit(app, {
action: 'user.update',
severity: parsed.data.disabled ? 'warning' : 'info',
...actorFromRequest(request),
targetType: 'user',
targetId: mapped.id,
summary: `Изменён пользователь ${mapped.email}`,
details: {
...parsed.data,
password: parsed.data.password ? '[changed]' : undefined,
},
ip: clientIp(request),
})
return mapped
},
)
app.delete<{ Params: { id: string } }>(
'/api/v1/admin/users/:id/passkeys',
async (request, reply) => {
const existing = getUserById(app.db, request.params.id)
if (!existing) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
})
}
const removed = deleteWebauthnCredentialsForUser(app.db, existing.id)
safeAudit(app, {
action: 'admin.passkey_reset',
severity: 'warning',
...actorFromRequest(request),
targetType: 'user',
targetId: existing.id,
summary: `Сброшены passkeys: ${existing.email} (${removed})`,
details: { removed },
ip: clientIp(request),
})
return { ok: true, removed, user: mapUser(app.db, existing) }
},
)
app.delete<{ Params: { id: string } }>(
'/api/v1/admin/users/:id',
async (request, reply) => {
if (request.authUser?.id === request.params.id) {
return reply.status(400).send({
error: {
code: 'VALIDATION_ERROR',
message: 'Нельзя удалить себя',
},
})
}
const existing = getUserById(app.db, request.params.id)
if (!existing) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
})
}
const ok = deleteUser(app.db, request.params.id)
if (!ok) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
})
}
safeAudit(app, {
action: 'user.delete',
severity: 'critical',
...actorFromRequest(request),
targetType: 'user',
targetId: existing.id,
summary: `Удалён пользователь ${existing.email}`,
details: { email: existing.email, name: existing.name },
ip: clientIp(request),
})
return reply.status(204).send()
},
)
app.put<{ Params: { id: string } }>(
'/api/v1/admin/users/:id/access',
async (request, reply) => {
const parsed = putUserAccessRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const existing = getUserById(app.db, request.params.id)
if (!existing) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
})
}
const permissions = normalizePermissionKeys(parsed.data.permissions)
const accessError = validateAccess(parsed.data.apps, permissions)
if (accessError) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: accessError },
})
}
setUserAccess(
app.db,
request.params.id,
parsed.data.apps,
permissions,
)
const mapped = mapUser(app.db, getUserById(app.db, request.params.id)!)
safeAudit(app, {
action: 'user.access_update',
severity: 'info',
...actorFromRequest(request),
targetType: 'user',
targetId: mapped.id,
summary: `Обновлены права: ${mapped.email}`,
details: {
apps: mapped.apps,
permissions_count: mapped.permissions.length,
},
ip: clientIp(request),
})
return mapped
},
)
app.get('/api/v1/admin/app-switcher', async () =>
getAppSwitcherConfig(app.db),
)
app.put('/api/v1/admin/app-switcher', async (request, reply) => {
const parsed = appSwitcherConfigSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const result = setAppSwitcherConfig(app.db, parsed.data)
safeAudit(app, {
action: 'app_switcher.update',
severity: 'info',
...actorFromRequest(request),
targetType: 'settings',
targetId: 'app_switcher',
summary: 'Обновлены ссылки App Switcher',
details: {
menuLabel: result.menuLabel,
apps: result.apps.map((a) => a.id),
},
ip: clientIp(request),
})
return result
})
app.get('/api/v1/admin/sessions', async (request) => {
const userId = (request.query as { user_id?: string }).user_id
return listActiveSessions(app.db, { userId }).map((s) => ({
id: s.id,
user_id: s.userId,
email: s.email,
name: s.name,
ip: s.ip,
user_agent: s.userAgent,
created_at: s.createdAt,
expires_at: s.expiresAt,
}))
})
app.delete<{ Params: { id: string } }>(
'/api/v1/admin/sessions/:id',
async (request, reply) => {
const ok = revokeSessionById(app.db, request.params.id)
if (!ok) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Сессия не найдена' },
})
}
safeAudit(app, {
action: 'auth.logout',
severity: 'warning',
...actorFromRequest(request),
targetType: 'session',
targetId: request.params.id,
summary: `Админ отозвал сессию ${request.params.id.slice(0, 8)}`,
details: { source: 'admin_revoke' },
ip: clientIp(request),
})
return { ok: true }
},
)
app.post<{ Params: { id: string } }>(
'/api/v1/admin/users/:id/sessions/revoke-all',
async (request, reply) => {
const user = getUserById(app.db, request.params.id)
if (!user) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
})
}
const revoked = revokeAllSessionsForUser(app.db, user.id)
safeAudit(app, {
action: 'auth.logout',
severity: 'warning',
...actorFromRequest(request),
targetType: 'user',
targetId: user.id,
summary: `Отозваны все сессии: ${user.email} (${revoked})`,
details: { revoked, source: 'admin_revoke_all' },
ip: clientIp(request),
})
return { revoked }
},
)
app.get('/api/v1/admin/oidc/meta', async () => {
const issuer = oidcIssuerFromConfig(app.config)
return {
issuer,
discovery_url: `${issuer}/.well-known/openid-configuration`,
jwks_url: `${issuer}/.well-known/jwks.json`,
scopes: [...OIDC_SCOPES],
}
})
app.get('/api/v1/admin/oidc/clients', async () =>
listOidcClients(app.db).map(mapOidcClient),
)
app.post('/api/v1/admin/oidc/clients', async (request, reply) => {
const parsed = createOidcClientRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const secret = generateOidcClientSecret()
const secretHash = hashClientSecret(secret)
const row = createOidcClient(app.db, {
name: parsed.data.name,
clientSecretHash: secretHash,
redirectUris: parsed.data.redirect_uris,
scopes: parsed.data.scopes,
enabled: parsed.data.enabled,
})
const mapped = mapOidcClient(row)
safeAudit(app, {
action: 'app_switcher.update',
severity: 'info',
...actorFromRequest(request),
targetType: 'settings',
targetId: row.id,
summary: `OIDC client создан: ${row.name}`,
details: { client_id: row.clientId },
ip: clientIp(request),
})
return { ...mapped, client_secret: secret }
})
app.get<{ Params: { id: string } }>(
'/api/v1/admin/oidc/clients/:id',
async (request, reply) => {
const row = getOidcClientById(app.db, request.params.id)
if (!row) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Клиент не найден' },
})
}
return mapOidcClient(row)
},
)
app.patch<{ Params: { id: string } }>(
'/api/v1/admin/oidc/clients/:id',
async (request, reply) => {
const parsed = patchOidcClientRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const row = updateOidcClient(app.db, request.params.id, {
name: parsed.data.name,
redirectUris: parsed.data.redirect_uris,
scopes: parsed.data.scopes,
enabled: parsed.data.enabled,
})
if (!row) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Клиент не найден' },
})
}
return mapOidcClient(row)
},
)
app.delete<{ Params: { id: string } }>(
'/api/v1/admin/oidc/clients/:id',
async (request, reply) => {
const ok = deleteOidcClient(app.db, request.params.id)
if (!ok) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Клиент не найден' },
})
}
return { ok: true }
},
)
app.post<{ Params: { id: string } }>(
'/api/v1/admin/oidc/clients/:id/rotate-secret',
async (request, reply) => {
const existing = getOidcClientById(app.db, request.params.id)
if (!existing) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Клиент не найден' },
})
}
const secret = generateOidcClientSecret()
const secretHash = hashClientSecret(secret)
const row = updateOidcClient(app.db, request.params.id, {
clientSecretHash: secretHash,
})!
return { ...mapOidcClient(row), client_secret: secret }
},
)
}
+83
View File
@@ -0,0 +1,83 @@
import type { FastifyInstance } from 'fastify'
import {
getAuditRetentionDays,
listAudit,
purgeAuditOlderThan,
setAuditRetentionDays,
} from '@authportal/db'
import {
auditListQuerySchema,
putAuditSettingsSchema,
} from '@authportal/shared'
import { actorFromRequest, clientIp, safeAudit } from '../lib/audit.js'
import { requireAdmin } from '../plugins/auth-guards.js'
export async function auditAdminRoutes(app: FastifyInstance): Promise<void> {
app.addHook('onRequest', async (request, reply) => {
if (!request.url.startsWith('/api/v1/admin/audit')) return
await requireAdmin(request, reply)
})
app.get('/api/v1/admin/audit', async (request, reply) => {
const parsed = auditListQuerySchema.safeParse(request.query)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные параметры' },
})
}
const q = parsed.data
return listAudit(app.db, {
action: q.action,
severity: q.severity,
userId: q.user_id,
actorEmail: q.actor_email,
sourceApp: q.source_app,
kind: q.kind,
limit: q.limit,
})
})
app.get('/api/v1/admin/audit/settings', async () => ({
retention_days: getAuditRetentionDays(app.db),
}))
app.put('/api/v1/admin/audit/settings', async (request, reply) => {
const parsed = putAuditSettingsSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: {
code: 'VALIDATION_ERROR',
message: 'retention_days: от 7 до 3650',
},
})
}
const prev = getAuditRetentionDays(app.db)
const days = setAuditRetentionDays(app.db, parsed.data.retention_days)
safeAudit(app, {
action: 'audit.settings_update',
severity: 'info',
...actorFromRequest(request),
targetType: 'settings',
targetId: 'audit_retention',
summary: `Срок хранения журнала: ${prev} → ${days} дн.`,
details: { previous: prev, retention_days: days },
ip: clientIp(request),
})
return { retention_days: days }
})
app.post('/api/v1/admin/audit/purge', async (request) => {
const days = getAuditRetentionDays(app.db)
const deleted = purgeAuditOlderThan(app.db, days)
safeAudit(app, {
action: 'audit.purge',
severity: 'warning',
...actorFromRequest(request),
targetType: 'system',
summary: `Ручная очистка: удалено ${deleted} записей старше ${days} дн.`,
details: { deleted, retention_days: days, source: 'manual' },
ip: clientIp(request),
})
return { deleted, retention_days: days }
})
}
+260
View File
@@ -0,0 +1,260 @@
import type { FastifyInstance } from 'fastify'
import {
PERMISSION_CATALOG,
appsMetaFromSwitcher,
loginRequestSchema,
publicAppSwitcherConfig,
ssoAccessRequestSchema,
} from '@authportal/shared'
import {
getAppSwitcherConfig,
getUserByEmail,
getUserById,
searchDirectoryUsers,
revokeRefreshSession,
} from '@authportal/db'
import { verifyDummyPassword, verifyPassword } from '../lib/password.js'
import { parseCookie } from '../lib/cookies.js'
import { requireAuth } from '../plugins/auth-guards.js'
import { issueAccessToken } from '../lib/issue-access-token.js'
import { completeLogin, REFRESH_COOKIE } from '../lib/complete-login.js'
import { clientIp, safeAudit } from '../lib/audit.js'
import { clientUserAgent, targetAppFromReturnTo } from '../lib/target-app.js'
export async function authRoutes(app: FastifyInstance): Promise<void> {
/** Public — SPA reads allowlist at runtime (Docker-friendly). */
app.get('/api/v1/auth/config', async (_request, reply) => {
reply.header('Cache-Control', 'public, max-age=60')
return {
return_to_allowlist: app.config.returnToAllowlist,
issuer: app.config.issuer,
webauthn: true,
}
})
app.post('/api/v1/auth/login', {
config: { rateLimit: { max: 20, timeWindow: '1 minute' } },
handler: async (request, reply) => {
const parsed = loginRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const { email, password, return_to: returnTo } = parsed.data
const ip = clientIp(request)
const userAgent = clientUserAgent(request.headers)
const targetApp = targetAppFromReturnTo(returnTo)
const user = getUserByEmail(app.db, email)
if (!user || user.disabled) {
await verifyDummyPassword(password)
safeAudit(app, {
action: 'auth.login_failed',
severity: 'warning',
actorEmail: email.toLowerCase(),
targetType: 'session',
summary: `Неудачный вход: ${email}`,
details: {
reason: !user ? 'unknown_user' : 'disabled',
user_agent: userAgent,
return_to: returnTo ?? null,
target_app: targetApp,
},
ip,
})
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Неверный email или пароль' },
})
}
const ok = await verifyPassword(user.passwordHash, password)
if (!ok) {
safeAudit(app, {
action: 'auth.login_failed',
severity: 'warning',
actorUserId: user.id,
actorEmail: user.email,
actorName: user.name,
targetType: 'session',
targetId: user.id,
summary: `Неудачный вход: ${user.email}`,
details: {
reason: 'bad_password',
user_agent: userAgent,
return_to: returnTo ?? null,
target_app: targetApp,
},
ip,
})
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Неверный email или пароль' },
})
}
return completeLogin(app, reply, user, {
method: 'password',
returnTo,
ip,
userAgent,
})
},
})
/**
* Re-mint access JWT from DB (apps/permissions/is_admin/tenants).
* Fixes stale localStorage JWT after role changes or account switch.
*/
app.post(
'/api/v1/auth/reissue',
{
onRequest: requireAuth,
config: { rateLimit: { max: 60, timeWindow: '1 minute' } },
},
async (request, reply) => {
const auth = request.authUser!
const user = getUserById(app.db, auth.id)
if (!user || user.disabled) {
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
})
}
return issueAccessToken(app, user)
},
)
/** Record SSO handoff to a connected app (already authenticated). */
app.post(
'/api/v1/auth/sso-access',
{
onRequest: requireAuth,
config: { rateLimit: { max: 60, timeWindow: '1 minute' } },
},
async (request, reply) => {
const parsed = ssoAccessRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const auth = request.authUser!
const user = getUserById(app.db, auth.id)
if (!user || user.disabled) {
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
})
}
const returnTo = parsed.data.return_to
const targetApp = targetAppFromReturnTo(returnTo)
const ip = clientIp(request)
const userAgent = clientUserAgent(request.headers)
safeAudit(app, {
action: 'auth.sso_handoff',
severity: 'info',
actorUserId: user.id,
actorEmail: user.email,
actorName: user.name,
targetType: 'session',
targetId: user.id,
summary: `SSO: ${user.email} → ${targetApp}`,
details: {
return_to: returnTo,
target_app: targetApp,
user_agent: userAgent,
},
ip,
})
return { ok: true, target_app: targetApp }
},
)
app.post(
'/api/v1/auth/logout',
{ config: { rateLimit: { max: 60, timeWindow: '1 minute' } } },
async (request, reply) => {
const refresh = parseCookie(request.headers.cookie, REFRESH_COOKIE)
if (refresh) {
revokeRefreshSession(app.db, refresh)
}
reply.header(
'Set-Cookie',
`${REFRESH_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`,
)
// Best-effort actor from JWT if present
let actorUserId: string | null = null
let actorEmail: string | null = null
let actorName: string | null = null
try {
await request.jwtVerify()
const sub = request.user.sub
const row = getUserById(app.db, sub)
if (row) {
actorUserId = row.id
actorEmail = row.email
actorName = row.name
}
} catch {
/* anonymous logout */
}
safeAudit(app, {
action: 'auth.logout',
severity: 'info',
actorUserId,
actorEmail,
actorName,
targetType: 'session',
targetId: actorUserId,
summary: actorEmail ? `Выход: ${actorEmail}` : 'Выход',
details: { user_agent: clientUserAgent(request.headers) },
ip: clientIp(request),
})
return { ok: true }
},
)
/** Public — apps chrome (CFDM/VPS) fetch switcher URLs without portal JWT. */
// Без Cache-Control: после сохранения в админке SPA должна сразу видеть новый конфиг.
app.get('/api/v1/app-switcher', async () =>
publicAppSwitcherConfig(getAppSwitcherConfig(app.db)),
)
app.get(
'/api/v1/auth/me',
{ onRequest: requireAuth },
async (request) => {
const auth = request.authUser!
return {
id: auth.id,
email: auth.email,
name: auth.name,
is_admin: auth.isAdmin,
apps: auth.apps,
permissions: auth.permissions,
}
},
)
app.get(
'/api/v1/directory/users',
{ onRequest: requireAuth },
async (request) => {
const q = String(
(request.query as { q?: string }).q ?? '',
)
.trim()
.toLowerCase()
return searchDirectoryUsers(app.db, q, 50)
},
)
app.get('/api/v1/catalog', { onRequest: requireAuth }, async () => {
const switcher = getAppSwitcherConfig(app.db)
return {
apps: appsMetaFromSwitcher(switcher),
permissions: PERMISSION_CATALOG,
}
})
}
+73
View File
@@ -0,0 +1,73 @@
import type { FastifyInstance } from 'fastify'
import { appendAuditBatch } from '@authportal/db'
import {
ingestAuditRequestSchema,
type AuditSourceApp,
} from '@authportal/shared'
import { timingSafeEqual } from 'node:crypto'
function secretsEqual(a: string, b: string): boolean {
const ba = Buffer.from(a)
const bb = Buffer.from(b)
if (ba.length !== bb.length) return false
return timingSafeEqual(ba, bb)
}
export async function auditIngestRoutes(app: FastifyInstance): Promise<void> {
app.post(
'/api/v1/ingest/audit',
{
config: { rateLimit: { max: 120, timeWindow: '1 minute' } },
},
async (request, reply) => {
const secret = app.config.auditIngestSecret
if (!secret) {
return reply.status(503).send({
error: {
code: 'UNAVAILABLE',
message: 'Audit ingest не настроен',
},
})
}
const auth = request.headers.authorization ?? ''
const token = auth.startsWith('Bearer ') ? auth.slice(7).trim() : ''
if (!token || !secretsEqual(token, secret)) {
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Неверный ingest secret' },
})
}
const parsed = ingestAuditRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const { accepted, duplicates, failed } = appendAuditBatch(
app.db,
parsed.data.events.map((event) => ({
eventId: event.event_id,
sourceApp: event.source_app as AuditSourceApp,
action: event.action,
severity: event.severity,
actorUserId: event.actor_user_id,
actorEmail: event.actor_email,
actorName: event.actor_name,
targetType: event.target_type,
targetId: event.target_id,
summary: event.summary,
details: event.details,
ip: event.ip,
createdAt: event.created_at,
})),
)
if (failed > 0) {
app.log.warn({ failed }, 'ingest append failed for some events')
}
return { accepted, duplicates }
},
)
}
+466
View File
@@ -0,0 +1,466 @@
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
import {
updateOidcClient,
createOidcAuthCode,
consumeOidcAuthCode,
getOidcClientByClientId,
getUserById,
getUserByRefreshToken,
getUserApps,
getUserPermissions,
parseJsonStringArray,
type OidcClientRow,
type UserRow,
} from '@authportal/db'
import {
oidcGroupsForUser,
normalizePermissionKeys,
} from '@authportal/shared'
import { oidcIssuerFromConfig } from '../config.js'
import { clientIp, safeAudit } from '../lib/audit.js'
import { checkClientSecret, hashClientSecret } from '../lib/client-secret.js'
import { parseCookie } from '../lib/cookies.js'
import {
buildJwks,
buildOidcDiscovery,
ensureOidcSigningKey,
signOidcJwt,
verifyOidcAccessToken,
} from '../lib/oidc/keys.js'
import { clientUserAgent, targetAppFromReturnTo } from '../lib/target-app.js'
const REFRESH_COOKIE = 'refresh_token'
const CODE_TTL_MS = 5 * 60 * 1000
const ACCESS_TTL_SEC = 3600
const ID_TOKEN_TTL_SEC = 3600
function parseBasicAuth(
header: string | undefined,
): { clientId: string; clientSecret: string } | null {
if (!header?.startsWith('Basic ')) return null
try {
const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8')
const idx = decoded.indexOf(':')
if (idx < 0) return null
return {
clientId: decoded.slice(0, idx),
clientSecret: decoded.slice(idx + 1),
}
} catch {
return null
}
}
async function authenticateClient(
app: FastifyInstance,
client: OidcClientRow,
secret: string,
): Promise<boolean> {
const res = await checkClientSecret(client.clientSecretHash, secret)
if (res.ok && res.needsRehash) {
// Миграция argon2 → SHA-256 при первом успешном обмене.
try {
updateOidcClient(app.db, client.id, {
clientSecretHash: hashClientSecret(secret),
})
} catch (err) {
app.log.warn({ err }, 'client secret rehash failed')
}
}
return res.ok
}
function redirectError(
reply: FastifyReply,
redirectUri: string | undefined,
error: string,
description: string,
state?: string | null,
) {
if (!redirectUri) {
return reply.status(400).send({
error,
error_description: description,
})
}
const url = new URL(redirectUri)
url.searchParams.set('error', error)
url.searchParams.set('error_description', description)
if (state) url.searchParams.set('state', state)
return reply.redirect(url.toString())
}
function resolveSessionUser(
app: FastifyInstance,
request: FastifyRequest,
): UserRow | undefined {
const raw = parseCookie(request.headers.cookie, REFRESH_COOKIE)
if (!raw) return undefined
return getUserByRefreshToken(app.db, raw)
}
function buildUserInfoClaims(
app: FastifyInstance,
user: UserRow,
scope: string,
) {
const scopes = new Set(scope.split(/\s+/).filter(Boolean))
const permissions = normalizePermissionKeys(
getUserPermissions(app.db, user.id),
)
const groups = oidcGroupsForUser({
isAdmin: user.isAdmin,
permissions,
})
const claims: Record<string, unknown> = {
sub: user.id,
}
if (scopes.has('email') || scopes.has('profile') || scopes.has('openid')) {
claims.email = user.email
claims.email_verified = true
}
if (scopes.has('profile') || scopes.has('openid')) {
claims.name = user.name
claims.preferred_username = user.email
}
if (scopes.has('groups')) {
claims.groups = groups
claims.roles = groups
}
return claims
}
function safeEqualStr(a: string, b: string): boolean {
const ba = Buffer.from(a)
const bb = Buffer.from(b)
if (ba.length !== bb.length) return false
return timingSafeEqual(ba, bb)
}
function verifyPkce(
codeVerifier: string | undefined,
challenge: string | null,
method: string | null,
): boolean {
if (!challenge) return true
if (!codeVerifier) return false
if (method === 'plain' || !method) {
return safeEqualStr(codeVerifier, challenge)
}
if (method === 'S256') {
const hash = createHash('sha256')
.update(codeVerifier)
.digest('base64url')
return hash === challenge
}
return false
}
export async function oidcRoutes(app: FastifyInstance): Promise<void> {
await ensureOidcSigningKey(app)
app.get('/.well-known/openid-configuration', async (_request, reply) => {
// Статичный документ — RP не должен перечитывать его на каждый запрос.
reply.header('Cache-Control', 'public, max-age=300')
return buildOidcDiscovery(app.config)
})
app.get('/.well-known/jwks.json', async (_request, reply) => {
reply.header('Cache-Control', 'public, max-age=300')
return buildJwks(app)
})
app.get('/oauth/authorize', {
config: { rateLimit: { max: 120, timeWindow: '1 minute' } },
}, async (request, reply) => {
const q = request.query as Record<string, string | undefined>
const clientId = q.client_id
const redirectUri = q.redirect_uri
const responseType = q.response_type
const scope = q.scope ?? 'openid'
const state = q.state
const nonce = q.nonce
const codeChallenge = q.code_challenge
const codeChallengeMethod = q.code_challenge_method
if (!clientId || !redirectUri) {
return reply.status(400).send({
error: 'invalid_request',
error_description: 'client_id and redirect_uri are required',
})
}
if (responseType !== 'code') {
return redirectError(
reply,
redirectUri,
'unsupported_response_type',
'Only response_type=code is supported',
state,
)
}
const client = getOidcClientByClientId(app.db, clientId)
if (!client || !client.enabled) {
return reply.status(400).send({
error: 'invalid_client',
error_description: 'Unknown or disabled client',
})
}
const allowedRedirects = parseJsonStringArray(client.redirectUrisJson)
if (!allowedRedirects.includes(redirectUri)) {
return reply.status(400).send({
error: 'invalid_request',
error_description: 'redirect_uri is not registered',
})
}
const requestedScopes = scope.split(/\s+/).filter(Boolean)
const clientScopes = new Set(parseJsonStringArray(client.scopesJson))
if (!requestedScopes.includes('openid')) {
return redirectError(
reply,
redirectUri,
'invalid_scope',
'openid scope is required',
state,
)
}
for (const s of requestedScopes) {
if (!clientScopes.has(s)) {
return redirectError(
reply,
redirectUri,
'invalid_scope',
`Scope not allowed: ${s}`,
state,
)
}
}
const user = resolveSessionUser(app, request)
if (!user) {
const issuer = oidcIssuerFromConfig(app.config)
const authorizeUrl = new URL(`${issuer}/oauth/authorize`)
for (const [k, v] of Object.entries(q)) {
if (v != null) authorizeUrl.searchParams.set(k, v)
}
const login = new URL(`${issuer}/`)
login.searchParams.set('return_to', authorizeUrl.toString())
return reply.redirect(login.toString())
}
const apps = getUserApps(app.db, user.id)
// Soft check: prefer dns app assignment for Technitium-like clients
if (!user.isAdmin && apps.length === 0) {
return redirectError(
reply,
redirectUri,
'access_denied',
'User has no application access',
state,
)
}
const rawCode = randomBytes(32).toString('base64url')
createOidcAuthCode(app.db, {
rawCode,
clientId: client.clientId,
userId: user.id,
redirectUri,
scope: requestedScopes.join(' '),
nonce: nonce ?? null,
codeChallenge: codeChallenge ?? null,
codeChallengeMethod: codeChallengeMethod ?? null,
expiresAt: new Date(Date.now() + CODE_TTL_MS),
})
const targetApp = targetAppFromReturnTo(redirectUri)
safeAudit(app, {
action: 'auth.sso_handoff',
severity: 'info',
actorUserId: user.id,
actorEmail: user.email,
actorName: user.name,
targetType: 'session',
targetId: user.id,
summary: `SSO OIDC: ${user.email} → ${targetApp}`,
details: {
return_to: redirectUri,
target_app: targetApp,
user_agent: clientUserAgent(request.headers),
oidc_client_id: client.clientId,
oidc_client_name: client.name,
auth_mode: 'oidc',
},
ip: clientIp(request),
})
const dest = new URL(redirectUri)
dest.searchParams.set('code', rawCode)
if (state) dest.searchParams.set('state', state)
return reply.redirect(dest.toString())
})
app.post('/oauth/token', {
config: { rateLimit: { max: 60, timeWindow: '1 minute' } },
handler: async (request, reply) => {
const raw = request.body
const body: Record<string, string | undefined> =
typeof raw === 'string'
? Object.fromEntries(new URLSearchParams(raw))
: raw && typeof raw === 'object'
? (raw as Record<string, string | undefined>)
: {}
const basic = parseBasicAuth(request.headers.authorization)
const clientId = body.client_id ?? basic?.clientId
const clientSecret = body.client_secret ?? basic?.clientSecret
const grantType = body.grant_type
const code = body.code
const redirectUri = body.redirect_uri
const codeVerifier = body.code_verifier
if (grantType !== 'authorization_code') {
return reply.status(400).send({
error: 'unsupported_grant_type',
error_description: 'Only authorization_code is supported',
})
}
if (!clientId || !clientSecret || !code || !redirectUri) {
return reply.status(400).send({
error: 'invalid_request',
error_description:
'client_id, client_secret, code, and redirect_uri are required',
})
}
const client = getOidcClientByClientId(app.db, clientId)
if (!client || !client.enabled) {
return reply.status(401).send({
error: 'invalid_client',
error_description: 'Unknown or disabled client',
})
}
const secretOk = await authenticateClient(app, client, clientSecret)
if (!secretOk) {
return reply.status(401).send({
error: 'invalid_client',
error_description: 'Invalid client credentials',
})
}
const authCode = consumeOidcAuthCode(app.db, code)
if (!authCode) {
return reply.status(400).send({
error: 'invalid_grant',
error_description: 'Invalid or expired authorization code',
})
}
if (authCode.clientId !== client.clientId) {
return reply.status(400).send({
error: 'invalid_grant',
error_description: 'Code was not issued to this client',
})
}
if (authCode.redirectUri !== redirectUri) {
return reply.status(400).send({
error: 'invalid_grant',
error_description: 'redirect_uri mismatch',
})
}
if (
!verifyPkce(
codeVerifier,
authCode.codeChallenge,
authCode.codeChallengeMethod,
)
) {
return reply.status(400).send({
error: 'invalid_grant',
error_description: 'PKCE verification failed',
})
}
const user = getUserById(app.db, authCode.userId)
if (!user || user.disabled) {
return reply.status(400).send({
error: 'invalid_grant',
error_description: 'User unavailable',
})
}
const info = buildUserInfoClaims(app, user, authCode.scope)
const idClaims: Record<string, unknown> = {
...info,
aud: client.clientId,
}
if (authCode.nonce) idClaims.nonce = authCode.nonce
const accessClaims: Record<string, unknown> = {
sub: user.id,
aud: client.clientId,
scope: authCode.scope,
token_use: 'access',
}
if (info.groups) {
accessClaims.groups = info.groups
accessClaims.roles = info.roles
}
const [idToken, accessToken] = await Promise.all([
signOidcJwt(app, idClaims, ID_TOKEN_TTL_SEC),
signOidcJwt(app, accessClaims, ACCESS_TTL_SEC),
])
return {
access_token: accessToken,
token_type: 'Bearer',
expires_in: ACCESS_TTL_SEC,
id_token: idToken,
scope: authCode.scope,
}
},
})
app.get('/oauth/userinfo', {
config: { rateLimit: { max: 120, timeWindow: '1 minute' } },
}, async (request, reply) => {
const auth = request.headers.authorization
if (!auth?.startsWith('Bearer ')) {
return reply.status(401).send({
error: 'invalid_token',
error_description: 'Bearer token required',
})
}
const token = auth.slice(7)
let payload: Record<string, unknown>
try {
payload = await verifyOidcAccessToken(app, token)
} catch {
return reply.status(401).send({
error: 'invalid_token',
error_description: 'Token invalid or expired',
})
}
const sub = typeof payload.sub === 'string' ? payload.sub : null
if (!sub) {
return reply.status(401).send({
error: 'invalid_token',
error_description: 'Missing sub',
})
}
const user = getUserById(app.db, sub)
if (!user || user.disabled) {
return reply.status(401).send({
error: 'invalid_token',
error_description: 'User unavailable',
})
}
const scope =
typeof payload.scope === 'string'
? payload.scope
: 'openid profile email groups'
return buildUserInfoClaims(app, user, scope)
})
}
+388
View File
@@ -0,0 +1,388 @@
import type { FastifyInstance } from 'fastify'
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
type AuthenticationResponseJSON,
type RegistrationResponseJSON,
} from '@simplewebauthn/server'
import {
consumeWebauthnChallenge,
createWebauthnChallenge,
createWebauthnCredential,
deleteWebauthnCredential,
getUserById,
getWebauthnCredentialByCredentialId,
getWebauthnCredentialById,
listWebauthnCredentials,
parseTransportsJson,
updateWebauthnCredentialName,
touchWebauthnCredential,
type WebauthnCredentialRow,
} from '@authportal/db'
import {
patchPasskeyRequestSchema,
webauthnVerifyRequestSchema,
type PasskeyCredential,
} from '@authportal/shared'
import { requireAuth } from '../plugins/auth-guards.js'
import { completeLogin } from '../lib/complete-login.js'
import { clientIp, safeAudit } from '../lib/audit.js'
import { clientUserAgent, targetAppFromReturnTo } from '../lib/target-app.js'
import {
asTransports,
decodePublicKey,
encodePublicKey,
userIdToBytes,
webauthnRelyingParty,
} from '../lib/webauthn.js'
const LOGIN_RATE = { max: 20, timeWindow: '1 minute' } as const
function toPasskeyDto(row: WebauthnCredentialRow): PasskeyCredential {
return {
id: row.id,
name: row.name,
created_at: row.createdAt,
last_used_at: row.lastUsedAt ?? null,
device_type: row.deviceType ?? null,
}
}
function defaultPasskeyName(userAgent: string | null): string {
const date = new Date().toLocaleDateString('ru-RU')
if (!userAgent) return `Passkey ${date}`
if (/iPhone|iPad|Macintosh/i.test(userAgent)) return `Apple ${date}`
if (/Windows/i.test(userAgent)) return `Windows Hello ${date}`
if (/Android/i.test(userAgent)) return `Android ${date}`
return `Passkey ${date}`
}
function asRegistrationResponse(
raw: unknown,
): RegistrationResponseJSON | null {
if (!raw || typeof raw !== 'object') return null
if (!('id' in raw) || typeof (raw as { id: unknown }).id !== 'string') {
return null
}
return raw as RegistrationResponseJSON
}
function asAuthenticationResponse(
raw: unknown,
): AuthenticationResponseJSON | null {
if (!raw || typeof raw !== 'object') return null
if (!('id' in raw) || typeof (raw as { id: unknown }).id !== 'string') {
return null
}
return raw as AuthenticationResponseJSON
}
export async function webauthnRoutes(app: FastifyInstance): Promise<void> {
app.post(
'/api/v1/webauthn/register/options',
{
onRequest: requireAuth,
config: { rateLimit: LOGIN_RATE },
},
async (request, reply) => {
const auth = request.authUser!
const user = getUserById(app.db, auth.id)
if (!user || user.disabled) {
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
})
}
const rp = webauthnRelyingParty(app)
const existing = listWebauthnCredentials(app.db, user.id)
const options = await generateRegistrationOptions({
rpName: rp.rpName,
rpID: rp.rpID,
userName: user.email,
userDisplayName: user.name,
userID: userIdToBytes(user.id),
attestationType: 'none',
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
excludeCredentials: existing.map((cred) => ({
id: cred.credentialId,
transports: asTransports(parseTransportsJson(cred.transportsJson)),
})),
})
const row = createWebauthnChallenge(app.db, {
purpose: 'register',
challenge: options.challenge,
userId: user.id,
})
return { challenge_id: row.id, options }
},
)
app.post(
'/api/v1/webauthn/register',
{
onRequest: requireAuth,
config: { rateLimit: LOGIN_RATE },
},
async (request, reply) => {
const parsed = webauthnVerifyRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const auth = request.authUser!
const user = getUserById(app.db, auth.id)
if (!user || user.disabled) {
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
})
}
const response = asRegistrationResponse(parsed.data.response)
if (!response) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректный ответ passkey' },
})
}
const challenge = consumeWebauthnChallenge(
app.db,
parsed.data.challenge_id,
'register',
user.id,
)
if (!challenge) {
return reply.status(400).send({
error: {
code: 'VALIDATION_ERROR',
message: 'Срок действия challenge истёк, повторите регистрацию',
},
})
}
const rp = webauthnRelyingParty(app)
let verification
try {
verification = await verifyRegistrationResponse({
response,
expectedChallenge: challenge.challenge,
expectedOrigin: rp.origins,
expectedRPID: rp.rpID,
requireUserVerification: true,
})
} catch (err) {
app.log.warn({ err }, 'webauthn register verify failed')
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Не удалось проверить passkey' },
})
}
if (!verification.verified || !verification.registrationInfo) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Passkey не подтверждён' },
})
}
const info = verification.registrationInfo
const duplicate = getWebauthnCredentialByCredentialId(
app.db,
info.credential.id,
)
if (duplicate) {
return reply.status(409).send({
error: { code: 'CONFLICT', message: 'Этот passkey уже зарегистрирован' },
})
}
const name =
parsed.data.name?.trim() ||
defaultPasskeyName(clientUserAgent(request.headers))
const row = createWebauthnCredential(app.db, {
userId: user.id,
credentialId: info.credential.id,
publicKey: encodePublicKey(info.credential.publicKey),
counter: info.credential.counter,
deviceType: info.credentialDeviceType,
backedUp: info.credentialBackedUp,
transports: info.credential.transports,
name,
})
safeAudit(app, {
action: 'auth.passkey_register',
severity: 'info',
actorUserId: user.id,
actorEmail: user.email,
actorName: user.name,
targetType: 'credential',
targetId: row.id,
summary: `Passkey добавлен: ${user.email}`,
details: { name: row.name, device_type: row.deviceType },
ip: clientIp(request),
})
return toPasskeyDto(row)
},
)
app.get(
'/api/v1/webauthn/credentials',
{ onRequest: requireAuth },
async (request) => {
const auth = request.authUser!
return listWebauthnCredentials(app.db, auth.id).map(toPasskeyDto)
},
)
app.patch<{ Params: { id: string } }>(
'/api/v1/webauthn/credentials/:id',
{ onRequest: requireAuth },
async (request, reply) => {
const parsed = patchPasskeyRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const auth = request.authUser!
const row = updateWebauthnCredentialName(
app.db,
request.params.id,
auth.id,
parsed.data.name.trim(),
)
if (!row) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Passkey не найден' },
})
}
return toPasskeyDto(row)
},
)
app.delete<{ Params: { id: string } }>(
'/api/v1/webauthn/credentials/:id',
{ onRequest: requireAuth },
async (request, reply) => {
const auth = request.authUser!
const existing = getWebauthnCredentialById(app.db, request.params.id)
if (!existing || existing.userId !== auth.id) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Passkey не найден' },
})
}
deleteWebauthnCredential(app.db, request.params.id, auth.id)
const user = getUserById(app.db, auth.id)
safeAudit(app, {
action: 'auth.passkey_delete',
severity: 'warning',
actorUserId: auth.id,
actorEmail: auth.email,
actorName: auth.name,
targetType: 'credential',
targetId: request.params.id,
summary: `Passkey удалён: ${user?.email ?? auth.email}`,
details: { name: existing.name },
ip: clientIp(request),
})
return { ok: true }
},
)
app.post(
'/api/v1/webauthn/login/options',
{ config: { rateLimit: LOGIN_RATE } },
async () => {
const rp = webauthnRelyingParty(app)
const options = await generateAuthenticationOptions({
rpID: rp.rpID,
userVerification: 'preferred',
})
const row = createWebauthnChallenge(app.db, {
purpose: 'authenticate',
challenge: options.challenge,
})
return { challenge_id: row.id, options }
},
)
app.post(
'/api/v1/webauthn/login',
{ config: { rateLimit: LOGIN_RATE } },
async (request, reply) => {
const parsed = webauthnVerifyRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const ip = clientIp(request)
const userAgent = clientUserAgent(request.headers)
const returnTo = parsed.data.return_to
const fail = (reason: string) => {
safeAudit(app, {
action: 'auth.passkey_login_failed',
severity: 'warning',
targetType: 'session',
summary: 'Неудачный вход по passkey',
details: {
reason,
user_agent: userAgent,
return_to: returnTo ?? null,
target_app: targetAppFromReturnTo(returnTo),
},
ip,
})
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Не удалось войти с passkey' },
})
}
const response = asAuthenticationResponse(parsed.data.response)
if (!response) return fail('bad_response')
const challenge = consumeWebauthnChallenge(
app.db,
parsed.data.challenge_id,
'authenticate',
)
if (!challenge) return fail('expired_challenge')
const cred = getWebauthnCredentialByCredentialId(app.db, response.id)
if (!cred) return fail('unknown_credential')
const user = getUserById(app.db, cred.userId)
if (!user || user.disabled) return fail(user ? 'disabled' : 'unknown_user')
const rp = webauthnRelyingParty(app)
let verification
try {
verification = await verifyAuthenticationResponse({
response,
expectedChallenge: challenge.challenge,
expectedOrigin: rp.origins,
expectedRPID: rp.rpID,
requireUserVerification: true,
credential: {
id: cred.credentialId,
publicKey: decodePublicKey(cred.publicKey),
counter: cred.counter,
transports: asTransports(parseTransportsJson(cred.transportsJson)),
},
})
} catch (err) {
app.log.warn({ err }, 'webauthn login verify failed')
return fail('verify_error')
}
if (!verification.verified) return fail('not_verified')
touchWebauthnCredential(
app.db,
cred.id,
verification.authenticationInfo.newCounter,
)
return completeLogin(app, reply, user, {
method: 'passkey',
returnTo,
ip,
userAgent,
})
},
)
}
+61
View File
@@ -0,0 +1,61 @@
import { readFileSync, existsSync } from 'node:fs'
import { resolve } from 'node:path'
import { buildApp } from './app.js'
import { loadConfig } from './config.js'
import { registerShutdown } from './lib/shutdown.js'
for (const path of [
resolve(import.meta.dirname, '../../../.env'),
'.env',
'../.env',
]) {
if (!existsSync(path)) continue
const content = readFileSync(path, 'utf-8')
for (const line of content.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const eq = trimmed.indexOf('=')
if (eq === -1) continue
const key = trimmed.slice(0, eq).trim()
let value = trimmed.slice(eq + 1).trim()
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
if (!(key in process.env)) process.env[key] = value
}
break
}
const config = loadConfig()
// `node dist/server.js backup` — разовый онлайн-бэкап без запуска сервера
// (для cron / `docker exec auth-portal node dist/server.js backup`).
if (process.argv[2] === 'backup') {
const { createDb, closeDb } = await import('@authportal/db')
const { runBackup } = await import('./services/backup.js')
const { sqlite } = createDb(config.databaseUrl)
try {
const res = await runBackup(sqlite, config)
console.log(`backup ok: ${res.file} (removed ${res.removed.length} old)`)
closeDb(sqlite)
process.exit(0)
} catch (err) {
console.error('backup failed:', err)
process.exit(1)
}
}
const app = await buildApp({ config })
registerShutdown(app, { timeoutMs: config.shutdownTimeoutMs })
try {
await app.listen({ port: config.serverPort, host: '0.0.0.0' })
app.log.info(`auth-portal listening on ${config.serverPort}`)
} catch (err) {
app.log.error(err)
process.exit(1)
}
+41
View File
@@ -0,0 +1,41 @@
import type { FastifyInstance } from 'fastify'
import {
getAuditRetentionDays,
purgeAuditOlderThan,
} from '@authportal/db'
import { safeAudit } from '../lib/audit.js'
const HOUR_MS = 60 * 60 * 1000
export function startAuditRetentionJob(app: FastifyInstance): () => void {
const run = () => {
try {
const days = getAuditRetentionDays(app.db)
const deleted = purgeAuditOlderThan(app.db, days)
if (deleted > 0) {
safeAudit(app, {
action: 'audit.purge',
severity: 'info',
targetType: 'system',
summary: `Автоочистка: удалено ${deleted} записей старше ${days} дн.`,
details: { deleted, retention_days: days, source: 'scheduler' },
})
app.log.info(
{ deleted, retention_days: days },
'audit_log retention purge',
)
}
} catch (err) {
app.log.warn({ err }, 'audit_log retention job failed')
}
}
// Defer first run slightly so bootstrap finishes
const initial = setTimeout(run, 15_000)
const interval = setInterval(run, HOUR_MS)
return () => {
clearTimeout(initial)
clearInterval(interval)
}
}
+43
View File
@@ -0,0 +1,43 @@
import { dirname, join, resolve } from 'node:path'
import type { FastifyInstance } from 'fastify'
import { backupDatabase, resolveDatabasePath, type Sqlite } from '@authportal/db'
import type { AppConfig } from '../config.js'
export function backupDirFor(config: AppConfig, databaseUrl = config.databaseUrl) {
if (config.backupDir) return resolve(config.backupDir)
const path = resolveDatabasePath(databaseUrl)
return join(dirname(resolve(path)), 'backups')
}
export async function runBackup(
sqlite: Sqlite,
config: AppConfig,
databaseUrl = config.databaseUrl,
) {
return backupDatabase(sqlite, {
dir: backupDirFor(config, databaseUrl),
keep: config.backupKeep,
})
}
/** Периодический бэкап; выключен при BACKUP_INTERVAL_HOURS=0. */
export function startBackupJob(app: FastifyInstance): () => void {
const hours = app.config.backupIntervalHours
if (hours <= 0 || app.sqlite.memory) return () => {}
let running = false
const run = async () => {
if (running) return
running = true
try {
const res = await runBackup(app.sqlite, app.config)
app.log.info({ file: res.file, removed: res.removed.length }, 'db backup')
} catch (err) {
app.log.error({ err }, 'db backup failed')
} finally {
running = false
}
}
const interval = setInterval(() => void run(), hours * 60 * 60 * 1000)
interval.unref()
return () => clearInterval(interval)
}
+28
View File
@@ -0,0 +1,28 @@
import type { FastifyInstance } from 'fastify'
import { runHousekeeping } from '@authportal/db'
const HOUR_MS = 60 * 60 * 1000
/** Часовая очистка сессий, кодов авторизации и WebAuthn-челленджей. */
export function startHousekeepingJob(app: FastifyInstance): () => void {
const run = () => {
try {
const res = runHousekeeping(app.db, {
retentionDays: app.config.sessionRetentionDays,
})
if (res.sessions + res.authCodes + res.challenges > 0) {
app.log.info(res, 'housekeeping purge')
}
} catch (err) {
app.log.warn({ err }, 'housekeeping job failed')
}
}
const initial = setTimeout(run, 30_000)
const interval = setInterval(run, HOUR_MS)
initial.unref()
interval.unref()
return () => {
clearTimeout(initial)
clearInterval(interval)
}
}
@@ -0,0 +1,82 @@
import { createHmac } from 'node:crypto'
import https from 'node:https'
import type { FastifyInstance } from 'fastify'
import { completePortalRevocation, enablePortalRevocations, nextPortalRevocation } from '@authportal/db'
export function portalRevocationTarget(raw: string): URL {
if (raw.length > 512) throw new Error('PORTAL_REVOCATION_TARGET_INVALID')
let url: URL
try { url = new URL(raw) } catch { throw new Error('PORTAL_REVOCATION_TARGET_INVALID') }
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash
|| url.pathname !== '/_internal/mcp/portal/revocations' || url.href !== raw) throw new Error('PORTAL_REVOCATION_TARGET_INVALID')
return url
}
/** One fixed operator-selected HTTPS destination. Core https never follows redirects. */
export function sendPortalRevocation(target: URL, secret: string, body: string, signal: AbortSignal): Promise<boolean> {
if (Buffer.byteLength(body) > 4096) return Promise.resolve(false)
const timestamp = String(Math.floor(Date.now() / 1000))
const signature = 'sha256=' + createHmac('sha256', secret).update(timestamp + '.').update(body).digest('hex')
return new Promise(resolve => {
let settled = false
let outcome = false
const finish = (accepted: boolean) => {
if (!settled) { settled = true; outcome = accepted; clearTimeout(deadline); request.destroy() }
}
const request = https.request(target, { method: 'POST', agent: false, rejectUnauthorized: true, signal, headers: {
'content-type': 'application/json', 'content-length': Buffer.byteLength(body),
'x-mm-timestamp': timestamp, 'x-mm-signature': signature,
} }, response => {
const chunks: Buffer[] = []; let size = 0
response.on('data', (chunk: Buffer) => {
size += chunk.length
if (size > 4096) { response.destroy(); request.destroy(); finish(false) }
else chunks.push(chunk)
})
response.once('error', () => finish(false))
response.once('aborted', () => finish(false))
response.once('end', () => {
let accepted = false
try {
const value: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
accepted = response.statusCode === 200 && !!value && typeof value === 'object' && !Array.isArray(value)
&& Object.keys(value).sort().join(',') === 'duplicate,ok' && (value as {ok?: unknown}).ok === true
&& typeof (value as {duplicate?: unknown}).duplicate === 'boolean'
} catch { /* No raw transport/response/error material is logged. */ }
finish(accepted)
})
})
const deadline = setTimeout(() => { request.destroy(); finish(false) }, 3000)
request.once('error', () => finish(false))
request.once('close', () => { clearTimeout(deadline); resolve(outcome) })
request.end(body)
})
}
export function configurePortalRevocationSender(app: FastifyInstance): void {
if (!app.config.portalRevocationsEnabled) return
const target = portalRevocationTarget(app.config.portalRevocationsTarget ?? '')
const secret = app.config.portalRevocationsSecret
if (!secret || secret.length < 32 || Buffer.byteLength(secret) > 1024) throw new Error('PORTAL_REVOCATION_SECRET_REQUIRED')
enablePortalRevocations(app.db, target.href)
let stopped = false, running: Promise<void> | undefined
const abort = new AbortController()
const run = async () => {
// Bounded work per tick, single in-flight request; rows survive all failures/restarts.
for (let i = 0; i < 4 && !stopped; i++) {
const event = nextPortalRevocation(app.db)
if (!event) return
const delivered = await sendPortalRevocation(target, secret, event.body, abort.signal)
completePortalRevocation(app.db, event, delivered)
if (!delivered) return
}
}
const tick = () => {
if (stopped || running) return
running = run().catch(() => { app.log.error({event: 'portal.revocation.delivery-unavailable'}, 'Revocation delivery unavailable') })
.finally(() => { running = undefined })
}
let interval: ReturnType<typeof setInterval> | undefined
app.addHook('onReady', async () => { interval = setInterval(tick, 1000); interval.unref(); tick() })
// preClose executes before onClose closes SQLite. Abort then drain exact in-flight work.
app.addHook('preClose', async () => { stopped = true; clearInterval(interval); abort.abort(); await running })
}
+431
View File
@@ -0,0 +1,431 @@
import { createHash } from 'node:crypto'
import { afterEach, describe, expect, it } from 'vitest'
import { hash } from '@node-rs/argon2'
import { createOidcClient, generateOidcClientSecret } from '@authportal/db'
import { adminToken, buildTestApp } from './helpers.js'
type App = Awaited<ReturnType<typeof buildTestApp>>
let app: App | undefined
afterEach(async () => {
await app?.close()
app = undefined
})
const auth = (token: string) => ({ authorization: `Bearer ${token}` })
async function login(a: App, email = '[email protected]', password = 'adminpass') {
const res = await a.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { email, password },
})
return res
}
describe('auth', () => {
it('login: неверный пароль 401, отключённый пользователь 401', async () => {
app = await buildTestApp()
const token = await adminToken(app)
expect((await login(app, '[email protected]', 'wrong')).statusCode).toBe(401)
expect((await login(app, '[email protected]', 'whatever')).statusCode).toBe(401)
const created = await app.inject({
method: 'POST',
url: '/api/v1/admin/users',
headers: auth(token),
payload: {
email: '[email protected]',
name: 'Off',
password: 'secret12',
is_admin: false,
apps: ['cfdm'],
permissions: [],
},
})
const id = (created.json() as { id: string }).id
await app.inject({
method: 'PATCH',
url: `/api/v1/admin/users/${id}`,
headers: auth(token),
payload: { disabled: true },
})
expect((await login(app, '[email protected]', 'secret12')).statusCode).toBe(401)
})
it('me / reissue / catalog / logout по cookie', async () => {
app = await buildTestApp()
const res = await login(app)
const token = (res.json() as { access_token: string }).access_token
const refresh = res.cookies.find((c) => c.name === 'refresh_token')!.value
const me = await app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth(token) })
expect(me.statusCode).toBe(200)
expect((me.json() as { email: string }).email).toBe('[email protected]')
const reissue = await app.inject({
method: 'POST',
url: '/api/v1/auth/reissue',
headers: auth(token),
})
expect(reissue.statusCode).toBe(200)
expect((reissue.json() as { access_token: string }).access_token).toBeTruthy()
const catalog = await app.inject({ method: 'GET', url: '/api/v1/catalog', headers: auth(token) })
expect(catalog.statusCode).toBe(200)
expect((catalog.json() as { apps: unknown[] }).apps.length).toBeGreaterThan(0)
const noAuth = await app.inject({ method: 'GET', url: '/api/v1/auth/me' })
expect(noAuth.statusCode).toBe(401)
const logout = await app.inject({
method: 'POST',
url: '/api/v1/auth/logout',
headers: { ...auth(token), cookie: `refresh_token=${refresh}` },
})
expect(logout.statusCode).toBe(200)
const revoked = app.sqlite
.prepare('SELECT revoked_at FROM refresh_sessions')
.get() as { revoked_at: string | null }
expect(revoked.revoked_at).toBeTruthy()
})
})
describe('admin users', () => {
it('создание: конфликт email 409, валидация 400; patch и удаление', async () => {
app = await buildTestApp()
const token = await adminToken(app)
const payload = {
email: '[email protected]',
name: 'Dup',
password: 'secret12',
is_admin: false,
apps: ['cfdm'],
permissions: [],
}
const first = await app.inject({ method: 'POST', url: '/api/v1/admin/users', headers: auth(token), payload })
expect(first.statusCode).toBe(201)
const id = (first.json() as { id: string }).id
const dup = await app.inject({ method: 'POST', url: '/api/v1/admin/users', headers: auth(token), payload })
expect(dup.statusCode).toBe(409)
const bad = await app.inject({
method: 'POST',
url: '/api/v1/admin/users',
headers: auth(token),
payload: { email: 'nope' },
})
expect(bad.statusCode).toBe(400)
const patched = await app.inject({
method: 'PATCH',
url: `/api/v1/admin/users/${id}`,
headers: auth(token),
payload: { name: 'Renamed', password: 'newsecret12' },
})
expect(patched.statusCode).toBe(200)
expect((patched.json() as { name: string }).name).toBe('Renamed')
expect((await login(app, '[email protected]', 'newsecret12')).statusCode).toBe(200)
expect((await login(app, '[email protected]', 'secret12')).statusCode).toBe(401)
const del = await app.inject({ method: 'DELETE', url: `/api/v1/admin/users/${id}`, headers: auth(token) })
expect(del.statusCode).toBe(204)
const gone = await app.inject({ method: 'GET', url: `/api/v1/admin/users/${id}`, headers: auth(token) })
expect(gone.statusCode).toBe(404)
})
it('нельзя удалить себя; обычный пользователь не админ (403)', async () => {
app = await buildTestApp()
const token = await adminToken(app)
const me = await app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth(token) })
const myId = (me.json() as { id: string }).id
const self = await app.inject({ method: 'DELETE', url: `/api/v1/admin/users/${myId}`, headers: auth(token) })
expect(self.statusCode).toBe(400)
await app.inject({
method: 'POST',
url: '/api/v1/admin/users',
headers: auth(token),
payload: {
email: '[email protected]',
name: 'Plain',
password: 'secret12',
is_admin: false,
apps: ['cfdm'],
permissions: [],
},
})
const userTok = ((await login(app, '[email protected]', 'secret12')).json() as { access_token: string }).access_token
const forbidden = await app.inject({ method: 'GET', url: '/api/v1/admin/users', headers: auth(userTok) })
expect(forbidden.statusCode).toBe(403)
})
})
describe('OIDC клиенты и обмен кода', () => {
async function setup(a: App) {
const token = await adminToken(a)
const created = await a.inject({
method: 'POST',
url: '/api/v1/admin/oidc/clients',
headers: auth(token),
payload: {
name: 'RP',
redirect_uris: ['https://rp.test.local/cb'],
scopes: ['openid', 'profile', 'email', 'groups'],
enabled: true,
},
})
expect(created.statusCode).toBe(200)
const client = created.json() as { id: string; client_id: string; client_secret: string }
const loginRes = await login(a)
const refresh = loginRes.cookies.find((c) => c.name === 'refresh_token')!.value
return { token, client, refresh }
}
async function authorizeCode(
a: App,
refresh: string,
clientId: string,
extra: Record<string, string> = {},
): Promise<string> {
const res = await a.inject({
method: 'GET',
url: '/oauth/authorize',
cookies: { refresh_token: refresh },
query: {
client_id: clientId,
redirect_uri: 'https://rp.test.local/cb',
response_type: 'code',
scope: 'openid profile',
...extra,
},
})
expect(res.statusCode).toBe(302)
return new URL(res.headers.location as string).searchParams.get('code')!
}
const exchange = (a: App, body: Record<string, string>) =>
a.inject({
method: 'POST',
url: '/oauth/token',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
payload: new URLSearchParams({
grant_type: 'authorization_code',
redirect_uri: 'https://rp.test.local/cb',
...body,
}).toString(),
})
it('секрет нового клиента хранится как sha256, обмен работает, replay кода отклоняется', async () => {
app = await buildTestApp()
const { client, refresh } = await setup(app)
const stored = app.sqlite
.prepare('SELECT client_secret_hash h FROM oidc_clients')
.get() as { h: string }
expect(stored.h.startsWith('sha256:')).toBe(true)
const code = await authorizeCode(app, refresh, client.client_id)
const ok = await exchange(app, {
client_id: client.client_id,
client_secret: client.client_secret,
code,
})
expect(ok.statusCode).toBe(200)
expect((ok.json() as { id_token: string }).id_token).toBeTruthy()
const replay = await exchange(app, {
client_id: client.client_id,
client_secret: client.client_secret,
code,
})
expect(replay.statusCode).toBe(400)
expect((replay.json() as { error: string }).error).toBe('invalid_grant')
})
it('PKCE: S256 принимается, неверный verifier и отсутствие verifier отклоняются', async () => {
app = await buildTestApp()
const { client, refresh } = await setup(app)
const verifier = 'v'.repeat(50)
const challenge = createHash('sha256').update(verifier).digest('base64url')
const pkce = { code_challenge: challenge, code_challenge_method: 'S256' }
const creds = { client_id: client.client_id, client_secret: client.client_secret }
const good = await exchange(app, {
...creds,
code: await authorizeCode(app, refresh, client.client_id, pkce),
code_verifier: verifier,
})
expect(good.statusCode).toBe(200)
const wrong = await exchange(app, {
...creds,
code: await authorizeCode(app, refresh, client.client_id, pkce),
code_verifier: 'x'.repeat(50),
})
expect(wrong.statusCode).toBe(400)
const missing = await exchange(app, {
...creds,
code: await authorizeCode(app, refresh, client.client_id, pkce),
})
expect(missing.statusCode).toBe(400)
const plain = await exchange(app, {
...creds,
code: await authorizeCode(app, refresh, client.client_id, {
code_challenge: 'plain-challenge',
code_challenge_method: 'plain',
}),
code_verifier: 'plain-challenge',
})
expect(plain.statusCode).toBe(200)
})
it('неверный секрет 401; чужой redirect_uri и scope отклоняются; userinfo по access_token', async () => {
app = await buildTestApp()
const { client, refresh } = await setup(app)
const code = await authorizeCode(app, refresh, client.client_id)
const badSecret = await exchange(app, {
client_id: client.client_id,
client_secret: 'nope',
code,
})
expect(badSecret.statusCode).toBe(401)
const badRedirect = await app.inject({
method: 'GET',
url: '/oauth/authorize',
cookies: { refresh_token: refresh },
query: {
client_id: client.client_id,
redirect_uri: 'https://evil.test/cb',
response_type: 'code',
scope: 'openid',
},
})
expect(badRedirect.statusCode).toBe(400)
const badScope = await app.inject({
method: 'GET',
url: '/oauth/authorize',
cookies: { refresh_token: refresh },
query: {
client_id: client.client_id,
redirect_uri: 'https://rp.test.local/cb',
response_type: 'code',
scope: 'openid admin',
},
})
expect(badScope.statusCode).toBe(302)
expect(badScope.headers.location).toContain('error=invalid_scope')
const ok = await exchange(app, {
client_id: client.client_id,
client_secret: client.client_secret,
code,
})
const accessToken = (ok.json() as { access_token: string }).access_token
const info = await app.inject({
method: 'GET',
url: '/oauth/userinfo',
headers: auth(accessToken),
})
expect(info.statusCode).toBe(200)
expect((info.json() as { email: string }).email).toBe('[email protected]')
const noToken = await app.inject({ method: 'GET', url: '/oauth/userinfo' })
expect(noToken.statusCode).toBe(401)
})
it('без сессии authorize редиректит на логин', async () => {
app = await buildTestApp()
const { client } = await setup(app)
const res = await app.inject({
method: 'GET',
url: '/oauth/authorize',
query: {
client_id: client.client_id,
redirect_uri: 'https://rp.test.local/cb',
response_type: 'code',
scope: 'openid',
},
})
expect(res.statusCode).toBe(302)
expect(res.headers.location).toContain('return_to=')
})
it('старый argon2-секрет работает и мигрирует в sha256', async () => {
app = await buildTestApp()
const { refresh } = await setup(app)
const secret = generateOidcClientSecret()
const legacy = createOidcClient(app.db, {
name: 'Legacy',
redirectUris: ['https://rp.test.local/cb'],
scopes: ['openid', 'profile'],
enabled: true,
clientSecretHash: await hash(secret),
})
const code = await authorizeCode(app, refresh, legacy.clientId)
const res = await exchange(app, {
client_id: legacy.clientId,
client_secret: secret,
code,
})
expect(res.statusCode).toBe(200)
const row = app.sqlite
.prepare('SELECT client_secret_hash h FROM oidc_clients WHERE client_id=?')
.get(legacy.clientId) as { h: string }
expect(row.h.startsWith('sha256:')).toBe(true)
// после миграции секрет всё ещё работает
const code2 = await authorizeCode(app, refresh, legacy.clientId)
const again = await exchange(app, {
client_id: legacy.clientId,
client_secret: secret,
code: code2,
})
expect(again.statusCode).toBe(200)
})
it('CRUD клиента: patch, rotate-secret инвалидирует старый секрет, delete', async () => {
app = await buildTestApp()
const { token, client, refresh } = await setup(app)
const patch = await app.inject({
method: 'PATCH',
url: `/api/v1/admin/oidc/clients/${client.id}`,
headers: auth(token),
payload: { name: 'Renamed RP' },
})
expect(patch.statusCode).toBe(200)
expect((patch.json() as { name: string }).name).toBe('Renamed RP')
const rotate = await app.inject({
method: 'POST',
url: `/api/v1/admin/oidc/clients/${client.id}/rotate-secret`,
headers: auth(token),
})
expect(rotate.statusCode).toBe(200)
const fresh = (rotate.json() as { client_secret: string }).client_secret
expect(fresh).not.toBe(client.client_secret)
const code = await authorizeCode(app, refresh, client.client_id)
expect(
(await exchange(app, { client_id: client.client_id, client_secret: client.client_secret, code })).statusCode,
).toBe(401)
expect(
(await exchange(app, { client_id: client.client_id, client_secret: fresh, code })).statusCode,
).toBe(200)
const del = await app.inject({
method: 'DELETE',
url: `/api/v1/admin/oidc/clients/${client.id}`,
headers: auth(token),
})
expect(del.statusCode).toBe(200)
const gone = await app.inject({
method: 'GET',
url: `/api/v1/admin/oidc/clients/${client.id}`,
headers: auth(token),
})
expect(gone.statusCode).toBe(404)
})
})
+95
View File
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest'
import { buildTestApp } from './helpers.js'
describe('app-switcher API', () => {
it('GET /api/v1/app-switcher is public and returns defaults', async () => {
const app = await buildTestApp()
const res = await app.inject({ method: 'GET', url: '/api/v1/app-switcher' })
expect(res.statusCode).toBe(200)
const body = res.json() as { menuLabel: string; apps: { id: string }[] }
expect(body.menuLabel).toBeTruthy()
expect(body.apps.map((a) => a.id).sort()).toEqual([
'bgp',
'cdn',
'cfdm',
'dns',
'fw',
'mm',
'vps',
])
expect(body.apps.find((a) => a.id === 'cdn')).toMatchObject({
authMode: 'jwt',
})
expect(body.apps.find((a) => a.id === 'mm')).toMatchObject({
authMode: 'jwt',
})
expect(body.apps.find((a) => a.id === 'dns')).toMatchObject({
authMode: 'oidc',
})
await app.close()
})
it('PUT /api/v1/admin/app-switcher requires admin and persists', async () => {
const app = await buildTestApp()
const denied = await app.inject({
method: 'PUT',
url: '/api/v1/admin/app-switcher',
payload: { menuLabel: 'Apps', apps: [] },
})
expect(denied.statusCode).toBe(401)
const login = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { email: '[email protected]', password: 'adminpass' },
})
expect(login.statusCode).toBe(200)
const token = (login.json() as { access_token: string }).access_token
const getBefore = await app.inject({
method: 'GET',
url: '/api/v1/app-switcher',
})
const before = getBefore.json() as {
menuLabel: string
apps: {
id: string
name: string
url: string
icon: string
enabled: boolean
}[]
}
const updated = {
menuLabel: 'Сервисы',
apps: before.apps.map((a) =>
a.id === 'cfdm'
? { ...a, url: 'https://cfdm.example.test', name: 'CFDM Test' }
: a,
),
}
const put = await app.inject({
method: 'PUT',
url: '/api/v1/admin/app-switcher',
headers: { authorization: `Bearer ${token}` },
payload: updated,
})
expect(put.statusCode).toBe(200)
expect(put.json()).toMatchObject({ menuLabel: 'Сервисы' })
const getAfter = await app.inject({
method: 'GET',
url: '/api/v1/app-switcher',
})
const after = getAfter.json() as typeof before
expect(after.menuLabel).toBe('Сервисы')
expect(after.apps.find((a) => a.id === 'cfdm')?.url).toBe(
'https://cfdm.example.test',
)
await app.close()
})
})
+302
View File
@@ -0,0 +1,302 @@
import { describe, expect, it } from 'vitest'
import { appendAudit, listAudit, purgeAuditOlderThan } from '@authportal/db'
import { adminToken, buildTestApp } from './helpers.js'
describe('audit log API', () => {
it('ingests external events with secret and dedupes by event_id', async () => {
const app = await buildTestApp()
const denied = await app.inject({
method: 'POST',
url: '/api/v1/ingest/audit',
payload: { events: [] },
})
expect(denied.statusCode).toBe(401)
const eventId = 'evt-test-1'
const ok = await app.inject({
method: 'POST',
url: '/api/v1/ingest/audit',
headers: { authorization: 'Bearer dev-audit-ingest-secret' },
payload: {
events: [
{
event_id: eventId,
source_app: 'vps',
action: 'vps.vps.create',
summary: 'Создан VPS',
actor_email: '[email protected]',
},
],
},
})
expect(ok.statusCode).toBe(200)
expect(ok.json()).toMatchObject({ accepted: 1, duplicates: 0 })
const dup = await app.inject({
method: 'POST',
url: '/api/v1/ingest/audit',
headers: { authorization: 'Bearer dev-audit-ingest-secret' },
payload: {
events: [
{
event_id: eventId,
source_app: 'vps',
action: 'vps.vps.create',
summary: 'Создан VPS',
},
],
},
})
expect(dup.json()).toMatchObject({ accepted: 0, duplicates: 1 })
const token = await adminToken(app)
const list = await app.inject({
method: 'GET',
url: '/api/v1/admin/audit?source_app=vps',
headers: { authorization: `Bearer ${token}` },
})
expect(list.statusCode).toBe(200)
const entries = list.json() as { source_app: string; action: string }[]
expect(entries.some((e) => e.action === 'vps.vps.create')).toBe(true)
await app.close()
})
it('records login and lists for admin', async () => {
const app = await buildTestApp()
const token = await adminToken(app)
const list = await app.inject({
method: 'GET',
url: '/api/v1/admin/audit',
headers: { authorization: `Bearer ${token}` },
})
expect(list.statusCode).toBe(200)
const entries = list.json() as { action: string }[]
expect(entries.some((e) => e.action === 'auth.login')).toBe(true)
const denied = await app.inject({
method: 'GET',
url: '/api/v1/admin/audit',
})
expect(denied.statusCode).toBe(401)
await app.close()
})
it('updates retention and purges old rows', async () => {
const app = await buildTestApp()
const token = await adminToken(app)
appendAudit(app.db, {
action: 'user.create',
summary: 'old event',
})
// Backdate the last inserted row
app.sqlite
.prepare(
`UPDATE audit_log SET created_at = ? WHERE summary = 'old event'`,
)
.run(new Date(Date.now() - 40 * 24 * 60 * 60 * 1000).toISOString())
const settings = await app.inject({
method: 'PUT',
url: '/api/v1/admin/audit/settings',
headers: { authorization: `Bearer ${token}` },
payload: { retention_days: 30 },
})
expect(settings.statusCode).toBe(200)
expect(settings.json()).toEqual({ retention_days: 30 })
const before = listAudit(app.db, { limit: 500 })
expect(before.some((e) => e.summary === 'old event')).toBe(true)
const deleted = purgeAuditOlderThan(app.db, 30)
expect(deleted).toBeGreaterThanOrEqual(1)
expect(listAudit(app.db, { limit: 500 }).some((e) => e.summary === 'old event')).toBe(
false,
)
const purge = await app.inject({
method: 'POST',
url: '/api/v1/admin/audit/purge',
headers: { authorization: `Bearer ${token}` },
})
expect(purge.statusCode).toBe(200)
expect((purge.json() as { retention_days: number }).retention_days).toBe(30)
await app.close()
})
it('records user.create from admin mutation', async () => {
const app = await buildTestApp()
const token = await adminToken(app)
const create = await app.inject({
method: 'POST',
url: '/api/v1/admin/users',
headers: { authorization: `Bearer ${token}` },
payload: {
email: '[email protected]',
name: 'New User',
password: 'secret12',
is_admin: false,
apps: ['cfdm'],
permissions: [],
},
})
expect(create.statusCode).toBe(201)
const list = await app.inject({
method: 'GET',
url: '/api/v1/admin/audit?action=user.create',
headers: { authorization: `Bearer ${token}` },
})
expect(list.statusCode).toBe(200)
const entries = list.json() as { action: string; summary: string }[]
expect(entries.length).toBeGreaterThanOrEqual(1)
expect(entries[0]?.summary).toContain('[email protected]')
await app.close()
})
it('records login with IP, UA and last_login_ip', async () => {
const app = await buildTestApp()
const login = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
headers: {
'x-forwarded-for': '203.0.113.10',
'user-agent': 'VitestBrowser/1.0',
},
payload: {
email: '[email protected]',
password: 'adminpass',
return_to: 'https://vps.example.test/dashboard',
},
})
expect(login.statusCode).toBe(200)
const token = (login.json() as { access_token: string }).access_token
const users = await app.inject({
method: 'GET',
url: '/api/v1/admin/users',
headers: { authorization: `Bearer ${token}` },
})
const admin = (
users.json() as { email: string; last_login_ip: string | null }[]
).find((u) => u.email === '[email protected]')
expect(admin?.last_login_ip).toBe('203.0.113.10')
const list = await app.inject({
method: 'GET',
url: '/api/v1/admin/audit?kind=logins',
headers: { authorization: `Bearer ${token}` },
})
const entries = list.json() as {
action: string
ip: string | null
details: Record<string, unknown> | null
}[]
const loginEvt = entries.find((e) => e.action === 'auth.login')
expect(loginEvt?.ip).toBe('203.0.113.10')
expect(loginEvt?.details?.user_agent).toBe('VitestBrowser/1.0')
expect(loginEvt?.details?.target_app).toBe('vps')
await app.close()
})
it('records failed login with email and IP', async () => {
const app = await buildTestApp()
const fail = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
headers: { 'x-forwarded-for': '198.51.100.7' },
payload: { email: '[email protected]', password: 'wrong' },
})
expect(fail.statusCode).toBe(401)
const token = await adminToken(app)
const list = await app.inject({
method: 'GET',
url: '/api/v1/admin/audit?kind=logins&[email protected]',
headers: { authorization: `Bearer ${token}` },
})
const entries = list.json() as {
action: string
actor_email: string | null
ip: string | null
}[]
expect(entries.some((e) => e.action === 'auth.login_failed')).toBe(true)
expect(entries[0]?.actor_email).toBe('[email protected]')
expect(entries[0]?.ip).toBe('198.51.100.7')
await app.close()
})
it('records sso-access and separates kind=changes', async () => {
const app = await buildTestApp()
const token = await adminToken(app)
const sso = await app.inject({
method: 'POST',
url: '/api/v1/auth/sso-access',
headers: { authorization: `Bearer ${token}` },
payload: { return_to: 'https://bgp.example.test/' },
})
expect(sso.statusCode).toBe(200)
expect((sso.json() as { target_app: string }).target_app).toBe('bgp')
const logins = await app.inject({
method: 'GET',
url: '/api/v1/admin/audit?kind=logins',
headers: { authorization: `Bearer ${token}` },
})
const loginEntries = logins.json() as { action: string }[]
expect(loginEntries.every((e) => e.action.startsWith('auth.'))).toBe(true)
expect(loginEntries.some((e) => e.action === 'auth.sso_handoff')).toBe(true)
const changes = await app.inject({
method: 'GET',
url: '/api/v1/admin/audit?kind=changes',
headers: { authorization: `Bearer ${token}` },
})
const changeEntries = changes.json() as { action: string }[]
expect(changeEntries.every((e) => !e.action.startsWith('auth.'))).toBe(true)
await app.close()
})
it('lists and revokes refresh sessions', async () => {
const app = await buildTestApp()
const token = await adminToken(app)
const sessions = await app.inject({
method: 'GET',
url: '/api/v1/admin/sessions',
headers: { authorization: `Bearer ${token}` },
})
expect(sessions.statusCode).toBe(200)
const rows = sessions.json() as { id: string; user_id: string }[]
expect(rows.length).toBeGreaterThanOrEqual(1)
const sessionId = rows[0]!.id
const revoke = await app.inject({
method: 'DELETE',
url: `/api/v1/admin/sessions/${sessionId}`,
headers: { authorization: `Bearer ${token}` },
})
expect(revoke.statusCode).toBe(200)
const after = await app.inject({
method: 'GET',
url: '/api/v1/admin/sessions',
headers: { authorization: `Bearer ${token}` },
})
expect(
(after.json() as { id: string }[]).some((s) => s.id === sessionId),
).toBe(false)
await app.close()
})
})
+82
View File
@@ -0,0 +1,82 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { listBackups } from '@authportal/db'
import { loadConfig } from '../src/config.js'
import { backupDirFor, runBackup, startBackupJob } from '../src/services/backup.js'
import { buildApp } from '../src/app.js'
import { adminToken } from './helpers.js'
const dirs: string[] = []
afterEach(() => {
vi.useRealTimers()
for (const d of dirs.splice(0)) {
try {
rmSync(d, { recursive: true, force: true })
} catch {
/* Windows может держать файл БД до GC — временный каталог не критичен */
}
}
})
function env(dir: string, extra: NodeJS.ProcessEnv = {}) {
return loadConfig({
JWT_SECRET: 'test-secret-at-least-8',
ADMIN_EMAIL: '[email protected]',
ADMIN_PASSWORD: 'adminpass',
DATABASE_URL: `sqlite:${join(dir, 'app.db')}`,
NODE_ENV: 'test',
...extra,
})
}
describe('backup', () => {
it('backupDirFor: по умолчанию рядом с БД, BACKUP_DIR приоритетнее', () => {
const cfg = env('/data/x')
expect(backupDirFor(cfg).replaceAll(String.fromCharCode(92), '/')).toMatch(/\/data\/x\/backups$/)
const custom = env('/data/x', { BACKUP_DIR: '/mnt/bk' })
expect(backupDirFor(custom).replaceAll(String.fromCharCode(92), '/')).toMatch(/\/mnt\/bk$/)
})
it('бэкап живого приложения восстанавливается: вход админа работает', async () => {
const dir = mkdtempSync(join(tmpdir(), 'ap-api-bk-'))
dirs.push(dir)
const config = env(dir, { BACKUP_KEEP: '2' })
const app = await buildApp({ config })
const res = await runBackup(app.sqlite, config)
expect(listBackups(backupDirFor(config))).toHaveLength(1)
await app.close()
const restored = await buildApp({
config: env(dir),
databaseUrl: `sqlite:${res.file}`,
})
const token = await adminToken(restored as never)
expect(token).toBeTruthy()
await restored.close()
})
it('startBackupJob выключен при 0 часов и работает по таймеру', async () => {
vi.useFakeTimers()
const dir = mkdtempSync(join(tmpdir(), 'ap-job-bk-'))
dirs.push(dir)
const config = env(dir, { BACKUP_INTERVAL_HOURS: '1' })
const app = await buildApp({ config })
const base = vi.getTimerCount()
const off = startBackupJob({ ...app, config: { ...config, backupIntervalHours: 0 } } as never)
expect(vi.getTimerCount()).toBe(base)
off()
const stop = startBackupJob(app)
expect(vi.getTimerCount()).toBe(base + 1)
await vi.advanceTimersByTimeAsync(60 * 60 * 1000 + 10)
vi.useRealTimers()
await vi.waitFor(() =>
expect(listBackups(backupDirFor(config)).length).toBeGreaterThan(0),
)
stop()
await app.close()
})
})
+150
View File
@@ -0,0 +1,150 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Writable } from 'node:stream'
import Fastify from 'fastify'
import { assertBootstrapPassword, loadConfig } from '../src/config.js'
import { buildApp } from '../src/app.js'
const base = { JWT_SECRET: 'a-sufficiently-long-secret-123' }
describe('loadConfig', () => {
it('значения по умолчанию для новых параметров', () => {
const c = loadConfig({ ...base, NODE_ENV: 'test' })
expect(c.shutdownTimeoutMs).toBe(8000)
expect(c.sessionRetentionDays).toBe(7)
expect(c.argon2Concurrency).toBe(2)
expect(c.backupKeep).toBe(7)
expect(c.backupIntervalHours).toBe(0)
expect(c.corsOrigins).toBe('')
expect(c.trustProxy).toContain('10.0.0.0/8')
})
it('читает переопределения из env', () => {
const c = loadConfig({
...base,
NODE_ENV: 'test',
SHUTDOWN_TIMEOUT_MS: '1500',
SESSION_RETENTION_DAYS: '30',
ARGON2_CONCURRENCY: '4',
BACKUP_KEEP: '3',
BACKUP_INTERVAL_HOURS: '12',
CORS_ORIGINS: 'https://a.example',
TRUST_PROXY: 'false',
})
expect(c).toMatchObject({
shutdownTimeoutMs: 1500,
sessionRetentionDays: 30,
argon2Concurrency: 4,
backupKeep: 3,
backupIntervalHours: 12,
corsOrigins: 'https://a.example',
trustProxy: 'false',
})
})
it.each([
['SHUTDOWN_TIMEOUT_MS', '0'],
['ARGON2_CONCURRENCY', '-1'],
['BACKUP_KEEP', 'abc'],
['BACKUP_INTERVAL_HOURS', '-5'],
])('невалидный %s=%s → ошибка', (key, value) => {
expect(() => loadConfig({ ...base, NODE_ENV: 'test', [key]: value })).toThrow()
})
})
describe('production guard', () => {
it.each(['change-me-in-production', 'dev-secret-change-me'])(
'известный дефолт JWT_SECRET "%s" отклоняется',
(secret) => {
expect(() =>
loadConfig({ NODE_ENV: 'production', JWT_SECRET: secret }),
).toThrow(/JWT_SECRET/)
},
)
it('короткий, но уникальный секрет в production не роняет старт (только предупреждение)', () => {
const warn = vi.spyOn(process, 'emitWarning').mockImplementation(() => {})
expect(() =>
loadConfig({ NODE_ENV: 'production', JWT_SECRET: 'short-secret' }),
).not.toThrow()
expect(warn).toHaveBeenCalledWith(expect.stringContaining('JWT_SECRET'))
warn.mockRestore()
})
it('нормальный секрет в production принимается без предупреждений; в test слабый разрешён', () => {
const warn = vi.spyOn(process, 'emitWarning').mockImplementation(() => {})
expect(() =>
loadConfig({ NODE_ENV: 'production', JWT_SECRET: base.JWT_SECRET }),
).not.toThrow()
expect(warn).not.toHaveBeenCalled()
expect(() =>
loadConfig({ NODE_ENV: 'test', JWT_SECRET: 'short-secret' }),
).not.toThrow()
warn.mockRestore()
})
it('пароль первого админа: слабый запрещён только в production', () => {
const prod = (pw?: string) =>
loadConfig({ NODE_ENV: 'production', ...base, ADMIN_PASSWORD: pw })
expect(() => assertBootstrapPassword(prod('admin'))).toThrow(/ADMIN_PASSWORD/)
expect(() => assertBootstrapPassword(prod(''))).toThrow(/ADMIN_PASSWORD/)
expect(() => assertBootstrapPassword(prod('short'))).toThrow(/ADMIN_PASSWORD/)
expect(() => assertBootstrapPassword(prod('long-enough-pw'))).not.toThrow()
expect(() =>
assertBootstrapPassword(loadConfig({ NODE_ENV: 'test', ...base })),
).not.toThrow()
})
it('buildApp в production без нормального пароля падает при создании админа', async () => {
await expect(
buildApp({
config: loadConfig({
NODE_ENV: 'production',
...base,
ADMIN_PASSWORD: 'admin',
DATABASE_URL: 'sqlite::memory:',
}),
databaseUrl: 'sqlite::memory:',
}),
).rejects.toThrow(/ADMIN_PASSWORD/)
})
})
describe('redact в логах', () => {
let app: ReturnType<typeof Fastify> | undefined
afterEach(async () => {
await app?.close()
})
it('authorization и cookie не попадают в лог', async () => {
const lines: string[] = []
const stream = new Writable({
write(chunk, _enc, cb) {
lines.push(String(chunk))
cb()
},
})
// Тот же набор путей, что и в buildApp.
app = Fastify({
logger: {
level: 'info',
stream,
redact: {
paths: ['req.headers.authorization', 'req.headers.cookie'],
censor: '[redacted]',
},
serializers: {
req: (req) => ({ method: req.method, headers: req.headers }),
},
},
})
app.get('/x', async () => ({ ok: true }))
await app.inject({
method: 'GET',
url: '/x',
headers: { authorization: 'Bearer SECRET', cookie: 'refresh_token=SECRET' },
})
const joined = lines.join('')
expect(joined).toContain('[redacted]')
expect(joined).not.toContain('SECRET')
})
})
+95
View File
@@ -0,0 +1,95 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { adminToken, buildTestApp } from './helpers.js'
import { startHousekeepingJob } from '../src/services/housekeeping.js'
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
afterEach(async () => {
vi.useRealTimers()
await app?.close()
app = undefined
})
describe('directory/users', () => {
it('отдаёт только id/email/name и ограничивает 50', async () => {
app = await buildTestApp()
const token = await adminToken(app)
for (let i = 0; i < 55; i++) {
app.sqlite
.prepare(
`INSERT INTO users (id,email,name,password_hash,is_admin,disabled,created_at,updated_at)
VALUES (?,?,?,?,0,0,'t','t')`,
)
.run(`id${i}`, `u${i}@t.io`, `U${i}`, 'hash')
}
const res = await app.inject({
method: 'GET',
url: '/api/v1/directory/users',
headers: { authorization: `Bearer ${token}` },
})
expect(res.statusCode).toBe(200)
const body = res.json() as Array<Record<string, unknown>>
expect(body).toHaveLength(50)
expect(Object.keys(body[0]!).sort()).toEqual(['email', 'id', 'name'])
expect(res.body).not.toContain('hash')
const q = await app.inject({
method: 'GET',
url: '/api/v1/directory/users?q=U54',
headers: { authorization: `Bearer ${token}` },
})
expect((q.json() as unknown[]).length).toBe(1)
})
})
describe('ingest audit', () => {
const ev = (i: number) => ({
event_id: `ev-${i}`,
source_app: 'dns',
action: 'dns.test',
severity: 'info',
summary: 'x',
})
it('принимает пачку одной транзакцией и считает дубликаты', async () => {
app = await buildTestApp()
const post = (events: unknown[]) =>
app!.inject({
method: 'POST',
url: '/api/v1/ingest/audit',
headers: { authorization: 'Bearer dev-audit-ingest-secret' },
payload: { events },
})
const first = await post([ev(1), ev(2), ev(3)])
expect(first.json()).toEqual({ accepted: 3, duplicates: 0 })
const second = await post([ev(3), ev(4)])
expect(second.json()).toEqual({ accepted: 1, duplicates: 1 })
})
})
describe('housekeeping job', () => {
it('удаляет устаревшие сессии по таймеру и останавливается', async () => {
vi.useFakeTimers()
app = await buildTestApp()
app.sqlite
.prepare(
`INSERT INTO users (id,email,name,password_hash,is_admin,disabled,created_at,updated_at)
VALUES ('uh','[email protected]','H','x',0,0,'t','t')`,
)
.run()
app.sqlite
.prepare(
`INSERT INTO refresh_sessions (id,user_id,token_hash,expires_at,created_at)
VALUES ('old','uh','h','2000-01-01T00:00:00.000Z','t')`,
)
.run()
const stop = startHousekeepingJob(app)
await vi.advanceTimersByTimeAsync(31_000)
const left = app.sqlite
.prepare(`SELECT COUNT(*) c FROM refresh_sessions WHERE id='old'`)
.get()
expect(left).toEqual({ c: 0 })
stop()
expect(vi.getTimerCount()).toBe(0)
})
})
+91
View File
@@ -0,0 +1,91 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const root = resolve(import.meta.dirname, '../../..')
const read = (p: string) => readFileSync(resolve(root, p), 'utf-8')
const dockerfile = read('deploy/docker/authportal/Dockerfile')
const composes = {
'docker-compose.yml': read('docker-compose.yml'),
'deploy/docker-compose.traefik.yml': read('deploy/docker-compose.traefik.yml'),
}
describe('Dockerfile', () => {
it('graceful stop и ограничение ресурсов Node', () => {
expect(dockerfile).toMatch(/^STOPSIGNAL SIGTERM$/m)
expect(dockerfile).toMatch(/NODE_OPTIONS=--max-old-space-size=\d+/)
expect(dockerfile).toMatch(/UV_THREADPOOL_SIZE=4/)
})
it('healthcheck без запуска node', () => {
const hc = dockerfile.split('HEALTHCHECK')[1]!.split('\n').slice(0, 2).join(' ')
expect(hc).toContain('wget')
expect(hc).not.toContain('"node"')
})
it('запуск не от root, рантайм без тулчейна и pnpm deploy', () => {
expect(dockerfile).toMatch(/^USER node$/m)
const runtime = dockerfile.split('AS runtime')[1]!
expect(runtime).not.toMatch(/apk add/)
expect(dockerfile).not.toMatch(/pnpm .*deploy/)
})
it('статика сжимается только brotli', () => {
expect(dockerfile).toContain('brotli')
expect(dockerfile).not.toMatch(/gzip -k/)
})
it('preview-маршруты дизайна вырезаются до сборки web', () => {
const rm = dockerfile.indexOf('rm -f apps/web/src/routes/design-preview*.tsx')
const build = dockerfile.indexOf('pnpm turbo build')
expect(rm).toBeGreaterThan(0)
expect(rm).toBeLessThan(build)
})
it('базовый образ закреплён по digest', () => {
expect(dockerfile).toMatch(/ARG BASE_NODE=\S+@sha256:[0-9a-f]{64}/)
expect(read('deploy/docker/docker-bake.hcl')).toMatch(/node:22-alpine@sha256:[0-9a-f]{64}/)
})
it('в образ копируется весь dist API, а tsup не дробит бандл на чанки', () => {
expect(dockerfile).toContain('cp -r apps/api/dist /out/dist')
expect(read('apps/api/tsup.config.ts')).toMatch(/splitting:\s*false/)
})
it('нативные модули проверяются при сборке', () => {
expect(dockerfile).toContain('native modules ok')
})
})
describe.each(Object.entries(composes))('%s', (_name, text) => {
it('init, grace period и лимиты ресурсов', () => {
expect(text).toMatch(/init: true/)
expect(text).toMatch(/stop_grace_period: 15s/)
expect(text).toMatch(/mem_limit: 256m/)
expect(text).toMatch(/pids_limit: \d+/)
expect(text).toMatch(/read_only: true/)
expect(text).toMatch(/cap_drop:\s*\n\s*- ALL/)
expect(text).toMatch(/no-new-privileges:true/)
})
it('healthcheck через wget', () => {
expect(text).toMatch(/"wget", "-q"/)
expect(text).not.toMatch(/fetch\('http:\/\/127\.0\.0\.1:8080\/health'\)/)
})
})
describe('traefik', () => {
it('app ждёт healthy traefik, у traefik есть healthcheck', () => {
const t = composes['deploy/docker-compose.traefik.yml']
expect(t).toMatch(/condition: service_healthy/)
expect(t).toContain('traefik", "healthcheck", "--ping')
expect(t).toContain('--ping=true')
})
it('включено сжатие ответов', () => {
expect(composes['deploy/docker-compose.traefik.yml']).toContain(
'middlewares.auth-portal-compress.compress=true',
)
})
})
+77
View File
@@ -0,0 +1,77 @@
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { z } from 'zod'
import { buildTestApp } from './helpers.js'
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
afterEach(async () => {
await app?.close()
app = undefined
})
describe('error handler', () => {
it('необработанная ошибка → 500 без деталей', async () => {
app = await buildTestApp()
app.get('/__boom', async () => {
throw new Error('secret internals')
})
const res = await app.inject({ method: 'GET', url: '/__boom' })
expect(res.statusCode).toBe(500)
expect(res.json()).toEqual({
error: { code: 'INTERNAL', message: 'Internal server error' },
})
expect(res.body).not.toContain('secret internals')
})
it('ZodError → 400 BAD_REQUEST', async () => {
app = await buildTestApp()
app.get('/__zod', async () => z.object({ a: z.string() }).parse({}))
const res = await app.inject({ method: 'GET', url: '/__zod' })
expect(res.statusCode).toBe(400)
expect(res.json().error.code).toBe('BAD_REQUEST')
})
it('ошибка с statusCode 4xx сохраняет статус', async () => {
app = await buildTestApp()
app.get('/__teapot', async () => {
throw Object.assign(new Error('nope'), { statusCode: 418 })
})
const res = await app.inject({ method: 'GET', url: '/__teapot' })
expect(res.statusCode).toBe(418)
})
})
describe('not found', () => {
it.each(['/oauth/nope', '/.well-known/nope', '/api/v1/nope'])(
'%s → 404 JSON',
async (url) => {
app = await buildTestApp()
const res = await app.inject({ method: 'GET', url })
expect(res.statusCode).toBe(404)
expect(res.json().error.code).toBe('NOT_FOUND')
},
)
it('без STATIC_DIR неизвестный путь → 404 JSON', async () => {
app = await buildTestApp()
const res = await app.inject({ method: 'GET', url: '/some/spa/route' })
expect(res.statusCode).toBe(404)
})
it('со STATIC_DIR: SPA-путь → index.html, /oauth и /.well-known → 404', async () => {
const dir = mkdtempSync(join(tmpdir(), 'ap-static-'))
writeFileSync(join(dir, 'index.html'), '<html>spa</html>')
app = await buildTestApp({ STATIC_DIR: dir })
const spa = await app.inject({ method: 'GET', url: '/some/spa/route' })
expect(spa.statusCode).toBe(200)
expect(spa.body).toContain('spa')
for (const url of ['/oauth/nope', '/.well-known/nope']) {
const res = await app.inject({ method: 'GET', url })
expect(res.statusCode).toBe(404)
expect(res.json().error.code).toBe('NOT_FOUND')
}
})
})
+67
View File
@@ -0,0 +1,67 @@
import { expect } from 'vitest'
import { buildApp } from '../src/app.js'
import { loadConfig } from '../src/config.js'
import { resetOidcKeyCache } from '../src/lib/oidc/keys.js'
/** Фиксированный тестовый RSA-ключ: importPKCS8 (~мс) вместо generateKeyPair
* (~100–300 мс) при построении приложения в каждом тесте. */
const TEST_OIDC_RSA_PRIVATE_KEY = [
'-----BEGIN PRIVATE KEY-----',
'MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCt/+Vwi6txs4ET',
'/OczfcOX0mVCOfxT3qGejj1xX19A4gKeGGjQ5LSBDGzcztncYMd0J/H1ozUWn0NU',
'ctolNRSTFchAYj1MoC2Pacfhe445nLR4bOssdADO9bwhHmWy8WQFyYc/lqs/xPSd',
'haZFwDRBRRfh9uxzzUPR8FXCovHEJG5HiLuvmALL1W7nkDg+L+vyLYCNje482hS+',
'ZcW3IH17gQpXgpqgXPaGHfZT1teIiYwhelaqPsApMQroOisKYGoQi1z/G/mdvaOF',
'/jravyBqhr5Hm03lFOMBvMptGrXsjopy+BVlqBVcvdwdyFfz94dayhnCmfEIvK7W',
'ynYGtoLnAgMBAAECggEAKqld04DjOb8FzGVLOKRCwyQ6YIkAFX42CT6d0QDNxYZE',
'lwesF1+j5CosPN1YrEBsVs3+dLgd6ZyF8Rwx4UKI92E1VpffqLxzP0Dz2ZSmM/j4',
'hLhL1vvgQN4eUM53p+6olASr7QrflQSQ51hgAqUUCtbKesREiEYRNVWkaVLzTSAr',
'KrwWAF81hzEms0KuHw6/EgThBkWYGAMzZjECFa173yBHklt1ikdB1AOmieL9PZbB',
'anjIequkUE2yoTJM/wOd2GN7sy61PmnMApwl1ke6k4VhrJeQhfahck0D2ynDvWqX',
'fnn1Wr/BRmSi38RNEOySWkrCVjM0XSjO3wEuHFFyuQKBgQDwgOgzgL1W5b13LKXo',
'8F/AJNRmi4vpM1lftMRalx5NU5ugw1iQ2zJzLG3Tl1/nvyt8tGFkXB7Dw9M9H0as',
'jjk7D8ZzL1a7COWQx7wya1l8vMxwv40ERvoWypwpuVo12SvRnHWgGwsbqW4wxYIG',
'9HsdvbxglFiVcSq+b6FDdg2nyQKBgQC5NgKatFPPed/DFb6MCkW+5Wnw2HzdKHxD',
'fEkAJeb3xX+9k7OifRHppurwaDV4jUPJ5W1Whly77cg+xmEYAbUdPS1kgfYJaDn1',
'mPN9dI7RP15v4X46/+5v5EZYAKcdUdMzr9gJ2iQVpoDi5ojp+LJknPLBuo7XTfp3',
'kj9lifONLwKBgHa3SudRXyTr1rBjC6qGLN2N0IKl03RTcoR5mIsXGVAWOzYJ90fZ',
'y3dqsPPHPwIILZErAQAnMNB1c2oybBJ1Se7LhKwK7Ab7B0aGMXG557xY/ZmSFCvO',
'VVi2vosiAVOfVa/sSWu0rbED226br+l6+OLfa4NI9skdyXmjZLkGJwtxAoGBALDp',
'LkOnzAZ6W4p00rXheg2GBVPQ9jQp38h2hyRlD+HL/OCQnR2wadPNZMyOPcUQ6peH',
'VpE3dozr7EJKK6RKvT1YmmtO2DzUGcjGeI51L5Yf+YzUz0Z8AL1QRwD4Xqha6GqO',
'pMWEYMNtb99MZatXqs8AO8Fg+eAe1Agy4euTgdhxAoGBAPAgrLUl7uJMdCsrHFEG',
'K8CH3BZwz2ytSj6429IdX4OCzkkmdsWnjx5/FK2hc+fXMluhybaIziSntgs55vzZ',
'1hd/Xw1T2FSTS6BuWegxVtjO6BQjbVXrxOumA0PPmFYaGMPvvBQrbZy8OSeuaKD/',
'a9jKZApDKnT8vmYvmyMMtsmE',
'-----END PRIVATE KEY-----',
].join('\n')
export async function buildTestApp(env: NodeJS.ProcessEnv = {}) {
resetOidcKeyCache()
const config = loadConfig({
...process.env,
JWT_SECRET: 'test-secret-at-least-8',
ADMIN_EMAIL: '[email protected]',
ADMIN_PASSWORD: 'adminpass',
DATABASE_URL: 'sqlite::memory:',
ISSUER: 'https://auth.test.local',
OIDC_ISSUER: 'https://auth.test.local',
NODE_ENV: 'test',
AUDIT_INGEST_SECRET: 'dev-audit-ingest-secret',
OIDC_RSA_PRIVATE_KEY: TEST_OIDC_RSA_PRIVATE_KEY,
...env,
})
return buildApp({ config, databaseUrl: 'sqlite::memory:' })
}
export async function adminToken(
app: Awaited<ReturnType<typeof buildTestApp>>,
): Promise<string> {
const login = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { email: '[email protected]', password: 'adminpass' },
})
expect(login.statusCode).toBe(200)
return (login.json() as { access_token: string }).access_token
}
+277
View File
@@ -0,0 +1,277 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { hash } from '@node-rs/argon2'
import { TtlCache } from '../src/lib/cache.js'
import { parseCookie } from '../src/lib/cookies.js'
import { checkClientSecret, hashClientSecret } from '../src/lib/client-secret.js'
import {
hashPassword,
passwordHashStats,
setPasswordConcurrency,
verifyPassword,
} from '../src/lib/password.js'
import { parseTrustProxy } from '../src/config.js'
import { adminToken, buildTestApp } from './helpers.js'
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
afterEach(async () => {
vi.restoreAllMocks()
await app?.close()
app = undefined
})
describe('TtlCache', () => {
it('истекает по TTL', () => {
let t = 0
const c = new TtlCache<string, number>({ max: 10, ttlMs: 100, now: () => t })
c.set('a', 1)
t = 99
expect(c.get('a')).toBe(1)
t = 100
expect(c.get('a')).toBeUndefined()
expect(c.size).toBe(0)
})
it('вытесняет давно не использованное (LRU)', () => {
const c = new TtlCache<string, number>({ max: 2, ttlMs: 1000 })
c.set('a', 1)
c.set('b', 2)
c.get('a') // освежили a
c.set('c', 3)
expect(c.get('b')).toBeUndefined()
expect(c.get('a')).toBe(1)
expect(c.get('c')).toBe(3)
c.delete('a')
expect(c.get('a')).toBeUndefined()
c.clear()
expect(c.size).toBe(0)
})
})
describe('parseCookie', () => {
it('находит значение и декодирует', () => {
expect(parseCookie('a=1; refresh_token=abc%20d; b=2', 'refresh_token')).toBe(
'abc d',
)
})
it('не путает суффикс имени', () => {
expect(parseCookie('x_refresh_token=bad', 'refresh_token')).toBeNull()
})
it('пусто/нет → null', () => {
expect(parseCookie(undefined, 'a')).toBeNull()
expect(parseCookie('a=; b=1', 'a')).toBeNull()
expect(parseCookie('b=1', 'a')).toBeNull()
})
})
describe('client secret', () => {
it('sha256-формат: верный/неверный, без rehash', async () => {
const stored = hashClientSecret('s3cret')
expect(stored.startsWith('sha256:')).toBe(true)
expect(await checkClientSecret(stored, 's3cret')).toEqual({
ok: true,
needsRehash: false,
})
expect((await checkClientSecret(stored, 'nope')).ok).toBe(false)
})
it('argon2-формат принимается и помечается на rehash', async () => {
const legacy = await hash('old-secret')
expect(await checkClientSecret(legacy, 'old-secret')).toEqual({
ok: true,
needsRehash: true,
})
expect(await checkClientSecret(legacy, 'bad')).toEqual({
ok: false,
needsRehash: false,
})
expect((await checkClientSecret('garbage', 'x')).ok).toBe(false)
})
})
describe('password limiter', () => {
it('не допускает больше N одновременных хешей', async () => {
setPasswordConcurrency(2)
let maxActive = 0
const poll = setInterval(() => {
maxActive = Math.max(maxActive, passwordHashStats().active)
}, 1)
const h = await hashPassword('pw')
await Promise.all(
Array.from({ length: 6 }, () => verifyPassword(h, 'pw')),
)
clearInterval(poll)
expect(maxActive).toBeLessThanOrEqual(2)
expect(passwordHashStats()).toEqual({ active: 0, waiting: 0 })
setPasswordConcurrency(2)
})
})
describe('parseTrustProxy', () => {
it('true/false/список', () => {
expect(parseTrustProxy('true')).toBe(true)
expect(parseTrustProxy('false')).toBe(false)
expect(parseTrustProxy('10.0.0.0/8, ::1')).toEqual(['10.0.0.0/8', '::1'])
})
})
async function createUserAndLogin(
a: NonNullable<typeof app>,
adminTok: string,
email: string,
) {
const create = await a.inject({
method: 'POST',
url: '/api/v1/admin/users',
headers: { authorization: `Bearer ${adminTok}` },
payload: {
email,
name: 'U',
password: 'secret12',
is_admin: false,
apps: ['cfdm'],
permissions: [],
},
})
expect(create.statusCode).toBe(201)
const id = (create.json() as { id: string }).id
const login = await a.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { email, password: 'secret12' },
})
expect(login.statusCode).toBe(200)
return { id, token: (login.json() as { access_token: string }).access_token }
}
describe('кэш профиля пользователя', () => {
it('повторные запросы не обращаются к user_apps/user_permissions', async () => {
app = await buildTestApp()
const admin = await adminToken(app)
const { token } = await createUserAndLogin(app, admin, '[email protected]')
const me = () =>
app!.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: { authorization: `Bearer ${token}` },
})
expect((await me()).statusCode).toBe(200) // прогрев
const spy = vi.spyOn(app.sqlite, 'prepare')
for (let i = 0; i < 5; i++) expect((await me()).statusCode).toBe(200)
const sqls = spy.mock.calls.map((c) => String(c[0]))
expect(sqls.filter((s) => /user_apps|user_permissions|from "users"/i.test(s)))
.toHaveLength(0)
})
it('блокировка пользователя действует немедленно', async () => {
app = await buildTestApp()
const admin = await adminToken(app)
const { id, token } = await createUserAndLogin(app, admin, '[email protected]')
const me = () =>
app!.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: { authorization: `Bearer ${token}` },
})
expect((await me()).statusCode).toBe(200)
const patch = await app.inject({
method: 'PATCH',
url: `/api/v1/admin/users/${id}`,
headers: { authorization: `Bearer ${admin}` },
payload: { disabled: true },
})
expect(patch.statusCode).toBe(200)
expect((await me()).statusCode).toBe(401)
})
it('смена доступа отражается сразу, удаление → 401', async () => {
app = await buildTestApp()
const admin = await adminToken(app)
const { id, token } = await createUserAndLogin(app, admin, '[email protected]')
const me = async () =>
(
await app!.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: { authorization: `Bearer ${token}` },
})
).json() as { apps?: string[] }
expect((await me()).apps).toEqual(['cfdm'])
const put = await app.inject({
method: 'PUT',
url: `/api/v1/admin/users/${id}/access`,
headers: { authorization: `Bearer ${admin}` },
payload: { apps: ['dns'], permissions: [] },
})
expect(put.statusCode).toBe(200)
expect((await me()).apps).toEqual(['dns'])
await app.inject({
method: 'DELETE',
url: `/api/v1/admin/users/${id}`,
headers: { authorization: `Bearer ${admin}` },
})
const gone = await app.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: { authorization: `Bearer ${token}` },
})
expect(gone.statusCode).toBe(401)
})
})
describe('app-switcher кэш', () => {
it('PUT сразу виден в GET', async () => {
app = await buildTestApp()
const admin = await adminToken(app)
const before = await app.inject({ method: 'GET', url: '/api/v1/app-switcher' })
const cfg = await app.inject({
method: 'GET',
url: '/api/v1/admin/app-switcher',
headers: { authorization: `Bearer ${admin}` },
})
expect(before.statusCode).toBe(200)
expect(cfg.statusCode).toBe(200)
})
})
describe('rate-limit', () => {
it('/health не лимитируется', async () => {
app = await buildTestApp()
for (let i = 0; i < 250; i++) {
const res = await app.inject({ method: 'GET', url: '/health' })
expect(res.statusCode).toBe(200)
}
})
it('логин ограничен 20/мин', async () => {
app = await buildTestApp()
let last = 0
for (let i = 0; i < 22; i++) {
const res = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { email: '[email protected]', password: 'wrongpass' },
})
last = res.statusCode
}
expect(last).toBe(429)
})
it('X-Forwarded-For от недоверенного клиента игнорируется', async () => {
app = await buildTestApp({ TRUST_PROXY: '10.0.0.0/8' })
const res = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
remoteAddress: '203.0.113.9',
headers: { 'x-forwarded-for': '198.51.100.7' },
payload: { email: '[email protected]', password: 'wrongpass' },
})
expect(res.statusCode).toBe(401)
const row = app.sqlite
.prepare(`SELECT ip FROM audit_log WHERE action='auth.login_failed'`)
.get() as { ip: string }
expect(row.ip).toBe('203.0.113.9')
})
})
+232
View File
@@ -0,0 +1,232 @@
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { createOriginChecker } from '../src/lib/cors.js'
import { loadConfig } from '../src/config.js'
import { buildTestApp } from './helpers.js'
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
afterEach(async () => {
await app?.close()
app = undefined
})
function staticDir(): string {
const dir = mkdtempSync(join(tmpdir(), 'ap-static-'))
mkdirSync(join(dir, 'assets'))
writeFileSync(join(dir, 'index.html'), '<html>spa</html>')
writeFileSync(join(dir, 'assets', 'app-abc123.js'), 'console.log(1)')
return dir
}
describe('статика', () => {
it('hashed-ассеты immutable, index.html no-cache', async () => {
app = await buildTestApp({ STATIC_DIR: staticDir() })
const asset = await app.inject({ method: 'GET', url: '/assets/app-abc123.js' })
expect(asset.statusCode).toBe(200)
expect(asset.headers['cache-control']).toBe(
'public, max-age=31536000, immutable',
)
const index = await app.inject({ method: 'GET', url: '/index.html' })
expect(index.headers['cache-control']).toBe('no-cache')
const spa = await app.inject({ method: 'GET', url: '/users/42' })
expect(spa.statusCode).toBe(200)
expect(spa.headers['cache-control']).toBe('no-cache')
})
it('отсутствующий ассет → 404, а не index.html (иначе после деплоя чанк-JS = HTML)', async () => {
app = await buildTestApp({ STATIC_DIR: staticDir() })
const res = await app.inject({ method: 'GET', url: '/assets/missing.js' })
expect(res.statusCode).toBe(404)
expect(res.body).not.toContain('<html>')
})
it('отдаёт предсжатый .br по Accept-Encoding', async () => {
const dir = staticDir()
writeFileSync(join(dir, 'assets', 'app-abc123.js.br'), 'BR-BYTES')
app = await buildTestApp({ STATIC_DIR: dir })
const res = await app.inject({
method: 'GET',
url: '/assets/app-abc123.js',
headers: { 'accept-encoding': 'br' },
})
expect(res.headers['content-encoding']).toBe('br')
expect(res.body).toBe('BR-BYTES')
})
})
describe('кэш-заголовки API', () => {
it('auth/config кэшируется, app-switcher — нет (админка должна видеть правки сразу)', async () => {
app = await buildTestApp()
const sw = await app.inject({ method: 'GET', url: '/api/v1/app-switcher' })
expect(sw.headers['cache-control']).toBeUndefined()
const cfg = await app.inject({ method: 'GET', url: '/api/v1/auth/config' })
expect(cfg.headers['cache-control']).toBe('public, max-age=60')
})
})
describe('health / ready', () => {
it('/health не трогает БД и живёт после закрытия БД', async () => {
app = await buildTestApp()
app.sqlite.close()
const res = await app.inject({ method: 'GET', url: '/health' })
expect(res.statusCode).toBe(200)
})
it('/ready 200 в норме и 503 при недоступной БД', async () => {
app = await buildTestApp()
const ok = await app.inject({ method: 'GET', url: '/ready' })
expect(ok.statusCode).toBe(200)
app.sqlite.close()
const bad = await app.inject({ method: 'GET', url: '/ready' })
expect(bad.statusCode).toBe(503)
expect(bad.json()).toEqual({ ok: false, reason: 'db' })
})
})
describe('CORS', () => {
const origin = (url: string) => ({ origin: url })
it('разрешает issuer/allowlist, блокирует чужие', async () => {
app = await buildTestApp({ RETURN_TO_ALLOWLIST: '.test.local,localhost' })
const allowed = await app.inject({
method: 'GET',
url: '/api/v1/auth/config',
headers: origin('https://auth.test.local'),
})
expect(allowed.headers['access-control-allow-origin']).toBe(
'https://auth.test.local',
)
expect(allowed.headers['access-control-allow-credentials']).toBe('true')
const foreign = await app.inject({
method: 'GET',
url: '/api/v1/auth/config',
headers: origin('https://evil.example'),
})
expect(foreign.headers['access-control-allow-origin']).toBeUndefined()
})
it('CORS_ORIGINS добавляет origin; запрос без Origin проходит', async () => {
app = await buildTestApp({ CORS_ORIGINS: 'https://extra.example' })
const res = await app.inject({
method: 'GET',
url: '/api/v1/auth/config',
headers: origin('https://extra.example'),
})
expect(res.headers['access-control-allow-origin']).toBe(
'https://extra.example',
)
const none = await app.inject({ method: 'GET', url: '/api/v1/auth/config' })
expect(none.statusCode).toBe(200)
})
it('боевой RETURN_TO_ALLOWLIST из env.traefik.example не теряет клиентов', () => {
const list =
'.shnt.top,https://vps.shnt.top,https://cfdm.shnt.top,https://bgp.shnt.top,https://fw.shnt.top,https://dns.shnt.top,https://cdn.shnt.top'
const check = createOriginChecker(
loadConfig({
JWT_SECRET: 'test-secret-at-least-8',
ISSUER: 'https://auth.shnt.top',
RETURN_TO_ALLOWLIST: list,
}),
() => [],
)
for (const o of [
'https://vps.shnt.top',
'https://dns.shnt.top',
'https://shnt.top',
'https://anything.shnt.top',
]) {
expect(check(o)).toBe(true)
}
expect(check('https://evil.example')).toBe(false)
})
it('private в allowlist пускает LAN-хосты', () => {
const check = createOriginChecker(
loadConfig({
JWT_SECRET: 'test-secret-at-least-8',
RETURN_TO_ALLOWLIST: '.shnt.top,private',
}),
() => [],
)
expect(check('http://192.168.1.10:8080')).toBe(true)
expect(check('http://10.0.0.5')).toBe(true)
expect(check('https://8.8.8.8')).toBe(false)
})
it('createOriginChecker: суффикс, localhost, switcher, *', () => {
const base = {
JWT_SECRET: 'test-secret-at-least-8',
ISSUER: 'https://auth.test.local',
RETURN_TO_ALLOWLIST: '.shnt.top,localhost,private',
}
const check = createOriginChecker(loadConfig(base), () => [
'https://dns.custom.io/path',
])
expect(check('https://cfdm.shnt.top')).toBe(true)
expect(check('http://localhost:5173')).toBe(true)
expect(check('https://dns.custom.io')).toBe(true)
expect(check('https://evilshnt.top')).toBe(false)
expect(check('not a url')).toBe(false)
const all = createOriginChecker(
loadConfig({ ...base, CORS_ORIGINS: '*' }),
() => [],
)
expect(all('https://anything.example')).toBe(true)
})
})
describe('сжатие в Node (ENABLE_COMPRESS)', () => {
async function withBigRoute(env: NodeJS.ProcessEnv) {
app = await buildTestApp(env)
app.get('/__big', async () => ({ data: 'x'.repeat(5000) }))
app.get('/__small', async () => ({ ok: true }))
return app
}
it('по умолчанию выключено', async () => {
const a = await withBigRoute({})
const res = await a.inject({
method: 'GET',
url: '/__big',
headers: { 'accept-encoding': 'gzip' },
})
expect(res.headers['content-encoding']).toBeUndefined()
})
it('включено: большой JSON сжимается, маленький нет, без Accept-Encoding нет', async () => {
const a = await withBigRoute({ ENABLE_COMPRESS: 'true' })
const big = await a.inject({
method: 'GET',
url: '/__big',
headers: { 'accept-encoding': 'gzip' },
})
expect(big.headers['content-encoding']).toBe('gzip')
expect(big.rawPayload.length).toBeLessThan(500)
const small = await a.inject({
method: 'GET',
url: '/__small',
headers: { 'accept-encoding': 'gzip' },
})
expect(small.headers['content-encoding']).toBeUndefined()
const none = await a.inject({ method: 'GET', url: '/__big' })
expect(none.headers['content-encoding']).toBeUndefined()
})
});
describe('CSP', () => {
it('отдаётся жёсткая политика без внешних источников', async () => {
app = await buildTestApp()
const res = await app.inject({ method: 'GET', url: '/health' })
const csp = String(res.headers['content-security-policy'])
expect(csp).toContain("default-src 'self'")
expect(csp).toContain("script-src 'self'")
expect(csp).toContain("frame-ancestors 'none'")
expect(csp).toContain("object-src 'none'")
expect(csp).not.toMatch(/script-src[^;]*'unsafe-inline'/)
expect(csp).not.toContain('http:')
})
})
+311
View File
@@ -0,0 +1,311 @@
import { describe, expect, it } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { jwtVerify, createLocalJWKSet } from 'jose'
import { buildApp } from '../src/app.js'
import { loadConfig } from '../src/config.js'
import { resetOidcKeyCache } from '../src/lib/oidc/keys.js'
import { buildTestApp } from './helpers.js'
describe('OIDC IdP', () => {
it('serves discovery and JWKS', async () => {
const app = await buildTestApp()
const discovery = await app.inject({
method: 'GET',
url: '/.well-known/openid-configuration',
})
expect(discovery.statusCode).toBe(200)
const meta = discovery.json() as {
issuer: string
authorization_endpoint: string
jwks_uri: string
}
expect(meta.issuer).toBe('https://auth.test.local')
expect(meta.authorization_endpoint).toContain('/oauth/authorize')
expect(meta.jwks_uri).toContain('/.well-known/jwks.json')
const jwks = await app.inject({
method: 'GET',
url: '/.well-known/jwks.json',
})
expect(jwks.statusCode).toBe(200)
const keys = jwks.json() as { keys: { kid: string; kty: string }[] }
expect(keys.keys.length).toBeGreaterThan(0)
expect(keys.keys[0]?.kty).toBe('RSA')
await app.close()
})
it('records auth.sso_handoff with target_app dns on authorize', async () => {
const app = await buildTestApp()
const login = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { email: '[email protected]', password: 'adminpass' },
})
expect(login.statusCode).toBe(200)
const token = (login.json() as { access_token: string }).access_token
const refresh = login.cookies.find((c) => c.name === 'refresh_token')
expect(refresh?.value).toBeTruthy()
const created = await app.inject({
method: 'POST',
url: '/api/v1/admin/oidc/clients',
headers: { authorization: `Bearer ${token}` },
payload: {
name: 'Technitium',
redirect_uris: ['https://dns.test.local/sso/callback'],
scopes: ['openid', 'profile', 'email', 'groups'],
enabled: true,
},
})
const client = created.json() as { client_id: string }
const authorize = await app.inject({
method: 'GET',
url: '/oauth/authorize',
cookies: { refresh_token: refresh!.value },
query: {
client_id: client.client_id,
redirect_uri: 'https://dns.test.local/sso/callback',
response_type: 'code',
scope: 'openid profile email groups',
state: 'xyz',
},
})
expect(authorize.statusCode).toBe(302)
const audit = await app.inject({
method: 'GET',
url: '/api/v1/admin/audit?kind=logins&limit=50',
headers: { authorization: `Bearer ${token}` },
})
expect(audit.statusCode).toBe(200)
const entries = audit.json() as {
action: string
details: Record<string, unknown> | null
}[]
const handoff = entries.find(
(e) =>
e.action === 'auth.sso_handoff' &&
e.details?.target_app === 'dns' &&
e.details?.auth_mode === 'oidc',
)
expect(handoff).toBeTruthy()
await app.close()
})
it('reloads RS256 key from SQLite after restart (extractable import)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'oidc-key-'))
const dbPath = `sqlite:${join(dir, 'app.db')}`
try {
resetOidcKeyCache()
const config = loadConfig({
...process.env,
JWT_SECRET: 'test-secret-at-least-8',
ADMIN_EMAIL: '[email protected]',
ADMIN_PASSWORD: 'adminpass',
DATABASE_URL: dbPath,
ISSUER: 'https://auth.test.local',
OIDC_ISSUER: 'https://auth.test.local',
NODE_ENV: 'test',
})
const first = await buildApp({ config, databaseUrl: dbPath })
const jwks1 = await first.inject({
method: 'GET',
url: '/.well-known/jwks.json',
})
expect(jwks1.statusCode).toBe(200)
const kid = (jwks1.json() as { keys: { kid: string }[] }).keys[0]?.kid
expect(kid).toBeTruthy()
await first.close()
resetOidcKeyCache()
const second = await buildApp({ config, databaseUrl: dbPath })
const jwks2 = await second.inject({
method: 'GET',
url: '/.well-known/jwks.json',
})
expect(jwks2.statusCode).toBe(200)
expect((jwks2.json() as { keys: { kid: string }[] }).keys[0]?.kid).toBe(
kid,
)
await second.close()
} finally {
try {
rmSync(dir, { recursive: true, force: true })
} catch {
// Windows may keep better-sqlite3 handle briefly
}
}
})
it('authorization code flow issues id_token with groups', async () => {
const app = await buildTestApp()
const login = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { email: '[email protected]', password: 'adminpass' },
})
expect(login.statusCode).toBe(200)
const token = (login.json() as { access_token: string }).access_token
const refresh = login.cookies.find((c) => c.name === 'refresh_token')
expect(refresh?.value).toBeTruthy()
const created = await app.inject({
method: 'POST',
url: '/api/v1/admin/oidc/clients',
headers: { authorization: `Bearer ${token}` },
payload: {
name: 'Technitium',
redirect_uris: ['https://dns.test.local/sso/callback'],
scopes: ['openid', 'profile', 'email', 'groups'],
enabled: true,
},
})
expect(created.statusCode).toBe(200)
const client = created.json() as {
client_id: string
client_secret: string
}
const authorize = await app.inject({
method: 'GET',
url:
'/oauth/authorize?' +
new URLSearchParams({
client_id: client.client_id,
redirect_uri: 'https://dns.test.local/sso/callback',
response_type: 'code',
scope: 'openid profile email groups',
state: 'xyz',
nonce: 'n1',
}).toString(),
cookies: { refresh_token: refresh!.value },
})
expect(authorize.statusCode).toBe(302)
const location = authorize.headers.location!
expect(location).toContain('https://dns.test.local/sso/callback')
const code = new URL(location).searchParams.get('code')
expect(code).toBeTruthy()
const tokenRes = await app.inject({
method: 'POST',
url: '/oauth/token',
payload: {
grant_type: 'authorization_code',
code: code!,
redirect_uri: 'https://dns.test.local/sso/callback',
client_id: client.client_id,
client_secret: client.client_secret,
},
})
expect(tokenRes.statusCode).toBe(200)
const tokens = tokenRes.json() as {
access_token: string
id_token: string
token_type: string
}
expect(tokens.token_type).toBe('Bearer')
const jwksRes = await app.inject({
method: 'GET',
url: '/.well-known/jwks.json',
})
const jwks = createLocalJWKSet(jwksRes.json() as { keys: never[] })
const { payload } = await jwtVerify(tokens.id_token, jwks, {
issuer: 'https://auth.test.local',
audience: client.client_id,
})
expect(payload.sub).toBeTruthy()
expect(payload.email).toBe('[email protected]')
expect(payload.nonce).toBe('n1')
const groups = payload.groups as string[]
expect(groups).toContain('technitium_admins')
expect(groups).toContain('technitium_dns_admins')
const userinfo = await app.inject({
method: 'GET',
url: '/oauth/userinfo',
headers: { authorization: `Bearer ${tokens.access_token}` },
})
expect(userinfo.statusCode).toBe(200)
const info = userinfo.json() as { groups: string[]; email: string }
expect(info.email).toBe('[email protected]')
expect(info.groups).toContain('technitium_admins')
await app.close()
})
it('rejects invalid client secret and redirect_uri mismatch', async () => {
const app = await buildTestApp()
const login = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { email: '[email protected]', password: 'adminpass' },
})
const token = (login.json() as { access_token: string }).access_token
const refresh = login.cookies.find((c) => c.name === 'refresh_token')!
const created = await app.inject({
method: 'POST',
url: '/api/v1/admin/oidc/clients',
headers: { authorization: `Bearer ${token}` },
payload: {
name: 'DNS',
redirect_uris: ['https://dns.test.local/sso/callback'],
scopes: ['openid', 'profile', 'email', 'groups'],
enabled: true,
},
})
const client = created.json() as {
client_id: string
client_secret: string
}
const badRedirect = await app.inject({
method: 'GET',
url:
'/oauth/authorize?' +
new URLSearchParams({
client_id: client.client_id,
redirect_uri: 'https://evil.test/callback',
response_type: 'code',
scope: 'openid',
}).toString(),
cookies: { refresh_token: refresh.value },
})
expect(badRedirect.statusCode).toBe(400)
const authorize = await app.inject({
method: 'GET',
url:
'/oauth/authorize?' +
new URLSearchParams({
client_id: client.client_id,
redirect_uri: 'https://dns.test.local/sso/callback',
response_type: 'code',
scope: 'openid',
}).toString(),
cookies: { refresh_token: refresh.value },
})
const code = new URL(authorize.headers.location!).searchParams.get('code')!
const badSecret = await app.inject({
method: 'POST',
url: '/oauth/token',
payload: {
grant_type: 'authorization_code',
code,
redirect_uri: 'https://dns.test.local/sso/callback',
client_id: client.client_id,
client_secret: 'wrong-secret',
},
})
expect(badSecret.statusCode).toBe(401)
await app.close()
})
})
+120
View File
@@ -0,0 +1,120 @@
import { EventEmitter } from 'node:events'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createDb, closeDb, migrateSchema } from '@authportal/db'
import { registerShutdown } from '../src/lib/shutdown.js'
import { buildTestApp } from './helpers.js'
function fakeApp(close: () => Promise<void>) {
return {
close,
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
} as unknown as Parameters<typeof registerShutdown>[0]
}
describe('registerShutdown', () => {
afterEach(() => {
vi.useRealTimers()
})
it('закрывает приложение один раз и выходит с 0', async () => {
const close = vi.fn(async () => {})
const exit = vi.fn()
const proc = new EventEmitter()
registerShutdown(fakeApp(close), {
timeoutMs: 1000,
exit,
proc: proc as never,
})
proc.emit('SIGTERM')
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(0))
expect(close).toHaveBeenCalledTimes(1)
})
it('повторный сигнал форсирует выход, close не вызывается дважды', async () => {
let release!: () => void
const close = vi.fn(
() => new Promise<void>((r) => (release = r)),
)
const exit = vi.fn()
const proc = new EventEmitter()
registerShutdown(fakeApp(close), {
timeoutMs: 1000,
exit,
proc: proc as never,
})
proc.emit('SIGTERM')
proc.emit('SIGINT')
expect(exit).toHaveBeenCalledWith(1)
expect(close).toHaveBeenCalledTimes(1)
release()
})
it('по таймауту принудительно выходит с кодом 1', async () => {
vi.useFakeTimers()
const exit = vi.fn()
const proc = new EventEmitter()
registerShutdown(
fakeApp(() => new Promise<void>(() => {})),
{ timeoutMs: 500, exit, proc: proc as never },
)
proc.emit('SIGTERM')
await vi.advanceTimersByTimeAsync(501)
expect(exit).toHaveBeenCalledWith(1)
})
it('ошибка close → выход с кодом 1', async () => {
const exit = vi.fn()
const proc = new EventEmitter()
registerShutdown(
fakeApp(async () => {
throw new Error('boom')
}),
{ timeoutMs: 1000, exit, proc: proc as never },
)
proc.emit('SIGTERM')
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1))
})
it('unhandledRejection запускает shutdown с кодом 1', async () => {
const exit = vi.fn()
const proc = new EventEmitter()
registerShutdown(fakeApp(async () => {}), {
timeoutMs: 1000,
exit,
proc: proc as never,
})
proc.emit('unhandledRejection', new Error('x'))
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1))
})
it('dispose снимает обработчики', () => {
const proc = new EventEmitter()
const h = registerShutdown(fakeApp(async () => {}), {
timeoutMs: 1000,
exit: vi.fn(),
proc: proc as never,
})
h.dispose()
expect(proc.listenerCount('SIGTERM')).toBe(0)
expect(proc.listenerCount('unhandledRejection')).toBe(0)
})
})
describe('closeDb', () => {
it('идемпотентно закрывает БД', () => {
const { sqlite } = createDb('sqlite::memory:')
migrateSchema(sqlite)
closeDb(sqlite)
expect(sqlite.open).toBe(false)
expect(() => closeDb(sqlite)).not.toThrow()
expect(() => sqlite.prepare('SELECT 1')).toThrow()
})
it('app.close() закрывает БД приложения', async () => {
const app = await buildTestApp()
await app.ready()
expect(app.sqlite.open).toBe(true)
await app.close()
expect(app.sqlite.open).toBe(false)
})
})
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { targetAppFromReturnTo } from '../src/lib/target-app.js'
describe('targetAppFromReturnTo', () => {
it('maps dns host and /sso/ path', () => {
expect(targetAppFromReturnTo('https://dns.shnt.top/sso/callback')).toBe(
'dns',
)
})
it('unwraps portal /oauth/authorize return_to via redirect_uri', () => {
const returnTo =
'https://auth.shnt.top/oauth/authorize?client_id=abc&redirect_uri=' +
encodeURIComponent('https://dns.shnt.top/sso/callback') +
'&response_type=code&scope=openid'
expect(targetAppFromReturnTo(returnTo)).toBe('dns')
})
it('keeps portal for bare issuer authorize without redirect_uri', () => {
expect(
targetAppFromReturnTo('https://auth.shnt.top/oauth/authorize'),
).toBe('portal')
})
it('maps cdn host', () => {
expect(targetAppFromReturnTo('https://cdn.shnt.top/auth/callback')).toBe(
'cdn',
)
})
it('maps mm host', () => {
expect(targetAppFromReturnTo('https://mm.shnt.top/auth/callback')).toBe(
'mm',
)
})
})
+160
View File
@@ -0,0 +1,160 @@
import { describe, expect, it } from 'vitest'
import { createWebauthnCredential } from '@authportal/db'
import { adminToken, buildTestApp } from './helpers.js'
describe('webauthn / passkeys', () => {
it('exposes webauthn flag on auth config', async () => {
const app = await buildTestApp()
const res = await app.inject({ method: 'GET', url: '/api/v1/auth/config' })
expect(res.statusCode).toBe(200)
expect(res.json()).toMatchObject({ webauthn: true })
expect(app.config.webauthnRpID).toBe('auth.test.local')
expect(app.config.webauthnOrigins).toContain('https://auth.test.local')
expect(app.config.webauthnOrigins).toContain('http://localhost:5173')
await app.close()
})
it('requires JWT for register options', async () => {
const app = await buildTestApp()
const denied = await app.inject({
method: 'POST',
url: '/api/v1/webauthn/register/options',
})
expect(denied.statusCode).toBe(401)
const token = await adminToken(app)
const ok = await app.inject({
method: 'POST',
url: '/api/v1/webauthn/register/options',
headers: { authorization: `Bearer ${token}` },
})
expect(ok.statusCode).toBe(200)
const body = ok.json() as {
challenge_id: string
options: { challenge: string; rp: { id: string } }
}
expect(body.challenge_id).toBeTruthy()
expect(body.options.challenge).toBeTruthy()
expect(body.options.rp.id).toBe('auth.test.local')
await app.close()
})
it('allows public login options and rejects a bogus assertion', async () => {
const app = await buildTestApp()
const options = await app.inject({
method: 'POST',
url: '/api/v1/webauthn/login/options',
})
expect(options.statusCode).toBe(200)
const body = options.json() as { challenge_id: string; options: unknown }
expect(body.challenge_id).toBeTruthy()
const login = await app.inject({
method: 'POST',
url: '/api/v1/webauthn/login',
payload: {
challenge_id: body.challenge_id,
response: { id: 'not-a-credential', type: 'public-key' },
},
})
expect(login.statusCode).toBe(401)
await app.close()
})
it('lists, deletes own credentials and reports passkey_count', async () => {
const app = await buildTestApp()
const token = await adminToken(app)
const me = await app.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: { authorization: `Bearer ${token}` },
})
const userId = (me.json() as { id: string }).id
createWebauthnCredential(app.db, {
userId,
credentialId: 'dGVzdC1jcmVkLWlk',
publicKey: 'dGVzdC1wdWJrZXk',
counter: 0,
name: 'Test key',
})
const listed = await app.inject({
method: 'GET',
url: '/api/v1/webauthn/credentials',
headers: { authorization: `Bearer ${token}` },
})
expect(listed.statusCode).toBe(200)
const creds = listed.json() as { id: string; name: string }[]
expect(creds).toHaveLength(1)
expect(creds[0]?.name).toBe('Test key')
const users = await app.inject({
method: 'GET',
url: '/api/v1/admin/users',
headers: { authorization: `Bearer ${token}` },
})
const admin = (
users.json() as { email: string; passkey_count: number }[]
).find((u) => u.email === '[email protected]')
expect(admin?.passkey_count).toBe(1)
const renamed = await app.inject({
method: 'PATCH',
url: `/api/v1/webauthn/credentials/${creds[0]!.id}`,
headers: { authorization: `Bearer ${token}` },
payload: { name: 'Laptop' },
})
expect(renamed.statusCode).toBe(200)
expect((renamed.json() as { name: string }).name).toBe('Laptop')
const deleted = await app.inject({
method: 'DELETE',
url: `/api/v1/webauthn/credentials/${creds[0]!.id}`,
headers: { authorization: `Bearer ${token}` },
})
expect(deleted.statusCode).toBe(200)
const empty = await app.inject({
method: 'GET',
url: '/api/v1/webauthn/credentials',
headers: { authorization: `Bearer ${token}` },
})
expect(empty.json()).toEqual([])
await app.close()
})
it('lets admin reset a user passkeys', async () => {
const app = await buildTestApp()
const token = await adminToken(app)
const me = await app.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: { authorization: `Bearer ${token}` },
})
const userId = (me.json() as { id: string }).id
createWebauthnCredential(app.db, {
userId,
credentialId: 'cmVzZXQta2V5',
publicKey: 'cHVia2V5',
counter: 1,
name: 'To reset',
})
const reset = await app.inject({
method: 'DELETE',
url: `/api/v1/admin/users/${userId}/passkeys`,
headers: { authorization: `Bearer ${token}` },
})
expect(reset.statusCode).toBe(200)
expect(reset.json()).toMatchObject({ ok: true, removed: 1 })
const listed = await app.inject({
method: 'GET',
url: '/api/v1/webauthn/credentials',
headers: { authorization: `Bearer ${token}` },
})
expect(listed.json()).toEqual([])
await app.close()
})
})
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"outDir": "dist",
"rootDir": "src",
"ignoreDeprecations": "6.0",
"types": ["node"]
},
"include": ["src"]
}
+27
View File
@@ -0,0 +1,27 @@
import { defineConfig } from 'tsup'
// Нативные модули остаются внешними (ставятся в образ отдельно),
// всё остальное (fastify, jose, drizzle, workspace-пакеты) бандлится в один файл.
const NATIVE = ['better-sqlite3', '@node-rs/argon2']
export default defineConfig({
entry: ['src/server.ts'],
format: ['esm'],
target: 'node22',
external: NATIVE,
noExternal: [/^(?!better-sqlite3$|@node-rs\/argon2).+/],
// CJS-зависимости внутри ESM-бандла используют require/__dirname.
banner: {
js: [
"import { createRequire as __cr } from 'node:module';",
"import { fileURLToPath as __fu } from 'node:url';",
"import { dirname as __dn } from 'node:path';",
'const require = __cr(import.meta.url);',
'const __filename = __fu(import.meta.url);',
'const __dirname = __dn(__filename);',
].join('\n'),
},
// Один файл: динамические import() (напр. CLI backup) не должны порождать чанки.
splitting: false,
clean: true,
})
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
passWithNoTests: true,
include: ['test/**/*.test.ts'],
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
reporter: ['text-summary', 'lcov'],
// Стартовые пороги: поднимать по мере роста покрытия, не снижать.
thresholds: { lines: 75 },
},
},
})
+32
View File
@@ -0,0 +1,32 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "../../packages/ui/src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"registries": {
"@reui": {
"url": "https://reui.io/r/{style}/{name}.json",
"headers": {
"Authorization": "Bearer ${REUI_LICENSE_KEY}"
}
}
},
"aliases": {
"components": "@/components",
"utils": "@authportal/ui/lib/utils",
"ui": "@authportal/ui/components",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle"
}
+49
View File
@@ -0,0 +1,49 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist', 'src/routeTree.gen.ts', 'src/components/reui/**']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
rules: {
'react-hooks/set-state-in-effect': 'off',
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@/components/ui/*'],
message: 'Импортируйте примитивы через @authportal/ui/components/*',
},
],
},
],
},
},
{
files: ['src/routes/**/*.{ts,tsx}'],
rules: {
'react-refresh/only-export-components': 'off',
},
},
{
files: ['src/components/reui-kit/**/*.{ts,tsx}'],
rules: {
'react-refresh/only-export-components': 'off',
'react-hooks/incompatible-library': 'off',
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Auth Portal</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+61
View File
@@ -0,0 +1,61 @@
{
"name": "web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsr generate && vite build",
"typecheck": "tsr generate && tsc -b",
"test": "vitest run --passWithNoTests",
"lint": "eslint .",
"preview": "vite preview",
"analyze": "vite build --mode analyze"
},
"dependencies": {
"@authportal/shared": "workspace:*",
"@authportal/ui": "workspace:*",
"@base-ui/react": "catalog:",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.4.0",
"@simplewebauthn/browser": "^13.3.0",
"@tailwindcss/vite": "^4.3.1",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-router": "^1.170.15",
"@tanstack/react-table": "^9.3.1",
"@tanstack/react-virtual": "^3.14.21",
"@tanstack/router-vite-plugin": "^1.167.18",
"class-variance-authority": "^0.7.1",
"date-fns": "^4.4.0",
"lucide-react": "catalog:",
"next-themes": "^0.4.6",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-hook-form": "^7.79.0",
"sonner": "^2.0.7",
"tailwindcss": "^4.3.1",
"zod": "catalog:"
},
"devDependencies": {
"@eslint/js": "catalog:",
"@tanstack/router-cli": "^1.167.31",
"@types/node": "catalog:",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "catalog:",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "catalog:",
"jsdom": "^30.1.3",
"rollup-plugin-visualizer": "^7.1.1",
"tw-animate-css": "^1.0.0",
"typescript": "catalog:",
"typescript-eslint": "catalog:",
"vite": "^8.0.12",
"vitest": "catalog:"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated

After

Width:  |  Height:  |  Size: 9.3 KiB

+142
View File
@@ -0,0 +1,142 @@
import { Link, useRouterState } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import {
AppWindowIcon,
FingerprintIcon,
HistoryIcon,
KeyRoundIcon,
LayoutGridIcon,
LogInIcon,
UsersIcon,
} from 'lucide-react'
import { AppSwitcher } from '@/components/app-switcher'
import { NavUser } from '@/components/nav-user'
import { meQueryOptions } from '@/queries/auth'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@authportal/ui/components/sidebar'
function isActive(pathname: string, to: string, exact: boolean) {
if (exact) return pathname === to
return pathname === to || pathname.startsWith(`${to}/`)
}
export function AppSidebar() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const { data: me } = useQuery(meQueryOptions)
return (
<Sidebar collapsible="icon">
<SidebarHeader>
<AppSwitcher />
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Портал</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
tooltip="Приложения"
isActive={isActive(pathname, '/apps', true)}
render={<Link to="/apps" />}
>
<LayoutGridIcon className="size-4" />
<span>Приложения</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
tooltip="Безопасность"
isActive={isActive(pathname, '/account', true)}
render={<Link to="/account" />}
>
<FingerprintIcon className="size-4" />
<span>Безопасность</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{me?.is_admin ? (
<SidebarGroup>
<SidebarGroupLabel>Админ</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
tooltip="Пользователи"
isActive={
isActive(pathname, '/admin', false) &&
!pathname.startsWith('/admin/apps') &&
!pathname.startsWith('/admin/audit') &&
!pathname.startsWith('/admin/logins') &&
!pathname.startsWith('/admin/oidc')
}
render={<Link to="/admin" />}
>
<UsersIcon className="size-4" />
<span>Пользователи</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
tooltip="Входы"
isActive={isActive(pathname, '/admin/logins', false)}
render={<Link to="/admin/logins" />}
>
<LogInIcon className="size-4" />
<span>Входы</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
tooltip="Изменения"
isActive={isActive(pathname, '/admin/audit', false)}
render={<Link to="/admin/audit" />}
>
<HistoryIcon className="size-4" />
<span>Изменения</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
tooltip="Приложения"
isActive={isActive(pathname, '/admin/apps', false)}
render={<Link to="/admin/apps" />}
>
<AppWindowIcon className="size-4" />
<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>
) : null}
</SidebarContent>
<SidebarFooter>
<NavUser />
</SidebarFooter>
</Sidebar>
)
}
+109
View File
@@ -0,0 +1,109 @@
import { useQuery } from '@tanstack/react-query'
import {
CheckIcon,
ChevronsUpDownIcon,
KeyRoundIcon,
CloudIcon,
ServerIcon,
NetworkIcon,
LayoutDashboardIcon,
ChartColumnIcon,
} from 'lucide-react'
import type { AppSwitcherIconName } from '@authportal/shared'
import { appSwitcherQueryOptions } from '@/queries/app-switcher'
import { ssoOpenApp } from '@/lib/auth'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu'
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@authportal/ui/components/sidebar'
const PORTAL = {
id: 'portal',
name: 'Auth Portal',
subtitle: 'shnt.top',
}
const ICON_MAP: Record<
AppSwitcherIconName,
React.ComponentType<{ className?: string }>
> = {
cloud: CloudIcon,
server: ServerIcon,
globe: NetworkIcon,
dashboard: LayoutDashboardIcon,
chart: ChartColumnIcon,
}
export function AppSwitcher() {
const { isMobile } = useSidebar()
const { data } = useQuery(appSwitcherQueryOptions)
const menuLabel = data?.menuLabel ?? 'Приложения'
const apps = (data?.apps ?? []).filter((a) => a.enabled !== false)
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />
}
>
<div
className="bg-primary text-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md"
aria-hidden
>
<KeyRoundIcon className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{PORTAL.name}</span>
<span className="text-muted-foreground truncate text-xs">
{PORTAL.subtitle}
</span>
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="start"
sideOffset={4}
>
<div className="text-muted-foreground px-2 py-1.5 text-xs">
{menuLabel}
</div>
<DropdownMenuItem disabled>
<KeyRoundIcon />
Auth Portal
<CheckIcon className="ml-auto size-4" />
</DropdownMenuItem>
{apps.map((app) => {
const Icon = ICON_MAP[app.icon] ?? ServerIcon
return (
<DropdownMenuItem
key={app.id}
onClick={() => {
void ssoOpenApp(app.url, app.authMode ?? 'jwt').catch(() => {
window.location.href = app.url.replace(/\/$/, '')
})
}}
>
<Icon />
{app.name}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
+35
View File
@@ -0,0 +1,35 @@
import { cn } from "@authportal/ui/lib/utils"
import { Item, ItemMedia } from "@authportal/ui/components/item"
export function AuthLogo({ className }: { className?: string }) {
return (
<Item
variant="outline"
className={cn(
"p-0",
"text-primary flex size-8 shrink-0 items-center justify-center",
className
)}
aria-hidden="true"
>
<ItemMedia variant="icon" className="size-auto">
<svg
width="50"
height="50"
viewBox="25.668 25.1352 49.6644 50"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="size-4"
>
<circle cx="70.634" cy="29.8334" r="4.69799" fill="currentColor" />
<path
fillRule="evenodd"
clipRule="evenodd"
d="M25.668 57.0144V29.8332C25.668 27.2386 27.7713 25.1352 30.366 25.1352C32.9606 25.1352 35.0639 27.2386 35.0639 29.8332V57.0144C35.0639 61.833 38.9702 65.7392 43.7888 65.7392H57.2116C62.0302 65.7392 65.9364 61.833 65.9364 57.0144V43.7258C65.9364 41.1312 68.0398 39.0278 70.6344 39.0278C73.229 39.0278 75.3324 41.1312 75.3324 43.7258V57.0144C75.3324 67.0222 67.2194 75.1352 57.2116 75.1352H43.7888C33.7809 75.1352 25.668 67.0222 25.668 57.0144Z"
fill="currentColor"
/>
</svg>
</ItemMedia>
</Item>
)
}
@@ -0,0 +1,34 @@
import type { CSSProperties, ReactNode } from 'react'
import { AppSidebar } from '@/components/app-sidebar'
import { SiteHeader } from '@/components/layout/site-header'
import { TooltipProvider } from '@authportal/ui/components/tooltip'
import { SidebarInset, SidebarProvider } from '@authportal/ui/components/sidebar'
const SKIP_TO_CONTENT_CLASS =
'bg-background text-foreground ring-ring sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:rounded-md focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-sm focus:ring-2'
/** Shared ops chrome — etalon app-shell-12. */
export function AppShell({ children }: { children: ReactNode }) {
return (
<TooltipProvider delay={0}>
<SidebarProvider
style={
{
'--sidebar-width': '240px',
} as CSSProperties
}
>
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
К содержимому
</a>
<AppSidebar />
<SidebarInset id="main-content">
<SiteHeader />
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
{children}
</div>
</SidebarInset>
</SidebarProvider>
</TooltipProvider>
)
}
@@ -0,0 +1,80 @@
import type { ComponentType } from 'react'
import {
LayoutGridIcon,
KeyRoundIcon,
CloudIcon,
ServerIcon,
NetworkIcon,
ShieldIcon,
GlobeIcon,
LayoutDashboardIcon,
} from 'lucide-react'
import { APPS, type AppId } from '@authportal/shared'
import { Button } from '@authportal/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu'
const APP_ICONS: Record<
AppId,
ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
> = {
cfdm: CloudIcon,
vps: ServerIcon,
bgp: NetworkIcon,
fw: ShieldIcon,
dns: GlobeIcon,
cdn: CloudIcon,
mm: LayoutDashboardIcon,
}
export function AppsMenu() {
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon" aria-label="Приложения" />
}
>
<LayoutGridIcon className="size-4.5" aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" sideOffset={8} className="w-72">
<DropdownMenuGroup>
<DropdownMenuLabel>Приложения</DropdownMenuLabel>
<div className="grid grid-cols-2 gap-1 p-1">
<DropdownMenuItem
disabled
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
>
<span className="text-muted-foreground">
<KeyRoundIcon aria-hidden />
</span>
<span className="text-xs font-medium">Auth Portal</span>
</DropdownMenuItem>
{APPS.map((app) => {
const Icon = APP_ICONS[app.id]
return (
<DropdownMenuItem
key={app.id}
nativeButton={false}
render={<a href={app.url} target="_blank" rel="noreferrer" />}
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
>
<span className="text-muted-foreground">
<Icon aria-hidden />
</span>
<span className="text-xs font-medium">{app.title}</span>
</DropdownMenuItem>
)
})}
</div>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,211 @@
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import {
AppWindowIcon,
FingerprintIcon,
HistoryIcon,
KeyRoundIcon,
LayoutGridIcon,
LogInIcon,
PlusIcon,
UsersIcon,
} from 'lucide-react'
import { AppSwitcher } from '@/components/app-switcher'
import { CommandPalette, PortalHeader, PortalSidebar } from '@/components/portal/chrome'
import { ChromeConfigProvider, PortalUiProvider } from '@/components/portal/chrome-context'
import type { ChromeConfig, MonitorItem, MonitorMetric, NavItem, PaletteObject } from '@/components/portal/chrome-store'
import { auditQueryOptions } from '@/queries/audit'
import { catalogQueryOptions, meQueryOptions, usersQueryOptions } from '@/queries/auth'
import { oidcClientsQueryOptions } from '@/queries/oidc'
import { sessionsQueryOptions } from '@/queries/sessions'
import { Skeleton } from '@authportal/ui/components/skeleton'
import { SidebarInset, SidebarProvider } from '@authportal/ui/components/sidebar'
import { TooltipProvider } from '@authportal/ui/components/tooltip'
const SKIP_TO_CONTENT_CLASS =
'bg-background text-foreground ring-ring sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:rounded-md focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-sm focus:ring-2'
const FRESH: Record<string, { badge: string; text: string }> = {
'/apps': { badge: 'Портал', text: 'Права из базы портала, а не из токена · SSO открывает приложение с текущей сессией' },
'/account': { badge: 'WebAuthn', text: 'Passkeys привязаны к домену портала · пароль остаётся запасным входом' },
'/admin/logins': { badge: 'Журнал', text: 'GET /api/v1/admin/audit?kind=logins · хранение по настройке ротации журнала' },
'/admin/audit': { badge: 'Журнал', text: 'GET /api/v1/admin/audit?kind=changes · события портала и приложений через ingest' },
'/admin/oidc': { badge: 'OIDC', text: 'Discovery и JWKS публикует портал · секрет клиента показывается один раз' },
'/admin/apps': { badge: 'Портал', text: 'portal_settings.app_switcher_json · публичный GET /api/v1/app-switcher' },
'/admin': { badge: 'Портал', text: 'GET /api/v1/admin/users · права на приложения и разделы' },
}
function freshFor(pathname: string) {
const key = Object.keys(FRESH)
.sort((a, b) => b.length - a.length)
.find((k) => pathname === k || pathname.startsWith(`${k}/`))
return FRESH[key ?? '/apps']!
}
function initialsOf(name: string, email: string) {
const src = (name || email).trim()
const parts = src.split(/[\s@._-]+/).filter(Boolean)
return ((parts[0]?.[0] ?? '?') + (parts[1]?.[0] ?? '')).toUpperCase()
}
/** Меню портала: «Портал» и «Админ» (для администраторов). Точки проблем приходят из данных. */
function useChromeData(): ChromeConfig {
const navigate = useNavigate()
const meQ = useQuery(meQueryOptions)
const isAdmin = Boolean(meQ.data?.is_admin)
const catalogQ = useQuery(catalogQueryOptions)
const usersQ = useQuery({ ...usersQueryOptions, enabled: isAdmin, retry: false })
const oidcQ = useQuery({ ...oidcClientsQueryOptions, enabled: isAdmin, retry: false })
const sessionsQ = useQuery({ ...sessionsQueryOptions(), enabled: isAdmin, retry: false })
const loginsQ = useQuery({ ...auditQueryOptions({ kind: 'logins', limit: 100 }), enabled: isAdmin, retry: false })
return useMemo<ChromeConfig>(() => {
const users = usersQ.data ?? []
const noPasskey = users.filter((u) => !u.disabled && u.passkey_count === 0)
const clients = oidcQ.data ?? []
const hourAgo = loginsQ.dataUpdatedAt - 60 * 60 * 1000
const failed = (loginsQ.data ?? []).filter((e) => e.action === 'auth.login_failed' && new Date(e.created_at).getTime() >= hourAgo)
const navItems: NavItem[] = [
{ id: 'apps', label: 'Приложения', to: '/apps', icon: LayoutGridIcon, group: 'Портал' },
{ id: 'account', label: 'Безопасность', to: '/account', icon: FingerprintIcon, group: 'Портал' },
]
if (isAdmin) {
navItems.push(
{ id: 'users', label: 'Пользователи', to: '/admin', icon: UsersIcon, group: 'Админ' },
{
id: 'logins',
label: 'Входы',
to: '/admin/logins',
icon: LogInIcon,
group: 'Админ',
problem: failed.length >= 5 ? { tone: 'er', label: `${failed.length} неудачных входов за час` } : undefined,
},
{ id: 'audit', label: 'Изменения', to: '/admin/audit', icon: HistoryIcon, group: 'Админ' },
{ id: 'oidc', label: 'OIDC-клиенты', to: '/admin/oidc', icon: KeyRoundIcon, group: 'Админ' },
{ id: 'app-links', label: 'Ссылки приложений', to: '/admin/apps', icon: AppWindowIcon, group: 'Админ' },
)
}
const monitorItems: MonitorItem[] = []
if (failed.length >= 5) {
monitorItems.push({
id: 'bruteforce',
tone: 'er',
title: `Неудачных входов за час: ${failed.length}`,
text: 'Проверьте журнал входов и при необходимости отключите учётную запись',
age: 'час',
to: '/admin/logins',
})
}
if (noPasskey.length > 0) {
monitorItems.push({
id: 'nopasskey',
tone: 'wn',
title: `${noPasskey.length} без passkey`,
text: noPasskey.slice(0, 3).map((u) => u.email.split('@')[0]).join(', ') + ' · вход только по паролю',
age: 'сейчас',
to: '/admin',
})
}
monitorItems.push({ id: 'api', tone: 'ok', title: 'API портала', text: 'GET /ready', age: 'сейчас', to: '/apps' })
const enabledClients = clients.filter((c) => c.enabled).length
const metrics: MonitorMetric[] = isAdmin
? [
{ l: 'Пользователи без passkey', v: `${noPasskey.length} из ${users.length}`, p: users.length ? Math.round((noPasskey.length / users.length) * 100) : 0, tone: noPasskey.length ? 'warning' : 'success' },
{ l: 'Приложения доступны', v: String(catalogQ.data?.apps.length ?? 0), p: 100, tone: 'success' },
{ l: 'OIDC-клиенты', v: `${enabledClients} из ${clients.length}`, p: clients.length ? Math.round((enabledClients / clients.length) * 100) : 100, tone: enabledClients === clients.length ? 'success' : 'warning' },
{ l: 'Сессии', v: String(sessionsQ.data?.length ?? 0), p: Math.min(100, (sessionsQ.data?.length ?? 0) * 10), tone: 'success' },
]
: [{ l: 'Приложения доступны', v: String(catalogQ.data?.apps.length ?? 0), p: 100, tone: 'success' }]
const objects: PaletteObject[] = [
...(isAdmin
? users.map<PaletteObject>((u) => ({
id: `user-${u.id}`,
group: 'Пользователи',
label: u.email,
hint: u.disabled ? 'Отключён' : u.is_admin ? 'Админ' : 'Пользователь',
dot: u.disabled ? 'mu' : u.passkey_count === 0 ? 'er' : 'ok',
to: `/admin/users/${u.id}`,
}))
: []),
...(catalogQ.data?.apps ?? []).map<PaletteObject>((a) => ({ id: `app-${a.id}`, group: 'Приложения', label: a.title, hint: a.url.replace(/^https?:\/\//, ''), dot: 'mu', to: '/apps' })),
...(isAdmin
? clients.map<PaletteObject>((c) => ({ id: `oidc-${c.id}`, group: 'OIDC-клиенты', label: c.name, hint: `client_id ${c.client_id}`, dot: 'mu', to: '/admin/oidc' }))
: []),
]
const me = meQ.data
return {
navItems,
navGroups: isAdmin ? ['Портал', 'Админ'] : ['Портал'],
brand: <AppSwitcher />,
user: { name: me?.name ?? '…', email: me?.email ?? '', initials: initialsOf(me?.name ?? '', me?.email ?? '') },
securityTo: '/account',
onLogout: () => void navigate({ to: '/logout' }),
fresh: freshFor,
monitor: { items: monitorItems, metrics, updated: 'обновлено сейчас', footerTo: isAdmin ? '/admin/logins' : '/apps', footerLabel: isAdmin ? 'Журнал входов' : 'Приложения' },
palette: {
actions: isAdmin
? [
{ id: 'act-user', label: 'Создать пользователя', hint: 'U', icon: PlusIcon, to: '/admin' },
{ id: 'act-client', label: 'Создать OIDC-клиент', hint: 'O', icon: PlusIcon, to: '/admin/oidc' },
{ id: 'act-passkey', label: 'Добавить passkey', hint: 'K', icon: PlusIcon, to: '/account' },
{ id: 'act-logins', label: 'Открыть журнал входов', hint: 'L', icon: LogInIcon, to: '/admin/logins' },
]
: [{ id: 'act-passkey', label: 'Добавить passkey', hint: 'K', icon: PlusIcon, to: '/account' }],
objects,
assistantTo: isAdmin ? '/admin/logins' : '/apps',
example: 'Подсказка: «неудачные входы ops за сутки»',
},
}
}, [navigate, meQ.data, isAdmin, catalogQ.data, usersQ.data, oidcQ.data, sessionsQ.data, loginsQ.data, loginsQ.dataUpdatedAt])
}
/** Оболочка портала: боковое меню, шапка, палитра ⌘K. Макет — `design-preview` на тех же блоках. */
export function PortalShell({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(true)
const [mounted, setMounted] = useState(false)
useEffect(() => {
// До 1100px меню сворачивается в колонку иконок (на телефоне его заменяет Sheet).
const mq = window.matchMedia('(min-width: 1100px)')
const sync = () => setOpen(mq.matches)
sync()
mq.addEventListener('change', sync)
setMounted(true)
return () => mq.removeEventListener('change', sync)
}, [])
const config = useChromeData()
if (!mounted) {
return (
<div className="flex min-h-svh flex-col gap-4 p-6" aria-busy>
<Skeleton className="h-12 w-full" />
<Skeleton className="h-64 w-full" />
</div>
)
}
return (
<PortalUiProvider>
<ChromeConfigProvider value={config}>
<TooltipProvider delay={0}>
<SidebarProvider open={open} onOpenChange={setOpen} style={{ '--sidebar-width': '240px' } as CSSProperties}>
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
К содержимому
</a>
<PortalSidebar />
<SidebarInset id="main-content" className="min-w-0">
<PortalHeader />
<div className="flex w-full min-w-0 flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">{children}</div>
</SidebarInset>
<CommandPalette />
</SidebarProvider>
</TooltipProvider>
</ChromeConfigProvider>
</PortalUiProvider>
)
}
@@ -0,0 +1,83 @@
import { Fragment, useMemo } from 'react'
import { Link, useRouterState } from '@tanstack/react-router'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@authportal/ui/components/breadcrumb'
import { Separator } from '@authportal/ui/components/separator'
import { SidebarTrigger } from '@authportal/ui/components/sidebar'
import { AppsMenu } from '@/components/layout/apps-menu'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
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' }]
}
if (pathname.startsWith('/admin/audit')) {
return [{ label: 'Журнал изменений', href: '/admin/audit' }]
}
if (pathname.startsWith('/admin/users/')) {
return [
{ label: 'Пользователи', href: '/admin' },
{ label: 'Права доступа', href: pathname },
]
}
if (pathname.startsWith('/admin')) {
return [{ label: 'Пользователи', href: '/admin' }]
}
if (pathname.startsWith('/apps')) {
return [{ label: 'Приложения', href: '/apps' }]
}
return [{ label: 'Auth Portal', href: '/apps' }]
}
export function SiteHeader() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const crumbs = useMemo(() => breadcrumbs(pathname), [pathname])
return (
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
className="mr-2 data-[orientation=vertical]:h-4"
/>
<Breadcrumb>
<BreadcrumbList>
{crumbs.map((crumb, i) => {
const last = i === crumbs.length - 1
return (
<Fragment key={crumb.href}>
{i > 0 ? <BreadcrumbSeparator /> : null}
<BreadcrumbItem>
{last ? (
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
) : (
<BreadcrumbLink render={<Link to={crumb.href} />}>
{crumb.label}
</BreadcrumbLink>
)}
</BreadcrumbItem>
</Fragment>
)
})}
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex items-center gap-2">
<AppsMenu />
<SystemMonitorPopover />
</div>
</header>
)
}
@@ -0,0 +1,255 @@
import { useMemo, useState, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Activity, AppWindow, KeyRound, Users } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { cn } from '@authportal/ui/lib/utils'
import { Item, ItemMedia } from '@authportal/ui/components/item'
import { Popover, PopoverContent, PopoverTrigger } from '@authportal/ui/components/popover'
import { catalogQueryOptions, meQueryOptions, usersQueryOptions } from '@/queries/auth'
import { oidcClientsQueryOptions } from '@/queries/oidc'
type MonitorMetric = {
id: string
label: string
value: string
unit: string
percent: number
icon: ReactNode
tone: 'success' | 'warning' | 'destructive' | 'info'
alert: boolean
}
function toneColor(tone: MonitorMetric['tone']) {
switch (tone) {
case 'success':
return 'var(--color-success)'
case 'warning':
return 'var(--color-warning)'
case 'destructive':
return 'var(--color-destructive)'
default:
return 'var(--color-info)'
}
}
function MetricBar({ percent, color }: { percent: number; color: string }) {
return (
<div className="bg-muted h-1 w-full overflow-hidden rounded-full">
<div
className="h-full rounded-full"
style={{
width: `${Math.min(100, Math.max(0, percent))}%`,
backgroundColor: color,
}}
/>
</div>
)
}
function MetricCell({ metric }: { metric: MonitorMetric }) {
const color = toneColor(metric.tone)
return (
<div className="flex flex-col gap-2 p-3">
<div className="flex items-center justify-between gap-1">
<div className="flex min-w-0 items-center gap-1.5">
<Item
className="flex size-5 shrink-0 items-center justify-center p-0"
style={{ backgroundColor: `${color}18` }}
>
<ItemMedia variant="icon" className="size-auto" style={{ color }}>
{metric.icon}
</ItemMedia>
</Item>
<span className="text-muted-foreground truncate text-[11px]">{metric.label}</span>
</div>
<span className="shrink-0 text-xs font-semibold tabular-nums" style={{ color }}>
{metric.value}
<span className="text-muted-foreground ml-0.5 text-[10px] font-normal">{metric.unit}</span>
</span>
</div>
<MetricBar percent={metric.percent} color={color} />
</div>
)
}
/** Live system monitor popover — app-shell-7, Auth Portal metrics. @see https://reui.io/preview/base/app-shell-7 */
export function SystemMonitorPopover() {
// Поллинг живёт только пока поповер открыт: закрытый монитор не должен
// каждые 30 секунд тянуть /ready, каталог и админские списки.
const [open, setOpen] = useState(false)
const meQ = useQuery(meQueryOptions)
const isAdmin = Boolean(meQ.data?.is_admin)
const readyQ = useQuery({
queryKey: ['ready', 'monitor'],
queryFn: async () => {
const res = await fetch('/ready')
if (!res.ok) throw new Error('not ready')
return (await res.json()) as { ok?: boolean }
},
enabled: open,
refetchInterval: 30_000,
retry: false,
})
const catalogQ = useQuery({
...catalogQueryOptions,
enabled: open,
refetchInterval: 30_000,
})
const usersQ = useQuery({
...usersQueryOptions,
enabled: isAdmin && open,
retry: false,
refetchInterval: 30_000,
})
const oidcQ = useQuery({
...oidcClientsQueryOptions,
enabled: isAdmin && open,
retry: false,
refetchInterval: 30_000,
})
const monitorFetched = readyQ.isFetched || catalogQ.isFetched
// До первого открытия данных нет — нейтральное состояние вместо ложной тревоги.
const apiOk = readyQ.isFetched
? readyQ.data?.ok === true || readyQ.isSuccess
: true
const appsCount = catalogQ.data?.apps.length ?? 0
const usersCount = isAdmin ? (usersQ.data?.length ?? 0) : null
const oidcCount = isAdmin ? (oidcQ.data?.length ?? 0) : null
const disabledUsers = isAdmin
? (usersQ.data ?? []).filter((u) => u.disabled).length
: 0
const metrics = useMemo<MonitorMetric[]>(
() => [
{
id: 'ready',
label: 'API',
value: apiOk ? 'OK' : '—',
unit: '',
percent: apiOk ? 100 : 15,
icon: <Activity aria-hidden />,
tone: apiOk ? 'success' : 'destructive',
alert: !apiOk,
},
{
id: 'apps',
label: 'Приложения',
value: String(appsCount),
unit: 'шт.',
percent: Math.min(100, appsCount * 20),
icon: <AppWindow aria-hidden />,
tone: appsCount > 0 ? 'success' : 'warning',
alert: appsCount === 0,
},
{
id: 'users',
label: 'Пользователи',
value: usersCount == null ? '—' : String(usersCount),
unit: usersCount == null ? '' : 'шт.',
percent:
usersCount == null ? 0 : Math.min(100, Math.max(20, usersCount * 10)),
icon: <Users aria-hidden />,
tone:
usersCount == null
? 'info'
: disabledUsers > 0
? 'warning'
: 'success',
alert: disabledUsers > 0,
},
{
id: 'oidc',
label: 'OIDC',
value: oidcCount == null ? '—' : String(oidcCount),
unit: oidcCount == null ? '' : 'шт.',
percent: oidcCount == null ? 0 : Math.min(100, Math.max(15, oidcCount * 25)),
icon: <KeyRound aria-hidden />,
tone: oidcCount == null ? 'info' : oidcCount > 0 ? 'success' : 'warning',
alert: false,
},
],
[apiOk, appsCount, disabledUsers, oidcCount, usersCount],
)
const spiking = monitorFetched && (metrics.some((m) => m.alert) || !apiOk)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<button
type="button"
aria-label="Монитор системы"
className={cn(
'relative inline-flex h-8 items-center gap-1.5 rounded-md border px-2 transition-colors outline-none',
'border-border hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring',
)}
/>
}
>
<span className="relative flex size-3.5 items-center justify-center">
<Activity
aria-hidden
className={cn(
'size-3.5 transition-colors',
spiking ? 'text-destructive' : 'text-muted-foreground',
)}
/>
{spiking ? (
<span className="bg-destructive/25 absolute inset-0 animate-ping rounded-full" aria-hidden />
) : null}
</span>
<span className="text-foreground hidden text-xs font-medium sm:inline">Система</span>
<Badge
variant={spiking ? 'destructive-light' : 'success-light'}
size="xs"
className="h-4 px-1.5 text-[10px]"
>
{spiking ? 'Внимание' : 'Норма'}
</Badge>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
<span className="text-foreground text-xs font-medium">Монитор Auth Portal</span>
<span className="text-muted-foreground text-[11px] tabular-nums">
{new Date().toLocaleTimeString('ru-RU')}
</span>
</div>
<div className="grid grid-cols-2">
{metrics.map((metric, i) => (
<div
key={metric.id}
className={cn(
i % 2 === 1 && 'border-border border-l',
i >= 2 && 'border-border border-t',
)}
>
<MetricCell metric={metric} />
</div>
))}
</div>
<div className="border-border text-muted-foreground border-t px-3 py-2 text-[11px]">
API:{' '}
<span className="text-foreground font-medium">{apiOk ? 'OK' : '—'}</span>
{isAdmin ? (
<>
{' · '}
Отключено:{' '}
<span className="text-foreground font-medium tabular-nums">{disabledUsers}</span>
</>
) : (
<>
{' · '}
Метрики пользователей — для админа
</>
)}
</div>
</PopoverContent>
</Popover>
)
}
+209
View File
@@ -0,0 +1,209 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
ChevronsUpDownIcon,
FingerprintIcon,
LogOutIcon,
MonitorIcon,
MoonIcon,
PaletteIcon,
SunIcon,
} from 'lucide-react'
import { useEffect, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useTheme } from 'next-themes'
import { Avatar, AvatarFallback } from '@authportal/ui/components/avatar'
import { Button } from '@authportal/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu'
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@authportal/ui/components/sidebar'
import { cn } from '@authportal/ui/lib/utils'
import { clearToken } from '@/lib/auth'
import { logout, meQueryOptions } from '@/queries/auth'
const THEMES = [
{
value: 'light',
label: 'Светлая',
icon: <SunIcon className="size-3.5" aria-hidden />,
},
{
value: 'dark',
label: 'Тёмная',
icon: <MoonIcon className="size-3.5" aria-hidden />,
},
{
value: 'system',
label: 'Системная',
icon: <MonitorIcon className="size-3.5" aria-hidden />,
},
] as const
function ThemeSegmentedToggle() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
const currentTheme = mounted ? (theme ?? 'system') : 'system'
return (
<div
role="radiogroup"
aria-label="Тема"
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
{THEMES.map(({ value, label, icon }) => {
const isActive = currentTheme === value
return (
<Button
key={value}
type="button"
role="radio"
aria-checked={isActive}
aria-label={label}
variant="ghost"
size="icon-xs"
onClick={() => setTheme(value)}
className={cn(
'rounded-full',
isActive
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{icon}
</Button>
)
})}
</div>
)
}
function initials(name: string, email: string): string {
const base = name.trim() || email.trim()
if (!base) return '?'
const parts = base.split(/\s+/).filter(Boolean)
if (parts.length >= 2) {
return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase()
}
return base.slice(0, 2).toUpperCase()
}
export function NavUser() {
const { isMobile } = useSidebar()
const { data: me } = useQuery(meQueryOptions)
const queryClient = useQueryClient()
const navigate = useNavigate()
const name = me?.name?.trim() || 'Пользователь'
const email = me?.email?.trim() || ''
const fallback = initials(name, email)
async function handleSignOut() {
try {
await logout()
} catch {
/* ignore */
}
clearToken()
queryClient.clear()
window.location.href = '/'
}
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton
size="lg"
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
/>
}
>
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">
{fallback}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{name}</span>
<span className="truncate text-xs text-muted-foreground">
{email || '—'}
</span>
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--anchor-width) min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuLabel className="flex items-center gap-2 py-2 font-normal text-foreground">
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">
{fallback}
</AvatarFallback>
</Avatar>
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{name}</span>
<span className="truncate text-xs text-muted-foreground">
{email || '—'}
</span>
</div>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => void navigate({ to: '/account' })}
>
<FingerprintIcon aria-hidden />
Безопасность
</DropdownMenuItem>
<DropdownMenuItem className="cursor-default focus:bg-transparent">
<PaletteIcon aria-hidden />
Тема
<div className="ml-auto">
<ThemeSegmentedToggle />
</div>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => void handleSignOut()}>
<LogOutIcon aria-hidden />
Выйти
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
+16
View File
@@ -0,0 +1,16 @@
import type { ReactNode } from 'react'
import { cn } from '@authportal/ui/lib/utils'
export function PageShell({
children,
className,
}: {
children: ReactNode
className?: string
}) {
return (
<div className={cn('flex flex-col gap-4 md:gap-6', className)}>
{children}
</div>
)
}
@@ -0,0 +1,299 @@
import { useEffect, useState, type FormEvent } from 'react'
import { useNavigate, useSearch } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { EyeIcon, EyeOffIcon, FingerprintIcon, HistoryIcon, ShieldIcon } from 'lucide-react'
import {
browserSupportsWebAuthn,
browserSupportsWebAuthnAutofill,
startAuthentication,
WebAuthnAbortService,
} from '@simplewebauthn/browser'
import type { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/browser'
import {
buildSsoRedirectUrl,
isPortalOidcAuthorizeUrl,
isReturnToAllowed,
type LoginResponse,
} 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'
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from '@authportal/ui/components/input-group'
import { Separator } from '@authportal/ui/components/separator'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { ensureAuthConfig, setToken } from '@/lib/auth'
import { ApiError } from '@/lib/api-client'
import { login, meQueryKey } from '@/queries/auth'
import { webauthnLogin, webauthnLoginOptions } from '@/queries/webauthn'
import { AuthLogo } from '@/components/auth-logo'
import { Badge } from '@/components/reui/badge'
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
const PERKS = [
{ icon: FingerprintIcon, title: 'Passkey вместо пароля', text: 'Отпечаток, Face ID или ключ безопасности' },
{ icon: ShieldIcon, title: 'Права по приложениям и разделам', text: 'Чтение, запись и администрирование для каждого сервиса' },
{ icon: HistoryIcon, title: 'Журнал входов и изменений', text: 'Кто, когда и откуда заходил в сервисы' },
] as const
export function PortalLoginForm() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const search = useSearch({ from: '/' }) as { return_to?: string }
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState<string | null>(null)
const [pending, setPending] = useState(false)
const [passkeySupported, setPasskeySupported] = useState(false)
async function applySession(res: LoginResponse) {
setToken(res.access_token)
queryClient.setQueryData(meQueryKey, res.user)
const returnTo = search.return_to
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,
res.expires_at,
)
return
}
if (res.user.is_admin) {
await navigate({ to: '/admin' })
} else {
await navigate({ to: '/apps' })
}
}
async function runPasskeyLogin() {
const { challenge_id, options } = await webauthnLoginOptions()
const assertion = await startAuthentication({
optionsJSON: options as unknown as PublicKeyCredentialRequestOptionsJSON,
})
const res = await webauthnLogin(challenge_id, assertion, search.return_to)
await applySession(res)
}
useEffect(() => {
if (!browserSupportsWebAuthn()) return
setPasskeySupported(true)
let cancelled = false
async function startConditional() {
if (!(await browserSupportsWebAuthnAutofill())) return
try {
const { challenge_id, options } = await webauthnLoginOptions()
if (cancelled) return
const assertion = await startAuthentication({
optionsJSON:
options as unknown as PublicKeyCredentialRequestOptionsJSON,
useBrowserAutofill: true,
})
if (cancelled) return
setPending(true)
setError(null)
const res = await webauthnLogin(
challenge_id,
assertion,
search.return_to,
)
await applySession(res)
} catch {
/* abort / unsupported / user dismissed */
} finally {
if (!cancelled) setPending(false)
}
}
void startConditional()
return () => {
cancelled = true
WebAuthnAbortService.cancelCeremony()
}
// Login page mount only — return_to is stable for the visit.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
WebAuthnAbortService.cancelCeremony()
setError(null)
setPending(true)
const form = new FormData(event.currentTarget)
const email = String(form.get('email') ?? '')
const password = String(form.get('password') ?? '')
try {
const res = await login(email, password, search.return_to)
await applySession(res)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Не удалось войти')
} finally {
setPending(false)
}
}
async function handlePasskeyClick() {
WebAuthnAbortService.cancelCeremony()
setError(null)
setPending(true)
try {
await runPasskeyLogin()
} catch (err) {
setError(
err instanceof ApiError ? err.message : 'Не удалось войти с passkey',
)
} finally {
setPending(false)
}
}
const returnHost = (() => {
try {
return search.return_to ? new URL(search.return_to).host : null
} catch {
return null
}
})()
return (
<section className="bg-background flex min-h-svh min-w-0 items-center justify-center px-6 py-8">
<div className="grid w-full max-w-[1040px] items-center gap-12 md:grid-cols-2">
<div className="hidden flex-col gap-6 md:flex">
<div className="flex items-center gap-3">
<AuthLogo />
<div className="flex flex-col">
<b className="text-base font-semibold">Auth Portal</b>
<span className="text-muted-foreground text-[13px]">auth.shnt.top</span>
</div>
</div>
<h1 className="text-3xl leading-tight font-semibold tracking-[-0.02em] text-balance">
Один вход для всех сервисов shnt.top
</h1>
<p className="text-muted-foreground text-[15px] leading-relaxed text-pretty">
Войдите один раз — и открывайте CFDM, VPS Tracker, EvoBGP, EvoFirewall, CDN Manager, MikrotikManager и
Technitium DNS без повторного ввода пароля.
</p>
<Frame spacing="sm" className="max-w-[420px]">
<FramePanel className="p-1">
{PERKS.map((perk) => (
<div key={perk.title} className="flex items-start gap-3 px-4 py-2.5">
<IconTile variant="outline" aria-hidden className="shrink-0">
<perk.icon />
</IconTile>
<div className="flex flex-col">
<b className="text-sm font-medium">{perk.title}</b>
<span className="text-muted-foreground text-[13px]">{perk.text}</span>
</div>
</div>
))}
</FramePanel>
</Frame>
</div>
<Frame spacing="sm" className="w-full max-w-[420px] justify-self-center">
<FrameHeader className="gap-1 px-5 pt-5 pb-1">
<FrameTitle className="text-xl">Вход в портал</FrameTitle>
<FrameDescription className="text-sm">Введите email и пароль или используйте passkey</FrameDescription>
</FrameHeader>
<FramePanel className="flex flex-col gap-4 px-5 pt-4 pb-5">
{returnHost ? (
<div className="bg-muted/40 flex items-start gap-2.5 rounded-lg border px-3 py-2.5 text-[13px]" role="status">
<Badge variant="info-light" size="sm">
SSO
</Badge>
<span>
После входа вы вернётесь в <b className="font-semibold">{returnHost}</b>
</span>
</div>
) : null}
{error ? (
<Alert variant="destructive" role="alert">
<AlertTitle>Ошибка входа</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<FieldGroup className="gap-3.5">
<Field className="gap-2">
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
name="email"
type="email"
autoComplete="username webauthn"
placeholder="[email protected]"
aria-invalid={error ? true : undefined}
required
/>
</Field>
<Field className="gap-2">
<FieldLabel htmlFor="password">Пароль</FieldLabel>
<InputGroup className="w-full">
<InputGroupInput
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
placeholder="Пароль"
aria-invalid={error ? true : undefined}
required
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
type="button"
size="icon-xs"
aria-label={showPassword ? 'Скрыть пароль' : 'Показать пароль'}
onClick={() => setShowPassword((v) => !v)}
>
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</Field>
</FieldGroup>
<Button type="submit" className="w-full" disabled={pending}>
{pending ? 'Вход…' : 'Войти'}
</Button>
</form>
{passkeySupported ? (
<>
<div className="flex items-center gap-3">
<Separator className="flex-1" />
<span className="text-muted-foreground text-xs">или</span>
<Separator className="flex-1" />
</div>
<Button type="button" variant="outline" className="w-full" disabled={pending} onClick={() => void handlePasskeyClick()}>
<FingerprintIcon aria-hidden="true" />
Войти с passkey
</Button>
<span className="text-muted-foreground text-center text-xs">
Пароль остаётся запасным входом. Passkey добавляется в разделе «Безопасность».
</span>
</>
) : null}
</FramePanel>
</Frame>
</div>
</section>
)
}
+481
View File
@@ -0,0 +1,481 @@
import { useState, type ReactNode } from 'react'
import { CircleAlertIcon, InfoIcon, TriangleAlertIcon, XIcon } from 'lucide-react'
import { toast } from 'sonner'
import { Alert, AlertAction, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { Badge } from '@/components/reui/badge'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@authportal/ui/components/alert-dialog'
import { Button } from '@authportal/ui/components/button'
import { cn } from '@authportal/ui/lib/utils'
import { Input } from '@authportal/ui/components/input'
import { Label } from '@authportal/ui/components/label'
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@authportal/ui/components/sheet'
import type { Tone } from './tone'
import { TONE_BADGE } from './tone'
const TONE_GLYPH: Record<Tone, string> = { ok: '●', wn: '▲', er: '✕', mu: '○', in: '' }
/** Плашка статуса: слово + глиф (● ▲ ✕ ○), чтобы цвет не был единственным признаком. */
export function StatusBadge({ tone, children }: { tone: Tone; children: ReactNode }) {
const glyph = TONE_GLYPH[tone]
return (
<Badge variant={TONE_BADGE[tone]} size="sm" className="whitespace-nowrap">
{glyph ? <span aria-hidden className="text-[0.55rem]">{glyph}</span> : null}
{children}
</Badge>
)
}
/** Заголовок страницы: название, описание, метка свежести и действия. */
export function PageHead({
title,
sub,
actions,
}: {
title: string
sub: string
actions?: ReactNode
}) {
return (
<header className="flex flex-wrap items-end justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<h1 className="text-xl font-semibold tracking-[-0.01em]">{title}</h1>
<p className="text-muted-foreground text-sm">{sub}</p>
</div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</header>
)
}
const ALERT_ICON = {
er: <CircleAlertIcon aria-hidden />,
wn: <TriangleAlertIcon aria-hidden />,
in: <InfoIcon aria-hidden />,
} as const
const ALERT_VARIANT = {
er: 'destructive',
wn: 'warning',
in: 'info',
} as const
export function SignalAlert({
tone,
title,
children,
action,
}: {
tone: 'er' | 'wn' | 'in'
title: string
children: ReactNode
action?: ReactNode
}) {
return (
<Alert variant={ALERT_VARIANT[tone]}>
{ALERT_ICON[tone]}
<AlertTitle>{title}</AlertTitle>
<AlertDescription>{children}</AlertDescription>
{action ? <AlertAction>{action}</AlertAction> : null}
</Alert>
)
}
/** Боковое окно (как CreateUserSheet): заголовок, прокручиваемое тело, подвал. */
export function PortalSheet({
open,
onOpenChange,
title,
description,
children,
footer,
size = 'md',
dirty,
}: {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description: string
children: ReactNode
footer?: ReactNode
/** Ширина панели: md — форма, lg — редактор с двумя колонками. */
size?: 'md' | 'lg'
/** Явный признак изменений (иначе ловим ввод в полях и переключатели). */
dirty?: boolean
}) {
const [touchedState, setTouched] = useState(false)
const touched = dirty ?? touchedState
const [guard, setGuard] = useState(false)
function requestChange(next: boolean) {
if (!next && touched) {
setGuard(true)
return
}
if (!next) setTouched(false)
onOpenChange(next)
}
return (
<>
<Sheet open={open} onOpenChange={requestChange}>
<SheetContent
side="right"
showCloseButton={false}
className={cn(
'inset-y-0 right-0 left-auto flex h-svh max-w-none flex-col gap-0 overflow-hidden rounded-none p-0 outline-none sm:max-w-none',
'data-[side=right]:sm:max-w-none',
size === 'lg'
? 'data-[side=right]:w-[min(45rem,100vw)]'
: 'data-[side=right]:w-[min(30rem,100vw)]',
)}
>
<SheetHeader className="shrink-0 gap-0 p-0">
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
<SheetTitle className="min-w-0 truncate text-base font-semibold">{title}</SheetTitle>
<SheetClose
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Закрыть"
className="text-muted-foreground hover:text-foreground"
/>
}
>
<XIcon aria-hidden="true" />
</SheetClose>
</div>
<SheetDescription className="text-muted-foreground px-4 pt-3 text-sm">
{description}
</SheetDescription>
</SheetHeader>
<div
className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 py-4"
onInput={() => setTouched(true)}
onClick={(e) => {
if ((e.target as HTMLElement).closest('[role=switch]')) setTouched(true)
}}
>
{children}
</div>
{footer ? (
<SheetFooter
className="flex-row flex-wrap justify-end gap-2 border-t px-4 py-3"
onClickCapture={(e) => {
// «Закрыть» и «Отмена» при изменённой форме тоже спрашивают подтверждение.
const label = (e.target as HTMLElement).closest('button')?.textContent?.trim()
if (touched && (label === 'Закрыть' || label === 'Отмена')) {
e.stopPropagation()
setGuard(true)
}
}}
>{footer}</SheetFooter>
) : null}
</SheetContent>
</Sheet>
<AlertDialog open={guard} onOpenChange={setGuard}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Закрыть без сохранения?</AlertDialogTitle>
<AlertDialogDescription>Изменения в форме будут потеряны.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Остаться</AlertDialogCancel>
<Button
type="button"
variant="destructive"
onClick={() => {
setGuard(false)
setTouched(false)
onOpenChange(false)
}}
>
Закрыть без сохранения
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
/** Подтверждение опасного действия вводом слова (email, «сбросить», «очистить»…). */
export function TypedConfirm({
open,
onOpenChange,
title,
description,
expected,
confirmLabel,
onConfirm,
}: {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description: ReactNode
expected: string
confirmLabel: string
onConfirm: () => void
}) {
const [value, setValue] = useState('')
const matches = value.trim().toLowerCase() === expected.toLowerCase()
return (
<AlertDialog
open={open}
onOpenChange={(next) => {
if (!next) setValue('')
onOpenChange(next)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex flex-col gap-2">
<Label htmlFor="typed-confirm">
Введите <span className="font-mono font-semibold">{expected}</span> для подтверждения
</Label>
<Input
id="typed-confirm"
value={value}
autoComplete="off"
onChange={(e) => setValue(e.target.value)}
aria-invalid={value.length > 0 && !matches}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<Button
type="button"
variant="destructive"
disabled={!matches}
onClick={() => {
setValue('')
onConfirm()
}}
>
{confirmLabel}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
export function KvFrame({
title,
description,
items,
}: {
title: string
description: string
items: Array<{ label: string; value: ReactNode }>
}) {
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full">
<FrameHeader>
<FrameTitle>{title}</FrameTitle>
<FrameDescription>{description}</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<dl className="grid divide-y @container sm:grid-cols-2 sm:divide-y-0">
{items.map((item) => (
<div
key={item.label}
className="flex flex-col gap-0.5 border-b px-4 py-3 last:border-b-0 sm:odd:border-r"
>
<dt className="text-muted-foreground text-xs">{item.label}</dt>
<dd className="text-sm font-medium break-words">{item.value}</dd>
</div>
))}
</dl>
</FramePanel>
</Frame>
)
}
/** Список «значение + подпись + копировать» (параметры issuer). */
export function CopyList({
title,
description,
items,
}: {
title: string
description: string
items: Array<{ label: string; value: string }>
}) {
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full">
<FrameHeader>
<FrameTitle>{title}</FrameTitle>
<FrameDescription>{description}</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<ul className="flex flex-col">
{items.map((item) => (
<li
key={item.label}
className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b px-4 py-2.5 last:border-b-0"
>
<code className="min-w-0 flex-1 basis-72 font-mono text-[13px] break-all">{item.value}</code>
<span className="text-muted-foreground min-w-0 flex-1 basis-48 text-[13px]">{item.label}</span>
<Button
type="button"
variant="outline"
aria-label={`Скопировать: ${item.label}`}
onClick={() => {
void navigator.clipboard?.writeText(item.value).catch(() => undefined)
toast.success(`${item.label} скопирован`)
}}
>
Копировать
</Button>
</li>
))}
</ul>
</FramePanel>
</Frame>
)
}
/** Компактный список «ключ — значение» для боковых окон. */
export function KvList({ items }: { items: Array<[string, string]> }) {
return (
<dl className="flex flex-col divide-y rounded-lg border">
{items.map(([k, v]) => (
<div key={k} className="flex flex-col gap-0.5 px-3 py-2">
<dt className="text-muted-foreground text-xs">{k}</dt>
<dd className="text-sm break-words">{v}</dd>
</div>
))}
</dl>
)
}
export function CodeBlock({ label, children }: { label: string; children: string }) {
return (
<div className="flex flex-col gap-1">
<span className="text-muted-foreground text-xs">{label}</span>
<pre className="bg-muted/40 overflow-x-auto rounded-md border p-3 font-mono text-xs leading-relaxed">{children}</pre>
</div>
)
}
/** Сегментированный переключатель на Tabs (вид таблицы, период). */
export function Segmented({
label,
options,
value,
onChange,
}: {
label: string
options: string[]
value: number
onChange: (index: number) => void
}) {
return (
<Tabs value={String(value)} onValueChange={(v) => onChange(Number(v))} aria-label={label} className="max-w-full">
<TabsList className="max-w-full justify-start overflow-x-auto">
{options.map((o, i) => (
<TabsTrigger key={o} value={String(i)}>
{o}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)
}
/** Плашка строки с накопленной правкой: «Не сохранено» или «Будет удалено». */
export function PendingChip({ mark }: { mark: 'new' | 'dirty' | 'deleted' }) {
return (
<Badge variant={mark === 'deleted' ? 'destructive-light' : 'warning-light'} size="sm" className="whitespace-nowrap">
{mark === 'deleted' ? 'Будет удалено' : 'Не сохранено'}
</Badge>
)
}
/** Вкладки страницы как на досках: линия-подчёркивание, число — мелким индексом. */
export function PageTabsList({ children }: { children: ReactNode }) {
return (
<TabsList variant="line" className="h-10! w-full justify-start gap-0.5 overflow-x-auto">
{children}
</TabsList>
)
}
export function PageTab({ value, label, count }: { value: string; label: string; count?: number | string }) {
return (
<TabsTrigger value={value} className="gap-1.5 px-4 text-sm">
{label}
{count !== undefined ? <span className="text-muted-foreground font-mono text-xs font-normal">{count}</span> : null}
</TabsTrigger>
)
}
/** Строка настройки как на досках: слева название и описание, справа элемент управления. */
export function SettingRow({ title, description, children }: { title: string; description: string; children: ReactNode }) {
return (
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b px-4 py-3.5 last:border-b-0">
<div className="flex min-w-0 flex-1 basis-64 flex-col gap-0.5">
<b className="text-sm font-medium">{title}</b>
<span className="text-muted-foreground text-[13px]">{description}</span>
</div>
<div className="shrink-0">{children}</div>
</div>
)
}
/** Frame с заголовком, итоговой плашкой справа и строками настроек (блок «form» досок). */
export function FormFrame({
title,
description,
badge,
tone = 'ok',
children,
}: {
title: string
description: string
badge: string
tone?: Tone
children: ReactNode
}) {
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<FrameTitle>{title}</FrameTitle>
<FrameDescription>{description}</FrameDescription>
</div>
<StatusBadge tone={tone}>{badge}</StatusBadge>
</FrameHeader>
<FramePanel className="p-0">{children}</FramePanel>
</Frame>
)
}
@@ -0,0 +1,142 @@
import type { ReactNode } from 'react'
import { Badge } from '@/components/reui/badge'
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import { cn } from '@authportal/ui/lib/utils'
import type { Tone } from './tone'
import { StatusBadge } from './bits'
export type AppCardItem = {
id: string
title: string
desc: string
badge: string
badgeVariant?: 'info-light' | 'warning-light' | 'outline'
meta: string
icon: ReactNode
onSelect?: () => void
}
/**
* Плитки приложений как на доске «Приложения»: Frame с шапкой, внутри сетка auto-fit от 260px,
* в плитке иконка, название, плашка действия, описание и адрес моноширинным шрифтом.
*/
export function AppCards({
title,
description,
items,
action,
}: {
title: string
description: string
items: AppCardItem[]
action?: ReactNode
}) {
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<FrameTitle>{title}</FrameTitle>
<FrameDescription className="text-pretty">{description}</FrameDescription>
</div>
{action}
</FrameHeader>
<FramePanel className="p-3">
<ul className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,16.25rem),1fr))] gap-3">
{items.map((c) => {
const body = (
<>
<div className="flex items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-2.5">
<IconTile variant="elevated" size="sm" aria-hidden className="text-muted-foreground">
{c.icon}
</IconTile>
<b className="min-w-0 text-sm leading-tight font-semibold">{c.title}</b>
</span>
<Badge variant={c.badgeVariant ?? 'info-light'} size="sm" className="shrink-0">
{c.badge}
</Badge>
</div>
<p className="text-muted-foreground mt-1.5 text-[13px] text-pretty">{c.desc}</p>
<p className="text-muted-foreground mt-2 truncate font-mono text-[11px]">{c.meta}</p>
</>
)
const cls = 'bg-muted/25 w-full rounded-lg border p-3.5 text-left'
return (
<li key={c.id} className="min-w-0">
{c.onSelect ? (
<button
type="button"
onClick={c.onSelect}
aria-label={`${c.title}: ${c.badge}`}
className={cn(cls, 'hover:bg-muted/50 focus-visible:ring-ring transition-colors outline-none focus-visible:ring-2')}
>
{body}
</button>
) : (
<div className={cls}>{body}</div>
)}
</li>
)
})}
</ul>
</FramePanel>
</Frame>
)
}
const DOT: Record<Tone, string> = {
ok: 'bg-success',
wn: 'bg-warning',
er: 'bg-destructive',
mu: 'bg-muted-foreground',
in: 'bg-chart-4',
}
/**
* Лента событий как на доске «Изменения»: время, точка важности с линией, плашка, заголовок
* и строка «кто · источник · IP».
*/
export function ActivityFeed({
title,
description,
items,
action,
}: {
title: string
description: string
items: Array<{ t: string; title: string; text: string; label: string; tone: Tone }>
action?: ReactNode
}) {
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<FrameTitle>{title}</FrameTitle>
<FrameDescription className="text-pretty">{description}</FrameDescription>
</div>
{action}
</FrameHeader>
<FramePanel className="py-1.5">
<ol>
{items.map((e) => (
<li key={`${e.t}-${e.title}`} className="grid grid-cols-[3.9rem_1rem_minmax(0,1fr)] items-start gap-2.5 px-4 py-2 text-[13px]">
<span className="text-muted-foreground pt-px font-mono text-xs tabular-nums">{e.t}</span>
<span aria-hidden className="flex h-full flex-col items-center">
<i className={cn('mt-1 size-[9px] rounded-full', DOT[e.tone])} />
<u className="bg-border mt-0.5 min-h-4.5 w-px flex-1 no-underline" />
</span>
<span className="min-w-0">
<span className="mr-2 inline-block align-middle">
<StatusBadge tone={e.tone}>{e.label}</StatusBadge>
</span>
<b className="font-medium">{e.title}</b>
<span className="text-muted-foreground mt-0.5 block text-xs">{e.text}</span>
</span>
</li>
))}
</ol>
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,83 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { ChromeConfigContext, UiStoreContext, type ChromeConfig, type Contrast, type Density, type UiStore } from './chrome-store'
export function ChromeConfigProvider({ value, children }: { value: ChromeConfig; children: ReactNode }) {
return <ChromeConfigContext.Provider value={value}>{children}</ChromeConfigContext.Provider>
}
function readStored<T>(key: string, fallback: T): T {
try {
const raw = window.localStorage.getItem(key)
return raw ? (JSON.parse(raw) as T) : fallback
} catch {
return fallback
}
}
function writeStored(key: string, value: unknown) {
try {
window.localStorage.setItem(key, JSON.stringify(value))
} catch {
/* без сохранения настроек */
}
}
/** Плотность, контраст, закреплённые пункты и палитра ⌘K. Атрибуты html[data-density|contrast] читает portal-theme.css. */
export function PortalUiProvider({ children, storageKey = 'portal-ui' }: { children: ReactNode; storageKey?: string }) {
const [paletteOpen, setPaletteOpen] = useState(false)
const [density, setDensityState] = useState<Density>(() => readStored(`${storageKey}:density`, 'comfortable'))
const [contrast, setContrastState] = useState<Contrast>(() => readStored(`${storageKey}:contrast`, 'standard'))
const [pins, setPins] = useState<string[]>(() => readStored(`${storageKey}:pins`, []))
const setDensity = useCallback(
(v: Density) => {
setDensityState(v)
writeStored(`${storageKey}:density`, v)
},
[storageKey],
)
const setContrast = useCallback(
(v: Contrast) => {
setContrastState(v)
writeStored(`${storageKey}:contrast`, v)
},
[storageKey],
)
const togglePin = useCallback(
(id: string) => {
setPins((prev) => {
const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
writeStored(`${storageKey}:pins`, next)
return next
})
},
[storageKey],
)
useEffect(() => {
const root = document.documentElement
root.dataset.density = density
root.dataset.contrast = contrast
return () => {
delete root.dataset.density
delete root.dataset.contrast
}
}, [density, contrast])
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
setPaletteOpen((v) => !v)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
const value = useMemo<UiStore>(
() => ({ paletteOpen, setPaletteOpen, pins, togglePin, density, setDensity, contrast, setContrast }),
[paletteOpen, pins, togglePin, density, setDensity, contrast, setContrast],
)
return <UiStoreContext.Provider value={value}>{children}</UiStoreContext.Provider>
}
@@ -0,0 +1,86 @@
import { createContext, useContext, type ComponentType, type ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
export type NavItem = {
id: string
label: string
to: string
icon: LucideIcon
group: string
/** Точка проблемы в углу иконки. */
problem?: { tone: 'er' | 'wn'; label: string }
}
export type MonitorItem = {
id: string
tone: 'er' | 'wn' | 'ok'
title: string
text: string
age: string
to: string
}
export type MonitorMetric = { l: string; v: string; p: number; tone: 'warning' | 'success' }
export type PaletteAction = {
id: string
label: string
/** Горячая буква справа. */
hint: string
icon: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
to: string
}
export type PaletteObject = {
id: string
group: string
label: string
hint: string
dot?: 'ok' | 'er' | 'mu'
to: string
}
/** Данные оболочки: меню, монитор, палитра и пользователь. В макете — суррогатные, в портале — из API. */
export type ChromeConfig = {
navItems: NavItem[]
navGroups: string[]
/** Верх бокового меню: бренд и переключатель приложений. */
brand: ReactNode
user: { name: string; email: string; initials: string }
securityTo: string
onLogout: () => void
fresh: (pathname: string) => { badge: string; text: string }
crumbFor?: (pathname: string, label: string | undefined) => string
monitor: { items: MonitorItem[]; metrics: MonitorMetric[]; updated: string; footerTo: string; footerLabel: string }
palette: { actions: PaletteAction[]; objects: PaletteObject[]; assistantTo: string; example: string }
}
export const ChromeConfigContext = createContext<ChromeConfig | null>(null)
export function useChromeConfig(): ChromeConfig {
const ctx = useContext(ChromeConfigContext)
if (!ctx) throw new Error('ChromeConfigProvider отсутствует')
return ctx
}
export type Density = 'comfortable' | 'compact'
export type Contrast = 'standard' | 'high'
export type UiStore = {
paletteOpen: boolean
setPaletteOpen: (open: boolean) => void
pins: string[]
togglePin: (id: string) => void
density: Density
setDensity: (v: Density) => void
contrast: Contrast
setContrast: (v: Contrast) => void
}
export const UiStoreContext = createContext<UiStore | null>(null)
export function useUiStore(): UiStore {
const ctx = useContext(UiStoreContext)
if (!ctx) throw new Error('PortalUiProvider отсутствует')
return ctx
}
+569
View File
@@ -0,0 +1,569 @@
import {
useMemo,
useState,
type ComponentType,
} from 'react'
import {
Link,
useNavigate,
useRouterState,
} from '@tanstack/react-router'
import {
ActivityIcon,
ArrowRightIcon,
ChevronDownIcon,
ChevronsUpDownIcon,
FingerprintIcon,
InfoIcon,
LogOutIcon,
SearchIcon,
StarIcon,
MoonIcon,
} from 'lucide-react'
import {
useTheme,
} from 'next-themes'
import {
Badge,
} from '@/components/reui/badge'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
} from '@authportal/ui/components/breadcrumb'
import {
Button,
} from '@authportal/ui/components/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@authportal/ui/components/dialog'
import {
Kbd,
} from '@authportal/ui/components/kbd'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@authportal/ui/components/popover'
import {
Separator,
} from '@authportal/ui/components/separator'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarSeparator,
SidebarTrigger,
useSidebar,
} from '@authportal/ui/components/sidebar'
import {
cn,
} from '@authportal/ui/lib/utils'
import {
Segmented,
} from './bits'
import {
useChromeConfig,
useUiStore,
type NavItem,
} from './chrome-store'
const LABEL_CLASS = 'text-muted-foreground text-[11px] font-medium uppercase'
function PortalNavUser() {
const { isMobile } = useSidebar()
const { theme, setTheme, resolvedTheme } = useTheme()
const { density, setDensity, contrast, setContrast } = useUiStore()
const { user, securityTo, onLogout } = useChromeConfig()
const navigate = useNavigate()
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
return (
<SidebarMenu>
<SidebarMenuItem>
<Popover>
<PopoverTrigger
render={
<SidebarMenuButton
size="lg"
aria-label={`Профиль и настройки интерфейса: ${user.name}, ${user.email}`}
className="bg-muted/40 aria-expanded:bg-muted"
/>
}
>
<div className="bg-background text-foreground flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
{user.initials}
</div>
<div className="grid min-w-0 flex-1 text-left leading-tight">
<span className="truncate text-sm font-semibold">{user.name}</span>
<span className="text-muted-foreground truncate text-xs">{user.email}</span>
</div>
<ChevronsUpDownIcon className="text-muted-foreground ml-auto size-4" aria-hidden />
</PopoverTrigger>
<PopoverContent side={isMobile ? 'top' : 'right'} align="end" sideOffset={8} className="w-72 gap-3">
<div className="flex items-center gap-2.5">
<div className="bg-background flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
{user.initials}
</div>
<div className="grid leading-tight">
<span className="text-sm font-semibold">{user.name}</span>
<span className="text-muted-foreground text-xs">{user.email}</span>
</div>
</div>
<Separator />
<button
type="button"
onClick={() => void navigate({ to: securityTo })}
className="hover:bg-muted flex items-center gap-2 rounded-md px-1 py-1 text-left text-sm"
>
<FingerprintIcon className="text-muted-foreground size-4" aria-hidden />
Безопасность
</button>
<div className="flex flex-col gap-1.5">
<span className={LABEL_CLASS}>Тема</span>
<Segmented
label="Тема"
options={['Тёмная', 'Светлая']}
value={current === 'light' ? 1 : 0}
onChange={(i) => setTheme(i === 1 ? 'light' : 'dark')}
/>
<span className={LABEL_CLASS}>Плотность</span>
<Segmented
label="Плотность"
options={['Обычная', 'Компактная']}
value={density === 'compact' ? 1 : 0}
onChange={(i) => setDensity(i === 1 ? 'compact' : 'comfortable')}
/>
<span className={LABEL_CLASS}>Контраст</span>
<Segmented
label="Контраст"
options={['Стандартный', 'Высокий']}
value={contrast === 'high' ? 1 : 0}
onChange={(i) => setContrast(i === 1 ? 'high' : 'standard')}
/>
</div>
<Separator />
<button
type="button"
onClick={onLogout}
className="text-muted-foreground hover:text-foreground flex items-center gap-2 text-left text-sm"
>
<LogOutIcon className="size-4" aria-hidden />
Выйти
</button>
</PopoverContent>
</Popover>
</SidebarMenuItem>
</SidebarMenu>
)
}
const DOT_CLASS = { er: 'bg-destructive', wn: 'bg-warning' } as const
function NavEntry({ item, pathname, pinned, onTogglePin }: { item: NavItem; pathname: string; pinned: boolean; onTogglePin: () => void }) {
const Icon = item.icon
const active = pathname === item.to || pathname.startsWith(`${item.to}/`)
return (
<SidebarMenuItem>
<SidebarMenuButton
tooltip={item.problem ? `${item.label} · ${item.problem.label}` : item.label}
isActive={active}
className="relative pr-8 group-data-[collapsible=icon]:pr-2!"
render={<Link to={item.to} />}
>
<Icon className="size-4" />
<span>{item.label}</span>
{item.problem ? (
<span
role="img"
aria-label={item.problem.label}
className={cn(
'ml-auto size-2 shrink-0 rounded-full group-data-[collapsible=icon]:absolute group-data-[collapsible=icon]:top-1 group-data-[collapsible=icon]:right-1 group-data-[collapsible=icon]:ml-0 group-data-[collapsible=icon]:size-1.5 group-data-[collapsible=icon]:ring-2 group-data-[collapsible=icon]:ring-sidebar',
DOT_CLASS[item.problem.tone],
)}
/>
) : null}
</SidebarMenuButton>
<SidebarMenuAction
showOnHover={!pinned}
aria-label={pinned ? `Открепить ${item.label}` : `Закрепить ${item.label}`}
aria-pressed={pinned}
onClick={onTogglePin}
>
<StarIcon className={cn(pinned && 'fill-current')} aria-hidden />
</SidebarMenuAction>
</SidebarMenuItem>
)
}
export function PortalSidebar() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const { pins, togglePin } = useUiStore()
const { navItems, navGroups, brand } = useChromeConfig()
const [closed, setClosed] = useState<Record<string, boolean>>({})
const pinned = navItems.filter((i) => pins.includes(i.id))
return (
<Sidebar collapsible="icon">
<SidebarHeader className="pb-1.5">
{brand}
</SidebarHeader>
<SidebarContent>
{pinned.length > 0 ? (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel className="text-[11px] font-semibold tracking-wide uppercase">Закреплённые</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{pinned.map((item) => (
<NavEntry key={item.id} item={item} pathname={pathname} pinned onTogglePin={() => togglePin(item.id)} />
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
) : null}
{navGroups.map((group, gi) => (
<div key={group} className="flex flex-col">
{gi > 0 ? <SidebarSeparator className="mx-2 my-1 h-px shrink-0 hidden group-data-[collapsible=icon]:block" /> : null}
<SidebarGroup className="py-0">
<SidebarGroupLabel
render={
<button
type="button"
aria-expanded={!closed[group]}
onClick={() => setClosed((c) => ({ ...c, [group]: !c[group] }))}
/>
}
className="h-7 cursor-pointer justify-between text-[11px] font-semibold tracking-wide uppercase"
>
{group}
<ChevronDownIcon className={cn('size-3.5 transition-transform', closed[group] && '-rotate-90')} aria-hidden />
</SidebarGroupLabel>
<SidebarGroupContent className={cn(closed[group] && 'group-data-[collapsible=icon]:block hidden')}>
<SidebarMenu className="gap-px">
{navItems.filter((i) => i.group === group).map((item) => (
<NavEntry
key={item.id}
item={item}
pathname={pathname}
pinned={pins.includes(item.id)}
onTogglePin={() => togglePin(item.id)}
/>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</div>
))}
</SidebarContent>
<SidebarFooter>
<PortalNavUser />
</SidebarFooter>
</Sidebar>
)
}
/** Единый центр состояния: внимание + сводка + список проблем. Адаптивен по ширине окна, как на досках. */
function MonitorPopover() {
const { monitor } = useChromeConfig()
const MONITOR_ITEMS = monitor.items
const MONITOR_METRICS = monitor.metrics
const bad = MONITOR_ITEMS.filter((m) => m.tone !== 'ok')
const errors = bad.filter((m) => m.tone === 'er').length
const warnings = bad.length - errors
const ok = MONITOR_ITEMS.length - bad.length
return (
<Popover>
<PopoverTrigger
render={
<Button
variant="outline"
type="button"
aria-label={`Состояние системы: внимание, ${errors} сбой, ${warnings} предупреждения`}
className="border-warning/40 bg-warning/10 hover:bg-warning/20 aria-expanded:bg-warning/20 relative h-8 gap-2 px-2 max-[760px]:w-8 max-[760px]:border-transparent max-[760px]:bg-transparent max-[760px]:px-0"
/>
}
>
<ActivityIcon className="text-warning size-4" aria-hidden />
<span className="text-warning hidden text-sm font-medium min-[1000px]:inline">Внимание</span>
<span className="hidden items-center gap-1.5 text-sm tabular-nums min-[1240px]:inline-flex">
<span className="bg-destructive size-1.5 rounded-full" aria-hidden />
{errors}
<span className="bg-warning size-1.5 rounded-full" aria-hidden />
{warnings}
</span>
<span className="bg-destructive hidden size-4.5 min-w-4.5 items-center justify-center rounded-full px-1 text-[11px] font-semibold text-white min-[760px]:inline-flex min-[1240px]:hidden">
{bad.length}
</span>
<span className="bg-destructive ring-background absolute top-1 right-1 size-2 rounded-full ring-2 min-[760px]:hidden" aria-hidden />
</PopoverTrigger>
<PopoverContent align="end" className="flex w-[24rem] max-w-[calc(100vw-1.5rem)] flex-col gap-2 p-2">
<div className="flex items-start justify-between gap-2 px-1.5 pt-1.5">
<div className="grid leading-tight">
<b className="text-sm font-semibold">Состояние системы</b>
<span className="text-muted-foreground text-xs">{monitor.updated}</span>
</div>
<Badge variant={errors > 0 ? "destructive-light" : warnings > 0 ? "warning-light" : "success-light"} size="sm">{errors > 0 ? "Сбой" : warnings > 0 ? "Внимание" : "Норма"}</Badge>
</div>
<div className="grid grid-cols-3 gap-1.5">
{[
{ n: errors, l: 'сбой', c: 'text-destructive' },
{ n: warnings, l: 'предупреждения', c: 'text-warning' },
{ n: ok, l: 'в норме', c: 'text-success' },
].map((x) => (
<span key={x.l} className="bg-muted/55 text-muted-foreground flex flex-col rounded-lg px-2.5 py-2 text-[11.5px]">
<b className={cn('text-lg leading-tight font-semibold tabular-nums', x.c)}>{x.n}</b>
{x.l}
</span>
))}
</div>
<ul className="flex flex-col gap-0.5">
{MONITOR_ITEMS.filter((m) => m.tone !== 'ok').map((m) => (
<li key={m.id}>
<Link to={m.to} className="hover:bg-muted/55 flex items-center gap-2.5 rounded-md px-1.5 py-2 outline-none focus-visible:bg-muted/55">
<span aria-hidden className={cn('size-2 shrink-0 rounded-full', m.tone === 'er' ? 'bg-destructive' : 'bg-warning')} />
<span className="grid min-w-0 flex-1 leading-tight">
<b className="truncate text-[13px] font-medium">{m.title}</b>
<span className="text-muted-foreground truncate text-xs">{m.text}</span>
</span>
<span className="text-muted-foreground shrink-0 text-[11.5px]">{m.age}</span>
</Link>
</li>
))}
</ul>
<div className="grid grid-cols-2 gap-1.5">
{MONITOR_METRICS.map((m) => (
<div key={m.l} className="bg-muted/55 flex min-w-0 flex-col gap-1.5 rounded-lg px-3 py-2.5">
<span className="text-muted-foreground text-xs leading-snug">{m.l}</span>
<b className={cn('text-base font-semibold whitespace-nowrap tabular-nums', m.tone === 'success' ? 'text-success' : 'text-warning')}>{m.v}</b>
<div className="bg-muted-foreground/25 mt-auto h-[3px] overflow-hidden rounded-full">
<i className={cn('block h-full rounded-full', m.tone === 'success' ? 'bg-success' : 'bg-warning')} style={{ width: `${m.p}%` }} />
</div>
</div>
))}
</div>
<Link to={monitor.footerTo} className="text-muted-foreground hover:text-foreground flex items-center justify-between px-1.5 pt-1 pb-0.5 text-sm">
{monitor.footerLabel}
<ArrowRightIcon className="size-3.5" aria-hidden />
</Link>
</PopoverContent>
</Popover>
)
}
function FreshnessPopover() {
const pathname = useRouterState({ select: (st) => st.location.pathname })
const { fresh: freshFor } = useChromeConfig()
const fresh = freshFor(pathname)
return (
<Popover>
<PopoverTrigger
render={
<Button type="button" variant="ghost" size="icon" aria-label="Источники и свежесть данных" title="Источники и свежесть данных" />
}
>
<InfoIcon aria-hidden />
</PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2">
<Badge variant="success-light" size="sm" className="w-fit">{fresh.badge}</Badge>
<p className="text-muted-foreground text-sm">{fresh.text}</p>
</PopoverContent>
</Popover>
)
}
export function PortalHeader() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const { setPaletteOpen } = useUiStore()
const { navItems, crumbFor } = useChromeConfig()
const current = navItems.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`))
return (
<header className="bg-background/80 sticky top-0 z-20 flex min-h-12 shrink-0 items-center gap-2 border-b px-4 py-1.5 backdrop-blur md:px-6">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb className="min-w-0">
<BreadcrumbList className="flex-nowrap">
<BreadcrumbItem className="min-w-0">
<BreadcrumbPage className="truncate">{crumbFor ? crumbFor(pathname, current?.label) : (current?.label ?? 'Auth Portal')}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex shrink-0 items-center gap-2">
<FreshnessPopover />
<Button
type="button"
variant="outline"
size="icon"
aria-label="Командная палитра (⌘K)"
className="md:hidden"
onClick={() => setPaletteOpen(true)}
>
<SearchIcon aria-hidden />
</Button>
<MonitorPopover />
</div>
</header>
)
}
type PaletteEntry = {
id: string
group: string
label: string
hint: string
icon?: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
dot?: 'ok' | 'er' | 'mu'
run: () => void
}
const DOT_BG = { ok: 'bg-success', er: 'bg-destructive', mu: 'bg-muted-foreground' } as const
/** Палитра ⌘K как на доске: 640px, группы заглавными, подсказки моноширинным справа, подвал с подсказкой. */
export function CommandPalette() {
const { paletteOpen, setPaletteOpen } = useUiStore()
const { navItems, palette } = useChromeConfig()
const navigate = useNavigate()
const { theme, setTheme, resolvedTheme } = useTheme()
const [query, setQuery] = useState('')
const [active, setActive] = useState(0)
const shown = useMemo(() => {
const q = query.trim().toLowerCase()
const match = (...t: string[]) => !q || t.some((x) => x.toLowerCase().includes(q))
const go = (to: string) => () => void navigate({ to })
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
const list: PaletteEntry[] = [
...[
...palette.actions.map((x) => ({ id: x.id, label: x.label, hint: x.hint, icon: x.icon, run: go(x.to) })),
{ id: 'act-theme', label: 'Переключить тему', hint: 'T', icon: MoonIcon, run: () => setTheme(current === 'dark' ? 'light' : 'dark') },
]
.filter((e) => match(e.label))
.map((e) => ({ ...e, group: 'Действия' })),
...navItems.filter((i) => match(i.label, i.group))
.slice(0, 7)
.map((i) => ({ id: `nav-${i.id}`, group: 'Разделы', label: i.label, hint: i.group, icon: i.icon, run: go(i.to) })),
...Object.values(
palette.objects
.filter((o) => match(o.label, o.hint))
.reduce<Record<string, typeof palette.objects>>((acc, o) => ((acc[o.group] ??= []).push(o), acc), {}),
).flatMap((g) => g.slice(0, 5).map((o) => ({ id: o.id, group: o.group, label: o.label, hint: o.hint, dot: o.dot, run: go(o.to) }))),
]
// Запрос из трёх и более слов — черновик ассистента (как на доске «Палитра ⌘K»).
if (query.trim().split(/\s+/).filter(Boolean).length >= 3) {
return [
{
id: 'ai',
group: 'Ассистент · запрос на естественном языке',
label: `Понял так: «${query.trim()}»`,
hint: 'Черновик: запрос разобран автоматически, результат нужно проверить.',
run: () => void navigate({ to: palette.assistantTo }),
} as PaletteEntry,
...list,
]
}
return list
}, [query, navigate, theme, resolvedTheme, setTheme, navItems, palette])
const groups = useMemo(() => {
const map = new Map<string, Array<PaletteEntry & { index: number }>>()
shown.forEach((e, index) => {
const list = map.get(e.group) ?? []
list.push({ ...e, index })
map.set(e.group, list)
})
return [...map.entries()]
}, [shown])
function close() {
setPaletteOpen(false)
setQuery('')
setActive(0)
}
return (
<Dialog open={paletteOpen} onOpenChange={(o) => (o ? setPaletteOpen(true) : close())}>
<DialogContent className="top-[90px] w-[min(40rem,94vw)] max-w-none translate-y-0 gap-0 overflow-hidden p-0 sm:max-w-none" showCloseButton={false}>
<DialogTitle className="sr-only">Командная палитра</DialogTitle>
<DialogDescription className="sr-only">Разделы, пользователи, приложения и действия</DialogDescription>
<div className="flex items-center gap-2.5 border-b px-4 py-3.5">
<SearchIcon className="text-muted-foreground size-[18px]" aria-hidden />
<input
autoFocus
value={query}
aria-label="Поиск и команды"
placeholder="Раздел, пользователь, приложение или действие…"
className="min-w-0 flex-1 bg-transparent text-[15px] outline-none"
onChange={(e) => {
setQuery(e.target.value)
setActive(0)
}}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
setActive((a) => Math.min(a + 1, shown.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActive((a) => Math.max(a - 1, 0))
} else if (e.key === 'Enter') {
e.preventDefault()
shown[active]?.run()
close()
}
}}
/>
<Kbd>Esc</Kbd>
</div>
<div className="max-h-[430px] overflow-y-auto" role="listbox" aria-label="Результаты">
{groups.length === 0 ? (
<p className="text-muted-foreground px-4 py-6 text-center text-sm">Ничего не найдено</p>
) : (
groups.map(([group, list]) => (
<div key={group} className="flex flex-col">
<div className="text-muted-foreground px-4 pt-2.5 pb-1 text-[11px] tracking-[0.05em] uppercase">{group}</div>
{list.map((e) => {
const Icon = e.icon
return (
<button
key={e.id}
type="button"
role="option"
aria-selected={e.index === active}
onMouseEnter={() => setActive(e.index)}
onClick={() => {
e.run()
close()
}}
className={cn('flex w-full items-center gap-3 px-4 py-[9px] text-left text-sm outline-none', e.index === active && 'bg-muted/60')}
>
{Icon ? <Icon className="size-4 shrink-0" aria-hidden /> : null}
{e.dot ? <span className={cn('size-2 shrink-0 rounded-full', DOT_BG[e.dot])} aria-hidden /> : null}
<span className="min-w-0 truncate">{e.label}</span>
<span className="text-muted-foreground ml-auto truncate font-mono text-xs">{e.hint}</span>
</button>
)
})}
</div>
))
)}
</div>
<div className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 border-t px-4 py-2 text-[11.5px]">
<span>↑↓ выбор</span>
<span>↵ открыть</span>
<span>Esc закрыть</span>
<span className="ml-auto">{palette.example}</span>
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,26 @@
import type { DataGridFeatures, DataGridProps } from '@/components/reui/data-grid/data-grid'
export const DATA_GRID_CELL_PAD = 'py-3'
export const DATA_GRID_CELL_PAD_FIRST = 'pl-5 py-3'
export const DATA_GRID_CELL_PAD_LAST = 'pr-4 py-3'
export const DATA_GRID_LAYOUT: NonNullable<DataGridProps<DataGridFeatures, object>['tableLayout']> = {
rowBorder: true,
headerBackground: true,
headerBorder: true,
columnsResizable: false,
}
export const DATA_GRID_CLASS_NAMES: NonNullable<DataGridProps<DataGridFeatures, object>['tableClassNames']> = {
headerRow: 'border-b border-border',
bodyRow: 'group/row',
}
export const DATA_GRID_CONTAINER_CLASS = 'w-full rounded-none border-0'
/** Отступы ячейки по положению колонки: первая — pl-5, последняя — pr-4. */
export function cellPad(index: number, count: number) {
if (index === 0) return DATA_GRID_CELL_PAD_FIRST
if (index === count - 1) return DATA_GRID_CELL_PAD_LAST
return DATA_GRID_CELL_PAD
}
@@ -0,0 +1,123 @@
/**
* Каркас списочной страницы как в MikrotikManager (/servers): карточка Frame без полей, тулбар
* «сегменты · фильтры · поиск · счётчик», таблица с подложкой шапки и ячейками py-3.
*/
import type { ReactNode } from 'react'
import type { Column } from '@tanstack/react-table'
import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, SearchIcon } from 'lucide-react'
import { Frame, FramePanel } from '@/components/reui/frame'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@authportal/ui/components/input-group'
import { cn } from '@authportal/ui/lib/utils'
import type { DataGridFeatures } from '@/components/reui/data-grid/data-grid'
export function DataPageCard({ children, className }: { children: ReactNode; className?: string }) {
return (
<Frame dense className={cn('w-full min-w-0', className)}>
<FramePanel className="flex w-full min-w-0 flex-col gap-0 overflow-hidden p-0 py-0 shadow-none">{children}</FramePanel>
</Frame>
)
}
export function SegmentedControl<T extends string>({
value,
onChange,
options,
label,
}: {
value: T
onChange: (value: T) => void
options: { value: T; label: string; count?: number }[]
label?: string
}) {
return (
<div role="group" aria-label={label} className="border-border bg-muted/40 flex w-fit items-center gap-1 rounded-md border p-0.5">
{options.map((o) => (
<button
key={o.value}
type="button"
aria-pressed={value === o.value}
onClick={() => onChange(o.value)}
className={cn(
'flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors',
value === o.value ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
)}
>
{o.label}
{o.count != null ? <span className="text-xs tabular-nums">{o.count}</span> : null}
</button>
))}
</div>
)
}
export function DataPageToolbar({
leading,
segmented,
filterControl,
search,
onSearchChange,
searchPlaceholder = 'Поиск…',
countLabel,
actions,
}: {
leading?: ReactNode
segmented?: { value: string; onChange: (value: string) => void; options: { value: string; label: string; count?: number }[]; label?: string }
filterControl?: ReactNode
search?: string
onSearchChange?: (value: string) => void
searchPlaceholder?: string
countLabel?: string
actions?: ReactNode
}) {
return (
<div className="flex flex-wrap items-center gap-3 border-b px-5 py-3">
{leading}
{segmented ? <SegmentedControl {...segmented} /> : null}
{filterControl}
{onSearchChange ? (
<InputGroup className="max-w-sm min-w-[220px]">
<InputGroupAddon>
<SearchIcon className="size-3.5" aria-hidden />
</InputGroupAddon>
<InputGroupInput aria-label={searchPlaceholder} placeholder={searchPlaceholder} value={search ?? ''} onChange={(e) => onSearchChange(e.target.value)} />
</InputGroup>
) : null}
{countLabel ? <span className="text-muted-foreground ml-auto text-sm">{countLabel}</span> : null}
{actions}
</div>
)
}
export function DataGridSortHeader<TData extends object>({
column,
title,
className,
}: {
column: Column<DataGridFeatures, TData, unknown>
title: string
className?: string
}) {
const sorted = column.getIsSorted()
if (!column.getCanSort()) {
return <span className={cn('text-muted-foreground text-xs font-medium', className)}>{title}</span>
}
return (
<button
type="button"
className={cn(
'text-muted-foreground hover:text-foreground focus-visible:outline-ring -ml-1 inline-flex items-center gap-1.5 rounded-md px-1 py-0.5 text-xs font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
className,
)}
onClick={column.getToggleSortingHandler()}
>
{title}
{sorted === 'asc' ? (
<ArrowUpIcon aria-hidden className="text-foreground size-3" />
) : sorted === 'desc' ? (
<ArrowDownIcon aria-hidden className="text-foreground size-3" />
) : (
<ArrowUpDownIcon aria-hidden className="size-3 opacity-35" />
)}
</button>
)
}
@@ -0,0 +1,88 @@
import { useState, type ReactNode } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { Button } from '@authportal/ui/components/button'
import { cn } from '@authportal/ui/lib/utils'
import { CodeBlock, PortalSheet, SignalAlert, StatusBadge } from './bits'
/**
* Баннер доски: заголовок и пояснение, справа «Объяснить» — открывает панель
* «Ассистент · разбор» (черновик с причинами и тем, что проверить).
*/
export function ExplainBanner({
tone,
title,
children,
text,
}: {
tone: 'er' | 'wn'
title: string
/** Текст для панели разбора (то же, что в баннере). */
text: string
children: ReactNode
}) {
const [open, setOpen] = useState(false)
const navigate = useNavigate()
return (
<>
<div
role="status"
className={cn(
'flex flex-wrap items-start gap-3 rounded-lg border px-3 py-2.5 text-sm',
tone === 'er' ? 'border-destructive/30 bg-destructive/10' : 'border-warning/30 bg-warning/10',
)}
>
<div className="min-w-0 flex-1 basis-80 text-pretty">
<b className="font-semibold">{title}</b> {children}
</div>
<Button type="button" variant="outline" onClick={() => setOpen(true)}>
Объяснить
</Button>
</div>
<PortalSheet
open={open}
onOpenChange={setOpen}
title="Ассистент · разбор"
description="Черновик: проверьте перед применением"
footer={
<>
<Button variant="outline" onClick={() => setOpen(false)}>
Закрыть
</Button>
<Button
onClick={() => {
setOpen(false)
void navigate({
// В прод-образе preview-маршруты вырезаны (см. Dockerfile) — ведём в реальный журнал.
to: window.location.pathname.startsWith('/design-preview')
? '/design-preview/logins'
: '/admin/logins',
})
}}
>
Открыть журнал входов
</Button>
</>
}
>
<SignalAlert tone="in" title="Что случилось">
{title} {text}
</SignalAlert>
<div className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs">Вероятные причины</span>
<div className="flex flex-wrap gap-1.5">
<StatusBadge tone="er">Подбор пароля</StatusBadge>
<StatusBadge tone="wn">Устаревший passkey</StatusBadge>
<StatusBadge tone="mu">Смена IP</StatusBadge>
</div>
<span className="text-muted-foreground text-xs">Отсортированы по вероятности по журналу входов за 24 часа.</span>
</div>
<CodeBlock label="Что проверить">
{'GET /api/v1/admin/audit?kind=logins&action=auth.login_failed&range=24h\nGET /api/v1/admin/[email protected]'}
</CodeBlock>
<SignalAlert tone="wn" title="Ассистент ничего не меняет сам">
Блокировка и сброс passkeys выполняются вручную с подтверждением и попадают в журнал «Изменения».
</SignalAlert>
</PortalSheet>
</>
)
}
+45
View File
@@ -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)} дн назад`
}
@@ -0,0 +1,74 @@
import { Badge } from '@/components/reui/badge'
import { IconTile } from '@/components/reui/icon-tile'
import { Frame, FramePanel } from '@/components/reui/frame'
import { cn } from '@authportal/ui/lib/utils'
import type { KpiItem, KpiTone } from './kpi'
const VALUE: Record<KpiTone, string> = {
neutral: 'text-foreground',
ok: 'text-foreground',
in: 'text-foreground',
wn: 'text-warning',
er: 'text-destructive',
}
/** Колонки по числу ячеек и ширине окна (на досках — ширина приложения): 4 → 4/2/1, 3 → 3/1, 2 → 2/1. */
const COLS: Record<number, string> = {
1: 'grid-cols-1',
2: 'grid-cols-2 max-[640px]:grid-cols-1',
3: 'grid-cols-1 min-[1240px]:grid-cols-3',
4: 'grid-cols-1 min-[640px]:grid-cols-2 min-[1240px]:grid-cols-4',
}
const TILE: Record<KpiTone, string> = {
neutral: 'text-muted-foreground',
ok: 'text-success',
in: 'text-info',
wn: 'text-warning',
er: 'text-destructive',
}
const GLYPH: Record<KpiTone, string> = { neutral: '', ok: '●', in: '', wn: '▲', er: '✕' }
const BADGE = {
neutral: 'outline',
ok: 'success-light',
in: 'info-light',
wn: 'warning-light',
er: 'destructive-light',
} as const
/**
* Полоса показателей как на досках: один Frame, ячейки разделены линиями,
* в ячейке подпись, крупное значение и плашка-подсказка.
*/
export function KpiStrip({ items, 'aria-label': ariaLabel }: { items: KpiItem[]; 'aria-label'?: string }) {
return (
<Frame spacing="sm" className="w-full min-w-0" aria-label={ariaLabel}>
<FramePanel className="overflow-hidden p-0">
<div className={cn('bg-border grid gap-px', COLS[Math.min(Math.max(items.length, 1), 4)])}>
{items.map((k) => {
const Icon = k.icon
return (
<div key={k.id} className="bg-card flex min-w-0 items-center gap-3 px-[18px] py-3.5">
<IconTile variant="outline" aria-hidden className={cn('shrink-0', TILE[k.tone])}>
<Icon />
</IconTile>
<div className="flex min-w-0 flex-col gap-1">
<div className="text-muted-foreground text-[13px]">{k.label}</div>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span className={cn('text-2xl font-semibold tracking-tight tabular-nums', VALUE[k.tone])}>{k.value}</span>
<Badge variant={BADGE[k.tone]} size="sm" className="whitespace-nowrap">
{GLYPH[k.tone] ? <span aria-hidden className="text-[0.55rem]">{GLYPH[k.tone]}</span> : null}
{k.hint}
</Badge>
</div>
</div>
</div>
)
})}
</div>
</FramePanel>
</Frame>
)
}
+24
View File
@@ -0,0 +1,24 @@
import type { LucideIcon } from 'lucide-react'
export type KpiTone = 'neutral' | 'ok' | 'wn' | 'er' | 'in'
export type KpiItem = {
id: string
label: string
value: string
/** Плашка-подсказка рядом со значением. */
hint: string
tone: KpiTone
/** Иконка показателя: плитка слева от подписи. */
icon: LucideIcon
}
/** Плитка KPI: иконка, подпись, значение (цветом при wn/er) и плашка-подсказка. */
export function kpi(
id: string,
label: string,
value: string,
opts: { icon: LucideIcon; tone?: KpiTone; badge: string },
): KpiItem {
return { id, label, value, hint: opts.badge, tone: opts.tone ?? 'neutral', icon: opts.icon }
}
@@ -0,0 +1,488 @@
import { useMemo, useState, type ReactNode } from 'react'
import { Columns3Icon, MoreHorizontalIcon } from 'lucide-react'
import { toast } from 'sonner'
import { useTable, type ColumnDef, type RowSelectionState, type SortingState } from '@tanstack/react-table'
import { DATA_GRID_RU } from '@/lib/reui-ru'
import { DataGrid, dataGridFeatures, type DataGridFeatures } from '@/components/reui/data-grid/data-grid'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import {
DataGridTableDndRowHandle,
DataGridTableDndRows,
} from '@/components/reui/data-grid/data-grid-table-dnd-rows'
import type { DragEndEvent } from '@dnd-kit/core'
import {
DataGridTable,
DataGridTableRowSelect,
DataGridTableRowSelectAll,
} from '@/components/reui/data-grid/data-grid-table'
import { Button } from '@authportal/ui/components/button'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu'
import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import { cn } from '@authportal/ui/lib/utils'
import { StatusBadge } from './bits'
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
DATA_GRID_CLASS_NAMES,
DATA_GRID_LAYOUT,
} from './data-page-layout'
import {
DataGridSortHeader,
DataPageCard,
DataPageToolbar,
} from './data-page'
import type { Tone } from './tone'
import type { RowMark } from './tone'
import { TONE_DOT } from './tone'
export type GridColumn<T> = {
id: string
title: string
/** Ширина колонки; сумма задаёт минимальную ширину таблицы. */
size: number
cell: (row: T) => ReactNode
/** Ключ сортировки; без него колонка не сортируется. */
sortValue?: (row: T) => string | number
}
export type GridView<T> = { label: string; match: (row: T) => boolean }
/** Карточка строки для режима «Карточки» (как на доске «Пользователи»). */
export type GridCard = {
name: string
sub?: string
tone: Tone
status: string
cells: Array<{ l: string; v: string }>
}
/** Действия меню строки «⋮»: на досках — Открыть, Включить/Выключить, Дублировать, Удалить…. */
export type RowMenuConfig = {
onOpen?: () => void
toggleLabel?: string
onToggle?: () => void
onDuplicate?: () => void
onDelete?: () => void
}
export type GridBulk = {
onLabel?: string
offLabel?: string
onRun?: (ids: string[]) => void
offRun?: (ids: string[]) => void
onDelete?: (ids: string[]) => void
}
const SELECT_COLUMN_SIZE = 44
const DRAG_COLUMN_SIZE = 44
const ACTIONS_COLUMN_SIZE = 120
/**
* Секция-таблица по доскам Auth Portal: шапка (название, действия, «Таблица/Карточки», поиск, «Колонки»),
* сохранённые виды линией со счётчиками, панель массовых действий, таблица со статусом и меню строки,
* подвал «Показано N из M». Ширина таблицы не меньше суммы колонок — прокрутка внутри контейнера.
*/
export function SectionGrid<T extends object>({
title,
description,
rows,
columns,
getRowId,
views,
searchText,
searchPlaceholder = 'Поиск…',
onRowClick,
selectable = true,
bulk,
tools,
card,
rowSwitch,
rowMenu,
onReorder,
rowStatus,
pageSize = 50,
emptyMessage = 'Нет записей по текущим фильтрам.',
className,
}: {
title: string
description: string
rows: T[]
columns: GridColumn<T>[]
getRowId: (row: T) => string
views?: GridView<T>[]
searchText?: (row: T) => string
searchPlaceholder?: string
onRowClick?: (row: T) => void
selectable?: boolean
bulk?: GridBulk
/** Кнопки действий в шапке; функция получает id выбранных строк. */
tools?: ReactNode | ((selectedIds: string[]) => ReactNode)
/** Включает переключатель «Таблица / Карточки». */
card?: (row: T) => GridCard
/** Переключатель «включено» в последней колонке (если у строки он есть). */
rowSwitch?: { value: (row: T) => boolean | undefined; onChange: (row: T, on: boolean) => void }
rowMenu?: (row: T) => RowMenuConfig
/** Включает перетаскивание строк (dnd-kit). Передаёт id строки и id строки, на место которой её бросили. */
onReorder?: (activeId: string, overId: string) => void
/** Пометка строки: «Не сохранено» (dirty/new) или «Будет удалено» (deleted). */
rowStatus?: (row: T) => RowMark | undefined
pageSize?: number
/** Свой текст подвала вместо «Показано N из M» (как «Показано 8 из 148» на доске). */
footer?: string
emptyMessage?: string
className?: string
}) {
const storageKey = `design-preview:view:${title}`
const [view, setViewState] = useState(() => {
try {
const v = Number(window.localStorage.getItem(storageKey))
return Number.isInteger(v) && v >= 0 && v < (views?.length ?? 0) ? v : 0
} catch {
return 0
}
})
const setView = (i: number) => {
setViewState(i)
try {
window.localStorage.setItem(storageKey, String(i))
} catch {
/* без сохранения вида */
}
}
const [query, setQuery] = useState('')
const [sorting, setSorting] = useState<SortingState>([])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize })
const [hidden, setHidden] = useState<Record<string, boolean>>({})
const [cards, setCards] = useState(false)
const data = useMemo(() => {
const matchView = views?.[view]?.match
const q = query.trim().toLowerCase()
return rows.filter((row) => {
if (matchView && !matchView(row)) return false
if (q && searchText && !searchText(row).toLowerCase().includes(q)) return false
return true
})
}, [rows, views, view, query, searchText])
const visibleColumns = useMemo(() => columns.filter((c, i) => i === 0 || !hidden[c.id]), [columns, hidden])
const defs = useMemo<ColumnDef<DataGridFeatures, T>[]>(() => {
const list: ColumnDef<DataGridFeatures, T>[] = []
if (onReorder) {
list.push({
id: 'drag',
header: () => <span className="sr-only">Порядок</span>,
cell: () => <DataGridTableDndRowHandle />,
enableSorting: false,
enableResizing: false,
size: DRAG_COLUMN_SIZE,
})
}
if (selectable) {
list.push({
id: 'select',
header: () => <DataGridTableRowSelectAll />,
cell: ({ row }) => (
<div onClick={(e) => e.stopPropagation()}>
<DataGridTableRowSelect row={row} />
</div>
),
enableSorting: false,
enableResizing: false,
size: SELECT_COLUMN_SIZE,
meta: { headerClassName: 'pl-5 py-3', cellClassName: 'pl-5 py-3' },
})
}
visibleColumns.forEach((col, i) => {
const pad = i === 0 && !selectable && !onReorder ? DATA_GRID_CELL_PAD_FIRST : DATA_GRID_CELL_PAD
list.push({
id: col.id,
accessorFn: col.sortValue ?? (() => ''),
header: ({ column }) => <DataGridSortHeader column={column} title={col.title} />,
cell: ({ row }) => col.cell(row.original),
enableSorting: Boolean(col.sortValue),
enableResizing: false,
size: col.size,
meta: { headerTitle: col.title, headerClassName: pad, cellClassName: pad },
})
})
list.push({
id: '__actions',
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => {
const r = row.original
const on = rowSwitch?.value(r)
const menu: RowMenuConfig = rowMenu?.(r) ?? {}
const soon = (what: string) => () => toast(`${what} (макет)`)
return (
<div className="flex items-center justify-end gap-2" onClick={(e) => e.stopPropagation()}>
{on !== undefined && rowSwitch ? (
<Switch size="sm" checked={on} onCheckedChange={(v) => rowSwitch.onChange(r, v)} aria-label="Включено" />
) : null}
<DropdownMenu>
<DropdownMenuTrigger render={<Button type="button" variant="ghost" size="icon" aria-label="Действия" />}>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-48">
<DropdownMenuItem onClick={menu.onOpen ?? (() => onRowClick?.(r))}>Открыть</DropdownMenuItem>
<DropdownMenuItem onClick={menu.onToggle ?? soon('Действие')}>
{menu.toggleLabel ?? (on === undefined ? 'Повторить проверку' : on ? 'Выключить' : 'Включить')}
</DropdownMenuItem>
<DropdownMenuItem onClick={menu.onDuplicate ?? soon('Дублирование')}>Дублировать</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={menu.onDelete ?? soon('Удаление')}>
Удалить…
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
},
enableSorting: false,
enableResizing: false,
size: ACTIONS_COLUMN_SIZE,
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
})
return list
}, [visibleColumns, selectable, onReorder, rowSwitch, rowMenu, onRowClick])
const table = useTable({
features: dataGridFeatures,
columns: defs,
data,
getRowId,
state: { sorting, rowSelection, pagination },
enableRowSelection: selectable,
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
})
const dataIds = table.getRowModel().rows.map((r) => r.id)
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!onReorder || !active || !over || active.id === over.id) return
onReorder(String(active.id), String(over.id))
}
// Колонки сжимаются под ширину рамки (текст переносится, как на досках); прокрутка — только на узких экранах.
const minWidth = Math.round(
visibleColumns.reduce(
(sum, c) => sum + c.size,
(selectable ? SELECT_COLUMN_SIZE : 0) + (onReorder ? DRAG_COLUMN_SIZE : 0) + ACTIONS_COLUMN_SIZE,
) * 0.7,
)
const selectedIds = table.getSelectedRowModel().rows.map((r) => r.id)
const clear = () => setRowSelection({})
const paged = data.length > pageSize
const showCards = Boolean(card) && cards
const countLabel = `${data.length} ${data.length === rows.length ? 'записей' : `из ${rows.length}`}`
return (
<DataGrid
table={table}
i18n={DATA_GRID_RU}
recordCount={data.length}
onRowClick={onRowClick}
getRowStatus={rowStatus}
emptyMessage={emptyMessage}
tableLayout={{ ...DATA_GRID_LAYOUT, rowsDraggable: Boolean(onReorder) }}
tableClassNames={DATA_GRID_CLASS_NAMES}
>
<DataPageCard className={className}>
<h2 className="sr-only">{title}</h2>
<DataPageToolbar
segmented={
views
? {
value: String(view),
label: description,
onChange: (v) => {
setView(Number(v))
setPagination((p) => ({ ...p, pageIndex: 0 }))
},
options: views.map((v, i) => ({ value: String(i), label: v.label, count: rows.filter(v.match).length })),
}
: undefined
}
leading={
card ? (
<Tabs value={cards ? 'cards' : 'table'} onValueChange={(v) => setCards(v === 'cards')} aria-label="Вид списка">
<TabsList className="h-9! gap-0.5 p-[3px]">
<TabsTrigger value="table" className="h-7! px-2.5 text-[12.5px]">
Таблица
</TabsTrigger>
<TabsTrigger value="cards" className="h-7! px-2.5 text-[12.5px]">
Карточки
</TabsTrigger>
</TabsList>
</Tabs>
) : undefined
}
search={query}
onSearchChange={
searchText
? (v) => {
setQuery(v)
setPagination((p) => ({ ...p, pageIndex: 0 }))
}
: undefined
}
searchPlaceholder={searchPlaceholder}
countLabel={countLabel}
actions={
<>
{typeof tools === 'function' ? tools(selectedIds) : tools}
{columns.length > 1 ? (
<DropdownMenu>
<DropdownMenuTrigger
render={<Button type="button" variant="outline" size="icon" aria-label="Выбрать колонки" title="Колонки" />}
>
<Columns3Icon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-52">
{columns.slice(1).map((c) => (
<DropdownMenuCheckboxItem key={c.id} checked={!hidden[c.id]} onCheckedChange={(v) => setHidden((h) => ({ ...h, [c.id]: !v }))}>
{c.title}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</>
}
/>
{selectable && selectedIds.length > 0 ? (
<div
role="toolbar"
aria-label="Массовые действия"
className="bg-primary/10 flex flex-wrap items-center gap-2 border-b px-3.5 py-2 text-[13px]"
>
<b className="mr-1.5 font-semibold">Выбрано: {selectedIds.length}</b>
<Button
type="button"
variant="outline"
onClick={() => {
if (bulk?.onRun) bulk.onRun(selectedIds)
else toast(`${bulk?.onLabel ?? 'Включить'}: ${selectedIds.length} (макет)`)
clear()
}}
>
{bulk?.onLabel ?? 'Включить'}
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
if (bulk?.offRun) bulk.offRun(selectedIds)
else toast(`${bulk?.offLabel ?? 'Выключить'}: ${selectedIds.length} (макет)`)
clear()
}}
>
{bulk?.offLabel ?? 'Выключить'}
</Button>
<Button
type="button"
variant="outline"
className="text-destructive"
onClick={() => {
if (bulk?.onDelete) bulk.onDelete(selectedIds)
else toast(`Удаление: ${selectedIds.length} (макет)`)
}}
>
Удалить…
</Button>
<span className="flex-1" />
<Button type="button" variant="outline" onClick={clear}>
Снять выбор
</Button>
</div>
) : null}
{showCards && card ? (
<ul className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,15rem),1fr))] gap-3 p-3">
{data.map((r) => {
const c = card(r)
const id = getRowId(r)
const marked = rowStatus?.(r)
return (
<li
key={id}
className={cn('bg-muted/25 flex flex-col gap-2 rounded-lg border p-3', marked === 'deleted' && 'opacity-60')}
>
<div className="flex items-center justify-between gap-2">
<input
type="checkbox"
aria-label={`Выбрать ${c.name}`}
checked={Boolean(rowSelection[id])}
onChange={(e) =>
setRowSelection((s) => {
const next = { ...s }
if (e.target.checked) next[id] = true
else delete next[id]
return next
})
}
className="accent-primary size-4"
/>
<StatusBadge tone={c.tone}>{c.status}</StatusBadge>
</div>
<button
type="button"
onClick={() => onRowClick?.(r)}
className="flex min-w-0 items-start gap-2 text-left"
aria-label={`Открыть: ${c.name}`}
>
<span aria-hidden className={cn('mt-1.5 size-2 shrink-0 rounded-full', TONE_DOT[c.tone])} />
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{c.name}</span>
{c.sub ? <span className="text-muted-foreground truncate font-mono text-[11px]">{c.sub}</span> : null}
</span>
</button>
<dl className="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs">
{c.cells.map((x) => (
<div key={x.l} className="min-w-0">
<dt className="text-muted-foreground">{x.l}</dt>
<dd className="truncate font-mono">{x.v}</dd>
</div>
))}
</dl>
</li>
)
})}
</ul>
) : (
<DataGridScrollArea>
<div style={{ minWidth }}>
{onReorder ? <DataGridTableDndRows dataIds={dataIds} handleDragEnd={handleDragEnd} /> : <DataGridTable />}
</div>
</DataGridScrollArea>
)}
{paged && !showCards ? <DataGridPagination className="px-5 py-3" sizes={[8, 16, 32]} /> : null}
</DataPageCard>
</DataGrid>
)
}
/** Двухстрочная ячейка: заголовок + подпись. */
export function TwoLineCell({ title, sub, dot }: { title: ReactNode; sub?: ReactNode; dot?: Tone }) {
return (
<div className="flex min-w-0 items-start gap-2">
{dot ? <span aria-hidden className={cn('mt-1.5 size-2 shrink-0 rounded-full', TONE_DOT[dot])} /> : null}
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{title}</span>
{sub ? <span className="text-muted-foreground truncate font-mono text-[11px]">{sub}</span> : null}
</div>
</div>
)
}
+28
View File
@@ -0,0 +1,28 @@
export type Tone = 'ok' | 'wn' | 'er' | 'mu' | 'in'
/** Пометка строки с накопленной правкой. */
export type RowMark = 'new' | 'dirty' | 'deleted'
type BadgeVariant =
| 'success-light'
| 'warning-light'
| 'destructive-light'
| 'info-light'
| 'outline'
export const TONE_BADGE: Record<Tone, BadgeVariant> = {
ok: 'success-light',
wn: 'warning-light',
er: 'destructive-light',
in: 'info-light',
mu: 'outline',
}
export const TONE_DOT: Record<Tone, string> = {
ok: 'bg-success',
wn: 'bg-warning',
er: 'bg-destructive',
in: 'bg-success',
mu: 'bg-muted-foreground/50',
}
@@ -0,0 +1,912 @@
/**
* Admin users grid — DNA solution-users-1 (members-grid, columns, bulk-action-bar).
* Preview: https://reui.io/preview/base/solution-users-1
* Docs: https://reui.io/docs/components/base/filters · https://reui.io/docs/components/base/data-grid
*/
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import type { AdminUser, AppId } from '@authportal/shared'
import {
CircleDotIcon,
FilterIcon,
FunnelXIcon,
FingerprintIcon,
LockIcon,
MailIcon,
MoreHorizontalIcon,
PauseCircleIcon,
PencilIcon,
ScrollTextIcon,
Settings2Icon,
ShieldCheckIcon,
Trash2Icon,
TriangleAlertIcon,
UserIcon,
UserPlusIcon,
} from 'lucide-react'
import {
type ColumnDef,
type PaginationState,
type Row,
type RowSelectionState,
type SortingState,
type ColumnVisibilityState,
useTable,
} from '@tanstack/react-table'
import { Badge } from '@/components/reui/badge'
import { DataGrid, dataGridFeatures, type DataGridFeatures } from '@/components/reui/data-grid/data-grid'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridColumnVisibility } from '@/components/reui/data-grid/data-grid-column-visibility'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import {
DataGridTable,
DataGridTableRowSelect,
DataGridTableRowSelectAll,
} from '@/components/reui/data-grid/data-grid-table'
import { DATA_GRID_RU, FILTERS_RU, FILTER_OPERATORS_RU } from '@/lib/reui-ru'
import { cellPad } from '@/components/portal/data-page-layout'
import {
DATA_GRID_CLASS_NAMES,
DATA_GRID_LAYOUT,
} from '@/components/portal/data-page-layout'
import { DataPageCard, DataPageToolbar } from '@/components/portal/data-page'
import { cn } from '@authportal/ui/lib/utils'
import { Filters } from '@/components/reui/filters/filters'
import {
createFilterQuery,
flattenFilterConditions,
type FilterCondition,
} from '@/components/reui/filters/filters-query'
import type {
FilterField,
FilterQuery,
} from '@/components/reui/filters/filters-types'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@authportal/ui/components/alert-dialog'
import {
Avatar,
AvatarFallback,
} from '@authportal/ui/components/avatar'
import { Button } from '@authportal/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@authportal/ui/components/select'
import { Skeleton } from '@authportal/ui/components/skeleton'
import { TooltipProvider } from '@authportal/ui/components/tooltip'
const BULK_ROLE_OPTIONS = [
{ value: 'admin', label: 'Админ' },
{ value: 'user', label: 'Пользователь' },
] as const
type BulkRole = (typeof BULK_ROLE_OPTIONS)[number]['value']
export interface AdminUsersGridProps {
users: AdminUser[]
isLoading?: boolean
onCreateClick: () => void
onOpenAudit: (user: AdminUser) => void
onOpenAccess: (user: AdminUser) => void
onDeactivate: (user: AdminUser) => void
onDelete: (user: AdminUser) => void
onResetPasskeys: (user: AdminUser) => void
onBulkSetRole: (userIds: string[], isAdmin: boolean) => void
onBulkDeactivate: (userIds: string[]) => void
}
function serializeConditionsKey(conditions: FilterCondition[]) {
return JSON.stringify(
conditions.map((c) => ({
field: c.field,
operator: c.operator,
values: c.values,
negated: c.negated,
})),
)
}
function filterFieldValue(item: AdminUser, field: string): unknown {
if (field === 'name') return item.name
if (field === 'email') return item.email
if (field === 'role') return item.is_admin ? 'admin' : 'user'
if (field === 'status') return item.disabled ? 'disabled' : 'active'
if (field === 'apps') return item.apps.join(' ')
return undefined
}
function matchesCondition(item: AdminUser, condition: FilterCondition): boolean {
const { field, operator, values } = condition
const raw = filterFieldValue(item, field)
const fieldValue = raw != null ? String(raw) : ''
const lower = fieldValue.toLowerCase()
const tokens = values.map((v) => String(v).trim()).filter(Boolean)
switch (operator) {
case 'is':
return values.map(String).includes(fieldValue)
case 'is_not':
return !values.map(String).includes(fieldValue)
case 'is_any_of':
return values.map(String).includes(fieldValue)
case 'is_none_of':
return !values.map(String).includes(fieldValue)
case 'contains':
return tokens.length === 0 || tokens.some((t) => lower.includes(t.toLowerCase()))
case 'not_contains':
return !tokens.some((t) => lower.includes(t.toLowerCase()))
case 'starts_with':
return tokens.some((t) => lower.startsWith(t.toLowerCase()))
case 'ends_with':
return tokens.some((t) => lower.endsWith(t.toLowerCase()))
case 'empty':
return fieldValue === ''
case 'not_empty':
return fieldValue !== ''
default:
return true
}
}
function applyConditionsToUsers(data: AdminUser[], conditions: FilterCondition[]): AdminUser[] {
if (conditions.length === 0) return data
return data.filter((item) =>
conditions.every((c) => {
const hit = matchesCondition(item, c)
return c.negated ? !hit : hit
}),
)
}
function userInitials(name: string, email: string) {
const parts = name.trim().split(/\s+/).filter(Boolean)
if (parts.length >= 2) {
return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase()
}
return (name || email).slice(0, 2).toUpperCase()
}
function formatLastActive(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)
}
function RoleBadge({ isAdmin }: { isAdmin: boolean }) {
return isAdmin ? (
<Badge variant="info-outline">Админ</Badge>
) : (
<Badge variant="secondary">Пользователь</Badge>
)
}
function StatusBadge({ disabled }: { disabled: boolean }) {
return disabled ? (
<Badge variant="outline">
<span className="bg-muted-foreground size-1.5 shrink-0 rounded-full!" />
Отключён
</Badge>
) : (
<Badge variant="outline">
<span className="bg-success size-1.5 shrink-0 rounded-full!" />
Активен
</Badge>
)
}
const AppTags = memo(function AppTags({ apps }: { apps: AppId[] }) {
if (apps.length === 0) {
return <span className="text-muted-foreground text-sm">—</span>
}
return (
<div className="flex flex-wrap items-center gap-1">
{apps.map((appId) => (
<Badge key={appId} variant="secondary" className="border-0">
{appId.toUpperCase()}
</Badge>
))}
</div>
)
})
const MemberCell = memo(function MemberCell({ row }: { row: Row<DataGridFeatures, AdminUser> }) {
const user = row.original
return (
<div className="flex min-w-0 items-center gap-2">
<Avatar className="size-8 shrink-0">
<AvatarFallback className="text-xs">
{userInitials(user.name, user.email)}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="text-foreground line-clamp-1 font-medium">
{user.name}
</div>
<div
className="text-muted-foreground line-clamp-1 text-xs"
title={user.email}
>
{user.email}
</div>
</div>
</div>
)
})
function AdminUsersBulkActionBar({
selectedCount,
roleValue,
onRoleChange,
onChangeRole,
onDeactivate,
onClear,
}: {
selectedCount: number
roleValue: BulkRole
onRoleChange: (value: BulkRole) => void
onChangeRole: () => void
onDeactivate: () => void
onClear: () => void
}) {
return (
<div className="bg-muted/25 flex flex-col gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py) lg:flex-row lg:items-center lg:justify-between">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-sm font-medium">
Выбрано: {selectedCount}
</span>
<span className="text-muted-foreground text-xs">
Массовое изменение роли или отключение доступа.
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<Select
value={roleValue}
onValueChange={(value) => {
if (!value) return
onRoleChange(value as BulkRole)
}}
items={BULK_ROLE_OPTIONS.map((o) => ({
value: o.value,
label: o.label,
}))}
>
<SelectTrigger size="sm" className="w-[164px]">
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectGroup>
{BULK_ROLE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button type="button" size="sm" variant="outline" onClick={onChangeRole}>
<PencilIcon data-icon="inline-start" aria-hidden="true" />
Сменить роль
</Button>
<Button type="button" size="sm" onClick={onDeactivate}>
<PauseCircleIcon data-icon="inline-start" aria-hidden="true" />
Отключить
</Button>
<Button type="button" size="sm" variant="ghost" onClick={onClear}>
Сбросить
</Button>
</div>
</div>
)
}
function ActionsCell({
row,
onOpenAudit,
onOpenAccess,
onDeactivate,
onDelete,
onResetPasskeys,
}: {
row: Row<DataGridFeatures, AdminUser>
onOpenAudit: (user: AdminUser) => void
onOpenAccess: (user: AdminUser) => void
onDeactivate: (user: AdminUser) => void
onDelete: (user: AdminUser) => void
onResetPasskeys: (user: AdminUser) => void
}) {
const [deleteOpen, setDeleteOpen] = useState(false)
const [resetOpen, setResetOpen] = useState(false)
const user = row.original
return (
<div onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
size="icon"
variant="ghost"
className="size-7"
aria-label="Действия"
/>
}
>
<MoreHorizontalIcon aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-44">
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => onOpenAudit(user)}>
<ScrollTextIcon className="size-4" aria-hidden="true" />
Журнал
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onOpenAccess(user)}>
<ShieldCheckIcon className="size-4" aria-hidden="true" />
Права
</DropdownMenuItem>
{!user.disabled ? (
<DropdownMenuItem onClick={() => onDeactivate(user)}>
<PauseCircleIcon className="size-4" aria-hidden="true" />
Отключить
</DropdownMenuItem>
) : null}
{(user.passkey_count ?? 0) > 0 ? (
<DropdownMenuItem onClick={() => setResetOpen(true)}>
<FingerprintIcon className="size-4" aria-hidden="true" />
Сбросить passkeys
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => setDeleteOpen(true)}
>
<Trash2Icon className="size-4" aria-hidden="true" />
Удалить
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog open={resetOpen} onOpenChange={setResetOpen}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Сбросить passkeys?</AlertDialogTitle>
<AlertDialogDescription>
Все ключи пользователя{' '}
<span className="text-foreground font-medium">{user.email}</span>{' '}
будут удалены. Вход останется по паролю.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
setResetOpen(false)
onResetPasskeys(user)
}}
>
Сбросить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Удалить пользователя?</AlertDialogTitle>
<AlertDialogDescription>
Учётная запись{' '}
<span className="text-foreground font-medium">{user.email}</span>{' '}
будет удалена без возможности восстановления.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
setDeleteOpen(false)
onDelete(user)
}}
>
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
function createAdminUserColumns(handlers: {
onOpenAudit: (user: AdminUser) => void
onOpenAccess: (user: AdminUser) => void
onDeactivate: (user: AdminUser) => void
onDelete: (user: AdminUser) => void
onResetPasskeys: (user: AdminUser) => void
}): ColumnDef<DataGridFeatures, AdminUser>[] {
return [
{
id: 'select',
header: () => (
<div onClick={(e) => e.stopPropagation()}>
<DataGridTableRowSelectAll />
</div>
),
cell: ({ row }) => (
<div onClick={(e) => e.stopPropagation()}>
<DataGridTableRowSelect row={row} />
</div>
),
enableSorting: false,
size: 40,
enableResizing: false,
enableHiding: false,
meta: {
skeleton: <Skeleton className="mx-auto size-5" />,
headerClassName:
'[--data-grid-header-cell-ps:var(--frame-panel-header-px)]',
cellClassName: '[--data-grid-body-cell-ps:var(--frame-panel-px)]',
},
},
{
accessorKey: 'name',
id: 'name',
header: ({ column }) => (
<DataGridColumnHeader title="Участник" visibility column={column} />
),
cell: ({ row }) => <MemberCell row={row} />,
enableSorting: true,
enableHiding: false,
enableResizing: true,
minSize: 220,
meta: {
headerTitle: 'Участник',
autoSize: true,
skeleton: (
<div className="flex min-w-0 items-center gap-2">
<Skeleton className="size-8 shrink-0 rounded-full" />
<div className="flex min-w-0 flex-col gap-0.5">
<Skeleton className="h-3.5 w-32" />
<Skeleton className="h-3 w-40" />
</div>
</div>
),
},
},
{
id: 'role',
accessorFn: (row) => (row.is_admin ? 'admin' : 'user'),
header: ({ column }) => (
<DataGridColumnHeader title="Роль" visibility column={column} />
),
cell: ({ row }) => <RoleBadge isAdmin={row.original.is_admin} />,
size: 130,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: 'Роль',
skeleton: <Skeleton className="h-6 w-20 rounded-full" />,
},
},
{
id: 'apps',
accessorFn: (row) => row.apps.length,
header: ({ column }) => (
<DataGridColumnHeader title="Apps" visibility column={column} />
),
cell: ({ row }) => <AppTags apps={row.original.apps} />,
size: 160,
enableSorting: false,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: 'Apps',
skeleton: (
<div className="flex flex-wrap items-center gap-1">
<Skeleton className="h-5 w-12 rounded-full" />
<Skeleton className="h-5 w-12 rounded-full" />
</div>
),
},
},
{
id: 'status',
accessorFn: (row) => (row.disabled ? 'disabled' : 'active'),
header: ({ column }) => (
<DataGridColumnHeader title="Статус" visibility column={column} />
),
cell: ({ row }) => <StatusBadge disabled={row.original.disabled} />,
size: 130,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: 'Статус',
skeleton: <Skeleton className="h-6 w-24 rounded-full" />,
},
},
{
id: 'auth',
header: ({ column }) => (
<DataGridColumnHeader title="Auth" visibility column={column} />
),
cell: () => (
<Badge variant="warning-outline">
<LockIcon aria-hidden="true" />
Локальный
</Badge>
),
size: 120,
enableSorting: false,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: 'Auth',
skeleton: <Skeleton className="h-5 w-20 rounded-full" />,
},
},
{
id: 'twoFactor',
accessorFn: (row) => (row.passkey_count ?? 0) > 0,
header: ({ column }) => (
<DataGridColumnHeader title="2FA" visibility column={column} />
),
cell: ({ row }) =>
(row.original.passkey_count ?? 0) > 0 ? (
<Badge variant="success-outline">
<FingerprintIcon aria-hidden="true" />
Passkey
</Badge>
) : (
<Badge variant="destructive-outline">
<TriangleAlertIcon aria-hidden="true" />
Выкл
</Badge>
),
size: 100,
enableSorting: false,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: '2FA',
skeleton: <Skeleton className="h-5 w-16 rounded-full" />,
},
},
{
accessorKey: 'last_login_at',
id: 'lastActive',
header: ({ column }) => (
<DataGridColumnHeader
title="Last Active"
visibility
column={column}
/>
),
cell: ({ row }) => (
<span className="text-muted-foreground text-sm">
{formatLastActive(row.original.last_login_at)}
</span>
),
size: 150,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: 'Last Active',
skeleton: <Skeleton className="h-4 w-28" />,
},
},
{
accessorKey: 'last_login_ip',
id: 'lastIp',
header: ({ column }) => (
<DataGridColumnHeader title="Последний IP" visibility column={column} />
),
cell: ({ row }) => (
<span className="text-muted-foreground font-mono text-xs tabular-nums">
{row.original.last_login_ip ?? '—'}
</span>
),
size: 140,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: 'Последний IP',
skeleton: <Skeleton className="h-4 w-24" />,
},
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<ActionsCell
row={row}
onOpenAudit={handlers.onOpenAudit}
onOpenAccess={handlers.onOpenAccess}
onDeactivate={handlers.onDeactivate}
onDelete={handlers.onDelete}
onResetPasskeys={handlers.onResetPasskeys}
/>
),
size: 60,
enableSorting: false,
enableHiding: false,
enableResizing: false,
meta: {
skeleton: <Skeleton className="mx-auto size-7 rounded-md" />,
headerClassName:
'[--data-grid-header-cell-pe:var(--frame-panel-header-px)]',
cellClassName: '[--data-grid-body-cell-pe:var(--frame-panel-px)]',
},
},
]
}
export function AdminUsersGrid({
users,
isLoading = false,
onCreateClick,
onOpenAudit,
onOpenAccess,
onDeactivate,
onDelete,
onResetPasskeys,
onBulkSetRole,
onBulkDeactivate,
}: AdminUsersGridProps) {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const [sorting, setSorting] = useState<SortingState>([
{ id: 'name', desc: false },
])
const [columnVisibility, setColumnVisibility] = useState<ColumnVisibilityState>({})
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
const [bulkRole, setBulkRole] = useState<BulkRole>('user')
const lastAppliedKey = useRef<string>(serializeConditionsKey([]))
const filterFields: FilterField[] = useMemo(
() => [
{
id: 'name',
label: 'Участник',
icon: <UserIcon className="size-3.5" aria-hidden />,
type: 'text',
placeholder: 'Поиск…',
},
{
id: 'email',
label: 'Email',
icon: <MailIcon className="size-3.5" aria-hidden />,
type: 'text',
placeholder: 'Поиск…',
},
{
id: 'role',
label: 'Роль',
icon: <ShieldCheckIcon className="size-3.5" aria-hidden />,
type: 'select',
searchable: false,
options: BULK_ROLE_OPTIONS.map((o) => ({ value: o.value, label: o.label })),
},
{
id: 'status',
label: 'Статус',
icon: <CircleDotIcon className="size-3.5" aria-hidden />,
type: 'select',
searchable: false,
options: [
{ value: 'active', label: 'Активен' },
{ value: 'disabled', label: 'Отключён' },
],
},
],
[],
)
const conditions = useMemo(() => flattenFilterConditions(query), [query])
const filteredData = useMemo(() => applyConditionsToUsers(users, conditions), [users, conditions])
const handleQueryChange = useCallback((next: FilterQuery) => {
setQuery(next)
const nextKey = serializeConditionsKey(flattenFilterConditions(next))
if (nextKey === lastAppliedKey.current) return
lastAppliedKey.current = nextKey
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
setRowSelection({})
}, [])
const columns = useMemo(
() =>
createAdminUserColumns({
onOpenAudit,
onOpenAccess,
onDeactivate,
onDelete,
onResetPasskeys,
}).map((col, i, all) => {
const pad = cellPad(i, all.length)
return {
...col,
meta: {
...col.meta,
headerClassName: cn(col.meta?.headerClassName, pad),
cellClassName: cn(col.meta?.cellClassName, pad),
},
}
}),
[onOpenAudit, onOpenAccess, onDeactivate, onDelete, onResetPasskeys],
)
const [columnOrder, setColumnOrder] = useState<string[]>(
() => columns.map((c) => c.id as string),
)
const table = useTable({
features: dataGridFeatures,
columns,
data: filteredData,
pageCount: Math.ceil(filteredData.length / pagination.pageSize),
getRowId: (row) => row.id,
state: {
pagination,
sorting,
columnOrder,
columnVisibility,
rowSelection,
},
enableRowSelection: true,
columnResizeMode: 'onChange',
onColumnOrderChange: setColumnOrder,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
})
const selectedCount = table.getSelectedRowModel().rows.length
const selectedIds = table
.getSelectedRowModel()
.rows.map((row) => row.original.id)
const showClearButton = conditions.length > 0
const gridLoading = isLoading
const handleClearSelection = useCallback(() => setRowSelection({}), [])
const handleChangeRole = useCallback(() => {
if (selectedCount === 0) return
onBulkSetRole(selectedIds, bulkRole === 'admin')
setRowSelection({})
}, [bulkRole, onBulkSetRole, selectedCount, selectedIds])
const handleBulkDeactivate = useCallback(() => {
if (selectedCount === 0) return
onBulkDeactivate(selectedIds)
setRowSelection({})
}, [onBulkDeactivate, selectedCount, selectedIds])
const handleClearFilters = useCallback(() => {
lastAppliedKey.current = serializeConditionsKey([])
setQuery(createFilterQuery())
setRowSelection({})
}, [])
return (
<TooltipProvider delay={200}>
<DataGrid
table={table}
i18n={DATA_GRID_RU}
isLoading={gridLoading}
loadingMode="skeleton"
recordCount={filteredData.length}
onRowClick={onOpenAudit}
emptyMessage={
!gridLoading && filteredData.length === 0
? 'Нет пользователей по текущим фильтрам. Сбросьте фильтры или измените условия.'
: undefined
}
tableLayout={{
...DATA_GRID_LAYOUT,
columnsResizable: true,
columnsMovable: true,
columnsVisibility: true,
headerSticky: true,
}}
tableClassNames={DATA_GRID_CLASS_NAMES}
>
<DataPageCard>
<DataPageToolbar
filterControl={
<Filters
query={query}
fields={filterFields}
onQueryChange={handleQueryChange}
labels={FILTERS_RU}
operatorLabels={FILTER_OPERATORS_RU}
size="default"
trigger={
<Button type="button" size="default" variant="outline" aria-label="Фильтры">
<FilterIcon aria-hidden />
Фильтры
</Button>
}
/>
}
countLabel={`${filteredData.length} пользователей`}
actions={
<>
{showClearButton ? (
<Button type="button" size="default" variant="outline" className="shrink-0" onClick={handleClearFilters} disabled={gridLoading}>
<FunnelXIcon aria-hidden />
Сбросить
</Button>
) : null}
<DataGridColumnVisibility
table={table}
trigger={
<Button type="button" size="default" variant="outline" aria-label="Настройки таблицы">
<Settings2Icon aria-hidden="true" />
Колонки
</Button>
}
/>
<Button type="button" size="default" onClick={onCreateClick}>
<UserPlusIcon aria-hidden="true" />
Создать
</Button>
</>
}
/>
{selectedCount > 0 ? (
<AdminUsersBulkActionBar
selectedCount={selectedCount}
roleValue={bulkRole}
onRoleChange={setBulkRole}
onChangeRole={handleChangeRole}
onDeactivate={handleBulkDeactivate}
onClear={handleClearSelection}
/>
) : null}
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<DataGridPagination className="px-5 py-3" sizes={[10, 20, 30]} />
</DataPageCard>
</DataGrid>
</TooltipProvider>
)
}
@@ -0,0 +1,175 @@
/**
* Admin editor for portal App Switcher URLs.
* Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3
*/
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import {
APP_IDS,
appSwitcherConfigSchema,
type AppId,
type AppSwitcherConfig,
type AppSwitcherIconName,
} 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'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@authportal/ui/components/select'
import { Switch } from '@authportal/ui/components/switch'
import { ItemSeparator } from '@authportal/ui/components/item'
const ICON_OPTIONS: AppSwitcherIconName[] = [
'server',
'cloud',
'globe',
'dashboard',
'chart',
]
interface AppSwitcherAdminEditorProps {
defaultValues: AppSwitcherConfig
onSave: (values: AppSwitcherConfig) => void
isSaving?: boolean
}
export function AppSwitcherAdminEditor({
defaultValues,
onSave,
isSaving,
}: AppSwitcherAdminEditorProps) {
const form = useForm({
resolver: zodResolver(appSwitcherConfigSchema),
defaultValues,
})
useEffect(() => {
form.reset(defaultValues)
}, [defaultValues, form])
return (
<form
className="flex flex-col gap-5"
onSubmit={(e) =>
void form.handleSubmit((values) => onSave(values))(e)
}
>
<FieldGroup>
<Field>
<FieldLabel htmlFor="menu-label">Заголовок меню</FieldLabel>
<Input id="menu-label" {...form.register('menuLabel')} />
</Field>
{APP_IDS.map((appId, index) => {
const apps = form.watch('apps')
const appIndex = apps.findIndex((a) => a.id === appId)
if (appIndex < 0) return null
return (
<div key={appId} className="flex flex-col gap-3">
{index > 0 ? <ItemSeparator /> : null}
<p className="text-sm font-medium tracking-tight">
{appId.toUpperCase()}
</p>
<div className="grid gap-3 sm:grid-cols-2">
<Field className="sm:col-span-2">
<FieldLabel htmlFor={`name-${appId}`}>Название</FieldLabel>
<Input
id={`name-${appId}`}
{...form.register(`apps.${appIndex}.name`)}
/>
</Field>
<Field className="sm:col-span-2">
<FieldLabel htmlFor={`url-${appId}`}>URL</FieldLabel>
<Input
id={`url-${appId}`}
{...form.register(`apps.${appIndex}.url`)}
placeholder="https://…"
/>
</Field>
{appId === 'bgp' ? (
<Field className="sm:col-span-2">
<FieldLabel htmlFor={`tenant-${appId}`}>
EvoBGP tenant ID
</FieldLabel>
<Input
id={`tenant-${appId}`}
{...form.register(`apps.${appIndex}.tenantId`)}
placeholder="UUID тенанта из EvoBGP"
/>
<p className="text-muted-foreground text-xs">
Попадает в JWT (`bgp_tenant_id` / `tenants.bgp`). Публичный
switcher его не отдаёт.
</p>
</Field>
) : null}
<Field className="sm:col-span-2">
<FieldLabel htmlFor={`subtitle-${appId}`}>
Описание
</FieldLabel>
<Input
id={`subtitle-${appId}`}
{...form.register(`apps.${appIndex}.subtitle`)}
/>
</Field>
<Field>
<FieldLabel htmlFor={`icon-${appId}`}>Иконка</FieldLabel>
<Select
value={form.watch(`apps.${appIndex}.icon`)}
onValueChange={(v) =>
form.setValue(
`apps.${appIndex}.icon`,
(v ?? 'server') as AppSwitcherIconName,
{ shouldDirty: true },
)
}
>
<SelectTrigger id={`icon-${appId}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ICON_OPTIONS.map((icon) => (
<SelectItem key={icon} value={icon}>
{icon}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field orientation="horizontal" className="items-center pt-6">
<Switch
checked={form.watch(`apps.${appIndex}.enabled`) !== false}
onCheckedChange={(v) =>
form.setValue(`apps.${appIndex}.enabled`, v, {
shouldDirty: true,
})
}
/>
<FieldLabel className="font-normal">Включено в switcher</FieldLabel>
</Field>
</div>
<input
type="hidden"
{...form.register(`apps.${appIndex}.id`)}
value={appId satisfies AppId}
/>
</div>
)
})}
</FieldGroup>
<Button
type="submit"
className="w-fit"
disabled={isSaving || !form.formState.isDirty}
>
{isSaving ? 'Сохранение…' : 'Сохранить'}
</Button>
</form>
)
}
@@ -0,0 +1,237 @@
import type { AuditLogEntry, AuditSeverity, AuditSourceApp } from '@authportal/shared'
import {
KeyRoundIcon,
LogInIcon,
LogOutIcon,
SettingsIcon,
ShieldAlertIcon,
Trash2Icon,
UserCogIcon,
UserPlusIcon,
UserXIcon,
type LucideIcon,
} from 'lucide-react'
import type { BadgeProps } from '@/components/reui/badge'
export type AuditFilterId = 'all' | 'auth' | 'users' | 'settings'
export const AUDIT_FILTER_OPTIONS: { value: AuditFilterId; label: string }[] = [
{ value: 'all', label: 'Все' },
{ value: 'auth', label: 'Auth' },
{ value: 'users', label: 'Пользователи' },
{ value: 'settings', label: 'Настройки' },
]
export const RANGE_OPTIONS = [
{ value: '24h', label: 'За 24 часа' },
{ value: '7d', label: 'За 7 дней' },
{ value: '30d', label: 'За 30 дней' },
{ value: '90d', label: 'За 90 дней' },
{ value: 'all', label: 'Весь период' },
] as const
export type AuditRange = (typeof RANGE_OPTIONS)[number]['value']
export const SOURCE_APP_OPTIONS: {
value: AuditSourceApp | 'all'
label: string
}[] = [
{ value: 'all', label: 'Все приложения' },
{ value: 'portal', label: 'Auth Portal' },
{ value: 'vps', label: 'VPS Tracker' },
{ value: 'cfdm', label: 'CFDM' },
{ value: 'bgp', label: 'EvoBGP' },
{ value: 'fw', label: 'EvoFirewall' },
{ value: 'dns', label: 'Technitium DNS' },
{ value: 'cdn', label: 'CDN Manager' },
{ value: 'mm', label: 'MikrotikManager' },
]
export const severityVariant: Record<AuditSeverity, BadgeProps['variant']> = {
info: 'success-outline',
warning: 'warning-outline',
critical: 'destructive-outline',
}
export const severityLabel: Record<AuditSeverity, string> = {
info: 'Info',
warning: 'Warning',
critical: 'Critical',
}
export const severityDotClass: Record<AuditSeverity, string> = {
info: 'bg-success',
warning: 'bg-warning',
critical: 'bg-destructive',
}
const ACTION_META: Record<
string,
{ label: string; icon: LucideIcon; filter: AuditFilterId }
> = {
'auth.login': { label: 'Вход', icon: LogInIcon, filter: 'auth' },
'auth.login_failed': {
label: 'Неудачный вход',
icon: ShieldAlertIcon,
filter: 'auth',
},
'auth.logout': { label: 'Выход', icon: LogOutIcon, filter: 'auth' },
'auth.sso_handoff': {
label: 'SSO',
icon: KeyRoundIcon,
filter: 'auth',
},
'user.create': { label: 'Создание пользователя', icon: UserPlusIcon, filter: 'users' },
'user.update': { label: 'Изменение пользователя', icon: UserCogIcon, filter: 'users' },
'user.delete': { label: 'Удаление пользователя', icon: UserXIcon, filter: 'users' },
'user.access_update': {
label: 'Обновление прав',
icon: KeyRoundIcon,
filter: 'users',
},
'app_switcher.update': {
label: 'App Switcher',
icon: SettingsIcon,
filter: 'settings',
},
'audit.settings_update': {
label: 'Срок хранения журнала',
icon: SettingsIcon,
filter: 'settings',
},
'audit.purge': { label: 'Очистка журнала', icon: Trash2Icon, filter: 'settings' },
}
export function actionMeta(action: string) {
return (
ACTION_META[action] ?? {
label: action,
icon: SettingsIcon,
filter: 'settings' as AuditFilterId,
}
)
}
export function matchesFilter(entry: AuditLogEntry, filter: AuditFilterId) {
if (filter === 'all') return true
return actionMeta(entry.action).filter === filter
}
export function matchesSourceApp(
entry: AuditLogEntry,
source: AuditSourceApp | 'all',
) {
if (source === 'all') return true
return entry.source_app === source
}
export function sourceAppLabel(app: AuditSourceApp | string): string {
return (
SOURCE_APP_OPTIONS.find((o) => o.value === app)?.label ?? String(app)
)
}
export function detailString(
details: Record<string, unknown> | null | undefined,
key: string,
): string | null {
if (!details) return null
const v = details[key]
if (typeof v === 'string' && v.trim()) return v
return null
}
export function matchesRange(entry: AuditLogEntry, range: AuditRange) {
if (range === 'all') return true
const ms =
range === '24h'
? 24 * 60 * 60 * 1000
: range === '7d'
? 7 * 24 * 60 * 60 * 1000
: range === '30d'
? 30 * 24 * 60 * 60 * 1000
: 90 * 24 * 60 * 60 * 1000
return Date.now() - new Date(entry.created_at).getTime() <= ms
}
export function initials(name: string | null, email: string | null) {
const source = (name ?? email ?? '?').trim()
const parts = source.split(/\s+/).filter(Boolean)
if (parts.length >= 2) {
return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase()
}
return source.slice(0, 2).toUpperCase()
}
export type AuditDayGroup = {
key: string
label: string
events: AuditLogEntry[]
}
export function groupByDay(entries: AuditLogEntry[]): AuditDayGroup[] {
const map = new Map<string, AuditLogEntry[]>()
for (const entry of entries) {
const d = new Date(entry.created_at)
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
const list = map.get(key) ?? []
list.push(entry)
map.set(key, list)
}
return [...map.entries()].map(([key, events]) => ({
key,
label: new Date(events[0]!.created_at).toLocaleDateString('ru-RU', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
}),
events,
}))
}
export function formatEventTime(iso: string) {
return new Date(iso).toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
export function exportAuditCsv(entries: AuditLogEntry[]) {
const header = [
'created_at',
'action',
'severity',
'actor_email',
'actor_name',
'summary',
'target_type',
'target_id',
'ip',
]
const rows = entries.map((e) =>
[
e.created_at,
e.action,
e.severity,
e.actor_email ?? '',
e.actor_name ?? '',
e.summary,
e.target_type ?? '',
e.target_id ?? '',
e.ip ?? '',
]
.map((v) => `"${String(v).replaceAll('"', '""')}"`)
.join(','),
)
const blob = new Blob([[header.join(','), ...rows].join('\n')], {
type: 'text/csv;charset=utf-8',
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `audit-log-${new Date().toISOString().slice(0, 10)}.csv`
a.click()
URL.revokeObjectURL(url)
}
@@ -0,0 +1,475 @@
/**
* Audit log timeline — adapted from @reui/solution-users-6.
* Preview: https://reui.io/preview/base/solution-users-6
* Docs: https://reui.io/blocks · Timeline: https://reui.io/docs/components/base/timeline
*/
import { useMemo, useState } from 'react'
import type { AuditLogEntry, AuditSourceApp } from '@authportal/shared'
import { toast } from 'sonner'
import {
CalendarIcon,
ChevronRightIcon,
CopyIcon,
DownloadIcon,
FilterIcon,
} from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import {
Frame,
FrameHeader,
FramePanel,
} from '@/components/reui/frame'
import {
Timeline,
TimelineContent,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
} from '@/components/reui/timeline'
import { cn } from '@authportal/ui/lib/utils'
import { Avatar, AvatarFallback } from '@authportal/ui/components/avatar'
import { Button } from '@authportal/ui/components/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@authportal/ui/components/collapsible'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@authportal/ui/components/empty'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@authportal/ui/components/select'
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import {
AUDIT_FILTER_OPTIONS,
RANGE_OPTIONS,
SOURCE_APP_OPTIONS,
actionMeta,
detailString,
exportAuditCsv,
formatEventTime,
groupByDay,
initials,
matchesFilter,
matchesRange,
matchesSourceApp,
severityDotClass,
severityLabel,
severityVariant,
sourceAppLabel,
type AuditFilterId,
type AuditRange,
} from './audit-log-helpers'
function copyValue(value: string) {
if (typeof navigator !== 'undefined' && navigator.clipboard) {
void navigator.clipboard.writeText(value).catch(() => undefined)
}
}
function DetailRow({
label,
children,
}: {
label: string
children: React.ReactNode
}) {
return (
<div className="flex min-w-0 flex-col gap-0.5">
<dt className="text-muted-foreground text-xs">{label}</dt>
<dd className="flex min-w-0 items-center text-sm">{children}</dd>
</div>
)
}
function EventRow({
event,
isLast,
step,
defaultOpen,
}: {
event: AuditLogEntry
isLast: boolean
step: number
defaultOpen: boolean
}) {
const [open, setOpen] = useState(defaultOpen)
const meta = actionMeta(event.action)
const Icon = meta.icon
const actorName = event.actor_name ?? event.actor_email ?? 'Система'
const userAgent = detailString(event.details, 'user_agent')
const targetApp =
detailString(event.details, 'target_app') ?? event.source_app
const reason = detailString(event.details, 'reason')
return (
<TimelineItem step={step} className={cn('ms-10 min-w-0', isLast ? 'pb-0' : 'pb-6')}>
<TimelineHeader className="flex min-w-0 items-center justify-between gap-2.5">
<TimelineSeparator className="bg-border! group-data-[orientation=vertical]/timeline:-left-7 group-data-[orientation=vertical]/timeline:h-[calc(100%-1.5rem-0.5rem)] group-data-[orientation=vertical]/timeline:translate-y-7" />
<div className="flex flex-wrap items-center gap-2">
<TimelineTitle className="text-sm font-semibold">
{meta.label}
</TimelineTitle>
<Badge variant={severityVariant[event.severity]} className="gap-1.5">
<span
className={cn(
'size-1.5 shrink-0 rounded-full',
severityDotClass[event.severity],
)}
aria-hidden="true"
/>
{severityLabel[event.severity]}
</Badge>
<Badge variant="outline" size="sm">
{sourceAppLabel(event.source_app)}
</Badge>
<span className="text-muted-foreground text-xs tabular-nums">
{formatEventTime(event.created_at)}
</span>
</div>
<TimelineIndicator className="border-border bg-background text-muted-foreground flex size-6 items-center justify-center border shadow-xs group-data-[orientation=vertical]/timeline:-left-7 [&_svg]:size-3.5">
<Icon aria-hidden="true" />
</TimelineIndicator>
</TimelineHeader>
<TimelineContent className="mt-2 min-w-0">
<Frame stacked dense spacing="sm" className="min-w-0">
<Collapsible
open={open}
onOpenChange={setOpen}
className="group/collapsible"
>
<CollapsibleTrigger
type="button"
className="flex w-full min-w-0"
aria-label={`Подробности: ${meta.label}`}
>
<FrameHeader className="flex min-w-0 grow flex-row items-center justify-between gap-2">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<Avatar className="size-5">
<AvatarFallback className="text-[10px]">
{initials(event.actor_name, event.actor_email)}
</AvatarFallback>
</Avatar>
<span className="text-foreground min-w-0 truncate text-sm font-medium">
{event.summary || actorName}
</span>
{event.ip ? (
<span className="text-muted-foreground font-mono text-xs tabular-nums">
{event.ip}
</span>
) : null}
{targetApp && targetApp !== 'portal' ? (
<Badge variant="info-outline" size="sm">
{sourceAppLabel(targetApp)}
</Badge>
) : null}
</div>
<ChevronRightIcon
className="text-muted-foreground size-4 shrink-0 transition-transform duration-200 group-data-open/collapsible:rotate-90"
aria-hidden="true"
/>
</FrameHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<FramePanel className="flex flex-col gap-3">
<dl className="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
<DetailRow label="Логин">
<span className="text-foreground truncate font-medium">
{event.actor_email ?? '—'}
</span>
</DetailRow>
<DetailRow label="IP">
<span className="text-foreground truncate font-medium tabular-nums font-mono text-xs">
{event.ip ?? '—'}
</span>
</DetailRow>
<DetailRow label="User-Agent">
<span
className="text-foreground line-clamp-2 text-xs"
title={userAgent ?? undefined}
>
{userAgent ?? '—'}
</span>
</DetailRow>
<DetailRow label="Приложение">
<span className="text-foreground truncate font-medium">
{sourceAppLabel(targetApp)}
</span>
</DetailRow>
{reason ? (
<DetailRow label="Причина">
<span className="text-foreground truncate font-medium">
{reason}
</span>
</DetailRow>
) : null}
<DetailRow label="Действие">
<span className="text-foreground truncate font-mono text-xs">
{event.action}
</span>
</DetailRow>
</dl>
{event.details ? (
<pre className="bg-muted/50 text-muted-foreground max-h-40 overflow-auto rounded-md p-2 text-xs">
{JSON.stringify(event.details, null, 2)}
</pre>
) : null}
<div className="flex flex-wrap items-center justify-between gap-2.5 border-t pt-2.5">
<Badge variant="outline" className="gap-1.5 font-mono">
{event.id.slice(0, 8)}
</Badge>
<div className="flex flex-wrap gap-1">
{userAgent ? (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
copyValue(userAgent)
toast.success('User-Agent скопирован')
}}
>
<CopyIcon className="opacity-60" aria-hidden="true" />
UA
</Button>
) : null}
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
copyValue(event.id)
toast.success('ID скопирован')
}}
>
<CopyIcon className="opacity-60" aria-hidden="true" />
ID
</Button>
</div>
</div>
</FramePanel>
</CollapsibleContent>
</Collapsible>
</Frame>
</TimelineContent>
</TimelineItem>
)
}
export function AuditLogTimeline({
entries,
totalCount,
compact = false,
lockedUserId,
sourceAppFilter,
onSourceAppFilterChange,
}: {
entries: AuditLogEntry[]
totalCount: number
compact?: boolean
lockedUserId?: string
sourceAppFilter?: AuditSourceApp | 'all'
onSourceAppFilterChange?: (v: AuditSourceApp | 'all') => void
}) {
const [filter, setFilter] = useState<AuditFilterId>('all')
const [range, setRange] = useState<AuditRange>(compact ? 'all' : '7d')
const [localSource, setLocalSource] = useState<AuditSourceApp | 'all'>(
sourceAppFilter ?? 'all',
)
const source = sourceAppFilter ?? localSource
const setSource = onSourceAppFilterChange ?? setLocalSource
const visible = useMemo(
() =>
entries.filter(
(e) =>
matchesFilter(e, filter) &&
matchesRange(e, range) &&
matchesSourceApp(e, source),
),
[entries, filter, range, source],
)
const days = useMemo(() => groupByDay(visible), [visible])
return (
<section className="w-full" aria-labelledby="audit-log-title">
<div className={cn('flex flex-col gap-4', compact ? 'mb-4' : 'mb-6')}>
{!compact ? (
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="flex min-w-0 flex-col gap-1">
<h1
id="audit-log-title"
className="text-xl font-semibold tracking-tight"
>
Журнал изменений
</h1>
<p className="text-muted-foreground text-sm leading-5">
{totalCount} событий · показано {visible.length}
{lockedUserId ? ' · пользователь' : ''}
</p>
</div>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Select
value={source}
onValueChange={(value) => {
if (value) setSource(value as AuditSourceApp | 'all')
}}
items={[...SOURCE_APP_OPTIONS]}
>
<SelectTrigger size="sm" className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent align="end" alignItemWithTrigger={false}>
<SelectGroup>
{SOURCE_APP_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Select
value={range}
onValueChange={(value) => {
if (value) setRange(value as AuditRange)
}}
items={[...RANGE_OPTIONS]}
>
<SelectTrigger size="sm" className="w-44">
<CalendarIcon
className="text-muted-foreground size-4"
aria-hidden="true"
/>
<SelectValue />
</SelectTrigger>
<SelectContent align="end" alignItemWithTrigger={false}>
<SelectGroup>
{RANGE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
size="sm"
type="button"
variant="outline"
onClick={() => {
exportAuditCsv(visible)
toast.success('CSV экспортирован', {
description: `${visible.length} событий`,
})
}}
>
<DownloadIcon aria-hidden="true" />
<span className="hidden sm:inline">Export CSV</span>
</Button>
</div>
</div>
) : (
<p
id="audit-log-title"
className="text-muted-foreground text-sm leading-5"
>
{visible.length} из {totalCount} событий
</p>
)}
<Tabs
value={filter}
onValueChange={(value) => {
if (value) setFilter(value as AuditFilterId)
}}
>
<TabsList
variant="line"
aria-label="Фильтр по типу"
className="h-10! w-full justify-start gap-6 overflow-x-auto border-b"
>
{AUDIT_FILTER_OPTIONS.map((option) => (
<TabsTrigger
key={option.value}
value={option.value}
className="px-1 text-sm after:-bottom-px!"
>
{option.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
{days.length === 0 ? (
<Empty className="min-h-[280px] border-0 bg-transparent">
<EmptyHeader>
<EmptyMedia variant="icon">
<FilterIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Нет событий</EmptyTitle>
<EmptyDescription>
Нет записей по текущему фильтру. Сбросьте фильтр или расширьте
период.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button
type="button"
variant="outline"
onClick={() => {
setFilter('all')
setRange('all')
}}
>
Сбросить
</Button>
</EmptyContent>
</Empty>
) : (
<div className="flex flex-col gap-8">
{days.map((day, dayIndex) => (
<div key={day.key} className="flex flex-col gap-4">
<h2 className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
{day.label}
</h2>
<Timeline>
{day.events.map((event, index) => (
<EventRow
key={event.id}
event={event}
step={index + 1}
isLast={index === day.events.length - 1}
defaultOpen={dayIndex === 0 && index < 2}
/>
))}
</Timeline>
</div>
))}
</div>
)}
</section>
)
}
@@ -0,0 +1,132 @@
/**
* Retention settings row — DNA settings-2.
* Preview: https://reui.io/preview/base/settings-2
*/
import { useEffect, useState } from 'react'
import { HistoryIcon } from 'lucide-react'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Button } from '@authportal/ui/components/button'
import { Input } from '@authportal/ui/components/input'
import {
Item,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from '@authportal/ui/components/item'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@authportal/ui/components/alert-dialog'
export function AuditRetentionPanel({
retentionDays,
saving,
purging,
onSave,
onPurge,
}: {
retentionDays: number
saving: boolean
purging: boolean
onSave: (days: number) => void
onPurge: () => void
}) {
const [days, setDays] = useState(String(retentionDays))
const [confirmOpen, setConfirmOpen] = useState(false)
useEffect(() => {
setDays(String(retentionDays))
}, [retentionDays])
const parsed = Number.parseInt(days, 10)
const valid = Number.isFinite(parsed) && parsed >= 7 && parsed <= 3650
return (
<>
<Frame className="w-full">
<FrameHeader>
<FrameTitle>Ротация журнала</FrameTitle>
<FrameDescription>
Записи старше N дней удаляются автоматически каждый час
</FrameDescription>
</FrameHeader>
<FramePanel className="flex flex-col gap-4">
<Item variant="outline" className="items-start">
<ItemMedia variant="icon">
<HistoryIcon className="size-4" aria-hidden="true" />
</ItemMedia>
<ItemContent>
<ItemTitle>Срок хранения</ItemTitle>
<ItemDescription>
От 7 до 3650 дней. Сейчас: {retentionDays} дн.
</ItemDescription>
</ItemContent>
<div className="flex shrink-0 items-center gap-2">
<Input
type="number"
min={7}
max={3650}
value={days}
onChange={(e) => setDays(e.target.value)}
className="w-24 tabular-nums"
aria-label="Дней хранения"
/>
<Button
size="sm"
disabled={!valid || saving || parsed === retentionDays}
onClick={() => onSave(parsed)}
>
{saving ? 'Сохранение…' : 'Сохранить'}
</Button>
<Button
size="sm"
variant="outline"
disabled={purging}
onClick={() => setConfirmOpen(true)}
>
Очистить сейчас
</Button>
</div>
</Item>
</FramePanel>
</Frame>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Очистить старые записи?</AlertDialogTitle>
<AlertDialogDescription>
Будут удалены события старше {retentionDays} дней. Это действие
необратимо.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
setConfirmOpen(false)
onPurge()
}}
>
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
@@ -0,0 +1,174 @@
/**
* Create OIDC client Sheet — auth-portal admin.
* Preview: https://reui.io/preview/base/sheet-8 · https://reui.io/preview/base/solution-users-1
* Docs: https://reui.io/blocks · https://ui.shadcn.com/docs/components/base/sheet
*/
import { useEffect, useState } from 'react'
import { XIcon } from 'lucide-react'
import { Button } from '@authportal/ui/components/button'
import { Field, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input'
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@authportal/ui/components/sheet'
import { Switch } from '@authportal/ui/components/switch'
import { Textarea } from '@authportal/ui/components/textarea'
const mutedIconButtonClassName = 'text-muted-foreground hover:text-foreground'
export type CreateOidcClientValues = {
name: string
redirect_uris: string[]
scopes: Array<'openid' | 'profile' | 'email' | 'groups'>
enabled: boolean
}
export function CreateOidcClientSheet({
open,
onOpenChange,
pending,
onSubmit,
}: {
open: boolean
onOpenChange: (open: boolean) => void
pending: boolean
onSubmit: (values: CreateOidcClientValues) => void
}) {
const [name, setName] = useState('Technitium DNS')
const [redirectUris, setRedirectUris] = useState(
'https://dns.shnt.top/sso/callback',
)
const [enabled, setEnabled] = useState(true)
useEffect(() => {
if (!open) {
setName('Technitium DNS')
setRedirectUris('https://dns.shnt.top/sso/callback')
setEnabled(true)
}
}, [open])
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
showCloseButton={false}
className="inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(24rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none sm:max-w-none"
>
<SheetHeader className="shrink-0 gap-0 p-0">
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
<SheetTitle className="min-w-0 truncate text-base font-semibold">
Новый OIDC-клиент
</SheetTitle>
<SheetClose
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Закрыть"
className={mutedIconButtonClassName}
>
<XIcon aria-hidden="true" />
</Button>
}
/>
</div>
<SheetDescription className="text-muted-foreground border-b px-4 py-2 text-sm">
Redirect URI для Technitium:{' '}
<code className="text-xs">https://&lt;host&gt;/sso/callback</code>
</SheetDescription>
</SheetHeader>
<form
id="create-oidc-client-form"
className="flex min-h-0 flex-1 flex-col"
onSubmit={(e) => {
e.preventDefault()
const uris = redirectUris
.split('\n')
.map((s) => s.trim())
.filter(Boolean)
onSubmit({
name: name.trim(),
redirect_uris: uris,
scopes: ['openid', 'profile', 'email', 'groups'],
enabled,
})
}}
>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5">
<FieldGroup>
<Field>
<FieldLabel htmlFor="oidc-name">Название</FieldLabel>
<Input
id="oidc-name"
name="name"
value={name}
onChange={(e) => setName(e.target.value)}
required
autoComplete="off"
/>
</Field>
<Field>
<FieldLabel htmlFor="oidc-redirects">
Redirect URIs
</FieldLabel>
<Textarea
id="oidc-redirects"
name="redirect_uris"
className="min-h-24"
value={redirectUris}
onChange={(e) => setRedirectUris(e.target.value)}
placeholder="https://dns.example.com/sso/callback"
required
/>
<p className="text-muted-foreground text-xs">
По одному URI на строку
</p>
</Field>
<Field orientation="horizontal" className="items-center justify-between">
<FieldLabel htmlFor="oidc-enabled" className="font-normal">
Включён
</FieldLabel>
<Switch
id="oidc-enabled"
checked={enabled}
onCheckedChange={(v: boolean) => setEnabled(v === true)}
/>
</Field>
</FieldGroup>
</div>
<SheetFooter className="bg-background shrink-0 border-t">
<div className="flex w-full gap-2">
<Button
type="button"
variant="outline"
className="min-w-0 flex-1"
disabled={pending}
onClick={() => onOpenChange(false)}
>
Отмена
</Button>
<Button
type="submit"
form="create-oidc-client-form"
className="min-w-0 flex-1"
disabled={pending || !name.trim() || !redirectUris.trim()}
>
{pending ? 'Создание…' : 'Создать'}
</Button>
</div>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,172 @@
/**
* Create user Sheet — auth-portal admin.
* Preview: https://reui.io/preview/base/solution-users-1
*/
import { useEffect, useState } from 'react'
import type { CreateUserRequest } from '@authportal/shared'
import { XIcon } from 'lucide-react'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { Button } from '@authportal/ui/components/button'
import { Checkbox } from '@authportal/ui/components/checkbox'
import { Field, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input'
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@authportal/ui/components/sheet'
const mutedIconButtonClassName = 'text-muted-foreground hover:text-foreground'
export function CreateUserSheet({
open,
onOpenChange,
pending,
error,
onSubmit,
}: {
open: boolean
onOpenChange: (open: boolean) => void
pending: boolean
error: string | null
onSubmit: (values: CreateUserRequest) => void
}) {
const [isAdmin, setIsAdmin] = useState(false)
useEffect(() => {
if (!open) setIsAdmin(false)
}, [open])
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
showCloseButton={false}
className="inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(24rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none sm:max-w-none"
>
<SheetHeader className="shrink-0 gap-0 p-0">
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
<SheetTitle className="min-w-0 truncate text-base font-semibold">
Новый пользователь
</SheetTitle>
<SheetClose
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Закрыть"
className={mutedIconButtonClassName}
>
<XIcon aria-hidden="true" />
</Button>
}
/>
</div>
<SheetDescription className="sr-only">
Создание учётной записи портала
</SheetDescription>
</SheetHeader>
<form
id="create-user-form"
className="flex min-h-0 flex-1 flex-col"
onSubmit={(e) => {
e.preventDefault()
const fd = new FormData(e.currentTarget)
onSubmit({
email: String(fd.get('email') ?? ''),
name: String(fd.get('name') ?? ''),
password: String(fd.get('password') ?? ''),
is_admin: isAdmin,
apps: [],
permissions: [],
})
}}
>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5">
<FieldGroup>
<Field>
<FieldLabel htmlFor="create-name">Имя</FieldLabel>
<Input
id="create-name"
name="name"
autoComplete="name"
required
/>
</Field>
<Field>
<FieldLabel htmlFor="create-email">Email</FieldLabel>
<Input
id="create-email"
name="email"
type="email"
autoComplete="email"
required
/>
</Field>
<Field>
<FieldLabel htmlFor="create-password">Пароль</FieldLabel>
<Input
id="create-password"
name="password"
type="password"
autoComplete="new-password"
minLength={6}
required
/>
</Field>
<Field orientation="horizontal">
<Checkbox
id="create-is-admin"
checked={isAdmin}
onCheckedChange={(v) => setIsAdmin(v === true)}
/>
<FieldLabel htmlFor="create-is-admin" className="font-normal">
Администратор портала
</FieldLabel>
</Field>
</FieldGroup>
{error ? (
<Alert variant="destructive" className="mt-5">
<AlertTitle>Ошибка</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
</div>
<SheetFooter className="bg-background shrink-0 border-t">
<div className="flex w-full gap-2">
<Button
type="button"
variant="outline"
className="min-w-0 flex-1"
disabled={pending}
onClick={() => onOpenChange(false)}
>
Отмена
</Button>
<Button
type="submit"
form="create-user-form"
className="min-w-0 flex-1"
disabled={pending}
>
{pending ? 'Создание…' : 'Создать'}
</Button>
</div>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
)
}
+18
View File
@@ -0,0 +1,18 @@
export {
KpiStatGrid,
KpiStatCard,
KpiStatCardTile,
kpiStatItemKey,
type KpiStatItem,
type KpiStatCardData,
type KpiStatCard as KpiStatCardType,
type KpiStatVariant,
type OpsKpiCard,
} from './kpi-stat-grid'
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
export { AppSwitcherAdminEditor } from './app-switcher-admin-editor'
export { UserAccessSheet } from './user-access-sheet'
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'
@@ -0,0 +1,10 @@
/** Shared grid column classes for hybrid KPI / Quick Actions tiles. */
export function kpiCols(count: number): string {
if (count <= 1) return 'grid-cols-1'
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
if (count === 4) return 'grid-cols-1 @xl:grid-cols-2 @6xl:grid-cols-4'
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
}
@@ -0,0 +1,322 @@
import type { KeyboardEvent, ReactNode } from 'react'
import { Link } from '@tanstack/react-router'
import { Frame, FramePanel } from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { cn } from '@authportal/ui/lib/utils'
import { kpiCols } from './kpi-cols'
import { IconTile } from '@/components/reui/icon-tile'
import { Skeleton } from '@authportal/ui/components/skeleton'
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
/**
* KPI tile data — horizontal compact hybrid (icon left + label/Badge + value).
* @see https://reui.io/preview/base/stats-12
*/
export type KpiStatItem = {
id?: string
label: ReactNode
value: ReactNode
hint?: ReactNode
to?: string
search?: Record<string, unknown>
onSelect?: () => void
onClick?: () => void
selected?: boolean
active?: boolean
icon?: ReactNode
iconClassName?: string
variant?: KpiStatVariant
footer?: ReactNode
}
/** CFDM-compatible card shape (id required). */
export type KpiStatCardData = KpiStatItem & { id: string }
/** @deprecated Use KpiStatCardData */
export type OpsKpiCard = KpiStatCardData
/** @deprecated Use KpiStatCardData — type alias for CFDM kit parity */
export type KpiStatCard = KpiStatCardData
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
default: 'text-foreground',
warning: 'text-warning',
destructive: 'text-destructive',
}
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onActivate()
}
}
function resolveActivate(item: KpiStatItem): (() => void) | undefined {
return item.onClick ?? item.onSelect
}
function isSelected(item: KpiStatItem): boolean {
return Boolean(item.selected ?? item.active)
}
function resolveFooter(item: KpiStatItem): ReactNode {
if (item.footer) return item.footer
if (typeof item.hint === 'string') {
return (
<Badge variant="outline" size="sm">
{item.hint}
</Badge>
)
}
if (item.hint) return item.hint
return null
}
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
const footer = resolveFooter(item)
const valueVariant = item.variant ?? 'default'
return (
<div className="relative z-10 flex h-full items-start gap-3">
{item.icon ? (
<IconTile
variant="elevated"
aria-hidden="true"
className={cn('size-10.5', item.iconClassName ?? DEFAULT_ICON_CLASS)}
>
{item.icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-start justify-between gap-2">
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
{footer ? <div className="shrink-0">{footer}</div> : null}
</div>
<div
className={cn(
'text-2xl leading-none font-bold tabular-nums',
VALUE_VARIANT_CLASS[valueVariant],
)}
>
{item.value}
</div>
</div>
</div>
)
}
function panelClassName(item: KpiStatItem, className?: string) {
const onActivate = resolveActivate(item)
const clickable = Boolean(item.to || onActivate)
const selected = isSelected(item)
return cn(
'relative isolate flex h-full flex-col',
clickable &&
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
selected && 'ring-primary/30 bg-muted/30 ring-1',
className,
)
}
/** Single KPI tile — used for embedded / standalone contexts. */
export function KpiStatCardTile({
item,
embedded = false,
className,
}: {
item: KpiStatItem
embedded?: boolean
className?: string
}) {
const onActivate = resolveActivate(item)
const panelClass = panelClassName(item, className)
let panel: ReactNode
if (item.to) {
panel = (
<FramePanel className={panelClass}>
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
<KpiStatCardBody item={item} />
</Link>
</FramePanel>
)
} else if (onActivate) {
panel = (
<FramePanel
className={panelClass}
onClick={onActivate}
role="button"
tabIndex={0}
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
>
<KpiStatCardBody item={item} />
</FramePanel>
)
} else {
panel = (
<FramePanel className={panelClass}>
<KpiStatCardBody item={item} />
</FramePanel>
)
}
if (embedded) {
return <Frame className="h-full ring-1 ring-foreground/10">{panel}</Frame>
}
return <Frame className="h-full">{panel}</Frame>
}
/** @deprecated Prefer KpiStatCardTile — kept for existing imports */
export function KpiStatCard({
item,
embedded = false,
className,
}: {
item: KpiStatItem
embedded?: boolean
className?: string
}) {
return <KpiStatCardTile item={item} embedded={embedded} className={className} />
}
function KpiStatGridSkeleton({ count }: { count: number }) {
return (
<Frame className="@container w-full">
<div className={cn('grid gap-2', kpiCols(count))}>
{Array.from({ length: count }).map((_, index) => (
<FramePanel key={index} className="flex items-start gap-3">
<Skeleton className="size-10.5 shrink-0 rounded-lg" />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="flex items-center justify-between gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4.5 w-14 rounded-full" />
</div>
<Skeleton className="h-7 w-16" />
</div>
</FramePanel>
))}
</div>
</Frame>
)
}
interface KpiStatGridProps {
/** EvoBGP primary API */
items?: KpiStatItem[]
/** CFDM-compatible API */
cards?: KpiStatCardData[]
isLoading?: boolean
emptyMessage?: ReactNode
emptyIcon?: ReactNode
className?: string
skeletonCount?: number
/** Wrap each tile in its own Frame (analytics panels). */
embedded?: boolean
'aria-label'?: string
}
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
const onActivate = resolveActivate(item)
const panelClass = panelClassName(item)
if (item.to) {
return (
<FramePanel className={panelClass}>
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
<KpiStatCardBody item={item} />
</Link>
</FramePanel>
)
}
if (onActivate) {
return (
<FramePanel
className={panelClass}
onClick={onActivate}
role="button"
tabIndex={0}
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
>
<KpiStatCardBody item={item} />
</FramePanel>
)
}
return (
<FramePanel className={panelClass}>
<KpiStatCardBody item={item} />
</FramePanel>
)
}
/**
* Hybrid KPI — EvoBGP visual + horizontal compact layout (icon left).
* Preview: https://reui.io/preview/base/stats-12
*/
export function KpiStatGrid({
items,
cards,
isLoading = false,
emptyMessage,
emptyIcon,
className,
skeletonCount = 4,
embedded = false,
'aria-label': ariaLabel,
}: KpiStatGridProps) {
if (isLoading) {
return <KpiStatGridSkeleton count={skeletonCount} />
}
const list = items ?? cards ?? []
if (list.length === 0 && (emptyMessage || emptyIcon)) {
return (
<Frame dense spacing="sm" className={cn('w-full', className)}>
<FramePanel className="flex items-center gap-3 p-4">
{emptyIcon}
{emptyMessage ? (
<p className="text-muted-foreground text-sm">{emptyMessage}</p>
) : null}
</FramePanel>
</Frame>
)
}
if (embedded) {
return (
<section aria-label={ariaLabel} className={cn('@container w-full', className)}>
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
{list.map((item, index) => (
<KpiStatCardTile key={kpiStatItemKey(item, index)} item={item} embedded />
))}
</div>
</section>
)
}
return (
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
{list.map((item, index) => (
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
))}
</div>
</Frame>
)
}
export function kpiStatItemKey(item: KpiStatItem, index: number): string {
if (item.id) return item.id
if (typeof item.label === 'string') return item.label
return `kpi-${index}`
}
@@ -0,0 +1,142 @@
import type { ReactNode } from 'react'
import { Link } from '@tanstack/react-router'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { IconTile } from '@/components/reui/icon-tile'
import { cn } from '@authportal/ui/lib/utils'
import { kpiCols } from './kpi-cols'
export interface QuickActionItem {
id: string
title: string
description: string
to?: string
search?: Record<string, unknown>
onSelect?: () => void
badgeLabel?: string
/** Вариант бейджа действия (по умолчанию outline). */
badgeVariant?: 'outline' | 'info-light' | 'warning-light'
/** Моноширинная подпись под описанием: адрес · способ входа. */
meta?: string
icon?: ReactNode
iconClassName?: string
}
interface QuickActionGridProps {
actions: QuickActionItem[]
title?: string
description?: string
className?: string
/** Плитки в Frame с отступами и сеткой auto-fit от 17rem (как KPI-полоса): без вылета за границы. */
autoFit?: boolean
}
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
function QuickActionBody({ action }: { action: QuickActionItem }) {
return (
<div className="relative z-10 flex h-full items-start gap-3">
{action.icon ? (
<IconTile
variant="elevated"
aria-hidden="true"
className={cn('size-10.5', action.iconClassName ?? DEFAULT_ICON_CLASS)}
>
{action.icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-start justify-between gap-2">
<span className="text-foreground text-sm font-medium">{action.title}</span>
<Badge variant={action.badgeVariant ?? 'outline'} size="sm" className="shrink-0">
{action.badgeLabel ?? 'Перейти'}
</Badge>
</div>
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
{action.description}
</p>
{action.meta ? (
<p className="text-muted-foreground truncate font-mono text-[11px]">{action.meta}</p>
) : null}
</div>
</div>
)
}
function QuickActionHit({ action }: { action: QuickActionItem }) {
const label = `${action.title}: ${action.description}`
const body = <QuickActionBody action={action} />
if (action.onSelect) {
return (
<button
type="button"
className="w-full text-left focus-visible:outline-none"
aria-label={label}
onClick={action.onSelect}
>
{body}
</button>
)
}
if (!action.to) return body
return (
<Link
to={action.to}
search={action.search}
className="focus-visible:outline-none"
aria-label={label}
>
{body}
</Link>
)
}
/**
* KPI-like quick actions strip (horizontal Frame tiles).
* Preview: https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/card-12
*/
export function QuickActionGrid({
actions,
title = 'Быстрые действия',
description,
className,
autoFit = false,
}: QuickActionGridProps) {
if (actions.length === 0) return null
return (
<Frame dense={!autoFit} spacing="sm" className={cn('@container w-full', className)}>
{(title || description) && (
<FrameHeader>
{title ? <FrameTitle>{title}</FrameTitle> : null}
{description ? <FrameDescription>{description}</FrameDescription> : null}
</FrameHeader>
)}
<div
className={cn(
'grid gap-2',
autoFit ? 'grid-cols-[repeat(auto-fit,minmax(min(100%,17rem),1fr))]' : kpiCols(actions.length),
)}
>
{actions.map((action) => (
<FramePanel
key={action.id}
className="relative isolate flex h-full flex-col hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2"
>
<QuickActionHit action={action} />
</FramePanel>
))}
</div>
</Frame>
)
}
@@ -0,0 +1,13 @@
/**
* Portal lists are not ResourcePage.
* Users: `AdminUsersGrid` — DNA [solution-users-1](https://reui.io/preview/base/solution-users-1).
* Other admin tables: PageShell + Frame + DataGrid in the route.
* Types kept for kit parity with ops apps — do not add a ResourcePage component here.
*/
export type ResourcePageTab = {
id: string
label: string
count?: number
}
export type ResourcePageProps = Record<string, never>
@@ -0,0 +1,507 @@
/**
* User access editor — adapted from ReUI PRO solution-users-1 member detail sheet.
* Preview: https://reui.io/preview/base/solution-users-1
* Docs: https://reui.io/blocks
*/
import { useEffect, useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { InfoIcon, XIcon } from 'lucide-react'
import {
APP_IDS,
APPS,
PERMISSION_CATALOG,
PERMISSION_ACTION_LABELS,
isIndependentPermissionSection,
withIndependentPermission,
permissionKey,
type AdminUser,
type AppId,
type CatalogSection,
type PermissionAction,
} from '@authportal/shared'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { Badge } from '@/components/reui/badge'
import {
Avatar,
AvatarFallback,
} from '@authportal/ui/components/avatar'
import { Button } from '@authportal/ui/components/button'
import { ButtonGroup } from '@authportal/ui/components/button-group'
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemTitle,
} from '@authportal/ui/components/item'
import { ScrollArea } from '@authportal/ui/components/scroll-area'
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@authportal/ui/components/sheet'
import { Skeleton } from '@authportal/ui/components/skeleton'
import { Switch } from '@authportal/ui/components/switch'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@authportal/ui/components/tabs'
import { cn } from '@authportal/ui/lib/utils'
import { api, ApiError } from '@/lib/api-client'
import { userQueryOptions, usersQueryKey } from '@/queries/auth'
type AccessLevel = 'none' | 'read' | 'write' | 'admin'
const LEVEL_LABELS: Record<AccessLevel, string> = {
none: 'Нет',
read: 'Чтение',
write: 'Редактирование',
admin: 'Админ',
}
const mutedIconButtonClassName = 'text-muted-foreground hover:text-foreground'
function levelsForSection(section: CatalogSection): AccessLevel[] {
const levels: AccessLevel[] = ['none']
if (section.actions.includes('read')) levels.push('read')
if (section.actions.includes('write')) levels.push('write')
if (section.actions.includes('admin')) levels.push('admin')
return levels
}
function getSectionLevel(
appId: AppId,
section: CatalogSection,
permissionSet: Set<string>,
): AccessLevel {
const has = (action: PermissionAction) =>
permissionSet.has(permissionKey(appId, section.id, action))
if (section.actions.includes('admin') && has('admin')) return 'admin'
if (section.actions.includes('write') && has('write')) return 'write'
if (section.actions.includes('read') && has('read')) return 'read'
return 'none'
}
function keysForLevel(
appId: AppId,
section: CatalogSection,
level: AccessLevel,
): string[] {
if (level === 'none') return []
if (level === 'read') {
return section.actions.includes('read')
? [permissionKey(appId, section.id, 'read')]
: []
}
if (level === 'write') {
const keys: string[] = []
if (section.actions.includes('read')) {
keys.push(permissionKey(appId, section.id, 'read'))
}
if (section.actions.includes('write')) {
keys.push(permissionKey(appId, section.id, 'write'))
}
return keys
}
return section.actions.map((action) =>
permissionKey(appId, section.id, action),
)
}
export function UserAccessSheet({
userId,
open,
onOpenChange,
}: {
userId: string | null
open: boolean
onOpenChange: (open: boolean) => void
}) {
const queryClient = useQueryClient()
const enabled = open && Boolean(userId)
const { data: user, isLoading, error } = useQuery({
...userQueryOptions(userId ?? ''),
enabled,
})
const [apps, setApps] = useState<AppId[]>([])
const [permissions, setPermissions] = useState<string[]>([])
const [activeApp, setActiveApp] = useState<AppId>('cfdm')
useEffect(() => {
if (!user) return
setApps(user.apps)
setPermissions(user.permissions)
const firstEnabled =
APP_IDS.find((id) => user.apps.includes(id)) ?? APP_IDS[0]
setActiveApp(firstEnabled)
}, [user])
const baseline = useMemo(() => {
if (!user) return null
return {
apps: [...user.apps].sort().join(','),
permissions: [...user.permissions].sort().join(','),
}
}, [user])
const dirty = useMemo(() => {
if (!baseline) return false
const current = {
apps: [...apps].sort().join(','),
permissions: [...permissions].sort().join(','),
}
return (
current.apps !== baseline.apps ||
current.permissions !== baseline.permissions
)
}, [apps, permissions, baseline])
const saveMutation = useMutation({
mutationFn: () =>
api.put<AdminUser>(`/api/v1/admin/users/${userId}/access`, {
apps,
permissions,
}),
onSuccess: async (updated) => {
await queryClient.invalidateQueries({ queryKey: usersQueryKey })
if (userId) {
queryClient.setQueryData(userQueryOptions(userId).queryKey, updated)
}
onOpenChange(false)
},
})
const permissionSet = useMemo(() => new Set(permissions), [permissions])
function toggleApp(appId: AppId, enabledApp: boolean) {
setApps((prev) => {
if (enabledApp) return prev.includes(appId) ? prev : [...prev, appId]
return prev.filter((id) => id !== appId)
})
if (!enabledApp) {
setPermissions((prev) => prev.filter((p) => !p.startsWith(`${appId}:`)))
} else {
setActiveApp(appId)
}
}
function setSectionLevel(
appId: AppId,
section: CatalogSection,
level: AccessLevel,
) {
const sectionPrefix = `${appId}:${section.id}:`
const nextKeys = keysForLevel(appId, section, level)
setPermissions((prev) => {
const kept = prev.filter((p) => !p.startsWith(sectionPrefix))
return [...kept, ...nextKeys]
})
}
function resetChanges() {
if (!user) return
setApps(user.apps)
setPermissions(user.permissions)
}
const initials = (user?.name ?? '?')
.split(/\s+/)
.map((p) => p[0])
.join('')
.slice(0, 2)
.toUpperCase()
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
showCloseButton={false}
className="inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(36rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none sm:max-w-none"
>
<SheetHeader className="shrink-0 gap-0 p-0">
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
<SheetTitle className="min-w-0 truncate text-base font-semibold">
Права доступа
</SheetTitle>
<SheetClose
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Закрыть"
className={mutedIconButtonClassName}
>
<XIcon aria-hidden="true" />
</Button>
}
/>
</div>
<SheetDescription className="sr-only">
Настройка доступа к приложениям и разделам
</SheetDescription>
</SheetHeader>
<div className="min-h-0 flex-1">
<ScrollArea className="h-full">
{isLoading ? (
<div className="flex flex-col gap-3 p-4">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-48 w-full" />
</div>
) : error || !user ? (
<div className="p-4">
<Alert variant="destructive">
<InfoIcon />
<AlertTitle>Ошибка</AlertTitle>
<AlertDescription>
{error instanceof ApiError
? error.message
: 'Пользователь не найден'}
</AlertDescription>
</Alert>
</div>
) : (
<div className="flex flex-col pb-4">
<div className="flex items-center gap-3 px-4 py-5">
<Avatar className="size-12 shrink-0">
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<div className="text-foreground truncate text-sm font-semibold">
{user.name}
</div>
{user.is_admin ? (
<Badge variant="warning-light" size="sm">
Админ
</Badge>
) : null}
{user.disabled ? (
<Badge variant="destructive-light" size="sm">
Отключён
</Badge>
) : (
<Badge variant="success-light" size="sm">
Активен
</Badge>
)}
</div>
<div className="text-muted-foreground truncate text-xs">
{user.email}
</div>
</div>
</div>
<div className="flex flex-col gap-4 border-t px-4 pt-4">
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Доступ к приложениям</p>
<div className="flex flex-col gap-2">
{APP_IDS.map((appId) => {
const meta = APPS.find((a) => a.id === appId)
const checked = apps.includes(appId)
return (
<Item
key={appId}
variant="outline"
className={cn(
'items-start gap-3',
checked && 'border-primary/40',
)}
>
<ItemContent className="gap-1">
<ItemTitle className="text-sm">
{meta?.title ?? appId}
</ItemTitle>
<ItemDescription className="text-xs">
{meta?.description ?? appId}
</ItemDescription>
</ItemContent>
<ItemActions className="ml-auto self-center">
<Switch
checked={checked}
onCheckedChange={(v) => toggleApp(appId, v)}
aria-label={meta?.title ?? appId}
/>
</ItemActions>
</Item>
)
})}
</div>
</div>
<div className="flex flex-col gap-3">
<p className="text-sm font-medium">Права по разделам</p>
<Tabs
value={activeApp}
onValueChange={(v) => setActiveApp(v as AppId)}
className="w-full gap-3"
>
<TabsList
variant="line"
className="h-auto w-full flex-wrap justify-start gap-4 border-b"
>
{PERMISSION_CATALOG.map((app) => {
const enabledApp = apps.includes(app.appId)
return (
<TabsTrigger
key={app.appId}
value={app.appId}
className="text-muted-foreground hover:text-foreground data-active:bg-transparent data-active:text-foreground dark:data-active:border-transparent dark:data-active:bg-transparent h-auto flex-none gap-1.5 rounded-none bg-transparent px-0 pb-2.5 text-sm shadow-none after:bottom-0 after:h-0.5 data-active:shadow-none"
>
<span className="max-w-28 truncate sm:max-w-none">
{app.title}
</span>
<span className="bg-muted text-muted-foreground rounded-md px-1.5 py-0.5 text-[0.625rem] tabular-nums">
{enabledApp ? 'вкл' : 'выкл'}
</span>
</TabsTrigger>
)
})}
</TabsList>
{PERMISSION_CATALOG.map((app) => (
<TabsContent
key={app.appId}
value={app.appId}
className="mt-0 w-full"
>
{!apps.includes(app.appId) ? (
<Alert variant="info" className="mb-3">
<InfoIcon />
<AlertTitle>Приложение выключено</AlertTitle>
<AlertDescription>
Включите «{app.title}», чтобы настроить разделы.
</AlertDescription>
</Alert>
) : null}
<div
className={cn(
'grid grid-cols-1 gap-2',
!apps.includes(app.appId) &&
'pointer-events-none opacity-50',
)}
>
{app.sections.map((section) => {
const level = getSectionLevel(
app.appId,
section,
permissionSet,
)
const levels = levelsForSection(section)
return (
<Item
key={section.id}
variant="outline"
className="items-start gap-2"
>
<ItemContent className="gap-2">
<ItemTitle className="text-sm">
{section.title}
</ItemTitle>
<ItemDescription className="text-xs">
{section.description}
</ItemDescription>
{isIndependentPermissionSection(app.appId, section.id) ? (
<div className="flex flex-col gap-2">
{section.actions.map((action) => {
const key = permissionKey(app.appId, section.id, action)
return (
<label key={action} className="flex items-center justify-between gap-3">
<span className="text-sm">{PERMISSION_ACTION_LABELS[action]}</span>
<Switch
checked={permissionSet.has(key)}
disabled={!apps.includes(app.appId)}
aria-label={`${section.title}: ${PERMISSION_ACTION_LABELS[action]}`}
onCheckedChange={(enabled) => setPermissions((prev) => withIndependentPermission(prev, app.appId, section.id, action, enabled))}
/>
</label>
)
})}
</div>
) : (
<ButtonGroup className="mt-0.5 flex-wrap">
{levels.map((lvl) => (
<Button
key={lvl}
size="sm"
variant={
level === lvl
? 'default'
: 'outline'
}
disabled={!apps.includes(app.appId)}
onClick={() =>
setSectionLevel(
app.appId,
section,
lvl,
)
}
>
{LEVEL_LABELS[lvl]}
</Button>
))}
</ButtonGroup>
)}
</ItemContent>
</Item>
)
})}
</div>
</TabsContent>
))}
</Tabs>
</div>
</div>
</div>
)}
</ScrollArea>
</div>
<SheetFooter className="bg-background shrink-0 border-t">
{saveMutation.error ? (
<p className="text-destructive w-full text-sm">
{saveMutation.error instanceof ApiError
? saveMutation.error.message
: 'Ошибка сохранения'}
</p>
) : null}
<div className="flex w-full gap-2">
<Button
type="button"
variant="outline"
className="min-w-0 flex-1"
disabled={!dirty || saveMutation.isPending}
onClick={resetChanges}
>
Отменить
</Button>
<Button
type="button"
className="min-w-0 flex-1"
disabled={!dirty || saveMutation.isPending || !user}
onClick={() => saveMutation.mutate()}
>
{saveMutation.isPending ? 'Сохранение…' : 'Сохранить'}
</Button>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,276 @@
/**
* User-scoped audit Sheet — chrome DNA solution-users-1 / solution-users-2.
* Preview: https://reui.io/preview/base/solution-users-1 · https://reui.io/preview/base/solution-users-2
* Timeline: https://reui.io/preview/base/solution-users-6
*/
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { AdminUser } from '@authportal/shared'
import { toast } from 'sonner'
import { Trash2Icon, XIcon } from 'lucide-react'
import { AuditLogTimeline } from '@/components/reui-kit/audit-log-timeline'
import { formatEventTime } from '@/components/reui-kit/audit-log-helpers'
import { auditQueryOptions } from '@/queries/audit'
import {
sessionsListQueryKey,
sessionsQueryOptions,
} from '@/queries/sessions'
import { api, ApiError } from '@/lib/api-client'
import { Button } from '@authportal/ui/components/button'
import { ScrollArea } from '@authportal/ui/components/scroll-area'
import { Skeleton } from '@authportal/ui/components/skeleton'
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@authportal/ui/components/sheet'
const mutedIconButtonClassName = 'text-muted-foreground hover:text-foreground'
export function UserAuditSheet({
user,
open,
onOpenChange,
}: {
user: AdminUser | null
open: boolean
onOpenChange: (open: boolean) => void
}) {
const queryClient = useQueryClient()
const userId = user?.id
const email = user?.email
const [tab, setTab] = useState<'logins' | 'changes' | 'sessions'>('logins')
const loginsQuery = useQuery({
...auditQueryOptions({
userId,
actorEmail: email,
kind: 'logins',
limit: 200,
}),
enabled: open && Boolean(userId) && tab === 'logins',
})
const changesQuery = useQuery({
...auditQueryOptions({ userId, kind: 'changes', limit: 200 }),
enabled: open && Boolean(userId) && tab === 'changes',
})
const sessionsQuery = useQuery({
...sessionsQueryOptions({ userId }),
enabled: open && Boolean(userId) && tab === 'sessions',
})
const revokeAll = useMutation({
mutationFn: () =>
api.post<{ revoked: number }>(
`/api/v1/admin/users/${userId}/sessions/revoke-all`,
),
onSuccess: async (data) => {
await queryClient.invalidateQueries({
queryKey: sessionsListQueryKey(userId),
})
toast.success(`Отозвано сессий: ${data.revoked}`)
},
onError: (err) => {
toast.error(
err instanceof ApiError ? err.message : 'Не удалось отозвать сессии',
)
},
})
const revokeOne = useMutation({
mutationFn: (id: string) => api.delete(`/api/v1/admin/sessions/${id}`),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: sessionsListQueryKey(userId),
})
toast.success('Сессия отозвана')
},
onError: (err) => {
toast.error(
err instanceof ApiError ? err.message : 'Не удалось отозвать сессию',
)
},
})
const active =
tab === 'logins'
? loginsQuery
: tab === 'changes'
? changesQuery
: sessionsQuery
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
showCloseButton={false}
className="inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(36rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none sm:max-w-none"
>
<SheetHeader className="shrink-0 gap-0 p-0">
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
<SheetTitle className="min-w-0 truncate text-base font-semibold">
Журнал: {user?.name ?? '…'}
</SheetTitle>
<SheetClose
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Закрыть"
className={mutedIconButtonClassName}
>
<XIcon aria-hidden="true" />
</Button>
}
/>
</div>
<SheetDescription className="text-muted-foreground border-b px-4 py-2 text-sm">
{user?.email ?? 'События пользователя'}
{user?.last_login_ip
? ` · последний IP ${user.last_login_ip}`
: ''}
</SheetDescription>
<div className="border-b px-4">
<Tabs
value={tab}
onValueChange={(v) => {
if (v === 'logins' || v === 'changes' || v === 'sessions') {
setTab(v)
}
}}
>
<TabsList
variant="line"
className="h-10! w-full justify-start gap-5"
>
<TabsTrigger
value="logins"
className="px-0 text-sm after:-bottom-px!"
>
Входы
</TabsTrigger>
<TabsTrigger
value="changes"
className="px-0 text-sm after:-bottom-px!"
>
Изменения
</TabsTrigger>
<TabsTrigger
value="sessions"
className="px-0 text-sm after:-bottom-px!"
>
Сессии
</TabsTrigger>
</TabsList>
</Tabs>
</div>
</SheetHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="p-4">
{active.error ? (
<div className="flex flex-col gap-2">
<p className="text-destructive text-sm">
{active.error instanceof ApiError
? active.error.message
: 'Ошибка загрузки'}
</p>
<Button
variant="outline"
size="sm"
className="w-fit"
onClick={() => active.refetch()}
>
Повторить
</Button>
</div>
) : active.isLoading ? (
<div className="flex flex-col gap-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
) : tab === 'sessions' ? (
<div className="flex flex-col gap-3">
<div className="flex justify-end">
<Button
type="button"
size="sm"
variant="outline"
disabled={
revokeAll.isPending ||
(sessionsQuery.data?.length ?? 0) === 0
}
onClick={() => revokeAll.mutate()}
>
Отозвать все
</Button>
</div>
{(sessionsQuery.data ?? []).length === 0 ? (
<p className="text-muted-foreground text-sm">
Нет активных сессий
</p>
) : (
(sessionsQuery.data ?? []).map((s) => (
<div
key={s.id}
className="border-border flex flex-col gap-1 rounded-lg border p-3"
>
<div className="flex items-start justify-between gap-2">
<span className="font-mono text-xs tabular-nums">
{s.ip ?? '—'}
</span>
<Button
type="button"
size="sm"
variant="ghost"
disabled={revokeOne.isPending}
onClick={() => revokeOne.mutate(s.id)}
>
<Trash2Icon aria-hidden="true" />
</Button>
</div>
<p
className="text-muted-foreground line-clamp-2 text-xs"
title={s.user_agent ?? undefined}
>
{s.user_agent ?? '—'}
</p>
<p className="text-muted-foreground text-xs tabular-nums">
{formatEventTime(s.created_at)} · до{' '}
{formatEventTime(s.expires_at)}
</p>
</div>
))
)}
</div>
) : (
<AuditLogTimeline
entries={
(tab === 'logins' ? loginsQuery.data : changesQuery.data) ??
[]
}
totalCount={
(
(tab === 'logins'
? loginsQuery.data
: changesQuery.data) ?? []
).length
}
compact
lockedUserId={userId}
/>
)}
</div>
</ScrollArea>
</SheetContent>
</Sheet>
)
}
+92
View File
@@ -0,0 +1,92 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@authportal/ui/lib/utils"
const alertVariants = cva(
[
"relative w-full text-sm border has-[>svg]:grid-cols-[calc(var(--spacing)*3)_1fr] grid-cols-[0_1fr] grid gap-y-0.5 items-center [&>svg:not([class*=size-])]:size-4",
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:[&_[data-slot=alert-action]]:sm:row-end-3",
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:items-start",
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:[&_svg]:translate-y-0.5",
"rounded-lg",
"px-3",
"py-2.5",
"has-[>svg]:gap-x-2.5",
],
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"border-destructive/30 bg-destructive/4 [&>svg]:text-destructive",
info: "border-info/30 bg-info/4 [&>svg]:text-info",
success: "border-success/30 bg-success/4 [&>svg]:text-success",
warning: "border-warning/30 bg-warning/4 [&>svg]:text-warning",
invert:
"border-invert bg-invert text-invert-foreground [&_[data-slot=alert-description]]:text-invert-foreground/70",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn(
"flex gap-1.5 max-sm:col-start-2 max-sm:mt-2 max-sm:justify-start sm:col-start-3 sm:row-start-1 sm:justify-end sm:self-center",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }
+102
View File
@@ -0,0 +1,102 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@authportal/ui/lib/utils"
const badgeVariants = cva(
[
"relative inline-flex shrink-0 items-center justify-center w-fit border border-transparent font-medium whitespace-nowrap outline-none transition-shadow",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-3",
],
{
variants: {
variant: {
default: "bg-primary text-primary-foreground",
outline: "border-border bg-transparent dark:bg-input/32",
secondary: "bg-secondary text-secondary-foreground",
info: "bg-info text-white",
success: "bg-success text-white",
warning: "bg-warning text-white",
destructive: "bg-destructive text-white",
focus: "bg-focus text-focus-foreground",
invert: "bg-invert text-invert-foreground",
"primary-light":
"border-primary/10 bg-primary/10 text-primary dark:border-primary/25 dark:bg-primary/15 dark:text-primary",
"warning-light":
"border-warning/15 bg-warning/10 text-warning-foreground dark:border-warning/25 dark:bg-warning/15 dark:text-warning",
"success-light":
"border-success/15 bg-success/10 text-success-foreground dark:border-success/25 dark:bg-success/15 dark:text-success",
"info-light":
"border-info/15 bg-info/10 text-info-foreground dark:border-info/25 dark:bg-info/15 dark:text-info",
"destructive-light":
"border-destructive/15 bg-destructive/10 text-destructive-foreground dark:border-destructive/25 dark:bg-destructive/15 dark:text-destructive",
"invert-light":
"border-invert/15 bg-invert/10 text-foreground dark:border-invert/45 dark:bg-invert/35 dark:text-invert-foreground",
"focus-light":
"border-focus/15 bg-focus/10 text-focus-foreground dark:border-focus/25 dark:bg-focus/15 dark:text-focus",
"primary-outline":
"bg-background border-border text-primary dark:bg-input/30",
"warning-outline":
"bg-background border-border text-warning-foreground dark:bg-input/30",
"success-outline":
"bg-background border-border text-success-foreground dark:bg-input/30",
"info-outline":
"bg-background border-border text-info-foreground dark:bg-input/30",
"destructive-outline":
"bg-background border-border text-destructive-foreground dark:bg-input/30",
"invert-outline":
"bg-background border-border text-invert-foreground dark:bg-input/30",
"focus-outline":
"bg-background border-border text-focus-foreground dark:bg-input/30",
},
size: {
xs: "px-1 py-0.25 text-[0.6rem] leading-none h-4 min-w-4 gap-1",
sm: "px-1 py-0.25 text-[0.625rem] leading-none h-4.5 min-w-4.5 gap-1",
default: "px-1.25 py-0.5 text-xs h-5 min-w-5 gap-1",
lg: "px-1.5 py-0.5 text-xs h-5.5 min-w-5.5 gap-1",
xl: "px-2 py-0.75 text-sm h-6 min-w-6 gap-1.5",
},
/** `default`: active style radius. `full`: pill radius. */
radius: {
default:
"rounded-sm",
full: "rounded-full",
},
},
defaultVariants: {
variant: "default",
size: "default",
radius: "default",
},
}
)
interface BadgeProps extends useRender.ComponentProps<"span"> {
variant?: VariantProps<typeof badgeVariants>["variant"]
size?: VariantProps<typeof badgeVariants>["size"]
radius?: VariantProps<typeof badgeVariants>["radius"]
}
function Badge({
className,
variant,
size,
radius,
render,
...props
}: BadgeProps) {
const defaultProps = {
"data-slot": "badge",
className: cn(badgeVariants({ variant, size, radius, className })),
}
return useRender({
defaultTagName: "span",
render,
props: mergeProps<"span">(defaultProps, props),
})
}
export { Badge, badgeVariants, type BadgeProps }
@@ -0,0 +1,926 @@
import * as React from "react"
import { useCascaderState } from "@/components/reui/cascader/cascader-context"
import {
CASCADER_ROOT_KEY,
isCascaderMoreNode,
} from "@/components/reui/cascader/cascader-lib"
import type {
CascaderIndex,
CascaderLoadContext,
CascaderLoadReason,
CascaderLoadResult,
CascaderLoadState,
CascaderNode,
CascaderSearchContext,
} from "@/components/reui/cascader/cascader-types"
/**
* Async data for the cascader. Loaded pages live in their own store and are
* MERGED onto the index built from `items`, never folded into that build:
* `items` changes identity on any parent re-render, and folding would discard
* every level the user drilled into. Map membership is the load discriminator:
* no `states` entry means never fetched, while an entry with no `loading`, no
* `error` and no children means empty for real.
*/
/* -------------------------------------------------------------------------- */
/* Types */
/* -------------------------------------------------------------------------- */
/**
* Fetches one level. `node` is `null` for the root level, and only
* `{ value, label: value }`, no `data`, for a level on screen whose node has
* not loaded: a deep link before the level above loads, or a page-loaded
* level after a `loadKey` change.
*/
export type CascaderGetChildren<T = unknown> = (
node: CascaderNode<T> | null,
context: CascaderLoadContext
) =>
| CascaderNode<T>[]
| CascaderLoadResult<T>
| Promise<CascaderNode<T>[] | CascaderLoadResult<T>>
/** Server-side search, replacing the local index scan while the query is set. */
export type CascaderOnSearch<T = unknown> = (
query: string,
context: CascaderSearchContext
) =>
| CascaderNode<T>[]
| CascaderLoadResult<T>
| Promise<CascaderNode<T>[] | CascaderLoadResult<T>>
/** Resolves a selected value to its ancestor chain, root first, node last. */
export type CascaderResolveValue<T = unknown> = (
value: string,
context: CascaderLoadContext
) => CascaderNode<T>[] | Promise<CascaderNode<T>[]>
export interface CascaderLoaderStore<T = unknown> {
/** Keyed by LEVEL: a parent's value, or `CASCADER_ROOT_KEY` for the root. */
pages: Map<string, CascaderNode<T>[]>
states: Map<string, CascaderLoadState>
/** Keyed by node value: search hits and resolved selections, level-less. */
detached: Map<string, CascaderNode<T>>
/**
* A resolved chain's parent links, node value to level key. Kept apart from
* `pages`: a level's first real page replaced a chain stub there, and a node
* that page left out lost its parent, and the trigger its path.
*/
links: Map<string, string>
}
export interface UseCascaderLoaderOptions<T = unknown> {
/** The index built from `items`, before any pages are merged in. */
base: CascaderIndex<T>
getChildren?: CascaderGetChildren<T>
onSearch?: CascaderOnSearch<T>
resolveValue?: CascaderResolveValue<T>
/** Milliseconds of quiet before `onSearch` fires. */
searchDebounce?: number
/** Changing this drops every cached page, state and detached node. */
loadKey?: unknown
/** Speculatively fetch a branch's children when it is highlighted. */
prefetch?: boolean
/** Called when a request fails. Never for an aborted or superseded one. */
onLoadError?: (
error: unknown,
context: { parent: string | null; reason: string }
) => void
/**
* Whether the panel is live: the popup is open, or the cascader is inline.
* Gates levels, paging and search. `resolveValue` runs regardless, and
* retries a failed value when this turns true.
*/
enabled: boolean
query: string
/** Level keys that are currently on screen. Root is `CASCADER_ROOT_KEY`. */
levels: string[]
/** Handed to `onSearch` as its scope and read for nothing else: the
* navigation path, or `[]` when the search covers the whole tree. */
path: string[]
/** Current selection, for `resolveValue`. */
values: string[]
}
export interface CascaderLoader<T = unknown> {
/** Whether a `getChildren` loader is configured at all. */
active: boolean
store: CascaderLoaderStore<T>
states: ReadonlyMap<string, CascaderLoadState>
/**
* Async search hits, `null` when no `onSearch` is running. EMPTY while the
* first request is in flight, so the level behind is not shown as the answer.
*/
searchResults: CascaderNode<T>[] | null
searchState: CascaderLoadState | null
/**
* Fetches a level's FIRST page. No-ops on a `states` entry (in flight,
* loaded or failed) or when `items` fills the level. The level effect fires
* only for on-screen levels, so a branch merely PRESSED is asked for by hand.
*/
ensureLevel: (parentKey: string, reason: CascaderLoadReason) => void
/** Fetches the next page of a level. No-ops unless one is available. */
loadMore: (parentKey: string) => void
/** Refires a failed level. No-ops unless that level is in an error state. */
retryLevel: (parentKey: string) => void
/** Refires the server search for the current query. No-ops without one. */
retrySearch: () => void
/** Schedules a speculative fetch. Safe to call on every highlight move. */
prefetchNode: (node: CascaderNode<T> | null | undefined) => void
/**
* Evicts ONE level: aborts its request, drops its `states`/`pages` entries
* and paging latch, so the level effect refetches it. `null` = root level.
*/
invalidateLevel: (value: string | null) => void
}
/* -------------------------------------------------------------------------- */
/* Constants */
/* -------------------------------------------------------------------------- */
/**
* Highlight dwell before `prefetch` fetches: long enough that holding ArrowDown
* does not fire a request per row, short enough to beat the ArrowRight press.
*/
const PREFETCH_DELAY = 150
/** Request keys for the two non-level requests. Never collide with a value. */
const SEARCH_KEY = "\u0000search"
const RESOLVE_PREFIX = "\u0000resolve:"
const NO_STATE: CascaderLoadState = {
loading: false,
error: false,
hasMore: false,
}
/** Stable empty result, so an idle search never churns the state context. */
const NO_RESULTS: CascaderNode<never>[] = []
function createStore<T>(): CascaderLoaderStore<T> {
return {
pages: new Map(),
states: new Map(),
detached: new Map(),
links: new Map(),
}
}
function sameLoadState(a: CascaderLoadState, b: CascaderLoadState): boolean {
return (
a.loading === b.loading &&
a.error === b.error &&
a.hasMore === b.hasMore &&
a.cursor === b.cursor
)
}
/* -------------------------------------------------------------------------- */
/* Store transitions */
/* -------------------------------------------------------------------------- */
/**
* Copy-on-write, and a NO-OP when nothing changed: the merged index is memoised
* on store identity, so a fresh object for an unchanged state would rebuild it.
*/
function withLoadState<T>(
store: CascaderLoaderStore<T>,
key: string,
update: (state: CascaderLoadState) => CascaderLoadState
): CascaderLoaderStore<T> {
const current = store.states.get(key) ?? NO_STATE
const next = update(current)
if (store.states.has(key) && sameLoadState(current, next)) return store
const states = new Map(store.states)
states.set(key, next)
return { ...store, states }
}
function withPage<T>(
store: CascaderLoaderStore<T>,
key: string,
items: readonly CascaderNode<T>[],
options: { append: boolean; hasMore: boolean; cursor?: string }
): CascaderLoaderStore<T> {
const previous = options.append ? (store.pages.get(key) ?? []) : []
const seen = new Set(previous.map((node) => node.value))
const merged = previous.slice()
for (const item of items) {
if (seen.has(item.value)) continue
seen.add(item.value)
merged.push(item)
}
const pages = new Map(store.pages)
pages.set(key, merged)
const states = new Map(store.states)
states.set(key, {
loading: false,
error: false,
hasMore: options.hasMore,
cursor: options.cursor,
})
return { ...store, pages, states }
}
function withDetached<T>(
store: CascaderLoaderStore<T>,
items: readonly CascaderNode<T>[]
): CascaderLoaderStore<T> {
let detached: Map<string, CascaderNode<T>> | null = null
for (const item of items) {
if (store.detached.get(item.value) === item) continue
detached = detached ?? new Map(store.detached)
detached.set(item.value, item)
}
if (!detached) return store
return { ...store, detached }
}
/**
* Records a resolved ancestor chain, root first, as `detached` nodes plus
* `links`, never as rows in `pages`: a stub there made an unloaded level read
* as loaded. Writes no `states`, so every level on it still fetches for real.
*/
function withChain<T>(
store: CascaderLoaderStore<T>,
chain: readonly CascaderNode<T>[]
): CascaderLoaderStore<T> {
if (chain.length === 0) return store
const detached = new Map(store.detached)
const links = new Map(store.links)
let parentKey = CASCADER_ROOT_KEY
for (const node of chain) {
detached.set(node.value, node)
links.set(node.value, parentKey)
parentKey = node.value
}
return { ...store, detached, links }
}
/* -------------------------------------------------------------------------- */
/* Hook */
/* -------------------------------------------------------------------------- */
interface CascaderLoaderLatest<T> {
base: CascaderIndex<T>
store: CascaderLoaderStore<T>
getChildren?: CascaderGetChildren<T>
onSearch?: CascaderOnSearch<T>
resolveValue?: CascaderResolveValue<T>
onLoadError?: (
error: unknown,
context: { parent: string | null; reason: string }
) => void
prefetch: boolean
path: string[]
query: string
}
interface CascaderSearchSlice<T> {
query: string
results: CascaderNode<T>[]
loading: boolean
error: boolean
}
/**
* The loader. A SIBLING of the `buildCascaderIndex` memo, never inside it: the
* build stays pure in `items`, the merge pure in that build plus this store.
*/
export function useCascaderLoader<T = unknown>({
base,
getChildren,
onSearch,
resolveValue,
searchDebounce = 250,
loadKey,
prefetch = false,
onLoadError,
enabled,
query,
levels,
path,
values,
}: UseCascaderLoaderOptions<T>): CascaderLoader<T> {
const [store, setStore] = React.useState<CascaderLoaderStore<T>>(createStore)
const [search, setSearch] = React.useState<CascaderSearchSlice<T> | null>(
null
)
/**
* Latest callbacks, WRITTEN IN AN EFFECT: `getChildren` is inline in most
* consumers, so closing over it would refire every in-flight request per
* re-render. The ref is what keeps the request machinery `[]`-dep. Declared
* FIRST, since effects run in declaration order, so the level effect below
* already sees the current commit.
*/
const latest = React.useRef<CascaderLoaderLatest<T>>({
base,
store,
getChildren,
onSearch,
resolveValue,
onLoadError,
prefetch,
path,
query,
})
React.useEffect(() => {
latest.current = {
base,
store,
getChildren,
onSearch,
resolveValue,
onLoadError,
prefetch,
path,
query,
}
})
/** One AbortController PER KEY: columns mode runs several levels at once. */
const controllers = React.useRef(new Map<string, AbortController>())
/** Monotonic per key. The stale guard for out-of-order responses. */
const requestIds = React.useRef(new Map<string, number>())
/** In-flight `(level, cursor)` signatures, so a duplicate ask is free. */
const inflight = React.useRef(new Map<string, string>())
/**
* The `(child count, cursor)` signature at the last paging fire, per level.
* Guards what `hasMore` cannot: a page of zero new items while the server
* still says `hasMore`. Cursor is IN the signature because an all-duplicates
* page advances it while the count stands still - real progress, which a
* count-only latch would brick forever.
*/
const moreLatch = React.useRef(new Map<string, string>())
/** Values `resolveValue` has already been asked about, so it asks once. */
const attempted = React.useRef(new Set<string>())
/**
* Values whose resolve failed. They stay `attempted` until the next open
* rather than the next values pass: with inline `items` every parent render
* is one, and an `onLoadError` that set parent state looped at network speed.
*/
const failed = React.useRef(new Set<string>())
/** Every node the loader has seen, so a level key can name its own node. */
const known = React.useRef(new Map<string, CascaderNode<T>>())
const timers = React.useRef<{
prefetch: ReturnType<typeof setTimeout> | null
}>({ prefetch: null })
/** Bumped by a `loadKey` change, so responses from before it are dropped. */
const epoch = React.useRef(0)
const remember = React.useCallback((nodes: readonly CascaderNode<T>[]) => {
const map = known.current
const walk = (list: readonly CascaderNode<T>[]) => {
for (const node of list) {
map.set(node.value, node)
if (node.children?.length) walk(node.children)
}
}
walk(nodes)
}, [])
const abortKey = React.useCallback((key: string) => {
const controller = controllers.current.get(key)
if (!controller) return
controllers.current.delete(key)
inflight.current.delete(key)
controller.abort()
}, [])
/* ------------------------------- level load ------------------------------ */
const runLoad = React.useCallback(
(
key: string,
reason: CascaderLoadReason,
cursor: string | undefined,
append: boolean
) => {
const { getChildren: loader, base: currentBase } = latest.current
if (!loader) return
const signature = `${append ? "1" : "0"}:${cursor ?? ""}`
if (inflight.current.get(key) === signature) return
abortKey(key)
const controller = new AbortController()
controllers.current.set(key, controller)
inflight.current.set(key, signature)
const requestId = (requestIds.current.get(key) ?? 0) + 1
requestIds.current.set(key, requestId)
const startEpoch = epoch.current
// A deep link (`path`, `expanded`) can put a level on screen before the
// level above it loads, and a `loadKey` reset forgets every node. `null`
// is the ROOT's argument, and a loader answered it with the root rows,
// so the level gets its value instead.
const node =
key === CASCADER_ROOT_KEY
? null
: (currentBase.byValue.get(key) ??
known.current.get(key) ?? { value: key, label: key })
setStore((prev) =>
withLoadState(prev, key, (state) => ({
...state,
loading: true,
error: false,
}))
)
const settle = () => {
if (inflight.current.get(key) === signature)
inflight.current.delete(key)
if (controllers.current.get(key) === controller) {
controllers.current.delete(key)
}
}
const stale = () =>
controller.signal.aborted ||
startEpoch !== epoch.current ||
requestId !== requestIds.current.get(key)
// `Promise.resolve().then(...)`, not a direct call: it normalises a
// SYNCHRONOUS throw into a rejection instead of taking the render down.
Promise.resolve()
.then(() => loader(node, { signal: controller.signal, cursor, reason }))
.then((result) => {
settle()
if (stale()) return
const items = Array.isArray(result) ? result : result.items
const nextCursor = Array.isArray(result)
? undefined
: result.nextCursor
const hasMore = Array.isArray(result)
? false
: (result.hasMore ?? nextCursor != null)
if (!append) moreLatch.current.delete(key)
remember(items)
setStore((prev) =>
withPage(prev, key, items, { append, hasMore, cursor: nextCursor })
)
})
.catch((error: unknown) => {
settle()
if (stale()) return
setStore((prev) =>
withLoadState(prev, key, (state) => ({
...state,
loading: false,
error: true,
}))
)
// Behind the stale guard: an abort is navigation, not a failure.
latest.current.onLoadError?.(error, {
parent: key === CASCADER_ROOT_KEY ? null : key,
reason,
})
})
},
[abortKey, remember]
)
const ensureLevel = React.useCallback(
(key: string, reason: CascaderLoadReason) => {
const {
getChildren: loader,
store: current,
base: index,
} = latest.current
if (!loader) return
if (current.states.has(key)) return
// A level `items` already fills is not the loader's business: that is how
// a static root plus `getChildren` for the branches works with no flag.
if (index.childrenOf.has(key)) return
runLoad(key, reason, undefined, false)
},
[runLoad]
)
const loadMore = React.useCallback(
(key: string) => {
const { getChildren: loader, store: current } = latest.current
if (!loader) return
const state = current.states.get(key)
if (!state || state.loading || !state.hasMore) return
const loaded = current.pages.get(key)?.length ?? 0
const signature = `${loaded}:${state.cursor ?? ""}`
if (moreLatch.current.get(key) === signature) return
moreLatch.current.set(key, signature)
runLoad(key, "more", state.cursor, true)
},
[runLoad]
)
const retryLevel = React.useCallback(
(key: string) => {
const { getChildren: loader, store: current } = latest.current
if (!loader) return
const state = current.states.get(key)
if (!state?.error) return
const loaded = current.pages.get(key)?.length ?? 0
// A retry must be able to re-fire the page the latch just blocked.
moreLatch.current.delete(key)
runLoad(key, "retry", loaded > 0 ? state.cursor : undefined, loaded > 0)
},
[runLoad]
)
/**
* `detached` and `links` are deliberately untouched: chains and search hits
* belong to no level, and the trigger needs their path while the new page is
* in flight.
*/
const invalidateLevel = React.useCallback(
(value: string | null) => {
const key = value ?? CASCADER_ROOT_KEY
abortKey(key)
// Bump the id too: a response past its signal check must still be stale.
requestIds.current.set(key, (requestIds.current.get(key) ?? 0) + 1)
moreLatch.current.delete(key)
setStore((prev) => {
if (!prev.states.has(key) && !prev.pages.has(key)) return prev
const states = new Map(prev.states)
states.delete(key)
const pages = new Map(prev.pages)
pages.delete(key)
return { ...prev, pages, states }
})
},
[abortKey]
)
const prefetchNode = React.useCallback(
(node: CascaderNode<T> | null | undefined) => {
const {
prefetch: on,
getChildren: loader,
store: current,
} = latest.current
if (!on || !loader || !node) return
if (isCascaderMoreNode(node)) return
if (!node.hasChildren) return
if (current.states.has(node.value)) return
const holder = timers.current
if (holder.prefetch) clearTimeout(holder.prefetch)
// A TIMEOUT, not a direct call: `onItemHighlighted` fires from a layout
// effect, where a synchronous setState is a render-phase cascade.
holder.prefetch = setTimeout(() => {
holder.prefetch = null
ensureLevel(node.value, "prefetch")
}, PREFETCH_DELAY)
},
[ensureLevel]
)
/* --------------------------------- search -------------------------------- */
const runSearch = React.useCallback(
(text: string) => {
const { onSearch: searcher, path: currentPath } = latest.current
if (!searcher) return
abortKey(SEARCH_KEY)
const controller = new AbortController()
controllers.current.set(SEARCH_KEY, controller)
const requestId = (requestIds.current.get(SEARCH_KEY) ?? 0) + 1
requestIds.current.set(SEARCH_KEY, requestId)
const startEpoch = epoch.current
setSearch((prev) => ({
query: text,
results: prev?.results ?? [],
loading: true,
error: false,
}))
const settle = () => {
if (controllers.current.get(SEARCH_KEY) === controller) {
controllers.current.delete(SEARCH_KEY)
}
}
const stale = () =>
controller.signal.aborted ||
startEpoch !== epoch.current ||
requestId !== requestIds.current.get(SEARCH_KEY)
Promise.resolve()
.then(() =>
searcher(text, { signal: controller.signal, path: currentPath })
)
.then((result) => {
settle()
if (stale()) return
const items = Array.isArray(result) ? result : result.items
remember(items)
// Search hits go to `detached`, never a level: a hit lives anywhere in
// the tree, and filing it under the open level would misplace it.
setStore((prev) => withDetached(prev, items))
setSearch({
query: text,
results: items,
loading: false,
error: false,
})
})
.catch((error: unknown) => {
settle()
if (stale()) return
setSearch((prev) => ({
query: text,
results: prev?.results ?? [],
loading: false,
error: true,
}))
latest.current.onLoadError?.(error, {
parent: null,
reason: "search",
})
})
},
[abortKey, remember]
)
const retrySearch = React.useCallback(() => {
const text = latest.current.query.trim()
if (text) runSearch(text)
}, [runSearch])
/* --------------------------------- resolve ------------------------------- */
const runResolve = React.useCallback(
(value: string) => {
const { resolveValue: resolver } = latest.current
if (!resolver) return
const key = `${RESOLVE_PREFIX}${value}`
abortKey(key)
const controller = new AbortController()
controllers.current.set(key, controller)
const startEpoch = epoch.current
const settle = () => {
if (controllers.current.get(key) === controller) {
controllers.current.delete(key)
}
}
Promise.resolve()
.then(() =>
resolver(value, { signal: controller.signal, reason: "resolve" })
)
.then((chain) => {
settle()
if (controller.signal.aborted || startEpoch !== epoch.current) return
if (!chain?.length) return
remember(chain)
setStore((prev) => withChain(prev, chain))
})
.catch((error: unknown) => {
// Read BEFORE `settle`. A newer ask or a reset that replaced this
// request owns the value's retry, so only a live failure is parked.
const live = controllers.current.get(key) === controller
settle()
if (live) failed.current.add(value)
if (controller.signal.aborted || startEpoch !== epoch.current) return
latest.current.onLoadError?.(error, {
parent: null,
reason: "resolve",
})
})
},
[abortKey, remember]
)
/* --------------------------------- resets -------------------------------- */
// Aborts what the PANEL asked for. A resolve keeps running: it feeds the
// trigger, which shows with the popup closed.
const cancelPanel = React.useCallback(() => {
const keys = Array.from(controllers.current.keys()).filter(
(key) => !key.startsWith(RESOLVE_PREFIX)
)
for (const key of keys) {
controllers.current.get(key)?.abort()
controllers.current.delete(key)
}
inflight.current.clear()
const holder = timers.current
if (holder.prefetch) {
clearTimeout(holder.prefetch)
holder.prefetch = null
}
if (keys.length === 0) return
setStore((prev) => {
let states: Map<string, CascaderLoadState> | null = null
for (const key of keys) {
const state = prev.states.get(key)
if (!state?.loading) continue
states = states ?? new Map(prev.states)
if ((prev.pages.get(key)?.length ?? 0) > 0) {
states.set(key, { ...state, loading: false })
} else {
// No entry AT ALL: membership is what says "loaded", so a stranded
// `loading: true` would read as loaded-and-empty forever.
states.delete(key)
}
}
return states ? { ...prev, states } : prev
})
setSearch((prev) => (prev?.loading ? { ...prev, loading: false } : prev))
}, [])
// A `loadKey` change drops everything. Declared BEFORE the level effect, so
// a reset always lands before the levels are asked for again.
const loadKeyRef = React.useRef<{ key: unknown } | null>(null)
React.useEffect(() => {
const previous = loadKeyRef.current
loadKeyRef.current = { key: loadKey }
if (!previous || Object.is(previous.key, loadKey)) return
epoch.current += 1
for (const controller of controllers.current.values()) controller.abort()
controllers.current.clear()
inflight.current.clear()
// `requestIds` is deliberately NOT cleared: the ids are stale-response
// guards, not cache. Reusing an aborted predecessor's id would leave only
// the epoch bump and the abort to reject the old response; monotonic per
// key keeps all three guards independent. The unmount cleanup clears them.
moreLatch.current.clear()
attempted.current.clear()
failed.current.clear()
known.current.clear()
const holder = timers.current
if (holder.prefetch) {
clearTimeout(holder.prefetch)
holder.prefetch = null
}
setStore(createStore)
setSearch(null)
}, [loadKey])
// Closing the popup aborts the panel's requests but does NOT drop the cache:
// reopening onto an already-loaded level is the point of keeping it.
React.useEffect(() => {
if (enabled) return
if (controllers.current.size === 0) return
cancelPanel()
}, [enabled, cancelPanel])
React.useEffect(() => {
const active = controllers.current
const holder = timers.current
const pending = inflight.current
const ids = requestIds.current
const tried = attempted.current
const parked = failed.current
return () => {
for (const controller of active.values()) controller.abort()
active.clear()
// Cleared WITH the controllers, for StrictMode's dev remount: a stale
// inflight signature would make `runLoad` skip the refetch forever, and
// a value still marked as asked would never be resolved again. The
// aborted promises are stale-guarded, so emptying the ids is safe.
pending.clear()
ids.clear()
tried.clear()
parked.clear()
if (holder.prefetch) clearTimeout(holder.prefetch)
}
}, [])
/* ------------------------------- the effects ----------------------------- */
const hasLoader = typeof getChildren === "function"
const hasSearch = typeof onSearch === "function"
const hasResolve = typeof resolveValue === "function"
const trimmed = query.trim()
// Serialised so the effects key on CONTENT, not on the array identity.
const levelsKey = JSON.stringify(levels)
const valuesKey = JSON.stringify(values)
// THE load trigger: one declarative effect on the levels on screen, not a
// call from `pushLevel`/`navigate`, which a controlled `path` never touches.
React.useEffect(() => {
if (!enabled || !hasLoader) return
for (const key of levels) ensureLevel(key, "level")
// `levels` enters through `levelsKey`; `store` re-runs after a load lands.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, hasLoader, levelsKey, store, ensureLevel])
React.useEffect(() => {
if (!hasSearch) return undefined
if (!enabled || !trimmed) {
abortKey(SEARCH_KEY)
setSearch((prev) => (prev === null ? prev : null))
return undefined
}
const timer = setTimeout(() => runSearch(trimmed), searchDebounce)
return () => {
clearTimeout(timer)
abortKey(SEARCH_KEY)
}
}, [hasSearch, enabled, trimmed, searchDebounce, abortKey, runSearch])
// Opening is the retry point for a failed resolve: un-ask it here, and the
// values pass below, which also re-runs on `enabled`, asks again.
React.useEffect(() => {
if (!enabled || failed.current.size === 0) return
for (const value of failed.current) attempted.current.delete(value)
failed.current.clear()
}, [enabled])
// NOT gated on `enabled`: the trigger shows the selection with the popup
// closed, so a saved value is pathed on load, not on the first open.
// `loadKey` re-runs it, because the reset dropped every resolved chain.
React.useEffect(() => {
if (!hasResolve) return
for (const value of values) {
if (!value) continue
if (attempted.current.has(value)) continue
if (base.byValue.has(value) || known.current.has(value)) continue
attempted.current.add(value)
runResolve(value)
}
// `values` is depended on through `valuesKey`.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, hasResolve, valuesKey, base, runResolve, loadKey])
/* -------------------------------- the value ------------------------------ */
const searchResults = React.useMemo(() => {
if (!hasSearch || !trimmed) return null
if (!search || search.query !== trimmed) {
return NO_RESULTS as CascaderNode<T>[]
}
return search.results
}, [hasSearch, trimmed, search])
const searchState = React.useMemo<CascaderLoadState | null>(() => {
if (!hasSearch || !trimmed) return null
const settled = search?.query === trimmed
return {
loading: !settled || !!search?.loading,
error: settled && !!search?.error,
hasMore: false,
}
}, [hasSearch, trimmed, search])
return React.useMemo(
() => ({
active: hasLoader,
store,
states: store.states,
searchResults,
searchState,
ensureLevel,
loadMore,
retryLevel,
retrySearch,
invalidateLevel,
prefetchNode,
}),
[
hasLoader,
store,
searchResults,
searchState,
ensureLevel,
loadMore,
retryLevel,
retrySearch,
invalidateLevel,
prefetchNode,
]
)
}
/* -------------------------------------------------------------------------- */
/* Consumers */
/* -------------------------------------------------------------------------- */
/** One level's load state, `null` when never fetched. Omit for the root. */
export function useCascaderLoadState(
parent?: string | null
): CascaderLoadState | null {
const { loadStates } = useCascaderState()
return loadStates.get(parent ?? CASCADER_ROOT_KEY) ?? null
}
@@ -0,0 +1,274 @@
"use client"
import * as React from "react"
import {
useCascaderActions,
useCascaderState,
} from "@/components/reui/cascader/cascader-context"
import type { CascaderColumn } from "@/components/reui/cascader/cascader-context"
import {
CascaderItem,
getCascaderMoreProps,
} from "@/components/reui/cascader/cascader-item"
import {
CASCADER_LIST_HEIGHT_CLASS,
CASCADER_LIST_PAD_CLASS,
CASCADER_ROOT_KEY,
CASCADER_ROWS_CLASS,
CASCADER_SCROLL_CLASS,
focusCascaderHome,
warnCascaderOnce,
} from "@/components/reui/cascader/cascader-lib"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { cn } from "@authportal/ui/lib/utils"
import { ScrollArea } from "@authportal/ui/components/scroll-area"
import { LoaderCircleIcon } from "lucide-react"
export interface CascaderColumnsProps extends Omit<
React.ComponentProps<"div">,
"children"
> {
/** Width of each column. */
columnWidth?: number | string
/** Height CAP per column. Falls back to the root `maxHeight`, then 24rem. */
maxHeight?: number | string
/** Replaces the default panel; the seam a windowed column plugs into. */
children?: (column: CascaderColumn) => React.ReactNode
}
/**
* Miller columns: the open trail side by side, one panel per level. Only the
* DEEPEST column is a real listbox (Base UI owns exactly one list); the trail
* behind is plain buttons, which keeps one state machine instead of a second,
* 2D one. `CascaderInput` moves between columns with ArrowLeft/ArrowRight.
*/
function CascaderColumns({
className,
columnWidth = 220,
maxHeight: maxHeightProp,
children,
...props
}: CascaderColumnsProps) {
const { maxHeight, mode, labels } = useCascaderActions()
const { columns } = useCascaderState()
// Before the early return, so the hook count is the same in both modes.
React.useEffect(() => {
if (process.env.NODE_ENV === "production") return
if (mode === "columns") return
warnCascaderOnce(
`columns-outside-columns-mode:${mode}`,
`\`CascaderColumns\` renders nothing in \`mode="${mode}"\`, so \`columnWidth\` and everything else on it does nothing. Set \`mode="columns"\` on the root, or render \`CascaderList\` instead.`
)
}, [mode])
if (mode !== "columns") return null
const height = maxHeightProp ?? maxHeight
const toCss = (value: number | string) =>
typeof value === "number" ? `${value}px` : value
return (
<div
data-slot="cascader-columns"
role="group"
aria-label={labels.columnsLabel}
style={
{
"--cascader-column-width": toCss(columnWidth),
/* Set only from an EXPLICIT cap: unset means "24rem or what the
viewport leaves", via the `min()` fallback on the panel. A `?? 280`
default here ignored short viewports and wasted tall ones. */
...(height != null
? { "--cascader-max-height": toCss(height) }
: null),
} as React.CSSProperties
}
className={cn(
/* `max-h-full` with `min-h-0`: the trail is the panel's shrinking
child, and the columns inside it size against this box. */
"flex max-h-full min-h-0 items-stretch overflow-x-auto overscroll-x-contain",
CASCADER_LIST_PAD_CLASS,
className
)}
{...props}
>
{columns.map((column) =>
children ? (
<React.Fragment key={column.depth}>{children(column)}</React.Fragment>
) : (
<CascaderColumnPanel key={column.depth} column={column} />
)
)}
</div>
)
}
/**
* One column's box. `columnWidth` is the width of the LIST, not the box: under
* `border-box` the 1px inline-start divider on every column but the first would
* come out of the rows, so bordered columns are widened by that pixel. The box
* is also the BOUND, the same `min(--available-height, cap)` the single list
* uses, and the `ScrollArea` inside scrolls, so every column shows a thumb.
*/
const PANEL_CLASS = `flex w-(--cascader-column-width) shrink-0 flex-col overscroll-contain not-first:w-[calc(var(--cascader-column-width)_+_1px)] not-first:border-border/60 not-first:border-s ${CASCADER_LIST_HEIGHT_CLASS}`
export interface CascaderColumnPanelProps {
column: CascaderColumn
/** Replaces the panel's rows; the empty state still wins on an empty column. */
children?: React.ReactNode
/** Containing block for the windowed column's absolutely positioned rows. */
virtualized?: boolean
}
function CascaderColumnPanel({
column,
children,
virtualized,
}: CascaderColumnPanelProps) {
const {
labels,
baseId,
isBranch,
isSelectable,
isSelected,
isIndeterminate,
retryLevel,
searchScope,
} = useCascaderActions()
const { loadStates, deepResults } = useCascaderState()
// Keyed per level, not one global flag: columns load and land independently.
const columnKey = column.parent?.value ?? CASCADER_ROOT_KEY
const loadState = loadStates.get(columnKey)
let emptyBody: React.ReactNode = labels.empty
if (loadState?.error) {
emptyBody = (
<>
{labels.error}{" "}
<button
type="button"
data-slot="cascader-retry"
onClick={(event) => {
// Same hand-off as the panel's Retry: a trail column, or an
// inline one, can hold focus, and the loading state unmounts this.
focusCascaderHome(event.currentTarget)
retryLevel(columnKey)
}}
className="text-foreground hover:bg-accent focus-visible:ring-ring/50 rounded-md px-1 font-medium outline-hidden transition-colors focus-visible:ring-2"
>
{labels.retry}
</button>
</>
)
} else if (loadState?.loading) {
emptyBody = (
<span className="flex items-center gap-1.5">
<LoaderCircleIcon className="size-3.5 animate-spin" aria-hidden />
{labels.loading}
</span>
)
}
const rows =
column.items.length === 0 ? (
<p
data-slot="cascader-column-empty"
data-state={
loadState?.error ? "error" : loadState?.loading ? "loading" : "empty"
}
className="text-muted-foreground px-2 py-1.5 text-sm"
>
{emptyBody}
</p>
) : (
(children ??
column.items.map((node, i) => {
const open = node.value === column.activeValue
return (
<CascaderItem
key={node.value}
node={node}
/* A trail row must not compete for `aria-activedescendant`. */
as={column.active ? "option" : "button"}
depth={column.depth}
/* Answered here, not in the row: the trail rows are memoised too. */
branch={isBranch(node)}
selectable={isSelectable(node)}
selected={isSelected(node)}
indeterminate={isIndeterminate(node)}
{...getCascaderMoreProps(node, loadStates)}
data-open={open || undefined}
className={open ? "bg-accent/60 text-accent-foreground" : undefined}
/* Set metadata is option-only; a trail row is a `role="button"`. */
{...(column.active
? {
"aria-setsize": column.items.length,
"aria-posinset": i + 1,
}
: null)}
{...(!column.active && open
? {
"aria-expanded": true,
"aria-controls": `${baseId}-column-${column.depth + 1}`,
}
: null)}
/>
)
}))
)
const shared = {
"data-slot": "cascader-column",
"data-active": column.active || undefined,
"data-depth": column.depth,
// Addressable so the opening trail row can point `aria-controls` here, and
// named even at the root, which has no parent label to borrow.
id: `${baseId}-column-${column.depth}`,
// Global hits come from the whole tree, so the parent would understate them.
"aria-label":
column.active && searchScope === "global" && deepResults
? labels.rootLevel
: (column.parent?.label ?? labels.rootLevel),
// Conditional spread, never an explicit `undefined`: the active column is a
// Base UI element, and its `mergeProps` iterates own keys.
...(virtualized ? { "data-virtualized": true } : null),
}
// A windowed row is absolutely positioned, so the ROWS' box is the containing
// block, not the scrollport: it carries the padding the geometry is measured
// against.
const rowsClass = cn(CASCADER_ROWS_CLASS, virtualized && "relative")
// The active column IS the Combobox list: only rows inside `Combobox.List`
// reach the CompositeList, arrow-key navigation and `aria-activedescendant`.
const body = column.active ? (
<ComboboxPrimitive.List {...shared} className={rowsClass}>
{rows}
</ComboboxPrimitive.List>
) : (
// A named `group`, not a second listbox competing with the active column.
<div {...shared} role="group" className={rowsClass}>
{rows}
</div>
)
return (
<div
data-slot="cascader-column-bounds"
/* Repeated from `shared`: this box, not the semantic element, owns the
width, the divider and the height, so style hooks must reach it, and
`:first-child` on the semantic element no longer means "first column"
(it is its own scrollport's only child). */
data-active={column.active || undefined}
data-depth={column.depth}
className={PANEL_CLASS}
>
<ScrollArea className={CASCADER_SCROLL_CLASS}>{body}</ScrollArea>
</div>
)
}
export { CascaderColumnPanel, CascaderColumns }
@@ -0,0 +1,326 @@
"use client"
import * as React from "react"
import type {
CascaderActionItem,
CascaderChangeReason,
CascaderFlatNode,
CascaderIndex,
CascaderLabels,
CascaderLoadState,
CascaderMode,
CascaderNode,
CascaderSearchScope,
} from "@/components/reui/cascader/cascader-types"
/**
* Four contexts, not one: actions (config and callbacks, near-stable), state
* (every keystroke), render (`renderItem` identity) and highlight (every arrow
* key and pointer move). One combined context re-rendered every row on every
* keystroke, which is what made `React.memo` on the row worth nothing.
*/
/* -------------------------------------------------------------------------- */
/* Shared types */
/* -------------------------------------------------------------------------- */
export interface CascaderColumn<T = unknown> {
parent: CascaderNode<T> | null
items: CascaderNode<T>[]
depth: number
/** The node in this column that is drilled into, if any. */
activeValue: string | null
/** Whether this is the deepest column, the one Base UI owns. */
active: boolean
}
export interface CascaderItemState<T = unknown> {
branch: boolean
selected: boolean
/** The node's own flag or a disabled ancestor's, as `isCascaderDisabled`. */
disabled: boolean
depth: number
count: number
/** Ancestor chain, root first. Populated for deep and global search rows. */
path: CascaderNode<T>[]
}
/* -------------------------------------------------------------------------- */
/* State */
/* -------------------------------------------------------------------------- */
/**
* Everything derived from the query, the path and the selection. One keystroke
* rebuilds most of it, so never subscribe to it from a row.
*/
export interface CascaderStateContextValue<T = unknown> {
/** Same `useMemo` identity as the actions context's `index`. */
index: CascaderIndex<T>
path: string[]
expanded: ReadonlySet<string>
query: string
currentParent: CascaderNode<T> | null
/** Rows for the current level, already filtered. */
levelItems: CascaderNode<T>[]
deepResults: CascaderNode<T>[] | null
/** What `Combobox.Root` is currently rendering, in render order. */
renderedItems: CascaderNode<T>[]
columns: CascaderColumn<T>[]
treeRows: CascaderFlatNode<T>[]
selectedValues: string[]
/** Selected nodes below each value, at any depth. Absent means zero. */
selectedDescendants: ReadonlyMap<string, number>
/**
* Per level, keyed by parent value or `CASCADER_ROOT_KEY`. MEMBERSHIP is the
* discriminator: no entry means never fetched, an entry with no `loading`, no
* `error` and no children means fetched and genuinely empty.
*/
loadStates: ReadonlyMap<string, CascaderLoadState>
/** Async search, `null` when idle. Separate: a search belongs to no level. */
searchState: CascaderLoadState | null
announcement: string
}
const CascaderStateContext = React.createContext<
CascaderStateContextValue | undefined
>(undefined)
/**
* One provider serves every `T`, so the context holds an erased `unknown` value
* and this cast restores it. The primitive never inspects the payload.
*/
export function useCascaderState<T = unknown>(): CascaderStateContextValue<T> {
const context = React.useContext(CascaderStateContext)
if (!context) {
throw new Error("useCascaderState must be used within a Cascader")
}
return context as unknown as CascaderStateContextValue<T>
}
/* -------------------------------------------------------------------------- */
/* Actions */
/* -------------------------------------------------------------------------- */
/**
* Config and callbacks, slow enough that a memoised row can subscribe: the
* mutators are `[]`-dep callbacks over a latest-props ref. The predicates are
* the exception, read DURING RENDER where a ref written in an effect would
* return the previous commit's answer, so each is memoised on its own input.
*/
export interface CascaderActionsContextValue<T = unknown> {
index: CascaderIndex<T>
mode: CascaderMode
multiple: boolean
/** Multi-select only: a commit propagates over the LOADED subtree. */
cascade: boolean
/** Whether a BRANCH is committable. Per-LIST: the check gutter is a COLUMN. */
branchesSelectable: boolean
/** Draws the SINGLE-SELECT check and its gutter. Ignored in multi-select. */
indicator: boolean
expandTrigger?: "click" | "hover"
actions: CascaderActionItem[]
searchScope: CascaderSearchScope
maxHeight?: number | string
inline: boolean
invalid: boolean
/**
* Id prefix; columns are `${baseId}-column-${depth}`. The SCHEME is contract:
* the filters primitive's `FilterMenuPinKeeper` restores its highlight across
* a live re-pin through `${baseId}-column-0` and the `cascader-item` slot.
*/
baseId: string
labels: CascaderLabels
/**
* Whether rows are WINDOWED. Also handed to `Combobox.Root`, which is what
* makes an explicit row `index` legal: forwarding one while this is false
* makes `aria-activedescendant` resolve to nothing. Latched per level.
*/
virtualized: boolean
/** Mounts a windowing renderer, returns its unregister. LAYOUT effect only. */
registerVirtualRenderer: () => () => void
/** The root's `virtualize` prop. `undefined` means "decide by count". */
virtualize?: boolean
virtualizeThreshold: number
estimateRowSize: number
overscan: number
/** Tells "this level is empty" from "not fetched yet" before a load state. */
hasLoader: boolean
/** Next page of a level. Latched: a page with nothing new is not re-asked. */
loadMore: (parentKey: string) => void
retryLevel: (parentKey: string) => void
/** Re-runs a failed server search (`onSearch`) for the current query. */
retrySearch: () => void
/**
* Evicts one level's async cache, `null` for the root, so membership reads
* never-loaded. A level that is on screen when evicted refetches at once.
*/
invalidateLevel: (value: string | null) => void
/** Index as of the last commit. Use in the stable callbacks, not `index`. */
getIndex: () => CascaderIndex<T>
getState: () => CascaderStateContextValue<T>
/** Highlighted row or null. A getter: the highlight moves per arrow key. */
getHighlighted: () => CascaderNode<T> | null
setPath: (next: string[] | ((prev: string[]) => string[])) => void
pushLevel: (value: string) => void
popLevel: () => void
goToDepth: (depth: number) => void
toggleExpanded: (value: string) => void
/**
* Registers a footer submenu as open or closed. `Combobox` has no
* `FloatingTree`, so one Escape would dismiss the flyout AND the cascader;
* the root's `onOpenChange` guard cancels the close while any is open, from a
* ref so it reads as of that event without re-rendering the root.
*/
setFlyoutOpen: (key: string, open: boolean) => void
hasOpenFlyout: () => boolean
setQuery: (next: string) => void
/**
* Replaces the selection. `onValueChange` diffs it against the current one
* for its node and reason; pass `reason` only when the caller knows better.
*/
setSelection: (values: string[], reason?: CascaderChangeReason) => void
/** Commits a node, for rows outside the listbox such as ancestor columns. */
commit: (node: CascaderNode<T>) => void
navigate: (node: CascaderNode<T>) => void
/** Into `node` as a child of `depth`, replacing anything deeper. */
navigateAt: (node: CascaderNode<T>, depth: number) => void
/** Never undefined: falls back to a remembered label, then a synthetic node. */
resolveNode: (value: string) => CascaderNode<T>
isBranch: (node: CascaderNode<T>) => boolean
isSelectable: (node: CascaderNode<T>) => boolean
/**
* The node or an ancestor is `disabled`. Rows render it disabled; row
* presses, keys, `commit`, `navigate`, `navigateAt` and a held navigation
* refuse it. `pushLevel`, `setPath`, `toggleExpanded` and `setSelection` are
* raw setters and do not ask.
*/
isDisabled: (node: CascaderNode<T>) => boolean
isSelected: (node: CascaderNode<T>) => boolean
/** Always `false` without `cascade`: partial selection needs propagation. */
isIndeterminate: (node: CascaderNode<T>) => boolean
/**
* O(1) read of `selectedDescendants`; a memoised row may not subscribe.
* Unlike `isIndeterminate` this answers in every mode.
*/
selectedDescendantCount: (node: CascaderNode<T>) => number
}
const CascaderActionsContext = React.createContext<
CascaderActionsContextValue | undefined
>(undefined)
export function useCascaderActions<
T = unknown,
>(): CascaderActionsContextValue<T> {
const context = React.useContext(CascaderActionsContext)
if (!context) {
throw new Error("useCascaderActions must be used within a Cascader")
}
return context as unknown as CascaderActionsContextValue<T>
}
/* -------------------------------------------------------------------------- */
/* Render props */
/* -------------------------------------------------------------------------- */
/**
* Their own context, republished every render. They cannot ride on the actions
* context: an inline closure read off a memoised object is whichever closure
* that object captured, so the row would call a stale prop over stale state.
*/
export interface CascaderRenderContextValue<T = unknown> {
renderItem?: (
node: CascaderNode<T>,
state: CascaderItemState<T>
) => React.ReactNode
renderLabel?: (
node: CascaderNode<T>,
state: CascaderItemState<T>
) => React.ReactNode
}
const CascaderRenderContext = React.createContext<CascaderRenderContextValue>(
{}
)
export function useCascaderRender<
T = unknown,
>(): CascaderRenderContextValue<T> {
return React.useContext(
CascaderRenderContext
) as CascaderRenderContextValue<T>
}
/* -------------------------------------------------------------------------- */
/* Highlight store */
/* -------------------------------------------------------------------------- */
export interface CascaderHighlight {
index: number
value: string | null
}
/**
* An external store, deliberately NOT React state: `onItemHighlighted` fires on
* every arrow key AND every pointer move over the list, so `setState` would
* re-render the whole root at mousemove rate. A store re-renders subscribers
* only, which inside the primitive is the virtualizer.
*/
export interface CascaderHighlightStore {
subscribe: (onStoreChange: () => void) => () => void
getSnapshot: () => CascaderHighlight
set: (next: CascaderHighlight) => void
}
const NO_HIGHLIGHT: CascaderHighlight = { index: -1, value: null }
export function createCascaderHighlightStore(): CascaderHighlightStore {
let snapshot: CascaderHighlight = NO_HIGHLIGHT
const listeners = new Set<() => void>()
return {
subscribe(onStoreChange) {
listeners.add(onStoreChange)
return () => {
listeners.delete(onStoreChange)
}
},
// The SAME object until something changes; `useSyncExternalStore` needs it.
getSnapshot() {
return snapshot
},
set(next) {
if (next.index === snapshot.index && next.value === snapshot.value) return
snapshot = next
for (const listener of listeners) listener()
},
}
}
/** Shared and permanently empty, so the hook degrades outside a `Cascader`. */
const FALLBACK_HIGHLIGHT_STORE = createCascaderHighlightStore()
const CascaderHighlightContext = React.createContext<CascaderHighlightStore>(
FALLBACK_HIGHLIGHT_STORE
)
/** Subscribes to the highlight. Re-renders ONLY the calling component. */
export function useCascaderHighlight(): CascaderHighlight {
const store = React.useContext(CascaderHighlightContext)
return React.useSyncExternalStore(
store.subscribe,
store.getSnapshot,
store.getSnapshot
)
}
export {
CascaderActionsContext,
CascaderHighlightContext,
CascaderRenderContext,
CascaderStateContext,
}
@@ -0,0 +1,731 @@
import * as React from "react"
import { useCascaderActions } from "@/components/reui/cascader/cascader-context"
import {
CASCADER_ACTION_CLASS,
CascaderGroup,
CascaderLabel,
} from "@/components/reui/cascader/cascader-item"
import {
CASCADER_LIST_PAD_CLASS,
getCascaderFooterStops,
isCascaderRtl,
} from "@/components/reui/cascader/cascader-lib"
import type { CascaderActionItem } from "@/components/reui/cascader/cascader-types"
import { Popover as PopoverPrimitive } from "@base-ui/react"
import { useDirection } from "@base-ui/react/direction-provider"
import { cn } from "@authportal/ui/lib/utils"
import { ChevronRightIcon } from "lucide-react"
/**
* The pinned footer, and the side-anchored flyout a footer row can open. These
* are COMMANDS: nothing here joins the selection, the filter set or the
* highlight. The flyout is a Base UI `Popover` rendered as a REACT CHILD of
* `Combobox.Popup` with its OWN `Portal` and NO `container`: a nested portal
* resolves to the parent portal node, so it is a DOM sibling of the combobox
* popup (not clipped, not `aria-hidden`) but a React descendant, which is what
* the outside-press and focus-out whitelists read. `Combobox` builds no
* `FloatingTree`, so the flyout is not consulted first and one Escape would
* dismiss both; hence `CascaderSubmenu` registering with the root to turn one
* Escape into two. `Combobox.List` clicks its highlighted row on Enter, hence
* the footer sitting outside `CascaderList`. And a `Positioner` throws without
* its `Portal`, while `modal` stays `false` on the `Root` so the combobox
* keeps its own dismissal behaviour.
*/
/* -------------------------------------------------------------------------- */
/* Footer */
/* -------------------------------------------------------------------------- */
/**
* Keys the option list acts on, swallowed at the footer boundary. Escape and
* Tab are absent on purpose: Escape must reach the root, Tab must keep moving.
*/
const FOOTER_SWALLOWED_KEYS = new Set([
"Enter",
" ",
"ArrowUp",
"ArrowDown",
"Home",
"End",
"PageUp",
"PageDown",
])
export type CascaderFooterProps = React.ComponentProps<"div">
/**
* Actions pinned below the list, a SIBLING of `CascaderList`. Children win
* over the root's `actions` prop; with neither it renders nothing.
*/
function CascaderFooter({
className,
children,
onKeyDown,
...props
}: CascaderFooterProps) {
const { actions, labels } = useCascaderActions()
const hasChildren = React.Children.count(children) > 0
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event)
if (event.defaultPrevented) return
if (!FOOTER_SWALLOWED_KEYS.has(event.key)) return
event.stopPropagation()
// The strip's own vertical movement, and the way back from the list's
// hand-off: either end returns focus to the search field, from which
// Base UI's empty highlight resumes the list. Down wraps to the FIELD,
// not a command (traps the arrows) or a row (no imperative highlight).
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return
const footer = event.currentTarget
const stops = getCascaderFooterStops(footer)
const active = document.activeElement as HTMLElement | null
const index = active ? stops.indexOf(active) : -1
if (index === -1) return
event.preventDefault()
const next =
event.key === "ArrowDown" ? stops[index + 1] : stops[index - 1]
if (next) {
next.focus()
return
}
if (event.key === "ArrowUp" && index > 0) return
footer
.closest<HTMLElement>('[data-slot="cascader-panel"]')
?.querySelector<HTMLElement>('[data-slot="cascader-input"]')
?.focus()
},
[onKeyDown]
)
if (!hasChildren && actions.length === 0) return null
return (
<div
data-slot="cascader-footer"
/* Named, not a bare div: without it a screen reader reaches the actions
with nothing to say they are not more options. */
role="group"
aria-label={labels.actionsLabel}
onKeyDown={handleKeyDown}
className={cn(
"border-border/60 flex shrink-0 flex-col gap-0.5 border-t",
/* The LIST's padding, not a flat `p-1`: with a padding of its own the
two columns of text were 2px out in luma and sera and 4px out in
lyra. It also gives a separator in here a number to cancel. */
CASCADER_LIST_PAD_CLASS,
"p-(--cascader-list-pad,4px)",
className
)}
{...props}
>
{hasChildren ? children : <CascaderFooterActions actions={actions} />}
</div>
)
}
function CascaderFooterActions({ actions }: { actions: CascaderActionItem[] }) {
return (
<>
{actions.map((action, i) =>
action.items?.length ? (
<CascaderSubmenu key={actionKey(action, i)}>
<CascaderSubmenuTrigger
icon={action.icon}
disabled={action.disabled}
>
{action.label}
</CascaderSubmenuTrigger>
<CascaderSubmenuContent>
<CascaderActionList items={action.items} />
</CascaderSubmenuContent>
</CascaderSubmenu>
) : (
<CascaderAction
key={actionKey(action, i)}
icon={action.icon}
disabled={action.disabled}
onSelect={action.onSelect}
>
{action.label}
</CascaderAction>
)
)}
</>
)
}
function actionKey(action: CascaderActionItem, index: number): string {
if (action.value != null) return action.value
if (typeof action.label === "string") return action.label
return String(index)
}
/**
* Consecutive entries sharing a `group`, as runs not buckets: two separated
* runs with the same name stay two, so the author's order survives.
*/
function groupActionRuns(
items: CascaderActionItem[]
): { group?: string; items: CascaderActionItem[] }[] {
const runs: { group?: string; items: CascaderActionItem[] }[] = []
for (const item of items) {
const last = runs[runs.length - 1]
if (last && last.group === item.group) last.items.push(item)
else runs.push({ group: item.group, items: [item] })
}
return runs
}
/**
* Flyout body for a data-driven submenu. A named run becomes a real
* `CascaderGroup`; unnamed runs stay unwrapped, as an unnamed group is noise.
*/
function CascaderActionList({ items }: { items: CascaderActionItem[] }) {
const { close } = useCascaderSubmenu()
const runs = React.useMemo(() => groupActionRuns(items), [items])
const renderAction = (item: CascaderActionItem, i: number) => (
<CascaderAction
key={actionKey(item, i)}
icon={item.icon}
disabled={item.disabled}
onSelect={() => {
item.onSelect?.()
/* Closes behind the command, or the entries would read as toggles. */
close()
}}
>
{item.label}
</CascaderAction>
)
return (
<>
{runs.map((run, runIndex) =>
run.group ? (
<CascaderGroup key={`${run.group}-${runIndex}`} className="gap-0.5">
<CascaderLabel>{run.group}</CascaderLabel>
{run.items.map(renderAction)}
</CascaderGroup>
) : (
<React.Fragment key={`run-${runIndex}`}>
{run.items.map(renderAction)}
</React.Fragment>
)
)}
</>
)
}
/* -------------------------------------------------------------------------- */
/* Action */
/* -------------------------------------------------------------------------- */
export interface CascaderActionProps extends Omit<
React.ComponentProps<"button">,
"onSelect"
> {
icon?: React.ReactNode
/** Fires on press, after `onClick`, and not at all when disabled. */
onSelect?: () => void
}
/**
* The cascader popup's panel per style, spelled with `style-<name>:` variants
* rather than ReUI theme CSS so an installed footer needs only Tailwind.
*/
const FLYOUT_SURFACE_CLASS =
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-72 overflow-hidden ring-1 duration-100 ring-foreground/10 shadow-md rounded-lg"
/**
* One footer command, shaped like a row and deliberately NOT one. A real
* `<button>`, never a `Combobox.Item`: an item would join the arrow-key ring,
* appear in `filteredItems`, and vanish the moment a query matched nothing -
* exactly when "Create new attribute" is most useful. Disabled is
* `aria-disabled`, never the native attribute: that one is not a tab stop and
* the panel's Tab order reads `button:not([disabled])`, so a footer whose ONLY
* row is a disabled command had no stop after the search field (measured on
* `c-cascader-8`). Staying focusable costs the guards below, plus the
* greyed-out look: `CASCADER_ACTION_CLASS` keys that off `aria-disabled`
* instead of `:disabled`.
*/
function CascaderAction({
className,
icon,
children,
onSelect,
onClick,
onKeyDown,
disabled,
...props
}: CascaderActionProps) {
// Set by `CascaderSubmenuContent`: a plain button in the footer's Tab ring,
// a roving-focus `menuitem` inside a flyout, never both.
const inMenu = React.useContext(CascaderMenuContext)
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
// Before the consumer's handler: a disabled command runs none of them.
if (disabled) {
event.preventDefault()
return
}
onClick?.(event)
if (event.defaultPrevented) return
onSelect?.()
},
[disabled, onClick, onSelect]
)
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (disabled) {
// The two keys a `<button>` activates on, and only those.
if (event.key === "Enter" || event.key === " ") event.preventDefault()
return
}
onKeyDown?.(event)
},
[disabled, onKeyDown]
)
return (
<button
type="button"
data-slot="cascader-action"
/* Conditional spread: `false` would publish `aria-disabled="false"`. */
{...(disabled ? { "aria-disabled": true, "data-disabled": "" } : null)}
onClick={handleClick}
onKeyDown={handleKeyDown}
/* Conditional spread, so a consumer's own `role` or `tabIndex` wins. */
{...(inMenu ? { role: "menuitem" as const, tabIndex: -1 } : null)}
className={cn(CASCADER_ACTION_CLASS, className)}
{...props}
>
{icon ? (
<span
data-slot="cascader-action-icon"
className="text-muted-foreground flex shrink-0 items-center justify-center"
>
{icon}
</span>
) : null}
<span className="min-w-0 flex-1 truncate text-start">{children}</span>
</button>
)
}
/* -------------------------------------------------------------------------- */
/* Submenu */
/* -------------------------------------------------------------------------- */
interface CascaderSubmenuContextValue {
rowRef: React.RefObject<HTMLButtonElement | null>
open: boolean
setOpen: (open: boolean) => void
close: () => void
/** Names the flyout: a menu is labelled by the control that opens it. */
triggerId: string
/**
* Whether the pending open came from the KEYBOARD. Base UI's own `openType`
* calls the opening arrow a POINTER open, because the trigger intercepts it
* and calls `setOpen` (measured: focus landed on the popup, not the first
* entry). A ref, so reading it in the focus phase cannot render.
*/
keyboardRef: React.RefObject<boolean>
}
/** Marks the subtree INSIDE a flyout. See `inMenu` in `CascaderAction`. */
const CascaderMenuContext = React.createContext(false)
/**
* Every entry a menu's roving focus may land on, in DOM order, read from the
* DOM because the entries are whatever the consumer composed. A disabled
* `CascaderAction` is INCLUDED: it carries `aria-disabled`, not the native
* attribute, so `:not([disabled])` excludes only a consumer's own natively
* disabled `menuitem`, which cannot take focus.
*/
function menuItems(popup: HTMLElement | null): HTMLElement[] {
if (!popup) return []
return Array.from(
popup.querySelectorAll<HTMLElement>('[role="menuitem"]:not([disabled])')
)
}
const CascaderSubmenuContext = React.createContext<
CascaderSubmenuContextValue | undefined
>(undefined)
/** The flyout's own state. `close()` is the one a custom entry usually wants. */
export function useCascaderSubmenu(): CascaderSubmenuContextValue {
const context = React.useContext(CascaderSubmenuContext)
if (!context) {
throw new Error("useCascaderSubmenu must be used within a CascaderSubmenu")
}
return context
}
export interface CascaderSubmenuProps {
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
children?: React.ReactNode
}
/**
* A footer row plus the flyout it opens. Registers with the cascader root
* while open, which turns one Escape into two. Cleared in an EFFECT, so the
* flyout still reads as open during the event that closed it.
*/
function CascaderSubmenu({
open: openProp,
defaultOpen = false,
onOpenChange,
children,
}: CascaderSubmenuProps) {
const { setFlyoutOpen } = useCascaderActions()
const key = React.useId()
const triggerId = React.useId()
const rowRef = React.useRef<HTMLButtonElement | null>(null)
const keyboardRef = React.useRef(false)
const [uncontrolled, setUncontrolled] = React.useState(defaultOpen)
const open = openProp ?? uncontrolled
const setOpen = React.useCallback(
(next: boolean) => {
if (openProp == null) setUncontrolled(next)
onOpenChange?.(next)
},
[openProp, onOpenChange]
)
const close = React.useCallback(() => setOpen(false), [setOpen])
React.useEffect(() => {
setFlyoutOpen(key, open)
return () => setFlyoutOpen(key, false)
}, [setFlyoutOpen, key, open])
const context = React.useMemo<CascaderSubmenuContextValue>(
() => ({ rowRef, open, setOpen, close, triggerId, keyboardRef }),
[open, setOpen, close, triggerId]
)
return (
<CascaderSubmenuContext.Provider value={context}>
<PopoverPrimitive.Root
open={open}
onOpenChange={setOpen}
/* NEVER modal: it would disable the combobox that owns it. */
modal={false}
>
{children}
</PopoverPrimitive.Root>
</CascaderSubmenuContext.Provider>
)
}
export interface CascaderSubmenuTriggerProps extends Omit<
React.ComponentProps<"button">,
"onSelect"
> {
icon?: React.ReactNode
}
/** Carries the handler-veto hook, derived so a Base UI bump cannot drift. */
type CascaderSubmenuTriggerClickEvent = Parameters<
NonNullable<PopoverPrimitive.Trigger.Props["onClick"]>
>[0]
/**
* The footer row that opens the flyout, and its anchor. `aria-haspopup="menu"`
* rather than the `dialog` Base UI would announce: what opens is a list of
* commands with roving focus. `disabled` is intercepted, not forwarded:
* `Popover.Trigger` runs it through `useButton`, which writes the NATIVE
* attribute for a native `<button>` and takes the row out of the panel's Tab
* ring, the defect `CascaderAction` documents, and this component exposes no
* `focusableWhenDisabled` to opt out. Published as `aria-disabled`, with the
* arrow keys, Enter and Space, and the click closed by hand (`useClick`
* ignores `defaultPrevented`).
*/
function CascaderSubmenuTrigger({
className,
icon,
children,
onKeyDown,
onClick,
disabled,
...props
}: CascaderSubmenuTriggerProps) {
const { labels } = useCascaderActions()
const { rowRef, setOpen, triggerId, keyboardRef } = useCascaderSubmenu()
const direction = useDirection()
const handleClick = React.useCallback(
(event: CascaderSubmenuTriggerClickEvent) => {
if (disabled) {
// `mergeProps` runs right to left, so this drops Base UI's own handler.
event.preventDefault()
event.preventBaseUIHandler()
return
}
onClick?.(event)
},
[disabled, onClick]
)
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (disabled) {
if (event.key === "Enter" || event.key === " ") event.preventDefault()
return
}
onKeyDown?.(event)
if (event.defaultPrevented) return
// The opening key points AT the flyout, so it flips with the writing
// direction; ArrowDown stays "next command". Resolved per keydown, never
// `useDirection()` alone: with no provider that answers "ltr" in RTL.
const openKey = isCascaderRtl(event.currentTarget, direction)
? "ArrowLeft"
: "ArrowRight"
// Enter and Space open through the button's own click, so only FLAGGED.
if (event.key === "Enter" || event.key === " ") {
keyboardRef.current = true
return
}
if (event.key !== openKey) return
event.preventDefault()
keyboardRef.current = true
setOpen(true)
},
[disabled, onKeyDown, direction, setOpen, keyboardRef]
)
return (
<PopoverPrimitive.Trigger
ref={rowRef}
id={triggerId}
data-slot="cascader-submenu-trigger"
aria-haspopup="menu"
/* NOT `disabled={disabled}`: Base UI writes the native attribute. */
{...(disabled ? { "aria-disabled": true, "data-disabled": "" } : null)}
onClick={handleClick}
onKeyDown={handleKeyDown}
className={cn(
CASCADER_ACTION_CLASS,
/* Painted while the flyout is open, as shadcn paints a SubTrigger.
Keyed off `aria-expanded`, not shadcn's `data-[state=open]`: the
popover trigger carries `aria-expanded` in both states. */
"aria-expanded:bg-accent aria-expanded:text-accent-foreground",
className
)}
{...props}
>
{icon ? (
<span
data-slot="cascader-action-icon"
className="text-muted-foreground flex shrink-0 items-center justify-center"
>
{icon}
</span>
) : null}
<span className="min-w-0 flex-1 truncate text-start">{children}</span>
{/* Nothing else says the row opens a MENU, not another tree level. */}
<span className="sr-only">, {labels.submenuAffordance}</span>
<ChevronRightIcon aria-hidden="true" className="text-muted-foreground -me-0.5 size-4 shrink-0 rtl:-scale-x-100" />
</PopoverPrimitive.Trigger>
)
}
export interface CascaderSubmenuContentProps
extends
PopoverPrimitive.Popup.Props,
Pick<
PopoverPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset"
> {}
/** Carries Base UI's handler-veto hook, so a plain React event will not do. */
type CascaderSubmenuKeyEvent = Parameters<
NonNullable<PopoverPrimitive.Popup.Props["onKeyDown"]>
>[0]
/**
* The flyout itself. `Portal` with NO `container`: a nested portal resolves to
* the parent portal node, the mechanism the file header describes.
*/
function CascaderSubmenuContent({
className,
children,
onKeyDown,
side = "inline-end",
align = "end",
sideOffset = 8,
alignOffset = 0,
...props
}: CascaderSubmenuContentProps) {
const { rowRef, close, triggerId, keyboardRef } = useCascaderSubmenu()
const direction = useDirection()
const popupRef = React.useRef<HTMLDivElement | null>(null)
const typeaheadRef = React.useRef({ buffer: "", at: 0 })
/**
* Keyboard opens land on the first entry; a pointer open falls through to
* Base UI's default, the popup, so a click paints no focus ring. Not an
* effect of our own: Base UI's focus manager runs on open and wins the race.
*/
const initialFocus = React.useCallback(() => {
const byKeyboard = keyboardRef.current
keyboardRef.current = false
if (!byKeyboard) return true
return menuItems(popupRef.current)[0] ?? true
}, [keyboardRef])
const closeAndReturn = React.useCallback(() => {
close()
rowRef.current?.focus()
}, [close, rowRef])
const handleKeyDown = React.useCallback(
(event: CascaderSubmenuKeyEvent) => {
onKeyDown?.(event)
// A DOM sibling of the combobox popup but a REACT descendant, so keys in
// here reach its handlers unless stopped. Enter would commit a row.
event.stopPropagation()
if (event.defaultPrevented) return
const popup = popupRef.current
const items = menuItems(popup)
if (items.length === 0) return
const active = document.activeElement as HTMLElement | null
const index = active ? items.indexOf(active) : -1
const move = (next: number) => {
event.preventDefault()
items[(next + items.length) % items.length]?.focus()
}
// Mirrors the open key on the SAME per-keydown answer as the trigger.
const closeKey = isCascaderRtl(event.currentTarget, direction)
? "ArrowRight"
: "ArrowLeft"
switch (event.key) {
case "ArrowDown":
return move(index + 1)
case "ArrowUp":
// From the popup itself (a pointer open) Up means the LAST entry.
return move(index === -1 ? items.length - 1 : index - 1)
case "Home":
return move(0)
case "End":
return move(items.length - 1)
case closeKey:
event.preventDefault()
return closeAndReturn()
case "Tab":
// A menu never holds Tab: it closes and focus carries on from its row.
close()
rowRef.current?.focus()
return
default:
break
}
// Typeahead, after the switch so it can never swallow a navigation key.
if (
event.key.length !== 1 ||
event.metaKey ||
event.ctrlKey ||
event.altKey
)
return
const now = event.timeStamp
const state = typeaheadRef.current
state.buffer = now - state.at > 500 ? event.key : state.buffer + event.key
state.at = now
const prefix = state.buffer.toLowerCase()
const from = index === -1 ? 0 : index
// Starts AFTER the current entry so one letter cycles rather than sticks.
const ordered = [
...items.slice(state.buffer.length > 1 ? from : from + 1),
...items.slice(0, state.buffer.length > 1 ? from : from + 1),
]
const hit = ordered.find((item) =>
(item.textContent ?? "").trim().toLowerCase().startsWith(prefix)
)
if (hit) {
event.preventDefault()
hit.focus()
}
},
[onKeyDown, direction, close, closeAndReturn, rowRef]
)
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
/* The ROW, not whatever Base UI last treated as the trigger. */
anchor={rowRef}
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
ref={popupRef}
initialFocus={initialFocus}
data-slot="cascader-submenu-content"
/* Roving focus, named by the row that opens it. `tabIndex={-1}` lets
a pointer open park focus here without adding a Tab stop. */
role="menu"
aria-orientation="vertical"
aria-labelledby={triggerId}
tabIndex={-1}
onKeyDown={handleKeyDown}
/* Marks a menu surface for the docs design-system picker, which
repaints menus by walking the DOM. An attribute, not a class. */
data-menu-target=""
className={cn(
FLYOUT_SURFACE_CLASS,
"flex max-w-(--available-width) min-w-48 flex-col gap-0.5 outline-hidden",
CASCADER_LIST_PAD_CLASS,
"p-(--cascader-list-pad,4px)",
className
)}
{...props}
>
<CascaderMenuContext.Provider value={true}>
{children}
</CascaderMenuContext.Provider>
</PopoverPrimitive.Popup>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}
/**
* Whether the footer would render anything, for a wrapper (a separator, a grid
* row) that has to make the same call `CascaderFooter` already makes.
*/
export function useCascaderHasActions(): boolean {
const { actions } = useCascaderActions()
return actions.length > 0
}
export {
CascaderAction,
CascaderFooter,
CascaderSubmenu,
CascaderSubmenuContent,
CascaderSubmenuTrigger,
}
@@ -0,0 +1,92 @@
import type {
CascaderLabels,
CascaderMode,
} from "@/components/reui/cascader/cascader-types"
/** Name of the root level. Hoisted so the root announcement can reuse it. */
const ROOT_LEVEL = "Top level"
/** Hoisted for the same reason: several defaults end in an item count. */
const itemCount = (count: number) =>
`${count} ${count === 1 ? "item" : "items"}`
/**
* English defaults. Every string the primitive can render lives here, so a
* consumer can translate the whole surface by passing `labels`.
*/
export const CASCADER_LABELS: CascaderLabels = {
// Deliberately NOT lowercased. `toLowerCase()` is locale-hostile - it maps
// Turkish "İ" to a two-code-point sequence and German "İstanbul" style
// proper nouns lose their casing - and a label is already written the way
// its author wants it read.
search: (parentLabel) =>
parentLabel ? `Search ${parentLabel}...` : "Search...",
back: "Back",
loading: "Loading...",
loadingMore: "Loading more...",
loadMore: "Load more",
error: "Could not load items.",
retry: "Retry",
empty: "No results found.",
selectedCount: (count) => `${count} selected`,
breadcrumbLabel: "Breadcrumb",
chipsLabel: "Selected items",
removeChip: (label) => `Remove ${label}`,
pathSeparator: "/",
rootLevel: ROOT_LEVEL,
itemCount,
branchAffordance: "submenu",
selectedState: "selected",
partiallySelectedState: "partially selected",
columnsLabel: "Levels",
actionsLabel: "Actions",
submenuAffordance: "opens a menu",
panelLabel: "Options",
keyboardHint: (mode: CascaderMode, dir: "ltr" | "rtl") => {
// "Deeper" is the direction the text runs, so the level keys mirror in
// RTL and the hint has to name the mirrored pair there - an LTR-worded
// hint would teach exactly the wrong keys.
const open = dir === "rtl" ? "Left" : "Right"
const back = dir === "rtl" ? "Right" : "Left"
if (mode === "tree") {
return `Use the ${open} Arrow key to expand and the ${back} Arrow key to collapse.`
}
if (mode === "columns") {
return `Use the ${open} Arrow key to open the next column and the ${back} Arrow key to go back.`
}
return `Use the ${open} Arrow key to open a branch and the ${back} Arrow key to go back.`
},
rootAnnouncement: (count) => `${ROOT_LEVEL}, ${itemCount(count)}`,
expandedAnnouncement: (label, count) =>
`${label} expanded, ${itemCount(count)}`,
collapsedAnnouncement: (label) => `${label} collapsed`,
levelAnnouncement: (parentLabel, depth, count) =>
`${parentLabel}, level ${depth}, ${itemCount(count)}`,
resultsAnnouncement: (count) =>
count === 1 ? "1 result" : `${count} results`,
maxReachedAnnouncement: (max) => `Selection limit of ${max} reached`,
cascadeAnnouncement: (label, count, selecting) =>
`${label} ${selecting ? "selected" : "deselected"}, ${itemCount(count)} followed`,
searchingAnnouncement: "Searching...",
}
/**
* Shallow-merges consumer overrides over the defaults, so `labels` can carry a
* single key without restating the rest.
*/
export function resolveCascaderLabels(
labels?: Partial<CascaderLabels>
): CascaderLabels {
if (!labels) return CASCADER_LABELS
return { ...CASCADER_LABELS, ...labels }
}
/** Resolves the search placeholder, which may be a string or a function. */
export function resolveCascaderSearchLabel(
labels: CascaderLabels,
parentLabel?: string
): string {
return typeof labels.search === "function"
? labels.search(parentLabel)
: labels.search
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,649 @@
import * as React from "react"
import {
useCascaderActions,
useCascaderState,
} from "@/components/reui/cascader/cascader-context"
import { resolveCascaderSearchLabel } from "@/components/reui/cascader/cascader-i18n"
import {
collapseCascaderPath,
getCascaderFooterStops,
getCascaderPath,
isCascaderRtl,
} from "@/components/reui/cascader/cascader-lib"
import type {
CascaderCollapse,
CascaderNode,
CascaderValueDisplay,
} from "@/components/reui/cascader/cascader-types"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { useDirection } from "@base-ui/react/direction-provider"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "@authportal/ui/lib/utils"
import { ChevronRightIcon, ChevronLeftIcon } from "lucide-react"
/* -------------------------------------------------------------------------- */
/* Separator */
/* -------------------------------------------------------------------------- */
function PathChevron() {
return (
<ChevronRightIcon className="size-3 shrink-0 opacity-50 rtl:-scale-x-100" />
)
}
/* -------------------------------------------------------------------------- */
/* Nav */
/* -------------------------------------------------------------------------- */
export type CascaderNavProps = useRender.ComponentProps<"div">
/**
* Header: back control, search input, and the separator under them. The
* breadcrumb belongs with the list below: it describes the rows, not the field.
*/
function CascaderNav({ className, ...props }: CascaderNavProps) {
const defaultProps = {
"data-slot": "cascader-nav",
className: cn(
// `py-1`, not `py-1.5`: the row inside sets its own per-style height, so
// even padding read too tall. 6px beside the field, 4px above and below.
"border-border/60 flex shrink-0 flex-col gap-1 border-b px-1.5 py-1",
className
),
}
return useRender({
defaultTagName: "div",
render: props.render,
props: mergeProps<"div">(defaultProps, props),
})
}
/* -------------------------------------------------------------------------- */
/* Back */
/* -------------------------------------------------------------------------- */
export interface CascaderBackProps extends Omit<
useRender.ComponentProps<"button">,
"children"
> {
children?: React.ReactNode
}
/** Pops one level. Renders nothing at the root, so no dead header space. */
function CascaderBack({ className, children, ...props }: CascaderBackProps) {
const { popLevel, labels, mode } = useCascaderActions()
const { path } = useCascaderState()
// Checked after `useRender`, never before it: an early return would change
// the hook count between the root level and any deeper one.
const hidden = mode !== "drill" || path.length === 0
const defaultProps = {
"data-slot": "cascader-back",
type: "button" as const,
"aria-label": labels.back,
onClick: () => popLevel(),
className: cn(
"text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring/50 flex shrink-0 items-center justify-center rounded-md outline-hidden transition-colors focus-visible:ring-2",
// A notch under the row height: an affordance, not a second field.
"size-6",
className
),
children: children ?? (
<ChevronLeftIcon className="size-4 rtl:-scale-x-100" />
),
}
const element = useRender({
defaultTagName: "button",
render: props.render,
props: mergeProps<"button">(defaultProps, props),
})
return hidden ? null : element
}
/* -------------------------------------------------------------------------- */
/* Breadcrumb */
/* -------------------------------------------------------------------------- */
export interface CascaderBreadcrumbProps extends Omit<
useRender.ComponentProps<"nav">,
"children"
> {
/** Maximum visible node segments before the middle collapses. */
maxSegments?: number
collapse?: CascaderCollapse
/** Clicking a segment navigates back to that level. Defaults to true. */
interactive?: boolean
}
/**
* Compact trail of the current level AND its ancestors, collapsed by the same
* helper as `CascaderValue` so panel and trigger cannot disagree. The current
* level IS the last crumb: dropping it read as a rendering bug, a trail naming
* only the places you are not.
*/
function CascaderBreadcrumb({
className,
maxSegments = 3,
collapse = "middle",
interactive = true,
...props
}: CascaderBreadcrumbProps) {
const { goToDepth, mode, labels } = useCascaderActions()
const { path, index } = useCascaderState()
// The WHOLE path, current level included; unresolvable entries drop out.
const nodes = path
.map((value) => index.byValue.get(value))
.filter(Boolean) as CascaderNode[]
const segments = collapseCascaderPath(nodes, { maxSegments, collapse })
// `collapseCascaderPath` never collapses the last node away, so this is
// always the final segment - derived rather than assumed, so a `collapse`
// mode that ever changes that cannot mark an ancestor as the current page.
const currentValue = nodes.length ? nodes[nodes.length - 1].value : null
// See `CascaderBack`: the hide check must not short-circuit past `useRender`.
const hidden = mode !== "drill" || nodes.length === 0
const goTo = (node: CascaderNode) => {
const depth = path.indexOf(node.value)
if (depth < 0) return
// `goToDepth`, not a bare `setPath`: it reports `reason: "breadcrumb"`,
// clears the query and drops any pending navigation.
goToDepth(depth + 1)
}
const defaultProps = {
"data-slot": "cascader-breadcrumb",
"aria-label": labels.breadcrumbLabel,
className: cn(
"text-muted-foreground flex min-w-0 shrink-0 items-center gap-0.5 pt-1.5 pb-0.5 text-xs",
// Lines up with the ROWS, not the header: each style's list padding plus
// its row inset. vega/mira/rhea `p-1`+`pl-2`, nova `p-1`+`pl-1.5`, maia
// `p-1`+`pl-3`, lyra `pl-2`, luma/sera `p-1.5`+`pl-3`, per style-*.css.
"px-2.5",
className
),
children: segments.map((segment, i) => (
<React.Fragment
key={segment.type === "node" ? segment.node.value : `gap-${i}`}
>
{i > 0 ? <PathChevron /> : null}
{segment.type === "ellipsis" ? (
<span
data-slot="cascader-breadcrumb-ellipsis"
title={segment.hidden
.map((n) => n.label)
.join(` ${labels.pathSeparator} `)}
className="shrink-0"
>
&hellip;
</span>
) : interactive && segment.node.value !== currentValue ? (
<button
type="button"
data-slot="cascader-breadcrumb-item"
onClick={() => goTo(segment.node)}
className="hover:text-foreground focus-visible:ring-ring/50 max-w-32 truncate rounded-sm outline-hidden transition-colors focus-visible:ring-2"
>
{segment.node.label}
</button>
) : (
<span
data-slot="cascader-breadcrumb-item"
/* The level on screen: a span because it goes nowhere, plus
`aria-current="page"` so it is not read as one of a flat list. */
{...(segment.node.value === currentValue
? { "aria-current": "page" as const }
: null)}
className={cn(
"max-w-32 truncate",
segment.node.value === currentValue &&
"text-foreground font-medium"
)}
>
{segment.node.label}
</span>
)}
</React.Fragment>
)),
}
const element = useRender({
defaultTagName: "nav",
render: props.render,
props: mergeProps<"nav">(defaultProps, props),
})
return hidden ? null : element
}
/* -------------------------------------------------------------------------- */
/* Input */
/* -------------------------------------------------------------------------- */
export interface CascaderInputProps extends ComboboxPrimitive.Input.Props {
/** Renders the back control inline, before the field. Defaults to true. */
showBack?: boolean
}
/** Base UI hands its input handlers an event carrying the veto hook. */
type CascaderInputKeyEvent = Parameters<
NonNullable<ComboboxPrimitive.Input.Props["onKeyDown"]>
>[0]
/**
* Search field. MUST render inside the positioner: only there does Base UI skip
* the refill from the committed selection that would fight every level swap.
*/
function CascaderInput({
className,
showBack = true,
placeholder,
onKeyDown,
"aria-describedby": ariaDescribedBy,
...props
}: CascaderInputProps) {
const {
labels,
popLevel,
mode,
getHighlighted,
isBranch,
isDisabled,
navigate,
index,
toggleExpanded,
inline,
invalid,
baseId,
searchScope,
} = useCascaderActions()
const direction = useDirection()
const { currentParent, query, path, expanded, renderedItems, treeRows } =
useCascaderState()
// Root wording under a global search: naming the level on screen would
// promise a narrower search than the one that runs.
const resolvedPlaceholder =
placeholder ??
resolveCascaderSearchLabel(
labels,
searchScope === "global" ? undefined : currentParent?.label
)
// Not `showBack`: `CascaderBack` renders nothing at the root or outside
// drill mode, where the field's own leading `px-1.5` is wanted. Beside the
// button it stacks with the row's `gap-1` into a 10px hole, hence `ps-0`.
const backVisible = showBack && mode === "drill" && path.length > 0
const hintId = `${baseId}-hint`
// Base UI names no list while inline; columns mode moves the real listbox to
// the deepest panel, which is the `path.length` one.
const listId = `${baseId}-column-${mode === "columns" ? path.length : 0}`
// The direction the HINT is worded for. `handleKeyDown` re-reads
// `isCascaderRtl` per keystroke; this is text on screen before any key, so
// the same check runs once against the mounted DOM (on the hint span, so no
// second ref into Base UI's input). SSR renders the "ltr" default and
// corrects on hydration. `direction` is the only dep: a `dir` attribute and
// the stylesheet are declarations, not state, so nothing to resubscribe to.
const hintRef = React.useRef<HTMLSpanElement>(null)
const [hintDir, setHintDir] = React.useState<"ltr" | "rtl">("ltr")
React.useLayoutEffect(() => {
const hint = hintRef.current
if (!hint) return
setHintDir(isCascaderRtl(hint, direction) ? "rtl" : "ltr")
}, [direction])
/**
* Moves the highlight to `targetIndex` in `treeRows`. Base UI has no
* imperative setter (`actionsRef` is `{ unmount }`), so the move is arrow
* presses, sound only because `useListNavigation` fires `onItemHighlighted`
* SYNCHRONOUSLY per keydown. A press that fails to close the gap ends it.
*/
const moveHighlightTo = (field: HTMLInputElement, targetIndex: number) => {
const rowIndex = () => {
const highlighted = getHighlighted()
if (!highlighted) return -1
return treeRows.findIndex((row) => row.node.value === highlighted.value)
}
let current = rowIndex()
for (let step = 0; step < treeRows.length; step += 1) {
if (current === -1 || current === targetIndex) return
const key = current > targetIndex ? "ArrowUp" : "ArrowDown"
field.dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true })
)
const next = rowIndex()
const progressed =
key === "ArrowUp"
? next < current && next >= targetIndex
: next > current && next <= targetIndex
if (next === -1 || !progressed) return
current = next
}
}
/**
* The tree pattern's two level keys; Base UI leaves both alone (list
* navigation is vertical-only, the chip handler no-ops without
* `Combobox.Chips`). Passed in rather than hardcoded because RTL swaps them:
* APG defines them as "toward the children", not as a physical arrow.
*/
const handleTreeKeyDown = (
event: CascaderInputKeyEvent,
field: HTMLInputElement,
caretAtStart: boolean,
caretAtEnd: boolean,
forwardKey: "ArrowLeft" | "ArrowRight",
backKey: "ArrowLeft" | "ArrowRight"
) => {
const forward = event.key === forwardKey
if (!forward && event.key !== backKey) return
if (forward ? !caretAtEnd : !caretAtStart) return
const highlighted = getHighlighted()
if (!highlighted) return
const rowIndex = treeRows.findIndex(
(row) => row.node.value === highlighted.value
)
if (rowIndex < 0) return
const row = treeRows[rowIndex]
if (forward) {
if (!row.branch) return
event.preventDefault()
if (!row.expanded) {
// `navigate`, not `toggleExpanded`: it waits for an unloaded branch's
// children, so the keyboard path in matches the expander.
navigate(row.node)
return
}
// `flattenCascaderTree` emits children right after their parent, so the
// first child is the next row. The depth check covers an empty branch.
const child = treeRows[rowIndex + 1]
if (child && child.depth === row.depth + 1) {
moveHighlightTo(field, rowIndex + 1)
}
return
}
// A query holds every hit's ancestors open without adding them to
// `expanded`, so the toggle would ADD such a row: it would open a disabled
// one for good. That row moves to its parent instead, as a closed one does.
const heldByQuery = isDisabled(row.node) && !expanded.has(row.node.value)
if (row.branch && row.expanded && !heldByQuery) {
event.preventDefault()
toggleExpanded(row.node.value)
return
}
const parentValue = index.parentOf.get(row.node.value)
if (!parentValue) return
const parentIndex = treeRows.findIndex(
(entry) => entry.node.value === parentValue
)
if (parentIndex < 0) return
event.preventDefault()
moveHighlightTo(field, parentIndex)
}
const handleKeyDown = (event: CascaderInputKeyEvent) => {
onKeyDown?.(event)
if (event.defaultPrevented) return
const field = event.currentTarget
const caretAtStart = field.selectionStart === 0 && field.selectionEnd === 0
const caretAtEnd =
field.selectionStart === query.length &&
field.selectionEnd === query.length
// ArrowDown at the END of the list hands real focus to the pinned footer,
// the key a combobox is actually navigated with; Tab still works and still
// skips the scroll area. An EMPTY list hands off at once, and the footer
// owns the way back. Counted from state, so a windowed list still answers.
if (event.key === "ArrowDown" && !event.altKey) {
const rowCount = mode === "tree" ? treeRows.length : renderedItems.length
const lastValue =
mode === "tree"
? treeRows[rowCount - 1]?.node.value
: renderedItems[rowCount - 1]?.value
const highlighted = getHighlighted()
const atEnd =
rowCount === 0 ||
(highlighted != null && highlighted.value === lastValue)
if (atEnd) {
const panel = field.closest<HTMLElement>('[data-slot="cascader-panel"]')
const stop = panel ? getCascaderFooterStops(panel)[0] : undefined
if (stop) {
event.preventDefault()
// Base UI's handler is deliberately NOT vetoed: its wrap's first
// press CLEARS the highlight, so no stale row stays active behind the
// focused command, and the emptied highlight completes the ring.
stop.focus()
return
}
}
}
// The level keys are LOGICAL, not physical: in RTL, ArrowLeft opens a
// branch and ArrowRight goes back. They act only at the caret edge, and
// those guards do NOT mirror: `selectionStart === 0` is the logical start.
const rtl = isCascaderRtl(field, direction)
const forwardKey = rtl ? "ArrowLeft" : "ArrowRight"
const backKey = rtl ? "ArrowRight" : "ArrowLeft"
if (mode === "tree") {
handleTreeKeyDown(
event,
field,
caretAtStart,
caretAtEnd,
forwardKey,
backKey
)
return
}
if (event.key === forwardKey && caretAtEnd) {
const highlighted = getHighlighted()
if (highlighted && isBranch(highlighted)) {
event.preventDefault()
navigate(highlighted)
return
}
}
// Backspace on an empty query pops a level too, so the keyboard way out of
// a level is symmetric with typing into it.
if (
(event.key === backKey && caretAtStart) ||
(event.key === "Backspace" && query.length === 0)
) {
if (path.length > 0) {
event.preventDefault()
popLevel()
}
}
}
return (
<div
data-slot="cascader-input-row"
className={cn(
"flex shrink-0 items-center gap-1",
/* Each style sizes a combobox search field only as a DIRECT child of
the popup; this row is nested, so the ladder is mirrored here rather
than hardcoding one height for all eight styles. */
"h-8"
)}
>
{showBack ? <CascaderBack /> : null}
<ComboboxPrimitive.Input
data-slot="cascader-input"
placeholder={resolvedPlaceholder}
onKeyDown={handleKeyDown}
/* Nothing on screen says the level keys exist. */
aria-describedby={[ariaDescribedBy, hintId].filter(Boolean).join(" ")}
/* Conditional: an explicit `undefined` deletes Base UI's own value. */
{...(mode === "tree" ? { "aria-haspopup": "tree" as const } : null)}
/* Base UI omits BOTH `aria-expanded` and `aria-controls` while inline,
and an embedded panel is permanently expanded, so supply both. */
{...(inline
? { "aria-expanded": true, "aria-controls": listId }
: null)}
/* No trigger to carry the invalid state in the `inline` case. */
{...(invalid ? { "aria-invalid": true, "data-invalid": "" } : null)}
className={cn(
"placeholder:text-muted-foreground h-full w-full min-w-0 flex-1 bg-transparent px-1.5 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
backVisible && "ps-0",
className
)}
{...props}
/>
<span id={hintId} ref={hintRef} className="sr-only">
{/* `hintDir`, not `direction`: the context alone misses an RTL app that
uses a `dir` attribute instead of `DirectionProvider`. */}
{labels.keyboardHint(mode, hintDir)}
</span>
</div>
)
}
/* -------------------------------------------------------------------------- */
/* Value */
/* -------------------------------------------------------------------------- */
export interface CascaderValueProps extends Omit<
useRender.ComponentProps<"span">,
"children"
> {
/** `path` shows the full trail, `leaf` only the node, `count` only a total. */
display?: CascaderValueDisplay
maxSegments?: number
collapse?: CascaderCollapse
separator?: React.ReactNode
/** Renders the selected node's icon before the trail. */
showIcon?: boolean
placeholder?: React.ReactNode
/** Replaces the whole rendering. Receives the resolved selection and path. */
children?: (selected: CascaderNode[], path: CascaderNode[]) => React.ReactNode
}
/**
* Trigger display. Defaults to the collapsed selection path rather than a bare
* leaf label, which in a nested picker is frequently ambiguous.
*/
function CascaderValue({
className,
display = "path",
maxSegments = 3,
collapse = "middle",
separator,
showIcon = true,
placeholder,
children,
...props
}: CascaderValueProps) {
const { labels, multiple, resolveNode } = useCascaderActions()
const { index, selectedValues } = useCascaderState()
// `resolveNode`, not `index.byValue`: a selection missing from `items` (async
// children, a removed item) must still render a label, not an empty trigger.
const selected = selectedValues.map(resolveNode)
const resolvedPath =
selectedValues.length === 1 ? getCascaderPath(index, selectedValues[0]) : []
// No ancestor chain: fall back to the node so `display="path"` shows a leaf.
const path =
resolvedPath.length > 0
? resolvedPath
: selected.length === 1
? selected
: []
let content: React.ReactNode
if (children) {
content = children(selected, path)
} else if (selectedValues.length === 0) {
content = (
<span className="text-muted-foreground truncate">{placeholder}</span>
)
} else if (display === "count" || (multiple && selectedValues.length > 1)) {
content = labels.selectedCount(selectedValues.length)
} else {
const leaf = path[path.length - 1] ?? selected[0]
const segments =
display === "leaf"
? [{ type: "node" as const, node: leaf }]
: collapseCascaderPath(path, { maxSegments, collapse })
content = (
<>
{showIcon && leaf?.icon ? (
<span
data-slot="cascader-value-icon"
className="text-muted-foreground flex shrink-0 items-center"
>
{leaf.icon}
</span>
) : null}
{segments.map((segment, i) => (
<React.Fragment
key={segment.type === "node" ? segment.node.value : `gap-${i}`}
>
{i > 0 ? (separator ?? <PathChevron />) : null}
{segment.type === "ellipsis" ? (
<span
data-slot="cascader-value-ellipsis"
title={segment.hidden
.map((n) => n.label)
.join(` ${labels.pathSeparator} `)}
className="text-muted-foreground shrink-0"
>
&hellip;
</span>
) : (
<span
className={cn(
"truncate",
i < segments.length - 1 && "text-muted-foreground"
)}
>
{segment.node?.label}
</span>
)}
</React.Fragment>
))}
</>
)
}
const defaultProps = {
"data-slot": "cascader-value",
className: cn("flex min-w-0 items-center gap-1 truncate", className),
children: content,
}
return useRender({
defaultTagName: "span",
render: props.render,
props: mergeProps<"span">(defaultProps, props),
})
}
export {
CascaderNav,
CascaderBack,
CascaderBreadcrumb,
CascaderInput,
CascaderValue,
}
Loaded 100 of 311 files, more files were not shown because too many files have changed in this diff. Show more