21 Commits
Author SHA1 Message Date
Denozordec c3e8ccf7c7 fix(ci): smoke — проверка 404 не должна падать из-за pipefail
quality / changes (push) Successful in 6s
quality / commitlint (push) Skipped
quality / web (push) Successful in 41s
quality / api (push) Successful in 48s
quality / docker-check (push) Successful in 1m6s
CD / quality (push) Successful in 2m44s
CD / publish (push) Successful in 1m54s
2026-10-23 17:57:42 +07:00
Denozordec 7cec86400b fix(ci): восстановить nightly-perf.yaml (контейнерная нагрузка, RSS отдельным шагом)
quality / changes (push) Successful in 7s
quality / commitlint (push) Skipped
quality / web (push) Successful in 45s
quality / api (push) Successful in 53s
quality / docker-check (push) Failing after 27s
CD / quality (push) Failing after 2m14s
CD / publish (push) Skipped
2026-10-23 17:54:09 +07:00
Denozordec eb48c83c7e fix(ci): smoke и nightly обращаются к контейнеру изнутри (раннер сам в контейнере)
nightly-perf.yaml (push) Invalid workflow file
quality / changes (push) Successful in 7s
quality / commitlint (push) Skipped
quality / docker-check (push) Canceled after 0s
quality / api (push) Canceled after 0s
quality / web (push) Canceled after 25s
CD / quality (push) Canceled after 34s
CD / publish (push) Skipped
2026-10-23 17:53:51 +07:00
Denozordec 60645d5773 fix(docker): копировать весь dist API и отключить splitting в tsup (ERR_MODULE_NOT_FOUND на chunk)
quality / changes (push) Successful in 6s
quality / web (push) Skipped
quality / commitlint (push) Skipped
quality / api (push) Successful in 52s
quality / docker-check (push) Failing after 1m7s
CD / quality (push) Failing after 2m8s
CD / publish (push) Skipped
2026-10-23 17:45:36 +07:00
Denozordec 395e37ea7a chore: @types/node через catalog (Node 22 во всём монорепо)
quality / changes (push) Successful in 8s
quality / commitlint (push) Skipped
quality / web (push) Successful in 57s
quality / api (push) Successful in 43s
quality / docker-check (push) Failing after 3m39s
CD / quality (push) Failing after 5m29s
CD / publish (push) Skipped
2026-10-23 17:24:24 +07:00
Denozordec 1a68597d48 perf: prepared statements, CSP, digest pin, вырезание design-preview, нагрузочные бюджеты, jsdom-тесты web, composite CI, SBOM/Trivy 2026-10-23 17:22:23 +07:00
Denozordec 08be310e6b fix(api): CORS через isReturnToAllowed, мягкая проверка JWT_SECRET, 404 для /assets, better-sqlite3 в api, healthcheck на /ready 2026-10-23 17:13:16 +07:00
Denozordec 5cbf4d29d7 ci: покрытие и пороги, тесты shared/api, smoke образа и бюджеты, документация 2026-10-23 17:08:36 +07:00
Denozordec 22dd6f9937 fix(api): защита секретов в production, redact логов, bootstrap без полного скана users 2026-10-23 17:03:45 +07:00
Denozordec 8cba7a5c41 feat(db): онлайн-бэкап SQLite, CLI backup, ротация и восстановление 2026-10-23 17:02:50 +07:00
Denozordec 8298454c63 perf(docker): бандл API, brotli-статика, wget-healthcheck, лимиты ресурсов, вендорные чанки web 2026-10-23 17:01:02 +07:00
Denozordec 1d7ea35a23 perf(api): кэш-заголовки статики, CORS по allowlist, раздельные health/ready, compress в Traefik 2026-10-23 16:55:59 +07:00
Denozordec 30d331c633 perf(api): кэш профиля и конфигурации, лимитер argon2, SHA-256 для client secret, trust proxy 2026-10-23 16:54:05 +07:00
DenozordecandClaude Sonnet 5.5 107eddba9e perf(db): pragma, транзакции, индексы, версия схемы, SQL-фильтры и очистка
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 15:56:25 +07:00
DenozordecandClaude Sonnet 5.5 140ffdb7b1 fix(api): graceful shutdown, единый обработчик ошибок и корректные 404
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 15:53:18 +07:00
DenozordecandClaude Sonnet 5.5 0948455d15 fix(web): привести таблицы к виду MikrotikManager — тулбар, сегменты, шапка и ячейки
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m2s
CD / quality (push) Successful in 1m12s
CD / publish (push) Successful in 1m31s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 14:43:46 +07:00
DenozordecandClaude Sonnet 5.5 e7b34ce6ae fix(web): вернуть нативные токены ReUI и добавить шапку страницы пользователей
quality / changes (push) Successful in 6s
quality / api (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 44s
CD / quality (push) Successful in 52s
CD / publish (push) Successful in 1m21s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 14:33:21 +07:00
DenozordecandClaude Sonnet 5.5 9b05fcf03a feat(web): перенести OIDC, ссылки приложений и пользователей на новый дизайн
quality / changes (push) Successful in 7s
quality / api (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 53s
CD / quality (push) Successful in 1m2s
CD / publish (push) Successful in 1m37s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 14:09:04 +07:00
DenozordecandClaude Sonnet 5.5 69b3553f6d feat(web): перенести страницы Приложения, Аккаунт, Входы и Изменения на новый дизайн
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 14:08:10 +07:00
DenozordecandClaude Sonnet 5.5 148a778ba1 feat(web): оболочка портала на блоках макета — меню, шапка, монитор, палитра ⌘K
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 13:59:35 +07:00
DenozordecandClaude Sonnet 5.5 6898d2255d refactor(web): вынести общие блоки макета в components/portal
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 13:53:59 +07:00
124 changed files with 7774 additions and 2121 deletions

No files matched your search

+1
View File
@@ -42,6 +42,7 @@ package-lock.json
data
*.exe
**/*.test.ts
**/test/**
**/*.spec.ts
coverage
.coverage
+34
View File
@@ -0,0 +1,34 @@
name: setup-pnpm
description: Node 22 + кэш pnpm store / corepack / node_modules / .turbo по хешу lockfile
outputs:
cache-hit:
description: Был ли точный хит кэша (передайте в PNPM_CACHE_HIT для scripts/ci/pnpm-ci.sh)
value: ${{ steps.pnpm-cache.outputs.cache-hit }}
runs:
using: composite
steps:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
- name: Export cache paths
shell: sh
run: sh scripts/ci/export-cache-env.sh
- id: pnpm-hash
shell: bash
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- id: pnpm-cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
${{ env.PNPM_STORE_DIR }}
${{ env.COREPACK_HOME }}
.turbo
node_modules
apps/web/node_modules
apps/api/node_modules
packages/ui/node_modules
packages/shared/node_modules
packages/db/node_modules
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
restore-keys: |
pnpm-${{ runner.os }}-
+2
View File
@@ -129,6 +129,8 @@ jobs:
env:
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
IMAGE_TAG: latest
# provenance + SBOM как attestations образа (только при push в registry)
ATTEST: "true"
VERSION: ${{ steps.meta.outputs.version }}
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
SHA_FULL: ${{ github.sha }}
+60
View File
@@ -0,0 +1,60 @@
# Ночной нагрузочный smoke: образ под лимитами compose + autocannon + бюджеты p99/RPS/RSS.
name: nightly-perf
on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
load:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Build image
working-directory: deploy/docker
env:
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
run: |
set -euxo pipefail
docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl --set 'authportal.output=type=docker' --set 'authportal.tags=authportal:load' authportal
- name: Run container with production limits
run: |
set -euxo pipefail
docker volume create load-data
docker run --rm -v load-data:/data --user root --entrypoint sh authportal:load -c 'chown 1000:1000 /data'
docker run -d --name authportal-load --init --memory 256m --memory-swap 256m --cpus 1.0 \
--read-only --tmpfs /tmp --cap-drop ALL -v load-data:/data \
-e JWT_SECRET=load-jwt-secret-0123456789 -e ADMIN_PASSWORD=load-admin-pass-1 \
-e ADMIN_EMAIL=admin@smoke.local -e ISSUER=http://127.0.0.1:8080 \
-e RATE_LIMIT_MAX=1000000 authportal:load
for i in $(seq 1 40); do
docker exec authportal-load wget -q -O /dev/null http://127.0.0.1:8080/ready && break
sleep 0.5
done
# Раннер сам работает в контейнере: нагрузку гоним из соседнего контейнера
# в сетевом namespace приложения (127.0.0.1:8080 — это приложение).
- name: Load test (autocannon в namespace приложения)
run: |
set -euxo pipefail
docker run --rm -i --network container:authportal-load \
-e LOAD_PASSWORD=load-admin-pass-1 -e LOAD_DURATION_S=15 \
node:22-alpine sh -c 'mkdir /w && cd /w && npm i --silent --no-audit --no-fund autocannon@7.15.0 && cat > load.mjs && node load.mjs http://127.0.0.1:8080' \
< scripts/perf/load.mjs
- name: RSS после нагрузки
run: |
set -euxo pipefail
rss=$(docker stats --no-stream --format '{{.MemUsage}}' authportal-load \
| sed -E 's/^([0-9.]+)([KMG]i?B).*/\1 \2/' \
| awk '{u=$2; v=$1; if(u ~ /^G/) v*=1024; else if(u ~ /^K/) v/=1024; printf "%d", v}')
echo "RSS: ${rss} MiB (budget 200)"
[ "$rss" -le 200 ]
- name: Container logs on failure
if: failure()
run: docker logs --tail 100 authportal-load || true
+29 -74
View File
@@ -110,7 +110,7 @@ jobs:
while IFS= read -r f || [ -n "${f:-}" ]; do
[ -z "${f:-}" ] && continue
case "$f" in
.gitea/workflows/*|scripts/*)
.gitea/workflows/*|.gitea/actions/*|scripts/*)
full_pipeline=true
;;
.cursor/*|.claude/*|.codegraph/*|.agents/*)
@@ -160,38 +160,19 @@ jobs:
timeout-minutes: 15
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
- name: Export cache paths
run: sh scripts/ci/export-cache-env.sh
- id: pnpm-hash
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- id: pnpm-cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
${{ env.PNPM_STORE_DIR }}
${{ env.COREPACK_HOME }}
.turbo
node_modules
apps/web/node_modules
apps/api/node_modules
packages/ui/node_modules
packages/shared/node_modules
packages/db/node_modules
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
restore-keys: |
pnpm-${{ runner.os }}-
- id: setup
uses: ./.gitea/actions/setup-pnpm
- name: pnpm install, lint, typecheck, build
env:
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
PNPM_CACHE_HIT: ${{ steps.setup.outputs.cache-hit }}
run: |
set -euxo pipefail
sh scripts/ci/pnpm-ci.sh
pnpm --filter web lint
pnpm exec turbo run typecheck --filter=web
pnpm exec turbo run build --filter=web
pnpm --filter web test
node scripts/ci/web-budget.mjs apps/web/dist/assets
api:
needs: [changes]
@@ -200,37 +181,16 @@ jobs:
timeout-minutes: 15
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
- name: Export cache paths
run: sh scripts/ci/export-cache-env.sh
- id: pnpm-hash
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- id: pnpm-cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
${{ env.PNPM_STORE_DIR }}
${{ env.COREPACK_HOME }}
.turbo
node_modules
apps/web/node_modules
apps/api/node_modules
packages/ui/node_modules
packages/shared/node_modules
packages/db/node_modules
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
restore-keys: |
pnpm-${{ runner.os }}-
- id: setup
uses: ./.gitea/actions/setup-pnpm
- name: pnpm install, lint, test, typecheck, build
env:
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
PNPM_CACHE_HIT: ${{ steps.setup.outputs.cache-hit }}
run: |
set -euxo pipefail
sh scripts/ci/pnpm-ci.sh
pnpm exec turbo run lint --filter=@authportal/api --filter=@authportal/db --filter=@authportal/shared
pnpm exec turbo run test --filter=@authportal/api
pnpm exec turbo run test:coverage --filter=@authportal/api --filter=@authportal/db --filter=@authportal/shared
pnpm exec turbo run typecheck --filter=@authportal/api
pnpm exec turbo run build --filter=@authportal/api
@@ -242,33 +202,13 @@ jobs:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
- name: Export cache paths
run: sh scripts/ci/export-cache-env.sh
- id: pnpm-hash
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- id: pnpm-cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
${{ env.PNPM_STORE_DIR }}
${{ env.COREPACK_HOME }}
node_modules
apps/web/node_modules
apps/api/node_modules
packages/ui/node_modules
packages/shared/node_modules
packages/db/node_modules
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
restore-keys: |
pnpm-${{ runner.os }}-
- id: setup
uses: ./.gitea/actions/setup-pnpm
- name: Lint commit messages
env:
BASE_SHA: ${{ inputs.base_sha }}
HEAD_SHA: ${{ inputs.head_sha }}
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
PNPM_CACHE_HIT: ${{ steps.setup.outputs.cache-hit }}
run: |
set -euxo pipefail
sh scripts/ci/pnpm-ci.sh
@@ -276,7 +216,9 @@ jobs:
docker-check:
needs: [changes]
if: inputs.is_pull_request && needs.changes.outputs.docker == 'true'
# Образ собирается из api/web/deploy: проверяем и на push в main (до публикации),
# чтобы сломанный Dockerfile не обнаруживался уже после создания тега релиза.
if: needs.changes.outputs.docker == 'true' || needs.changes.outputs.api == 'true' || needs.changes.outputs.web == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
@@ -310,3 +252,16 @@ jobs:
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
export CACHE_REF_NODE="git.shx.one/${owner_lc}/authportal-buildcache:node-buildcache"
docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl default
- name: build image + smoke test (ресурсы, graceful stop, сохранность данных)
working-directory: deploy/docker
env:
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
run: |
set -euxo pipefail
docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl --set 'authportal.output=type=docker' --set 'authportal.tags=authportal:smoke' authportal
bash "${{ github.workspace }}/scripts/ci/smoke-image.sh" authportal:smoke
- name: trivy (HIGH/CRITICAL; информативно, пайплайн не блокирует)
continue-on-error: true
run: |
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:0.58.1 image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 0 authportal:smoke
+2
View File
@@ -36,3 +36,5 @@ version.txt
.release-version
CHANGELOG.md
.claude/settings.local.json
coverage
+11 -9
View File
@@ -5,38 +5,40 @@
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsup src/server.ts --format esm --dts",
"build": "tsup",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"start": "node dist/server.js",
"test": "vitest run --passWithNoTests"
"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/sensible": "^6.0.3",
"@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",
"fastify-plugin": "^5.0.1",
"jose": "^6.2.8",
"zod": "catalog:"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^22.15.32",
"eslint": "^10.7.0",
"globals": "^17.7.0",
"@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": "^8.64.0",
"typescript-eslint": "catalog:",
"vitest": "catalog:"
}
}
+165 -36
View File
@@ -1,9 +1,9 @@
import type { FastifyInstance } from 'fastify'
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 sensible from '@fastify/sensible'
import formbody from '@fastify/formbody'
import fjwt from '@fastify/jwt'
import fastifyStatic from '@fastify/static'
@@ -14,8 +14,12 @@ import {
allPermissionKeys,
} from '@authportal/shared'
import {
closeDb,
createDb,
getAppSwitcherConfig,
getUserApps,
hasAnyUser,
listAdmins,
healthCheck,
migrateSchema,
setUserAccess,
@@ -23,9 +27,13 @@ import {
type AppDb,
type Sqlite,
} from '@authportal/db'
import { hash } from '@node-rs/argon2'
import { hashPassword, setPasswordConcurrency } from './lib/password.js'
import { randomUUID } from 'node:crypto'
import type { AppConfig } from './config.js'
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'
@@ -33,7 +41,22 @@ 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 { ensureOidcSigningKey, resetOidcKeyCache } from './lib/oidc/keys.js'
import { startHousekeepingJob } from './services/housekeeping.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 {
@@ -43,11 +66,11 @@ declare module 'fastify' {
}
}
async function ensureBootstrapAdmin(app: FastifyInstance): Promise<void> {
const existing = app.db.select().from(users).all()
if (existing.length === 0) {
export async function ensureBootstrapAdmin(app: FastifyInstance): Promise<void> {
if (!hasAnyUser(app.db)) {
assertBootstrapPassword(app.config)
const now = new Date().toISOString()
const passwordHash = await hash(app.config.adminPassword)
const passwordHash = await hashPassword(app.config.adminPassword)
const id = randomUUID()
app.db
.insert(users)
@@ -69,8 +92,7 @@ async function ensureBootstrapAdmin(app: FastifyInstance): Promise<void> {
}
// Ensure existing admins have app tiles if access was never set
for (const user of existing) {
if (!user.isAdmin) continue
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())
@@ -91,32 +113,112 @@ export async function buildApp(opts: {
mkdirSync(dirname(resolve(path)), { recursive: true })
}
setPasswordConcurrency(config.argon2Concurrency)
const { db, sqlite } = createDb(dbUrl)
migrateSchema(sqlite)
const app = Fastify({
logger: { level: config.logLevel },
trustProxy: true,
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)
await app.register(sensible)
await app.register(formbody)
await app.register(cors, { origin: true, credentials: true })
await app.register(helmet, { contentSecurityPolicy: false })
await app.register(rateLimit, { max: 200, timeWindow: '1 minute' })
await app.register(fjwt, { secret: config.jwtSecret })
app.get('/health', async () => {
healthCheck(sqlite)
return { ok: true }
app.addHook('onClose', async () => {
closeDb(sqlite)
})
app.get('/ready', async () => {
healthCheck(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 }
})
@@ -134,28 +236,55 @@ export async function buildApp(opts: {
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,
// Traefik перед приложением не сжимает; .gz/.br для статики готовит
// Dockerfile на этапе сборки (find … gzip/brotli), здесь только отдаём.
// .gz/.br для статики готовит Dockerfile на этапе сборки, здесь только отдаём.
preCompressed: true,
})
app.setNotFoundHandler((req, reply) => {
if (req.method === 'GET' && !req.url.startsWith('/api')) {
return reply.sendFile('index.html')
}
return reply
.status(404)
.send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
// 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
}
+77 -1
View File
@@ -5,6 +5,17 @@ const boolFromEnv = (v: string | undefined, fallback: boolean) => {
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),
@@ -22,6 +33,26 @@ export const configSchema = z.object({
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(),
webauthnRpID: z.string().min(1),
@@ -31,6 +62,39 @@ export const configSchema = z.object({
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')
@@ -40,7 +104,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
const issuer = env.ISSUER ?? 'https://auth.shnt.top'
const { rpID, origins } = webauthnFromIssuer(issuer, env, isProd)
return configSchema.parse({
const config = configSchema.parse({
databaseUrl: env.DATABASE_URL ?? 'sqlite:data/app.db',
jwtSecret,
jwtTtlHours: env.JWT_TTL_HOURS ?? 1,
@@ -56,6 +120,16 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
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,
@@ -64,6 +138,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
webauthnRpName: env.WEBAUTHN_RP_NAME || 'Auth Portal',
webauthnOrigins: origins,
})
assertProductionSecrets(config)
return config
}
function webauthnFromIssuer(
+1 -4
View File
@@ -5,10 +5,7 @@ import {
} from '@authportal/db'
export function clientIp(request: FastifyRequest): string | null {
const forwarded = request.headers['x-forwarded-for']
if (typeof forwarded === 'string' && forwarded.trim()) {
return forwarded.split(',')[0]?.trim() ?? null
}
// request.ip учитывает X-Forwarded-For только от доверенных прокси (TRUST_PROXY).
return request.ip ?? null
}
+51
View File
@@ -0,0 +1,51 @@
/** Мини-кэш с TTL и вытеснением по LRU (Map сохраняет порядок вставки). */
export class TtlCache<K, V> {
private readonly map = new Map<K, { value: V; expires: number }>()
constructor(
private readonly opts: {
max: number
ttlMs: number
now?: () => number
},
) {}
private now(): number {
return (this.opts.now ?? Date.now)()
}
get(key: K): V | undefined {
const hit = this.map.get(key)
if (!hit) return undefined
if (hit.expires <= this.now()) {
this.map.delete(key)
return undefined
}
// освежаем позицию для LRU
this.map.delete(key)
this.map.set(key, hit)
return hit.value
}
set(key: K, value: V): void {
this.map.delete(key)
this.map.set(key, { value, expires: this.now() + this.opts.ttlMs })
while (this.map.size > this.opts.max) {
const oldest = this.map.keys().next()
if (oldest.done) break
this.map.delete(oldest.value)
}
}
delete(key: K): void {
this.map.delete(key)
}
clear(): void {
this.map.clear()
}
get size(): number {
return this.map.size
}
}
+35
View File
@@ -0,0 +1,35 @@
import { createHash, timingSafeEqual } from 'node:crypto'
import { verifyPassword } from './password.js'
const PREFIX = 'sha256:'
/**
* Секреты OIDC-клиентов — 32 случайных байта (высокая энтропия), поэтому
* медленный argon2 не нужен: SHA-256 + сравнение за постоянное время.
*/
export function hashClientSecret(secret: string): string {
return PREFIX + createHash('sha256').update(secret).digest('hex')
}
export type ClientSecretCheck = { ok: boolean; needsRehash: boolean }
export async function checkClientSecret(
stored: string,
secret: string,
): Promise<ClientSecretCheck> {
if (stored.startsWith(PREFIX)) {
const a = Buffer.from(stored)
const b = Buffer.from(hashClientSecret(secret))
return {
ok: a.length === b.length && timingSafeEqual(a, b),
needsRehash: false,
}
}
// Устаревший формат (argon2): проверяем и просим перехешировать при успехе.
try {
const ok = await verifyPassword(stored, secret)
return { ok, needsRehash: ok }
} catch {
return { ok: false, needsRehash: false }
}
}
+27
View File
@@ -0,0 +1,27 @@
/** Значение cookie без построения RegExp на каждый запрос. */
export function parseCookie(
header: string | undefined,
name: string,
): string | null {
if (!header) return null
let from = 0
while (from < header.length) {
let end = header.indexOf(';', from)
if (end === -1) end = header.length
const eq = header.indexOf('=', from)
if (eq !== -1 && eq < end) {
const key = header.slice(from, eq).trim()
if (key === name) {
const raw = header.slice(eq + 1, end).trim()
if (!raw) return null
try {
return decodeURIComponent(raw)
} catch {
return raw
}
}
}
from = end + 1
}
return null
}
+50
View File
@@ -0,0 +1,50 @@
import { isReturnToAllowed } from '@authportal/shared'
import type { AppConfig } from '../config.js'
import { oidcIssuerFromConfig } from '../config.js'
function originOf(url: string): string | null {
try {
return new URL(url).origin
} catch {
return null
}
}
/**
* Проверка Origin для CORS с credentials. Разрешены: CORS_ORIGINS, issuer,
* приложения из App Switcher и всё, что разрешает RETURN_TO_ALLOWLIST
* (та же isReturnToAllowed, что для return_to: ".suffix", localhost, полные origin, private). '*' в CORS_ORIGINS —
* разрешить всё (только осознанно).
*/
export function createOriginChecker(
config: AppConfig,
switcherUrls: () => string[],
): (origin: string) => boolean {
const explicit = new Set<string>()
let allowAll = false
for (const raw of config.corsOrigins.split(',')) {
const v = raw.trim()
if (!v) continue
if (v === '*') allowAll = true
else {
const o = originOf(v)
if (o) explicit.add(o)
}
}
for (const u of [config.issuer, oidcIssuerFromConfig(config)]) {
const o = originOf(u)
if (o) explicit.add(o)
}
return (origin: string) => {
if (allowAll) return true
const o = originOf(origin)
if (!o) return false
if (explicit.has(o)) return true
// Тот же allowlist, что у return_to (.suffix, localhost, полные origin, private).
if (isReturnToAllowed(o, config.returnToAllowlist)) return true
for (const u of switcherUrls()) {
if (originOf(u) === o) return true
}
return false
}
}
+8 -5
View File
@@ -12,17 +12,20 @@ import {
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 {
const apps = getUserApps(app.db, user.id)
let permissions = normalizePermissionKeys(getUserPermissions(app.db, user.id))
if (user.isAdmin) {
permissions = allPermissionKeys()
}
// Профиль из кэша (инвалидируется записями в 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)
+22
View File
@@ -113,10 +113,16 @@ export async function ensureOidcSigningKey(
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) {
@@ -157,6 +163,17 @@ 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 {
@@ -174,6 +191,11 @@ export async function buildJwks(
return { keys }
}
/** Принудительный сброс JWKS (после ручных правок ключей в БД). */
export function invalidateJwks(app: FastifyInstance): void {
jwksCache.delete(app.db)
}
export async function signOidcJwt(
app: FastifyInstance,
claims: Record<string, unknown>,
+58
View File
@@ -0,0 +1,58 @@
import { hash, verify } from '@node-rs/argon2'
/**
* argon2 держит ~19 МиБ на вызов и занимает поток libuv; без ограничителя
* шквал логинов раздувает RSS. Семафор ограничивает число одновременных хешей.
*/
let limit = 2
let active = 0
const waiters: Array<() => void> = []
export function setPasswordConcurrency(n: number): void {
limit = Math.max(1, Math.floor(n))
}
export function passwordHashStats(): { active: number; waiting: number } {
return { active, waiting: waiters.length }
}
async function withSlot<T>(fn: () => Promise<T>): Promise<T> {
if (active >= limit) {
await new Promise<void>((resolve) => waiters.push(resolve))
} else {
active += 1
}
try {
return await fn()
} finally {
const next = waiters.shift()
if (next) next() // слот передаётся ожидающему без изменения active
else active -= 1
}
}
export function hashPassword(password: string): Promise<string> {
return withSlot(() => hash(password))
}
export function verifyPassword(
passwordHash: string,
password: string,
): Promise<boolean> {
return withSlot(() => verify(passwordHash, password))
}
let dummyHash: Promise<string> | null = null
/**
* Для неизвестного/отключённого пользователя выполняем настоящую проверку
* по фиктивному хешу: время ответа не выдаёт существование учётки.
*/
export async function verifyDummyPassword(password: string): Promise<void> {
dummyHash ??= hash('dummy-password-for-timing')
try {
await withSlot(async () => verify(await dummyHash!, password))
} catch {
/* результат не важен */
}
}
+67
View File
@@ -0,0 +1,67 @@
import type { FastifyInstance } from 'fastify'
export interface ShutdownOptions {
timeoutMs: number
exit?: (code: number) => void
signals?: NodeJS.Signals[]
proc?: Pick<NodeJS.Process, 'on' | 'off'>
}
/**
* SIGTERM/SIGINT → app.close() (дозавершает запросы, onClose закрывает БД),
* принудительный выход по таймауту, повторный сигнал — немедленный выход.
* Возвращает функцию отписки и ручной триггер (для тестов).
*/
export function registerShutdown(app: FastifyInstance, opts: ShutdownOptions) {
const exit = opts.exit ?? ((code: number) => process.exit(code))
const proc = opts.proc ?? process
const signals = opts.signals ?? ['SIGTERM', 'SIGINT']
let closing: Promise<void> | null = null
const shutdown = (reason: string, code = 0): Promise<void> => {
if (closing) {
app.log.warn(`shutdown: repeated ${reason}, forcing exit`)
exit(1)
return closing
}
app.log.info(`shutdown: ${reason}`)
const timer = setTimeout(() => {
app.log.error('shutdown: timeout, forcing exit')
exit(1)
}, opts.timeoutMs)
timer.unref()
closing = app
.close()
.then(() => {
clearTimeout(timer)
exit(code)
})
.catch((err: unknown) => {
clearTimeout(timer)
app.log.error(err, 'shutdown: close failed')
exit(1)
})
return closing
}
const handlers = signals.map((sig) => {
const h = () => void shutdown(sig)
proc.on(sig, h)
return [sig, h] as const
})
const onFatal = (err: unknown) => {
app.log.error(err, 'fatal error')
void shutdown('fatal', 1)
}
proc.on('unhandledRejection', onFatal)
proc.on('uncaughtException', onFatal)
return {
shutdown,
dispose() {
for (const [sig, h] of handlers) proc.off(sig, h)
proc.off('unhandledRejection', onFatal)
proc.off('uncaughtException', onFatal)
},
}
}
+70
View File
@@ -0,0 +1,70 @@
import {
getUserApps,
getUserById,
getUserDataGeneration,
getUserPermissions,
type AppDb,
type UserRow,
} from '@authportal/db'
import { allPermissionKeys, normalizePermissionKeys } from '@authportal/shared'
import { TtlCache } from './cache.js'
export type UserProfile = {
row: UserRow
/** Сырые app_id из БД. */
apps: string[]
/** Нормализованные права; у админа — полный каталог. */
permissions: string[]
}
type Entry = { profile: UserProfile; generation: number }
/** Страховочный TTL: основная инвалидация — по счётчику поколения из @authportal/db. */
const PROFILE_TTL_MS = 10_000
const PROFILE_MAX = 500
const caches = new WeakMap<AppDb, TtlCache<string, Entry>>()
function cacheFor(db: AppDb): TtlCache<string, Entry> {
let c = caches.get(db)
if (!c) {
c = new TtlCache({ max: PROFILE_MAX, ttlMs: PROFILE_TTL_MS })
caches.set(db, c)
}
return c
}
export function loadUserProfile(db: AppDb, id: string): UserProfile | undefined {
const row = getUserById(db, id)
if (!row) return undefined
return profileFromRow(db, row)
}
function profileFromRow(db: AppDb, row: UserRow): UserProfile {
const apps = getUserApps(db, row.id)
const permissions = row.isAdmin
? allPermissionKeys()
: normalizePermissionKeys(getUserPermissions(db, row.id))
return { row, apps, permissions }
}
/**
* Профиль пользователя (строка + приложения + права) с кэшем: 3 SELECT на
* запрос → 0 пока данные пользователей не менялись.
*/
export function getUserProfile(
db: AppDb,
id: string,
): UserProfile | undefined {
const cache = cacheFor(db)
const generation = getUserDataGeneration()
const hit = cache.get(id)
if (hit && hit.generation === generation) return hit.profile
const profile = loadUserProfile(db, id)
if (!profile) {
cache.delete(id)
return undefined
}
cache.set(id, { profile, generation })
return profile
}
+14 -27
View File
@@ -1,12 +1,8 @@
import type { FastifyReply, FastifyRequest } from 'fastify'
import {
getUserApps,
getUserById,
getUserPermissions,
type UserRow,
} from '@authportal/db'
import type { UserRow } from '@authportal/db'
import type { AppId, MeResponse } from '@authportal/shared'
import { APP_IDS, allPermissionKeys, normalizePermissionKeys } from '@authportal/shared'
import { APP_IDS } from '@authportal/shared'
import { getUserProfile, type UserProfile } from '../lib/user-profile.js'
export type AuthUser = {
id: string
@@ -67,24 +63,15 @@ export function toMe(user: UserRow, apps: string[], permissions: string[]): MeRe
}
}
export function loadAuthUser(
request: FastifyRequest,
user: UserRow,
): AuthUser {
const apps = getUserApps(request.server.db, user.id)
let permissions = normalizePermissionKeys(
getUserPermissions(request.server.db, user.id),
)
if (user.isAdmin) {
permissions = allPermissionKeys()
}
export function authUserFromProfile(profile: UserProfile): AuthUser {
const { row } = profile
return {
id: user.id,
email: user.email,
name: user.name,
isAdmin: user.isAdmin,
apps: asAppIds(apps),
permissions,
id: row.id,
email: row.email,
name: row.name,
isAdmin: row.isAdmin,
apps: asAppIds(profile.apps),
permissions: profile.permissions,
}
}
@@ -101,14 +88,14 @@ export async function requireAuth(
}
const sub = request.user.sub
const row = getUserById(request.server.db, sub)
if (!row || row.disabled) {
const profile = getUserProfile(request.server.db, sub)
if (!profile || profile.row.disabled) {
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
})
}
request.authUser = loadAuthUser(request, row)
request.authUser = authUserFromProfile(profile)
}
export async function requireAdmin(
+16 -11
View File
@@ -1,5 +1,6 @@
import type { FastifyInstance } from 'fastify'
import { hash } from '@node-rs/argon2'
import { hashPassword } from '../lib/password.js'
import { hashClientSecret } from '../lib/client-secret.js'
import {
countWebauthnCredentials,
countWebauthnCredentialsBatch,
@@ -27,6 +28,7 @@ import {
setUserAccess,
updateOidcClient,
updateUser,
withTransaction,
} from '@authportal/db'
import {
APP_IDS,
@@ -168,14 +170,17 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
})
}
const passwordHash = await hash(data.password)
const user = createUser(app.db, {
email: data.email,
name: data.name,
passwordHash,
isAdmin: data.is_admin,
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
})
setUserAccess(app.db, user.id, data.apps, permissions)
const mapped = mapUser(app.db, getUserById(app.db, user.id)!)
safeAudit(app, {
action: 'user.create',
@@ -218,7 +223,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
}
const passwordHash = parsed.data.password
? await hash(parsed.data.password)
? await hashPassword(parsed.data.password)
: undefined
const updated = updateUser(app.db, request.params.id, {
email: parsed.data.email,
@@ -463,7 +468,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
})
}
const secret = generateOidcClientSecret()
const secretHash = await hash(secret)
const secretHash = hashClientSecret(secret)
const row = createOidcClient(app.db, {
name: parsed.data.name,
clientSecretHash: secretHash,
@@ -545,7 +550,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
})
}
const secret = generateOidcClientSecret()
const secretHash = await hash(secret)
const secretHash = hashClientSecret(secret)
const row = updateOidcClient(app.db, request.params.id, {
clientSecretHash: secretHash,
})!
+24 -28
View File
@@ -1,5 +1,4 @@
import type { FastifyInstance } from 'fastify'
import { verify } from '@node-rs/argon2'
import {
PERMISSION_CATALOG,
appsMetaFromSwitcher,
@@ -11,9 +10,11 @@ import {
getAppSwitcherConfig,
getUserByEmail,
getUserById,
listUsers,
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'
@@ -22,11 +23,14 @@ 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 () => ({
return_to_allowlist: app.config.returnToAllowlist,
issuer: app.config.issuer,
webauthn: true,
}))
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' } },
@@ -44,6 +48,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
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',
@@ -63,7 +68,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
})
}
const ok = await verify(user.passwordHash, password)
const ok = await verifyPassword(user.passwordHash, password)
if (!ok) {
safeAudit(app, {
action: 'auth.login_failed',
@@ -163,11 +168,13 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
},
)
app.post('/api/v1/auth/logout', async (request, reply) => {
const cookie = request.headers.cookie ?? ''
const match = cookie.match(new RegExp(`${REFRESH_COOKIE}=([^;]+)`))
if (match?.[1]) {
revokeRefreshSession(app.db, match[1])
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',
@@ -205,9 +212,11 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
})
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)),
)
@@ -237,20 +246,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
)
.trim()
.toLowerCase()
const all = listUsers(app.db).filter((u) => !u.disabled)
const filtered = q
? all.filter(
(u) =>
u.email.toLowerCase().includes(q) ||
u.name.toLowerCase().includes(q) ||
u.id.toLowerCase().includes(q),
)
: all
return filtered.slice(0, 50).map((u) => ({
id: u.id,
email: u.email,
name: u.name,
}))
return searchDirectoryUsers(app.db, q, 50)
},
)
+21 -25
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from 'fastify'
import { appendAudit } from '@authportal/db'
import { appendAuditBatch } from '@authportal/db'
import {
ingestAuditRequestSchema,
type AuditSourceApp,
@@ -45,30 +45,26 @@ export async function auditIngestRoutes(app: FastifyInstance): Promise<void> {
})
}
let accepted = 0
let duplicates = 0
for (const event of parsed.data.events) {
try {
const inserted = appendAudit(app.db, {
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 (inserted) accepted += 1
else duplicates += 1
} catch (err) {
app.log.warn({ err, event_id: event.event_id }, 'ingest append failed')
}
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 }
+23 -18
View File
@@ -1,7 +1,7 @@
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
import { verify } from '@node-rs/argon2'
import {
updateOidcClient,
createOidcAuthCode,
consumeOidcAuthCode,
getOidcClientByClientId,
@@ -19,6 +19,8 @@ import {
} 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,
@@ -33,15 +35,6 @@ const CODE_TTL_MS = 5 * 60 * 1000
const ACCESS_TTL_SEC = 3600
const ID_TOKEN_TTL_SEC = 3600
function cookieValue(
cookieHeader: string | undefined,
name: string,
): string | null {
if (!cookieHeader) return null
const match = cookieHeader.match(new RegExp(`${name}=([^;]+)`))
return match?.[1] ? decodeURIComponent(match[1]) : null
}
function parseBasicAuth(
header: string | undefined,
): { clientId: string; clientSecret: string } | null {
@@ -60,14 +53,22 @@ function parseBasicAuth(
}
async function authenticateClient(
app: FastifyInstance,
client: OidcClientRow,
secret: string,
): Promise<boolean> {
try {
return await verify(client.clientSecretHash, secret)
} catch {
return false
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(
@@ -94,7 +95,7 @@ function resolveSessionUser(
app: FastifyInstance,
request: FastifyRequest,
): UserRow | undefined {
const raw = cookieValue(request.headers.cookie, REFRESH_COOKIE)
const raw = parseCookie(request.headers.cookie, REFRESH_COOKIE)
if (!raw) return undefined
return getUserByRefreshToken(app.db, raw)
}
@@ -171,7 +172,9 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
return buildJwks(app)
})
app.get('/oauth/authorize', async (request, reply) => {
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
@@ -339,7 +342,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
error_description: 'Unknown or disabled client',
})
}
const secretOk = await authenticateClient(client, clientSecret)
const secretOk = await authenticateClient(app, client, clientSecret)
if (!secretOk) {
return reply.status(401).send({
error: 'invalid_client',
@@ -420,7 +423,9 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
},
})
app.get('/oauth/userinfo', async (request, reply) => {
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({
+21
View File
@@ -2,6 +2,7 @@ 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'),
@@ -29,8 +30,28 @@ for (const path of [
}
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}`)
+43
View File
@@ -0,0 +1,43 @@
import { dirname, join, resolve } from 'node:path'
import type { FastifyInstance } from 'fastify'
import { backupDatabase, resolveDatabasePath, type Sqlite } from '@authportal/db'
import type { AppConfig } from '../config.js'
export function backupDirFor(config: AppConfig, databaseUrl = config.databaseUrl) {
if (config.backupDir) return resolve(config.backupDir)
const path = resolveDatabasePath(databaseUrl)
return join(dirname(resolve(path)), 'backups')
}
export async function runBackup(
sqlite: Sqlite,
config: AppConfig,
databaseUrl = config.databaseUrl,
) {
return backupDatabase(sqlite, {
dir: backupDirFor(config, databaseUrl),
keep: config.backupKeep,
})
}
/** Периодический бэкап; выключен при BACKUP_INTERVAL_HOURS=0. */
export function startBackupJob(app: FastifyInstance): () => void {
const hours = app.config.backupIntervalHours
if (hours <= 0 || app.sqlite.memory) return () => {}
let running = false
const run = async () => {
if (running) return
running = true
try {
const res = await runBackup(app.sqlite, app.config)
app.log.info({ file: res.file, removed: res.removed.length }, 'db backup')
} catch (err) {
app.log.error({ err }, 'db backup failed')
} finally {
running = false
}
}
const interval = setInterval(() => void run(), hours * 60 * 60 * 1000)
interval.unref()
return () => clearInterval(interval)
}
+28
View File
@@ -0,0 +1,28 @@
import type { FastifyInstance } from 'fastify'
import { runHousekeeping } from '@authportal/db'
const HOUR_MS = 60 * 60 * 1000
/** Часовая очистка сессий, кодов авторизации и WebAuthn-челленджей. */
export function startHousekeepingJob(app: FastifyInstance): () => void {
const run = () => {
try {
const res = runHousekeeping(app.db, {
retentionDays: app.config.sessionRetentionDays,
})
if (res.sessions + res.authCodes + res.challenges > 0) {
app.log.info(res, 'housekeeping purge')
}
} catch (err) {
app.log.warn({ err }, 'housekeeping job failed')
}
}
const initial = setTimeout(run, 30_000)
const interval = setInterval(run, HOUR_MS)
initial.unref()
interval.unref()
return () => {
clearTimeout(initial)
clearInterval(interval)
}
}
+431
View File
@@ -0,0 +1,431 @@
import { createHash } from 'node:crypto'
import { afterEach, describe, expect, it } from 'vitest'
import { hash } from '@node-rs/argon2'
import { createOidcClient, generateOidcClientSecret } from '@authportal/db'
import { adminToken, buildTestApp } from './helpers.js'
type App = Awaited<ReturnType<typeof buildTestApp>>
let app: App | undefined
afterEach(async () => {
await app?.close()
app = undefined
})
const auth = (token: string) => ({ authorization: `Bearer ${token}` })
async function login(a: App, email = 'admin@test.local', 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, 'admin@test.local', 'wrong')).statusCode).toBe(401)
expect((await login(app, 'ghost@test.local', 'whatever')).statusCode).toBe(401)
const created = await app.inject({
method: 'POST',
url: '/api/v1/admin/users',
headers: auth(token),
payload: {
email: 'off@test.local',
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, 'off@test.local', '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('admin@test.local')
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: 'dup@test.local',
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, 'dup@test.local', 'newsecret12')).statusCode).toBe(200)
expect((await login(app, 'dup@test.local', '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: 'plain@test.local',
name: 'Plain',
password: 'secret12',
is_admin: false,
apps: ['cfdm'],
permissions: [],
},
})
const userTok = ((await login(app, 'plain@test.local', '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('admin@test.local')
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)
})
})
+82
View File
@@ -0,0 +1,82 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { listBackups } from '@authportal/db'
import { loadConfig } from '../src/config.js'
import { backupDirFor, runBackup, startBackupJob } from '../src/services/backup.js'
import { buildApp } from '../src/app.js'
import { adminToken } from './helpers.js'
const dirs: string[] = []
afterEach(() => {
vi.useRealTimers()
for (const d of dirs.splice(0)) {
try {
rmSync(d, { recursive: true, force: true })
} catch {
/* Windows может держать файл БД до GC — временный каталог не критичен */
}
}
})
function env(dir: string, extra: NodeJS.ProcessEnv = {}) {
return loadConfig({
JWT_SECRET: 'test-secret-at-least-8',
ADMIN_EMAIL: 'admin@test.local',
ADMIN_PASSWORD: 'adminpass',
DATABASE_URL: `sqlite:${join(dir, 'app.db')}`,
NODE_ENV: 'test',
...extra,
})
}
describe('backup', () => {
it('backupDirFor: по умолчанию рядом с БД, BACKUP_DIR приоритетнее', () => {
const cfg = env('/data/x')
expect(backupDirFor(cfg).replaceAll(String.fromCharCode(92), '/')).toMatch(/\/data\/x\/backups$/)
const custom = env('/data/x', { BACKUP_DIR: '/mnt/bk' })
expect(backupDirFor(custom).replaceAll(String.fromCharCode(92), '/')).toMatch(/\/mnt\/bk$/)
})
it('бэкап живого приложения восстанавливается: вход админа работает', async () => {
const dir = mkdtempSync(join(tmpdir(), 'ap-api-bk-'))
dirs.push(dir)
const config = env(dir, { BACKUP_KEEP: '2' })
const app = await buildApp({ config })
const res = await runBackup(app.sqlite, config)
expect(listBackups(backupDirFor(config))).toHaveLength(1)
await app.close()
const restored = await buildApp({
config: env(dir),
databaseUrl: `sqlite:${res.file}`,
})
const token = await adminToken(restored as never)
expect(token).toBeTruthy()
await restored.close()
})
it('startBackupJob выключен при 0 часов и работает по таймеру', async () => {
vi.useFakeTimers()
const dir = mkdtempSync(join(tmpdir(), 'ap-job-bk-'))
dirs.push(dir)
const config = env(dir, { BACKUP_INTERVAL_HOURS: '1' })
const app = await buildApp({ config })
const base = vi.getTimerCount()
const off = startBackupJob({ ...app, config: { ...config, backupIntervalHours: 0 } } as never)
expect(vi.getTimerCount()).toBe(base)
off()
const stop = startBackupJob(app)
expect(vi.getTimerCount()).toBe(base + 1)
await vi.advanceTimersByTimeAsync(60 * 60 * 1000 + 10)
vi.useRealTimers()
await vi.waitFor(() =>
expect(listBackups(backupDirFor(config)).length).toBeGreaterThan(0),
)
stop()
await app.close()
})
})
+150
View File
@@ -0,0 +1,150 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Writable } from 'node:stream'
import Fastify from 'fastify'
import { assertBootstrapPassword, loadConfig } from '../src/config.js'
import { buildApp } from '../src/app.js'
const base = { JWT_SECRET: 'a-sufficiently-long-secret-123' }
describe('loadConfig', () => {
it('значения по умолчанию для новых параметров', () => {
const c = loadConfig({ ...base, NODE_ENV: 'test' })
expect(c.shutdownTimeoutMs).toBe(8000)
expect(c.sessionRetentionDays).toBe(7)
expect(c.argon2Concurrency).toBe(2)
expect(c.backupKeep).toBe(7)
expect(c.backupIntervalHours).toBe(0)
expect(c.corsOrigins).toBe('')
expect(c.trustProxy).toContain('10.0.0.0/8')
})
it('читает переопределения из env', () => {
const c = loadConfig({
...base,
NODE_ENV: 'test',
SHUTDOWN_TIMEOUT_MS: '1500',
SESSION_RETENTION_DAYS: '30',
ARGON2_CONCURRENCY: '4',
BACKUP_KEEP: '3',
BACKUP_INTERVAL_HOURS: '12',
CORS_ORIGINS: 'https://a.example',
TRUST_PROXY: 'false',
})
expect(c).toMatchObject({
shutdownTimeoutMs: 1500,
sessionRetentionDays: 30,
argon2Concurrency: 4,
backupKeep: 3,
backupIntervalHours: 12,
corsOrigins: 'https://a.example',
trustProxy: 'false',
})
})
it.each([
['SHUTDOWN_TIMEOUT_MS', '0'],
['ARGON2_CONCURRENCY', '-1'],
['BACKUP_KEEP', 'abc'],
['BACKUP_INTERVAL_HOURS', '-5'],
])('невалидный %s=%s → ошибка', (key, value) => {
expect(() => loadConfig({ ...base, NODE_ENV: 'test', [key]: value })).toThrow()
})
})
describe('production guard', () => {
it.each(['change-me-in-production', 'dev-secret-change-me'])(
'известный дефолт JWT_SECRET "%s" отклоняется',
(secret) => {
expect(() =>
loadConfig({ NODE_ENV: 'production', JWT_SECRET: secret }),
).toThrow(/JWT_SECRET/)
},
)
it('короткий, но уникальный секрет в production не роняет старт (только предупреждение)', () => {
const warn = vi.spyOn(process, 'emitWarning').mockImplementation(() => {})
expect(() =>
loadConfig({ NODE_ENV: 'production', JWT_SECRET: 'short-secret' }),
).not.toThrow()
expect(warn).toHaveBeenCalledWith(expect.stringContaining('JWT_SECRET'))
warn.mockRestore()
})
it('нормальный секрет в production принимается без предупреждений; в test слабый разрешён', () => {
const warn = vi.spyOn(process, 'emitWarning').mockImplementation(() => {})
expect(() =>
loadConfig({ NODE_ENV: 'production', JWT_SECRET: base.JWT_SECRET }),
).not.toThrow()
expect(warn).not.toHaveBeenCalled()
expect(() =>
loadConfig({ NODE_ENV: 'test', JWT_SECRET: 'short-secret' }),
).not.toThrow()
warn.mockRestore()
})
it('пароль первого админа: слабый запрещён только в production', () => {
const prod = (pw?: string) =>
loadConfig({ NODE_ENV: 'production', ...base, ADMIN_PASSWORD: pw })
expect(() => assertBootstrapPassword(prod('admin'))).toThrow(/ADMIN_PASSWORD/)
expect(() => assertBootstrapPassword(prod(''))).toThrow(/ADMIN_PASSWORD/)
expect(() => assertBootstrapPassword(prod('short'))).toThrow(/ADMIN_PASSWORD/)
expect(() => assertBootstrapPassword(prod('long-enough-pw'))).not.toThrow()
expect(() =>
assertBootstrapPassword(loadConfig({ NODE_ENV: 'test', ...base })),
).not.toThrow()
})
it('buildApp в production без нормального пароля падает при создании админа', async () => {
await expect(
buildApp({
config: loadConfig({
NODE_ENV: 'production',
...base,
ADMIN_PASSWORD: 'admin',
DATABASE_URL: 'sqlite::memory:',
}),
databaseUrl: 'sqlite::memory:',
}),
).rejects.toThrow(/ADMIN_PASSWORD/)
})
})
describe('redact в логах', () => {
let app: ReturnType<typeof Fastify> | undefined
afterEach(async () => {
await app?.close()
})
it('authorization и cookie не попадают в лог', async () => {
const lines: string[] = []
const stream = new Writable({
write(chunk, _enc, cb) {
lines.push(String(chunk))
cb()
},
})
// Тот же набор путей, что и в buildApp.
app = Fastify({
logger: {
level: 'info',
stream,
redact: {
paths: ['req.headers.authorization', 'req.headers.cookie'],
censor: '[redacted]',
},
serializers: {
req: (req) => ({ method: req.method, headers: req.headers }),
},
},
})
app.get('/x', async () => ({ ok: true }))
await app.inject({
method: 'GET',
url: '/x',
headers: { authorization: 'Bearer SECRET', cookie: 'refresh_token=SECRET' },
})
const joined = lines.join('')
expect(joined).toContain('[redacted]')
expect(joined).not.toContain('SECRET')
})
})
+95
View File
@@ -0,0 +1,95 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { adminToken, buildTestApp } from './helpers.js'
import { startHousekeepingJob } from '../src/services/housekeeping.js'
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
afterEach(async () => {
vi.useRealTimers()
await app?.close()
app = undefined
})
describe('directory/users', () => {
it('отдаёт только id/email/name и ограничивает 50', async () => {
app = await buildTestApp()
const token = await adminToken(app)
for (let i = 0; i < 55; i++) {
app.sqlite
.prepare(
`INSERT INTO users (id,email,name,password_hash,is_admin,disabled,created_at,updated_at)
VALUES (?,?,?,?,0,0,'t','t')`,
)
.run(`id${i}`, `u${i}@t.io`, `U${i}`, 'hash')
}
const res = await app.inject({
method: 'GET',
url: '/api/v1/directory/users',
headers: { authorization: `Bearer ${token}` },
})
expect(res.statusCode).toBe(200)
const body = res.json() as Array<Record<string, unknown>>
expect(body).toHaveLength(50)
expect(Object.keys(body[0]!).sort()).toEqual(['email', 'id', 'name'])
expect(res.body).not.toContain('hash')
const q = await app.inject({
method: 'GET',
url: '/api/v1/directory/users?q=U54',
headers: { authorization: `Bearer ${token}` },
})
expect((q.json() as unknown[]).length).toBe(1)
})
})
describe('ingest audit', () => {
const ev = (i: number) => ({
event_id: `ev-${i}`,
source_app: 'dns',
action: 'dns.test',
severity: 'info',
summary: 'x',
})
it('принимает пачку одной транзакцией и считает дубликаты', async () => {
app = await buildTestApp()
const post = (events: unknown[]) =>
app!.inject({
method: 'POST',
url: '/api/v1/ingest/audit',
headers: { authorization: 'Bearer dev-audit-ingest-secret' },
payload: { events },
})
const first = await post([ev(1), ev(2), ev(3)])
expect(first.json()).toEqual({ accepted: 3, duplicates: 0 })
const second = await post([ev(3), ev(4)])
expect(second.json()).toEqual({ accepted: 1, duplicates: 1 })
})
})
describe('housekeeping job', () => {
it('удаляет устаревшие сессии по таймеру и останавливается', async () => {
vi.useFakeTimers()
app = await buildTestApp()
app.sqlite
.prepare(
`INSERT INTO users (id,email,name,password_hash,is_admin,disabled,created_at,updated_at)
VALUES ('uh','h@t.io','H','x',0,0,'t','t')`,
)
.run()
app.sqlite
.prepare(
`INSERT INTO refresh_sessions (id,user_id,token_hash,expires_at,created_at)
VALUES ('old','uh','h','2000-01-01T00:00:00.000Z','t')`,
)
.run()
const stop = startHousekeepingJob(app)
await vi.advanceTimersByTimeAsync(31_000)
const left = app.sqlite
.prepare(`SELECT COUNT(*) c FROM refresh_sessions WHERE id='old'`)
.get()
expect(left).toEqual({ c: 0 })
stop()
expect(vi.getTimerCount()).toBe(0)
})
})
+91
View File
@@ -0,0 +1,91 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const root = resolve(import.meta.dirname, '../../..')
const read = (p: string) => readFileSync(resolve(root, p), 'utf-8')
const dockerfile = read('deploy/docker/authportal/Dockerfile')
const composes = {
'docker-compose.yml': read('docker-compose.yml'),
'deploy/docker-compose.traefik.yml': read('deploy/docker-compose.traefik.yml'),
}
describe('Dockerfile', () => {
it('graceful stop и ограничение ресурсов Node', () => {
expect(dockerfile).toMatch(/^STOPSIGNAL SIGTERM$/m)
expect(dockerfile).toMatch(/NODE_OPTIONS=--max-old-space-size=\d+/)
expect(dockerfile).toMatch(/UV_THREADPOOL_SIZE=4/)
})
it('healthcheck без запуска node', () => {
const hc = dockerfile.split('HEALTHCHECK')[1]!.split('\n').slice(0, 2).join(' ')
expect(hc).toContain('wget')
expect(hc).not.toContain('"node"')
})
it('запуск не от root, рантайм без тулчейна и pnpm deploy', () => {
expect(dockerfile).toMatch(/^USER node$/m)
const runtime = dockerfile.split('AS runtime')[1]!
expect(runtime).not.toMatch(/apk add/)
expect(dockerfile).not.toMatch(/pnpm .*deploy/)
})
it('статика сжимается только brotli', () => {
expect(dockerfile).toContain('brotli')
expect(dockerfile).not.toMatch(/gzip -k/)
})
it('preview-маршруты дизайна вырезаются до сборки web', () => {
const rm = dockerfile.indexOf('rm -f apps/web/src/routes/design-preview*.tsx')
const build = dockerfile.indexOf('pnpm turbo build')
expect(rm).toBeGreaterThan(0)
expect(rm).toBeLessThan(build)
})
it('базовый образ закреплён по digest', () => {
expect(dockerfile).toMatch(/ARG BASE_NODE=\S+@sha256:[0-9a-f]{64}/)
expect(read('deploy/docker/docker-bake.hcl')).toMatch(/node:22-alpine@sha256:[0-9a-f]{64}/)
})
it('в образ копируется весь dist API, а tsup не дробит бандл на чанки', () => {
expect(dockerfile).toContain('cp -r apps/api/dist /out/dist')
expect(read('apps/api/tsup.config.ts')).toMatch(/splitting:\s*false/)
})
it('нативные модули проверяются при сборке', () => {
expect(dockerfile).toContain('native modules ok')
})
})
describe.each(Object.entries(composes))('%s', (_name, text) => {
it('init, grace period и лимиты ресурсов', () => {
expect(text).toMatch(/init: true/)
expect(text).toMatch(/stop_grace_period: 15s/)
expect(text).toMatch(/mem_limit: 256m/)
expect(text).toMatch(/pids_limit: \d+/)
expect(text).toMatch(/read_only: true/)
expect(text).toMatch(/cap_drop:\s*\n\s*- ALL/)
expect(text).toMatch(/no-new-privileges:true/)
})
it('healthcheck через wget', () => {
expect(text).toMatch(/"wget", "-q"/)
expect(text).not.toMatch(/fetch\('http:\/\/127\.0\.0\.1:8080\/health'\)/)
})
})
describe('traefik', () => {
it('app ждёт healthy traefik, у traefik есть healthcheck', () => {
const t = composes['deploy/docker-compose.traefik.yml']
expect(t).toMatch(/condition: service_healthy/)
expect(t).toContain('traefik", "healthcheck", "--ping')
expect(t).toContain('--ping=true')
})
it('включено сжатие ответов', () => {
expect(composes['deploy/docker-compose.traefik.yml']).toContain(
'middlewares.auth-portal-compress.compress=true',
)
})
})
+77
View File
@@ -0,0 +1,77 @@
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { z } from 'zod'
import { buildTestApp } from './helpers.js'
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
afterEach(async () => {
await app?.close()
app = undefined
})
describe('error handler', () => {
it('необработанная ошибка → 500 без деталей', async () => {
app = await buildTestApp()
app.get('/__boom', async () => {
throw new Error('secret internals')
})
const res = await app.inject({ method: 'GET', url: '/__boom' })
expect(res.statusCode).toBe(500)
expect(res.json()).toEqual({
error: { code: 'INTERNAL', message: 'Internal server error' },
})
expect(res.body).not.toContain('secret internals')
})
it('ZodError → 400 BAD_REQUEST', async () => {
app = await buildTestApp()
app.get('/__zod', async () => z.object({ a: z.string() }).parse({}))
const res = await app.inject({ method: 'GET', url: '/__zod' })
expect(res.statusCode).toBe(400)
expect(res.json().error.code).toBe('BAD_REQUEST')
})
it('ошибка с statusCode 4xx сохраняет статус', async () => {
app = await buildTestApp()
app.get('/__teapot', async () => {
throw Object.assign(new Error('nope'), { statusCode: 418 })
})
const res = await app.inject({ method: 'GET', url: '/__teapot' })
expect(res.statusCode).toBe(418)
})
})
describe('not found', () => {
it.each(['/oauth/nope', '/.well-known/nope', '/api/v1/nope'])(
'%s → 404 JSON',
async (url) => {
app = await buildTestApp()
const res = await app.inject({ method: 'GET', url })
expect(res.statusCode).toBe(404)
expect(res.json().error.code).toBe('NOT_FOUND')
},
)
it('без STATIC_DIR неизвестный путь → 404 JSON', async () => {
app = await buildTestApp()
const res = await app.inject({ method: 'GET', url: '/some/spa/route' })
expect(res.statusCode).toBe(404)
})
it('со STATIC_DIR: SPA-путь → index.html, /oauth и /.well-known → 404', async () => {
const dir = mkdtempSync(join(tmpdir(), 'ap-static-'))
writeFileSync(join(dir, 'index.html'), '<html>spa</html>')
app = await buildTestApp({ STATIC_DIR: dir })
const spa = await app.inject({ method: 'GET', url: '/some/spa/route' })
expect(spa.statusCode).toBe(200)
expect(spa.body).toContain('spa')
for (const url of ['/oauth/nope', '/.well-known/nope']) {
const res = await app.inject({ method: 'GET', url })
expect(res.statusCode).toBe(404)
expect(res.json().error.code).toBe('NOT_FOUND')
}
})
})
+277
View File
@@ -0,0 +1,277 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { hash } from '@node-rs/argon2'
import { TtlCache } from '../src/lib/cache.js'
import { parseCookie } from '../src/lib/cookies.js'
import { checkClientSecret, hashClientSecret } from '../src/lib/client-secret.js'
import {
hashPassword,
passwordHashStats,
setPasswordConcurrency,
verifyPassword,
} from '../src/lib/password.js'
import { parseTrustProxy } from '../src/config.js'
import { adminToken, buildTestApp } from './helpers.js'
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
afterEach(async () => {
vi.restoreAllMocks()
await app?.close()
app = undefined
})
describe('TtlCache', () => {
it('истекает по TTL', () => {
let t = 0
const c = new TtlCache<string, number>({ max: 10, ttlMs: 100, now: () => t })
c.set('a', 1)
t = 99
expect(c.get('a')).toBe(1)
t = 100
expect(c.get('a')).toBeUndefined()
expect(c.size).toBe(0)
})
it('вытесняет давно не использованное (LRU)', () => {
const c = new TtlCache<string, number>({ max: 2, ttlMs: 1000 })
c.set('a', 1)
c.set('b', 2)
c.get('a') // освежили a
c.set('c', 3)
expect(c.get('b')).toBeUndefined()
expect(c.get('a')).toBe(1)
expect(c.get('c')).toBe(3)
c.delete('a')
expect(c.get('a')).toBeUndefined()
c.clear()
expect(c.size).toBe(0)
})
})
describe('parseCookie', () => {
it('находит значение и декодирует', () => {
expect(parseCookie('a=1; refresh_token=abc%20d; b=2', 'refresh_token')).toBe(
'abc d',
)
})
it('не путает суффикс имени', () => {
expect(parseCookie('x_refresh_token=bad', 'refresh_token')).toBeNull()
})
it('пусто/нет → null', () => {
expect(parseCookie(undefined, 'a')).toBeNull()
expect(parseCookie('a=; b=1', 'a')).toBeNull()
expect(parseCookie('b=1', 'a')).toBeNull()
})
})
describe('client secret', () => {
it('sha256-формат: верный/неверный, без rehash', async () => {
const stored = hashClientSecret('s3cret')
expect(stored.startsWith('sha256:')).toBe(true)
expect(await checkClientSecret(stored, 's3cret')).toEqual({
ok: true,
needsRehash: false,
})
expect((await checkClientSecret(stored, 'nope')).ok).toBe(false)
})
it('argon2-формат принимается и помечается на rehash', async () => {
const legacy = await hash('old-secret')
expect(await checkClientSecret(legacy, 'old-secret')).toEqual({
ok: true,
needsRehash: true,
})
expect(await checkClientSecret(legacy, 'bad')).toEqual({
ok: false,
needsRehash: false,
})
expect((await checkClientSecret('garbage', 'x')).ok).toBe(false)
})
})
describe('password limiter', () => {
it('не допускает больше N одновременных хешей', async () => {
setPasswordConcurrency(2)
let maxActive = 0
const poll = setInterval(() => {
maxActive = Math.max(maxActive, passwordHashStats().active)
}, 1)
const h = await hashPassword('pw')
await Promise.all(
Array.from({ length: 6 }, () => verifyPassword(h, 'pw')),
)
clearInterval(poll)
expect(maxActive).toBeLessThanOrEqual(2)
expect(passwordHashStats()).toEqual({ active: 0, waiting: 0 })
setPasswordConcurrency(2)
})
})
describe('parseTrustProxy', () => {
it('true/false/список', () => {
expect(parseTrustProxy('true')).toBe(true)
expect(parseTrustProxy('false')).toBe(false)
expect(parseTrustProxy('10.0.0.0/8, ::1')).toEqual(['10.0.0.0/8', '::1'])
})
})
async function createUserAndLogin(
a: NonNullable<typeof app>,
adminTok: string,
email: string,
) {
const create = await a.inject({
method: 'POST',
url: '/api/v1/admin/users',
headers: { authorization: `Bearer ${adminTok}` },
payload: {
email,
name: 'U',
password: 'secret12',
is_admin: false,
apps: ['cfdm'],
permissions: [],
},
})
expect(create.statusCode).toBe(201)
const id = (create.json() as { id: string }).id
const login = await a.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { email, password: 'secret12' },
})
expect(login.statusCode).toBe(200)
return { id, token: (login.json() as { access_token: string }).access_token }
}
describe('кэш профиля пользователя', () => {
it('повторные запросы не обращаются к user_apps/user_permissions', async () => {
app = await buildTestApp()
const admin = await adminToken(app)
const { token } = await createUserAndLogin(app, admin, 'cache@test.local')
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, 'ban@test.local')
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, 'acc@test.local')
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: 'nobody@test.local', 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: 'nobody@test.local', password: 'wrongpass' },
})
expect(res.statusCode).toBe(401)
const row = app.sqlite
.prepare(`SELECT ip FROM audit_log WHERE action='auth.login_failed'`)
.get() as { ip: string }
expect(row.ip).toBe('203.0.113.9')
})
})
+232
View File
@@ -0,0 +1,232 @@
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { createOriginChecker } from '../src/lib/cors.js'
import { loadConfig } from '../src/config.js'
import { buildTestApp } from './helpers.js'
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
afterEach(async () => {
await app?.close()
app = undefined
})
function staticDir(): string {
const dir = mkdtempSync(join(tmpdir(), 'ap-static-'))
mkdirSync(join(dir, 'assets'))
writeFileSync(join(dir, 'index.html'), '<html>spa</html>')
writeFileSync(join(dir, 'assets', 'app-abc123.js'), 'console.log(1)')
return dir
}
describe('статика', () => {
it('hashed-ассеты immutable, index.html no-cache', async () => {
app = await buildTestApp({ STATIC_DIR: staticDir() })
const asset = await app.inject({ method: 'GET', url: '/assets/app-abc123.js' })
expect(asset.statusCode).toBe(200)
expect(asset.headers['cache-control']).toBe(
'public, max-age=31536000, immutable',
)
const index = await app.inject({ method: 'GET', url: '/index.html' })
expect(index.headers['cache-control']).toBe('no-cache')
const spa = await app.inject({ method: 'GET', url: '/users/42' })
expect(spa.statusCode).toBe(200)
expect(spa.headers['cache-control']).toBe('no-cache')
})
it('отсутствующий ассет → 404, а не index.html (иначе после деплоя чанк-JS = HTML)', async () => {
app = await buildTestApp({ STATIC_DIR: staticDir() })
const res = await app.inject({ method: 'GET', url: '/assets/missing.js' })
expect(res.statusCode).toBe(404)
expect(res.body).not.toContain('<html>')
})
it('отдаёт предсжатый .br по Accept-Encoding', async () => {
const dir = staticDir()
writeFileSync(join(dir, 'assets', 'app-abc123.js.br'), 'BR-BYTES')
app = await buildTestApp({ STATIC_DIR: dir })
const res = await app.inject({
method: 'GET',
url: '/assets/app-abc123.js',
headers: { 'accept-encoding': 'br' },
})
expect(res.headers['content-encoding']).toBe('br')
expect(res.body).toBe('BR-BYTES')
})
})
describe('кэш-заголовки API', () => {
it('auth/config кэшируется, app-switcher — нет (админка должна видеть правки сразу)', async () => {
app = await buildTestApp()
const sw = await app.inject({ method: 'GET', url: '/api/v1/app-switcher' })
expect(sw.headers['cache-control']).toBeUndefined()
const cfg = await app.inject({ method: 'GET', url: '/api/v1/auth/config' })
expect(cfg.headers['cache-control']).toBe('public, max-age=60')
})
})
describe('health / ready', () => {
it('/health не трогает БД и живёт после закрытия БД', async () => {
app = await buildTestApp()
app.sqlite.close()
const res = await app.inject({ method: 'GET', url: '/health' })
expect(res.statusCode).toBe(200)
})
it('/ready 200 в норме и 503 при недоступной БД', async () => {
app = await buildTestApp()
const ok = await app.inject({ method: 'GET', url: '/ready' })
expect(ok.statusCode).toBe(200)
app.sqlite.close()
const bad = await app.inject({ method: 'GET', url: '/ready' })
expect(bad.statusCode).toBe(503)
expect(bad.json()).toEqual({ ok: false, reason: 'db' })
})
})
describe('CORS', () => {
const origin = (url: string) => ({ origin: url })
it('разрешает issuer/allowlist, блокирует чужие', async () => {
app = await buildTestApp({ RETURN_TO_ALLOWLIST: '.test.local,localhost' })
const allowed = await app.inject({
method: 'GET',
url: '/api/v1/auth/config',
headers: origin('https://auth.test.local'),
})
expect(allowed.headers['access-control-allow-origin']).toBe(
'https://auth.test.local',
)
expect(allowed.headers['access-control-allow-credentials']).toBe('true')
const foreign = await app.inject({
method: 'GET',
url: '/api/v1/auth/config',
headers: origin('https://evil.example'),
})
expect(foreign.headers['access-control-allow-origin']).toBeUndefined()
})
it('CORS_ORIGINS добавляет origin; запрос без Origin проходит', async () => {
app = await buildTestApp({ CORS_ORIGINS: 'https://extra.example' })
const res = await app.inject({
method: 'GET',
url: '/api/v1/auth/config',
headers: origin('https://extra.example'),
})
expect(res.headers['access-control-allow-origin']).toBe(
'https://extra.example',
)
const none = await app.inject({ method: 'GET', url: '/api/v1/auth/config' })
expect(none.statusCode).toBe(200)
})
it('боевой RETURN_TO_ALLOWLIST из env.traefik.example не теряет клиентов', () => {
const list =
'.shnt.top,https://vps.shnt.top,https://cfdm.shnt.top,https://bgp.shnt.top,https://fw.shnt.top,https://dns.shnt.top,https://cdn.shnt.top'
const check = createOriginChecker(
loadConfig({
JWT_SECRET: 'test-secret-at-least-8',
ISSUER: 'https://auth.shnt.top',
RETURN_TO_ALLOWLIST: list,
}),
() => [],
)
for (const o of [
'https://vps.shnt.top',
'https://dns.shnt.top',
'https://shnt.top',
'https://anything.shnt.top',
]) {
expect(check(o)).toBe(true)
}
expect(check('https://evil.example')).toBe(false)
})
it('private в allowlist пускает LAN-хосты', () => {
const check = createOriginChecker(
loadConfig({
JWT_SECRET: 'test-secret-at-least-8',
RETURN_TO_ALLOWLIST: '.shnt.top,private',
}),
() => [],
)
expect(check('http://192.168.1.10:8080')).toBe(true)
expect(check('http://10.0.0.5')).toBe(true)
expect(check('https://8.8.8.8')).toBe(false)
})
it('createOriginChecker: суффикс, localhost, switcher, *', () => {
const base = {
JWT_SECRET: 'test-secret-at-least-8',
ISSUER: 'https://auth.test.local',
RETURN_TO_ALLOWLIST: '.shnt.top,localhost,private',
}
const check = createOriginChecker(loadConfig(base), () => [
'https://dns.custom.io/path',
])
expect(check('https://cfdm.shnt.top')).toBe(true)
expect(check('http://localhost:5173')).toBe(true)
expect(check('https://dns.custom.io')).toBe(true)
expect(check('https://evilshnt.top')).toBe(false)
expect(check('not a url')).toBe(false)
const all = createOriginChecker(
loadConfig({ ...base, CORS_ORIGINS: '*' }),
() => [],
)
expect(all('https://anything.example')).toBe(true)
})
})
describe('сжатие в Node (ENABLE_COMPRESS)', () => {
async function withBigRoute(env: NodeJS.ProcessEnv) {
app = await buildTestApp(env)
app.get('/__big', async () => ({ data: 'x'.repeat(5000) }))
app.get('/__small', async () => ({ ok: true }))
return app
}
it('по умолчанию выключено', async () => {
const a = await withBigRoute({})
const res = await a.inject({
method: 'GET',
url: '/__big',
headers: { 'accept-encoding': 'gzip' },
})
expect(res.headers['content-encoding']).toBeUndefined()
})
it('включено: большой JSON сжимается, маленький нет, без Accept-Encoding нет', async () => {
const a = await withBigRoute({ ENABLE_COMPRESS: 'true' })
const big = await a.inject({
method: 'GET',
url: '/__big',
headers: { 'accept-encoding': 'gzip' },
})
expect(big.headers['content-encoding']).toBe('gzip')
expect(big.rawPayload.length).toBeLessThan(500)
const small = await a.inject({
method: 'GET',
url: '/__small',
headers: { 'accept-encoding': 'gzip' },
})
expect(small.headers['content-encoding']).toBeUndefined()
const none = await a.inject({ method: 'GET', url: '/__big' })
expect(none.headers['content-encoding']).toBeUndefined()
})
});
describe('CSP', () => {
it('отдаётся жёсткая политика без внешних источников', async () => {
app = await buildTestApp()
const res = await app.inject({ method: 'GET', url: '/health' })
const csp = String(res.headers['content-security-policy'])
expect(csp).toContain("default-src 'self'")
expect(csp).toContain("script-src 'self'")
expect(csp).toContain("frame-ancestors 'none'")
expect(csp).toContain("object-src 'none'")
expect(csp).not.toMatch(/script-src[^;]*'unsafe-inline'/)
expect(csp).not.toContain('http:')
})
})
+120
View File
@@ -0,0 +1,120 @@
import { EventEmitter } from 'node:events'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createDb, closeDb, migrateSchema } from '@authportal/db'
import { registerShutdown } from '../src/lib/shutdown.js'
import { buildTestApp } from './helpers.js'
function fakeApp(close: () => Promise<void>) {
return {
close,
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
} as unknown as Parameters<typeof registerShutdown>[0]
}
describe('registerShutdown', () => {
afterEach(() => {
vi.useRealTimers()
})
it('закрывает приложение один раз и выходит с 0', async () => {
const close = vi.fn(async () => {})
const exit = vi.fn()
const proc = new EventEmitter()
registerShutdown(fakeApp(close), {
timeoutMs: 1000,
exit,
proc: proc as never,
})
proc.emit('SIGTERM')
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(0))
expect(close).toHaveBeenCalledTimes(1)
})
it('повторный сигнал форсирует выход, close не вызывается дважды', async () => {
let release!: () => void
const close = vi.fn(
() => new Promise<void>((r) => (release = r)),
)
const exit = vi.fn()
const proc = new EventEmitter()
registerShutdown(fakeApp(close), {
timeoutMs: 1000,
exit,
proc: proc as never,
})
proc.emit('SIGTERM')
proc.emit('SIGINT')
expect(exit).toHaveBeenCalledWith(1)
expect(close).toHaveBeenCalledTimes(1)
release()
})
it('по таймауту принудительно выходит с кодом 1', async () => {
vi.useFakeTimers()
const exit = vi.fn()
const proc = new EventEmitter()
registerShutdown(
fakeApp(() => new Promise<void>(() => {})),
{ timeoutMs: 500, exit, proc: proc as never },
)
proc.emit('SIGTERM')
await vi.advanceTimersByTimeAsync(501)
expect(exit).toHaveBeenCalledWith(1)
})
it('ошибка close → выход с кодом 1', async () => {
const exit = vi.fn()
const proc = new EventEmitter()
registerShutdown(
fakeApp(async () => {
throw new Error('boom')
}),
{ timeoutMs: 1000, exit, proc: proc as never },
)
proc.emit('SIGTERM')
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1))
})
it('unhandledRejection запускает shutdown с кодом 1', async () => {
const exit = vi.fn()
const proc = new EventEmitter()
registerShutdown(fakeApp(async () => {}), {
timeoutMs: 1000,
exit,
proc: proc as never,
})
proc.emit('unhandledRejection', new Error('x'))
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1))
})
it('dispose снимает обработчики', () => {
const proc = new EventEmitter()
const h = registerShutdown(fakeApp(async () => {}), {
timeoutMs: 1000,
exit: vi.fn(),
proc: proc as never,
})
h.dispose()
expect(proc.listenerCount('SIGTERM')).toBe(0)
expect(proc.listenerCount('unhandledRejection')).toBe(0)
})
})
describe('closeDb', () => {
it('идемпотентно закрывает БД', () => {
const { sqlite } = createDb('sqlite::memory:')
migrateSchema(sqlite)
closeDb(sqlite)
expect(sqlite.open).toBe(false)
expect(() => closeDb(sqlite)).not.toThrow()
expect(() => sqlite.prepare('SELECT 1')).toThrow()
})
it('app.close() закрывает БД приложения', async () => {
const app = await buildTestApp()
await app.ready()
expect(app.sqlite.open).toBe(true)
await app.close()
expect(app.sqlite.open).toBe(false)
})
})
+27
View File
@@ -0,0 +1,27 @@
import { defineConfig } from 'tsup'
// Нативные модули остаются внешними (ставятся в образ отдельно),
// всё остальное (fastify, jose, drizzle, workspace-пакеты) бандлится в один файл.
const NATIVE = ['better-sqlite3', '@node-rs/argon2']
export default defineConfig({
entry: ['src/server.ts'],
format: ['esm'],
target: 'node22',
external: NATIVE,
noExternal: [/^(?!better-sqlite3$|@node-rs\/argon2).+/],
// CJS-зависимости внутри ESM-бандла используют require/__dirname.
banner: {
js: [
"import { createRequire as __cr } from 'node:module';",
"import { fileURLToPath as __fu } from 'node:url';",
"import { dirname as __dn } from 'node:path';",
'const require = __cr(import.meta.url);',
'const __filename = __fu(import.meta.url);',
'const __dirname = __dn(__filename);',
].join('\n'),
},
// Один файл: динамические import() (напр. CLI backup) не должны порождать чанки.
splitting: false,
clean: true,
})
+8
View File
@@ -3,5 +3,13 @@ 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 },
},
},
})
+7 -8
View File
@@ -15,13 +15,11 @@
"dependencies": {
"@authportal/shared": "workspace:*",
"@authportal/ui": "workspace:*",
"@base-ui/react": "^1.5.0",
"@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",
"@fontsource-variable/geist": "^5.3.0",
"@fontsource-variable/geist-mono": "^5.3.0",
"@hookform/resolvers": "^5.4.0",
"@simplewebauthn/browser": "^13.3.0",
"@tailwindcss/vite": "^4.3.1",
@@ -42,20 +40,21 @@
"zod": "catalog:"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@eslint/js": "catalog:",
"@tanstack/router-cli": "^1.167.31",
"@types/node": "^24.12.3",
"@types/node": "catalog:",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint": "catalog:",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"globals": "catalog:",
"jsdom": "^30.1.3",
"rollup-plugin-visualizer": "^7.1.1",
"tw-animate-css": "^1.0.0",
"typescript": "catalog:",
"typescript-eslint": "^8.59.2",
"typescript-eslint": "catalog:",
"vite": "^8.0.12",
"vitest": "catalog:"
}
@@ -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="mx-auto flex w-full min-w-0 max-w-[1480px] 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>
)
}
@@ -33,7 +33,7 @@ import {
SheetHeader,
SheetTitle,
} from '@authportal/ui/components/sheet'
import type { Tone } from './mock'
import type { Tone } from './tone'
import { TONE_BADGE } from './tone'
const TONE_GLYPH: Record<Tone, string> = { ok: '●', wn: '▲', er: '✕', mu: '○', in: '' }
@@ -104,7 +104,7 @@ export function SignalAlert({
}
/** Боковое окно (как CreateUserSheet): заголовок, прокручиваемое тело, подвал. */
export function PreviewSheet({
export function PortalSheet({
open,
onOpenChange,
title,
@@ -3,7 +3,7 @@ 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 './mock'
import type { Tone } from './tone'
import { StatusBadge } from './bits'
export type AppCardItem = {
@@ -0,0 +1,83 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { ChromeConfigContext, UiStoreContext, type ChromeConfig, type Contrast, type Density, type UiStore } from './chrome-store'
export function ChromeConfigProvider({ value, children }: { value: ChromeConfig; children: ReactNode }) {
return <ChromeConfigContext.Provider value={value}>{children}</ChromeConfigContext.Provider>
}
function readStored<T>(key: string, fallback: T): T {
try {
const raw = window.localStorage.getItem(key)
return raw ? (JSON.parse(raw) as T) : fallback
} catch {
return fallback
}
}
function writeStored(key: string, value: unknown) {
try {
window.localStorage.setItem(key, JSON.stringify(value))
} catch {
/* без сохранения настроек */
}
}
/** Плотность, контраст, закреплённые пункты и палитра ⌘K. Атрибуты html[data-density|contrast] читает portal-theme.css. */
export function PortalUiProvider({ children, storageKey = 'portal-ui' }: { children: ReactNode; storageKey?: string }) {
const [paletteOpen, setPaletteOpen] = useState(false)
const [density, setDensityState] = useState<Density>(() => readStored(`${storageKey}:density`, 'comfortable'))
const [contrast, setContrastState] = useState<Contrast>(() => readStored(`${storageKey}:contrast`, 'standard'))
const [pins, setPins] = useState<string[]>(() => readStored(`${storageKey}:pins`, []))
const setDensity = useCallback(
(v: Density) => {
setDensityState(v)
writeStored(`${storageKey}:density`, v)
},
[storageKey],
)
const setContrast = useCallback(
(v: Contrast) => {
setContrastState(v)
writeStored(`${storageKey}:contrast`, v)
},
[storageKey],
)
const togglePin = useCallback(
(id: string) => {
setPins((prev) => {
const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
writeStored(`${storageKey}:pins`, next)
return next
})
},
[storageKey],
)
useEffect(() => {
const root = document.documentElement
root.dataset.density = density
root.dataset.contrast = contrast
return () => {
delete root.dataset.density
delete root.dataset.contrast
}
}, [density, contrast])
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
setPaletteOpen((v) => !v)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
const value = useMemo<UiStore>(
() => ({ paletteOpen, setPaletteOpen, pins, togglePin, density, setDensity, contrast, setContrast }),
[paletteOpen, pins, togglePin, density, setDensity, contrast, setContrast],
)
return <UiStoreContext.Provider value={value}>{children}</UiStoreContext.Provider>
}
@@ -0,0 +1,86 @@
import { createContext, useContext, type ComponentType, type ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
export type NavItem = {
id: string
label: string
to: string
icon: LucideIcon
group: string
/** Точка проблемы в углу иконки. */
problem?: { tone: 'er' | 'wn'; label: string }
}
export type MonitorItem = {
id: string
tone: 'er' | 'wn' | 'ok'
title: string
text: string
age: string
to: string
}
export type MonitorMetric = { l: string; v: string; p: number; tone: 'warning' | 'success' }
export type PaletteAction = {
id: string
label: string
/** Горячая буква справа. */
hint: string
icon: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
to: string
}
export type PaletteObject = {
id: string
group: string
label: string
hint: string
dot?: 'ok' | 'er' | 'mu'
to: string
}
/** Данные оболочки: меню, монитор, палитра и пользователь. В макете — суррогатные, в портале — из API. */
export type ChromeConfig = {
navItems: NavItem[]
navGroups: string[]
/** Верх бокового меню: бренд и переключатель приложений. */
brand: ReactNode
user: { name: string; email: string; initials: string }
securityTo: string
onLogout: () => void
fresh: (pathname: string) => { badge: string; text: string }
crumbFor?: (pathname: string, label: string | undefined) => string
monitor: { items: MonitorItem[]; metrics: MonitorMetric[]; updated: string; footerTo: string; footerLabel: string }
palette: { actions: PaletteAction[]; objects: PaletteObject[]; assistantTo: string; example: string }
}
export const ChromeConfigContext = createContext<ChromeConfig | null>(null)
export function useChromeConfig(): ChromeConfig {
const ctx = useContext(ChromeConfigContext)
if (!ctx) throw new Error('ChromeConfigProvider отсутствует')
return ctx
}
export type Density = 'comfortable' | 'compact'
export type Contrast = 'standard' | 'high'
export type UiStore = {
paletteOpen: boolean
setPaletteOpen: (open: boolean) => void
pins: string[]
togglePin: (id: string) => void
density: Density
setDensity: (v: Density) => void
contrast: Contrast
setContrast: (v: Contrast) => void
}
export const UiStoreContext = createContext<UiStore | null>(null)
export function useUiStore(): UiStore {
const ctx = useContext(UiStoreContext)
if (!ctx) throw new Error('PortalUiProvider отсутствует')
return ctx
}
+569
View File
@@ -0,0 +1,569 @@
import {
useMemo,
useState,
type ComponentType,
} from 'react'
import {
Link,
useNavigate,
useRouterState,
} from '@tanstack/react-router'
import {
ActivityIcon,
ArrowRightIcon,
ChevronDownIcon,
ChevronsUpDownIcon,
FingerprintIcon,
InfoIcon,
LogOutIcon,
SearchIcon,
StarIcon,
MoonIcon,
} from 'lucide-react'
import {
useTheme,
} from 'next-themes'
import {
Badge,
} from '@/components/reui/badge'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
} from '@authportal/ui/components/breadcrumb'
import {
Button,
} from '@authportal/ui/components/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@authportal/ui/components/dialog'
import {
Kbd,
} from '@authportal/ui/components/kbd'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@authportal/ui/components/popover'
import {
Separator,
} from '@authportal/ui/components/separator'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarSeparator,
SidebarTrigger,
useSidebar,
} from '@authportal/ui/components/sidebar'
import {
cn,
} from '@authportal/ui/lib/utils'
import {
Segmented,
} from './bits'
import {
useChromeConfig,
useUiStore,
type NavItem,
} from './chrome-store'
const LABEL_CLASS = 'text-muted-foreground text-[11px] font-medium uppercase'
function PortalNavUser() {
const { isMobile } = useSidebar()
const { theme, setTheme, resolvedTheme } = useTheme()
const { density, setDensity, contrast, setContrast } = useUiStore()
const { user, securityTo, onLogout } = useChromeConfig()
const navigate = useNavigate()
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
return (
<SidebarMenu>
<SidebarMenuItem>
<Popover>
<PopoverTrigger
render={
<SidebarMenuButton
size="lg"
aria-label={`Профиль и настройки интерфейса: ${user.name}, ${user.email}`}
className="bg-muted/40 aria-expanded:bg-muted"
/>
}
>
<div className="bg-background text-foreground flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
{user.initials}
</div>
<div className="grid min-w-0 flex-1 text-left leading-tight">
<span className="truncate text-sm font-semibold">{user.name}</span>
<span className="text-muted-foreground truncate text-xs">{user.email}</span>
</div>
<ChevronsUpDownIcon className="text-muted-foreground ml-auto size-4" aria-hidden />
</PopoverTrigger>
<PopoverContent side={isMobile ? 'top' : 'right'} align="end" sideOffset={8} className="w-72 gap-3">
<div className="flex items-center gap-2.5">
<div className="bg-background flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
{user.initials}
</div>
<div className="grid leading-tight">
<span className="text-sm font-semibold">{user.name}</span>
<span className="text-muted-foreground text-xs">{user.email}</span>
</div>
</div>
<Separator />
<button
type="button"
onClick={() => void navigate({ to: securityTo })}
className="hover:bg-muted flex items-center gap-2 rounded-md px-1 py-1 text-left text-sm"
>
<FingerprintIcon className="text-muted-foreground size-4" aria-hidden />
Безопасность
</button>
<div className="flex flex-col gap-1.5">
<span className={LABEL_CLASS}>Тема</span>
<Segmented
label="Тема"
options={['Тёмная', 'Светлая']}
value={current === 'light' ? 1 : 0}
onChange={(i) => setTheme(i === 1 ? 'light' : 'dark')}
/>
<span className={LABEL_CLASS}>Плотность</span>
<Segmented
label="Плотность"
options={['Обычная', 'Компактная']}
value={density === 'compact' ? 1 : 0}
onChange={(i) => setDensity(i === 1 ? 'compact' : 'comfortable')}
/>
<span className={LABEL_CLASS}>Контраст</span>
<Segmented
label="Контраст"
options={['Стандартный', 'Высокий']}
value={contrast === 'high' ? 1 : 0}
onChange={(i) => setContrast(i === 1 ? 'high' : 'standard')}
/>
</div>
<Separator />
<button
type="button"
onClick={onLogout}
className="text-muted-foreground hover:text-foreground flex items-center gap-2 text-left text-sm"
>
<LogOutIcon className="size-4" aria-hidden />
Выйти
</button>
</PopoverContent>
</Popover>
</SidebarMenuItem>
</SidebarMenu>
)
}
const DOT_CLASS = { er: 'bg-destructive', wn: 'bg-warning' } as const
function NavEntry({ item, pathname, pinned, onTogglePin }: { item: NavItem; pathname: string; pinned: boolean; onTogglePin: () => void }) {
const Icon = item.icon
const active = pathname === item.to || pathname.startsWith(`${item.to}/`)
return (
<SidebarMenuItem>
<SidebarMenuButton
tooltip={item.problem ? `${item.label} · ${item.problem.label}` : item.label}
isActive={active}
className="relative pr-8 group-data-[collapsible=icon]:pr-2!"
render={<Link to={item.to} />}
>
<Icon className="size-4" />
<span>{item.label}</span>
{item.problem ? (
<span
role="img"
aria-label={item.problem.label}
className={cn(
'ml-auto size-2 shrink-0 rounded-full group-data-[collapsible=icon]:absolute group-data-[collapsible=icon]:top-1 group-data-[collapsible=icon]:right-1 group-data-[collapsible=icon]:ml-0 group-data-[collapsible=icon]:size-1.5 group-data-[collapsible=icon]:ring-2 group-data-[collapsible=icon]:ring-sidebar',
DOT_CLASS[item.problem.tone],
)}
/>
) : null}
</SidebarMenuButton>
<SidebarMenuAction
showOnHover={!pinned}
aria-label={pinned ? `Открепить ${item.label}` : `Закрепить ${item.label}`}
aria-pressed={pinned}
onClick={onTogglePin}
>
<StarIcon className={cn(pinned && 'fill-current')} aria-hidden />
</SidebarMenuAction>
</SidebarMenuItem>
)
}
export function PortalSidebar() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const { pins, togglePin } = useUiStore()
const { navItems, navGroups, brand } = useChromeConfig()
const [closed, setClosed] = useState<Record<string, boolean>>({})
const pinned = navItems.filter((i) => pins.includes(i.id))
return (
<Sidebar collapsible="icon">
<SidebarHeader className="pb-1.5">
{brand}
</SidebarHeader>
<SidebarContent>
{pinned.length > 0 ? (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel className="text-[11px] font-semibold tracking-wide uppercase">Закреплённые</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{pinned.map((item) => (
<NavEntry key={item.id} item={item} pathname={pathname} pinned onTogglePin={() => togglePin(item.id)} />
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
) : null}
{navGroups.map((group, gi) => (
<div key={group} className="flex flex-col">
{gi > 0 ? <SidebarSeparator className="mx-2 my-1 h-px shrink-0 hidden group-data-[collapsible=icon]:block" /> : null}
<SidebarGroup className="py-0">
<SidebarGroupLabel
render={
<button
type="button"
aria-expanded={!closed[group]}
onClick={() => setClosed((c) => ({ ...c, [group]: !c[group] }))}
/>
}
className="h-7 cursor-pointer justify-between text-[11px] font-semibold tracking-wide uppercase"
>
{group}
<ChevronDownIcon className={cn('size-3.5 transition-transform', closed[group] && '-rotate-90')} aria-hidden />
</SidebarGroupLabel>
<SidebarGroupContent className={cn(closed[group] && 'group-data-[collapsible=icon]:block hidden')}>
<SidebarMenu className="gap-px">
{navItems.filter((i) => i.group === group).map((item) => (
<NavEntry
key={item.id}
item={item}
pathname={pathname}
pinned={pins.includes(item.id)}
onTogglePin={() => togglePin(item.id)}
/>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</div>
))}
</SidebarContent>
<SidebarFooter>
<PortalNavUser />
</SidebarFooter>
</Sidebar>
)
}
/** Единый центр состояния: внимание + сводка + список проблем. Адаптивен по ширине окна, как на досках. */
function MonitorPopover() {
const { monitor } = useChromeConfig()
const MONITOR_ITEMS = monitor.items
const MONITOR_METRICS = monitor.metrics
const bad = MONITOR_ITEMS.filter((m) => m.tone !== 'ok')
const errors = bad.filter((m) => m.tone === 'er').length
const warnings = bad.length - errors
const ok = MONITOR_ITEMS.length - bad.length
return (
<Popover>
<PopoverTrigger
render={
<Button
variant="outline"
type="button"
aria-label={`Состояние системы: внимание, ${errors} сбой, ${warnings} предупреждения`}
className="border-warning/40 bg-warning/10 hover:bg-warning/20 aria-expanded:bg-warning/20 relative h-8 gap-2 px-2 max-[760px]:w-8 max-[760px]:border-transparent max-[760px]:bg-transparent max-[760px]:px-0"
/>
}
>
<ActivityIcon className="text-warning size-4" aria-hidden />
<span className="text-warning hidden text-sm font-medium min-[1000px]:inline">Внимание</span>
<span className="hidden items-center gap-1.5 text-sm tabular-nums min-[1240px]:inline-flex">
<span className="bg-destructive size-1.5 rounded-full" aria-hidden />
{errors}
<span className="bg-warning size-1.5 rounded-full" aria-hidden />
{warnings}
</span>
<span className="bg-destructive hidden size-4.5 min-w-4.5 items-center justify-center rounded-full px-1 text-[11px] font-semibold text-white min-[760px]:inline-flex min-[1240px]:hidden">
{bad.length}
</span>
<span className="bg-destructive ring-background absolute top-1 right-1 size-2 rounded-full ring-2 min-[760px]:hidden" aria-hidden />
</PopoverTrigger>
<PopoverContent align="end" className="flex w-[24rem] max-w-[calc(100vw-1.5rem)] flex-col gap-2 p-2">
<div className="flex items-start justify-between gap-2 px-1.5 pt-1.5">
<div className="grid leading-tight">
<b className="text-sm font-semibold">Состояние системы</b>
<span className="text-muted-foreground text-xs">{monitor.updated}</span>
</div>
<Badge variant={errors > 0 ? "destructive-light" : warnings > 0 ? "warning-light" : "success-light"} size="sm">{errors > 0 ? "Сбой" : warnings > 0 ? "Внимание" : "Норма"}</Badge>
</div>
<div className="grid grid-cols-3 gap-1.5">
{[
{ n: errors, l: 'сбой', c: 'text-destructive' },
{ n: warnings, l: 'предупреждения', c: 'text-warning' },
{ n: ok, l: 'в норме', c: 'text-success' },
].map((x) => (
<span key={x.l} className="bg-muted/55 text-muted-foreground flex flex-col rounded-lg px-2.5 py-2 text-[11.5px]">
<b className={cn('text-lg leading-tight font-semibold tabular-nums', x.c)}>{x.n}</b>
{x.l}
</span>
))}
</div>
<ul className="flex flex-col gap-0.5">
{MONITOR_ITEMS.filter((m) => m.tone !== 'ok').map((m) => (
<li key={m.id}>
<Link to={m.to} className="hover:bg-muted/55 flex items-center gap-2.5 rounded-md px-1.5 py-2 outline-none focus-visible:bg-muted/55">
<span aria-hidden className={cn('size-2 shrink-0 rounded-full', m.tone === 'er' ? 'bg-destructive' : 'bg-warning')} />
<span className="grid min-w-0 flex-1 leading-tight">
<b className="truncate text-[13px] font-medium">{m.title}</b>
<span className="text-muted-foreground truncate text-xs">{m.text}</span>
</span>
<span className="text-muted-foreground shrink-0 text-[11.5px]">{m.age}</span>
</Link>
</li>
))}
</ul>
<div className="grid grid-cols-2 gap-1.5">
{MONITOR_METRICS.map((m) => (
<div key={m.l} className="bg-muted/55 flex min-w-0 flex-col gap-1.5 rounded-lg px-3 py-2.5">
<span className="text-muted-foreground text-xs leading-snug">{m.l}</span>
<b className={cn('text-base font-semibold whitespace-nowrap tabular-nums', m.tone === 'success' ? 'text-success' : 'text-warning')}>{m.v}</b>
<div className="bg-muted-foreground/25 mt-auto h-[3px] overflow-hidden rounded-full">
<i className={cn('block h-full rounded-full', m.tone === 'success' ? 'bg-success' : 'bg-warning')} style={{ width: `${m.p}%` }} />
</div>
</div>
))}
</div>
<Link to={monitor.footerTo} className="text-muted-foreground hover:text-foreground flex items-center justify-between px-1.5 pt-1 pb-0.5 text-sm">
{monitor.footerLabel}
<ArrowRightIcon className="size-3.5" aria-hidden />
</Link>
</PopoverContent>
</Popover>
)
}
function FreshnessPopover() {
const pathname = useRouterState({ select: (st) => st.location.pathname })
const { fresh: freshFor } = useChromeConfig()
const fresh = freshFor(pathname)
return (
<Popover>
<PopoverTrigger
render={
<Button type="button" variant="ghost" size="icon" aria-label="Источники и свежесть данных" title="Источники и свежесть данных" />
}
>
<InfoIcon aria-hidden />
</PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2">
<Badge variant="success-light" size="sm" className="w-fit">{fresh.badge}</Badge>
<p className="text-muted-foreground text-sm">{fresh.text}</p>
</PopoverContent>
</Popover>
)
}
export function PortalHeader() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const { setPaletteOpen } = useUiStore()
const { navItems, crumbFor } = useChromeConfig()
const current = navItems.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`))
return (
<header className="bg-background/80 sticky top-0 z-20 flex min-h-12 shrink-0 items-center gap-2 border-b px-4 py-1.5 backdrop-blur md:px-6">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb className="min-w-0">
<BreadcrumbList className="flex-nowrap">
<BreadcrumbItem className="min-w-0">
<BreadcrumbPage className="truncate">{crumbFor ? crumbFor(pathname, current?.label) : (current?.label ?? 'Auth Portal')}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex shrink-0 items-center gap-2">
<FreshnessPopover />
<Button
type="button"
variant="outline"
size="icon"
aria-label="Командная палитра (⌘K)"
className="md:hidden"
onClick={() => setPaletteOpen(true)}
>
<SearchIcon aria-hidden />
</Button>
<MonitorPopover />
</div>
</header>
)
}
type PaletteEntry = {
id: string
group: string
label: string
hint: string
icon?: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
dot?: 'ok' | 'er' | 'mu'
run: () => void
}
const DOT_BG = { ok: 'bg-success', er: 'bg-destructive', mu: 'bg-muted-foreground' } as const
/** Палитра ⌘K как на доске: 640px, группы заглавными, подсказки моноширинным справа, подвал с подсказкой. */
export function CommandPalette() {
const { paletteOpen, setPaletteOpen } = useUiStore()
const { navItems, palette } = useChromeConfig()
const navigate = useNavigate()
const { theme, setTheme, resolvedTheme } = useTheme()
const [query, setQuery] = useState('')
const [active, setActive] = useState(0)
const shown = useMemo(() => {
const q = query.trim().toLowerCase()
const match = (...t: string[]) => !q || t.some((x) => x.toLowerCase().includes(q))
const go = (to: string) => () => void navigate({ to })
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
const list: PaletteEntry[] = [
...[
...palette.actions.map((x) => ({ id: x.id, label: x.label, hint: x.hint, icon: x.icon, run: go(x.to) })),
{ id: 'act-theme', label: 'Переключить тему', hint: 'T', icon: MoonIcon, run: () => setTheme(current === 'dark' ? 'light' : 'dark') },
]
.filter((e) => match(e.label))
.map((e) => ({ ...e, group: 'Действия' })),
...navItems.filter((i) => match(i.label, i.group))
.slice(0, 7)
.map((i) => ({ id: `nav-${i.id}`, group: 'Разделы', label: i.label, hint: i.group, icon: i.icon, run: go(i.to) })),
...Object.values(
palette.objects
.filter((o) => match(o.label, o.hint))
.reduce<Record<string, typeof palette.objects>>((acc, o) => ((acc[o.group] ??= []).push(o), acc), {}),
).flatMap((g) => g.slice(0, 5).map((o) => ({ id: o.id, group: o.group, label: o.label, hint: o.hint, dot: o.dot, run: go(o.to) }))),
]
// Запрос из трёх и более слов — черновик ассистента (как на доске «Палитра ⌘K»).
if (query.trim().split(/\s+/).filter(Boolean).length >= 3) {
return [
{
id: 'ai',
group: 'Ассистент · запрос на естественном языке',
label: `Понял так: «${query.trim()}»`,
hint: 'Черновик: запрос разобран автоматически, результат нужно проверить.',
run: () => void navigate({ to: palette.assistantTo }),
} as PaletteEntry,
...list,
]
}
return list
}, [query, navigate, theme, resolvedTheme, setTheme, navItems, palette])
const groups = useMemo(() => {
const map = new Map<string, Array<PaletteEntry & { index: number }>>()
shown.forEach((e, index) => {
const list = map.get(e.group) ?? []
list.push({ ...e, index })
map.set(e.group, list)
})
return [...map.entries()]
}, [shown])
function close() {
setPaletteOpen(false)
setQuery('')
setActive(0)
}
return (
<Dialog open={paletteOpen} onOpenChange={(o) => (o ? setPaletteOpen(true) : close())}>
<DialogContent className="top-[90px] w-[min(40rem,94vw)] max-w-none translate-y-0 gap-0 overflow-hidden p-0 sm:max-w-none" showCloseButton={false}>
<DialogTitle className="sr-only">Командная палитра</DialogTitle>
<DialogDescription className="sr-only">Разделы, пользователи, приложения и действия</DialogDescription>
<div className="flex items-center gap-2.5 border-b px-4 py-3.5">
<SearchIcon className="text-muted-foreground size-[18px]" aria-hidden />
<input
autoFocus
value={query}
aria-label="Поиск и команды"
placeholder="Раздел, пользователь, приложение или действие…"
className="min-w-0 flex-1 bg-transparent text-[15px] outline-none"
onChange={(e) => {
setQuery(e.target.value)
setActive(0)
}}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
setActive((a) => Math.min(a + 1, shown.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActive((a) => Math.max(a - 1, 0))
} else if (e.key === 'Enter') {
e.preventDefault()
shown[active]?.run()
close()
}
}}
/>
<Kbd>Esc</Kbd>
</div>
<div className="max-h-[430px] overflow-y-auto" role="listbox" aria-label="Результаты">
{groups.length === 0 ? (
<p className="text-muted-foreground px-4 py-6 text-center text-sm">Ничего не найдено</p>
) : (
groups.map(([group, list]) => (
<div key={group} className="flex flex-col">
<div className="text-muted-foreground px-4 pt-2.5 pb-1 text-[11px] tracking-[0.05em] uppercase">{group}</div>
{list.map((e) => {
const Icon = e.icon
return (
<button
key={e.id}
type="button"
role="option"
aria-selected={e.index === active}
onMouseEnter={() => setActive(e.index)}
onClick={() => {
e.run()
close()
}}
className={cn('flex w-full items-center gap-3 px-4 py-[9px] text-left text-sm outline-none', e.index === active && 'bg-muted/60')}
>
{Icon ? <Icon className="size-4 shrink-0" aria-hidden /> : null}
{e.dot ? <span className={cn('size-2 shrink-0 rounded-full', DOT_BG[e.dot])} aria-hidden /> : null}
<span className="min-w-0 truncate">{e.label}</span>
<span className="text-muted-foreground ml-auto truncate font-mono text-xs">{e.hint}</span>
</button>
)
})}
</div>
))
)}
</div>
<div className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 border-t px-4 py-2 text-[11.5px]">
<span>↑↓ выбор</span>
<span>↵ открыть</span>
<span>Esc закрыть</span>
<span className="ml-auto">{palette.example}</span>
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,26 @@
import type { DataGridFeatures, DataGridProps } from '@/components/reui/data-grid/data-grid'
export const DATA_GRID_CELL_PAD = 'py-3'
export const DATA_GRID_CELL_PAD_FIRST = 'pl-5 py-3'
export const DATA_GRID_CELL_PAD_LAST = 'pr-4 py-3'
export const DATA_GRID_LAYOUT: NonNullable<DataGridProps<DataGridFeatures, object>['tableLayout']> = {
rowBorder: true,
headerBackground: true,
headerBorder: true,
columnsResizable: false,
}
export const DATA_GRID_CLASS_NAMES: NonNullable<DataGridProps<DataGridFeatures, object>['tableClassNames']> = {
headerRow: 'border-b border-border',
bodyRow: 'group/row',
}
export const DATA_GRID_CONTAINER_CLASS = 'w-full rounded-none border-0'
/** Отступы ячейки по положению колонки: первая — pl-5, последняя — pr-4. */
export function cellPad(index: number, count: number) {
if (index === 0) return DATA_GRID_CELL_PAD_FIRST
if (index === count - 1) return DATA_GRID_CELL_PAD_LAST
return DATA_GRID_CELL_PAD
}
@@ -0,0 +1,123 @@
/**
* Каркас списочной страницы как в MikrotikManager (/servers): карточка Frame без полей, тулбар
* «сегменты · фильтры · поиск · счётчик», таблица с подложкой шапки и ячейками py-3.
*/
import type { ReactNode } from 'react'
import type { Column } from '@tanstack/react-table'
import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, SearchIcon } from 'lucide-react'
import { Frame, FramePanel } from '@/components/reui/frame'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@authportal/ui/components/input-group'
import { cn } from '@authportal/ui/lib/utils'
import type { DataGridFeatures } from '@/components/reui/data-grid/data-grid'
export function DataPageCard({ children, className }: { children: ReactNode; className?: string }) {
return (
<Frame dense className={cn('w-full min-w-0', className)}>
<FramePanel className="flex w-full min-w-0 flex-col gap-0 overflow-hidden p-0 py-0 shadow-none">{children}</FramePanel>
</Frame>
)
}
export function SegmentedControl<T extends string>({
value,
onChange,
options,
label,
}: {
value: T
onChange: (value: T) => void
options: { value: T; label: string; count?: number }[]
label?: string
}) {
return (
<div role="group" aria-label={label} className="border-border bg-muted/40 flex w-fit items-center gap-1 rounded-md border p-0.5">
{options.map((o) => (
<button
key={o.value}
type="button"
aria-pressed={value === o.value}
onClick={() => onChange(o.value)}
className={cn(
'flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors',
value === o.value ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
)}
>
{o.label}
{o.count != null ? <span className="text-xs tabular-nums">{o.count}</span> : null}
</button>
))}
</div>
)
}
export function DataPageToolbar({
leading,
segmented,
filterControl,
search,
onSearchChange,
searchPlaceholder = 'Поиск…',
countLabel,
actions,
}: {
leading?: ReactNode
segmented?: { value: string; onChange: (value: string) => void; options: { value: string; label: string; count?: number }[]; label?: string }
filterControl?: ReactNode
search?: string
onSearchChange?: (value: string) => void
searchPlaceholder?: string
countLabel?: string
actions?: ReactNode
}) {
return (
<div className="flex flex-wrap items-center gap-3 border-b px-5 py-3">
{leading}
{segmented ? <SegmentedControl {...segmented} /> : null}
{filterControl}
{onSearchChange ? (
<InputGroup className="max-w-sm min-w-[220px]">
<InputGroupAddon>
<SearchIcon className="size-3.5" aria-hidden />
</InputGroupAddon>
<InputGroupInput aria-label={searchPlaceholder} placeholder={searchPlaceholder} value={search ?? ''} onChange={(e) => onSearchChange(e.target.value)} />
</InputGroup>
) : null}
{countLabel ? <span className="text-muted-foreground ml-auto text-sm">{countLabel}</span> : null}
{actions}
</div>
)
}
export function DataGridSortHeader<TData extends object>({
column,
title,
className,
}: {
column: Column<DataGridFeatures, TData, unknown>
title: string
className?: string
}) {
const sorted = column.getIsSorted()
if (!column.getCanSort()) {
return <span className={cn('text-muted-foreground text-xs font-medium', className)}>{title}</span>
}
return (
<button
type="button"
className={cn(
'text-muted-foreground hover:text-foreground focus-visible:outline-ring -ml-1 inline-flex items-center gap-1.5 rounded-md px-1 py-0.5 text-xs font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
className,
)}
onClick={column.getToggleSortingHandler()}
>
{title}
{sorted === 'asc' ? (
<ArrowUpIcon aria-hidden className="text-foreground size-3" />
) : sorted === 'desc' ? (
<ArrowDownIcon aria-hidden className="text-foreground size-3" />
) : (
<ArrowUpDownIcon aria-hidden className="size-3 opacity-35" />
)}
</button>
)
}
@@ -2,7 +2,7 @@ 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, PreviewSheet, SignalAlert, StatusBadge } from './bits'
import { CodeBlock, PortalSheet, SignalAlert, StatusBadge } from './bits'
/**
* Баннер доски: заголовок и пояснение, справа «Объяснить» — открывает панель
@@ -38,7 +38,7 @@ export function ExplainBanner({
Объяснить
</Button>
</div>
<PreviewSheet
<PortalSheet
open={open}
onOpenChange={setOpen}
title="Ассистент · разбор"
@@ -51,7 +51,12 @@ export function ExplainBanner({
<Button
onClick={() => {
setOpen(false)
void navigate({ to: '/design-preview/logins' })
void navigate({
// В прод-образе preview-маршруты вырезаны (см. Dockerfile) — ведём в реальный журнал.
to: window.location.pathname.startsWith('/design-preview')
? '/design-preview/logins'
: '/admin/logins',
})
}}
>
Открыть журнал входов
@@ -77,7 +82,7 @@ export function ExplainBanner({
<SignalAlert tone="wn" title="Ассистент ничего не меняет сам">
Блокировка и сброс passkeys выполняются вручную с подтверждением и попадают в журнал «Изменения».
</SignalAlert>
</PreviewSheet>
</PortalSheet>
</>
)
}
+45
View File
@@ -0,0 +1,45 @@
const DATE = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' })
const SHORT = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit' })
const TIME = new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' })
function parse(iso: string | null | undefined): Date | null {
if (!iso) return null
const d = new Date(iso)
return Number.isNaN(d.getTime()) ? null : d
}
/** 12.09.2026 */
export function fmtDate(iso: string | null | undefined, empty = '—') {
const d = parse(iso)
return d ? DATE.format(d) : empty
}
/** «сегодня 09:41», «вчера 22:15», иначе «12.09 09:41». */
export function fmtWhen(iso: string | null | undefined, empty = '—', now = Date.now()) {
const d = parse(iso)
if (!d) return empty
const dayMs = 24 * 60 * 60 * 1000
const startOfToday = new Date(now)
startOfToday.setHours(0, 0, 0, 0)
const diff = startOfToday.getTime() - new Date(d).setHours(0, 0, 0, 0)
if (diff === 0) return `сегодня ${TIME.format(d)}`
if (diff === dayMs) return `вчера ${TIME.format(d)}`
return `${SHORT.format(d)} ${TIME.format(d)}`
}
/** Время дня 14:02. */
export function fmtTime(iso: string | null | undefined, empty = '—') {
const d = parse(iso)
return d ? TIME.format(d) : empty
}
/** «5 дн назад», «3 ч назад», «только что». */
export function fmtAgo(iso: string | null | undefined, empty = '—', now = Date.now()) {
const d = parse(iso)
if (!d) return empty
const sec = Math.max(0, Math.round((now - d.getTime()) / 1000))
if (sec < 60) return 'только что'
if (sec < 3600) return `${Math.floor(sec / 60)} мин назад`
if (sec < 86400) return `${Math.floor(sec / 3600)} ч назад`
return `${Math.floor(sec / 86400)} дн назад`
}
File renamed without changes.
@@ -1,10 +1,9 @@
import { useMemo, useState, type ReactNode } from 'react'
import { Columns3Icon, MoreHorizontalIcon, SearchIcon } from 'lucide-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 { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import {
@@ -17,7 +16,6 @@ import {
DataGridTableRowSelect,
DataGridTableRowSelectAll,
} from '@/components/reui/data-grid/data-grid-table'
import { Frame, FrameDescription, FrameFooter, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { Button } from '@authportal/ui/components/button'
import {
DropdownMenu,
@@ -26,14 +24,24 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@authportal/ui/components/input-group'
import { Separator } from '@authportal/ui/components/separator'
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 type { Tone } from './mock'
import type { RowMark } from './preview-store'
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> = {
@@ -83,7 +91,7 @@ const ACTIONS_COLUMN_SIZE = 120
* сохранённые виды линией со счётчиками, панель массовых действий, таблица со статусом и меню строки,
* подвал «Показано N из M». Ширина таблицы не меньше суммы колонок — прокрутка внутри контейнера.
*/
export function PreviewSectionGrid<T extends object>({
export function SectionGrid<T extends object>({
title,
description,
rows,
@@ -101,8 +109,7 @@ export function PreviewSectionGrid<T extends object>({
rowMenu,
onReorder,
rowStatus,
pageSize = 8,
footer,
pageSize = 50,
emptyMessage = 'Нет записей по текущим фильтрам.',
className,
}: {
@@ -194,20 +201,22 @@ export function PreviewSectionGrid<T extends object>({
enableSorting: false,
enableResizing: false,
size: SELECT_COLUMN_SIZE,
meta: { headerClassName: 'pl-5 py-3', cellClassName: 'pl-5 py-3' },
})
}
for (const col of visibleColumns) {
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 }) => <DataGridColumnHeader title={col.title} column={column} />,
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 },
meta: { headerTitle: col.title, headerClassName: pad, cellClassName: pad },
})
}
})
list.push({
id: '__actions',
header: () => <span className="sr-only">Действия</span>,
@@ -242,6 +251,7 @@ export function PreviewSectionGrid<T extends object>({
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])
@@ -277,6 +287,8 @@ export function PreviewSectionGrid<T extends object>({
const paged = data.length > pageSize
const showCards = Boolean(card) && cards
const countLabel = `${data.length} ${data.length === rows.length ? 'записей' : `из ${rows.length}`}`
return (
<DataGrid
table={table}
@@ -285,82 +297,72 @@ export function PreviewSectionGrid<T extends object>({
onRowClick={onRowClick}
getRowStatus={rowStatus}
emptyMessage={emptyMessage}
tableLayout={{ headerSticky: false, dense: false, width: 'auto', rowsDraggable: Boolean(onReorder) }}
tableLayout={{ ...DATA_GRID_LAYOUT, rowsDraggable: Boolean(onReorder) }}
tableClassNames={DATA_GRID_CLASS_NAMES}
>
<Frame className={cn('[--frame-panel-header-py-adjust:4px] w-full min-w-0', className)}>
<FrameHeader className="flex-row flex-wrap items-center gap-x-3 gap-y-2">
<div className="flex min-w-0 flex-1 basis-52 flex-col gap-0.5">
<FrameTitle>{title}</FrameTitle>
<FrameDescription className="text-xs text-pretty">{description}</FrameDescription>
</div>
{typeof tools === 'function' ? tools(selectedIds) : tools}
{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>
) : null}
{searchText ? (
<InputGroup className="w-[220px] max-w-full">
<InputGroupAddon>
<SearchIcon aria-hidden />
</InputGroupAddon>
<InputGroupInput
value={query}
placeholder="Поиск"
aria-label={searchPlaceholder}
onChange={(e) => {
setQuery(e.target.value)
setPagination((p) => ({ ...p, pageIndex: 0 }))
}}
/>
</InputGroup>
) : null}
{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}
</FrameHeader>
<FramePanel className="min-w-0 p-0 shadow-none">
{views ? (
<Tabs
value={String(view)}
onValueChange={(v) => {
setView(Number(v))
setPagination((p) => ({ ...p, pageIndex: 0 }))
}}
>
<TabsList variant="line" aria-label="Сохранённые виды" className="h-10! w-full justify-start gap-4 overflow-x-auto px-4">
{views.map((v, i) => (
<TabsTrigger key={v.label} value={String(i)} className="px-1 text-[13px]">
{v.label} · {rows.filter(v.match).length}
<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>
))}
</TabsList>
</Tabs>
) : null}
<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"
@@ -461,24 +463,13 @@ export function PreviewSectionGrid<T extends object>({
</ul>
) : (
<DataGridScrollArea>
<div className="pb-3" style={{ minWidth }}>
{onReorder ? (
<DataGridTableDndRows dataIds={dataIds} handleDragEnd={handleDragEnd} />
) : (
<DataGridTable />
)}
<div style={{ minWidth }}>
{onReorder ? <DataGridTableDndRows dataIds={dataIds} handleDragEnd={handleDragEnd} /> : <DataGridTable />}
</div>
</DataGridScrollArea>
)}
<Separator />
<FrameFooter className="flex-wrap justify-between gap-2">
<span className="text-muted-foreground text-xs">
{footer ?? `Показано ${showCards ? data.length : table.getRowModel().rows.length} из ${data.length}`}
</span>
{paged && !showCards ? <DataGridPagination sizes={[8, 16, 32]} /> : null}
</FrameFooter>
</FramePanel>
</Frame>
{paged && !showCards ? <DataGridPagination className="px-5 py-3" sizes={[8, 16, 32]} /> : null}
</DataPageCard>
</DataGrid>
)
}
@@ -1,4 +1,8 @@
import type { Tone } from './mock'
export type Tone = 'ok' | 'wn' | 'er' | 'mu' | 'in'
/** Пометка строки с накопленной правкой. */
export type RowMark = 'new' | 'dirty' | 'deleted'
type BadgeVariant =
| 'success-light'
@@ -44,6 +44,13 @@ import {
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,
@@ -54,14 +61,6 @@ import type {
FilterField,
FilterQuery,
} from '@/components/reui/filters/filters-types'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import {
AlertDialog,
AlertDialogAction,
@@ -93,7 +92,6 @@ import {
SelectTrigger,
SelectValue,
} from '@authportal/ui/components/select'
import { Separator } from '@authportal/ui/components/separator'
import { Skeleton } from '@authportal/ui/components/skeleton'
import { TooltipProvider } from '@authportal/ui/components/tooltip'
@@ -201,15 +199,6 @@ function formatLastActive(iso: string | null) {
}).format(date)
}
function DotSeparator() {
return (
<span
className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
)
}
function RoleBadge({ isAdmin }: { isAdmin: boolean }) {
return isAdmin ? (
<Badge variant="info-outline">Админ</Badge>
@@ -710,15 +699,6 @@ export function AdminUsersGrid({
const [bulkRole, setBulkRole] = useState<BulkRole>('user')
const lastAppliedKey = useRef<string>(serializeConditionsKey([]))
const stats = useMemo(
() => ({
total: users.length,
admins: users.filter((u) => u.is_admin).length,
disabled: users.filter((u) => u.disabled).length,
}),
[users],
)
const filterFields: FilterField[] = useMemo(
() => [
{
@@ -778,6 +758,16 @@ export function AdminUsersGrid({
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],
)
@@ -850,38 +840,17 @@ export function AdminUsersGrid({
: undefined
}
tableLayout={{
...DATA_GRID_LAYOUT,
columnsResizable: true,
columnsMovable: true,
columnsVisibility: true,
headerSticky: true,
dense: true,
}}
tableClassNames={DATA_GRID_CLASS_NAMES}
>
<Frame spacing="sm" className="w-full">
<FrameHeader className="flex-row items-center justify-between gap-3">
<div className="flex flex-col gap-0.5">
<FrameTitle id="page-heading" className="text-balance">
Пользователи
</FrameTitle>
<FrameDescription className="flex items-center gap-1.5 text-xs text-pretty">
<span>{stats.total} учётных записей</span>
<DotSeparator />
<span>{stats.admins} админов</span>
{stats.disabled > 0 ? (
<>
<DotSeparator />
<span>{stats.disabled} отключено</span>
</>
) : null}
</FrameDescription>
</div>
<Button type="button" size="default" onClick={onCreateClick}>
<UserPlusIcon aria-hidden="true" />
Создать
</Button>
</FrameHeader>
<FramePanel className="p-0 shadow-none">
<div className="flex flex-wrap items-center justify-between gap-2 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
<DataPageCard>
<DataPageToolbar
filterControl={
<Filters
query={query}
fields={filterFields}
@@ -890,27 +859,18 @@ export function AdminUsersGrid({
operatorLabels={FILTER_OPERATORS_RU}
size="default"
trigger={
<Button
type="button"
size="default"
variant="outline"
aria-label="Фильтры"
>
<Button type="button" size="default" variant="outline" aria-label="Фильтры">
<FilterIcon aria-hidden />
Фильтры
</Button>
}
/>
<div className="flex flex-wrap items-center gap-2">
}
countLabel={`${filteredData.length} пользователей`}
actions={
<>
{showClearButton ? (
<Button
type="button"
size="default"
variant="outline"
className="shrink-0"
onClick={handleClearFilters}
disabled={gridLoading}
>
<Button type="button" size="default" variant="outline" className="shrink-0" onClick={handleClearFilters} disabled={gridLoading}>
<FunnelXIcon aria-hidden />
Сбросить
</Button>
@@ -918,42 +878,34 @@ export function AdminUsersGrid({
<DataGridColumnVisibility
table={table}
trigger={
<Button
type="button"
size="default"
variant="outline"
aria-label="Настройки таблицы"
>
<Button type="button" size="default" variant="outline" aria-label="Настройки таблицы">
<Settings2Icon aria-hidden="true" />
Колонки
</Button>
}
/>
</div>
</div>
<Separator />
{selectedCount > 0 ? (
<>
<AdminUsersBulkActionBar
selectedCount={selectedCount}
roleValue={bulkRole}
onRoleChange={setBulkRole}
onChangeRole={handleChangeRole}
onDeactivate={handleBulkDeactivate}
onClear={handleClearSelection}
/>
<Separator />
<Button type="button" size="default" onClick={onCreateClick}>
<UserPlusIcon aria-hidden="true" />
Создать
</Button>
</>
) : null}
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<Separator />
<FrameFooter>
<DataGridPagination sizes={[10, 20, 30]} />
</FrameFooter>
</FramePanel>
</Frame>
}
/>
{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>
)
@@ -16,4 +16,3 @@ export { UserAuditSheet } from './user-audit-sheet'
export { CreateUserSheet } from './create-user-sheet'
export { CreateOidcClientSheet } from './create-oidc-client-sheet'
export { AdminUsersGrid, type AdminUsersGridProps } from './admin-users-grid'
export { PasskeySettingsPanel } from './passkey-settings-panel'
@@ -1,213 +0,0 @@
/**
* Passkey management — DNA settings-16 / settings-2 / settings-10.
* Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-2 · https://reui.io/preview/base/settings-10
* Docs: https://reui.io/docs/components/base/frame
*/
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { FingerprintIcon, PlusIcon, Trash2Icon } from 'lucide-react'
import {
startRegistration,
browserSupportsWebAuthn,
} from '@simplewebauthn/browser'
import type { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/browser'
import type { PasskeyCredential } from '@authportal/shared'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { Button } from '@authportal/ui/components/button'
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from '@authportal/ui/components/item'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@authportal/ui/components/alert-dialog'
import { Skeleton } from '@authportal/ui/components/skeleton'
import { toast } from 'sonner'
import { ApiError } from '@/lib/api-client'
import {
deletePasskey,
passkeysQueryKey,
passkeysQueryOptions,
webauthnRegister,
webauthnRegisterOptions,
} from '@/queries/webauthn'
function formatWhen(iso: string | null) {
if (!iso) return 'ещё не использовался'
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('ru-RU', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date)
}
export function PasskeySettingsPanel() {
const queryClient = useQueryClient()
const { data: passkeys = [], isLoading } = useQuery(passkeysQueryOptions)
const [pendingDelete, setPendingDelete] = useState<PasskeyCredential | null>(
null,
)
const supported = browserSupportsWebAuthn()
const registerMutation = useMutation({
mutationFn: async () => {
const { challenge_id, options } = await webauthnRegisterOptions()
const attResp = await startRegistration({
optionsJSON:
options as unknown as PublicKeyCredentialCreationOptionsJSON,
})
return webauthnRegister(challenge_id, attResp)
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: passkeysQueryKey })
toast.success('Passkey добавлен')
},
onError: (err) => {
toast.error(
err instanceof ApiError ? err.message : 'Не удалось добавить passkey',
)
},
})
const deleteMutation = useMutation({
mutationFn: (id: string) => deletePasskey(id),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: passkeysQueryKey })
toast.success('Passkey удалён')
setPendingDelete(null)
},
onError: (err) => {
toast.error(
err instanceof ApiError ? err.message : 'Не удалось удалить passkey',
)
},
})
return (
<>
<Frame className="w-full">
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<FrameTitle>Passkeys</FrameTitle>
<FrameDescription>
Вход без пароля: Windows Hello, Face ID, ключ безопасности
</FrameDescription>
</div>
<Button
type="button"
size="sm"
disabled={!supported || registerMutation.isPending}
onClick={() => registerMutation.mutate()}
>
<PlusIcon aria-hidden="true" />
{registerMutation.isPending ? 'Ожидание…' : 'Добавить passkey'}
</Button>
</FrameHeader>
<FramePanel className="flex flex-col gap-2">
{!supported ? (
<p className="text-muted-foreground text-sm">
Этот браузер не поддерживает WebAuthn.
</p>
) : null}
{isLoading ? (
<div className="flex flex-col gap-2">
<Skeleton className="h-16 w-full rounded-xl" />
<Skeleton className="h-16 w-full rounded-xl" />
</div>
) : passkeys.length === 0 ? (
<p className="text-muted-foreground text-sm">
Ключи не зарегистрированы. Добавьте passkey, чтобы входить без
пароля.
</p>
) : (
passkeys.map((item) => (
<Item key={item.id} variant="outline" className="items-start">
<ItemMedia variant="icon">
<FingerprintIcon className="size-4" aria-hidden="true" />
</ItemMedia>
<ItemContent>
<ItemTitle className="flex flex-wrap items-center gap-2">
{item.name}
{item.device_type === 'multiDevice' ? (
<Badge variant="info-outline" size="sm">
Синхронизируется
</Badge>
) : null}
</ItemTitle>
<ItemDescription>
Добавлен {formatWhen(item.created_at)} · вход{' '}
{formatWhen(item.last_used_at)}
</ItemDescription>
</ItemContent>
<ItemActions>
<Button
type="button"
size="sm"
variant="ghost"
aria-label={`Удалить ${item.name}`}
onClick={() => setPendingDelete(item)}
>
<Trash2Icon aria-hidden="true" />
Удалить
</Button>
</ItemActions>
</Item>
))
)}
</FramePanel>
</Frame>
<AlertDialog
open={Boolean(pendingDelete)}
onOpenChange={(open) => {
if (!open) setPendingDelete(null)
}}
>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Удалить passkey?</AlertDialogTitle>
<AlertDialogDescription>
{pendingDelete
? `«${pendingDelete.name}» больше нельзя будет использовать для входа.`
: null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deleteMutation.isPending}
onClick={() => {
if (pendingDelete) deleteMutation.mutate(pendingDelete.id)
}}
>
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
@@ -10,7 +10,7 @@ import {
} from '@authportal/ui/components/select'
import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import { CodeBlock, SignalAlert } from './bits'
import { CodeBlock, SignalAlert } from '@/components/portal/bits'
import { withLevel, levelOf, type AccessValue, type Level } from './access-model'
import { APP_SHORT, USER_JOURNAL } from './mock'
@@ -1,6 +1,6 @@
import { StatusBadge } from './bits'
import { StatusBadge } from '@/components/portal/bits'
import type { Tone } from './mock'
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from './section-grid'
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
type MatrixRow = {
id: string
@@ -39,7 +39,7 @@ const COLUMNS: GridColumn<MatrixRow>[] = [
/** Матрица доступа по доске: сколько пользователей имеет доступ к каждому приложению и с каким уровнем. */
export function AccessMatrix() {
return (
<PreviewSectionGrid
<SectionGrid
title="Матрица доступа"
description="Сколько пользователей имеет доступ к каждому приложению и с каким уровнем"
rows={ROWS}
+3 -3
View File
@@ -3,7 +3,7 @@ import type { AdminUser } from '@authportal/shared'
import { Button } from '@authportal/ui/components/button'
import { AccessEditor } from './access-editor'
import { diffAccess, type AccessValue } from './access-model'
import { PreviewSheet } from './bits'
import { PortalSheet } from '@/components/portal/bits'
/** Окно «Права доступа»: широкая правая панель, чтобы таблица оставалась в контексте. */
export function AccessSheet({
@@ -19,7 +19,7 @@ export function AccessSheet({
const changes = useMemo(() => diffAccess({ apps: user.apps, permissions: user.permissions }, value).length, [user, value])
return (
<PreviewSheet
<PortalSheet
open
onOpenChange={(o) => !o && onClose()}
size="lg"
@@ -47,6 +47,6 @@ export function AccessSheet({
}
>
<AccessEditor user={user} value={value} onChange={setValue} />
</PreviewSheet>
</PortalSheet>
)
}
@@ -6,10 +6,10 @@ import { Field, FieldDescription, FieldError, FieldLabel } from '@authportal/ui/
import { Input } from '@authportal/ui/components/input'
import { Switch } from '@authportal/ui/components/switch'
import { Textarea } from '@authportal/ui/components/textarea'
import { PreviewSheet, SignalAlert } from './bits'
import { PortalSheet, SignalAlert } from '@/components/portal/bits'
/** Окно «Создать пользователя» по доске: email, имя, временный пароль, администратор и заметка про журнал. */
export function CreateUserPreviewSheet({
export function CreateUserPortalSheet({
open,
onOpenChange,
onSubmit,
@@ -37,7 +37,7 @@ export function CreateUserPreviewSheet({
}
return (
<PreviewSheet
<PortalSheet
open={open}
onOpenChange={(o) => {
if (!o) reset()
@@ -93,12 +93,12 @@ export function CreateUserPreviewSheet({
<SignalAlert tone="wn" title="Журнал">
Создание записывается в журнал «Изменения» с вашим именем и IP.
</SignalAlert>
</PreviewSheet>
</PortalSheet>
)
}
/** Окно «Создать OIDC-клиент» по доске: название, redirect URI, включён. */
export function CreateOidcPreviewSheet({
export function CreateOidcPortalSheet({
open,
onOpenChange,
onSubmit,
@@ -128,7 +128,7 @@ export function CreateOidcPreviewSheet({
}
return (
<PreviewSheet
<PortalSheet
open={open}
onOpenChange={(o) => {
if (!o) reset()
@@ -189,6 +189,6 @@ export function CreateOidcPreviewSheet({
<FieldDescription>Выключенный клиент не может получить токены</FieldDescription>
</div>
</Field>
</PreviewSheet>
</PortalSheet>
)
}
@@ -1,145 +0,0 @@
@import '@fontsource-variable/geist';
@import '@fontsource-variable/geist-mono';
/* Плотность и контраст макета. Атрибуты ставит PreviewProvider и снимает при выходе. */
html[data-preview-density='compact'] [data-slot='data-grid-table'] :is(th, td) {
padding-block: 0.25rem;
}
html[data-preview-density='compact'] [data-slot='sidebar-inset'] > div:last-child {
gap: 0.75rem;
}
/* Палитра MikrotikManager v2 (доски Auth Portal): только пока смонтирован макет. */
html[data-preview-density] {
--background: oklch(0.973 0.004 247.858);
--foreground: oklch(0.205 0.01 264);
--card: oklch(1 0 0);
--card-foreground: oklch(0.205 0.01 264);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.205 0.01 264);
--secondary: oklch(0.955 0.005 264);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.95 0.006 264);
--muted-foreground: oklch(0.44 0.02 264);
--accent: oklch(0.93 0.01 264);
--accent-foreground: oklch(0.205 0 0);
--border: oklch(0.88 0.008 264);
--input: oklch(0.86 0.008 264);
--ring: oklch(0.488 0.243 264.376);
--sidebar: oklch(0.985 0.003 264);
--sidebar-foreground: oklch(0.205 0.01 264);
--sidebar-accent: oklch(0.93 0.01 264);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.88 0.008 264);
--invert: oklch(0.9 0.01 264);
--sidebar-primary: oklch(0.488 0.243 264.376);
--destructive-foreground: oklch(0.45 0.2 27);
--success: oklch(0.6 0.15 163);
--success-foreground: oklch(0.4 0.1 163);
--warning: oklch(0.75 0.16 80);
--warning-foreground: oklch(0.45 0.1 70);
--info: oklch(0.55 0.23 293);
--info-foreground: oklch(0.42 0.2 293);
}
html[data-preview-density].dark {
--background: oklch(0.133 0.011 264.376);
--foreground: oklch(0.985 0 0);
--card: oklch(0.191 0.011 264.376);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.191 0.011 264.376);
--popover-foreground: oklch(0.985 0 0);
--secondary: oklch(0.252 0.01 264.376);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.252 0.01 264.376);
--muted-foreground: oklch(0.648 0.008 264.376);
--accent: oklch(0.285 0.022 264.376);
--accent-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.488 0.243 264.376);
--sidebar: oklch(0.191 0.011 264.376);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.262 0.014 264.376);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--invert: oklch(0.37 0.013 285.805);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.577 0.245 27.325);
--success: oklch(0.696 0.17 162.48);
--success-foreground: oklch(0.596 0.145 163.225);
--warning: oklch(0.795 0.184 86.047);
--warning-foreground: oklch(0.681 0.162 75.834);
--info: oklch(0.606 0.25 292.717);
--info-foreground: oklch(0.541 0.281 293.009);
}
html[data-preview-density][data-preview-contrast='high'] {
--border: color-mix(in oklch, var(--foreground) 55%, transparent);
--input: color-mix(in oklch, var(--foreground) 60%, transparent);
--muted-foreground: color-mix(in oklch, var(--foreground) 88%, transparent);
--ring: oklch(0.35 0.2 264);
}
html[data-preview-density].dark[data-preview-contrast='high'] {
--background: oklch(0 0 0);
--primary: oklch(1 0 0);
--primary-foreground: oklch(0 0 0);
--sidebar: oklch(0.1 0 0);
--foreground: oklch(1 0 0);
--success: oklch(0.82 0.19 150);
--success-foreground: oklch(0.9 0.15 150);
--warning: oklch(0.9 0.17 95);
--warning-foreground: oklch(0.95 0.12 95);
--destructive: oklch(0.78 0.2 25);
--destructive-foreground: oklch(0.88 0.12 25);
--info: oklch(0.8 0.15 300);
--info-foreground: oklch(0.9 0.1 300);
--card: oklch(0.12 0 0);
--popover: oklch(0.1 0 0);
--muted: oklch(0.22 0 0);
--muted-foreground: oklch(0.86 0 0);
--border: oklch(1 0 0 / 55%);
--input: oklch(1 0 0 / 60%);
--ring: oklch(0.85 0.17 95);
}
/* Шрифты дизайн-системы (Geist / Geist Mono) — только пока смонтирован макет. */
html[data-preview-density] {
--font-sans: 'Geist Variable', ui-sans-serif, system-ui, sans-serif;
--font-mono: 'Geist Mono Variable', ui-monospace, monospace;
}
html[data-preview-density],
html[data-preview-density] body {
font-family: var(--font-sans);
}
/* Видимый фокус и reduced motion — как в макете MikrotikManager v2. */
html[data-preview-density] [data-slot='sidebar-wrapper'] :focus-visible,
html[data-preview-density] #main-content :focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
html[data-preview-density] #main-content *,
html[data-preview-density] [data-slot='sidebar-wrapper'] * {
animation: none !important;
transition: none !important;
}
}
/* Шапка таблицы как на досках: 12px, лёгкая подложка. */
html[data-preview-density] [data-slot='data-grid-table'] thead th,
html[data-preview-density] [data-slot='data-grid-table'] thead th :is(span, button, div) {
font-size: 0.75rem;
background: color-mix(in oklch, var(--muted) 30%, transparent);
}
/* Затемнение под панелями без размытия — как на досках. */
html[data-preview-density] :is([data-slot='sheet-overlay'], [data-slot='dialog-overlay'], [data-slot='alert-dialog-overlay']) {
background: oklch(0 0 0 / 55%);
backdrop-filter: none;
}
+2 -1
View File
@@ -21,7 +21,8 @@ import {
type AuditLogEntry,
} from '@authportal/shared'
export type Tone = 'ok' | 'wn' | 'er' | 'mu' | 'in'
import type { Tone } from '@/components/portal/tone'
export type { Tone }
export type NavItem = {
id: string
@@ -15,13 +15,13 @@ import {
} from '@authportal/ui/components/select'
import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { KpiStrip } from '../kpi-strip'
import { PageHead, PreviewSheet, StatusBadge, PageTab, PageTabsList, FormFrame, SettingRow } from '../bits'
import { AppCards } from '../canvas-blocks'
import { kpi } from '../kpi'
import { KpiStrip } from '@/components/portal/kpi-strip'
import { PageHead, PortalSheet, StatusBadge, PageTab, PageTabsList, FormFrame, SettingRow } from '@/components/portal/bits'
import { AppCards } from '@/components/portal/canvas-blocks'
import { kpi } from '@/components/portal/kpi'
import { MOCK_SWITCHER } from '../mock'
import { usePreviewStore } from '../preview-store'
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid'
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
type LinkRow = {
id: AppId
@@ -180,7 +180,7 @@ export function AppLinksPage() {
<PageTab value="menu" label="Меню" />
</PageTabsList>
<TabsContent value="list" className="min-w-0">
<PreviewSectionGrid
<SectionGrid
title="Ссылки сервисов"
description="Порядок строк = порядок в меню · переключатель управляет видимостью · изменения применяются после сохранения"
rows={rows}
@@ -247,7 +247,7 @@ export function AppLinksPage() {
</TabsContent>
</Tabs>
<PreviewSheet
<PortalSheet
open={editing !== null}
onOpenChange={(o) => !o && setEditing(null)}
title="Приложение"
@@ -323,7 +323,7 @@ export function AppLinksPage() {
</Field>
</>
) : null}
</PreviewSheet>
</PortalSheet>
</>
)
}
@@ -2,12 +2,12 @@ import { Link } from '@tanstack/react-router'
import { BadgeCheckIcon, CloudIcon, GlobeIcon, KeyRoundIcon, LayoutDashboardIcon, LayoutGridIcon, ServerIcon, ShieldIcon } from 'lucide-react'
import { toast } from 'sonner'
import { APPS, type AppId } from '@authportal/shared'
import { KpiStrip } from '../kpi-strip'
import { KpiStrip } from '@/components/portal/kpi-strip'
import { Button } from '@authportal/ui/components/button'
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { PageHead, PageTab, PageTabsList } from '../bits'
import { AppCards, type AppCardItem } from '../canvas-blocks'
import { kpi } from '../kpi'
import { PageHead, PageTab, PageTabsList } from '@/components/portal/bits'
import { AppCards, type AppCardItem } from '@/components/portal/canvas-blocks'
import { kpi } from '@/components/portal/kpi'
const ICONS: Record<AppId, typeof CloudIcon> = {
cfdm: CloudIcon,
@@ -1,16 +1,16 @@
import { HistoryIcon, NetworkIcon, OctagonAlertIcon, UserCogIcon } from 'lucide-react'
import { useState } from 'react'
import { toast } from 'sonner'
import { KpiStrip } from '../kpi-strip'
import { KpiStrip } from '@/components/portal/kpi-strip'
import { Button } from '@authportal/ui/components/button'
import { Input } from '@authportal/ui/components/input'
import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { CodeBlock, FormFrame, KvList, PageHead, PageTab, PageTabsList, PreviewSheet, SettingRow, StatusBadge, TypedConfirm } from '../bits'
import { ActivityFeed } from '../canvas-blocks'
import { kpi } from '../kpi'
import { CodeBlock, FormFrame, KvList, PageHead, PageTab, PageTabsList, PortalSheet, SettingRow, StatusBadge, TypedConfirm } from '@/components/portal/bits'
import { ActivityFeed } from '@/components/portal/canvas-blocks'
import { kpi } from '@/components/portal/kpi'
import { AUDIT_FEED, AUDIT_ROWS, type AuditRow } from '../mock'
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid'
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
const mono = (v: string) => <span className="font-mono text-[12.5px] whitespace-nowrap">{v}</span>
@@ -74,7 +74,7 @@ export function AuditPage() {
/>
</TabsContent>
<TabsContent value="table" className="min-w-0">
<PreviewSectionGrid
<SectionGrid
title="Журнал изменений"
description="Поиск по событию, пользователю и IP · виды по важности"
rows={AUDIT_ROWS}
@@ -113,7 +113,7 @@ export function AuditPage() {
</TabsContent>
</Tabs>
<PreviewSheet
<PortalSheet
open={entry !== null}
onOpenChange={(o) => !o && setEntry(null)}
title="Событие журнала"
@@ -137,7 +137,7 @@ export function AuditPage() {
</CodeBlock>
</>
) : null}
</PreviewSheet>
</PortalSheet>
<TypedConfirm
open={purgeOpen}
@@ -9,7 +9,7 @@ import { Button } from '@authportal/ui/components/button'
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input'
import { Separator } from '@authportal/ui/components/separator'
import { SignalAlert } from '../bits'
import { SignalAlert } from '@/components/portal/bits'
const PERKS = [
{ icon: FingerprintIcon, title: 'Passkey вместо пароля', text: 'Отпечаток, Face ID или ключ безопасности' },
@@ -2,14 +2,14 @@ import { CircleCheckIcon, GlobeIcon, MonitorSmartphoneIcon, ShieldAlertIcon } fr
import { useMemo, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { toast } from 'sonner'
import { KpiStrip } from '../kpi-strip'
import { KpiStrip } from '@/components/portal/kpi-strip'
import { Button } from '@authportal/ui/components/button'
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { ExplainBanner } from '../explain-banner'
import { Segmented, CodeBlock, KvList, PageHead, PreviewSheet, StatusBadge, PageTab, PageTabsList } from '../bits'
import { kpi } from '../kpi'
import { ExplainBanner } from '@/components/portal/explain-banner'
import { Segmented, CodeBlock, KvList, PageHead, PortalSheet, StatusBadge, PageTab, PageTabsList } from '@/components/portal/bits'
import { kpi } from '@/components/portal/kpi'
import { MOCK_LOGINS, MOCK_SESSIONS, type LoginRow, type SessionRow } from '../mock'
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid'
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
const PERIODS = ['24 ч', '7 д', '30 д', '90 д']
@@ -76,7 +76,7 @@ export function LoginsPage() {
<PageTab value="sess" label="Активные сессии" count={sessions.length} />
</PageTabsList>
<TabsContent value="hist" className="flex min-w-0 flex-col gap-4">
<PreviewSectionGrid
<SectionGrid
title="Журнал входов"
description="Поиск по логину и IP · фильтры: результат, приложение, пользователь · период выбирается выше"
rows={MOCK_LOGINS}
@@ -94,7 +94,7 @@ export function LoginsPage() {
/>
</TabsContent>
<TabsContent value="sess" className="flex min-w-0 flex-col gap-4">
<PreviewSectionGrid
<SectionGrid
title="Активные сессии"
description="Токены, действующие сейчас · завершение сессии выкидывает пользователя из портала и приложений"
rows={sessions}
@@ -112,7 +112,7 @@ export function LoginsPage() {
</TabsContent>
</Tabs>
<PreviewSheet
<PortalSheet
open={event !== null}
onOpenChange={(o) => !o && setEvent(null)}
title="Событие входа"
@@ -148,7 +148,7 @@ export function LoginsPage() {
<CodeBlock label="Событие">{`action: ${event.tone === 'er' ? 'auth.login_failed' : 'auth.login'}\nsource_app: portal\nseverity: ${event.tone === 'er' ? 'warning' : 'info'}\nip: ${event.ip}`}</CodeBlock>
</>
) : null}
</PreviewSheet>
</PortalSheet>
</>
)
}
+10 -10
View File
@@ -2,8 +2,8 @@ import { useMemo, useState } from 'react'
import { CircleCheckIcon, CopyIcon, KeyRoundIcon, LockKeyholeIcon, TicketIcon } from 'lucide-react'
import { toast } from 'sonner'
import type { CreateOidcClientValues } from '@/components/reui-kit/create-oidc-client-sheet'
import { CreateOidcPreviewSheet } from '../create-sheets'
import { KpiStrip } from '../kpi-strip'
import { CreateOidcPortalSheet } from '../create-sheets'
import { KpiStrip } from '@/components/portal/kpi-strip'
import { Button } from '@authportal/ui/components/button'
import {
AlertDialog,
@@ -18,9 +18,9 @@ import { Input } from '@authportal/ui/components/input'
import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { Textarea } from '@authportal/ui/components/textarea'
import { ExplainBanner } from '../explain-banner'
import { CopyList, KvFrame, PageHead, PreviewSheet, StatusBadge, TypedConfirm, PageTab, PageTabsList } from '../bits'
import { kpi } from '../kpi'
import { ExplainBanner } from '@/components/portal/explain-banner'
import { CopyList, KvFrame, PageHead, PortalSheet, StatusBadge, TypedConfirm, PageTab, PageTabsList } from '@/components/portal/bits'
import { kpi } from '@/components/portal/kpi'
import {
MOCK_CLIENT_SECRET,
MOCK_OIDC_CLIENTS,
@@ -29,7 +29,7 @@ import {
type OidcClientRow,
type Tone
} from '../mock'
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid'
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
function tone(c: OidcClientRow): Tone {
return !c.enabled ? 'mu' : c.stale ? 'wn' : 'ok'
@@ -111,7 +111,7 @@ export function OidcPage() {
<PageTab value="meta" label="Параметры issuer" />
</PageTabsList>
<TabsContent value="clients" className="flex min-w-0 flex-col gap-4">
<PreviewSectionGrid
<SectionGrid
title="Зарегистрированные клиенты"
description="Каждый клиент — внешнее приложение (RP) · секрет показывается один раз при создании и обновлении"
rows={clients}
@@ -148,9 +148,9 @@ export function OidcPage() {
</TabsContent>
</Tabs>
<CreateOidcPreviewSheet open={createOpen} onOpenChange={setCreateOpen} onSubmit={onCreate} />
<CreateOidcPortalSheet open={createOpen} onOpenChange={setCreateOpen} onSubmit={onCreate} />
<PreviewSheet
<PortalSheet
open={editing !== null}
onOpenChange={(o) => !o && setEditing(null)}
title="OIDC-клиент"
@@ -193,7 +193,7 @@ export function OidcPage() {
</Field>
</>
) : null}
</PreviewSheet>
</PortalSheet>
<TypedConfirm
open={rotating !== null}
@@ -1,17 +1,17 @@
import { useMemo, useState } from 'react'
import { ClockIcon, FingerprintIcon, KeyRoundIcon, LockIcon, MonitorSmartphoneIcon, TrashIcon } from 'lucide-react'
import { toast } from 'sonner'
import { KpiStrip } from '../kpi-strip'
import { KpiStrip } from '@/components/portal/kpi-strip'
import { Button } from '@authportal/ui/components/button'
import { Field, FieldDescription, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input'
import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { ExplainBanner } from '../explain-banner'
import { KvFrame, KvList, PageHead, PreviewSheet, SignalAlert, StatusBadge, TypedConfirm, PageTab, PageTabsList, FormFrame, SettingRow } from '../bits'
import { kpi } from '../kpi'
import { ExplainBanner } from '@/components/portal/explain-banner'
import { KvFrame, KvList, PageHead, PortalSheet, SignalAlert, StatusBadge, TypedConfirm, PageTab, PageTabsList, FormFrame, SettingRow } from '@/components/portal/bits'
import { kpi } from '@/components/portal/kpi'
import { MOCK_PASSKEYS, type PasskeyRow } from '../mock'
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid'
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
export function SecurityPage() {
const [keys, setKeys] = useState<PasskeyRow[]>(MOCK_PASSKEYS)
@@ -72,7 +72,7 @@ export function SecurityPage() {
<PageTab value="acc" label="Учётная запись" />
</PageTabsList>
<TabsContent value="keys" className="flex min-w-0 flex-col gap-4">
<PreviewSectionGrid
<SectionGrid
title="Мои passkeys"
description="Вход без пароля: отпечаток, Face ID, PIN или ключ безопасности · удаление необратимо"
rows={view}
@@ -137,7 +137,7 @@ export function SecurityPage() {
</TabsContent>
</Tabs>
<PreviewSheet
<PortalSheet
open={addOpen}
onOpenChange={setAddOpen}
title="Добавить passkey"
@@ -169,9 +169,9 @@ export function SecurityPage() {
<SignalAlert tone="in" title="Что произойдёт дальше">
Откроется системный диалог: подтвердите отпечатком, Face ID, PIN или ключом безопасности. Закрытый ключ не покидает устройство.
</SignalAlert>
</PreviewSheet>
</PortalSheet>
<PreviewSheet
<PortalSheet
open={selected !== null}
onOpenChange={(o) => !o && setSelected(null)}
title="Passkey"
@@ -213,7 +213,7 @@ export function SecurityPage() {
['Последний вход', `${selected?.lastUsed ?? ''} · 203.0.113.10`],
]}
/>
</PreviewSheet>
</PortalSheet>
<TypedConfirm
open={deleting !== null}
@@ -1,16 +1,16 @@
import { FingerprintIcon, ShieldIcon, UserXIcon, UsersIcon } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { AdminUser, CreateUserRequest } from '@authportal/shared'
import { CreateUserPreviewSheet } from '../create-sheets'
import { KpiStrip } from '../kpi-strip'
import { CreateUserPortalSheet } from '../create-sheets'
import { KpiStrip } from '@/components/portal/kpi-strip'
import { Button } from '@authportal/ui/components/button'
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { AccessSheet } from '../access-sheet'
import { diffAccess, type AccessValue } from '../access-model'
import { PageHead, TypedConfirm, PageTab, PageTabsList } from '../bits'
import { kpi } from '../kpi'
import { PageHead, TypedConfirm, PageTab, PageTabsList } from '@/components/portal/bits'
import { kpi } from '@/components/portal/kpi'
import { ALL_APP_IDS } from '../mock'
import { ExplainBanner } from '../explain-banner'
import { ExplainBanner } from '@/components/portal/explain-banner'
import { AccessMatrix } from '../access-matrix'
import { UsersGrid, type UsersGridHandlers } from '../users-grid'
import { usePreviewStore } from '../preview-store'
@@ -131,7 +131,7 @@ export function UsersPage() {
</TabsContent>
</Tabs>
<CreateUserPreviewSheet open={createOpen} onOpenChange={setCreateOpen} onSubmit={onCreate} />
<CreateUserPortalSheet open={createOpen} onOpenChange={setCreateOpen} onSubmit={onCreate} />
{accessFor ? (
<AccessSheet
@@ -67,7 +67,7 @@ import {
useSidebar,
} from '@authportal/ui/components/sidebar'
import { cn } from '@authportal/ui/lib/utils'
import { Segmented } from './bits'
import { Segmented } from '@/components/portal/bits'
import { FRESH_BY_PAGE, MONITOR_ITEMS, NAV_GROUPS, NAV_ITEMS, type NavItem } from './mock'
import { usePreviewStore } from './preview-store'
@@ -48,11 +48,11 @@ export function PreviewProvider({ children }: { children: ReactNode }) {
useEffect(() => {
const root = document.documentElement
root.dataset.previewDensity = density
root.dataset.previewContrast = contrast
root.dataset.density = density
root.dataset.contrast = contrast
return () => {
delete root.dataset.previewDensity
delete root.dataset.previewContrast
delete root.dataset.density
delete root.dataset.contrast
}
}, [density, contrast])
@@ -4,7 +4,6 @@ import { SidebarInset, SidebarProvider } from '@authportal/ui/components/sidebar
import { TooltipProvider } from '@authportal/ui/components/tooltip'
import { CommandPalette, PreviewHeader, PreviewSidebar } from './preview-chrome'
import { UnsavedBar } from './unsaved-bar'
import './design-preview.css'
/** Оболочка макета: сайдбар, шапка, палитра и панель «Не сохранено». Монтируется после гидрации. */
export function PreviewShell({ children }: { children: ReactNode }) {
+2 -1
View File
@@ -3,7 +3,8 @@ import type { AdminUser } from '@authportal/shared'
export type ChangeKind = 'add' | 'chg' | 'del'
export type RowMark = 'new' | 'dirty' | 'deleted'
import type { RowMark } from '@/components/portal/tone'
export type { RowMark }
export type PendingChange = {
id: string
+3 -3
View File
@@ -2,10 +2,10 @@ import { useMemo } from 'react'
import type { AdminUser } from '@authportal/shared'
import { toast } from 'sonner'
import { Button } from '@authportal/ui/components/button'
import { PendingChip, StatusBadge } from './bits'
import { PendingChip, StatusBadge } from '@/components/portal/bits'
import type { RowMark } from './preview-store'
import { ALL_APP_IDS, APP_SHORT, USER_META, type Tone } from './mock'
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from './section-grid'
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
function userStatus(u: AdminUser): { tone: Tone; label: string } {
if (u.disabled) return { tone: 'mu', label: 'Отключён' }
@@ -65,7 +65,7 @@ export function UsersGrid({ users, handlers, marks }: { users: AdminUser[]; hand
)
return (
<PreviewSectionGrid
<SectionGrid
title="Пользователи портала"
description="Поиск по email и имени · фильтры: роль, статус, приложения, passkey · колонки настраиваются, вид сохраняется"
rows={users}
+1
View File
@@ -6,6 +6,7 @@ import { ThemeProvider } from 'next-themes'
import { Toaster } from 'sonner'
import { routeTree } from './routeTree.gen'
import '@authportal/ui/globals.css'
import './portal-theme.css'
const queryClient = new QueryClient({
defaultOptions: {
+23
View File
@@ -0,0 +1,23 @@
/* Плотность портала. Палитра, шрифты и таблицы — нативные токены ReUI из @authportal/ui/globals.css. */
html[data-density='compact'] [data-slot='data-grid-table'] :is(th, td) {
padding-block: 0.25rem;
}
html[data-density='compact'] [data-slot='sidebar-inset'] > div:last-child {
gap: 0.75rem;
}
/* Видимый фокус и reduced motion — как в макете MikrotikManager v2. */
html [data-slot='sidebar-wrapper'] :focus-visible,
html #main-content :focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
html #main-content *,
html [data-slot='sidebar-wrapper'] * {
animation: none !important;
transition: none !important;
}
}
+270 -15
View File
@@ -1,25 +1,280 @@
/**
* Account security — passkeys.
* Preview: https://reui.io/preview/base/settings-10 · https://reui.io/preview/base/settings-16
*/
import { useMemo, useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { PageShell } from '@/components/page-shell'
import { PasskeySettingsPanel } from '@/components/reui-kit/passkey-settings-panel'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ClockIcon, FingerprintIcon, LayoutGridIcon, ShieldIcon, TrashIcon } from 'lucide-react'
import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser'
import type { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/browser'
import { toast } from 'sonner'
import type { PasskeyCredential } from '@authportal/shared'
import { ExplainBanner } from '@/components/portal/explain-banner'
import { KvFrame, KvList, PageHead, PageTab, PageTabsList, PortalSheet, SignalAlert, StatusBadge, TypedConfirm } from '@/components/portal/bits'
import { fmtDate, fmtWhen } from '@/components/portal/format'
import { kpi } from '@/components/portal/kpi'
import { KpiStrip } from '@/components/portal/kpi-strip'
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
import { Button } from '@authportal/ui/components/button'
import { Field, FieldDescription, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input'
import { Skeleton } from '@authportal/ui/components/skeleton'
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { ApiError } from '@/lib/api-client'
import { meQueryOptions } from '@/queries/auth'
import {
deletePasskey,
passkeysQueryKey,
passkeysQueryOptions,
renamePasskey,
webauthnRegister,
webauthnRegisterOptions,
} from '@/queries/webauthn'
export const Route = createFileRoute('/_auth/account')({
component: AccountPage,
})
const STALE_DAYS = 60
type Row = PasskeyCredential & { tone: 'ok' | 'wn'; status: string }
function toRow(k: PasskeyCredential, now: number): Row {
const last = new Date(k.last_used_at ?? k.created_at).getTime()
const stale = now - last > STALE_DAYS * 24 * 60 * 60 * 1000
return { ...k, tone: stale ? 'wn' : 'ok', status: stale ? 'Давно не использовался' : 'Активен' }
}
function kindOf(k: PasskeyCredential) {
return k.device_type === 'multiDevice' ? 'Синхронизируемый' : k.device_type === 'singleDevice' ? 'Ключ устройства' : 'Passkey'
}
function AccountPage() {
const queryClient = useQueryClient()
const { data: me } = useQuery(meQueryOptions)
const { data: passkeys = [], isLoading, isError, refetch, dataUpdatedAt } = useQuery(passkeysQueryOptions)
const supported = browserSupportsWebAuthn()
const [addOpen, setAddOpen] = useState(false)
const [newName, setNewName] = useState('')
const [selected, setSelected] = useState<Row | null>(null)
const [renameValue, setRenameValue] = useState('')
const [deleting, setDeleting] = useState<Row | null>(null)
const rows = useMemo(() => passkeys.map((k) => toRow(k, dataUpdatedAt || Date.parse(k.created_at))), [passkeys, dataUpdatedAt])
const lastUsed = rows.map((r) => r.last_used_at).filter((x): x is string => Boolean(x)).sort().at(-1) ?? null
const registerMutation = useMutation({
mutationFn: async (name: string) => {
const { challenge_id, options } = await webauthnRegisterOptions()
const att = await startRegistration({ optionsJSON: options as unknown as PublicKeyCredentialCreationOptionsJSON })
return webauthnRegister(challenge_id, att, name.trim() || undefined)
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: passkeysQueryKey })
setAddOpen(false)
setNewName('')
toast.success('Passkey добавлен')
},
onError: (err) => toast.error(err instanceof ApiError ? err.message : 'Не удалось добавить passkey'),
})
const renameMutation = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) => renamePasskey(id, name),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: passkeysQueryKey })
setSelected(null)
toast.success('Название сохранено')
},
onError: (err) => toast.error(err instanceof ApiError ? err.message : 'Не удалось переименовать passkey'),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => deletePasskey(id),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: passkeysQueryKey })
setDeleting(null)
toast.success('Passkey удалён')
},
onError: (err) => toast.error(err instanceof ApiError ? err.message : 'Не удалось удалить passkey'),
})
const columns = useMemo<GridColumn<Row>[]>(
() => [
{ id: 'name', title: 'Passkey', size: 280, sortValue: (r) => r.name, cell: (r) => <TwoLineCell dot={r.tone} title={r.name} sub={kindOf(r)} /> },
{ id: 'status', title: 'Статус', size: 210, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> },
{ id: 'added', title: 'Добавлен', size: 120, sortValue: (r) => r.created_at, cell: (r) => <span className="font-mono text-[12.5px]">{fmtDate(r.created_at)}</span> },
{ id: 'last', title: 'Последний вход', size: 180, sortValue: (r) => r.last_used_at ?? '', cell: (r) => <span className="font-mono text-[12.5px]">{r.last_used_at ? fmtWhen(r.last_used_at) : 'ещё не использовался'}</span> },
],
[],
)
const open = (r: Row) => {
setSelected(r)
setRenameValue(r.name)
}
return (
<PageShell>
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">Безопасность</h1>
<p className="text-muted-foreground text-sm">
Passkey как альтернатива паролю. Пароль остаётся запасным входом.
</p>
</div>
<PasskeySettingsPanel />
</PageShell>
<>
<PageHead
title="Безопасность"
sub="Passkey как альтернатива паролю · пароль остаётся запасным входом"
actions={
<Button disabled={!supported} onClick={() => setAddOpen(true)}>
Добавить passkey
</Button>
}
/>
{!supported ? (
<SignalAlert tone="wn" title="Браузер не поддерживает passkeys.">
Откройте портал по HTTPS в современном браузере. Вход по паролю остаётся доступным.
</SignalAlert>
) : null}
{supported && !isLoading && rows.length <= 1 ? (
<ExplainBanner
tone="wn"
title={rows.length === 0 ? 'Passkey не добавлен.' : 'Один passkey — единственная копия.'}
text={
rows.length === 0
? 'Сейчас вход возможен только по паролю. Добавьте passkey, чтобы входить отпечатком, Face ID или ключом безопасности.'
: 'Если устройство будет потеряно, вход возможен только по паролю. Добавьте второй ключ на другом устройстве или ключ безопасности.'
}
>
Совет: держите два ключа на разных устройствах, например платформенный и ключ безопасности.
</ExplainBanner>
) : null}
<KpiStrip
aria-label="Показатели"
items={[
kpi('keys', 'Passkeys', String(rows.length), { icon: FingerprintIcon, tone: rows.length ? 'ok' : 'wn', badge: rows.length ? 'активны' : 'не добавлены' }),
kpi('last', 'Последний вход', lastUsed ? fmtWhen(lastUsed) : '—', { icon: ClockIcon, badge: 'по passkey' }),
kpi('apps', 'Приложений', String(me?.apps.length ?? 0), { icon: LayoutGridIcon, badge: 'доступно вам' }),
kpi('role', 'Роль', me?.is_admin ? 'Админ' : 'Пользователь', { icon: ShieldIcon, badge: me?.is_admin ? 'is_admin' : 'по правам' }),
]}
/>
<Tabs defaultValue="keys" className="min-w-0">
<PageTabsList>
<PageTab value="keys" label="Passkeys" count={rows.length} />
<PageTab value="acc" label="Учётная запись" />
</PageTabsList>
<TabsContent value="keys" className="flex min-w-0 flex-col gap-4">
{isLoading ? (
<Skeleton className="h-64 w-full rounded-xl" />
) : isError ? (
<div role="alert" className="border-destructive/40 bg-destructive/10 flex flex-wrap items-center justify-between gap-3 rounded-xl border px-4 py-3 text-sm">
<span>Не удалось загрузить passkeys. Повторите запрос.</span>
<Button variant="outline" onClick={() => void refetch()}>
Повторить
</Button>
</div>
) : (
<SectionGrid
title="Мои passkeys"
description="Вход без пароля: отпечаток, Face ID, PIN или ключ безопасности · удаление необратимо"
rows={rows}
columns={columns}
getRowId={(r) => r.id}
searchText={(r) => `${r.name} ${kindOf(r)}`}
selectable={false}
views={[
{ label: 'Все', match: () => true },
{ label: 'Давно не используются', match: (r) => r.tone === 'wn' },
]}
rowMenu={(r) => ({ onOpen: () => open(r), onDelete: () => setDeleting(r) })}
onRowClick={open}
emptyMessage="Ключи не зарегистрированы. Добавьте passkey, чтобы входить без пароля."
/>
)}
</TabsContent>
<TabsContent value="acc">
<KvFrame
title={me?.email ?? '—'}
description="Данные учётной записи"
items={[
{ label: 'Имя', value: me?.name ?? '—' },
{ label: 'Роль', value: me?.is_admin ? 'Администратор' : 'Пользователь' },
{ label: 'Приложений', value: String(me?.apps.length ?? 0) },
{ label: 'Двухфакторная защита', value: rows.length ? `Passkey ×${rows.length}` : 'Не настроена' },
]}
/>
</TabsContent>
</Tabs>
<PortalSheet
open={addOpen}
onOpenChange={setAddOpen}
title="Добавить passkey"
description="Браузер предложит создать ключ для этого портала"
footer={
<>
<Button variant="outline" onClick={() => setAddOpen(false)}>
Отмена
</Button>
<Button disabled={registerMutation.isPending} onClick={() => registerMutation.mutate(newName)}>
{registerMutation.isPending ? 'Ожидание…' : 'Создать ключ'}
</Button>
</>
}
>
<Field>
<FieldLabel htmlFor="pk-name">Название ключа</FieldLabel>
<Input id="pk-name" value={newName} placeholder="Рабочий ноутбук" onChange={(e) => setNewName(e.target.value)} />
<FieldDescription>Чтобы отличать устройства в списке</FieldDescription>
</Field>
<SignalAlert tone="in" title="Что произойдёт дальше">
Откроется системный диалог: подтвердите отпечатком, Face ID, PIN или ключом безопасности. Закрытый ключ не покидает устройство.
</SignalAlert>
</PortalSheet>
<PortalSheet
open={selected !== null}
onOpenChange={(o) => !o && setSelected(null)}
title="Passkey"
description="Название и сведения о ключе"
footer={
<>
<Button
variant="outline"
className="mr-auto"
onClick={() => {
setDeleting(selected)
setSelected(null)
}}
>
<TrashIcon aria-hidden />
Удалить
</Button>
<Button variant="outline" onClick={() => setSelected(null)}>
Закрыть
</Button>
<Button
disabled={renameMutation.isPending || !renameValue.trim() || renameValue.trim() === selected?.name}
onClick={() => selected && renameMutation.mutate({ id: selected.id, name: renameValue.trim() })}
>
Сохранить
</Button>
</>
}
>
<Field>
<FieldLabel htmlFor="pk-rename">Название</FieldLabel>
<Input id="pk-rename" value={renameValue} onChange={(e) => setRenameValue(e.target.value)} />
</Field>
<KvList
items={[
['Тип', selected ? kindOf(selected) : ''],
['Добавлен', fmtDate(selected?.created_at)],
['Последний вход', selected?.last_used_at ? fmtWhen(selected.last_used_at) : 'ещё не использовался'],
]}
/>
</PortalSheet>
<TypedConfirm
open={deleting !== null}
onOpenChange={(o) => !o && setDeleting(null)}
title="Удалить passkey?"
description={`Ключ «${deleting?.name ?? ''}» будет удалён без возможности восстановления. Войти по нему больше не получится.`}
expected="удалить"
confirmLabel="Удалить ключ"
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
/>
</>
)
}
+4 -9
View File
@@ -2,7 +2,7 @@ import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { defaultAppSwitcherConfig } from '@authportal/shared'
import { PageShell } from '@/components/page-shell'
import { PageHead } from '@/components/portal/bits'
import { AppSwitcherAdminEditor } from '@/components/reui-kit/app-switcher-admin-editor'
import {
Frame,
@@ -47,13 +47,8 @@ function AdminAppsPage() {
})
return (
<PageShell>
<div className="flex flex-col gap-px">
<h1 className="text-2xl font-semibold tracking-tight">Приложения</h1>
<p className="text-muted-foreground text-sm">
URL и подписи для App Switcher (CFDM, VPS Tracker, EvoBGP)
</p>
</div>
<>
<PageHead title="Ссылки приложений" sub="URL и подписи для App Switcher (CFDM, VPS Tracker, EvoBGP)" />
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
@@ -84,6 +79,6 @@ function AdminAppsPage() {
)}
</FramePanel>
</Frame>
</PageShell>
</>
)
}
+242 -73
View File
@@ -1,105 +1,274 @@
import { useMemo, useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { HistoryIcon, NetworkIcon, OctagonAlertIcon, UserCogIcon } from 'lucide-react'
import { toast } from 'sonner'
import type {
AuditLogEntry,
AuditPurgeResponse,
AuditSettings,
} from '@authportal/shared'
import { PageShell } from '@/components/page-shell'
import { AuditLogTimeline } from '@/components/reui-kit/audit-log-timeline'
import { AuditRetentionPanel } from '@/components/reui-kit/audit-retention-panel'
import { api, ApiError } from '@/lib/api-client'
import {
auditQueryKey,
auditQueryOptions,
auditSettingsQueryKey,
auditSettingsQueryOptions,
} from '@/queries/audit'
import { Skeleton } from '@authportal/ui/components/skeleton'
import type { AuditLogEntry, AuditPurgeResponse, AuditSettings, AuditSeverity } from '@authportal/shared'
import { actionMeta, exportAuditCsv, severityLabel, sourceAppLabel } from '@/components/reui-kit/audit-log-helpers'
import { CodeBlock, FormFrame, KvList, PageHead, PageTab, PageTabsList, PortalSheet, SettingRow, StatusBadge, TypedConfirm } from '@/components/portal/bits'
import { ActivityFeed } from '@/components/portal/canvas-blocks'
import { fmtTime, fmtWhen } from '@/components/portal/format'
import { kpi } from '@/components/portal/kpi'
import { KpiStrip } from '@/components/portal/kpi-strip'
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
import type { Tone } from '@/components/portal/tone'
import { Button } from '@authportal/ui/components/button'
import { Input } from '@authportal/ui/components/input'
import { Skeleton } from '@authportal/ui/components/skeleton'
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { api, ApiError } from '@/lib/api-client'
import { auditQueryKey, auditQueryOptions, auditSettingsQueryKey, auditSettingsQueryOptions } from '@/queries/audit'
export const Route = createFileRoute('/_auth/admin/audit')({
component: AdminAuditPage,
})
const SEVERITY_TONE: Record<AuditSeverity, Tone> = { info: 'ok', warning: 'wn', critical: 'er' }
type Row = {
id: string
action: string
summary: string
tone: Tone
label: string
source: string
who: string
ip: string
when: string
entry: AuditLogEntry
}
function AdminAuditPage() {
const queryClient = useQueryClient()
const {
data: entries = [],
isLoading,
error,
refetch,
} = useQuery(auditQueryOptions({ kind: 'changes', limit: 500 }))
const [tab, setTab] = useState('feed')
const [entry, setEntry] = useState<Row | null>(null)
const [purgeOpen, setPurgeOpen] = useState(false)
const [daysDraft, setDaysDraft] = useState<string | null>(null)
const entriesQ = useQuery(auditQueryOptions({ kind: 'changes', limit: 500 }))
const { data: settings } = useQuery(auditSettingsQueryOptions)
const retention = settings?.retention_days ?? 90
const entries = useMemo(() => entriesQ.data ?? [], [entriesQ.data])
const saveMutation = useMutation({
mutationFn: (retention_days: number) =>
api.put<AuditSettings>('/api/v1/admin/audit/settings', {
retention_days,
}),
mutationFn: (retention_days: number) => api.put<AuditSettings>('/api/v1/admin/audit/settings', { retention_days }),
onSuccess: async (data) => {
await queryClient.invalidateQueries({ queryKey: auditSettingsQueryKey })
await queryClient.invalidateQueries({ queryKey: auditQueryKey })
setDaysDraft(null)
toast.success(`Срок хранения: ${data.retention_days} дн.`)
},
onError: (err) => {
toast.error(
err instanceof ApiError ? err.message : 'Не удалось сохранить',
)
},
onError: (err) => toast.error(err instanceof ApiError ? err.message : 'Не удалось сохранить'),
})
const purgeMutation = useMutation({
mutationFn: () =>
api.post<AuditPurgeResponse>('/api/v1/admin/audit/purge'),
mutationFn: () => api.post<AuditPurgeResponse>('/api/v1/admin/audit/purge'),
onSuccess: async (data) => {
await queryClient.invalidateQueries({ queryKey: auditQueryKey })
setPurgeOpen(false)
toast.success(`Удалено записей: ${data.deleted}`)
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Ошибка очистки')
},
onError: (err) => toast.error(err instanceof ApiError ? err.message : 'Ошибка очистки'),
})
return (
<PageShell>
<div className="flex flex-col gap-6">
<AuditRetentionPanel
retentionDays={settings?.retention_days ?? 90}
saving={saveMutation.isPending}
purging={purgeMutation.isPending}
onSave={(days) => saveMutation.mutate(days)}
onPurge={() => purgeMutation.mutate()}
/>
const rows = useMemo<Row[]>(
() =>
entries.map((e) => ({
id: e.id,
action: e.action,
summary: e.summary,
tone: SEVERITY_TONE[e.severity],
label: severityLabel[e.severity],
source: sourceAppLabel(e.source_app),
who: e.actor_email?.split('@')[0] ?? 'system',
ip: e.ip ?? '—',
when: fmtWhen(e.created_at),
entry: e,
})),
[entries],
)
{error ? (
<div className="flex flex-col gap-2">
<p className="text-destructive text-sm">
{error instanceof ApiError ? error.message : 'Ошибка загрузки'}
</p>
<Button
variant="outline"
size="sm"
className="w-fit"
onClick={() => refetch()}
>
Повторить
const feed = useMemo(
() =>
rows.slice(0, 5).map((r) => ({
t: fmtTime(r.entry.created_at),
title: actionMeta(r.action).label,
text: `${r.summary} · ${r.who} · ${r.source}`,
label: r.label,
tone: r.tone,
})),
[rows],
)
const columns = useMemo<GridColumn<Row>[]>(
() => [
{ id: 'action', title: 'Событие', size: 340, sortValue: (r) => r.action, cell: (r) => <TwoLineCell dot={r.tone} title={<span className="font-mono text-[13px]">{r.action}</span>} sub={r.summary} /> },
{ id: 'status', title: 'Статус', size: 120, cell: (r) => <StatusBadge tone={r.tone}>{r.label}</StatusBadge> },
{ id: 'src', title: 'Источник', size: 160, sortValue: (r) => r.source, cell: (r) => <span className="font-mono text-[12.5px] whitespace-nowrap">{r.source}</span> },
{ id: 'who', title: 'Кто', size: 120, cell: (r) => <span className="font-mono text-[12.5px] whitespace-nowrap">{r.who}</span> },
{ id: 'ip', title: 'IP', size: 140, cell: (r) => <span className="font-mono text-[12.5px] whitespace-nowrap">{r.ip}</span> },
{ id: 'when', title: 'Когда', size: 150, sortValue: (r) => r.entry.created_at, cell: (r) => <span className="font-mono text-[12.5px] whitespace-nowrap">{r.when}</span> },
],
[],
)
const stats = {
all: entries.length,
access: entries.filter((e) => e.action === 'user.access_update').length,
critical: entries.filter((e) => e.severity === 'critical').length,
sources: new Set(entries.map((e) => e.source_app)).size,
}
const daysValue = daysDraft ?? String(retention)
const parsedDays = Number.parseInt(daysValue, 10)
const daysValid = Number.isFinite(parsedDays) && parsedDays >= 7 && parsedDays <= 3650
return (
<>
<PageHead
title="Изменения"
sub="Журнал действий администраторов и приложений · портал, EvoFirewall, EvoBGP, MikrotikManager и другие через ingest"
actions={
<>
<Button variant="outline" disabled={rows.length === 0} onClick={() => exportAuditCsv(entries)}>
Экспорт CSV
</Button>
</div>
) : isLoading ? (
<div className="flex flex-col gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : (
<AuditLogTimeline
entries={entries as AuditLogEntry[]}
totalCount={entries.length}
/>
)}
</div>
</PageShell>
<Button variant="outline" onClick={() => setPurgeOpen(true)}>
Очистить старые записи…
</Button>
</>
}
/>
{entriesQ.isError ? (
<div role="alert" className="border-destructive/40 bg-destructive/10 flex flex-wrap items-center justify-between gap-3 rounded-xl border px-4 py-3 text-sm">
<span>{entriesQ.error instanceof ApiError ? entriesQ.error.message : 'Не удалось загрузить журнал. Повторите запрос.'}</span>
<Button variant="outline" onClick={() => void entriesQ.refetch()}>
Повторить
</Button>
</div>
) : null}
<KpiStrip
aria-label="Показатели"
items={[
kpi('all', 'Событий', String(stats.all), { icon: HistoryIcon, badge: 'в выборке' }),
kpi('acc', 'Изменений прав', String(stats.access), { icon: UserCogIcon, tone: 'ok', badge: 'user.access_update' }),
kpi('crit', 'Критичных', String(stats.critical), { icon: OctagonAlertIcon, tone: stats.critical ? 'er' : 'neutral', badge: 'critical' }),
kpi('src', 'Источников', String(stats.sources), { icon: NetworkIcon, badge: 'портал и приложения' }),
]}
/>
<Tabs value={tab} onValueChange={setTab} className="min-w-0">
<PageTabsList>
<PageTab value="feed" label="Лента" />
<PageTab value="table" label="Таблица" count={rows.length} />
<PageTab value="retention" label="Ротация" />
</PageTabsList>
<TabsContent value="feed" className="min-w-0">
{entriesQ.isLoading ? (
<Skeleton className="h-64 w-full rounded-xl" />
) : (
<ActivityFeed
title="Лента изменений"
description="Последние события · цвет точки — важность"
items={feed}
action={
<Button variant="outline" onClick={() => setTab('table')}>
Весь журнал
</Button>
}
/>
)}
</TabsContent>
<TabsContent value="table" className="min-w-0">
{entriesQ.isLoading ? (
<Skeleton className="h-72 w-full rounded-xl" />
) : (
<SectionGrid
title="Журнал изменений"
description="Поиск по событию, пользователю и IP · виды по важности"
rows={rows}
columns={columns}
getRowId={(r) => r.id}
searchText={(r) => `${r.action} ${r.summary} ${r.who} ${r.ip}`}
selectable={false}
views={[
{ label: 'Все', match: () => true },
{ label: 'Warning и выше', match: (r) => r.tone === 'wn' || r.tone === 'er' },
{ label: 'Critical', match: (r) => r.tone === 'er' },
]}
onRowClick={setEntry}
emptyMessage="В журнале нет записей."
/>
)}
</TabsContent>
<TabsContent value="retention">
<FormFrame title="Ротация журнала" description="Сколько хранить записи и как очищать" badge={`${retention} дней`}>
<SettingRow title="Срок хранения" description="Записи старше удаляются автоматически каждый час · от 7 до 3650 дней">
<div className="flex items-center gap-2">
<Input
type="number"
min={7}
max={3650}
value={daysValue}
onChange={(e) => setDaysDraft(e.target.value)}
aria-label="Срок хранения, дней"
className="w-28 tabular-nums"
/>
<Button disabled={!daysValid || saveMutation.isPending || parsedDays === retention} onClick={() => saveMutation.mutate(parsedDays)}>
{saveMutation.isPending ? 'Сохранение…' : 'Сохранить'}
</Button>
</div>
</SettingRow>
<SettingRow title="Записей в журнале" description="В выборке изменений">
<StatusBadge tone="mu">{String(entries.length)}</StatusBadge>
</SettingRow>
</FormFrame>
</TabsContent>
</Tabs>
<PortalSheet
open={entry !== null}
onOpenChange={(o) => !o && setEntry(null)}
title="Событие журнала"
description="Детали записи"
footer={
<Button variant="outline" onClick={() => setEntry(null)}>
Закрыть
</Button>
}
>
{entry ? (
<>
<KvList items={[['Событие', `${entry.action} · ${entry.summary}`]]} />
<div>
<StatusBadge tone={entry.tone}>{entry.label}</StatusBadge>
</div>
<KvList
items={[
['Источник', entry.source],
['Кто', `${entry.entry.actor_email ?? 'system'} · ${entry.ip}`],
['Когда', fmtWhen(entry.entry.created_at)],
]}
/>
<CodeBlock label="Подробности">
{JSON.stringify(
{ target_type: entry.entry.target_type, target_id: entry.entry.target_id, details: entry.entry.details },
null,
2,
)}
</CodeBlock>
</>
) : null}
</PortalSheet>
<TypedConfirm
open={purgeOpen}
onOpenChange={setPurgeOpen}
title="Очистить старые записи?"
description={`Будут безвозвратно удалены записи старше ${retention} дней. Само действие попадёт в журнал как audit.purge.`}
expected="очистить"
confirmLabel="Очистить"
onConfirm={() => purgeMutation.mutate()}
/>
</>
)
}
+19 -3
View File
@@ -6,8 +6,11 @@ import { useCallback, useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { ShieldCheckIcon, UserCheckIcon, UserXIcon, UsersIcon } from 'lucide-react'
import type { AdminUser, CreateUserRequest } from '@authportal/shared'
import { PageShell } from '@/components/page-shell'
import { PageHead } from '@/components/portal/bits'
import { kpi } from '@/components/portal/kpi'
import { KpiStrip } from '@/components/portal/kpi-strip'
import { AdminUsersGrid } from '@/components/reui-kit/admin-users-grid'
import { CreateUserSheet } from '@/components/reui-kit/create-user-sheet'
import { UserAccessSheet } from '@/components/reui-kit/user-access-sheet'
@@ -174,8 +177,21 @@ function AdminUsersPage() {
[invalidateUsers],
)
const admins = users.filter((u) => u.is_admin).length
const disabled = users.filter((u) => u.disabled).length
return (
<PageShell>
<>
<PageHead title="Пользователи" sub="Учётные записи портала · роли, доступ к приложениям, passkeys" />
<KpiStrip
aria-label="Показатели"
items={[
kpi('all', 'Пользователей', String(users.length), { icon: UsersIcon, badge: 'учётных записей' }),
kpi('adm', 'Администраторов', String(admins), { icon: ShieldCheckIcon, tone: 'ok', badge: 'is_admin' }),
kpi('act', 'Активных', String(users.length - disabled), { icon: UserCheckIcon, badge: 'могут войти' }),
kpi('off', 'Отключённых', String(disabled), { icon: UserXIcon, tone: disabled ? 'wn' : 'neutral', badge: 'disabled' }),
]}
/>
{error ? (
<div className="flex flex-col gap-2">
<p className="text-destructive text-sm">
@@ -229,6 +245,6 @@ function AdminUsersPage() {
if (!next) setAuditUser(null)
}}
/>
</PageShell>
</>
)
}
+269 -496
View File
@@ -1,542 +1,315 @@
/**
* Admin logins journal — history DataGrid + active sessions.
* Preview: https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/preview/base/stats-12
* Sessions DNA: https://reui.io/preview/base/solution-users-2
*/
import { useMemo, useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
type ColumnDef,
type PaginationState,
type SortingState,
useTable,
} from '@tanstack/react-table'
import { CircleCheckIcon, GlobeIcon, MonitorSmartphoneIcon, ShieldAlertIcon } from 'lucide-react'
import { toast } from 'sonner'
import type { AdminSession, AuditLogEntry } from '@authportal/shared'
import {
KeyRoundIcon,
LogInIcon,
ShieldAlertIcon,
Trash2Icon,
} from 'lucide-react'
import { PageShell } from '@/components/page-shell'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
import {
actionMeta,
detailString,
formatEventTime,
matchesRange,
sourceAppLabel,
type AuditRange,
} from '@/components/reui-kit/audit-log-helpers'
import { Badge } from '@/components/reui/badge'
import { DATA_GRID_RU } from '@/lib/reui-ru'
import { DataGrid, dataGridFeatures, type DataGridFeatures } from '@/components/reui/data-grid/data-grid'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import {
Frame,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { auditQueryOptions } from '@/queries/audit'
import {
sessionsListQueryKey,
sessionsQueryOptions,
} from '@/queries/sessions'
import { api, ApiError } from '@/lib/api-client'
import { actionMeta, detailString, exportAuditCsv, matchesRange, sourceAppLabel, type AuditRange } from '@/components/reui-kit/audit-log-helpers'
import { ExplainBanner } from '@/components/portal/explain-banner'
import { CodeBlock, KvList, PageHead, PageTab, PageTabsList, PortalSheet, Segmented, StatusBadge } from '@/components/portal/bits'
import { fmtWhen } from '@/components/portal/format'
import { kpi } from '@/components/portal/kpi'
import { KpiStrip } from '@/components/portal/kpi-strip'
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
import type { Tone } from '@/components/portal/tone'
import { Button } from '@authportal/ui/components/button'
import { Skeleton } from '@authportal/ui/components/skeleton'
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@authportal/ui/components/select'
import { RANGE_OPTIONS } from '@/components/reui-kit/audit-log-helpers'
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { api, ApiError } from '@/lib/api-client'
import { auditQueryOptions } from '@/queries/audit'
import { sessionsListQueryKey, sessionsQueryOptions } from '@/queries/sessions'
export const Route = createFileRoute('/_auth/admin/logins')({
component: AdminLoginsPage,
})
type ResultFilter = 'all' | 'success' | 'failed'
const PERIODS: Array<{ label: string; value: AuditRange }> = [
{ label: '24 ч', value: '24h' },
{ label: '7 д', value: '7d' },
{ label: '30 д', value: '30d' },
{ label: '90 д', value: '90d' },
]
function resultVariant(action: string) {
if (action === 'auth.login_failed') return 'warning-outline' as const
if (action === 'auth.sso_handoff') return 'info-outline' as const
return 'success-outline' as const
type LoginRow = {
id: string
login: string
client: string
tone: Tone
status: string
time: string
ip: string
app: string
reason: string
entry: AuditLogEntry
}
type SessionRow = {
id: string
user: string
note: string
ipClient: string
created: string
expires: string
}
const FAILED = 'auth.login_failed'
const isOk = (a: string) => a === 'auth.login' || a === 'auth.sso_handoff'
function toneOf(action: string): Tone {
if (action === FAILED) return 'er'
if (action === 'auth.sso_handoff') return 'in'
if (action === 'auth.logout') return 'wn'
return 'ok'
}
function shortUa(ua: string | null | undefined) {
if (!ua) return '—'
const m = /(Edg|Chrome|Firefox|Safari)\/(\d+)/.exec(ua)
const os = /Windows|Mac OS X|Linux|iPhone|Android/.exec(ua)?.[0]?.replace('Mac OS X', 'macOS')
return [m ? `${m[1] === 'Edg' ? 'Edge' : m[1]} ${m[2]}` : ua.slice(0, 24), os].filter(Boolean).join(' · ')
}
function AdminLoginsPage() {
const queryClient = useQueryClient()
const [tab, setTab] = useState<'history' | 'sessions'>('history')
const [resultFilter, setResultFilter] = useState<ResultFilter>('all')
const [range, setRange] = useState<AuditRange>('7d')
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 20,
})
const [sorting, setSorting] = useState<SortingState>([
{ id: 'created_at', desc: true },
])
const navigate = useNavigate()
const [range, setRange] = useState(0)
const [event, setEvent] = useState<LoginRow | null>(null)
const {
data: entries = [],
isLoading,
error,
refetch,
} = useQuery(auditQueryOptions({ kind: 'logins', limit: 500 }))
const entriesQ = useQuery(auditQueryOptions({ kind: 'logins', limit: 500 }))
const sessionsQ = useQuery(sessionsQueryOptions())
const entries = useMemo(() => entriesQ.data ?? [], [entriesQ.data])
const sessions = useMemo(() => sessionsQ.data ?? [], [sessionsQ.data])
const period = PERIODS[range]!.value
const {
data: sessions = [],
isLoading: sessionsLoading,
error: sessionsError,
refetch: refetchSessions,
} = useQuery(sessionsQueryOptions())
const revokeMutation = useMutation({
const revoke = useMutation({
mutationFn: (id: string) => api.delete(`/api/v1/admin/sessions/${id}`),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: sessionsListQueryKey() })
toast.success('Сессия отозвана')
},
onError: (err) => {
toast.error(
err instanceof ApiError ? err.message : 'Не удалось отозвать сессию',
)
toast.success('Сессия завершена')
},
onError: (err) => toast.error(err instanceof ApiError ? err.message : 'Не удалось завершить сессию'),
})
const filtered = useMemo(() => {
return entries.filter((e) => {
if (!matchesRange(e, range)) return false
if (resultFilter === 'success') {
return e.action === 'auth.login' || e.action === 'auth.sso_handoff'
}
if (resultFilter === 'failed') return e.action === 'auth.login_failed'
return true
})
}, [entries, range, resultFilter])
const rows = useMemo<LoginRow[]>(
() =>
entries
.filter((e) => matchesRange(e, period))
.map((e) => ({
id: e.id,
login: e.actor_email ?? '—',
client: shortUa(detailString(e.details, 'user_agent')),
tone: toneOf(e.action),
status: actionMeta(e.action).label,
time: fmtWhen(e.created_at),
ip: e.ip ?? '—',
app: sourceAppLabel(detailString(e.details, 'target_app') ?? e.source_app),
reason: detailString(e.details, 'reason') ?? '—',
entry: e,
})),
[entries, period],
)
const kpi = useMemo(() => {
const sessionRows = useMemo<SessionRow[]>(
() =>
sessions.map((s: AdminSession) => ({
id: s.id,
user: s.email,
note: s.name,
ipClient: `${s.ip ?? '—'} · ${shortUa(s.user_agent)}`,
created: fmtWhen(s.created_at),
expires: fmtWhen(s.expires_at),
})),
[sessions],
)
const stats = useMemo(() => {
const day = entries.filter((e) => matchesRange(e, '24h'))
const ok = day.filter(
(e) => e.action === 'auth.login' || e.action === 'auth.sso_handoff',
).length
const fail = day.filter((e) => e.action === 'auth.login_failed').length
const ips = new Set(
day.map((e) => e.ip).filter((ip): ip is string => Boolean(ip)),
)
return { ok, fail, ips: ips.size, sessions: sessions.length }
}, [entries, sessions.length])
return {
ok: day.filter((e) => isOk(e.action)).length,
fail: day.filter((e) => e.action === FAILED).length,
ips: new Set(day.map((e) => e.ip).filter(Boolean)).size,
users: new Set(sessions.map((s) => s.user_id)).size,
}
}, [entries, sessions])
const historyColumns = useMemo<ColumnDef<DataGridFeatures, AuditLogEntry>[]>(
// Серия неудач: 5 и больше с одного IP за последние 10 минут (по времени самой свежей записи журнала).
const burst = useMemo(() => {
const latest = entries.reduce((m, e) => Math.max(m, new Date(e.created_at).getTime()), 0)
const byIp = new Map<string, AuditLogEntry[]>()
for (const e of entries) {
if (e.action !== FAILED || !e.ip) continue
if (latest - new Date(e.created_at).getTime() > 10 * 60 * 1000) continue
byIp.set(e.ip, [...(byIp.get(e.ip) ?? []), e])
}
const top = [...byIp.entries()].sort((a, b) => b[1].length - a[1].length)[0]
return top && top[1].length >= 5 ? { ip: top[0], n: top[1].length, login: top[1][0]!.actor_email ?? '—' } : null
}, [entries])
const loginCols = useMemo<GridColumn<LoginRow>[]>(
() => [
{
accessorKey: 'created_at',
header: ({ column }) => (
<DataGridColumnHeader title="Время" column={column} />
),
cell: ({ row }) => (
<span className="text-muted-foreground text-sm tabular-nums">
{formatEventTime(row.original.created_at)}
</span>
),
size: 140,
},
{
id: 'result',
accessorFn: (r) => r.action,
header: ({ column }) => (
<DataGridColumnHeader title="Результат" column={column} />
),
cell: ({ row }) => (
<Badge variant={resultVariant(row.original.action)} size="sm">
{actionMeta(row.original.action).label}
</Badge>
),
size: 140,
},
{
accessorKey: 'actor_email',
header: ({ column }) => (
<DataGridColumnHeader title="Логин" column={column} />
),
cell: ({ row }) => (
<span className="truncate text-sm font-medium">
{row.original.actor_email ?? '—'}
</span>
),
size: 200,
},
{
accessorKey: 'ip',
header: ({ column }) => (
<DataGridColumnHeader title="IP" column={column} />
),
cell: ({ row }) => (
<span className="font-mono text-xs tabular-nums">
{row.original.ip ?? '—'}
</span>
),
size: 130,
},
{
id: 'ua',
accessorFn: (r) => detailString(r.details, 'user_agent') ?? '',
header: ({ column }) => (
<DataGridColumnHeader title="User-Agent" column={column} />
),
cell: ({ row }) => {
const ua = detailString(row.original.details, 'user_agent')
return (
<span className="text-muted-foreground line-clamp-1 max-w-[220px] text-xs" title={ua ?? undefined}>
{ua ?? '—'}
</span>
)
},
size: 220,
},
{
id: 'app',
accessorFn: (r) =>
detailString(r.details, 'target_app') ?? r.source_app,
header: ({ column }) => (
<DataGridColumnHeader title="Приложение" column={column} />
),
cell: ({ row }) => {
const app =
detailString(row.original.details, 'target_app') ??
row.original.source_app
return (
<Badge variant="outline" size="sm">
{sourceAppLabel(app)}
</Badge>
)
},
size: 120,
},
{
id: 'reason',
accessorFn: (r) => detailString(r.details, 'reason') ?? '',
header: ({ column }) => (
<DataGridColumnHeader title="Причина" column={column} />
),
cell: ({ row }) => (
<span className="text-muted-foreground text-sm">
{detailString(row.original.details, 'reason') ?? '—'}
</span>
),
size: 120,
},
{ id: 'login', title: 'Логин', size: 260, sortValue: (r) => r.login, cell: (r) => <TwoLineCell dot={r.tone} title={r.login} sub={r.client} /> },
{ id: 'status', title: 'Статус', size: 130, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> },
{ id: 'time', title: 'Время', size: 150, sortValue: (r) => r.entry.created_at, cell: (r) => <span className="font-mono text-[12.5px] whitespace-nowrap">{r.time}</span> },
{ id: 'ip', title: 'IP', size: 150, sortValue: (r) => r.ip, cell: (r) => <span className="font-mono text-[12.5px]">{r.ip}</span> },
{ id: 'app', title: 'Приложение', size: 160, cell: (r) => <span className="font-mono text-[12.5px]">{r.app}</span> },
{ id: 'reason', title: 'Причина', size: 200, cell: (r) => <span className="font-mono text-[12.5px]">{r.reason}</span> },
],
[],
)
const historyTable = useTable({
features: dataGridFeatures,
data: filtered,
columns: historyColumns,
state: { pagination, sorting },
onPaginationChange: setPagination,
onSortingChange: setSorting,
})
const sessionColumns = useMemo<ColumnDef<DataGridFeatures, AdminSession>[]>(
const sessionCols = useMemo<GridColumn<SessionRow>[]>(
() => [
{
accessorKey: 'email',
header: ({ column }) => (
<DataGridColumnHeader title="Пользователь" column={column} />
),
cell: ({ row }) => (
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-sm font-medium">
{row.original.name}
</span>
<span className="text-muted-foreground truncate text-xs">
{row.original.email}
</span>
</div>
),
size: 200,
},
{
accessorKey: 'ip',
header: ({ column }) => (
<DataGridColumnHeader title="IP" column={column} />
),
cell: ({ row }) => (
<span className="font-mono text-xs tabular-nums">
{row.original.ip ?? '—'}
</span>
),
size: 130,
},
{
accessorKey: 'user_agent',
header: ({ column }) => (
<DataGridColumnHeader title="User-Agent" column={column} />
),
cell: ({ row }) => (
<span
className="text-muted-foreground line-clamp-1 max-w-[240px] text-xs"
title={row.original.user_agent ?? undefined}
>
{row.original.user_agent ?? '—'}
</span>
),
size: 240,
},
{
accessorKey: 'created_at',
header: ({ column }) => (
<DataGridColumnHeader title="Создана" column={column} />
),
cell: ({ row }) => (
<span className="text-muted-foreground text-sm tabular-nums">
{formatEventTime(row.original.created_at)}
</span>
),
size: 140,
},
{
accessorKey: 'expires_at',
header: ({ column }) => (
<DataGridColumnHeader title="Истекает" column={column} />
),
cell: ({ row }) => (
<span className="text-muted-foreground text-sm tabular-nums">
{formatEventTime(row.original.expires_at)}
</span>
),
size: 140,
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<Button
type="button"
size="sm"
variant="outline"
disabled={revokeMutation.isPending}
onClick={() => revokeMutation.mutate(row.original.id)}
>
<Trash2Icon aria-hidden="true" />
Отозвать
</Button>
),
size: 120,
},
{ id: 'user', title: 'Пользователь', size: 260, sortValue: (r) => r.user, cell: (r) => <TwoLineCell dot="ok" title={r.user} sub={r.note} /> },
{ id: 'status', title: 'Статус', size: 110, cell: () => <StatusBadge tone="ok">Активна</StatusBadge> },
{ id: 'ip', title: 'IP · клиент', size: 280, cell: (r) => <span className="font-mono text-[12.5px]">{r.ipClient}</span> },
{ id: 'created', title: 'Создана', size: 150, cell: (r) => <span className="font-mono text-[12.5px] whitespace-nowrap">{r.created}</span> },
{ id: 'exp', title: 'Истекает', size: 150, cell: (r) => <span className="font-mono text-[12.5px] whitespace-nowrap">{r.expires}</span> },
],
[revokeMutation],
[],
)
const [sessionPagination, setSessionPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 20,
})
const sessionsTable = useTable({
features: dataGridFeatures,
data: sessions,
columns: sessionColumns,
state: { pagination: sessionPagination },
onPaginationChange: setSessionPagination,
})
const loading = entriesQ.isLoading || sessionsQ.isLoading
const failedQuery = entriesQ.isError || sessionsQ.isError
return (
<PageShell>
<div className="flex flex-col gap-6">
<KpiStatGrid
items={[
{
id: 'ok',
label: 'Успешные (24ч)',
value: kpi.ok,
icon: <LogInIcon />,
iconClassName: 'text-success',
hint: 'Вход и SSO',
},
{
id: 'fail',
label: 'Неудачи (24ч)',
value: kpi.fail,
icon: <ShieldAlertIcon />,
iconClassName: 'text-warning',
variant: kpi.fail > 0 ? 'warning' : 'default',
},
{
id: 'ips',
label: 'Уникальные IP (24ч)',
value: kpi.ips,
icon: <KeyRoundIcon />,
},
{
id: 'sessions',
label: 'Активные сессии',
value: kpi.sessions,
icon: <KeyRoundIcon />,
onSelect: () => setTab('sessions'),
},
]}
/>
<Tabs
value={tab}
onValueChange={(v) => {
if (v === 'history' || v === 'sessions') setTab(v)
}}
>
<TabsList
variant="line"
className="h-10! w-full justify-start gap-6 border-b"
<>
<PageHead
title="Входы"
sub="Журнал входов и активные сессии · портал и SSO в приложения"
actions={
<Button
variant="outline"
disabled={rows.length === 0}
onClick={() => {
exportAuditCsv(rows.map((r) => r.entry))
toast.success(`CSV сформирован: ${rows.length} записей`)
}}
>
<TabsTrigger value="history" className="px-1 text-sm after:-bottom-px!">
История
</TabsTrigger>
<TabsTrigger value="sessions" className="px-1 text-sm after:-bottom-px!">
Активные сессии
<span className="bg-muted text-muted-foreground ms-2 rounded-md px-1.5 text-xs tabular-nums">
{sessions.length}
</span>
</TabsTrigger>
</TabsList>
</Tabs>
Экспорт CSV
</Button>
}
/>
{burst ? (
<ExplainBanner
tone="er"
title={`Подозрительная серия неудач: ${burst.login}.`}
text={`${burst.n} неудачных входов с ${burst.ip} за 10 минут. Действия: «Показать», отключить пользователя в разделе «Пользователи».`}
>
{burst.n} неудачных входов с {burst.ip} за 10 минут. Проверьте журнал и при необходимости отключите учётную запись или заблокируйте адрес.
</ExplainBanner>
) : null}
{failedQuery ? (
<div role="alert" className="border-destructive/40 bg-destructive/10 flex flex-wrap items-center justify-between gap-3 rounded-xl border px-4 py-3 text-sm">
<span>Не удалось загрузить журнал. Повторите запрос.</span>
<Button
variant="outline"
onClick={() => {
void entriesQ.refetch()
void sessionsQ.refetch()
}}
>
Повторить
</Button>
</div>
) : null}
<Segmented label="Период" options={PERIODS.map((p) => p.label)} value={range} onChange={setRange} />
<KpiStrip
aria-label="Показатели"
items={[
kpi('ok', 'Успешные (24ч)', String(stats.ok), { icon: CircleCheckIcon, tone: 'ok', badge: 'входы и SSO' }),
kpi('fail', 'Неудачи (24ч)', String(stats.fail), { icon: ShieldAlertIcon, tone: stats.fail ? 'er' : 'neutral', badge: 'неверный пароль и др.' }),
kpi('ip', 'Уникальные IP (24ч)', String(stats.ips), { icon: GlobeIcon, badge: 'по журналу' }),
kpi('sess', 'Активные сессии', String(sessions.length), { icon: MonitorSmartphoneIcon, badge: `${stats.users} пользователей` }),
]}
/>
<Tabs defaultValue="hist" className="min-w-0">
<PageTabsList>
<PageTab value="hist" label="История" count={rows.length} />
<PageTab value="sess" label="Активные сессии" count={sessions.length} />
</PageTabsList>
<TabsContent value="hist" className="flex min-w-0 flex-col gap-4">
{loading ? (
<Skeleton className="h-72 w-full rounded-xl" />
) : (
<SectionGrid
title="Журнал входов"
description="Поиск по логину и IP · виды по результату · период выбирается выше"
rows={rows}
columns={loginCols}
getRowId={(r) => r.id}
searchText={(r) => `${r.login} ${r.ip} ${r.app}`}
searchPlaceholder="Логин или IP"
selectable={false}
views={[
{ label: 'Все', match: () => true },
{ label: 'Успешные', match: (r) => r.tone === 'ok' || r.tone === 'in' },
{ label: 'Неудачные', match: (r) => r.tone === 'er' || r.tone === 'wn' },
]}
onRowClick={setEvent}
emptyMessage="За выбранный период входов не было."
/>
)}
</TabsContent>
<TabsContent value="sess" className="flex min-w-0 flex-col gap-4">
{loading ? (
<Skeleton className="h-48 w-full rounded-xl" />
) : (
<SectionGrid
title="Активные сессии"
description="Токены, действующие сейчас · завершение сессии выкидывает пользователя из портала и приложений"
rows={sessionRows}
columns={sessionCols}
getRowId={(r) => r.id}
selectable={false}
emptyMessage="Нет активных сессий."
rowMenu={(r) => ({ toggleLabel: 'Завершить сессию', onToggle: () => revoke.mutate(r.id) })}
/>
)}
</TabsContent>
</Tabs>
{tab === 'history' ? (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<Tabs
value={resultFilter}
onValueChange={(v) => {
if (v === 'all' || v === 'success' || v === 'failed') {
setResultFilter(v)
}
}}
>
<TabsList variant="line" className="h-auto gap-4 border-0">
<TabsTrigger value="all" className="px-0 pb-1 text-sm">
Все
</TabsTrigger>
<TabsTrigger value="success" className="px-0 pb-1 text-sm">
Успешные
</TabsTrigger>
<TabsTrigger value="failed" className="px-0 pb-1 text-sm">
Неудачи
</TabsTrigger>
</TabsList>
</Tabs>
<Select
value={range}
onValueChange={(value) => {
if (value) setRange(value as AuditRange)
}}
items={[...RANGE_OPTIONS]}
>
<SelectTrigger size="sm" className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{RANGE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
{error ? (
<div className="flex flex-col gap-2">
<p className="text-destructive text-sm">
{error instanceof ApiError ? error.message : 'Ошибка загрузки'}
</p>
<Button
variant="outline"
size="sm"
className="w-fit"
onClick={() => refetch()}
>
Повторить
</Button>
</div>
) : isLoading ? (
<Skeleton className="h-64 w-full" />
) : (
<DataGrid
i18n={DATA_GRID_RU}
table={historyTable}
recordCount={filtered.length}
tableLayout={{ dense: true }}
>
<Frame stacked dense>
<FrameHeader>
<FrameTitle>Журнал входов</FrameTitle>
</FrameHeader>
<FramePanel className="p-0">
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<FrameFooter>
<DataGridPagination sizes={[10, 20, 30]} />
</FrameFooter>
</FramePanel>
</Frame>
</DataGrid>
)}
</div>
) : sessionsError ? (
<div className="flex flex-col gap-2">
<p className="text-destructive text-sm">
{sessionsError instanceof ApiError
? sessionsError.message
: 'Ошибка загрузки'}
</p>
<PortalSheet
open={event !== null}
onOpenChange={(o) => !o && setEvent(null)}
title="Событие входа"
description="Детали записи журнала"
footer={
<>
<Button variant="outline" onClick={() => setEvent(null)}>
Закрыть
</Button>
<Button
variant="outline"
size="sm"
className="w-fit"
onClick={() => refetchSessions()}
disabled={!event?.entry.actor_user_id}
onClick={() => {
const id = event?.entry.actor_user_id
setEvent(null)
if (id) void navigate({ to: '/admin/users/$userId', params: { userId: id } })
}}
>
Повторить
Открыть пользователя
</Button>
</div>
) : sessionsLoading ? (
<Skeleton className="h-64 w-full" />
) : (
<DataGrid
i18n={DATA_GRID_RU}
table={sessionsTable}
recordCount={sessions.length}
tableLayout={{ dense: true }}
>
<Frame stacked dense>
<FrameHeader>
<FrameTitle>Активные сессии</FrameTitle>
</FrameHeader>
<FramePanel className="p-0">
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<FrameFooter>
<DataGridPagination sizes={[10, 20, 30]} />
</FrameFooter>
</FramePanel>
</Frame>
</DataGrid>
)}
</div>
</PageShell>
</>
}
>
{event ? (
<>
<div>
<StatusBadge tone={event.tone}>{event.status}</StatusBadge>
</div>
<KvList
items={[
['Логин', event.login],
['IP', event.ip],
['Клиент', event.client],
['Приложение', event.app],
['Причина', event.reason],
]}
/>
<CodeBlock label="Событие">{`action: ${event.entry.action}\nsource_app: ${event.entry.source_app}\nseverity: ${event.entry.severity}\nip: ${event.ip}\nat: ${event.entry.created_at}`}</CodeBlock>
</>
) : null}
</PortalSheet>
</>
)
}
+180 -292
View File
@@ -1,345 +1,233 @@
/**
* Admin OIDC clients — Frame surface.
* Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/sheet-8
* Docs: https://reui.io/blocks
*/
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { PlusIcon, KeyRoundIcon, Trash2Icon, CopyIcon } from 'lucide-react'
import { CopyIcon, KeyRoundIcon, LinkIcon, PlusIcon, PowerIcon, ShieldCheckIcon } from 'lucide-react'
import { toast } from 'sonner'
import type { OidcClientCreated, OidcClientPublic } from '@authportal/shared'
import { PageShell } from '@/components/page-shell'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { CodeBlock, FormFrame, KvList, PageHead, PageTab, PageTabsList, PortalSheet, SettingRow, StatusBadge, TypedConfirm } from '@/components/portal/bits'
import { kpi } from '@/components/portal/kpi'
import { KpiStrip } from '@/components/portal/kpi-strip'
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
import { Button } from '@authportal/ui/components/button'
import { Skeleton } from '@authportal/ui/components/skeleton'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@authportal/ui/components/alert-dialog'
import { Badge } from '@/components/reui/badge'
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { CreateOidcClientSheet } from '@/components/reui-kit/create-oidc-client-sheet'
import { ApiError } from '@/lib/api-client'
import {
createOidcClient,
deleteOidcClient,
oidcClientsQueryOptions,
oidcMetaQueryOptions,
rotateOidcClientSecret,
} from '@/queries/oidc'
import { createOidcClient, deleteOidcClient, oidcClientsQueryOptions, oidcMetaQueryOptions, rotateOidcClientSecret } from '@/queries/oidc'
export const Route = createFileRoute('/_auth/admin/oidc')({
component: AdminOidcPage,
})
function copy(value: string) {
void navigator.clipboard.writeText(value)
toast.success('Скопировано')
}
function AdminOidcPage() {
const queryClient = useQueryClient()
const { data: clients, isLoading, isError, error } = useQuery(
oidcClientsQueryOptions,
)
const { data: clients = [], isLoading, isError, error, refetch } = useQuery(oidcClientsQueryOptions)
const { data: meta } = useQuery(oidcMetaQueryOptions)
const [tab, setTab] = useState('clients')
const [createOpen, setCreateOpen] = useState(false)
const [secretOnce, setSecretOnce] = useState<OidcClientCreated | null>(null)
const [deleteId, setDeleteId] = useState<string | null>(null)
const [selected, setSelected] = useState<OidcClientPublic | null>(null)
const [deleteTarget, setDeleteTarget] = useState<OidcClientPublic | null>(null)
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'oidc'] })
const fail = (fallback: string) => (err: unknown) => toast.error(err instanceof ApiError ? err.message : fallback)
const createMutation = useMutation({
mutationFn: createOidcClient,
onSuccess: (created) => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'oidc'] })
void invalidate()
setCreateOpen(false)
setSecretOnce(created)
toast.success('OIDC-клиент создан')
},
onError: (err) => {
toast.error(
err instanceof ApiError ? err.message : 'Не удалось создать клиента',
)
},
onError: fail('Не удалось создать клиента'),
})
const deleteMutation = useMutation({
mutationFn: deleteOidcClient,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'oidc'] })
setDeleteId(null)
void invalidate()
setDeleteTarget(null)
setSelected(null)
toast.success('Клиент удалён')
},
onError: (err) => {
toast.error(
err instanceof ApiError ? err.message : 'Не удалось удалить',
)
},
onError: fail('Не удалось удалить'),
})
const rotateMutation = useMutation({
mutationFn: rotateOidcClientSecret,
onSuccess: (created) => {
setSecretOnce(created)
toast.success('Секрет обновлён')
},
onError: (err) => {
toast.error(
err instanceof ApiError ? err.message : 'Не удалось обновить секрет',
)
},
onError: fail('Не удалось обновить секрет'),
})
return (
<PageShell>
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="flex flex-col gap-px">
<h1 className="text-2xl font-semibold tracking-tight">OIDC-клиенты</h1>
<p className="text-muted-foreground text-sm">
Внешние сервисы (Technitium DNS и др.) через OpenID Connect
</p>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<PlusIcon className="size-4" />
Добавить
</Button>
</div>
const columns = useMemo<GridColumn<OidcClientPublic>[]>(
() => [
{
id: 'name',
title: 'Клиент',
size: 280,
sortValue: (c) => c.name,
cell: (c) => <TwoLineCell dot={c.enabled ? 'ok' : 'wn'} title={c.name} sub={c.client_id} />,
},
{ id: 'status', title: 'Статус', size: 120, cell: (c) => <StatusBadge tone={c.enabled ? 'ok' : 'wn'}>{c.enabled ? 'enabled' : 'disabled'}</StatusBadge> },
{ id: 'redirects', title: 'Redirect URI', size: 340, cell: (c) => <span className="font-mono text-[12.5px]">{c.redirect_uris[0] ?? '—'}{c.redirect_uris.length > 1 ? ` +${c.redirect_uris.length - 1}` : ''}</span> },
{ id: 'scopes', title: 'Scopes', size: 200, cell: (c) => <span className="font-mono text-[12.5px]">{c.scopes.join(' ')}</span> },
],
[],
)
{meta ? (
<Frame dense className="w-full">
<FrameHeader>
<FrameTitle>Issuer</FrameTitle>
<FrameDescription>
Metadata для Relying Party
</FrameDescription>
</FrameHeader>
<FramePanel className="flex flex-col gap-2 text-sm">
<CopyRow label="Issuer" value={meta.issuer} />
<CopyRow label="Discovery" value={meta.discovery_url} />
<CopyRow label="JWKS" value={meta.jwks_url} />
</FramePanel>
</Frame>
const enabled = clients.filter((c) => c.enabled).length
return (
<>
<PageHead
title="OIDC-клиенты"
sub="Внешние сервисы (Technitium DNS и др.) через OpenID Connect · Authorization Code"
actions={
<Button onClick={() => setCreateOpen(true)}>
<PlusIcon /> Добавить клиента
</Button>
}
/>
{isError ? (
<div role="alert" className="border-destructive/40 bg-destructive/10 flex flex-wrap items-center justify-between gap-3 rounded-xl border px-4 py-3 text-sm">
<span>{error instanceof ApiError ? error.message : 'Не удалось загрузить клиентов.'}</span>
<Button variant="outline" onClick={() => void refetch()}>
Повторить
</Button>
</div>
) : null}
<Frame dense className="w-full">
<FrameHeader>
<FrameTitle>Клиенты</FrameTitle>
<FrameDescription>
Confidential clients (Authorization Code)
</FrameDescription>
</FrameHeader>
<FramePanel>
<KpiStrip
aria-label="Показатели"
items={[
kpi('all', 'Клиентов', String(clients.length), { icon: KeyRoundIcon, badge: 'confidential' }),
kpi('on', 'Включено', String(enabled), { icon: PowerIcon, tone: 'ok', badge: 'enabled' }),
kpi('off', 'Отключено', String(clients.length - enabled), { icon: ShieldCheckIcon, tone: clients.length - enabled ? 'wn' : 'neutral', badge: 'disabled' }),
kpi('iss', 'Issuer', meta ? 'готов' : '—', { icon: LinkIcon, badge: 'discovery + JWKS' }),
]}
/>
<Tabs value={tab} onValueChange={setTab} className="min-w-0">
<PageTabsList>
<PageTab value="clients" label="Клиенты" count={clients.length} />
<PageTab value="issuer" label="Issuer" />
</PageTabsList>
<TabsContent value="clients" className="min-w-0">
{isLoading ? (
<div className="flex flex-col gap-3">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
) : isError ? (
<p className="text-destructive text-sm">
{error instanceof ApiError
? error.message
: 'Не удалось загрузить'}
</p>
) : !clients?.length ? (
<p className="text-muted-foreground text-sm">
Пока нет OIDC-клиентов. Создайте клиент для Technitium DNS.
</p>
<Skeleton className="h-64 w-full rounded-xl" />
) : (
<ul className="flex flex-col gap-3">
{clients.map((c) => (
<OidcClientRow
key={c.id}
client={c}
onRotate={() => rotateMutation.mutate(c.id)}
onDelete={() => setDeleteId(c.id)}
rotating={rotateMutation.isPending}
/>
))}
</ul>
<SectionGrid
title="Клиенты"
description="Клик по строке — секрет, redirect URI и удаление"
rows={clients}
columns={columns}
getRowId={(c) => c.id}
searchText={(c) => `${c.name} ${c.client_id} ${c.redirect_uris.join(' ')}`}
selectable={false}
views={[
{ label: 'Все', match: () => true },
{ label: 'Включённые', match: (c) => c.enabled },
{ label: 'Отключённые', match: (c) => !c.enabled },
]}
onRowClick={setSelected}
emptyMessage="Пока нет OIDC-клиентов. Создайте клиент для Technitium DNS."
/>
)}
</FramePanel>
</Frame>
<CreateOidcClientSheet
open={createOpen}
onOpenChange={setCreateOpen}
pending={createMutation.isPending}
onSubmit={(values) => createMutation.mutate(values)}
/>
<SecretRevealDialog
client={secretOnce}
onClose={() => setSecretOnce(null)}
/>
<AlertDialog
open={deleteId != null}
onOpenChange={(o: boolean) => {
if (!o) setDeleteId(null)
}}
>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Удалить OIDC-клиент?</AlertDialogTitle>
<AlertDialogDescription>
Relying Party перестанет получать токены с этим client_id.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deleteMutation.isPending}
onClick={() => {
if (deleteId) deleteMutation.mutate(deleteId)
}}
>
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageShell>
)
}
function CopyRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground w-24 shrink-0">{label}</span>
<code className="bg-muted truncate rounded px-2 py-1 text-xs">
{value}
</code>
<Button
type="button"
size="icon"
variant="ghost"
className="size-7"
aria-label={`Копировать ${label}`}
onClick={() => {
void navigator.clipboard.writeText(value)
toast.success('Скопировано')
}}
>
<CopyIcon className="size-3.5" />
</Button>
</div>
)
}
function OidcClientRow({
client,
onRotate,
onDelete,
rotating,
}: {
client: OidcClientPublic
onRotate: () => void
onDelete: () => void
rotating: boolean
}) {
return (
<li className="border-border flex flex-col gap-2 rounded-lg border p-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{client.name}</span>
{client.enabled ? (
<Badge variant="success-light" size="sm">
enabled
</Badge>
</TabsContent>
<TabsContent value="issuer">
<FormFrame title="Issuer" description="Metadata для Relying Party" badge="OpenID Connect">
{meta ? (
<>
{(
[
['Issuer', meta.issuer],
['Discovery', meta.discovery_url],
['JWKS', meta.jwks_url],
] as const
).map(([label, value]) => (
<SettingRow key={label} title={label} description={value}>
<Button variant="outline" onClick={() => copy(value)} aria-label={`Копировать ${label}`}>
<CopyIcon /> Копировать
</Button>
</SettingRow>
))}
</>
) : (
<Badge variant="warning-light" size="sm">
disabled
</Badge>
<Skeleton className="h-24 w-full" />
)}
</div>
<code className="text-muted-foreground text-xs">
{client.client_id}
</code>
</div>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant="outline"
disabled={rotating}
onClick={onRotate}
>
<KeyRoundIcon className="size-4" />
Секрет
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={onDelete}
aria-label="Удалить"
>
<Trash2Icon className="size-4" />
</Button>
</div>
</div>
<div className="text-muted-foreground flex flex-col gap-1 text-xs">
{client.redirect_uris.map((u) => (
<span key={u}>{u}</span>
))}
<span>scopes: {client.scopes.join(' ')}</span>
</div>
</li>
)
}
</FormFrame>
</TabsContent>
</Tabs>
function SecretRevealDialog({
client,
onClose,
}: {
client: OidcClientCreated | null
onClose: () => void
}) {
const open = client != null
const text = useMemo(() => {
if (!client) return ''
return `client_id: ${client.client_id}\nclient_secret: ${client.client_secret}`
}, [client])
return (
<AlertDialog
open={open}
onOpenChange={(o: boolean) => {
if (!o) onClose()
}}
>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Сохраните client_secret</AlertDialogTitle>
<AlertDialogDescription>
Секрет показывается только один раз. Вставьте его в Technitium SSO.
</AlertDialogDescription>
</AlertDialogHeader>
{client ? (
<pre className="bg-muted max-h-40 overflow-auto rounded-md p-3 text-xs">
{text}
</pre>
<PortalSheet
open={selected !== null}
onOpenChange={(o) => !o && setSelected(null)}
title={selected?.name ?? 'Клиент'}
description="OIDC-клиент"
footer={
<>
<Button variant="outline" onClick={() => setSelected(null)}>
Закрыть
</Button>
<Button
variant="outline"
disabled={!selected || rotateMutation.isPending}
onClick={() => selected && rotateMutation.mutate(selected.id)}
>
<KeyRoundIcon /> Новый секрет
</Button>
<Button variant="destructive" onClick={() => selected && setDeleteTarget(selected)}>
Удалить
</Button>
</>
}
>
{selected ? (
<>
<div>
<StatusBadge tone={selected.enabled ? 'ok' : 'wn'}>{selected.enabled ? 'enabled' : 'disabled'}</StatusBadge>
</div>
<KvList items={[['client_id', selected.client_id], ['Scopes', selected.scopes.join(' ')]]} />
<CodeBlock label="Redirect URI">{selected.redirect_uris.join('\n')}</CodeBlock>
</>
) : null}
<AlertDialogFooter>
<AlertDialogCancel>Закрыть</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
void navigator.clipboard.writeText(text)
toast.success('Скопировано')
}}
>
Копировать
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PortalSheet>
<CreateOidcClientSheet open={createOpen} onOpenChange={setCreateOpen} pending={createMutation.isPending} onSubmit={(values) => createMutation.mutate(values)} />
<PortalSheet
open={secretOnce !== null}
onOpenChange={(o) => !o && setSecretOnce(null)}
title="Сохраните client_secret"
description="Секрет показывается только один раз · вставьте его в Technitium SSO"
footer={
<>
<Button variant="outline" onClick={() => setSecretOnce(null)}>
Закрыть
</Button>
<Button onClick={() => secretOnce && copy(`client_id: ${secretOnce.client_id}\nclient_secret: ${secretOnce.client_secret}`)}>
<CopyIcon /> Копировать
</Button>
</>
}
>
{secretOnce ? <CodeBlock label="Учётные данные">{`client_id: ${secretOnce.client_id}\nclient_secret: ${secretOnce.client_secret}`}</CodeBlock> : null}
</PortalSheet>
<TypedConfirm
open={deleteTarget !== null}
onOpenChange={(o) => !o && setDeleteTarget(null)}
title="Удалить OIDC-клиент?"
description="Relying Party перестанет получать токены с этим client_id."
expected={deleteTarget?.name ?? 'удалить'}
confirmLabel="Удалить"
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
/>
</>
)
}
+95 -81
View File
@@ -1,23 +1,24 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import {
BadgeCheckIcon,
CloudIcon,
GlobeIcon,
KeyRoundIcon,
LayoutDashboardIcon,
LayoutGridIcon,
ServerIcon,
ShieldIcon,
} from 'lucide-react'
import { toast } from 'sonner'
import type { AppAuthMode, AppId } from '@authportal/shared'
import { PageShell } from '@/components/page-shell'
import { IconTile } from '@/components/reui/icon-tile'
import {
Frame,
FramePanel,
} from '@/components/reui/frame'
import { QuickActionGrid } from '@/components/reui-kit/quick-action-grid'
import { PageHead, PageTab, PageTabsList } from '@/components/portal/bits'
import { AppCards, type AppCardItem } from '@/components/portal/canvas-blocks'
import { kpi } from '@/components/portal/kpi'
import { KpiStrip } from '@/components/portal/kpi-strip'
import { Button } from '@authportal/ui/components/button'
import { Skeleton } from '@authportal/ui/components/skeleton'
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { ssoOpenApp } from '@/lib/auth'
import { catalogQueryOptions, meQueryOptions } from '@/queries/auth'
@@ -35,93 +36,106 @@ const APP_ICONS: Record<AppId, typeof CloudIcon> = {
mm: LayoutDashboardIcon,
}
async function openApp(
_appId: AppId,
baseUrl: string,
authMode: AppAuthMode = 'jwt',
) {
async function openApp(baseUrl: string, authMode: AppAuthMode = 'jwt') {
try {
await ssoOpenApp(baseUrl, authMode)
} catch {
toast.error('Не удалось выпустить токен', { description: 'Открываем приложение по базовому адресу — войдите в нём отдельно.' })
window.location.href = baseUrl.replace(/\/$/, '')
}
}
function AppsPage() {
const { data: me, isLoading: meLoading } = useQuery(meQueryOptions)
const { data: catalog, isLoading: catalogLoading } = useQuery(
catalogQueryOptions,
)
const isLoading = meLoading || catalogLoading
const allowed = new Set(me?.apps ?? [])
const apps = (catalog?.apps ?? []).filter((app) => allowed.has(app.id))
const { data: catalog, isLoading: catalogLoading, isError, refetch } = useQuery(catalogQueryOptions)
if (meLoading || catalogLoading) {
return (
<>
<PageHead title="Приложения" sub="Открыть приложение с текущей сессией · SSO handoff без повторного ввода пароля" />
<Skeleton className="h-24 w-full rounded-xl" />
<Skeleton className="h-64 w-full rounded-xl" />
</>
)
}
const all = catalog?.apps ?? []
const allowedIds = new Set(me?.apps ?? [])
const allowed = all.filter((a) => allowedIds.has(a.id))
const denied = all.filter((a) => !allowedIds.has(a.id))
const modeOf = (a: (typeof all)[number]): AppAuthMode => a.authMode ?? (a.id === 'dns' ? 'oidc' : 'jwt')
const toCard = (app: (typeof all)[number], open: boolean): AppCardItem => {
const Icon = APP_ICONS[app.id] ?? LayoutGridIcon
const meta = `${app.url.replace(/^https?:\/\//, '')} · ${modeOf(app) === 'oidc' ? 'OIDC' : 'JWT'}`
return {
id: app.id,
title: app.title,
desc: app.description,
badge: open ? 'Открыть' : 'Нет доступа',
badgeVariant: open ? 'info-light' : 'outline',
meta,
icon: <Icon />,
onSelect: () =>
open
? void openApp(app.url, modeOf(app))
: toast.warning(`Нет доступа к ${app.title}`, { description: 'Запросите права у администратора.' }),
}
}
return (
<PageShell>
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">Приложения</h1>
<p className="text-muted-foreground text-sm">
Открыть приложение с текущей сессией (SSO handoff).
</p>
</div>
{me?.is_admin ? (
<Button
variant="outline"
size="sm"
render={<Link to="/admin/apps" />}
nativeButton={false}
>
Ссылки приложений
<>
<PageHead
title="Приложения"
sub="Открыть приложение с текущей сессией · SSO handoff без повторного ввода пароля"
actions={
me?.is_admin ? (
<Button variant="outline" render={<Link to="/admin/apps" />} nativeButton={false}>
Ссылки приложений
</Button>
) : undefined
}
/>
{isError ? (
<div role="alert" className="border-destructive/40 bg-destructive/10 flex flex-wrap items-center justify-between gap-3 rounded-xl border px-4 py-3 text-sm">
<span>Не удалось загрузить каталог приложений. Повторите запрос.</span>
<Button variant="outline" onClick={() => void refetch()}>
Повторить
</Button>
) : null}
</div>
{isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-24 w-full rounded-xl" />
))}
</div>
) : apps.length === 0 ? (
<Frame dense className="w-full">
<FramePanel className="flex flex-col items-start gap-3 py-10">
<IconTile
variant="elevated"
aria-hidden="true"
className="size-10.5 text-muted-foreground"
>
<LayoutGridIcon />
</IconTile>
<div className="flex flex-col gap-1">
<p className="font-medium">Нет доступных приложений</p>
<p className="text-muted-foreground text-sm">
Обратитесь к администратору, чтобы получить доступ.
</p>
) : null}
<KpiStrip
aria-label="Показатели"
items={[
kpi('avail', 'Доступно приложений', String(allowed.length), { icon: LayoutGridIcon, tone: 'ok', badge: `из ${all.length}` }),
kpi('jwt', 'JWT SSO', String(allowed.filter((a) => modeOf(a) === 'jwt').length), { icon: KeyRoundIcon, badge: 'fragment-токен' }),
kpi('oidc', 'OIDC', String(allowed.filter((a) => modeOf(a) === 'oidc').length), { icon: BadgeCheckIcon, badge: 'внешние RP' }),
kpi('role', 'Роль', me?.is_admin ? 'Админ' : 'Пользователь', { icon: ShieldIcon, badge: me?.is_admin ? 'is_admin' : 'доступ по правам' }),
]}
/>
<Tabs defaultValue="all" className="min-w-0">
<PageTabsList>
<PageTab value="all" label="Доступные" count={allowed.length} />
<PageTab value="no" label="Без доступа" count={denied.length} />
</PageTabsList>
<TabsContent value="all" className="min-w-0">
{allowed.length === 0 ? (
<div className="text-muted-foreground flex flex-col gap-1 rounded-xl border border-dashed px-4 py-10 text-sm">
<b className="text-foreground font-medium">Нет доступных приложений</b>
Обратитесь к администратору, чтобы получить доступ.
</div>
</FramePanel>
</Frame>
) : (
<QuickActionGrid
title=""
actions={apps.map((app) => {
const Icon = APP_ICONS[app.id] ?? LayoutGridIcon
return {
id: app.id,
title: app.title,
description: app.description,
badgeLabel: 'Открыть',
icon: <Icon />,
onSelect: () =>
void openApp(
app.id,
app.url,
app.authMode ?? (app.id === 'dns' ? 'oidc' : 'jwt'),
),
}
})}
/>
)}
</PageShell>
) : (
<AppCards
title="Мои приложения"
description="Клик открывает приложение в новой вкладке · портал заново выпускает токен с актуальными правами"
items={allowed.map((a) => toCard(a, true))}
/>
)}
</TabsContent>
<TabsContent value="no" className="min-w-0">
<AppCards title="Недоступные приложения" description="Права не выданы · запросите у администратора" items={denied.map((a) => toCard(a, false))} />
</TabsContent>
</Tabs>
</>
)
}
+3 -3
View File
@@ -1,5 +1,5 @@
import { Outlet, createFileRoute, redirect } from '@tanstack/react-router'
import { AppShell } from '@/components/layout/app-shell'
import { PortalShell } from '@/components/layout/portal-shell'
import {
clearToken,
getToken,
@@ -36,8 +36,8 @@ export const Route = createFileRoute('/_auth')({
function AuthLayout() {
return (
<AppShell>
<PortalShell>
<Outlet />
</AppShell>
</PortalShell>
)
}
+97
View File
@@ -0,0 +1,97 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
clearToken,
getToken,
jwtClaimsStaleVsMe,
setToken,
} from '@/lib/auth'
import { ApiError, api } from '@/lib/api-client'
function jwt(payload: object): string {
const b64 = (o: object) => btoa(JSON.stringify(o)).replace(/=+$/, '')
return `${b64({ alg: 'none' })}.${b64(payload)}.sig`
}
const future = () => Math.floor(Date.now() / 1000) + 3600
beforeEach(() => localStorage.clear())
afterEach(() => vi.restoreAllMocks())
describe('хранилище токена', () => {
it('отдаёт валидный токен, удаляет просроченный', () => {
const good = jwt({ exp: future() })
setToken(good)
expect(getToken()).toBe(good)
setToken(jwt({ exp: 1 }))
expect(getToken()).toBeNull()
expect(localStorage.getItem('authportal_token')).toBeNull()
})
it('clearToken очищает', () => {
setToken(jwt({ exp: future() }))
clearToken()
expect(getToken()).toBeNull()
})
})
describe('jwtClaimsStaleVsMe', () => {
const me = {
id: 'u1',
email: 'a@b.co',
name: 'A',
is_admin: false,
apps: ['dns', 'vps'],
permissions: ['dns:dns:read'],
} as never
it('совпадающие клейм-ы не устарели (порядок не важен)', () => {
const t = jwt({ is_admin: false, apps: ['vps', 'dns'], permissions: ['dns:dns:read'] })
expect(jwtClaimsStaleVsMe(t, me)).toBe(false)
})
it('любое расхождение — устарел', () => {
expect(jwtClaimsStaleVsMe(jwt({ is_admin: true, apps: ['dns', 'vps'], permissions: ['dns:dns:read'] }), me)).toBe(true)
expect(jwtClaimsStaleVsMe(jwt({ is_admin: false, apps: ['dns'], permissions: ['dns:dns:read'] }), me)).toBe(true)
expect(jwtClaimsStaleVsMe(jwt({ is_admin: false, apps: ['dns', 'vps'], permissions: [] }), me)).toBe(true)
expect(jwtClaimsStaleVsMe('garbage', me)).toBe(true)
})
})
describe('api-client', () => {
function mockFetch(status: number, body?: string) {
return vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(body ?? null, { status }),
)
}
it('добавляет Bearer и разбирает JSON', async () => {
const token = jwt({ exp: future() })
setToken(token)
const spy = mockFetch(200, JSON.stringify({ ok: 1 }))
await expect(api.get('/api/v1/x')).resolves.toEqual({ ok: 1 })
const init = spy.mock.calls[0]![1] as RequestInit
expect((init.headers as Record<string, string>).Authorization).toBe(`Bearer ${token}`)
expect(init.credentials).toBe('include')
})
it('204 → undefined', async () => {
mockFetch(204)
await expect(api.get('/api/v1/x')).resolves.toBeUndefined()
})
it('401 очищает токен и бросает ApiError с кодом сервера', async () => {
setToken(jwt({ exp: future() }))
mockFetch(401, JSON.stringify({ error: { code: 'UNAUTHORIZED', message: 'nope' } }))
const err = await api.get('/api/v1/x').catch((e) => e)
expect(err).toBeInstanceOf(ApiError)
expect(err).toMatchObject({ status: 401, code: 'UNAUTHORIZED', message: 'nope' })
expect(getToken()).toBeNull()
})
it('не-JSON тело ошибки → ApiError, а не падение парсера', async () => {
mockFetch(500, '<html>oops</html>')
const err = await api.get('/api/v1/x').catch((e) => e)
expect(err).toBeInstanceOf(ApiError)
expect(err.code).toBe('PARSE_ERROR')
})
})
+35
View File
@@ -0,0 +1,35 @@
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
// @ts-expect-error — .mjs без типов
import { checkBudget } from '../../../scripts/ci/web-budget.mjs'
function dist(files: Record<string, string>): string {
const dir = mkdtempSync(join(tmpdir(), 'web-budget-'))
for (const [name, body] of Object.entries(files)) {
writeFileSync(join(dir, name), body)
}
return dir
}
describe('web-budget', () => {
it('в бюджете → без ошибок', () => {
const dir = dist({ 'index-abc.js': 'a'.repeat(100), 'x-1.js': 'b'.repeat(100) })
const { errors, sizes } = checkBudget(dir, { entryGz: 1000, totalGz: 2000 })
expect(errors).toEqual([])
expect(sizes.entryGz).toBeGreaterThan(0)
})
it('превышение entry и total → ошибки', () => {
const noise = Array.from({ length: 5000 }, (_, i) => `v${i}=${Math.random()}`).join(';')
const dir = dist({ 'index-abc.js': noise })
const { errors } = checkBudget(dir, { entryGz: 10, totalGz: 10 })
expect(errors).toHaveLength(2)
})
it('нет entry-чанка → ошибка', () => {
const dir = dist({ 'other.js': 'x' })
expect(checkBudget(dir).errors[0]).toMatch(/entry chunk/)
})
})
+30
View File
@@ -0,0 +1,30 @@
// Node ≥ 25 имеет собственный экспериментальный localStorage, который затеняет
// jsdom и без --localstorage-file не работает. Подставляем простую реализацию.
class MemoryStorage implements Storage {
private map = new Map<string, string>()
get length() {
return this.map.size
}
clear() {
this.map.clear()
}
getItem(key: string) {
return this.map.get(key) ?? null
}
key(index: number) {
return [...this.map.keys()][index] ?? null
}
removeItem(key: string) {
this.map.delete(key)
}
setItem(key: string, value: string) {
this.map.set(key, String(value))
}
}
if (typeof globalThis.localStorage?.clear !== 'function') {
Object.defineProperty(globalThis, 'localStorage', {
value: new MemoryStorage(),
configurable: true,
})
}
+16
View File
@@ -18,6 +18,22 @@ export default defineConfig(({ mode }) => ({
? [visualizer({ filename: 'dist/stats.html', gzipSize: true })]
: []),
],
build: {
// Вендорные чанки меняются реже кода приложения → лучше кэш браузера между релизами.
rolldownOptions: {
output: {
advancedChunks: {
groups: [
{
name: 'vendor-react',
test: /node_modules[\\/](react|react-dom|scheduler)[\\/]/,
},
{ name: 'vendor-tanstack', test: /node_modules[\\/]@tanstack[\\/]/ },
],
},
},
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
+13
View File
@@ -1,7 +1,20 @@
import path from 'path'
import { defineConfig } from 'vitest/config'
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@authportal/shared': path.resolve(
__dirname,
'../../packages/shared/src/index.ts',
),
},
},
test: {
passWithNoTests: true,
environment: 'jsdom',
include: ['test/**/*.test.ts'],
setupFiles: ['test/setup.ts'],
},
})
+28 -8
View File
@@ -29,6 +29,7 @@ services:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.network=auth-portal
- --ping=true
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --entrypoints.web.http.redirections.entrypoint.to=websecure
@@ -43,6 +44,12 @@ services:
- traefik_letsencrypt:/letsencrypt
networks:
- auth-portal
healthcheck:
test: ["CMD", "traefik", "healthcheck", "--ping"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
logging:
driver: json-file
options:
@@ -54,8 +61,23 @@ services:
pull_policy: always
container_name: auth-portal
restart: unless-stopped
init: true
stop_grace_period: 15s
# Лимиты ресурсов: heap Node (NODE_OPTIONS в образе) рассчитан на 256 МиБ.
mem_limit: 256m
memswap_limit: 256m
cpus: "1.0"
pids_limit: 128
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
depends_on:
- traefik
traefik:
condition: service_healthy
# No host ports — only Traefik publishes 80/443.
env_file:
- .env
@@ -86,14 +108,12 @@ services:
- traefik.http.routers.auth-portal.tls=true
- traefik.http.routers.auth-portal.tls.certresolver=letsencrypt
- traefik.http.services.auth-portal.loadbalancer.server.port=8080
# Сжатие JSON/текста на стороне Traefik (ноль CPU/RAM в Node);
# предсжатые .br/.gz статики Traefik не пересжимает (есть Content-Encoding).
- traefik.http.middlewares.auth-portal-compress.compress=true
- traefik.http.routers.auth-portal.middlewares=auth-portal-compress
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/ready"]
interval: 30s
timeout: 5s
retries: 3
+32 -12
View File
@@ -1,6 +1,8 @@
# syntax=docker/dockerfile:1.7
# All-in-one Auth Portal (Fastify + SPA + SQLite). Analog cfdm / evofw.
ARG BASE_NODE=docker.io/library/node:22-alpine
# Закреплён по digest (воспроизводимая сборка); обновляйте вместе с node:22-alpine
# (digest: docker buildx imagetools inspect node:22-alpine).
ARG BASE_NODE=docker.io/library/node:22-alpine@sha256:fe4f6d74e20370acb04ad889077d14d0f64a9c8ea6a42bc60cddf1e844699914
FROM ${BASE_NODE} AS deps
WORKDIR /repo
@@ -22,27 +24,43 @@ COPY apps/api apps/api
COPY packages/ui packages/ui
COPY packages/shared packages/shared
COPY packages/db packages/db
# Preview-маршруты дизайна (mock-данные) не нужны в проде: вырезаем до генерации routeTree
# (-45% файлов и ~-10% gzip в бандле). Общие компоненты из src/design-preview остаются.
RUN rm -f apps/web/src/routes/design-preview*.tsx
# API собирается tsup в один файл (все зависимости внутри, кроме нативных).
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
pnpm turbo build --filter=web --filter=@authportal/api \
&& pnpm --filter @authportal/api deploy --prod /out \
pnpm turbo build --filter=web --filter=@authportal/api
# Рантайм-слой: бандл + статика (только brotli) + нативные модули с теми же версиями, что в lockfile.
RUN mkdir -p /out \
&& cp -r apps/api/dist /out/dist \
&& cp -r apps/web/dist /out/static \
&& find /out/static -type f \( -name '*.js' -o -name '*.css' \) -exec sh -c \
'gzip -k9 "$1" && brotli -f -q 11 -o "$1.br" "$1"' _ {} \; \
&& rm -rf /out/src /out/test /out/.turbo \
&& rm -rf /out/node_modules/@authportal/db/src /out/node_modules/@authportal/db/scripts /out/node_modules/@authportal/db/.turbo \
&& rm -rf /out/node_modules/@authportal/shared/src /out/node_modules/@authportal/shared/.turbo \
&& find /out/dist /out/node_modules/@authportal -type f \( -name '*.d.ts' -o -name '*.map' -o -name 'tsconfig*.json' -o -name 'vitest.config.ts' -o -name 'drizzle.config.ts' \) -delete
&& find /out/static -type f \( -name '*.js' -o -name '*.css' -o -name '*.html' -o -name '*.svg' -o -name '*.json' \) \
-exec sh -c 'brotli -f -q 11 -o "$1.br" "$1"' _ {} \; \
&& node -e "const v=(p)=>require(p).version;require('fs').writeFileSync('/out/package.json',JSON.stringify({name:'authportal-runtime',private:true,type:'module',dependencies:{'better-sqlite3':v('./packages/db/node_modules/better-sqlite3/package.json'),'@node-rs/argon2':v('./apps/api/node_modules/@node-rs/argon2/package.json')}}))"
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
cd /out && npm install --omit=dev --no-audit --no-fund \
&& rm -rf node_modules/better-sqlite3/deps node_modules/better-sqlite3/src \
node_modules/better-sqlite3/build/Release/obj node_modules/better-sqlite3/build/Release/.deps \
node_modules/better-sqlite3/build/deps \
&& node -e "const D=require('better-sqlite3');new D(':memory:').prepare('select 1').get();import('@node-rs/argon2').then(m=>m.hash('x')).then(()=>console.log('native modules ok'))"
FROM ${BASE_NODE} AS runtime
WORKDIR /app
# Не-root runtime; /data — bind mount с хоста, на сервере нужен
# `chown -R 1000:1000 ./data` (см. deploy/docker-compose.traefik.yml).
RUN apk add --no-cache ca-certificates \
&& mkdir -p /data && chown node:node /data
RUN mkdir -p /data && chown node:node /data
ARG VERSION=dev
ARG GIT_SHA=unknown
ARG BUILD_TIME=
LABEL org.opencontainers.image.title="auth-portal" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.revision="${GIT_SHA}" \
org.opencontainers.image.created="${BUILD_TIME}"
# Куча V8 ограничена под контейнер 256 МиБ (RSS = куча + нативная память + argon2);
# при другом mem_limit переопределите NODE_OPTIONS.
ENV NODE_ENV=production \
NODE_OPTIONS=--max-old-space-size=160 \
UV_THREADPOOL_SIZE=4 \
STATIC_DIR=/app/static \
DATABASE_URL=sqlite:/data/app.db \
SERVER_PORT=8080 \
@@ -52,7 +70,9 @@ ENV NODE_ENV=production \
COPY --from=build --chown=node:node /out ./
USER node
EXPOSE 8080
STOPSIGNAL SIGTERM
VOLUME ["/data"]
# busybox wget вместо запуска второго процесса node каждые 30 с.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
CMD ["wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/ready"]
CMD ["node", "dist/server.js"]
+7 -1
View File
@@ -30,8 +30,13 @@ variable "CACHE_REF_NODE" {
default = ""
}
# provenance + SBOM; включается только при --push (docker exporter attestations не поддерживает).
variable "ATTEST" {
default = "false"
}
variable "BASE_NODE" {
default = "docker.io/library/node:22-alpine"
default = "docker.io/library/node:22-alpine@sha256:fe4f6d74e20370acb04ad889077d14d0f64a9c8ea6a42bc60cddf1e844699914"
}
function "node-cache-from" {
@@ -113,5 +118,6 @@ target "authportal" {
build = "target:node-build"
}
cache-from = node-cache-from()
attest = equal(ATTEST, "true") ? ["type=provenance,mode=min", "type=sbom"] : []
tags = concat(image-tags("authportal"), image-tags("auth-portal"))
}
+25
View File
@@ -31,3 +31,28 @@ NODE_ENV=production
# WEBAUTHN_RP_ID=auth.shnt.top
# WEBAUTHN_ORIGINS=https://auth.shnt.top
# WEBAUTHN_RP_NAME=Auth Portal
# --- Ingest аудита от подключённых приложений (обязателен для /api/v1/ingest/audit) ---
# AUDIT_INGEST_SECRET=
# --- Производительность / эксплуатация (все необязательны) ---
# Максимум на graceful shutdown, мс
# SHUTDOWN_TIMEOUT_MS=8000
# Одновременных argon2-хешей (каждый ~19 МиБ RAM)
# ARGON2_CONCURRENCY=2
# Сколько дней хранить истёкшие/отозванные сессии и коды
# SESSION_RETENTION_DAYS=7
# Доверенные прокси для X-Forwarded-For (IP/CIDR через запятую, true/false)
# 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
# Сжатие ответов в Node (нужно только без Traefik compress)
# ENABLE_COMPRESS=false
# Глобальный лимит запросов на IP в минуту
# RATE_LIMIT_MAX=200
# Доп. origins для CORS (по умолчанию: issuer, RETURN_TO_ALLOWLIST, приложения App Switcher)
# CORS_ORIGINS=
# Бэкап SQLite: период в часах (0 = выключен), число копий, каталог
# BACKUP_INTERVAL_HOURS=24
# BACKUP_KEEP=7
# BACKUP_DIR=/data/backups
# Heap Node (образ рассчитан на mem_limit 256m); при другом лимите:
# NODE_OPTIONS=--max-old-space-size=160
+17 -3
View File
@@ -6,7 +6,7 @@ services:
- "8080:8080"
environment:
DATABASE_URL: sqlite:/data/app.db
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in .env (min 16 chars)}
JWT_TTL_HOURS: ${JWT_TTL_HOURS:-1}
REFRESH_TTL_DAYS: ${REFRESH_TTL_DAYS:-14}
ISSUER: ${ISSUER:-https://auth.shnt.top}
@@ -15,7 +15,7 @@ services:
# Опционально: PKCS8 PEM RSA. Иначе ключ генерируется и хранится в SQLite (/data).
OIDC_RSA_PRIVATE_KEY: ${OIDC_RSA_PRIVATE_KEY:-}
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@shnt.top}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env (min 8 chars)}
ADMIN_NAME: ${ADMIN_NAME:-Admin}
RETURN_TO_ALLOWLIST: ${RETURN_TO_ALLOWLIST:-.shnt.top,localhost,private}
LOG_LEVEL: info
@@ -23,8 +23,22 @@ services:
volumes:
- ./data:/data
restart: unless-stopped
init: true
stop_grace_period: 15s
# Лимиты ресурсов: heap Node (NODE_OPTIONS в образе) рассчитан на 256 МиБ.
mem_limit: 256m
memswap_limit: 256m
cpus: "1.0"
pids_limit: 128
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/ready"]
interval: 30s
timeout: 5s
retries: 3
+12 -2
View File
@@ -211,14 +211,24 @@ Portal — IdP; Technitium — RP. После `docker compose up`:
## Бэкап
```bash
# SQLite
cp /opt/auth-portal/data/app.db /opt/auth-portal/data/app.db.bak-$(date +%F)
# SQLite: согласованная онлайн-копия (WAL учитывается, сервис не останавливается),
# проверка integrity_check и ротация (BACKUP_KEEP, по умолчанию 7) → /data/backups/
docker exec auth-portal node dist/server.js backup
# Либо встроенный планировщик: BACKUP_INTERVAL_HOURS=24 в .env (0 = выключен).
# Каталог: BACKUP_DIR (по умолчанию <каталог БД>/backups). Не копируйте app.db
# через `cp` на живой БД — без -wal/-shm копия может быть неполной.
# ACME
docker run --rm -v auth_portal_traefik_letsencrypt:/data -v "$PWD:/backup" alpine \
tar czf /backup/traefik-acme-$(date +%F).tgz -C /data .
```
**Восстановление:** `docker compose stop app`, затем в `./data` удалить
`app.db-wal`/`app.db-shm`, скопировать нужный `backups/app-*.db` как `app.db`,
`chown 1000:1000`, `docker compose up -d`. OIDC-ключ подписи хранится в этой же
БД, если не задан `OIDC_RSA_PRIVATE_KEY` — храните ключ отдельным секретом.
Откат образа: в `.env` `AUTH_IMAGE_TAG=vX.Y.Z` → `docker compose pull && docker compose up -d`.
---
+62
View File
@@ -0,0 +1,62 @@
# Производительность и ресурсы контейнера
Что сделано для быстродействия, стабильности и минимального потребления
ресурсов, и как это проверять.
## Что включено
| Область | Решение |
|---|---|
| Остановка | SIGTERM/SIGINT → `app.close()` (дожидается запросов) → WAL checkpoint → `close`. Таймаут `SHUTDOWN_TIMEOUT_MS` (8 с), повторный сигнал — немедленный выход. В compose `init: true`, `stop_grace_period: 15s`. |
| SQLite | `busy_timeout`, `cache_size`, `temp_store=MEMORY`, `mmap_size`, лимит WAL; транзакции для записи доступа/аудита; версия схемы в `user_version` (миграции не гоняются на каждом старте); индексы под очистку и фильтры; фильтры/сортировки в SQL. |
| Очистка | Раз в час: истёкшие/отозванные сессии, коды авторизации, WebAuthn-челленджи (пачками по 1000, `SESSION_RETENTION_DAYS`). |
| Горячий путь | Кэш профиля пользователя (инвалидируется любой записью в users/доступ — блокировка действует сразу), кэш App Switcher, мемоизация JWKS. Лимитер argon2 (`ARGON2_CONCURRENCY`). Секреты OIDC-клиентов — SHA-256 (старые argon2 мигрируют при первом обмене). |
| HTTP | Hashed-ассеты `immutable` на год, `index.html` — `no-cache`; сжатие JSON на стороне Traefik; CORS по allowlist; `/health` (liveness, без БД) и `/ready` (БД + ключ OIDC); корректные 404 для `/oauth`, `/.well-known`, `/api`. |
| Образ | API бандлится tsup в один файл, в образ ставятся только нативные модули; статика только `.br`; healthcheck через `wget`; `NODE_OPTIONS=--max-old-space-size=160`; лимиты compose: 256 МиБ, 1 CPU, `read_only`, `cap_drop: ALL`. |
| Бэкап | `docker exec auth-portal node dist/server.js backup` или `BACKUP_INTERVAL_HOURS`; онлайн-копия, `integrity_check`, ротация. |
Дополнительно: подготовленные запросы (`prepare` один раз на соединение),
строгая CSP (`'self'`, без inline-скриптов), опциональное сжатие в Node
(`ENABLE_COMPRESS=true`, если нет Traefik compress), базовый образ закреплён по
digest, `design-preview`-маршруты вырезаются из прод-образа, healthcheck Traefik
и `depends_on: service_healthy`, provenance + SBOM при публикации, Trivy
(информативно), общий composite-action CI, `inputs` в `turbo.json`.
## Замеры (локально, Windows, Node 25; не Linux-контейнер)
| Что | Результат |
|---|---|
| `listActiveSessions` по пользователю на 100k сессий | ~1.6 мс |
| `runHousekeeping`, 90k устаревших сессий | ~220 мс |
| `appendAuditBatch`, 10k событий одной транзакцией | ~610 мс |
| `listAudit(kind=logins)` на 10k | ~5 мс |
| 20k prepared-запросов профиля | ~28 мс |
| HTTP `GET /health` (autocannon, 50 соединений) | ~15 000 rps, p99 7 мс |
| HTTP `GET /api/v1/auth/me` (JWT, кэш профиля) | ~8 300 rps, p99 11 мс |
| web: файлов в `dist/assets` | 87 → 48 без preview-маршрутов; gzip JS 484 → 437 КБ |
| Рабочий набор процесса (Windows) | ~117 МиБ в покое, ~237 МиБ после потока 8 с при куче 160 МБ |
Рабочий набор Windows не равен RSS контейнера Linux. Реальный RSS под лимитом
256 МиБ мерит `scripts/ci/smoke-image.sh` и ночной `nightly-perf` — при
превышении увеличьте `mem_limit` и `NODE_OPTIONS` вместе.
## Проверка
```bash
pnpm turbo test:coverage --filter=@authportal/api --filter=@authportal/db --filter=@authportal/shared
pnpm --filter web build && node scripts/ci/web-budget.mjs apps/web/dist/assets
bash scripts/ci/smoke-image.sh <image> # размер, RSS, /ready, docker stop, данные после рестарта
pnpm --filter @authportal/db test:perf # бюджеты производительности БД
node scripts/perf/load.mjs http://127.0.0.1:8080 [container] # autocannon (ночью: nightly-perf.yaml)
```
Бюджеты smoke-теста (env): `MAX_IMAGE_MB=250`, `MAX_RSS_MB=200`, `MAX_START_S=15`,
`MAX_STOP_S=5`. Подтяните их после первого замера на CI.
## Эксплуатационные заметки
- В production `JWT_SECRET` должен быть уникальным (≥ 16 символов), а
`ADMIN_PASSWORD` для первого запуска — не короче 8 символов и не `admin`.
- Если нужен другой лимит памяти, меняйте `mem_limit` и `NODE_OPTIONS` вместе
(куча ≈ 60–65 % лимита).
- Одна реплика: SQLite не поддерживает несколько писателей.
+6 -1
View File
@@ -16,12 +16,17 @@
"@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/exec": "^7.0.0",
"@semantic-release/release-notes-generator": "^14.0.3",
"autocannon": "7.15.0",
"conventional-commits-parser": "^6.4.0",
"semantic-release": "^25.0.2",
"turbo": "^2.5.4"
},
"packageManager": "pnpm@10.12.1",
"pnpm": {
"onlyBuiltDependencies": ["better-sqlite3", "esbuild", "@node-rs/argon2"]
"onlyBuiltDependencies": [
"better-sqlite3",
"esbuild",
"@node-rs/argon2"
]
}
}
+9 -4
View File
@@ -19,6 +19,9 @@
"build": "tsup src/index.ts --format esm --dts",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage",
"test:perf": "vitest run --config vitest.perf.config.ts",
"dev": "tsup src/index.ts --format esm --dts --watch",
"db:generate": "drizzle-kit generate",
"db:push": "drizzle-kit push"
@@ -29,13 +32,15 @@
"drizzle-orm": "^0.44.2"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@eslint/js": "catalog:",
"@types/better-sqlite3": "^7.6.13",
"@vitest/coverage-v8": "4.1.10",
"drizzle-kit": "^0.31.1",
"eslint": "^10.7.0",
"globals": "^17.7.0",
"eslint": "catalog:",
"globals": "catalog:",
"tsup": "^8.5.0",
"typescript": "catalog:",
"typescript-eslint": "^8.64.0"
"typescript-eslint": "catalog:",
"vitest": "catalog:"
}
}
+35 -14
View File
@@ -1,4 +1,4 @@
import { and, desc, eq, like, lt, notLike, or, sql } from 'drizzle-orm'
import { and, desc, eq, gte, lt, or, sql } from 'drizzle-orm'
import { randomUUID } from 'node:crypto'
import {
DEFAULT_AUDIT_RETENTION_DAYS,
@@ -10,6 +10,7 @@ import {
} from '@authportal/shared'
import type { AppDb } from './index.js'
import { auditLog, portalSettings } from './schema/index.js'
import { withTransaction } from './tx.js'
const SETTINGS_ID = 'main'
@@ -64,16 +65,9 @@ export function appendAudit(db: AppDb, input: AppendAuditInput): boolean {
const now = input.createdAt ?? new Date().toISOString()
const eventId = input.eventId ?? null
if (eventId) {
const existing = db
.select({ id: auditLog.id })
.from(auditLog)
.where(eq(auditLog.eventId, eventId))
.get()
if (existing) return false
}
db.insert(auditLog)
// Дубликат event_id отсекается уникальным индексом: без SELECT и гонок.
const res = db
.insert(auditLog)
.values({
id: randomUUID(),
eventId,
@@ -90,8 +84,32 @@ export function appendAudit(db: AppDb, input: AppendAuditInput): boolean {
ip: input.ip ?? null,
createdAt: now,
})
.onConflictDoNothing()
.run()
return true
return res.changes > 0
}
/** Пачка событий в одной транзакции — один коммит вместо N. */
export function appendAuditBatch(
db: AppDb,
inputs: AppendAuditInput[],
): { accepted: number; duplicates: number; failed: number } {
let accepted = 0
let duplicates = 0
let failed = 0
withTransaction(db, () => {
for (const input of inputs) {
try {
// SAVEPOINT: сбой одного события не откатывает остальные.
const ok = withTransaction(db, () => appendAudit(db, input))
if (ok) accepted += 1
else duplicates += 1
} catch {
failed += 1
}
}
})
return { accepted, duplicates, failed }
}
export function listAudit(
@@ -112,9 +130,12 @@ export function listAudit(
if (opts.severity) conditions.push(eq(auditLog.severity, opts.severity))
if (opts.sourceApp) conditions.push(eq(auditLog.sourceApp, opts.sourceApp))
if (opts.kind === 'logins') {
conditions.push(like(auditLog.action, 'auth.%'))
// Диапазон по индексу action: 'auth.' <= action < 'auth/' ('/' идёт сразу после '.').
conditions.push(gte(auditLog.action, 'auth.'), lt(auditLog.action, 'auth/'))
} else if (opts.kind === 'changes') {
conditions.push(notLike(auditLog.action, 'auth.%'))
conditions.push(
or(lt(auditLog.action, 'auth.'), gte(auditLog.action, 'auth/'))!,
)
}
if (opts.actorEmail && opts.userId) {
conditions.push(
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff. Show more