fix(mcp): подготовить доступ и отзыв разрешений для приёмки
This commit is contained in:
commit
ef7bb79d4e
311 files changed
+73119
No files matched your search
@@ -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: '^_' },
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(/\/$/, '')
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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 {
|
||||
/* результат не важен */
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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: 'Только для администраторов' },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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:')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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>
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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://<host>/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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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"
|
||||
>
|
||||
…
|
||||
</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"
|
||||
>
|
||||
…
|
||||
</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
Reference in new issue
Block a user