45 Commits
Author SHA1 Message Date
DenozordecandClaude Sonnet 5.5 5c695c0ae0 feat(web): страница входа по доске AuthLogin
quality / changes (push) Successful in 6s
quality / api (push) Skipped
quality / commitlint (push) Skipped
quality / web (push) Successful in 1m0s
quality / docker-check (push) Successful in 1m31s
CD / quality (push) Successful in 4m35s
CD / publish (push) Successful in 1m46s
Две колонки: описание портала с перечнем возможностей и форма во Frame; плашка SSO показывает приложение, в которое вернёмся после входа.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 19:48:43 +07:00
DenozordecandClaude Sonnet 5.5 11a0b56be5 fix(web): контент app-shell на всю ширину, как в MikrotikManager
quality / changes (push) Successful in 6s
quality / api (push) Skipped
quality / commitlint (push) Skipped
quality / web (push) Successful in 47s
quality / docker-check (push) Successful in 1m15s
CD / quality (push) Successful in 2m10s
CD / publish (push) Successful in 1m37s
Убран max-w-[1480px] mx-auto у контейнера содержимого в portal-shell.
Ширина сайдбара (240px / 18rem / 3rem), высота шапки (min-h-12) и
отступы (px-4 md:px-6, py-4 md:py-5) уже совпадали.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 18:16:02 +07:00
DenozordecandClaude Sonnet 5.5 09c9531ad9 feat(ui): палитра токенов как в MikrotikManager (light/dark)
quality / changes (push) Successful in 7s
quality / api (push) Skipped
quality / commitlint (push) Skipped
quality / web (push) Successful in 46s
quality / docker-check (push) Successful in 1m19s
CD / quality (push) Successful in 2m13s
CD / publish (push) Successful in 1m21s
packages/ui/src/styles/globals.css: значения :root/.dark перенесены из
MikrotikManager app/globals.css (background, card, primary, secondary, muted,
accent, destructive, border, input, ring, chart-1..5, sidebar-*, success/info/
warning/invert). Добавлены токены MM: chart-line-1..4, chart-rx/tx, status-*
(online/offline/degraded + fg/bg). Шкала радиусов -sm..-4xl как в MM
(множители от --radius 0.625rem). Имена токенов ReUI/shadcn сохранены,
--focus* оставлены (используются ReUI).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 18:11:29 +07:00
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
2026-10-23 14:09:04 +07:00
DenozordecandClaude Sonnet 5.5 69b3553f6d feat(web): перенести страницы Приложения, Аккаунт, Входы и Изменения на новый дизайн
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 14:08:10 +07:00
DenozordecandClaude Sonnet 5.5 148a778ba1 feat(web): оболочка портала на блоках макета — меню, шапка, монитор, палитра ⌘K
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 13:59:35 +07:00
DenozordecandClaude Sonnet 5.5 6898d2255d refactor(web): вынести общие блоки макета в components/portal
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 13:53:59 +07:00
DenozordecandClaude Sonnet 5.5 b89e72ec1b fix(design-preview): плитки монитора системы не разъезжаются
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 46s
CD / quality (push) Successful in 56s
CD / publish (push) Successful in 1m27s
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 12:14:13 +07:00
DenozordecandClaude Sonnet 5.5 41d9325d14 fix(design-preview): убрать плашку «Обновлено · Live · авто» из шапки
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 12:13:16 +07:00
DenozordecandClaude Sonnet 5.5 f6466d9d7c revert(design-preview): вернуть компоновку каркаса как в MikrotikManager v2
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 12:11:28 +07:00
DenozordecandClaude Sonnet 5.5 609bea6ad7 feat(design-preview): каркас на блоке app-shell-12 из ReUI PRO
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 12:03:55 +07:00
DenozordecandClaude Sonnet 5.5 f97604c23e fix(design-preview): вкладки line как в ReUI — без сплошной нижней линии
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 11:57:16 +07:00
DenozordecandClaude Sonnet 5.5 1395e2a70b fix(design-preview): убрать двойную линию под вкладками окна прав
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 18:54:32 +07:00
DenozordecandClaude Sonnet 5.5 a07cb0e55a feat(design-preview): иконки в карточках KPI
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 16:54:54 +07:00
DenozordecandClaude Sonnet 5.5 c8d69bbdfa fix(design-preview): палитра shnt Ops, окно прав и палитра ⌘K как на досках
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 16:37:40 +07:00
DenozordecandClaude Sonnet 5.5 700ad508d8 fix(design-preview): правки по ревью — пороги, высоты вкладок, контраст фокуса, счётчик вкладок
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 14:57:22 +07:00
DenozordecandClaude Sonnet 5.5 b82c8031c2 fix(design-preview): размеры, отступы и пороги ширины как на досках Auth Portal
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 14:53:25 +07:00
DenozordecandClaude Sonnet 5.5 3e1ad15ba3 fix(design-preview): палитра MikrotikManager v2 для макета Auth Portal
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 14:43:30 +07:00
DenozordecandClaude Sonnet 5.5 1b89135186 fix(design-preview): окна создания, подписи и кнопки как на досках Auth Portal
Окна «Создать пользователя» и «Создать OIDC-клиент» по доске с проверкой полей; баннер безопасности
всегда виден; кнопки действий без иконок; подписи иконок приложений как на доске; поиск в таблице passkeys.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 03:44:19 +07:00
DenozordecandClaude Sonnet 5.5 cd6516edd6 fix(design-preview): полоса KPI, баннеры и шапка точно как на досках Auth Portal
Полоса показателей — один Frame с линиями и плашками, колонки по порогам контейнера; баннер в одну строку
с «Объяснить»; бренд «Единый вход · SSO», заголовки групп капсом; пороги монитора в шапке как на доске.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 03:41:00 +07:00
DenozordecandClaude Sonnet 5.5 afbc6fc366 feat(design-preview): перенос досок Auth Portal 1 к 1 — таблицы, вкладки, формы, права
Таблицы по анатомии доски: «Таблица/Карточки», поиск, «Колонки», виды линией со счётчиками, панель
массовых действий, переключатель и меню «⋮» в строке. Вкладки страниц — линия с мелким счётчиком.
Строки настроек (пароль, ротация журнала, меню), «Объяснить» в баннерах с панелью разбора,
палитра с ассистентом и OIDC-клиентами, сворачиваемые группы меню, плашка «Обновлено · Live»,
данные досок «Изменения» и «Доступ по приложениям», права доступа вкладками.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 03:35:45 +07:00
DenozordecandClaude Sonnet 5.5 709dd93548 fix(design-preview): «Приложения» и «Изменения» точно как на досках канваса
Плитки приложений и лента событий собраны по разметке доски: Frame с шапкой, сетка auto-fit
от 260px, иконка, плашка, описание и адрес; лента — время, точка с линией, плашка и подпись.
На «Приложениях» возвращена полоса KPI, «Весь журнал» открывает таблицу.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 03:18:06 +07:00
DenozordecandClaude Sonnet 5.5 60e0b886fc fix(design-preview): плитки приложений внутри рамки и стиль MikrotikManager v2
QuickActionGrid получил autoFit: Frame с отступами и сетка auto-fit от 17rem, плитки больше не
вылетают за границы. Центр состояния в шапке, меню пользователя (тема, плотность, контраст),
точки проблем в меню, размытая шапка, высокий контраст и видимый фокус приведены к макету v2.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 03:15:12 +07:00
DenozordecandClaude Sonnet 5.5 8bbaab62e4 fix(design-preview): убрать страницу пользователя — дублировала панель прав
Права, сброс passkeys, отключение и удаление уже есть в панели прав и меню строки,
сессии и события — в «Входах» и «Изменениях». Страница откроется из коммита 007ece4.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 03:11:43 +07:00
DenozordecandClaude Sonnet 5.5 c8351424f4 feat(design-preview): матрица доступа «пользователи × приложения» (solution-users-3)
Переключатели в ячейках, счётчик выданных доступов в заголовке колонки, заблокированные ячейки
администраторов, пометки «Не сохранено». Исправлена вёрстка рейки вкладок профиля.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 02:39:06 +07:00
DenozordecandClaude Sonnet 5.5 007ece4575 feat(design-preview): страница пользователя с рейкой вкладок (solution-users-2)
Права, вход и passkeys, сессии, активность и опасная зона. Каталог пользователей вынесен в общее
состояние макета, поэтому правки видны и в списке, и в профиле. Переходы: меню строки «Профиль»,
«Открыть профиль» в панели прав, хлебные крошки в шапке.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 02:35:57 +07:00
DenozordecandClaude Sonnet 5.5 f0a257220f feat(design-preview): права доступа — широкая боковая панель с двумя колонками
Слева приложения с переключателем и счётчиком выданных разделов, справа разделы с уровнем
«Нет / Чтение / Запись / Админ». «Сохранить права» активна только при изменении,
закрытие с правками спрашивает подтверждение.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 02:33:06 +07:00
DenozordecandClaude Sonnet 5.5 e02cfad0b1 feat(design-preview): пометки строк «Не сохранено» и «Будет удалено», массовое удаление с подтверждением
Строки с накопленными правками подсвечиваются и получают плашку (getRowStatus из DataGrid v9),
удаление применяется при «Сохранить», в каталоге пользователей добавлено «Удалить…» в панели
массовых действий.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 02:26:37 +07:00
135 changed files with 10135 additions and 3442 deletions

No files matched your search

+1
View File
@@ -42,6 +42,7 @@ package-lock.json
data data
*.exe *.exe
**/*.test.ts **/*.test.ts
**/test/**
**/*.spec.ts **/*.spec.ts
coverage coverage
.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: env:
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }} REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
IMAGE_TAG: latest IMAGE_TAG: latest
# provenance + SBOM как attestations образа (только при push в registry)
ATTEST: "true"
VERSION: ${{ steps.meta.outputs.version }} VERSION: ${{ steps.meta.outputs.version }}
SHORT_SHA: ${{ steps.meta.outputs.short_sha }} SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
SHA_FULL: ${{ github.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 [email protected] -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 [email protected] && 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 while IFS= read -r f || [ -n "${f:-}" ]; do
[ -z "${f:-}" ] && continue [ -z "${f:-}" ] && continue
case "$f" in case "$f" in
.gitea/workflows/*|scripts/*) .gitea/workflows/*|.gitea/actions/*|scripts/*)
full_pipeline=true full_pipeline=true
;; ;;
.cursor/*|.claude/*|.codegraph/*|.agents/*) .cursor/*|.claude/*|.codegraph/*|.agents/*)
@@ -160,38 +160,19 @@ jobs:
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - id: setup
with: uses: ./.gitea/actions/setup-pnpm
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 }}-
- name: pnpm install, lint, typecheck, build - name: pnpm install, lint, typecheck, build
env: env:
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }} PNPM_CACHE_HIT: ${{ steps.setup.outputs.cache-hit }}
run: | run: |
set -euxo pipefail set -euxo pipefail
sh scripts/ci/pnpm-ci.sh sh scripts/ci/pnpm-ci.sh
pnpm --filter web lint pnpm --filter web lint
pnpm exec turbo run typecheck --filter=web pnpm exec turbo run typecheck --filter=web
pnpm exec turbo run build --filter=web pnpm exec turbo run build --filter=web
pnpm --filter web test
node scripts/ci/web-budget.mjs apps/web/dist/assets
api: api:
needs: [changes] needs: [changes]
@@ -200,37 +181,16 @@ jobs:
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - id: setup
with: uses: ./.gitea/actions/setup-pnpm
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 }}-
- name: pnpm install, lint, test, typecheck, build - name: pnpm install, lint, test, typecheck, build
env: env:
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }} PNPM_CACHE_HIT: ${{ steps.setup.outputs.cache-hit }}
run: | run: |
set -euxo pipefail set -euxo pipefail
sh scripts/ci/pnpm-ci.sh sh scripts/ci/pnpm-ci.sh
pnpm exec turbo run lint --filter=@authportal/api --filter=@authportal/db --filter=@authportal/shared 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 typecheck --filter=@authportal/api
pnpm exec turbo run build --filter=@authportal/api pnpm exec turbo run build --filter=@authportal/api
@@ -242,33 +202,13 @@ jobs:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - id: setup
with: uses: ./.gitea/actions/setup-pnpm
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 }}-
- name: Lint commit messages - name: Lint commit messages
env: env:
BASE_SHA: ${{ inputs.base_sha }} BASE_SHA: ${{ inputs.base_sha }}
HEAD_SHA: ${{ inputs.head_sha }} HEAD_SHA: ${{ inputs.head_sha }}
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }} PNPM_CACHE_HIT: ${{ steps.setup.outputs.cache-hit }}
run: | run: |
set -euxo pipefail set -euxo pipefail
sh scripts/ci/pnpm-ci.sh sh scripts/ci/pnpm-ci.sh
@@ -276,7 +216,9 @@ jobs:
docker-check: docker-check:
needs: [changes] 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 runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 20
steps: steps:
@@ -310,3 +252,16 @@ jobs:
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
export CACHE_REF_NODE="git.shx.one/${owner_lc}/authportal-buildcache:node-buildcache" 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 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 .release-version
CHANGELOG.md CHANGELOG.md
.claude/settings.local.json .claude/settings.local.json
coverage
+11 -9
View File
@@ -5,38 +5,40 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "tsx watch src/server.ts", "dev": "tsx watch src/server.ts",
"build": "tsup src/server.ts --format esm --dts", "build": "tsup",
"lint": "eslint .", "lint": "eslint .",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"start": "node dist/server.js", "start": "node dist/server.js",
"test": "vitest run --passWithNoTests" "test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"@authportal/db": "workspace:*", "@authportal/db": "workspace:*",
"@authportal/shared": "workspace:*", "@authportal/shared": "workspace:*",
"@fastify/compress": "^8.3.1",
"@fastify/cors": "^11.0.1", "@fastify/cors": "^11.0.1",
"@fastify/formbody": "^9.0.0", "@fastify/formbody": "^9.0.0",
"@fastify/helmet": "^13.0.1", "@fastify/helmet": "^13.0.1",
"@fastify/jwt": "^9.1.0", "@fastify/jwt": "^9.1.0",
"@fastify/rate-limit": "^10.3.0", "@fastify/rate-limit": "^10.3.0",
"@fastify/sensible": "^6.0.3",
"@fastify/static": "^8.2.0", "@fastify/static": "^8.2.0",
"@node-rs/argon2": "^2.0.2", "@node-rs/argon2": "^2.0.2",
"@simplewebauthn/server": "^13.3.2", "@simplewebauthn/server": "^13.3.2",
"better-sqlite3": "^11.10.0",
"fastify": "^5.4.0", "fastify": "^5.4.0",
"fastify-plugin": "^5.0.1",
"jose": "^6.2.8", "jose": "^6.2.8",
"zod": "catalog:" "zod": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "catalog:",
"@types/node": "^22.15.32", "@types/node": "catalog:",
"eslint": "^10.7.0", "@vitest/coverage-v8": "4.1.10",
"globals": "^17.7.0", "eslint": "catalog:",
"globals": "catalog:",
"tsup": "^8.5.0", "tsup": "^8.5.0",
"tsx": "^4.20.3", "tsx": "^4.20.3",
"typescript": "catalog:", "typescript": "catalog:",
"typescript-eslint": "^8.64.0", "typescript-eslint": "catalog:",
"vitest": "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 Fastify from 'fastify'
import compress from '@fastify/compress'
import cors from '@fastify/cors' import cors from '@fastify/cors'
import helmet from '@fastify/helmet' import helmet from '@fastify/helmet'
import rateLimit from '@fastify/rate-limit' import rateLimit from '@fastify/rate-limit'
import sensible from '@fastify/sensible'
import formbody from '@fastify/formbody' import formbody from '@fastify/formbody'
import fjwt from '@fastify/jwt' import fjwt from '@fastify/jwt'
import fastifyStatic from '@fastify/static' import fastifyStatic from '@fastify/static'
@@ -14,8 +14,12 @@ import {
allPermissionKeys, allPermissionKeys,
} from '@authportal/shared' } from '@authportal/shared'
import { import {
closeDb,
createDb, createDb,
getAppSwitcherConfig,
getUserApps, getUserApps,
hasAnyUser,
listAdmins,
healthCheck, healthCheck,
migrateSchema, migrateSchema,
setUserAccess, setUserAccess,
@@ -23,9 +27,13 @@ import {
type AppDb, type AppDb,
type Sqlite, type Sqlite,
} from '@authportal/db' } from '@authportal/db'
import { hash } from '@node-rs/argon2' import { hashPassword, setPasswordConcurrency } from './lib/password.js'
import { randomUUID } from 'node:crypto' 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 { authRoutes } from './routes/auth.js'
import { adminRoutes } from './routes/admin.js' import { adminRoutes } from './routes/admin.js'
import { auditAdminRoutes } from './routes/audit.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 { oidcRoutes } from './routes/oidc.js'
import { webauthnRoutes } from './routes/webauthn.js' import { webauthnRoutes } from './routes/webauthn.js'
import { startAuditRetentionJob } from './services/audit-retention.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' { declare module 'fastify' {
interface FastifyInstance { interface FastifyInstance {
@@ -43,11 +66,11 @@ declare module 'fastify' {
} }
} }
async function ensureBootstrapAdmin(app: FastifyInstance): Promise<void> { export async function ensureBootstrapAdmin(app: FastifyInstance): Promise<void> {
const existing = app.db.select().from(users).all() if (!hasAnyUser(app.db)) {
if (existing.length === 0) { assertBootstrapPassword(app.config)
const now = new Date().toISOString() const now = new Date().toISOString()
const passwordHash = await hash(app.config.adminPassword) const passwordHash = await hashPassword(app.config.adminPassword)
const id = randomUUID() const id = randomUUID()
app.db app.db
.insert(users) .insert(users)
@@ -69,8 +92,7 @@ async function ensureBootstrapAdmin(app: FastifyInstance): Promise<void> {
} }
// Ensure existing admins have app tiles if access was never set // Ensure existing admins have app tiles if access was never set
for (const user of existing) { for (const user of listAdmins(app.db)) {
if (!user.isAdmin) continue
const apps = getUserApps(app.db, user.id) const apps = getUserApps(app.db, user.id)
if (apps.length === 0) { if (apps.length === 0) {
setUserAccess(app.db, user.id, [...APP_IDS], allPermissionKeys()) setUserAccess(app.db, user.id, [...APP_IDS], allPermissionKeys())
@@ -91,32 +113,112 @@ export async function buildApp(opts: {
mkdirSync(dirname(resolve(path)), { recursive: true }) mkdirSync(dirname(resolve(path)), { recursive: true })
} }
setPasswordConcurrency(config.argon2Concurrency)
const { db, sqlite } = createDb(dbUrl) const { db, sqlite } = createDb(dbUrl)
migrateSchema(sqlite) migrateSchema(sqlite)
const app = Fastify({ const app = Fastify({
logger: { level: config.logLevel }, logger: {
trustProxy: true, 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('config', config)
app.decorate('db', db) app.decorate('db', db)
app.decorate('sqlite', sqlite) app.decorate('sqlite', sqlite)
app.addHook('onClose', async () => {
await app.register(sensible) closeDb(sqlite)
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.get('/ready', async () => { app.setErrorHandler((err: FastifyError | Error, req, reply) => {
healthCheck(sqlite) 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 } return { ok: true }
}) })
@@ -134,28 +236,55 @@ export async function buildApp(opts: {
if (process.env.NODE_ENV !== 'test') { if (process.env.NODE_ENV !== 'test') {
const stopRetention = startAuditRetentionJob(app) const stopRetention = startAuditRetentionJob(app)
const stopHousekeeping = startHousekeepingJob(app)
const stopBackup = startBackupJob(app)
app.addHook('onClose', async () => { app.addHook('onClose', async () => {
stopRetention() 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)) { if (config.staticDir && existsSync(config.staticDir)) {
await app.register(fastifyStatic, { await app.register(fastifyStatic, {
root: resolve(config.staticDir), root: resolve(config.staticDir),
wildcard: false, wildcard: false,
// Traefik перед приложением не сжимает; .gz/.br для статики готовит // .gz/.br для статики готовит Dockerfile на этапе сборки, здесь только отдаём.
// Dockerfile на этапе сборки (find … gzip/brotli), здесь только отдаём.
preCompressed: true, preCompressed: true,
}) // Cache-Control выставляем сами (иначе плагин перезаписывает заголовок).
app.setNotFoundHandler((req, reply) => { cacheControl: false,
if (req.method === 'GET' && !req.url.startsWith('/api')) { setHeaders(res, filePath) {
return reply.sendFile('index.html') if (HASHED_ASSET_RE.test(filePath)) {
} // Vite кладёт хеш в имя файла → кэш на год без ревалидации.
return reply res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
.status(404) } else if (INDEX_HTML_RE.test(filePath)) {
.send({ error: { code: 'NOT_FOUND', message: 'Not found' } }) // Точка входа должна ревалидироваться, иначе клиент застрянет на старых чанках.
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 return app
} }
+77 -1
View File
@@ -5,6 +5,17 @@ const boolFromEnv = (v: string | undefined, fallback: boolean) => {
return v === '1' || v.toLowerCase() === 'true' 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({ export const configSchema = z.object({
databaseUrl: z.string().default('sqlite:data/app.db'), databaseUrl: z.string().default('sqlite:data/app.db'),
jwtSecret: z.string().min(8), jwtSecret: z.string().min(8),
@@ -22,6 +33,26 @@ export const configSchema = z.object({
serverPort: z.coerce.number().int().positive().default(8080), serverPort: z.coerce.number().int().positive().default(8080),
staticDir: z.string().optional(), staticDir: z.string().optional(),
logLevel: z.string().default('info'), 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(), isProd: z.boolean(),
auditIngestSecret: z.string().min(8).optional(), auditIngestSecret: z.string().min(8).optional(),
webauthnRpID: z.string().min(1), webauthnRpID: z.string().min(1),
@@ -31,6 +62,39 @@ export const configSchema = z.object({
export type AppConfig = z.infer<typeof configSchema> 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 { export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
const isProd = env.NODE_ENV === 'production' const isProd = env.NODE_ENV === 'production'
const jwtSecret = env.JWT_SECRET ?? (isProd ? '' : 'dev-secret-change-me') 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 issuer = env.ISSUER ?? 'https://auth.shnt.top'
const { rpID, origins } = webauthnFromIssuer(issuer, env, isProd) const { rpID, origins } = webauthnFromIssuer(issuer, env, isProd)
return configSchema.parse({ const config = configSchema.parse({
databaseUrl: env.DATABASE_URL ?? 'sqlite:data/app.db', databaseUrl: env.DATABASE_URL ?? 'sqlite:data/app.db',
jwtSecret, jwtSecret,
jwtTtlHours: env.JWT_TTL_HOURS ?? 1, jwtTtlHours: env.JWT_TTL_HOURS ?? 1,
@@ -56,6 +120,16 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
serverPort: env.SERVER_PORT ?? 8080, serverPort: env.SERVER_PORT ?? 8080,
staticDir: env.STATIC_DIR || undefined, staticDir: env.STATIC_DIR || undefined,
logLevel: env.LOG_LEVEL ?? 'info', 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: isProd:
boolFromEnv(env.NODE_ENV === 'production' ? 'true' : undefined, false) || boolFromEnv(env.NODE_ENV === 'production' ? 'true' : undefined, false) ||
isProd, isProd,
@@ -64,6 +138,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
webauthnRpName: env.WEBAUTHN_RP_NAME || 'Auth Portal', webauthnRpName: env.WEBAUTHN_RP_NAME || 'Auth Portal',
webauthnOrigins: origins, webauthnOrigins: origins,
}) })
assertProductionSecrets(config)
return config
} }
function webauthnFromIssuer( function webauthnFromIssuer(
+1 -4
View File
@@ -5,10 +5,7 @@ import {
} from '@authportal/db' } from '@authportal/db'
export function clientIp(request: FastifyRequest): string | null { export function clientIp(request: FastifyRequest): string | null {
const forwarded = request.headers['x-forwarded-for'] // request.ip учитывает X-Forwarded-For только от доверенных прокси (TRUST_PROXY).
if (typeof forwarded === 'string' && forwarded.trim()) {
return forwarded.split(',')[0]?.trim() ?? null
}
return request.ip ?? null 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, type UserRow,
} from '@authportal/db' } from '@authportal/db'
import { toMe } from '../plugins/auth-guards.js' import { toMe } from '../plugins/auth-guards.js'
import { getUserProfile } from './user-profile.js'
/** Mint access JWT + Me from current DB state (login / reissue). */ /** Mint access JWT + Me from current DB state (login / reissue). */
export function issueAccessToken( export function issueAccessToken(
app: FastifyInstance, app: FastifyInstance,
user: UserRow, user: UserRow,
): LoginResponse { ): LoginResponse {
const apps = getUserApps(app.db, user.id) // Профиль из кэша (инвалидируется записями в users/доступ); user — свежая строка вызывающего.
let permissions = normalizePermissionKeys(getUserPermissions(app.db, user.id)) const profile = getUserProfile(app.db, user.id)
if (user.isAdmin) { const apps = profile?.apps ?? getUserApps(app.db, user.id)
permissions = allPermissionKeys() const permissions = user.isAdmin
} ? allPermissionKeys()
: (profile?.permissions ??
normalizePermissionKeys(getUserPermissions(app.db, user.id)))
const switcher = getAppSwitcherConfig(app.db) const switcher = getAppSwitcherConfig(app.db)
const tenants = tenantsClaimForUser(switcher, apps) const tenants = tenantsClaimForUser(switcher, apps)
const me = toMe(user, apps, permissions) const me = toMe(user, apps, permissions)
+22
View File
@@ -113,10 +113,16 @@ export async function ensureOidcSigningKey(
return cached return cached
} }
/** Ключ подписи загружен — приложение готово выдавать токены. */
export function isOidcKeyReady(): boolean {
return cached !== null
}
/** Reset cache (tests). */ /** Reset cache (tests). */
export function resetOidcKeyCache(): void { export function resetOidcKeyCache(): void {
cached = null cached = null
importedPublicKeys.clear() importedPublicKeys.clear()
jwksCache = new WeakMap()
} }
export function buildOidcDiscovery(config: AppConfig) { export function buildOidcDiscovery(config: AppConfig) {
@@ -157,6 +163,17 @@ export async function buildJwks(
app: FastifyInstance, app: FastifyInstance,
): Promise<{ keys: JWK[] }> { ): Promise<{ keys: JWK[] }> {
await ensureOidcSigningKey(app) 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) const keys = listOidcSigningKeys(app.db)
.map((row): JWK | null => { .map((row): JWK | null => {
try { try {
@@ -174,6 +191,11 @@ export async function buildJwks(
return { keys } return { keys }
} }
/** Принудительный сброс JWKS (после ручных правок ключей в БД). */
export function invalidateJwks(app: FastifyInstance): void {
jwksCache.delete(app.db)
}
export async function signOidcJwt( export async function signOidcJwt(
app: FastifyInstance, app: FastifyInstance,
claims: Record<string, unknown>, 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 type { FastifyReply, FastifyRequest } from 'fastify'
import { import type { UserRow } from '@authportal/db'
getUserApps,
getUserById,
getUserPermissions,
type UserRow,
} from '@authportal/db'
import type { AppId, MeResponse } from '@authportal/shared' 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 = { export type AuthUser = {
id: string id: string
@@ -67,24 +63,15 @@ export function toMe(user: UserRow, apps: string[], permissions: string[]): MeRe
} }
} }
export function loadAuthUser( export function authUserFromProfile(profile: UserProfile): AuthUser {
request: FastifyRequest, const { row } = profile
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()
}
return { return {
id: user.id, id: row.id,
email: user.email, email: row.email,
name: user.name, name: row.name,
isAdmin: user.isAdmin, isAdmin: row.isAdmin,
apps: asAppIds(apps), apps: asAppIds(profile.apps),
permissions, permissions: profile.permissions,
} }
} }
@@ -101,14 +88,14 @@ export async function requireAuth(
} }
const sub = request.user.sub const sub = request.user.sub
const row = getUserById(request.server.db, sub) const profile = getUserProfile(request.server.db, sub)
if (!row || row.disabled) { if (!profile || profile.row.disabled) {
return reply.status(401).send({ return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' }, error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
}) })
} }
request.authUser = loadAuthUser(request, row) request.authUser = authUserFromProfile(profile)
} }
export async function requireAdmin( export async function requireAdmin(
+16 -11
View File
@@ -1,5 +1,6 @@
import type { FastifyInstance } from 'fastify' 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 { import {
countWebauthnCredentials, countWebauthnCredentials,
countWebauthnCredentialsBatch, countWebauthnCredentialsBatch,
@@ -27,6 +28,7 @@ import {
setUserAccess, setUserAccess,
updateOidcClient, updateOidcClient,
updateUser, updateUser,
withTransaction,
} from '@authportal/db' } from '@authportal/db'
import { import {
APP_IDS, APP_IDS,
@@ -168,14 +170,17 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
}) })
} }
const passwordHash = await hash(data.password) const passwordHash = await hashPassword(data.password)
const user = createUser(app.db, { const user = withTransaction(app.db, () => {
email: data.email, const created = createUser(app.db, {
name: data.name, email: data.email,
passwordHash, name: data.name,
isAdmin: data.is_admin, 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)!) const mapped = mapUser(app.db, getUserById(app.db, user.id)!)
safeAudit(app, { safeAudit(app, {
action: 'user.create', action: 'user.create',
@@ -218,7 +223,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
} }
const passwordHash = parsed.data.password const passwordHash = parsed.data.password
? await hash(parsed.data.password) ? await hashPassword(parsed.data.password)
: undefined : undefined
const updated = updateUser(app.db, request.params.id, { const updated = updateUser(app.db, request.params.id, {
email: parsed.data.email, email: parsed.data.email,
@@ -463,7 +468,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
}) })
} }
const secret = generateOidcClientSecret() const secret = generateOidcClientSecret()
const secretHash = await hash(secret) const secretHash = hashClientSecret(secret)
const row = createOidcClient(app.db, { const row = createOidcClient(app.db, {
name: parsed.data.name, name: parsed.data.name,
clientSecretHash: secretHash, clientSecretHash: secretHash,
@@ -545,7 +550,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
}) })
} }
const secret = generateOidcClientSecret() const secret = generateOidcClientSecret()
const secretHash = await hash(secret) const secretHash = hashClientSecret(secret)
const row = updateOidcClient(app.db, request.params.id, { const row = updateOidcClient(app.db, request.params.id, {
clientSecretHash: secretHash, clientSecretHash: secretHash,
})! })!
+24 -28
View File
@@ -1,5 +1,4 @@
import type { FastifyInstance } from 'fastify' import type { FastifyInstance } from 'fastify'
import { verify } from '@node-rs/argon2'
import { import {
PERMISSION_CATALOG, PERMISSION_CATALOG,
appsMetaFromSwitcher, appsMetaFromSwitcher,
@@ -11,9 +10,11 @@ import {
getAppSwitcherConfig, getAppSwitcherConfig,
getUserByEmail, getUserByEmail,
getUserById, getUserById,
listUsers, searchDirectoryUsers,
revokeRefreshSession, revokeRefreshSession,
} from '@authportal/db' } from '@authportal/db'
import { verifyDummyPassword, verifyPassword } from '../lib/password.js'
import { parseCookie } from '../lib/cookies.js'
import { requireAuth } from '../plugins/auth-guards.js' import { requireAuth } from '../plugins/auth-guards.js'
import { issueAccessToken } from '../lib/issue-access-token.js' import { issueAccessToken } from '../lib/issue-access-token.js'
import { completeLogin, REFRESH_COOKIE } from '../lib/complete-login.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> { export async function authRoutes(app: FastifyInstance): Promise<void> {
/** Public — SPA reads allowlist at runtime (Docker-friendly). */ /** Public — SPA reads allowlist at runtime (Docker-friendly). */
app.get('/api/v1/auth/config', async () => ({ app.get('/api/v1/auth/config', async (_request, reply) => {
return_to_allowlist: app.config.returnToAllowlist, reply.header('Cache-Control', 'public, max-age=60')
issuer: app.config.issuer, return {
webauthn: true, return_to_allowlist: app.config.returnToAllowlist,
})) issuer: app.config.issuer,
webauthn: true,
}
})
app.post('/api/v1/auth/login', { app.post('/api/v1/auth/login', {
config: { rateLimit: { max: 20, timeWindow: '1 minute' } }, config: { rateLimit: { max: 20, timeWindow: '1 minute' } },
@@ -44,6 +48,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
const targetApp = targetAppFromReturnTo(returnTo) const targetApp = targetAppFromReturnTo(returnTo)
const user = getUserByEmail(app.db, email) const user = getUserByEmail(app.db, email)
if (!user || user.disabled) { if (!user || user.disabled) {
await verifyDummyPassword(password)
safeAudit(app, { safeAudit(app, {
action: 'auth.login_failed', action: 'auth.login_failed',
severity: 'warning', 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) { if (!ok) {
safeAudit(app, { safeAudit(app, {
action: 'auth.login_failed', 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) => { app.post(
const cookie = request.headers.cookie ?? '' '/api/v1/auth/logout',
const match = cookie.match(new RegExp(`${REFRESH_COOKIE}=([^;]+)`)) { config: { rateLimit: { max: 60, timeWindow: '1 minute' } } },
if (match?.[1]) { async (request, reply) => {
revokeRefreshSession(app.db, match[1]) const refresh = parseCookie(request.headers.cookie, REFRESH_COOKIE)
if (refresh) {
revokeRefreshSession(app.db, refresh)
} }
reply.header( reply.header(
'Set-Cookie', 'Set-Cookie',
@@ -205,9 +212,11 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
}) })
return { ok: true } return { ok: true }
}) },
)
/** Public — apps chrome (CFDM/VPS) fetch switcher URLs without portal JWT. */ /** Public — apps chrome (CFDM/VPS) fetch switcher URLs without portal JWT. */
// Без Cache-Control: после сохранения в админке SPA должна сразу видеть новый конфиг.
app.get('/api/v1/app-switcher', async () => app.get('/api/v1/app-switcher', async () =>
publicAppSwitcherConfig(getAppSwitcherConfig(app.db)), publicAppSwitcherConfig(getAppSwitcherConfig(app.db)),
) )
@@ -237,20 +246,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
) )
.trim() .trim()
.toLowerCase() .toLowerCase()
const all = listUsers(app.db).filter((u) => !u.disabled) return searchDirectoryUsers(app.db, q, 50)
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,
}))
}, },
) )
+21 -25
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from 'fastify' import type { FastifyInstance } from 'fastify'
import { appendAudit } from '@authportal/db' import { appendAuditBatch } from '@authportal/db'
import { import {
ingestAuditRequestSchema, ingestAuditRequestSchema,
type AuditSourceApp, type AuditSourceApp,
@@ -45,30 +45,26 @@ export async function auditIngestRoutes(app: FastifyInstance): Promise<void> {
}) })
} }
let accepted = 0 const { accepted, duplicates, failed } = appendAuditBatch(
let duplicates = 0 app.db,
for (const event of parsed.data.events) { parsed.data.events.map((event) => ({
try { eventId: event.event_id,
const inserted = appendAudit(app.db, { sourceApp: event.source_app as AuditSourceApp,
eventId: event.event_id, action: event.action,
sourceApp: event.source_app as AuditSourceApp, severity: event.severity,
action: event.action, actorUserId: event.actor_user_id,
severity: event.severity, actorEmail: event.actor_email,
actorUserId: event.actor_user_id, actorName: event.actor_name,
actorEmail: event.actor_email, targetType: event.target_type,
actorName: event.actor_name, targetId: event.target_id,
targetType: event.target_type, summary: event.summary,
targetId: event.target_id, details: event.details,
summary: event.summary, ip: event.ip,
details: event.details, createdAt: event.created_at,
ip: event.ip, })),
createdAt: event.created_at, )
}) if (failed > 0) {
if (inserted) accepted += 1 app.log.warn({ failed }, 'ingest append failed for some events')
else duplicates += 1
} catch (err) {
app.log.warn({ err, event_id: event.event_id }, 'ingest append failed')
}
} }
return { accepted, duplicates } return { accepted, duplicates }
+23 -18
View File
@@ -1,7 +1,7 @@
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto' import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
import { verify } from '@node-rs/argon2'
import { import {
updateOidcClient,
createOidcAuthCode, createOidcAuthCode,
consumeOidcAuthCode, consumeOidcAuthCode,
getOidcClientByClientId, getOidcClientByClientId,
@@ -19,6 +19,8 @@ import {
} from '@authportal/shared' } from '@authportal/shared'
import { oidcIssuerFromConfig } from '../config.js' import { oidcIssuerFromConfig } from '../config.js'
import { clientIp, safeAudit } from '../lib/audit.js' import { clientIp, safeAudit } from '../lib/audit.js'
import { checkClientSecret, hashClientSecret } from '../lib/client-secret.js'
import { parseCookie } from '../lib/cookies.js'
import { import {
buildJwks, buildJwks,
buildOidcDiscovery, buildOidcDiscovery,
@@ -33,15 +35,6 @@ const CODE_TTL_MS = 5 * 60 * 1000
const ACCESS_TTL_SEC = 3600 const ACCESS_TTL_SEC = 3600
const ID_TOKEN_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( function parseBasicAuth(
header: string | undefined, header: string | undefined,
): { clientId: string; clientSecret: string } | null { ): { clientId: string; clientSecret: string } | null {
@@ -60,14 +53,22 @@ function parseBasicAuth(
} }
async function authenticateClient( async function authenticateClient(
app: FastifyInstance,
client: OidcClientRow, client: OidcClientRow,
secret: string, secret: string,
): Promise<boolean> { ): Promise<boolean> {
try { const res = await checkClientSecret(client.clientSecretHash, secret)
return await verify(client.clientSecretHash, secret) if (res.ok && res.needsRehash) {
} catch { // Миграция argon2 → SHA-256 при первом успешном обмене.
return false try {
updateOidcClient(app.db, client.id, {
clientSecretHash: hashClientSecret(secret),
})
} catch (err) {
app.log.warn({ err }, 'client secret rehash failed')
}
} }
return res.ok
} }
function redirectError( function redirectError(
@@ -94,7 +95,7 @@ function resolveSessionUser(
app: FastifyInstance, app: FastifyInstance,
request: FastifyRequest, request: FastifyRequest,
): UserRow | undefined { ): UserRow | undefined {
const raw = cookieValue(request.headers.cookie, REFRESH_COOKIE) const raw = parseCookie(request.headers.cookie, REFRESH_COOKIE)
if (!raw) return undefined if (!raw) return undefined
return getUserByRefreshToken(app.db, raw) return getUserByRefreshToken(app.db, raw)
} }
@@ -171,7 +172,9 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
return buildJwks(app) 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 q = request.query as Record<string, string | undefined>
const clientId = q.client_id const clientId = q.client_id
const redirectUri = q.redirect_uri const redirectUri = q.redirect_uri
@@ -339,7 +342,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
error_description: 'Unknown or disabled client', error_description: 'Unknown or disabled client',
}) })
} }
const secretOk = await authenticateClient(client, clientSecret) const secretOk = await authenticateClient(app, client, clientSecret)
if (!secretOk) { if (!secretOk) {
return reply.status(401).send({ return reply.status(401).send({
error: 'invalid_client', 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 const auth = request.headers.authorization
if (!auth?.startsWith('Bearer ')) { if (!auth?.startsWith('Bearer ')) {
return reply.status(401).send({ return reply.status(401).send({
+21
View File
@@ -2,6 +2,7 @@ import { readFileSync, existsSync } from 'node:fs'
import { resolve } from 'node:path' import { resolve } from 'node:path'
import { buildApp } from './app.js' import { buildApp } from './app.js'
import { loadConfig } from './config.js' import { loadConfig } from './config.js'
import { registerShutdown } from './lib/shutdown.js'
for (const path of [ for (const path of [
resolve(import.meta.dirname, '../../../.env'), resolve(import.meta.dirname, '../../../.env'),
@@ -29,8 +30,28 @@ for (const path of [
} }
const config = loadConfig() 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 }) const app = await buildApp({ config })
registerShutdown(app, { timeoutMs: config.shutdownTimeoutMs })
try { try {
await app.listen({ port: config.serverPort, host: '0.0.0.0' }) await app.listen({ port: config.serverPort, host: '0.0.0.0' })
app.log.info(`auth-portal listening on ${config.serverPort}`) 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 = '[email protected]', password = 'adminpass') {
const res = await a.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { email, password },
})
return res
}
describe('auth', () => {
it('login: неверный пароль 401, отключённый пользователь 401', async () => {
app = await buildTestApp()
const token = await adminToken(app)
expect((await login(app, '[email protected]', 'wrong')).statusCode).toBe(401)
expect((await login(app, '[email protected]', 'whatever')).statusCode).toBe(401)
const created = await app.inject({
method: 'POST',
url: '/api/v1/admin/users',
headers: auth(token),
payload: {
email: '[email protected]',
name: 'Off',
password: 'secret12',
is_admin: false,
apps: ['cfdm'],
permissions: [],
},
})
const id = (created.json() as { id: string }).id
await app.inject({
method: 'PATCH',
url: `/api/v1/admin/users/${id}`,
headers: auth(token),
payload: { disabled: true },
})
expect((await login(app, '[email protected]', 'secret12')).statusCode).toBe(401)
})
it('me / reissue / catalog / logout по cookie', async () => {
app = await buildTestApp()
const res = await login(app)
const token = (res.json() as { access_token: string }).access_token
const refresh = res.cookies.find((c) => c.name === 'refresh_token')!.value
const me = await app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth(token) })
expect(me.statusCode).toBe(200)
expect((me.json() as { email: string }).email).toBe('[email protected]')
const reissue = await app.inject({
method: 'POST',
url: '/api/v1/auth/reissue',
headers: auth(token),
})
expect(reissue.statusCode).toBe(200)
expect((reissue.json() as { access_token: string }).access_token).toBeTruthy()
const catalog = await app.inject({ method: 'GET', url: '/api/v1/catalog', headers: auth(token) })
expect(catalog.statusCode).toBe(200)
expect((catalog.json() as { apps: unknown[] }).apps.length).toBeGreaterThan(0)
const noAuth = await app.inject({ method: 'GET', url: '/api/v1/auth/me' })
expect(noAuth.statusCode).toBe(401)
const logout = await app.inject({
method: 'POST',
url: '/api/v1/auth/logout',
headers: { ...auth(token), cookie: `refresh_token=${refresh}` },
})
expect(logout.statusCode).toBe(200)
const revoked = app.sqlite
.prepare('SELECT revoked_at FROM refresh_sessions')
.get() as { revoked_at: string | null }
expect(revoked.revoked_at).toBeTruthy()
})
})
describe('admin users', () => {
it('создание: конфликт email 409, валидация 400; patch и удаление', async () => {
app = await buildTestApp()
const token = await adminToken(app)
const payload = {
email: '[email protected]',
name: 'Dup',
password: 'secret12',
is_admin: false,
apps: ['cfdm'],
permissions: [],
}
const first = await app.inject({ method: 'POST', url: '/api/v1/admin/users', headers: auth(token), payload })
expect(first.statusCode).toBe(201)
const id = (first.json() as { id: string }).id
const dup = await app.inject({ method: 'POST', url: '/api/v1/admin/users', headers: auth(token), payload })
expect(dup.statusCode).toBe(409)
const bad = await app.inject({
method: 'POST',
url: '/api/v1/admin/users',
headers: auth(token),
payload: { email: 'nope' },
})
expect(bad.statusCode).toBe(400)
const patched = await app.inject({
method: 'PATCH',
url: `/api/v1/admin/users/${id}`,
headers: auth(token),
payload: { name: 'Renamed', password: 'newsecret12' },
})
expect(patched.statusCode).toBe(200)
expect((patched.json() as { name: string }).name).toBe('Renamed')
expect((await login(app, '[email protected]', 'newsecret12')).statusCode).toBe(200)
expect((await login(app, '[email protected]', 'secret12')).statusCode).toBe(401)
const del = await app.inject({ method: 'DELETE', url: `/api/v1/admin/users/${id}`, headers: auth(token) })
expect(del.statusCode).toBe(204)
const gone = await app.inject({ method: 'GET', url: `/api/v1/admin/users/${id}`, headers: auth(token) })
expect(gone.statusCode).toBe(404)
})
it('нельзя удалить себя; обычный пользователь не админ (403)', async () => {
app = await buildTestApp()
const token = await adminToken(app)
const me = await app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth(token) })
const myId = (me.json() as { id: string }).id
const self = await app.inject({ method: 'DELETE', url: `/api/v1/admin/users/${myId}`, headers: auth(token) })
expect(self.statusCode).toBe(400)
await app.inject({
method: 'POST',
url: '/api/v1/admin/users',
headers: auth(token),
payload: {
email: '[email protected]',
name: 'Plain',
password: 'secret12',
is_admin: false,
apps: ['cfdm'],
permissions: [],
},
})
const userTok = ((await login(app, '[email protected]', 'secret12')).json() as { access_token: string }).access_token
const forbidden = await app.inject({ method: 'GET', url: '/api/v1/admin/users', headers: auth(userTok) })
expect(forbidden.statusCode).toBe(403)
})
})
describe('OIDC клиенты и обмен кода', () => {
async function setup(a: App) {
const token = await adminToken(a)
const created = await a.inject({
method: 'POST',
url: '/api/v1/admin/oidc/clients',
headers: auth(token),
payload: {
name: 'RP',
redirect_uris: ['https://rp.test.local/cb'],
scopes: ['openid', 'profile', 'email', 'groups'],
enabled: true,
},
})
expect(created.statusCode).toBe(200)
const client = created.json() as { id: string; client_id: string; client_secret: string }
const loginRes = await login(a)
const refresh = loginRes.cookies.find((c) => c.name === 'refresh_token')!.value
return { token, client, refresh }
}
async function authorizeCode(
a: App,
refresh: string,
clientId: string,
extra: Record<string, string> = {},
): Promise<string> {
const res = await a.inject({
method: 'GET',
url: '/oauth/authorize',
cookies: { refresh_token: refresh },
query: {
client_id: clientId,
redirect_uri: 'https://rp.test.local/cb',
response_type: 'code',
scope: 'openid profile',
...extra,
},
})
expect(res.statusCode).toBe(302)
return new URL(res.headers.location as string).searchParams.get('code')!
}
const exchange = (a: App, body: Record<string, string>) =>
a.inject({
method: 'POST',
url: '/oauth/token',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
payload: new URLSearchParams({
grant_type: 'authorization_code',
redirect_uri: 'https://rp.test.local/cb',
...body,
}).toString(),
})
it('секрет нового клиента хранится как sha256, обмен работает, replay кода отклоняется', async () => {
app = await buildTestApp()
const { client, refresh } = await setup(app)
const stored = app.sqlite
.prepare('SELECT client_secret_hash h FROM oidc_clients')
.get() as { h: string }
expect(stored.h.startsWith('sha256:')).toBe(true)
const code = await authorizeCode(app, refresh, client.client_id)
const ok = await exchange(app, {
client_id: client.client_id,
client_secret: client.client_secret,
code,
})
expect(ok.statusCode).toBe(200)
expect((ok.json() as { id_token: string }).id_token).toBeTruthy()
const replay = await exchange(app, {
client_id: client.client_id,
client_secret: client.client_secret,
code,
})
expect(replay.statusCode).toBe(400)
expect((replay.json() as { error: string }).error).toBe('invalid_grant')
})
it('PKCE: S256 принимается, неверный verifier и отсутствие verifier отклоняются', async () => {
app = await buildTestApp()
const { client, refresh } = await setup(app)
const verifier = 'v'.repeat(50)
const challenge = createHash('sha256').update(verifier).digest('base64url')
const pkce = { code_challenge: challenge, code_challenge_method: 'S256' }
const creds = { client_id: client.client_id, client_secret: client.client_secret }
const good = await exchange(app, {
...creds,
code: await authorizeCode(app, refresh, client.client_id, pkce),
code_verifier: verifier,
})
expect(good.statusCode).toBe(200)
const wrong = await exchange(app, {
...creds,
code: await authorizeCode(app, refresh, client.client_id, pkce),
code_verifier: 'x'.repeat(50),
})
expect(wrong.statusCode).toBe(400)
const missing = await exchange(app, {
...creds,
code: await authorizeCode(app, refresh, client.client_id, pkce),
})
expect(missing.statusCode).toBe(400)
const plain = await exchange(app, {
...creds,
code: await authorizeCode(app, refresh, client.client_id, {
code_challenge: 'plain-challenge',
code_challenge_method: 'plain',
}),
code_verifier: 'plain-challenge',
})
expect(plain.statusCode).toBe(200)
})
it('неверный секрет 401; чужой redirect_uri и scope отклоняются; userinfo по access_token', async () => {
app = await buildTestApp()
const { client, refresh } = await setup(app)
const code = await authorizeCode(app, refresh, client.client_id)
const badSecret = await exchange(app, {
client_id: client.client_id,
client_secret: 'nope',
code,
})
expect(badSecret.statusCode).toBe(401)
const badRedirect = await app.inject({
method: 'GET',
url: '/oauth/authorize',
cookies: { refresh_token: refresh },
query: {
client_id: client.client_id,
redirect_uri: 'https://evil.test/cb',
response_type: 'code',
scope: 'openid',
},
})
expect(badRedirect.statusCode).toBe(400)
const badScope = await app.inject({
method: 'GET',
url: '/oauth/authorize',
cookies: { refresh_token: refresh },
query: {
client_id: client.client_id,
redirect_uri: 'https://rp.test.local/cb',
response_type: 'code',
scope: 'openid admin',
},
})
expect(badScope.statusCode).toBe(302)
expect(badScope.headers.location).toContain('error=invalid_scope')
const ok = await exchange(app, {
client_id: client.client_id,
client_secret: client.client_secret,
code,
})
const accessToken = (ok.json() as { access_token: string }).access_token
const info = await app.inject({
method: 'GET',
url: '/oauth/userinfo',
headers: auth(accessToken),
})
expect(info.statusCode).toBe(200)
expect((info.json() as { email: string }).email).toBe('[email protected]')
const noToken = await app.inject({ method: 'GET', url: '/oauth/userinfo' })
expect(noToken.statusCode).toBe(401)
})
it('без сессии authorize редиректит на логин', async () => {
app = await buildTestApp()
const { client } = await setup(app)
const res = await app.inject({
method: 'GET',
url: '/oauth/authorize',
query: {
client_id: client.client_id,
redirect_uri: 'https://rp.test.local/cb',
response_type: 'code',
scope: 'openid',
},
})
expect(res.statusCode).toBe(302)
expect(res.headers.location).toContain('return_to=')
})
it('старый argon2-секрет работает и мигрирует в sha256', async () => {
app = await buildTestApp()
const { refresh } = await setup(app)
const secret = generateOidcClientSecret()
const legacy = createOidcClient(app.db, {
name: 'Legacy',
redirectUris: ['https://rp.test.local/cb'],
scopes: ['openid', 'profile'],
enabled: true,
clientSecretHash: await hash(secret),
})
const code = await authorizeCode(app, refresh, legacy.clientId)
const res = await exchange(app, {
client_id: legacy.clientId,
client_secret: secret,
code,
})
expect(res.statusCode).toBe(200)
const row = app.sqlite
.prepare('SELECT client_secret_hash h FROM oidc_clients WHERE client_id=?')
.get(legacy.clientId) as { h: string }
expect(row.h.startsWith('sha256:')).toBe(true)
// после миграции секрет всё ещё работает
const code2 = await authorizeCode(app, refresh, legacy.clientId)
const again = await exchange(app, {
client_id: legacy.clientId,
client_secret: secret,
code: code2,
})
expect(again.statusCode).toBe(200)
})
it('CRUD клиента: patch, rotate-secret инвалидирует старый секрет, delete', async () => {
app = await buildTestApp()
const { token, client, refresh } = await setup(app)
const patch = await app.inject({
method: 'PATCH',
url: `/api/v1/admin/oidc/clients/${client.id}`,
headers: auth(token),
payload: { name: 'Renamed RP' },
})
expect(patch.statusCode).toBe(200)
expect((patch.json() as { name: string }).name).toBe('Renamed RP')
const rotate = await app.inject({
method: 'POST',
url: `/api/v1/admin/oidc/clients/${client.id}/rotate-secret`,
headers: auth(token),
})
expect(rotate.statusCode).toBe(200)
const fresh = (rotate.json() as { client_secret: string }).client_secret
expect(fresh).not.toBe(client.client_secret)
const code = await authorizeCode(app, refresh, client.client_id)
expect(
(await exchange(app, { client_id: client.client_id, client_secret: client.client_secret, code })).statusCode,
).toBe(401)
expect(
(await exchange(app, { client_id: client.client_id, client_secret: fresh, code })).statusCode,
).toBe(200)
const del = await app.inject({
method: 'DELETE',
url: `/api/v1/admin/oidc/clients/${client.id}`,
headers: auth(token),
})
expect(del.statusCode).toBe(200)
const gone = await app.inject({
method: 'GET',
url: `/api/v1/admin/oidc/clients/${client.id}`,
headers: auth(token),
})
expect(gone.statusCode).toBe(404)
})
})
+82
View File
@@ -0,0 +1,82 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { listBackups } from '@authportal/db'
import { loadConfig } from '../src/config.js'
import { backupDirFor, runBackup, startBackupJob } from '../src/services/backup.js'
import { buildApp } from '../src/app.js'
import { adminToken } from './helpers.js'
const dirs: string[] = []
afterEach(() => {
vi.useRealTimers()
for (const d of dirs.splice(0)) {
try {
rmSync(d, { recursive: true, force: true })
} catch {
/* Windows может держать файл БД до GC — временный каталог не критичен */
}
}
})
function env(dir: string, extra: NodeJS.ProcessEnv = {}) {
return loadConfig({
JWT_SECRET: 'test-secret-at-least-8',
ADMIN_EMAIL: '[email protected]',
ADMIN_PASSWORD: 'adminpass',
DATABASE_URL: `sqlite:${join(dir, 'app.db')}`,
NODE_ENV: 'test',
...extra,
})
}
describe('backup', () => {
it('backupDirFor: по умолчанию рядом с БД, BACKUP_DIR приоритетнее', () => {
const cfg = env('/data/x')
expect(backupDirFor(cfg).replaceAll(String.fromCharCode(92), '/')).toMatch(/\/data\/x\/backups$/)
const custom = env('/data/x', { BACKUP_DIR: '/mnt/bk' })
expect(backupDirFor(custom).replaceAll(String.fromCharCode(92), '/')).toMatch(/\/mnt\/bk$/)
})
it('бэкап живого приложения восстанавливается: вход админа работает', async () => {
const dir = mkdtempSync(join(tmpdir(), 'ap-api-bk-'))
dirs.push(dir)
const config = env(dir, { BACKUP_KEEP: '2' })
const app = await buildApp({ config })
const res = await runBackup(app.sqlite, config)
expect(listBackups(backupDirFor(config))).toHaveLength(1)
await app.close()
const restored = await buildApp({
config: env(dir),
databaseUrl: `sqlite:${res.file}`,
})
const token = await adminToken(restored as never)
expect(token).toBeTruthy()
await restored.close()
})
it('startBackupJob выключен при 0 часов и работает по таймеру', async () => {
vi.useFakeTimers()
const dir = mkdtempSync(join(tmpdir(), 'ap-job-bk-'))
dirs.push(dir)
const config = env(dir, { BACKUP_INTERVAL_HOURS: '1' })
const app = await buildApp({ config })
const base = vi.getTimerCount()
const off = startBackupJob({ ...app, config: { ...config, backupIntervalHours: 0 } } as never)
expect(vi.getTimerCount()).toBe(base)
off()
const stop = startBackupJob(app)
expect(vi.getTimerCount()).toBe(base + 1)
await vi.advanceTimersByTimeAsync(60 * 60 * 1000 + 10)
vi.useRealTimers()
await vi.waitFor(() =>
expect(listBackups(backupDirFor(config)).length).toBeGreaterThan(0),
)
stop()
await app.close()
})
})
+150
View File
@@ -0,0 +1,150 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Writable } from 'node:stream'
import Fastify from 'fastify'
import { assertBootstrapPassword, loadConfig } from '../src/config.js'
import { buildApp } from '../src/app.js'
const base = { JWT_SECRET: 'a-sufficiently-long-secret-123' }
describe('loadConfig', () => {
it('значения по умолчанию для новых параметров', () => {
const c = loadConfig({ ...base, NODE_ENV: 'test' })
expect(c.shutdownTimeoutMs).toBe(8000)
expect(c.sessionRetentionDays).toBe(7)
expect(c.argon2Concurrency).toBe(2)
expect(c.backupKeep).toBe(7)
expect(c.backupIntervalHours).toBe(0)
expect(c.corsOrigins).toBe('')
expect(c.trustProxy).toContain('10.0.0.0/8')
})
it('читает переопределения из env', () => {
const c = loadConfig({
...base,
NODE_ENV: 'test',
SHUTDOWN_TIMEOUT_MS: '1500',
SESSION_RETENTION_DAYS: '30',
ARGON2_CONCURRENCY: '4',
BACKUP_KEEP: '3',
BACKUP_INTERVAL_HOURS: '12',
CORS_ORIGINS: 'https://a.example',
TRUST_PROXY: 'false',
})
expect(c).toMatchObject({
shutdownTimeoutMs: 1500,
sessionRetentionDays: 30,
argon2Concurrency: 4,
backupKeep: 3,
backupIntervalHours: 12,
corsOrigins: 'https://a.example',
trustProxy: 'false',
})
})
it.each([
['SHUTDOWN_TIMEOUT_MS', '0'],
['ARGON2_CONCURRENCY', '-1'],
['BACKUP_KEEP', 'abc'],
['BACKUP_INTERVAL_HOURS', '-5'],
])('невалидный %s=%s → ошибка', (key, value) => {
expect(() => loadConfig({ ...base, NODE_ENV: 'test', [key]: value })).toThrow()
})
})
describe('production guard', () => {
it.each(['change-me-in-production', 'dev-secret-change-me'])(
'известный дефолт JWT_SECRET "%s" отклоняется',
(secret) => {
expect(() =>
loadConfig({ NODE_ENV: 'production', JWT_SECRET: secret }),
).toThrow(/JWT_SECRET/)
},
)
it('короткий, но уникальный секрет в production не роняет старт (только предупреждение)', () => {
const warn = vi.spyOn(process, 'emitWarning').mockImplementation(() => {})
expect(() =>
loadConfig({ NODE_ENV: 'production', JWT_SECRET: 'short-secret' }),
).not.toThrow()
expect(warn).toHaveBeenCalledWith(expect.stringContaining('JWT_SECRET'))
warn.mockRestore()
})
it('нормальный секрет в production принимается без предупреждений; в test слабый разрешён', () => {
const warn = vi.spyOn(process, 'emitWarning').mockImplementation(() => {})
expect(() =>
loadConfig({ NODE_ENV: 'production', JWT_SECRET: base.JWT_SECRET }),
).not.toThrow()
expect(warn).not.toHaveBeenCalled()
expect(() =>
loadConfig({ NODE_ENV: 'test', JWT_SECRET: 'short-secret' }),
).not.toThrow()
warn.mockRestore()
})
it('пароль первого админа: слабый запрещён только в production', () => {
const prod = (pw?: string) =>
loadConfig({ NODE_ENV: 'production', ...base, ADMIN_PASSWORD: pw })
expect(() => assertBootstrapPassword(prod('admin'))).toThrow(/ADMIN_PASSWORD/)
expect(() => assertBootstrapPassword(prod(''))).toThrow(/ADMIN_PASSWORD/)
expect(() => assertBootstrapPassword(prod('short'))).toThrow(/ADMIN_PASSWORD/)
expect(() => assertBootstrapPassword(prod('long-enough-pw'))).not.toThrow()
expect(() =>
assertBootstrapPassword(loadConfig({ NODE_ENV: 'test', ...base })),
).not.toThrow()
})
it('buildApp в production без нормального пароля падает при создании админа', async () => {
await expect(
buildApp({
config: loadConfig({
NODE_ENV: 'production',
...base,
ADMIN_PASSWORD: 'admin',
DATABASE_URL: 'sqlite::memory:',
}),
databaseUrl: 'sqlite::memory:',
}),
).rejects.toThrow(/ADMIN_PASSWORD/)
})
})
describe('redact в логах', () => {
let app: ReturnType<typeof Fastify> | undefined
afterEach(async () => {
await app?.close()
})
it('authorization и cookie не попадают в лог', async () => {
const lines: string[] = []
const stream = new Writable({
write(chunk, _enc, cb) {
lines.push(String(chunk))
cb()
},
})
// Тот же набор путей, что и в buildApp.
app = Fastify({
logger: {
level: 'info',
stream,
redact: {
paths: ['req.headers.authorization', 'req.headers.cookie'],
censor: '[redacted]',
},
serializers: {
req: (req) => ({ method: req.method, headers: req.headers }),
},
},
})
app.get('/x', async () => ({ ok: true }))
await app.inject({
method: 'GET',
url: '/x',
headers: { authorization: 'Bearer SECRET', cookie: 'refresh_token=SECRET' },
})
const joined = lines.join('')
expect(joined).toContain('[redacted]')
expect(joined).not.toContain('SECRET')
})
})
+95
View File
@@ -0,0 +1,95 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { adminToken, buildTestApp } from './helpers.js'
import { startHousekeepingJob } from '../src/services/housekeeping.js'
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
afterEach(async () => {
vi.useRealTimers()
await app?.close()
app = undefined
})
describe('directory/users', () => {
it('отдаёт только id/email/name и ограничивает 50', async () => {
app = await buildTestApp()
const token = await adminToken(app)
for (let i = 0; i < 55; i++) {
app.sqlite
.prepare(
`INSERT INTO users (id,email,name,password_hash,is_admin,disabled,created_at,updated_at)
VALUES (?,?,?,?,0,0,'t','t')`,
)
.run(`id${i}`, `u${i}@t.io`, `U${i}`, 'hash')
}
const res = await app.inject({
method: 'GET',
url: '/api/v1/directory/users',
headers: { authorization: `Bearer ${token}` },
})
expect(res.statusCode).toBe(200)
const body = res.json() as Array<Record<string, unknown>>
expect(body).toHaveLength(50)
expect(Object.keys(body[0]!).sort()).toEqual(['email', 'id', 'name'])
expect(res.body).not.toContain('hash')
const q = await app.inject({
method: 'GET',
url: '/api/v1/directory/users?q=U54',
headers: { authorization: `Bearer ${token}` },
})
expect((q.json() as unknown[]).length).toBe(1)
})
})
describe('ingest audit', () => {
const ev = (i: number) => ({
event_id: `ev-${i}`,
source_app: 'dns',
action: 'dns.test',
severity: 'info',
summary: 'x',
})
it('принимает пачку одной транзакцией и считает дубликаты', async () => {
app = await buildTestApp()
const post = (events: unknown[]) =>
app!.inject({
method: 'POST',
url: '/api/v1/ingest/audit',
headers: { authorization: 'Bearer dev-audit-ingest-secret' },
payload: { events },
})
const first = await post([ev(1), ev(2), ev(3)])
expect(first.json()).toEqual({ accepted: 3, duplicates: 0 })
const second = await post([ev(3), ev(4)])
expect(second.json()).toEqual({ accepted: 1, duplicates: 1 })
})
})
describe('housekeeping job', () => {
it('удаляет устаревшие сессии по таймеру и останавливается', async () => {
vi.useFakeTimers()
app = await buildTestApp()
app.sqlite
.prepare(
`INSERT INTO users (id,email,name,password_hash,is_admin,disabled,created_at,updated_at)
VALUES ('uh','[email protected]','H','x',0,0,'t','t')`,
)
.run()
app.sqlite
.prepare(
`INSERT INTO refresh_sessions (id,user_id,token_hash,expires_at,created_at)
VALUES ('old','uh','h','2000-01-01T00:00:00.000Z','t')`,
)
.run()
const stop = startHousekeepingJob(app)
await vi.advanceTimersByTimeAsync(31_000)
const left = app.sqlite
.prepare(`SELECT COUNT(*) c FROM refresh_sessions WHERE id='old'`)
.get()
expect(left).toEqual({ c: 0 })
stop()
expect(vi.getTimerCount()).toBe(0)
})
})
+91
View File
@@ -0,0 +1,91 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const root = resolve(import.meta.dirname, '../../..')
const read = (p: string) => readFileSync(resolve(root, p), 'utf-8')
const dockerfile = read('deploy/docker/authportal/Dockerfile')
const composes = {
'docker-compose.yml': read('docker-compose.yml'),
'deploy/docker-compose.traefik.yml': read('deploy/docker-compose.traefik.yml'),
}
describe('Dockerfile', () => {
it('graceful stop и ограничение ресурсов Node', () => {
expect(dockerfile).toMatch(/^STOPSIGNAL SIGTERM$/m)
expect(dockerfile).toMatch(/NODE_OPTIONS=--max-old-space-size=\d+/)
expect(dockerfile).toMatch(/UV_THREADPOOL_SIZE=4/)
})
it('healthcheck без запуска node', () => {
const hc = dockerfile.split('HEALTHCHECK')[1]!.split('\n').slice(0, 2).join(' ')
expect(hc).toContain('wget')
expect(hc).not.toContain('"node"')
})
it('запуск не от root, рантайм без тулчейна и pnpm deploy', () => {
expect(dockerfile).toMatch(/^USER node$/m)
const runtime = dockerfile.split('AS runtime')[1]!
expect(runtime).not.toMatch(/apk add/)
expect(dockerfile).not.toMatch(/pnpm .*deploy/)
})
it('статика сжимается только brotli', () => {
expect(dockerfile).toContain('brotli')
expect(dockerfile).not.toMatch(/gzip -k/)
})
it('preview-маршруты дизайна вырезаются до сборки web', () => {
const rm = dockerfile.indexOf('rm -f apps/web/src/routes/design-preview*.tsx')
const build = dockerfile.indexOf('pnpm turbo build')
expect(rm).toBeGreaterThan(0)
expect(rm).toBeLessThan(build)
})
it('базовый образ закреплён по digest', () => {
expect(dockerfile).toMatch(/ARG BASE_NODE=\S+@sha256:[0-9a-f]{64}/)
expect(read('deploy/docker/docker-bake.hcl')).toMatch(/node:22-alpine@sha256:[0-9a-f]{64}/)
})
it('в образ копируется весь dist API, а tsup не дробит бандл на чанки', () => {
expect(dockerfile).toContain('cp -r apps/api/dist /out/dist')
expect(read('apps/api/tsup.config.ts')).toMatch(/splitting:\s*false/)
})
it('нативные модули проверяются при сборке', () => {
expect(dockerfile).toContain('native modules ok')
})
})
describe.each(Object.entries(composes))('%s', (_name, text) => {
it('init, grace period и лимиты ресурсов', () => {
expect(text).toMatch(/init: true/)
expect(text).toMatch(/stop_grace_period: 15s/)
expect(text).toMatch(/mem_limit: 256m/)
expect(text).toMatch(/pids_limit: \d+/)
expect(text).toMatch(/read_only: true/)
expect(text).toMatch(/cap_drop:\s*\n\s*- ALL/)
expect(text).toMatch(/no-new-privileges:true/)
})
it('healthcheck через wget', () => {
expect(text).toMatch(/"wget", "-q"/)
expect(text).not.toMatch(/fetch\('http:\/\/127\.0\.0\.1:8080\/health'\)/)
})
})
describe('traefik', () => {
it('app ждёт healthy traefik, у traefik есть healthcheck', () => {
const t = composes['deploy/docker-compose.traefik.yml']
expect(t).toMatch(/condition: service_healthy/)
expect(t).toContain('traefik", "healthcheck", "--ping')
expect(t).toContain('--ping=true')
})
it('включено сжатие ответов', () => {
expect(composes['deploy/docker-compose.traefik.yml']).toContain(
'middlewares.auth-portal-compress.compress=true',
)
})
})
+77
View File
@@ -0,0 +1,77 @@
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { z } from 'zod'
import { buildTestApp } from './helpers.js'
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
afterEach(async () => {
await app?.close()
app = undefined
})
describe('error handler', () => {
it('необработанная ошибка → 500 без деталей', async () => {
app = await buildTestApp()
app.get('/__boom', async () => {
throw new Error('secret internals')
})
const res = await app.inject({ method: 'GET', url: '/__boom' })
expect(res.statusCode).toBe(500)
expect(res.json()).toEqual({
error: { code: 'INTERNAL', message: 'Internal server error' },
})
expect(res.body).not.toContain('secret internals')
})
it('ZodError → 400 BAD_REQUEST', async () => {
app = await buildTestApp()
app.get('/__zod', async () => z.object({ a: z.string() }).parse({}))
const res = await app.inject({ method: 'GET', url: '/__zod' })
expect(res.statusCode).toBe(400)
expect(res.json().error.code).toBe('BAD_REQUEST')
})
it('ошибка с statusCode 4xx сохраняет статус', async () => {
app = await buildTestApp()
app.get('/__teapot', async () => {
throw Object.assign(new Error('nope'), { statusCode: 418 })
})
const res = await app.inject({ method: 'GET', url: '/__teapot' })
expect(res.statusCode).toBe(418)
})
})
describe('not found', () => {
it.each(['/oauth/nope', '/.well-known/nope', '/api/v1/nope'])(
'%s → 404 JSON',
async (url) => {
app = await buildTestApp()
const res = await app.inject({ method: 'GET', url })
expect(res.statusCode).toBe(404)
expect(res.json().error.code).toBe('NOT_FOUND')
},
)
it('без STATIC_DIR неизвестный путь → 404 JSON', async () => {
app = await buildTestApp()
const res = await app.inject({ method: 'GET', url: '/some/spa/route' })
expect(res.statusCode).toBe(404)
})
it('со STATIC_DIR: SPA-путь → index.html, /oauth и /.well-known → 404', async () => {
const dir = mkdtempSync(join(tmpdir(), 'ap-static-'))
writeFileSync(join(dir, 'index.html'), '<html>spa</html>')
app = await buildTestApp({ STATIC_DIR: dir })
const spa = await app.inject({ method: 'GET', url: '/some/spa/route' })
expect(spa.statusCode).toBe(200)
expect(spa.body).toContain('spa')
for (const url of ['/oauth/nope', '/.well-known/nope']) {
const res = await app.inject({ method: 'GET', url })
expect(res.statusCode).toBe(404)
expect(res.json().error.code).toBe('NOT_FOUND')
}
})
})
+277
View File
@@ -0,0 +1,277 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { hash } from '@node-rs/argon2'
import { TtlCache } from '../src/lib/cache.js'
import { parseCookie } from '../src/lib/cookies.js'
import { checkClientSecret, hashClientSecret } from '../src/lib/client-secret.js'
import {
hashPassword,
passwordHashStats,
setPasswordConcurrency,
verifyPassword,
} from '../src/lib/password.js'
import { parseTrustProxy } from '../src/config.js'
import { adminToken, buildTestApp } from './helpers.js'
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
afterEach(async () => {
vi.restoreAllMocks()
await app?.close()
app = undefined
})
describe('TtlCache', () => {
it('истекает по TTL', () => {
let t = 0
const c = new TtlCache<string, number>({ max: 10, ttlMs: 100, now: () => t })
c.set('a', 1)
t = 99
expect(c.get('a')).toBe(1)
t = 100
expect(c.get('a')).toBeUndefined()
expect(c.size).toBe(0)
})
it('вытесняет давно не использованное (LRU)', () => {
const c = new TtlCache<string, number>({ max: 2, ttlMs: 1000 })
c.set('a', 1)
c.set('b', 2)
c.get('a') // освежили a
c.set('c', 3)
expect(c.get('b')).toBeUndefined()
expect(c.get('a')).toBe(1)
expect(c.get('c')).toBe(3)
c.delete('a')
expect(c.get('a')).toBeUndefined()
c.clear()
expect(c.size).toBe(0)
})
})
describe('parseCookie', () => {
it('находит значение и декодирует', () => {
expect(parseCookie('a=1; refresh_token=abc%20d; b=2', 'refresh_token')).toBe(
'abc d',
)
})
it('не путает суффикс имени', () => {
expect(parseCookie('x_refresh_token=bad', 'refresh_token')).toBeNull()
})
it('пусто/нет → null', () => {
expect(parseCookie(undefined, 'a')).toBeNull()
expect(parseCookie('a=; b=1', 'a')).toBeNull()
expect(parseCookie('b=1', 'a')).toBeNull()
})
})
describe('client secret', () => {
it('sha256-формат: верный/неверный, без rehash', async () => {
const stored = hashClientSecret('s3cret')
expect(stored.startsWith('sha256:')).toBe(true)
expect(await checkClientSecret(stored, 's3cret')).toEqual({
ok: true,
needsRehash: false,
})
expect((await checkClientSecret(stored, 'nope')).ok).toBe(false)
})
it('argon2-формат принимается и помечается на rehash', async () => {
const legacy = await hash('old-secret')
expect(await checkClientSecret(legacy, 'old-secret')).toEqual({
ok: true,
needsRehash: true,
})
expect(await checkClientSecret(legacy, 'bad')).toEqual({
ok: false,
needsRehash: false,
})
expect((await checkClientSecret('garbage', 'x')).ok).toBe(false)
})
})
describe('password limiter', () => {
it('не допускает больше N одновременных хешей', async () => {
setPasswordConcurrency(2)
let maxActive = 0
const poll = setInterval(() => {
maxActive = Math.max(maxActive, passwordHashStats().active)
}, 1)
const h = await hashPassword('pw')
await Promise.all(
Array.from({ length: 6 }, () => verifyPassword(h, 'pw')),
)
clearInterval(poll)
expect(maxActive).toBeLessThanOrEqual(2)
expect(passwordHashStats()).toEqual({ active: 0, waiting: 0 })
setPasswordConcurrency(2)
})
})
describe('parseTrustProxy', () => {
it('true/false/список', () => {
expect(parseTrustProxy('true')).toBe(true)
expect(parseTrustProxy('false')).toBe(false)
expect(parseTrustProxy('10.0.0.0/8, ::1')).toEqual(['10.0.0.0/8', '::1'])
})
})
async function createUserAndLogin(
a: NonNullable<typeof app>,
adminTok: string,
email: string,
) {
const create = await a.inject({
method: 'POST',
url: '/api/v1/admin/users',
headers: { authorization: `Bearer ${adminTok}` },
payload: {
email,
name: 'U',
password: 'secret12',
is_admin: false,
apps: ['cfdm'],
permissions: [],
},
})
expect(create.statusCode).toBe(201)
const id = (create.json() as { id: string }).id
const login = await a.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { email, password: 'secret12' },
})
expect(login.statusCode).toBe(200)
return { id, token: (login.json() as { access_token: string }).access_token }
}
describe('кэш профиля пользователя', () => {
it('повторные запросы не обращаются к user_apps/user_permissions', async () => {
app = await buildTestApp()
const admin = await adminToken(app)
const { token } = await createUserAndLogin(app, admin, '[email protected]')
const me = () =>
app!.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: { authorization: `Bearer ${token}` },
})
expect((await me()).statusCode).toBe(200) // прогрев
const spy = vi.spyOn(app.sqlite, 'prepare')
for (let i = 0; i < 5; i++) expect((await me()).statusCode).toBe(200)
const sqls = spy.mock.calls.map((c) => String(c[0]))
expect(sqls.filter((s) => /user_apps|user_permissions|from "users"/i.test(s)))
.toHaveLength(0)
})
it('блокировка пользователя действует немедленно', async () => {
app = await buildTestApp()
const admin = await adminToken(app)
const { id, token } = await createUserAndLogin(app, admin, '[email protected]')
const me = () =>
app!.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: { authorization: `Bearer ${token}` },
})
expect((await me()).statusCode).toBe(200)
const patch = await app.inject({
method: 'PATCH',
url: `/api/v1/admin/users/${id}`,
headers: { authorization: `Bearer ${admin}` },
payload: { disabled: true },
})
expect(patch.statusCode).toBe(200)
expect((await me()).statusCode).toBe(401)
})
it('смена доступа отражается сразу, удаление → 401', async () => {
app = await buildTestApp()
const admin = await adminToken(app)
const { id, token } = await createUserAndLogin(app, admin, '[email protected]')
const me = async () =>
(
await app!.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: { authorization: `Bearer ${token}` },
})
).json() as { apps?: string[] }
expect((await me()).apps).toEqual(['cfdm'])
const put = await app.inject({
method: 'PUT',
url: `/api/v1/admin/users/${id}/access`,
headers: { authorization: `Bearer ${admin}` },
payload: { apps: ['dns'], permissions: [] },
})
expect(put.statusCode).toBe(200)
expect((await me()).apps).toEqual(['dns'])
await app.inject({
method: 'DELETE',
url: `/api/v1/admin/users/${id}`,
headers: { authorization: `Bearer ${admin}` },
})
const gone = await app.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: { authorization: `Bearer ${token}` },
})
expect(gone.statusCode).toBe(401)
})
})
describe('app-switcher кэш', () => {
it('PUT сразу виден в GET', async () => {
app = await buildTestApp()
const admin = await adminToken(app)
const before = await app.inject({ method: 'GET', url: '/api/v1/app-switcher' })
const cfg = await app.inject({
method: 'GET',
url: '/api/v1/admin/app-switcher',
headers: { authorization: `Bearer ${admin}` },
})
expect(before.statusCode).toBe(200)
expect(cfg.statusCode).toBe(200)
})
})
describe('rate-limit', () => {
it('/health не лимитируется', async () => {
app = await buildTestApp()
for (let i = 0; i < 250; i++) {
const res = await app.inject({ method: 'GET', url: '/health' })
expect(res.statusCode).toBe(200)
}
})
it('логин ограничен 20/мин', async () => {
app = await buildTestApp()
let last = 0
for (let i = 0; i < 22; i++) {
const res = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { email: '[email protected]', password: 'wrongpass' },
})
last = res.statusCode
}
expect(last).toBe(429)
})
it('X-Forwarded-For от недоверенного клиента игнорируется', async () => {
app = await buildTestApp({ TRUST_PROXY: '10.0.0.0/8' })
const res = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
remoteAddress: '203.0.113.9',
headers: { 'x-forwarded-for': '198.51.100.7' },
payload: { email: '[email protected]', password: 'wrongpass' },
})
expect(res.statusCode).toBe(401)
const row = app.sqlite
.prepare(`SELECT ip FROM audit_log WHERE action='auth.login_failed'`)
.get() as { ip: string }
expect(row.ip).toBe('203.0.113.9')
})
})
+232
View File
@@ -0,0 +1,232 @@
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { createOriginChecker } from '../src/lib/cors.js'
import { loadConfig } from '../src/config.js'
import { buildTestApp } from './helpers.js'
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
afterEach(async () => {
await app?.close()
app = undefined
})
function staticDir(): string {
const dir = mkdtempSync(join(tmpdir(), 'ap-static-'))
mkdirSync(join(dir, 'assets'))
writeFileSync(join(dir, 'index.html'), '<html>spa</html>')
writeFileSync(join(dir, 'assets', 'app-abc123.js'), 'console.log(1)')
return dir
}
describe('статика', () => {
it('hashed-ассеты immutable, index.html no-cache', async () => {
app = await buildTestApp({ STATIC_DIR: staticDir() })
const asset = await app.inject({ method: 'GET', url: '/assets/app-abc123.js' })
expect(asset.statusCode).toBe(200)
expect(asset.headers['cache-control']).toBe(
'public, max-age=31536000, immutable',
)
const index = await app.inject({ method: 'GET', url: '/index.html' })
expect(index.headers['cache-control']).toBe('no-cache')
const spa = await app.inject({ method: 'GET', url: '/users/42' })
expect(spa.statusCode).toBe(200)
expect(spa.headers['cache-control']).toBe('no-cache')
})
it('отсутствующий ассет → 404, а не index.html (иначе после деплоя чанк-JS = HTML)', async () => {
app = await buildTestApp({ STATIC_DIR: staticDir() })
const res = await app.inject({ method: 'GET', url: '/assets/missing.js' })
expect(res.statusCode).toBe(404)
expect(res.body).not.toContain('<html>')
})
it('отдаёт предсжатый .br по Accept-Encoding', async () => {
const dir = staticDir()
writeFileSync(join(dir, 'assets', 'app-abc123.js.br'), 'BR-BYTES')
app = await buildTestApp({ STATIC_DIR: dir })
const res = await app.inject({
method: 'GET',
url: '/assets/app-abc123.js',
headers: { 'accept-encoding': 'br' },
})
expect(res.headers['content-encoding']).toBe('br')
expect(res.body).toBe('BR-BYTES')
})
})
describe('кэш-заголовки API', () => {
it('auth/config кэшируется, app-switcher — нет (админка должна видеть правки сразу)', async () => {
app = await buildTestApp()
const sw = await app.inject({ method: 'GET', url: '/api/v1/app-switcher' })
expect(sw.headers['cache-control']).toBeUndefined()
const cfg = await app.inject({ method: 'GET', url: '/api/v1/auth/config' })
expect(cfg.headers['cache-control']).toBe('public, max-age=60')
})
})
describe('health / ready', () => {
it('/health не трогает БД и живёт после закрытия БД', async () => {
app = await buildTestApp()
app.sqlite.close()
const res = await app.inject({ method: 'GET', url: '/health' })
expect(res.statusCode).toBe(200)
})
it('/ready 200 в норме и 503 при недоступной БД', async () => {
app = await buildTestApp()
const ok = await app.inject({ method: 'GET', url: '/ready' })
expect(ok.statusCode).toBe(200)
app.sqlite.close()
const bad = await app.inject({ method: 'GET', url: '/ready' })
expect(bad.statusCode).toBe(503)
expect(bad.json()).toEqual({ ok: false, reason: 'db' })
})
})
describe('CORS', () => {
const origin = (url: string) => ({ origin: url })
it('разрешает issuer/allowlist, блокирует чужие', async () => {
app = await buildTestApp({ RETURN_TO_ALLOWLIST: '.test.local,localhost' })
const allowed = await app.inject({
method: 'GET',
url: '/api/v1/auth/config',
headers: origin('https://auth.test.local'),
})
expect(allowed.headers['access-control-allow-origin']).toBe(
'https://auth.test.local',
)
expect(allowed.headers['access-control-allow-credentials']).toBe('true')
const foreign = await app.inject({
method: 'GET',
url: '/api/v1/auth/config',
headers: origin('https://evil.example'),
})
expect(foreign.headers['access-control-allow-origin']).toBeUndefined()
})
it('CORS_ORIGINS добавляет origin; запрос без Origin проходит', async () => {
app = await buildTestApp({ CORS_ORIGINS: 'https://extra.example' })
const res = await app.inject({
method: 'GET',
url: '/api/v1/auth/config',
headers: origin('https://extra.example'),
})
expect(res.headers['access-control-allow-origin']).toBe(
'https://extra.example',
)
const none = await app.inject({ method: 'GET', url: '/api/v1/auth/config' })
expect(none.statusCode).toBe(200)
})
it('боевой RETURN_TO_ALLOWLIST из env.traefik.example не теряет клиентов', () => {
const list =
'.shnt.top,https://vps.shnt.top,https://cfdm.shnt.top,https://bgp.shnt.top,https://fw.shnt.top,https://dns.shnt.top,https://cdn.shnt.top'
const check = createOriginChecker(
loadConfig({
JWT_SECRET: 'test-secret-at-least-8',
ISSUER: 'https://auth.shnt.top',
RETURN_TO_ALLOWLIST: list,
}),
() => [],
)
for (const o of [
'https://vps.shnt.top',
'https://dns.shnt.top',
'https://shnt.top',
'https://anything.shnt.top',
]) {
expect(check(o)).toBe(true)
}
expect(check('https://evil.example')).toBe(false)
})
it('private в allowlist пускает LAN-хосты', () => {
const check = createOriginChecker(
loadConfig({
JWT_SECRET: 'test-secret-at-least-8',
RETURN_TO_ALLOWLIST: '.shnt.top,private',
}),
() => [],
)
expect(check('http://192.168.1.10:8080')).toBe(true)
expect(check('http://10.0.0.5')).toBe(true)
expect(check('https://8.8.8.8')).toBe(false)
})
it('createOriginChecker: суффикс, localhost, switcher, *', () => {
const base = {
JWT_SECRET: 'test-secret-at-least-8',
ISSUER: 'https://auth.test.local',
RETURN_TO_ALLOWLIST: '.shnt.top,localhost,private',
}
const check = createOriginChecker(loadConfig(base), () => [
'https://dns.custom.io/path',
])
expect(check('https://cfdm.shnt.top')).toBe(true)
expect(check('http://localhost:5173')).toBe(true)
expect(check('https://dns.custom.io')).toBe(true)
expect(check('https://evilshnt.top')).toBe(false)
expect(check('not a url')).toBe(false)
const all = createOriginChecker(
loadConfig({ ...base, CORS_ORIGINS: '*' }),
() => [],
)
expect(all('https://anything.example')).toBe(true)
})
})
describe('сжатие в Node (ENABLE_COMPRESS)', () => {
async function withBigRoute(env: NodeJS.ProcessEnv) {
app = await buildTestApp(env)
app.get('/__big', async () => ({ data: 'x'.repeat(5000) }))
app.get('/__small', async () => ({ ok: true }))
return app
}
it('по умолчанию выключено', async () => {
const a = await withBigRoute({})
const res = await a.inject({
method: 'GET',
url: '/__big',
headers: { 'accept-encoding': 'gzip' },
})
expect(res.headers['content-encoding']).toBeUndefined()
})
it('включено: большой JSON сжимается, маленький нет, без Accept-Encoding нет', async () => {
const a = await withBigRoute({ ENABLE_COMPRESS: 'true' })
const big = await a.inject({
method: 'GET',
url: '/__big',
headers: { 'accept-encoding': 'gzip' },
})
expect(big.headers['content-encoding']).toBe('gzip')
expect(big.rawPayload.length).toBeLessThan(500)
const small = await a.inject({
method: 'GET',
url: '/__small',
headers: { 'accept-encoding': 'gzip' },
})
expect(small.headers['content-encoding']).toBeUndefined()
const none = await a.inject({ method: 'GET', url: '/__big' })
expect(none.headers['content-encoding']).toBeUndefined()
})
});
describe('CSP', () => {
it('отдаётся жёсткая политика без внешних источников', async () => {
app = await buildTestApp()
const res = await app.inject({ method: 'GET', url: '/health' })
const csp = String(res.headers['content-security-policy'])
expect(csp).toContain("default-src 'self'")
expect(csp).toContain("script-src 'self'")
expect(csp).toContain("frame-ancestors 'none'")
expect(csp).toContain("object-src 'none'")
expect(csp).not.toMatch(/script-src[^;]*'unsafe-inline'/)
expect(csp).not.toContain('http:')
})
})
+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({ export default defineConfig({
test: { test: {
passWithNoTests: true, 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": { "dependencies": {
"@authportal/shared": "workspace:*", "@authportal/shared": "workspace:*",
"@authportal/ui": "workspace:*", "@authportal/ui": "workspace:*",
"@base-ui/react": "^1.5.0", "@base-ui/react": "catalog:",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/geist": "^5.3.0",
"@fontsource-variable/geist-mono": "^5.3.0",
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
"@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/browser": "^13.3.0",
"@tailwindcss/vite": "^4.3.1", "@tailwindcss/vite": "^4.3.1",
@@ -42,20 +40,21 @@
"zod": "catalog:" "zod": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "catalog:",
"@tanstack/router-cli": "^1.167.31", "@tanstack/router-cli": "^1.167.31",
"@types/node": "^24.12.3", "@types/node": "catalog:",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0", "eslint": "catalog:",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0", "globals": "catalog:",
"jsdom": "^30.1.3",
"rollup-plugin-visualizer": "^7.1.1", "rollup-plugin-visualizer": "^7.1.1",
"tw-animate-css": "^1.0.0", "tw-animate-css": "^1.0.0",
"typescript": "catalog:", "typescript": "catalog:",
"typescript-eslint": "^8.59.2", "typescript-eslint": "catalog:",
"vite": "^8.0.12", "vite": "^8.0.12",
"vitest": "catalog:" "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="flex w-full min-w-0 flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">{children}</div>
</SidebarInset>
<CommandPalette />
</SidebarProvider>
</TooltipProvider>
</ChromeConfigProvider>
</PortalUiProvider>
)
}
+133 -79
View File
@@ -1,7 +1,7 @@
import { useEffect, useState, type FormEvent } from 'react' import { useEffect, useState, type FormEvent } from 'react'
import { useNavigate, useSearch } from '@tanstack/react-router' import { useNavigate, useSearch } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { EyeIcon, EyeOffIcon, FingerprintIcon } from 'lucide-react' import { EyeIcon, EyeOffIcon, FingerprintIcon, HistoryIcon, ShieldIcon } from 'lucide-react'
import { import {
browserSupportsWebAuthn, browserSupportsWebAuthn,
browserSupportsWebAuthnAutofill, browserSupportsWebAuthnAutofill,
@@ -35,6 +35,15 @@ import { ApiError } from '@/lib/api-client'
import { login, meQueryKey } from '@/queries/auth' import { login, meQueryKey } from '@/queries/auth'
import { webauthnLogin, webauthnLoginOptions } from '@/queries/webauthn' import { webauthnLogin, webauthnLoginOptions } from '@/queries/webauthn'
import { AuthLogo } from '@/components/auth-logo' import { AuthLogo } from '@/components/auth-logo'
import { Badge } from '@/components/reui/badge'
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
const PERKS = [
{ icon: FingerprintIcon, title: 'Passkey вместо пароля', text: 'Отпечаток, Face ID или ключ безопасности' },
{ icon: ShieldIcon, title: 'Права по приложениям и разделам', text: 'Чтение, запись и администрирование для каждого сервиса' },
{ icon: HistoryIcon, title: 'Журнал входов и изменений', text: 'Кто, когда и откуда заходил в сервисы' },
] as const
export function PortalLoginForm() { export function PortalLoginForm() {
const navigate = useNavigate() const navigate = useNavigate()
@@ -153,92 +162,137 @@ export function PortalLoginForm() {
} }
} }
const returnHost = (() => {
try {
return search.return_to ? new URL(search.return_to).host : null
} catch {
return null
}
})()
return ( return (
<section className="flex min-h-svh min-w-0 flex-col justify-center py-8"> <section className="bg-background flex min-h-svh min-w-0 items-center justify-center px-6 py-8">
<div className="mx-auto flex w-full max-w-90 flex-col gap-6 px-4"> <div className="grid w-full max-w-[1040px] items-center gap-12 md:grid-cols-2">
<div className="flex flex-col items-center gap-3 text-center"> <div className="hidden flex-col gap-6 md:flex">
<AuthLogo /> <div className="flex items-center gap-3">
<div className="flex flex-col gap-1"> <AuthLogo />
<h1 className="text-xl font-semibold tracking-tight"> <div className="flex flex-col">
Auth Portal <b className="text-base font-semibold">Auth Portal</b>
</h1> <span className="text-muted-foreground text-[13px]">auth.shnt.top</span>
<p className="text-muted-foreground text-sm"> </div>
Единый вход в приложения shnt.top
</p>
</div> </div>
<h1 className="text-3xl leading-tight font-semibold tracking-[-0.02em] text-balance">
Один вход для всех сервисов shnt.top
</h1>
<p className="text-muted-foreground text-[15px] leading-relaxed text-pretty">
Войдите один раз — и открывайте CFDM, VPS Tracker, EvoBGP, EvoFirewall, CDN Manager, MikrotikManager и
Technitium DNS без повторного ввода пароля.
</p>
<Frame spacing="sm" className="max-w-[420px]">
<FramePanel className="p-1">
{PERKS.map((perk) => (
<div key={perk.title} className="flex items-start gap-3 px-4 py-2.5">
<IconTile variant="outline" aria-hidden className="shrink-0">
<perk.icon />
</IconTile>
<div className="flex flex-col">
<b className="text-sm font-medium">{perk.title}</b>
<span className="text-muted-foreground text-[13px]">{perk.text}</span>
</div>
</div>
))}
</FramePanel>
</Frame>
</div> </div>
{error ? ( <Frame spacing="sm" className="w-full max-w-[420px] justify-self-center">
<Alert variant="destructive"> <FrameHeader className="gap-1 px-5 pt-5 pb-1">
<AlertTitle>Ошибка входа</AlertTitle> <FrameTitle className="text-xl">Вход в портал</FrameTitle>
<AlertDescription>{error}</AlertDescription> <FrameDescription className="text-sm">Введите email и пароль или используйте passkey</FrameDescription>
</Alert> </FrameHeader>
) : null} <FramePanel className="flex flex-col gap-4 px-5 pt-4 pb-5">
{returnHost ? (
<div className="bg-muted/40 flex items-start gap-2.5 rounded-lg border px-3 py-2.5 text-[13px]" role="status">
<Badge variant="info-light" size="sm">
SSO
</Badge>
<span>
После входа вы вернётесь в <b className="font-semibold">{returnHost}</b>
</span>
</div>
) : null}
<form className="flex flex-col gap-4" onSubmit={handleSubmit}> {error ? (
<FieldGroup className="gap-3.5"> <Alert variant="destructive" role="alert">
<Field className="gap-2"> <AlertTitle>Ошибка входа</AlertTitle>
<FieldLabel htmlFor="email">Email</FieldLabel> <AlertDescription>{error}</AlertDescription>
<Input </Alert>
id="email" ) : null}
name="email"
type="email"
autoComplete="username webauthn"
placeholder="[email protected]"
className="bg-background"
required
/>
</Field>
<Field className="gap-2"> <form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<FieldLabel htmlFor="password">Пароль</FieldLabel> <FieldGroup className="gap-3.5">
<InputGroup className="bg-background w-full"> <Field className="gap-2">
<InputGroupInput <FieldLabel htmlFor="email">Email</FieldLabel>
id="password" <Input
name="password" id="email"
type={showPassword ? 'text' : 'password'} name="email"
autoComplete="current-password" type="email"
placeholder="Пароль" autoComplete="username webauthn"
required placeholder="[email protected]"
/> aria-invalid={error ? true : undefined}
<InputGroupAddon align="inline-end"> required
<InputGroupButton />
type="button" </Field>
size="icon-xs"
aria-label={showPassword ? 'Скрыть пароль' : 'Показать пароль'}
onClick={() => setShowPassword((v) => !v)}
>
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</Field>
</FieldGroup>
<Button type="submit" className="w-full" disabled={pending}> <Field className="gap-2">
{pending ? 'Вход…' : 'Войти'} <FieldLabel htmlFor="password">Пароль</FieldLabel>
</Button> <InputGroup className="w-full">
</form> <InputGroupInput
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
placeholder="Пароль"
aria-invalid={error ? true : undefined}
required
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
type="button"
size="icon-xs"
aria-label={showPassword ? 'Скрыть пароль' : 'Показать пароль'}
onClick={() => setShowPassword((v) => !v)}
>
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</Field>
</FieldGroup>
{passkeySupported ? ( <Button type="submit" className="w-full" disabled={pending}>
<div className="flex flex-col gap-3"> {pending ? 'Вход…' : 'Войти'}
<div className="flex items-center gap-3"> </Button>
<Separator className="flex-1" /> </form>
<span className="text-muted-foreground text-xs">или</span>
<Separator className="flex-1" /> {passkeySupported ? (
</div> <>
<Button <div className="flex items-center gap-3">
type="button" <Separator className="flex-1" />
variant="outline" <span className="text-muted-foreground text-xs">или</span>
className="w-full" <Separator className="flex-1" />
disabled={pending} </div>
onClick={() => void handlePasskeyClick()} <Button type="button" variant="outline" className="w-full" disabled={pending} onClick={() => void handlePasskeyClick()}>
> <FingerprintIcon aria-hidden="true" />
<FingerprintIcon aria-hidden="true" /> Войти с passkey
Войти с passkey </Button>
</Button> <span className="text-muted-foreground text-center text-xs">
</div> Пароль остаётся запасным входом. Passkey добавляется в разделе «Безопасность».
) : null} </span>
</>
) : null}
</FramePanel>
</Frame>
</div> </div>
</section> </section>
) )
@@ -1,5 +1,5 @@
import { useState, type ReactNode } from 'react' import { useState, type ReactNode } from 'react'
import { CircleAlertIcon, CopyIcon, InfoIcon, TriangleAlertIcon, XIcon } from 'lucide-react' import { CircleAlertIcon, InfoIcon, TriangleAlertIcon, XIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Alert, AlertAction, AlertDescription, AlertTitle } from '@/components/reui/alert' import { Alert, AlertAction, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
@@ -20,6 +20,7 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from '@authportal/ui/components/alert-dialog' } from '@authportal/ui/components/alert-dialog'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import { cn } from '@authportal/ui/lib/utils'
import { Input } from '@authportal/ui/components/input' import { Input } from '@authportal/ui/components/input'
import { Label } from '@authportal/ui/components/label' import { Label } from '@authportal/ui/components/label'
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
@@ -32,7 +33,7 @@ import {
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from '@authportal/ui/components/sheet' } from '@authportal/ui/components/sheet'
import type { Tone } from './mock' import type { Tone } from './tone'
import { TONE_BADGE } from './tone' import { TONE_BADGE } from './tone'
const TONE_GLYPH: Record<Tone, string> = { ok: '●', wn: '▲', er: '✕', mu: '○', in: '' } const TONE_GLYPH: Record<Tone, string> = { ok: '●', wn: '▲', er: '✕', mu: '○', in: '' }
@@ -59,10 +60,10 @@ export function PageHead({
actions?: ReactNode actions?: ReactNode
}) { }) {
return ( return (
<header className="@container flex flex-wrap items-end justify-between gap-x-4 gap-y-3"> <header className="flex flex-wrap items-end justify-between gap-3">
<div className="flex min-w-0 flex-col gap-1"> <div className="flex min-w-0 flex-col gap-0.5">
<h1 className="text-2xl font-semibold tracking-tight text-balance">{title}</h1> <h1 className="text-xl font-semibold tracking-[-0.01em]">{title}</h1>
<p className="text-muted-foreground max-w-prose text-sm text-pretty">{sub}</p> <p className="text-muted-foreground text-sm">{sub}</p>
</div> </div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null} {actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</header> </header>
@@ -103,13 +104,15 @@ export function SignalAlert({
} }
/** Боковое окно (как CreateUserSheet): заголовок, прокручиваемое тело, подвал. */ /** Боковое окно (как CreateUserSheet): заголовок, прокручиваемое тело, подвал. */
export function PreviewSheet({ export function PortalSheet({
open, open,
onOpenChange, onOpenChange,
title, title,
description, description,
children, children,
footer, footer,
size = 'md',
dirty,
}: { }: {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
@@ -117,8 +120,13 @@ export function PreviewSheet({
description: string description: string
children: ReactNode children: ReactNode
footer?: ReactNode footer?: ReactNode
/** Ширина панели: md — форма, lg — редактор с двумя колонками. */
size?: 'md' | 'lg'
/** Явный признак изменений (иначе ловим ввод в полях и переключатели). */
dirty?: boolean
}) { }) {
const [touched, setTouched] = useState(false) const [touchedState, setTouched] = useState(false)
const touched = dirty ?? touchedState
const [guard, setGuard] = useState(false) const [guard, setGuard] = useState(false)
function requestChange(next: boolean) { function requestChange(next: boolean) {
@@ -136,7 +144,13 @@ export function PreviewSheet({
<SheetContent <SheetContent
side="right" side="right"
showCloseButton={false} showCloseButton={false}
className="inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(26rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none sm:max-w-none" className={cn(
'inset-y-0 right-0 left-auto flex h-svh max-w-none flex-col gap-0 overflow-hidden rounded-none p-0 outline-none sm:max-w-none',
'data-[side=right]:sm:max-w-none',
size === 'lg'
? 'data-[side=right]:w-[min(45rem,100vw)]'
: 'data-[side=right]:w-[min(30rem,100vw)]',
)}
> >
<SheetHeader className="shrink-0 gap-0 p-0"> <SheetHeader className="shrink-0 gap-0 p-0">
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4"> <div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
@@ -284,7 +298,7 @@ export function KvFrame({
items: Array<{ label: string; value: ReactNode }> items: Array<{ label: string; value: ReactNode }>
}) { }) {
return ( return (
<Frame spacing="sm" className="w-full"> <Frame className="[--frame-panel-header-py-adjust:4px] w-full">
<FrameHeader> <FrameHeader>
<FrameTitle>{title}</FrameTitle> <FrameTitle>{title}</FrameTitle>
<FrameDescription>{description}</FrameDescription> <FrameDescription>{description}</FrameDescription>
@@ -317,7 +331,7 @@ export function CopyList({
items: Array<{ label: string; value: string }> items: Array<{ label: string; value: string }>
}) { }) {
return ( return (
<Frame spacing="sm" className="w-full"> <Frame className="[--frame-panel-header-py-adjust:4px] w-full">
<FrameHeader> <FrameHeader>
<FrameTitle>{title}</FrameTitle> <FrameTitle>{title}</FrameTitle>
<FrameDescription>{description}</FrameDescription> <FrameDescription>{description}</FrameDescription>
@@ -329,19 +343,18 @@ export function CopyList({
key={item.label} key={item.label}
className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b px-4 py-2.5 last:border-b-0" className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b px-4 py-2.5 last:border-b-0"
> >
<code className="min-w-0 flex-1 basis-64 font-mono text-[13px] break-all">{item.value}</code> <code className="min-w-0 flex-1 basis-72 font-mono text-[13px] break-all">{item.value}</code>
<span className="text-muted-foreground basis-40 text-xs">{item.label}</span> <span className="text-muted-foreground min-w-0 flex-1 basis-48 text-[13px]">{item.label}</span>
<Button <Button
type="button" type="button"
size="icon-sm" variant="outline"
variant="ghost"
aria-label={`Скопировать: ${item.label}`} aria-label={`Скопировать: ${item.label}`}
onClick={() => { onClick={() => {
void navigator.clipboard?.writeText(item.value).catch(() => undefined) void navigator.clipboard?.writeText(item.value).catch(() => undefined)
toast.success(`${item.label} скопирован`) toast.success(`${item.label} скопирован`)
}} }}
> >
<CopyIcon aria-hidden /> Копировать
</Button> </Button>
</li> </li>
))} ))}
@@ -398,3 +411,71 @@ export function Segmented({
</Tabs> </Tabs>
) )
} }
/** Плашка строки с накопленной правкой: «Не сохранено» или «Будет удалено». */
export function PendingChip({ mark }: { mark: 'new' | 'dirty' | 'deleted' }) {
return (
<Badge variant={mark === 'deleted' ? 'destructive-light' : 'warning-light'} size="sm" className="whitespace-nowrap">
{mark === 'deleted' ? 'Будет удалено' : 'Не сохранено'}
</Badge>
)
}
/** Вкладки страницы как на досках: линия-подчёркивание, число — мелким индексом. */
export function PageTabsList({ children }: { children: ReactNode }) {
return (
<TabsList variant="line" className="h-10! w-full justify-start gap-0.5 overflow-x-auto">
{children}
</TabsList>
)
}
export function PageTab({ value, label, count }: { value: string; label: string; count?: number | string }) {
return (
<TabsTrigger value={value} className="gap-1.5 px-4 text-sm">
{label}
{count !== undefined ? <span className="text-muted-foreground font-mono text-xs font-normal">{count}</span> : null}
</TabsTrigger>
)
}
/** Строка настройки как на досках: слева название и описание, справа элемент управления. */
export function SettingRow({ title, description, children }: { title: string; description: string; children: ReactNode }) {
return (
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b px-4 py-3.5 last:border-b-0">
<div className="flex min-w-0 flex-1 basis-64 flex-col gap-0.5">
<b className="text-sm font-medium">{title}</b>
<span className="text-muted-foreground text-[13px]">{description}</span>
</div>
<div className="shrink-0">{children}</div>
</div>
)
}
/** Frame с заголовком, итоговой плашкой справа и строками настроек (блок «form» досок). */
export function FormFrame({
title,
description,
badge,
tone = 'ok',
children,
}: {
title: string
description: string
badge: string
tone?: Tone
children: ReactNode
}) {
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<FrameTitle>{title}</FrameTitle>
<FrameDescription>{description}</FrameDescription>
</div>
<StatusBadge tone={tone}>{badge}</StatusBadge>
</FrameHeader>
<FramePanel className="p-0">{children}</FramePanel>
</Frame>
)
}
@@ -0,0 +1,142 @@
import type { ReactNode } from 'react'
import { Badge } from '@/components/reui/badge'
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import { cn } from '@authportal/ui/lib/utils'
import type { Tone } from './tone'
import { StatusBadge } from './bits'
export type AppCardItem = {
id: string
title: string
desc: string
badge: string
badgeVariant?: 'info-light' | 'warning-light' | 'outline'
meta: string
icon: ReactNode
onSelect?: () => void
}
/**
* Плитки приложений как на доске «Приложения»: Frame с шапкой, внутри сетка auto-fit от 260px,
* в плитке иконка, название, плашка действия, описание и адрес моноширинным шрифтом.
*/
export function AppCards({
title,
description,
items,
action,
}: {
title: string
description: string
items: AppCardItem[]
action?: ReactNode
}) {
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<FrameTitle>{title}</FrameTitle>
<FrameDescription className="text-pretty">{description}</FrameDescription>
</div>
{action}
</FrameHeader>
<FramePanel className="p-3">
<ul className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,16.25rem),1fr))] gap-3">
{items.map((c) => {
const body = (
<>
<div className="flex items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-2.5">
<IconTile variant="elevated" size="sm" aria-hidden className="text-muted-foreground">
{c.icon}
</IconTile>
<b className="min-w-0 text-sm leading-tight font-semibold">{c.title}</b>
</span>
<Badge variant={c.badgeVariant ?? 'info-light'} size="sm" className="shrink-0">
{c.badge}
</Badge>
</div>
<p className="text-muted-foreground mt-1.5 text-[13px] text-pretty">{c.desc}</p>
<p className="text-muted-foreground mt-2 truncate font-mono text-[11px]">{c.meta}</p>
</>
)
const cls = 'bg-muted/25 w-full rounded-lg border p-3.5 text-left'
return (
<li key={c.id} className="min-w-0">
{c.onSelect ? (
<button
type="button"
onClick={c.onSelect}
aria-label={`${c.title}: ${c.badge}`}
className={cn(cls, 'hover:bg-muted/50 focus-visible:ring-ring transition-colors outline-none focus-visible:ring-2')}
>
{body}
</button>
) : (
<div className={cls}>{body}</div>
)}
</li>
)
})}
</ul>
</FramePanel>
</Frame>
)
}
const DOT: Record<Tone, string> = {
ok: 'bg-success',
wn: 'bg-warning',
er: 'bg-destructive',
mu: 'bg-muted-foreground',
in: 'bg-chart-4',
}
/**
* Лента событий как на доске «Изменения»: время, точка важности с линией, плашка, заголовок
* и строка «кто · источник · IP».
*/
export function ActivityFeed({
title,
description,
items,
action,
}: {
title: string
description: string
items: Array<{ t: string; title: string; text: string; label: string; tone: Tone }>
action?: ReactNode
}) {
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<FrameTitle>{title}</FrameTitle>
<FrameDescription className="text-pretty">{description}</FrameDescription>
</div>
{action}
</FrameHeader>
<FramePanel className="py-1.5">
<ol>
{items.map((e) => (
<li key={`${e.t}-${e.title}`} className="grid grid-cols-[3.9rem_1rem_minmax(0,1fr)] items-start gap-2.5 px-4 py-2 text-[13px]">
<span className="text-muted-foreground pt-px font-mono text-xs tabular-nums">{e.t}</span>
<span aria-hidden className="flex h-full flex-col items-center">
<i className={cn('mt-1 size-[9px] rounded-full', DOT[e.tone])} />
<u className="bg-border mt-0.5 min-h-4.5 w-px flex-1 no-underline" />
</span>
<span className="min-w-0">
<span className="mr-2 inline-block align-middle">
<StatusBadge tone={e.tone}>{e.label}</StatusBadge>
</span>
<b className="font-medium">{e.title}</b>
<span className="text-muted-foreground mt-0.5 block text-xs">{e.text}</span>
</span>
</li>
))}
</ol>
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,83 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { ChromeConfigContext, UiStoreContext, type ChromeConfig, type Contrast, type Density, type UiStore } from './chrome-store'
export function ChromeConfigProvider({ value, children }: { value: ChromeConfig; children: ReactNode }) {
return <ChromeConfigContext.Provider value={value}>{children}</ChromeConfigContext.Provider>
}
function readStored<T>(key: string, fallback: T): T {
try {
const raw = window.localStorage.getItem(key)
return raw ? (JSON.parse(raw) as T) : fallback
} catch {
return fallback
}
}
function writeStored(key: string, value: unknown) {
try {
window.localStorage.setItem(key, JSON.stringify(value))
} catch {
/* без сохранения настроек */
}
}
/** Плотность, контраст, закреплённые пункты и палитра ⌘K. Атрибуты html[data-density|contrast] читает portal-theme.css. */
export function PortalUiProvider({ children, storageKey = 'portal-ui' }: { children: ReactNode; storageKey?: string }) {
const [paletteOpen, setPaletteOpen] = useState(false)
const [density, setDensityState] = useState<Density>(() => readStored(`${storageKey}:density`, 'comfortable'))
const [contrast, setContrastState] = useState<Contrast>(() => readStored(`${storageKey}:contrast`, 'standard'))
const [pins, setPins] = useState<string[]>(() => readStored(`${storageKey}:pins`, []))
const setDensity = useCallback(
(v: Density) => {
setDensityState(v)
writeStored(`${storageKey}:density`, v)
},
[storageKey],
)
const setContrast = useCallback(
(v: Contrast) => {
setContrastState(v)
writeStored(`${storageKey}:contrast`, v)
},
[storageKey],
)
const togglePin = useCallback(
(id: string) => {
setPins((prev) => {
const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
writeStored(`${storageKey}:pins`, next)
return next
})
},
[storageKey],
)
useEffect(() => {
const root = document.documentElement
root.dataset.density = density
root.dataset.contrast = contrast
return () => {
delete root.dataset.density
delete root.dataset.contrast
}
}, [density, contrast])
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
setPaletteOpen((v) => !v)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
const value = useMemo<UiStore>(
() => ({ paletteOpen, setPaletteOpen, pins, togglePin, density, setDensity, contrast, setContrast }),
[paletteOpen, pins, togglePin, density, setDensity, contrast, setContrast],
)
return <UiStoreContext.Provider value={value}>{children}</UiStoreContext.Provider>
}
@@ -0,0 +1,86 @@
import { createContext, useContext, type ComponentType, type ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
export type NavItem = {
id: string
label: string
to: string
icon: LucideIcon
group: string
/** Точка проблемы в углу иконки. */
problem?: { tone: 'er' | 'wn'; label: string }
}
export type MonitorItem = {
id: string
tone: 'er' | 'wn' | 'ok'
title: string
text: string
age: string
to: string
}
export type MonitorMetric = { l: string; v: string; p: number; tone: 'warning' | 'success' }
export type PaletteAction = {
id: string
label: string
/** Горячая буква справа. */
hint: string
icon: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
to: string
}
export type PaletteObject = {
id: string
group: string
label: string
hint: string
dot?: 'ok' | 'er' | 'mu'
to: string
}
/** Данные оболочки: меню, монитор, палитра и пользователь. В макете — суррогатные, в портале — из API. */
export type ChromeConfig = {
navItems: NavItem[]
navGroups: string[]
/** Верх бокового меню: бренд и переключатель приложений. */
brand: ReactNode
user: { name: string; email: string; initials: string }
securityTo: string
onLogout: () => void
fresh: (pathname: string) => { badge: string; text: string }
crumbFor?: (pathname: string, label: string | undefined) => string
monitor: { items: MonitorItem[]; metrics: MonitorMetric[]; updated: string; footerTo: string; footerLabel: string }
palette: { actions: PaletteAction[]; objects: PaletteObject[]; assistantTo: string; example: string }
}
export const ChromeConfigContext = createContext<ChromeConfig | null>(null)
export function useChromeConfig(): ChromeConfig {
const ctx = useContext(ChromeConfigContext)
if (!ctx) throw new Error('ChromeConfigProvider отсутствует')
return ctx
}
export type Density = 'comfortable' | 'compact'
export type Contrast = 'standard' | 'high'
export type UiStore = {
paletteOpen: boolean
setPaletteOpen: (open: boolean) => void
pins: string[]
togglePin: (id: string) => void
density: Density
setDensity: (v: Density) => void
contrast: Contrast
setContrast: (v: Contrast) => void
}
export const UiStoreContext = createContext<UiStore | null>(null)
export function useUiStore(): UiStore {
const ctx = useContext(UiStoreContext)
if (!ctx) throw new Error('PortalUiProvider отсутствует')
return ctx
}
+569
View File
@@ -0,0 +1,569 @@
import {
useMemo,
useState,
type ComponentType,
} from 'react'
import {
Link,
useNavigate,
useRouterState,
} from '@tanstack/react-router'
import {
ActivityIcon,
ArrowRightIcon,
ChevronDownIcon,
ChevronsUpDownIcon,
FingerprintIcon,
InfoIcon,
LogOutIcon,
SearchIcon,
StarIcon,
MoonIcon,
} from 'lucide-react'
import {
useTheme,
} from 'next-themes'
import {
Badge,
} from '@/components/reui/badge'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
} from '@authportal/ui/components/breadcrumb'
import {
Button,
} from '@authportal/ui/components/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@authportal/ui/components/dialog'
import {
Kbd,
} from '@authportal/ui/components/kbd'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@authportal/ui/components/popover'
import {
Separator,
} from '@authportal/ui/components/separator'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarSeparator,
SidebarTrigger,
useSidebar,
} from '@authportal/ui/components/sidebar'
import {
cn,
} from '@authportal/ui/lib/utils'
import {
Segmented,
} from './bits'
import {
useChromeConfig,
useUiStore,
type NavItem,
} from './chrome-store'
const LABEL_CLASS = 'text-muted-foreground text-[11px] font-medium uppercase'
function PortalNavUser() {
const { isMobile } = useSidebar()
const { theme, setTheme, resolvedTheme } = useTheme()
const { density, setDensity, contrast, setContrast } = useUiStore()
const { user, securityTo, onLogout } = useChromeConfig()
const navigate = useNavigate()
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
return (
<SidebarMenu>
<SidebarMenuItem>
<Popover>
<PopoverTrigger
render={
<SidebarMenuButton
size="lg"
aria-label={`Профиль и настройки интерфейса: ${user.name}, ${user.email}`}
className="bg-muted/40 aria-expanded:bg-muted"
/>
}
>
<div className="bg-background text-foreground flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
{user.initials}
</div>
<div className="grid min-w-0 flex-1 text-left leading-tight">
<span className="truncate text-sm font-semibold">{user.name}</span>
<span className="text-muted-foreground truncate text-xs">{user.email}</span>
</div>
<ChevronsUpDownIcon className="text-muted-foreground ml-auto size-4" aria-hidden />
</PopoverTrigger>
<PopoverContent side={isMobile ? 'top' : 'right'} align="end" sideOffset={8} className="w-72 gap-3">
<div className="flex items-center gap-2.5">
<div className="bg-background flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
{user.initials}
</div>
<div className="grid leading-tight">
<span className="text-sm font-semibold">{user.name}</span>
<span className="text-muted-foreground text-xs">{user.email}</span>
</div>
</div>
<Separator />
<button
type="button"
onClick={() => void navigate({ to: securityTo })}
className="hover:bg-muted flex items-center gap-2 rounded-md px-1 py-1 text-left text-sm"
>
<FingerprintIcon className="text-muted-foreground size-4" aria-hidden />
Безопасность
</button>
<div className="flex flex-col gap-1.5">
<span className={LABEL_CLASS}>Тема</span>
<Segmented
label="Тема"
options={['Тёмная', 'Светлая']}
value={current === 'light' ? 1 : 0}
onChange={(i) => setTheme(i === 1 ? 'light' : 'dark')}
/>
<span className={LABEL_CLASS}>Плотность</span>
<Segmented
label="Плотность"
options={['Обычная', 'Компактная']}
value={density === 'compact' ? 1 : 0}
onChange={(i) => setDensity(i === 1 ? 'compact' : 'comfortable')}
/>
<span className={LABEL_CLASS}>Контраст</span>
<Segmented
label="Контраст"
options={['Стандартный', 'Высокий']}
value={contrast === 'high' ? 1 : 0}
onChange={(i) => setContrast(i === 1 ? 'high' : 'standard')}
/>
</div>
<Separator />
<button
type="button"
onClick={onLogout}
className="text-muted-foreground hover:text-foreground flex items-center gap-2 text-left text-sm"
>
<LogOutIcon className="size-4" aria-hidden />
Выйти
</button>
</PopoverContent>
</Popover>
</SidebarMenuItem>
</SidebarMenu>
)
}
const DOT_CLASS = { er: 'bg-destructive', wn: 'bg-warning' } as const
function NavEntry({ item, pathname, pinned, onTogglePin }: { item: NavItem; pathname: string; pinned: boolean; onTogglePin: () => void }) {
const Icon = item.icon
const active = pathname === item.to || pathname.startsWith(`${item.to}/`)
return (
<SidebarMenuItem>
<SidebarMenuButton
tooltip={item.problem ? `${item.label} · ${item.problem.label}` : item.label}
isActive={active}
className="relative pr-8 group-data-[collapsible=icon]:pr-2!"
render={<Link to={item.to} />}
>
<Icon className="size-4" />
<span>{item.label}</span>
{item.problem ? (
<span
role="img"
aria-label={item.problem.label}
className={cn(
'ml-auto size-2 shrink-0 rounded-full group-data-[collapsible=icon]:absolute group-data-[collapsible=icon]:top-1 group-data-[collapsible=icon]:right-1 group-data-[collapsible=icon]:ml-0 group-data-[collapsible=icon]:size-1.5 group-data-[collapsible=icon]:ring-2 group-data-[collapsible=icon]:ring-sidebar',
DOT_CLASS[item.problem.tone],
)}
/>
) : null}
</SidebarMenuButton>
<SidebarMenuAction
showOnHover={!pinned}
aria-label={pinned ? `Открепить ${item.label}` : `Закрепить ${item.label}`}
aria-pressed={pinned}
onClick={onTogglePin}
>
<StarIcon className={cn(pinned && 'fill-current')} aria-hidden />
</SidebarMenuAction>
</SidebarMenuItem>
)
}
export function PortalSidebar() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const { pins, togglePin } = useUiStore()
const { navItems, navGroups, brand } = useChromeConfig()
const [closed, setClosed] = useState<Record<string, boolean>>({})
const pinned = navItems.filter((i) => pins.includes(i.id))
return (
<Sidebar collapsible="icon">
<SidebarHeader className="pb-1.5">
{brand}
</SidebarHeader>
<SidebarContent>
{pinned.length > 0 ? (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel className="text-[11px] font-semibold tracking-wide uppercase">Закреплённые</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{pinned.map((item) => (
<NavEntry key={item.id} item={item} pathname={pathname} pinned onTogglePin={() => togglePin(item.id)} />
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
) : null}
{navGroups.map((group, gi) => (
<div key={group} className="flex flex-col">
{gi > 0 ? <SidebarSeparator className="mx-2 my-1 h-px shrink-0 hidden group-data-[collapsible=icon]:block" /> : null}
<SidebarGroup className="py-0">
<SidebarGroupLabel
render={
<button
type="button"
aria-expanded={!closed[group]}
onClick={() => setClosed((c) => ({ ...c, [group]: !c[group] }))}
/>
}
className="h-7 cursor-pointer justify-between text-[11px] font-semibold tracking-wide uppercase"
>
{group}
<ChevronDownIcon className={cn('size-3.5 transition-transform', closed[group] && '-rotate-90')} aria-hidden />
</SidebarGroupLabel>
<SidebarGroupContent className={cn(closed[group] && 'group-data-[collapsible=icon]:block hidden')}>
<SidebarMenu className="gap-px">
{navItems.filter((i) => i.group === group).map((item) => (
<NavEntry
key={item.id}
item={item}
pathname={pathname}
pinned={pins.includes(item.id)}
onTogglePin={() => togglePin(item.id)}
/>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</div>
))}
</SidebarContent>
<SidebarFooter>
<PortalNavUser />
</SidebarFooter>
</Sidebar>
)
}
/** Единый центр состояния: внимание + сводка + список проблем. Адаптивен по ширине окна, как на досках. */
function MonitorPopover() {
const { monitor } = useChromeConfig()
const MONITOR_ITEMS = monitor.items
const MONITOR_METRICS = monitor.metrics
const bad = MONITOR_ITEMS.filter((m) => m.tone !== 'ok')
const errors = bad.filter((m) => m.tone === 'er').length
const warnings = bad.length - errors
const ok = MONITOR_ITEMS.length - bad.length
return (
<Popover>
<PopoverTrigger
render={
<Button
variant="outline"
type="button"
aria-label={`Состояние системы: внимание, ${errors} сбой, ${warnings} предупреждения`}
className="border-warning/40 bg-warning/10 hover:bg-warning/20 aria-expanded:bg-warning/20 relative h-8 gap-2 px-2 max-[760px]:w-8 max-[760px]:border-transparent max-[760px]:bg-transparent max-[760px]:px-0"
/>
}
>
<ActivityIcon className="text-warning size-4" aria-hidden />
<span className="text-warning hidden text-sm font-medium min-[1000px]:inline">Внимание</span>
<span className="hidden items-center gap-1.5 text-sm tabular-nums min-[1240px]:inline-flex">
<span className="bg-destructive size-1.5 rounded-full" aria-hidden />
{errors}
<span className="bg-warning size-1.5 rounded-full" aria-hidden />
{warnings}
</span>
<span className="bg-destructive hidden size-4.5 min-w-4.5 items-center justify-center rounded-full px-1 text-[11px] font-semibold text-white min-[760px]:inline-flex min-[1240px]:hidden">
{bad.length}
</span>
<span className="bg-destructive ring-background absolute top-1 right-1 size-2 rounded-full ring-2 min-[760px]:hidden" aria-hidden />
</PopoverTrigger>
<PopoverContent align="end" className="flex w-[24rem] max-w-[calc(100vw-1.5rem)] flex-col gap-2 p-2">
<div className="flex items-start justify-between gap-2 px-1.5 pt-1.5">
<div className="grid leading-tight">
<b className="text-sm font-semibold">Состояние системы</b>
<span className="text-muted-foreground text-xs">{monitor.updated}</span>
</div>
<Badge variant={errors > 0 ? "destructive-light" : warnings > 0 ? "warning-light" : "success-light"} size="sm">{errors > 0 ? "Сбой" : warnings > 0 ? "Внимание" : "Норма"}</Badge>
</div>
<div className="grid grid-cols-3 gap-1.5">
{[
{ n: errors, l: 'сбой', c: 'text-destructive' },
{ n: warnings, l: 'предупреждения', c: 'text-warning' },
{ n: ok, l: 'в норме', c: 'text-success' },
].map((x) => (
<span key={x.l} className="bg-muted/55 text-muted-foreground flex flex-col rounded-lg px-2.5 py-2 text-[11.5px]">
<b className={cn('text-lg leading-tight font-semibold tabular-nums', x.c)}>{x.n}</b>
{x.l}
</span>
))}
</div>
<ul className="flex flex-col gap-0.5">
{MONITOR_ITEMS.filter((m) => m.tone !== 'ok').map((m) => (
<li key={m.id}>
<Link to={m.to} className="hover:bg-muted/55 flex items-center gap-2.5 rounded-md px-1.5 py-2 outline-none focus-visible:bg-muted/55">
<span aria-hidden className={cn('size-2 shrink-0 rounded-full', m.tone === 'er' ? 'bg-destructive' : 'bg-warning')} />
<span className="grid min-w-0 flex-1 leading-tight">
<b className="truncate text-[13px] font-medium">{m.title}</b>
<span className="text-muted-foreground truncate text-xs">{m.text}</span>
</span>
<span className="text-muted-foreground shrink-0 text-[11.5px]">{m.age}</span>
</Link>
</li>
))}
</ul>
<div className="grid grid-cols-2 gap-1.5">
{MONITOR_METRICS.map((m) => (
<div key={m.l} className="bg-muted/55 flex min-w-0 flex-col gap-1.5 rounded-lg px-3 py-2.5">
<span className="text-muted-foreground text-xs leading-snug">{m.l}</span>
<b className={cn('text-base font-semibold whitespace-nowrap tabular-nums', m.tone === 'success' ? 'text-success' : 'text-warning')}>{m.v}</b>
<div className="bg-muted-foreground/25 mt-auto h-[3px] overflow-hidden rounded-full">
<i className={cn('block h-full rounded-full', m.tone === 'success' ? 'bg-success' : 'bg-warning')} style={{ width: `${m.p}%` }} />
</div>
</div>
))}
</div>
<Link to={monitor.footerTo} className="text-muted-foreground hover:text-foreground flex items-center justify-between px-1.5 pt-1 pb-0.5 text-sm">
{monitor.footerLabel}
<ArrowRightIcon className="size-3.5" aria-hidden />
</Link>
</PopoverContent>
</Popover>
)
}
function FreshnessPopover() {
const pathname = useRouterState({ select: (st) => st.location.pathname })
const { fresh: freshFor } = useChromeConfig()
const fresh = freshFor(pathname)
return (
<Popover>
<PopoverTrigger
render={
<Button type="button" variant="ghost" size="icon" aria-label="Источники и свежесть данных" title="Источники и свежесть данных" />
}
>
<InfoIcon aria-hidden />
</PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2">
<Badge variant="success-light" size="sm" className="w-fit">{fresh.badge}</Badge>
<p className="text-muted-foreground text-sm">{fresh.text}</p>
</PopoverContent>
</Popover>
)
}
export function PortalHeader() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const { setPaletteOpen } = useUiStore()
const { navItems, crumbFor } = useChromeConfig()
const current = navItems.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`))
return (
<header className="bg-background/80 sticky top-0 z-20 flex min-h-12 shrink-0 items-center gap-2 border-b px-4 py-1.5 backdrop-blur md:px-6">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb className="min-w-0">
<BreadcrumbList className="flex-nowrap">
<BreadcrumbItem className="min-w-0">
<BreadcrumbPage className="truncate">{crumbFor ? crumbFor(pathname, current?.label) : (current?.label ?? 'Auth Portal')}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex shrink-0 items-center gap-2">
<FreshnessPopover />
<Button
type="button"
variant="outline"
size="icon"
aria-label="Командная палитра (⌘K)"
className="md:hidden"
onClick={() => setPaletteOpen(true)}
>
<SearchIcon aria-hidden />
</Button>
<MonitorPopover />
</div>
</header>
)
}
type PaletteEntry = {
id: string
group: string
label: string
hint: string
icon?: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
dot?: 'ok' | 'er' | 'mu'
run: () => void
}
const DOT_BG = { ok: 'bg-success', er: 'bg-destructive', mu: 'bg-muted-foreground' } as const
/** Палитра ⌘K как на доске: 640px, группы заглавными, подсказки моноширинным справа, подвал с подсказкой. */
export function CommandPalette() {
const { paletteOpen, setPaletteOpen } = useUiStore()
const { navItems, palette } = useChromeConfig()
const navigate = useNavigate()
const { theme, setTheme, resolvedTheme } = useTheme()
const [query, setQuery] = useState('')
const [active, setActive] = useState(0)
const shown = useMemo(() => {
const q = query.trim().toLowerCase()
const match = (...t: string[]) => !q || t.some((x) => x.toLowerCase().includes(q))
const go = (to: string) => () => void navigate({ to })
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
const list: PaletteEntry[] = [
...[
...palette.actions.map((x) => ({ id: x.id, label: x.label, hint: x.hint, icon: x.icon, run: go(x.to) })),
{ id: 'act-theme', label: 'Переключить тему', hint: 'T', icon: MoonIcon, run: () => setTheme(current === 'dark' ? 'light' : 'dark') },
]
.filter((e) => match(e.label))
.map((e) => ({ ...e, group: 'Действия' })),
...navItems.filter((i) => match(i.label, i.group))
.slice(0, 7)
.map((i) => ({ id: `nav-${i.id}`, group: 'Разделы', label: i.label, hint: i.group, icon: i.icon, run: go(i.to) })),
...Object.values(
palette.objects
.filter((o) => match(o.label, o.hint))
.reduce<Record<string, typeof palette.objects>>((acc, o) => ((acc[o.group] ??= []).push(o), acc), {}),
).flatMap((g) => g.slice(0, 5).map((o) => ({ id: o.id, group: o.group, label: o.label, hint: o.hint, dot: o.dot, run: go(o.to) }))),
]
// Запрос из трёх и более слов — черновик ассистента (как на доске «Палитра ⌘K»).
if (query.trim().split(/\s+/).filter(Boolean).length >= 3) {
return [
{
id: 'ai',
group: 'Ассистент · запрос на естественном языке',
label: `Понял так: «${query.trim()}»`,
hint: 'Черновик: запрос разобран автоматически, результат нужно проверить.',
run: () => void navigate({ to: palette.assistantTo }),
} as PaletteEntry,
...list,
]
}
return list
}, [query, navigate, theme, resolvedTheme, setTheme, navItems, palette])
const groups = useMemo(() => {
const map = new Map<string, Array<PaletteEntry & { index: number }>>()
shown.forEach((e, index) => {
const list = map.get(e.group) ?? []
list.push({ ...e, index })
map.set(e.group, list)
})
return [...map.entries()]
}, [shown])
function close() {
setPaletteOpen(false)
setQuery('')
setActive(0)
}
return (
<Dialog open={paletteOpen} onOpenChange={(o) => (o ? setPaletteOpen(true) : close())}>
<DialogContent className="top-[90px] w-[min(40rem,94vw)] max-w-none translate-y-0 gap-0 overflow-hidden p-0 sm:max-w-none" showCloseButton={false}>
<DialogTitle className="sr-only">Командная палитра</DialogTitle>
<DialogDescription className="sr-only">Разделы, пользователи, приложения и действия</DialogDescription>
<div className="flex items-center gap-2.5 border-b px-4 py-3.5">
<SearchIcon className="text-muted-foreground size-[18px]" aria-hidden />
<input
autoFocus
value={query}
aria-label="Поиск и команды"
placeholder="Раздел, пользователь, приложение или действие…"
className="min-w-0 flex-1 bg-transparent text-[15px] outline-none"
onChange={(e) => {
setQuery(e.target.value)
setActive(0)
}}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
setActive((a) => Math.min(a + 1, shown.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActive((a) => Math.max(a - 1, 0))
} else if (e.key === 'Enter') {
e.preventDefault()
shown[active]?.run()
close()
}
}}
/>
<Kbd>Esc</Kbd>
</div>
<div className="max-h-[430px] overflow-y-auto" role="listbox" aria-label="Результаты">
{groups.length === 0 ? (
<p className="text-muted-foreground px-4 py-6 text-center text-sm">Ничего не найдено</p>
) : (
groups.map(([group, list]) => (
<div key={group} className="flex flex-col">
<div className="text-muted-foreground px-4 pt-2.5 pb-1 text-[11px] tracking-[0.05em] uppercase">{group}</div>
{list.map((e) => {
const Icon = e.icon
return (
<button
key={e.id}
type="button"
role="option"
aria-selected={e.index === active}
onMouseEnter={() => setActive(e.index)}
onClick={() => {
e.run()
close()
}}
className={cn('flex w-full items-center gap-3 px-4 py-[9px] text-left text-sm outline-none', e.index === active && 'bg-muted/60')}
>
{Icon ? <Icon className="size-4 shrink-0" aria-hidden /> : null}
{e.dot ? <span className={cn('size-2 shrink-0 rounded-full', DOT_BG[e.dot])} aria-hidden /> : null}
<span className="min-w-0 truncate">{e.label}</span>
<span className="text-muted-foreground ml-auto truncate font-mono text-xs">{e.hint}</span>
</button>
)
})}
</div>
))
)}
</div>
<div className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 border-t px-4 py-2 text-[11.5px]">
<span>↑↓ выбор</span>
<span>↵ открыть</span>
<span>Esc закрыть</span>
<span className="ml-auto">{palette.example}</span>
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,26 @@
import type { DataGridFeatures, DataGridProps } from '@/components/reui/data-grid/data-grid'
export const DATA_GRID_CELL_PAD = 'py-3'
export const DATA_GRID_CELL_PAD_FIRST = 'pl-5 py-3'
export const DATA_GRID_CELL_PAD_LAST = 'pr-4 py-3'
export const DATA_GRID_LAYOUT: NonNullable<DataGridProps<DataGridFeatures, object>['tableLayout']> = {
rowBorder: true,
headerBackground: true,
headerBorder: true,
columnsResizable: false,
}
export const DATA_GRID_CLASS_NAMES: NonNullable<DataGridProps<DataGridFeatures, object>['tableClassNames']> = {
headerRow: 'border-b border-border',
bodyRow: 'group/row',
}
export const DATA_GRID_CONTAINER_CLASS = 'w-full rounded-none border-0'
/** Отступы ячейки по положению колонки: первая — pl-5, последняя — pr-4. */
export function cellPad(index: number, count: number) {
if (index === 0) return DATA_GRID_CELL_PAD_FIRST
if (index === count - 1) return DATA_GRID_CELL_PAD_LAST
return DATA_GRID_CELL_PAD
}
@@ -0,0 +1,123 @@
/**
* Каркас списочной страницы как в MikrotikManager (/servers): карточка Frame без полей, тулбар
* «сегменты · фильтры · поиск · счётчик», таблица с подложкой шапки и ячейками py-3.
*/
import type { ReactNode } from 'react'
import type { Column } from '@tanstack/react-table'
import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, SearchIcon } from 'lucide-react'
import { Frame, FramePanel } from '@/components/reui/frame'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@authportal/ui/components/input-group'
import { cn } from '@authportal/ui/lib/utils'
import type { DataGridFeatures } from '@/components/reui/data-grid/data-grid'
export function DataPageCard({ children, className }: { children: ReactNode; className?: string }) {
return (
<Frame dense className={cn('w-full min-w-0', className)}>
<FramePanel className="flex w-full min-w-0 flex-col gap-0 overflow-hidden p-0 py-0 shadow-none">{children}</FramePanel>
</Frame>
)
}
export function SegmentedControl<T extends string>({
value,
onChange,
options,
label,
}: {
value: T
onChange: (value: T) => void
options: { value: T; label: string; count?: number }[]
label?: string
}) {
return (
<div role="group" aria-label={label} className="border-border bg-muted/40 flex w-fit items-center gap-1 rounded-md border p-0.5">
{options.map((o) => (
<button
key={o.value}
type="button"
aria-pressed={value === o.value}
onClick={() => onChange(o.value)}
className={cn(
'flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors',
value === o.value ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
)}
>
{o.label}
{o.count != null ? <span className="text-xs tabular-nums">{o.count}</span> : null}
</button>
))}
</div>
)
}
export function DataPageToolbar({
leading,
segmented,
filterControl,
search,
onSearchChange,
searchPlaceholder = 'Поиск…',
countLabel,
actions,
}: {
leading?: ReactNode
segmented?: { value: string; onChange: (value: string) => void; options: { value: string; label: string; count?: number }[]; label?: string }
filterControl?: ReactNode
search?: string
onSearchChange?: (value: string) => void
searchPlaceholder?: string
countLabel?: string
actions?: ReactNode
}) {
return (
<div className="flex flex-wrap items-center gap-3 border-b px-5 py-3">
{leading}
{segmented ? <SegmentedControl {...segmented} /> : null}
{filterControl}
{onSearchChange ? (
<InputGroup className="max-w-sm min-w-[220px]">
<InputGroupAddon>
<SearchIcon className="size-3.5" aria-hidden />
</InputGroupAddon>
<InputGroupInput aria-label={searchPlaceholder} placeholder={searchPlaceholder} value={search ?? ''} onChange={(e) => onSearchChange(e.target.value)} />
</InputGroup>
) : null}
{countLabel ? <span className="text-muted-foreground ml-auto text-sm">{countLabel}</span> : null}
{actions}
</div>
)
}
export function DataGridSortHeader<TData extends object>({
column,
title,
className,
}: {
column: Column<DataGridFeatures, TData, unknown>
title: string
className?: string
}) {
const sorted = column.getIsSorted()
if (!column.getCanSort()) {
return <span className={cn('text-muted-foreground text-xs font-medium', className)}>{title}</span>
}
return (
<button
type="button"
className={cn(
'text-muted-foreground hover:text-foreground focus-visible:outline-ring -ml-1 inline-flex items-center gap-1.5 rounded-md px-1 py-0.5 text-xs font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
className,
)}
onClick={column.getToggleSortingHandler()}
>
{title}
{sorted === 'asc' ? (
<ArrowUpIcon aria-hidden className="text-foreground size-3" />
) : sorted === 'desc' ? (
<ArrowDownIcon aria-hidden className="text-foreground size-3" />
) : (
<ArrowUpDownIcon aria-hidden className="size-3 opacity-35" />
)}
</button>
)
}
@@ -0,0 +1,88 @@
import { useState, type ReactNode } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { Button } from '@authportal/ui/components/button'
import { cn } from '@authportal/ui/lib/utils'
import { CodeBlock, PortalSheet, SignalAlert, StatusBadge } from './bits'
/**
* Баннер доски: заголовок и пояснение, справа «Объяснить» — открывает панель
* «Ассистент · разбор» (черновик с причинами и тем, что проверить).
*/
export function ExplainBanner({
tone,
title,
children,
text,
}: {
tone: 'er' | 'wn'
title: string
/** Текст для панели разбора (то же, что в баннере). */
text: string
children: ReactNode
}) {
const [open, setOpen] = useState(false)
const navigate = useNavigate()
return (
<>
<div
role="status"
className={cn(
'flex flex-wrap items-start gap-3 rounded-lg border px-3 py-2.5 text-sm',
tone === 'er' ? 'border-destructive/30 bg-destructive/10' : 'border-warning/30 bg-warning/10',
)}
>
<div className="min-w-0 flex-1 basis-80 text-pretty">
<b className="font-semibold">{title}</b> {children}
</div>
<Button type="button" variant="outline" onClick={() => setOpen(true)}>
Объяснить
</Button>
</div>
<PortalSheet
open={open}
onOpenChange={setOpen}
title="Ассистент · разбор"
description="Черновик: проверьте перед применением"
footer={
<>
<Button variant="outline" onClick={() => setOpen(false)}>
Закрыть
</Button>
<Button
onClick={() => {
setOpen(false)
void navigate({
// В прод-образе preview-маршруты вырезаны (см. Dockerfile) — ведём в реальный журнал.
to: window.location.pathname.startsWith('/design-preview')
? '/design-preview/logins'
: '/admin/logins',
})
}}
>
Открыть журнал входов
</Button>
</>
}
>
<SignalAlert tone="in" title="Что случилось">
{title} {text}
</SignalAlert>
<div className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs">Вероятные причины</span>
<div className="flex flex-wrap gap-1.5">
<StatusBadge tone="er">Подбор пароля</StatusBadge>
<StatusBadge tone="wn">Устаревший passkey</StatusBadge>
<StatusBadge tone="mu">Смена IP</StatusBadge>
</div>
<span className="text-muted-foreground text-xs">Отсортированы по вероятности по журналу входов за 24 часа.</span>
</div>
<CodeBlock label="Что проверить">
{'GET /api/v1/admin/audit?kind=logins&action=auth.login_failed&range=24h\nGET /api/v1/admin/[email protected]'}
</CodeBlock>
<SignalAlert tone="wn" title="Ассистент ничего не меняет сам">
Блокировка и сброс passkeys выполняются вручную с подтверждением и попадают в журнал «Изменения».
</SignalAlert>
</PortalSheet>
</>
)
}
+45
View File
@@ -0,0 +1,45 @@
const DATE = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' })
const SHORT = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit' })
const TIME = new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' })
function parse(iso: string | null | undefined): Date | null {
if (!iso) return null
const d = new Date(iso)
return Number.isNaN(d.getTime()) ? null : d
}
/** 12.09.2026 */
export function fmtDate(iso: string | null | undefined, empty = '—') {
const d = parse(iso)
return d ? DATE.format(d) : empty
}
/** «сегодня 09:41», «вчера 22:15», иначе «12.09 09:41». */
export function fmtWhen(iso: string | null | undefined, empty = '—', now = Date.now()) {
const d = parse(iso)
if (!d) return empty
const dayMs = 24 * 60 * 60 * 1000
const startOfToday = new Date(now)
startOfToday.setHours(0, 0, 0, 0)
const diff = startOfToday.getTime() - new Date(d).setHours(0, 0, 0, 0)
if (diff === 0) return `сегодня ${TIME.format(d)}`
if (diff === dayMs) return `вчера ${TIME.format(d)}`
return `${SHORT.format(d)} ${TIME.format(d)}`
}
/** Время дня 14:02. */
export function fmtTime(iso: string | null | undefined, empty = '—') {
const d = parse(iso)
return d ? TIME.format(d) : empty
}
/** «5 дн назад», «3 ч назад», «только что». */
export function fmtAgo(iso: string | null | undefined, empty = '—', now = Date.now()) {
const d = parse(iso)
if (!d) return empty
const sec = Math.max(0, Math.round((now - d.getTime()) / 1000))
if (sec < 60) return 'только что'
if (sec < 3600) return `${Math.floor(sec / 60)} мин назад`
if (sec < 86400) return `${Math.floor(sec / 3600)} ч назад`
return `${Math.floor(sec / 86400)} дн назад`
}
@@ -0,0 +1,74 @@
import { Badge } from '@/components/reui/badge'
import { IconTile } from '@/components/reui/icon-tile'
import { Frame, FramePanel } from '@/components/reui/frame'
import { cn } from '@authportal/ui/lib/utils'
import type { KpiItem, KpiTone } from './kpi'
const VALUE: Record<KpiTone, string> = {
neutral: 'text-foreground',
ok: 'text-foreground',
in: 'text-foreground',
wn: 'text-warning',
er: 'text-destructive',
}
/** Колонки по числу ячеек и ширине окна (на досках — ширина приложения): 4 → 4/2/1, 3 → 3/1, 2 → 2/1. */
const COLS: Record<number, string> = {
1: 'grid-cols-1',
2: 'grid-cols-2 max-[640px]:grid-cols-1',
3: 'grid-cols-1 min-[1240px]:grid-cols-3',
4: 'grid-cols-1 min-[640px]:grid-cols-2 min-[1240px]:grid-cols-4',
}
const TILE: Record<KpiTone, string> = {
neutral: 'text-muted-foreground',
ok: 'text-success',
in: 'text-info',
wn: 'text-warning',
er: 'text-destructive',
}
const GLYPH: Record<KpiTone, string> = { neutral: '', ok: '●', in: '', wn: '▲', er: '✕' }
const BADGE = {
neutral: 'outline',
ok: 'success-light',
in: 'info-light',
wn: 'warning-light',
er: 'destructive-light',
} as const
/**
* Полоса показателей как на досках: один Frame, ячейки разделены линиями,
* в ячейке подпись, крупное значение и плашка-подсказка.
*/
export function KpiStrip({ items, 'aria-label': ariaLabel }: { items: KpiItem[]; 'aria-label'?: string }) {
return (
<Frame spacing="sm" className="w-full min-w-0" aria-label={ariaLabel}>
<FramePanel className="overflow-hidden p-0">
<div className={cn('bg-border grid gap-px', COLS[Math.min(Math.max(items.length, 1), 4)])}>
{items.map((k) => {
const Icon = k.icon
return (
<div key={k.id} className="bg-card flex min-w-0 items-center gap-3 px-[18px] py-3.5">
<IconTile variant="outline" aria-hidden className={cn('shrink-0', TILE[k.tone])}>
<Icon />
</IconTile>
<div className="flex min-w-0 flex-col gap-1">
<div className="text-muted-foreground text-[13px]">{k.label}</div>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span className={cn('text-2xl font-semibold tracking-tight tabular-nums', VALUE[k.tone])}>{k.value}</span>
<Badge variant={BADGE[k.tone]} size="sm" className="whitespace-nowrap">
{GLYPH[k.tone] ? <span aria-hidden className="text-[0.55rem]">{GLYPH[k.tone]}</span> : null}
{k.hint}
</Badge>
</div>
</div>
</div>
)
})}
</div>
</FramePanel>
</Frame>
)
}
+24
View File
@@ -0,0 +1,24 @@
import type { LucideIcon } from 'lucide-react'
export type KpiTone = 'neutral' | 'ok' | 'wn' | 'er' | 'in'
export type KpiItem = {
id: string
label: string
value: string
/** Плашка-подсказка рядом со значением. */
hint: string
tone: KpiTone
/** Иконка показателя: плитка слева от подписи. */
icon: LucideIcon
}
/** Плитка KPI: иконка, подпись, значение (цветом при wn/er) и плашка-подсказка. */
export function kpi(
id: string,
label: string,
value: string,
opts: { icon: LucideIcon; tone?: KpiTone; badge: string },
): KpiItem {
return { id, label, value, hint: opts.badge, tone: opts.tone ?? 'neutral', icon: opts.icon }
}
@@ -0,0 +1,488 @@
import { useMemo, useState, type ReactNode } from 'react'
import { Columns3Icon, MoreHorizontalIcon } from 'lucide-react'
import { toast } from 'sonner'
import { useTable, type ColumnDef, type RowSelectionState, type SortingState } from '@tanstack/react-table'
import { DATA_GRID_RU } from '@/lib/reui-ru'
import { DataGrid, dataGridFeatures, type DataGridFeatures } from '@/components/reui/data-grid/data-grid'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import {
DataGridTableDndRowHandle,
DataGridTableDndRows,
} from '@/components/reui/data-grid/data-grid-table-dnd-rows'
import type { DragEndEvent } from '@dnd-kit/core'
import {
DataGridTable,
DataGridTableRowSelect,
DataGridTableRowSelectAll,
} from '@/components/reui/data-grid/data-grid-table'
import { Button } from '@authportal/ui/components/button'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu'
import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import { cn } from '@authportal/ui/lib/utils'
import { StatusBadge } from './bits'
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
DATA_GRID_CLASS_NAMES,
DATA_GRID_LAYOUT,
} from './data-page-layout'
import {
DataGridSortHeader,
DataPageCard,
DataPageToolbar,
} from './data-page'
import type { Tone } from './tone'
import type { RowMark } from './tone'
import { TONE_DOT } from './tone'
export type GridColumn<T> = {
id: string
title: string
/** Ширина колонки; сумма задаёт минимальную ширину таблицы. */
size: number
cell: (row: T) => ReactNode
/** Ключ сортировки; без него колонка не сортируется. */
sortValue?: (row: T) => string | number
}
export type GridView<T> = { label: string; match: (row: T) => boolean }
/** Карточка строки для режима «Карточки» (как на доске «Пользователи»). */
export type GridCard = {
name: string
sub?: string
tone: Tone
status: string
cells: Array<{ l: string; v: string }>
}
/** Действия меню строки «⋮»: на досках — Открыть, Включить/Выключить, Дублировать, Удалить…. */
export type RowMenuConfig = {
onOpen?: () => void
toggleLabel?: string
onToggle?: () => void
onDuplicate?: () => void
onDelete?: () => void
}
export type GridBulk = {
onLabel?: string
offLabel?: string
onRun?: (ids: string[]) => void
offRun?: (ids: string[]) => void
onDelete?: (ids: string[]) => void
}
const SELECT_COLUMN_SIZE = 44
const DRAG_COLUMN_SIZE = 44
const ACTIONS_COLUMN_SIZE = 120
/**
* Секция-таблица по доскам Auth Portal: шапка (название, действия, «Таблица/Карточки», поиск, «Колонки»),
* сохранённые виды линией со счётчиками, панель массовых действий, таблица со статусом и меню строки,
* подвал «Показано N из M». Ширина таблицы не меньше суммы колонок — прокрутка внутри контейнера.
*/
export function SectionGrid<T extends object>({
title,
description,
rows,
columns,
getRowId,
views,
searchText,
searchPlaceholder = 'Поиск…',
onRowClick,
selectable = true,
bulk,
tools,
card,
rowSwitch,
rowMenu,
onReorder,
rowStatus,
pageSize = 50,
emptyMessage = 'Нет записей по текущим фильтрам.',
className,
}: {
title: string
description: string
rows: T[]
columns: GridColumn<T>[]
getRowId: (row: T) => string
views?: GridView<T>[]
searchText?: (row: T) => string
searchPlaceholder?: string
onRowClick?: (row: T) => void
selectable?: boolean
bulk?: GridBulk
/** Кнопки действий в шапке; функция получает id выбранных строк. */
tools?: ReactNode | ((selectedIds: string[]) => ReactNode)
/** Включает переключатель «Таблица / Карточки». */
card?: (row: T) => GridCard
/** Переключатель «включено» в последней колонке (если у строки он есть). */
rowSwitch?: { value: (row: T) => boolean | undefined; onChange: (row: T, on: boolean) => void }
rowMenu?: (row: T) => RowMenuConfig
/** Включает перетаскивание строк (dnd-kit). Передаёт id строки и id строки, на место которой её бросили. */
onReorder?: (activeId: string, overId: string) => void
/** Пометка строки: «Не сохранено» (dirty/new) или «Будет удалено» (deleted). */
rowStatus?: (row: T) => RowMark | undefined
pageSize?: number
/** Свой текст подвала вместо «Показано N из M» (как «Показано 8 из 148» на доске). */
footer?: string
emptyMessage?: string
className?: string
}) {
const storageKey = `design-preview:view:${title}`
const [view, setViewState] = useState(() => {
try {
const v = Number(window.localStorage.getItem(storageKey))
return Number.isInteger(v) && v >= 0 && v < (views?.length ?? 0) ? v : 0
} catch {
return 0
}
})
const setView = (i: number) => {
setViewState(i)
try {
window.localStorage.setItem(storageKey, String(i))
} catch {
/* без сохранения вида */
}
}
const [query, setQuery] = useState('')
const [sorting, setSorting] = useState<SortingState>([])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize })
const [hidden, setHidden] = useState<Record<string, boolean>>({})
const [cards, setCards] = useState(false)
const data = useMemo(() => {
const matchView = views?.[view]?.match
const q = query.trim().toLowerCase()
return rows.filter((row) => {
if (matchView && !matchView(row)) return false
if (q && searchText && !searchText(row).toLowerCase().includes(q)) return false
return true
})
}, [rows, views, view, query, searchText])
const visibleColumns = useMemo(() => columns.filter((c, i) => i === 0 || !hidden[c.id]), [columns, hidden])
const defs = useMemo<ColumnDef<DataGridFeatures, T>[]>(() => {
const list: ColumnDef<DataGridFeatures, T>[] = []
if (onReorder) {
list.push({
id: 'drag',
header: () => <span className="sr-only">Порядок</span>,
cell: () => <DataGridTableDndRowHandle />,
enableSorting: false,
enableResizing: false,
size: DRAG_COLUMN_SIZE,
})
}
if (selectable) {
list.push({
id: 'select',
header: () => <DataGridTableRowSelectAll />,
cell: ({ row }) => (
<div onClick={(e) => e.stopPropagation()}>
<DataGridTableRowSelect row={row} />
</div>
),
enableSorting: false,
enableResizing: false,
size: SELECT_COLUMN_SIZE,
meta: { headerClassName: 'pl-5 py-3', cellClassName: 'pl-5 py-3' },
})
}
visibleColumns.forEach((col, i) => {
const pad = i === 0 && !selectable && !onReorder ? DATA_GRID_CELL_PAD_FIRST : DATA_GRID_CELL_PAD
list.push({
id: col.id,
accessorFn: col.sortValue ?? (() => ''),
header: ({ column }) => <DataGridSortHeader column={column} title={col.title} />,
cell: ({ row }) => col.cell(row.original),
enableSorting: Boolean(col.sortValue),
enableResizing: false,
size: col.size,
meta: { headerTitle: col.title, headerClassName: pad, cellClassName: pad },
})
})
list.push({
id: '__actions',
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => {
const r = row.original
const on = rowSwitch?.value(r)
const menu: RowMenuConfig = rowMenu?.(r) ?? {}
const soon = (what: string) => () => toast(`${what} (макет)`)
return (
<div className="flex items-center justify-end gap-2" onClick={(e) => e.stopPropagation()}>
{on !== undefined && rowSwitch ? (
<Switch size="sm" checked={on} onCheckedChange={(v) => rowSwitch.onChange(r, v)} aria-label="Включено" />
) : null}
<DropdownMenu>
<DropdownMenuTrigger render={<Button type="button" variant="ghost" size="icon" aria-label="Действия" />}>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-48">
<DropdownMenuItem onClick={menu.onOpen ?? (() => onRowClick?.(r))}>Открыть</DropdownMenuItem>
<DropdownMenuItem onClick={menu.onToggle ?? soon('Действие')}>
{menu.toggleLabel ?? (on === undefined ? 'Повторить проверку' : on ? 'Выключить' : 'Включить')}
</DropdownMenuItem>
<DropdownMenuItem onClick={menu.onDuplicate ?? soon('Дублирование')}>Дублировать</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={menu.onDelete ?? soon('Удаление')}>
Удалить…
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
},
enableSorting: false,
enableResizing: false,
size: ACTIONS_COLUMN_SIZE,
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
})
return list
}, [visibleColumns, selectable, onReorder, rowSwitch, rowMenu, onRowClick])
const table = useTable({
features: dataGridFeatures,
columns: defs,
data,
getRowId,
state: { sorting, rowSelection, pagination },
enableRowSelection: selectable,
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
})
const dataIds = table.getRowModel().rows.map((r) => r.id)
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!onReorder || !active || !over || active.id === over.id) return
onReorder(String(active.id), String(over.id))
}
// Колонки сжимаются под ширину рамки (текст переносится, как на досках); прокрутка — только на узких экранах.
const minWidth = Math.round(
visibleColumns.reduce(
(sum, c) => sum + c.size,
(selectable ? SELECT_COLUMN_SIZE : 0) + (onReorder ? DRAG_COLUMN_SIZE : 0) + ACTIONS_COLUMN_SIZE,
) * 0.7,
)
const selectedIds = table.getSelectedRowModel().rows.map((r) => r.id)
const clear = () => setRowSelection({})
const paged = data.length > pageSize
const showCards = Boolean(card) && cards
const countLabel = `${data.length} ${data.length === rows.length ? 'записей' : `из ${rows.length}`}`
return (
<DataGrid
table={table}
i18n={DATA_GRID_RU}
recordCount={data.length}
onRowClick={onRowClick}
getRowStatus={rowStatus}
emptyMessage={emptyMessage}
tableLayout={{ ...DATA_GRID_LAYOUT, rowsDraggable: Boolean(onReorder) }}
tableClassNames={DATA_GRID_CLASS_NAMES}
>
<DataPageCard className={className}>
<h2 className="sr-only">{title}</h2>
<DataPageToolbar
segmented={
views
? {
value: String(view),
label: description,
onChange: (v) => {
setView(Number(v))
setPagination((p) => ({ ...p, pageIndex: 0 }))
},
options: views.map((v, i) => ({ value: String(i), label: v.label, count: rows.filter(v.match).length })),
}
: undefined
}
leading={
card ? (
<Tabs value={cards ? 'cards' : 'table'} onValueChange={(v) => setCards(v === 'cards')} aria-label="Вид списка">
<TabsList className="h-9! gap-0.5 p-[3px]">
<TabsTrigger value="table" className="h-7! px-2.5 text-[12.5px]">
Таблица
</TabsTrigger>
<TabsTrigger value="cards" className="h-7! px-2.5 text-[12.5px]">
Карточки
</TabsTrigger>
</TabsList>
</Tabs>
) : undefined
}
search={query}
onSearchChange={
searchText
? (v) => {
setQuery(v)
setPagination((p) => ({ ...p, pageIndex: 0 }))
}
: undefined
}
searchPlaceholder={searchPlaceholder}
countLabel={countLabel}
actions={
<>
{typeof tools === 'function' ? tools(selectedIds) : tools}
{columns.length > 1 ? (
<DropdownMenu>
<DropdownMenuTrigger
render={<Button type="button" variant="outline" size="icon" aria-label="Выбрать колонки" title="Колонки" />}
>
<Columns3Icon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-52">
{columns.slice(1).map((c) => (
<DropdownMenuCheckboxItem key={c.id} checked={!hidden[c.id]} onCheckedChange={(v) => setHidden((h) => ({ ...h, [c.id]: !v }))}>
{c.title}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</>
}
/>
{selectable && selectedIds.length > 0 ? (
<div
role="toolbar"
aria-label="Массовые действия"
className="bg-primary/10 flex flex-wrap items-center gap-2 border-b px-3.5 py-2 text-[13px]"
>
<b className="mr-1.5 font-semibold">Выбрано: {selectedIds.length}</b>
<Button
type="button"
variant="outline"
onClick={() => {
if (bulk?.onRun) bulk.onRun(selectedIds)
else toast(`${bulk?.onLabel ?? 'Включить'}: ${selectedIds.length} (макет)`)
clear()
}}
>
{bulk?.onLabel ?? 'Включить'}
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
if (bulk?.offRun) bulk.offRun(selectedIds)
else toast(`${bulk?.offLabel ?? 'Выключить'}: ${selectedIds.length} (макет)`)
clear()
}}
>
{bulk?.offLabel ?? 'Выключить'}
</Button>
<Button
type="button"
variant="outline"
className="text-destructive"
onClick={() => {
if (bulk?.onDelete) bulk.onDelete(selectedIds)
else toast(`Удаление: ${selectedIds.length} (макет)`)
}}
>
Удалить…
</Button>
<span className="flex-1" />
<Button type="button" variant="outline" onClick={clear}>
Снять выбор
</Button>
</div>
) : null}
{showCards && card ? (
<ul className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,15rem),1fr))] gap-3 p-3">
{data.map((r) => {
const c = card(r)
const id = getRowId(r)
const marked = rowStatus?.(r)
return (
<li
key={id}
className={cn('bg-muted/25 flex flex-col gap-2 rounded-lg border p-3', marked === 'deleted' && 'opacity-60')}
>
<div className="flex items-center justify-between gap-2">
<input
type="checkbox"
aria-label={`Выбрать ${c.name}`}
checked={Boolean(rowSelection[id])}
onChange={(e) =>
setRowSelection((s) => {
const next = { ...s }
if (e.target.checked) next[id] = true
else delete next[id]
return next
})
}
className="accent-primary size-4"
/>
<StatusBadge tone={c.tone}>{c.status}</StatusBadge>
</div>
<button
type="button"
onClick={() => onRowClick?.(r)}
className="flex min-w-0 items-start gap-2 text-left"
aria-label={`Открыть: ${c.name}`}
>
<span aria-hidden className={cn('mt-1.5 size-2 shrink-0 rounded-full', TONE_DOT[c.tone])} />
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{c.name}</span>
{c.sub ? <span className="text-muted-foreground truncate font-mono text-[11px]">{c.sub}</span> : null}
</span>
</button>
<dl className="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs">
{c.cells.map((x) => (
<div key={x.l} className="min-w-0">
<dt className="text-muted-foreground">{x.l}</dt>
<dd className="truncate font-mono">{x.v}</dd>
</div>
))}
</dl>
</li>
)
})}
</ul>
) : (
<DataGridScrollArea>
<div style={{ minWidth }}>
{onReorder ? <DataGridTableDndRows dataIds={dataIds} handleDragEnd={handleDragEnd} /> : <DataGridTable />}
</div>
</DataGridScrollArea>
)}
{paged && !showCards ? <DataGridPagination className="px-5 py-3" sizes={[8, 16, 32]} /> : null}
</DataPageCard>
</DataGrid>
)
}
/** Двухстрочная ячейка: заголовок + подпись. */
export function TwoLineCell({ title, sub, dot }: { title: ReactNode; sub?: ReactNode; dot?: Tone }) {
return (
<div className="flex min-w-0 items-start gap-2">
{dot ? <span aria-hidden className={cn('mt-1.5 size-2 shrink-0 rounded-full', TONE_DOT[dot])} /> : null}
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{title}</span>
{sub ? <span className="text-muted-foreground truncate font-mono text-[11px]">{sub}</span> : null}
</div>
</div>
)
}
@@ -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 = type BadgeVariant =
| 'success-light' | 'success-light'
@@ -19,6 +23,6 @@ export const TONE_DOT: Record<Tone, string> = {
ok: 'bg-success', ok: 'bg-success',
wn: 'bg-warning', wn: 'bg-warning',
er: 'bg-destructive', er: 'bg-destructive',
in: 'bg-info', in: 'bg-success',
mu: 'bg-muted-foreground/50', mu: 'bg-muted-foreground/50',
} }
@@ -44,6 +44,13 @@ import {
DataGridTableRowSelectAll, DataGridTableRowSelectAll,
} from '@/components/reui/data-grid/data-grid-table' } from '@/components/reui/data-grid/data-grid-table'
import { DATA_GRID_RU, FILTERS_RU, FILTER_OPERATORS_RU } from '@/lib/reui-ru' 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 { Filters } from '@/components/reui/filters/filters'
import { import {
createFilterQuery, createFilterQuery,
@@ -54,14 +61,6 @@ import type {
FilterField, FilterField,
FilterQuery, FilterQuery,
} from '@/components/reui/filters/filters-types' } from '@/components/reui/filters/filters-types'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -93,7 +92,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@authportal/ui/components/select' } from '@authportal/ui/components/select'
import { Separator } from '@authportal/ui/components/separator'
import { Skeleton } from '@authportal/ui/components/skeleton' import { Skeleton } from '@authportal/ui/components/skeleton'
import { TooltipProvider } from '@authportal/ui/components/tooltip' import { TooltipProvider } from '@authportal/ui/components/tooltip'
@@ -201,15 +199,6 @@ function formatLastActive(iso: string | null) {
}).format(date) }).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 }) { function RoleBadge({ isAdmin }: { isAdmin: boolean }) {
return isAdmin ? ( return isAdmin ? (
<Badge variant="info-outline">Админ</Badge> <Badge variant="info-outline">Админ</Badge>
@@ -710,15 +699,6 @@ export function AdminUsersGrid({
const [bulkRole, setBulkRole] = useState<BulkRole>('user') const [bulkRole, setBulkRole] = useState<BulkRole>('user')
const lastAppliedKey = useRef<string>(serializeConditionsKey([])) 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( const filterFields: FilterField[] = useMemo(
() => [ () => [
{ {
@@ -778,6 +758,16 @@ export function AdminUsersGrid({
onDeactivate, onDeactivate,
onDelete, onDelete,
onResetPasskeys, 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], [onOpenAudit, onOpenAccess, onDeactivate, onDelete, onResetPasskeys],
) )
@@ -850,38 +840,17 @@ export function AdminUsersGrid({
: undefined : undefined
} }
tableLayout={{ tableLayout={{
...DATA_GRID_LAYOUT,
columnsResizable: true, columnsResizable: true,
columnsMovable: true, columnsMovable: true,
columnsVisibility: true, columnsVisibility: true,
headerSticky: true, headerSticky: true,
dense: true,
}} }}
tableClassNames={DATA_GRID_CLASS_NAMES}
> >
<Frame spacing="sm" className="w-full"> <DataPageCard>
<FrameHeader className="flex-row items-center justify-between gap-3"> <DataPageToolbar
<div className="flex flex-col gap-0.5"> filterControl={
<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)">
<Filters <Filters
query={query} query={query}
fields={filterFields} fields={filterFields}
@@ -890,27 +859,18 @@ export function AdminUsersGrid({
operatorLabels={FILTER_OPERATORS_RU} operatorLabels={FILTER_OPERATORS_RU}
size="default" size="default"
trigger={ trigger={
<Button <Button type="button" size="default" variant="outline" aria-label="Фильтры">
type="button"
size="default"
variant="outline"
aria-label="Фильтры"
>
<FilterIcon aria-hidden /> <FilterIcon aria-hidden />
Фильтры Фильтры
</Button> </Button>
} }
/> />
<div className="flex flex-wrap items-center gap-2"> }
countLabel={`${filteredData.length} пользователей`}
actions={
<>
{showClearButton ? ( {showClearButton ? (
<Button <Button type="button" size="default" variant="outline" className="shrink-0" onClick={handleClearFilters} disabled={gridLoading}>
type="button"
size="default"
variant="outline"
className="shrink-0"
onClick={handleClearFilters}
disabled={gridLoading}
>
<FunnelXIcon aria-hidden /> <FunnelXIcon aria-hidden />
Сбросить Сбросить
</Button> </Button>
@@ -918,42 +878,34 @@ export function AdminUsersGrid({
<DataGridColumnVisibility <DataGridColumnVisibility
table={table} table={table}
trigger={ trigger={
<Button <Button type="button" size="default" variant="outline" aria-label="Настройки таблицы">
type="button"
size="default"
variant="outline"
aria-label="Настройки таблицы"
>
<Settings2Icon aria-hidden="true" /> <Settings2Icon aria-hidden="true" />
Колонки Колонки
</Button> </Button>
} }
/> />
</div> <Button type="button" size="default" onClick={onCreateClick}>
</div> <UserPlusIcon aria-hidden="true" />
<Separator /> Создать
{selectedCount > 0 ? ( </Button>
<>
<AdminUsersBulkActionBar
selectedCount={selectedCount}
roleValue={bulkRole}
onRoleChange={setBulkRole}
onChangeRole={handleChangeRole}
onDeactivate={handleBulkDeactivate}
onClear={handleClearSelection}
/>
<Separator />
</> </>
) : null} }
<DataGridScrollArea> />
<DataGridTable /> {selectedCount > 0 ? (
</DataGridScrollArea> <AdminUsersBulkActionBar
<Separator /> selectedCount={selectedCount}
<FrameFooter> roleValue={bulkRole}
<DataGridPagination sizes={[10, 20, 30]} /> onRoleChange={setBulkRole}
</FrameFooter> onChangeRole={handleChangeRole}
</FramePanel> onDeactivate={handleBulkDeactivate}
</Frame> onClear={handleClearSelection}
/>
) : null}
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<DataGridPagination className="px-5 py-3" sizes={[10, 20, 30]} />
</DataPageCard>
</DataGrid> </DataGrid>
</TooltipProvider> </TooltipProvider>
) )
@@ -16,4 +16,3 @@ export { UserAuditSheet } from './user-audit-sheet'
export { CreateUserSheet } from './create-user-sheet' export { CreateUserSheet } from './create-user-sheet'
export { CreateOidcClientSheet } from './create-oidc-client-sheet' export { CreateOidcClientSheet } from './create-oidc-client-sheet'
export { AdminUsersGrid, type AdminUsersGridProps } from './admin-users-grid' 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>
</>
)
}
@@ -33,6 +33,8 @@ interface QuickActionGridProps {
title?: string title?: string
description?: string description?: string
className?: string className?: string
/** Плитки в Frame с отступами и сеткой auto-fit от 17rem (как KPI-полоса): без вылета за границы. */
autoFit?: boolean
} }
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current' const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
@@ -108,18 +110,24 @@ export function QuickActionGrid({
title = 'Быстрые действия', title = 'Быстрые действия',
description, description,
className, className,
autoFit = false,
}: QuickActionGridProps) { }: QuickActionGridProps) {
if (actions.length === 0) return null if (actions.length === 0) return null
return ( return (
<Frame dense spacing="sm" className={cn('@container w-full', className)}> <Frame dense={!autoFit} spacing="sm" className={cn('@container w-full', className)}>
{(title || description) && ( {(title || description) && (
<FrameHeader> <FrameHeader>
{title ? <FrameTitle>{title}</FrameTitle> : null} {title ? <FrameTitle>{title}</FrameTitle> : null}
{description ? <FrameDescription>{description}</FrameDescription> : null} {description ? <FrameDescription>{description}</FrameDescription> : null}
</FrameHeader> </FrameHeader>
)} )}
<div className={cn('grid gap-2', kpiCols(actions.length))}> <div
className={cn(
'grid gap-2',
autoFit ? 'grid-cols-[repeat(auto-fit,minmax(min(100%,17rem),1fr))]' : kpiCols(actions.length),
)}
>
{actions.map((action) => ( {actions.map((action) => (
<FramePanel <FramePanel
key={action.id} key={action.id}
@@ -1,196 +0,0 @@
import { useMemo, useState } from 'react'
import {
PERMISSION_CATALOG,
hasPermission,
permissionKey,
type AdminUser,
type AppId,
type PermissionAction,
} from '@authportal/shared'
import { APPS } from '@authportal/shared'
import { Button } from '@authportal/ui/components/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@authportal/ui/components/dialog'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} 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 { diffAccess, type AccessValue } from './access-model'
import { APP_SHORT, USER_JOURNAL } from './mock'
type Level = 'none' | PermissionAction
const LEVEL_LABEL: Record<Level, string> = {
none: 'Нет доступа',
read: 'Чтение',
write: 'Запись',
admin: 'Админ',
}
function levelOf(perms: readonly string[], appId: AppId, sectionId: string, actions: PermissionAction[]): Level {
for (const action of ['admin', 'write', 'read'] as const) {
if (actions.includes(action) && hasPermission(perms, permissionKey(appId, sectionId, action))) {
return action
}
}
return 'none'
}
/** Ключи для уровня: иерархия админ ⊃ запись ⊃ чтение внутри раздела. */
function keysForLevel(appId: AppId, sectionId: string, actions: PermissionAction[], level: Level): string[] {
if (level === 'none') return []
const order: PermissionAction[] = ['read', 'write', 'admin']
const upto = order.indexOf(level)
return order
.slice(0, upto + 1)
.filter((a) => actions.includes(a))
.map((a) => permissionKey(appId, sectionId, a))
}
/** Окно «Права доступа»: вкладки по приложениям и разделам, локальное состояние, без API. */
export function AccessDialog({
user,
onClose,
onSave,
}: {
user: AdminUser
onClose: () => void
onSave: (value: AccessValue) => void
}) {
const [apps, setApps] = useState<AppId[]>(user.apps)
const [permissions, setPermissions] = useState<string[]>(user.permissions)
const next = useMemo<AccessValue>(() => ({ apps, permissions }), [apps, permissions])
const lines = useMemo(() => diffAccess({ apps: user.apps, permissions: user.permissions }, next), [user, next])
function setLevel(appId: AppId, sectionId: string, actions: PermissionAction[], level: Level) {
setPermissions((prev) => {
const prefix = `${appId}:${sectionId}:`
const rest = prev.filter((k) => !k.startsWith(prefix))
return [...rest, ...keysForLevel(appId, sectionId, actions, level)].sort()
})
}
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="flex max-h-[calc(100svh-2rem)] w-[min(52rem,calc(100vw-2rem))] max-w-none flex-col gap-4 sm:max-w-none">
<DialogHeader>
<DialogTitle>Права доступа · {user.email}</DialogTitle>
<DialogDescription>Настройка доступа к приложениям и разделам</DialogDescription>
</DialogHeader>
{user.is_admin ? (
<SignalAlert tone="in" title="Администратор">
Полный доступ ко всем приложениям и разделам не зависит от настроек ниже.
</SignalAlert>
) : null}
<Tabs defaultValue="apps" className="min-h-0 min-w-0 flex-1">
<TabsList className="max-w-full justify-start overflow-x-auto">
<TabsTrigger value="apps">Приложения</TabsTrigger>
{PERMISSION_CATALOG.map((c) => (
<TabsTrigger key={c.appId} value={c.appId}>
{APP_SHORT[c.appId]}
</TabsTrigger>
))}
<TabsTrigger value="journal">Журнал</TabsTrigger>
</TabsList>
<TabsContent value="apps" className="min-h-0 overflow-y-auto">
<ul className="flex flex-col divide-y rounded-lg border">
{APPS.map((app) => (
<li key={app.id} className="flex items-center justify-between gap-3 px-3 py-2.5">
<label htmlFor={`app-${app.id}`} className="flex min-w-0 flex-col">
<span className="text-sm font-medium">{app.title}</span>
<span className="text-muted-foreground text-xs">{app.description}</span>
</label>
<Switch
id={`app-${app.id}`}
checked={apps.includes(app.id)}
onCheckedChange={(on) =>
setApps((prev) => (on ? [...prev, app.id] : prev.filter((x) => x !== app.id)))
}
/>
</li>
))}
</ul>
</TabsContent>
{PERMISSION_CATALOG.map((catalog) => (
<TabsContent key={catalog.appId} value={catalog.appId} className="min-h-0 overflow-y-auto">
{!apps.includes(catalog.appId) ? (
<p className="text-muted-foreground mb-2 text-xs">
Приложение отключено на вкладке «Приложения» — права разделов не действуют.
</p>
) : null}
<ul className="flex flex-col divide-y rounded-lg border">
{catalog.sections.map((section) => {
const level = levelOf(permissions, catalog.appId, section.id, section.actions)
const options: Level[] = ['none', ...section.actions]
return (
<li key={section.id} className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 px-3 py-2.5">
<div className="flex min-w-0 flex-1 basis-48 flex-col">
<span className="text-sm font-medium">{section.title}</span>
<span className="text-muted-foreground text-xs">{section.description}</span>
</div>
<Select
value={level}
onValueChange={(v) => v && setLevel(catalog.appId, section.id, section.actions, v as Level)}
items={options.map((o) => ({ value: o, label: LEVEL_LABEL[o] }))}
>
<SelectTrigger size="sm" className="w-40" aria-label={`${section.title}: уровень доступа`}>
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectGroup>
{options.map((o) => (
<SelectItem key={o} value={o}>
{LEVEL_LABEL[o]}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</li>
)
})}
</ul>
</TabsContent>
))}
<TabsContent value="journal" className="min-h-0 overflow-y-auto">
<CodeBlock label={`${user.email} · 24 часа`}>{USER_JOURNAL}</CodeBlock>
</TabsContent>
</Tabs>
<DialogFooter className="items-center">
<span className="text-muted-foreground mr-auto text-xs">
{lines.length > 0 ? `Изменений: ${lines.length}` : 'Без изменений'}
</span>
<Button variant="outline" onClick={onClose}>
Отмена
</Button>
<Button
disabled={lines.length === 0}
onClick={() => {
onSave(next)
onClose()
}}
>
Сохранить права
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,125 @@
import { Field, FieldDescription, FieldLabel } from '@authportal/ui/components/field'
import { APPS, PERMISSION_CATALOG, type AdminUser, type AppId } from '@authportal/shared'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} 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 '@/components/portal/bits'
import { withLevel, levelOf, type AccessValue, type Level } from './access-model'
import { APP_SHORT, USER_JOURNAL } from './mock'
const LEVEL_NAME: Record<Level, string> = { none: 'Нет доступа', read: 'Чтение', write: 'Запись', admin: 'Админ' }
/** Приложения, у которых на доске есть вкладка с разделами. */
const PERM_TABS: AppId[] = ['cfdm', 'vps', 'bgp', 'fw', 'mm']
/**
* Редактор прав по доске «Права доступа»: вкладки «Приложения», по приложению с разделами и «Журнал».
* Раздел — строка с названием, подсказкой и уровнем «Нет доступа / Чтение / Запись / Админ».
*/
export function AccessEditor({
user,
value,
onChange,
}: {
user: AdminUser
value: AccessValue
onChange: (next: AccessValue) => void
}) {
return (
<div className="flex min-h-0 flex-1 flex-col gap-3">
{user.is_admin ? (
<SignalAlert tone="in" title="Администратор">
Полный доступ ко всем приложениям и разделам не зависит от настроек ниже.
</SignalAlert>
) : null}
<Tabs defaultValue="apps" className="min-h-0 min-w-0 flex-1">
<TabsList variant="line" className="h-10! w-full justify-start gap-5 overflow-x-auto">
<TabsTrigger value="apps" className="px-1 text-sm">
Приложения
</TabsTrigger>
{PERM_TABS.map((id) => (
<TabsTrigger key={id} value={id} className="px-1 text-sm">
{id === 'vps' ? 'VPS Tracker' : APP_SHORT[id]}
</TabsTrigger>
))}
<TabsTrigger value="journal" className="px-1 text-sm">
Журнал
</TabsTrigger>
</TabsList>
<TabsContent value="apps" className="mt-4 min-h-0 overflow-y-auto">
<ul className="flex flex-col divide-y rounded-lg border">
{APPS.map((a) => (
<li key={a.id} className="flex items-center justify-between gap-3 px-3.5 py-3">
<label htmlFor={`acc-${a.id}`} className="flex min-w-0 flex-col">
<span className="text-sm font-medium">{a.title}</span>
<span className="text-muted-foreground text-xs">{a.description}</span>
</label>
<Switch
id={`acc-${a.id}`}
checked={value.apps.includes(a.id)}
onCheckedChange={(on) =>
onChange({ ...value, apps: on ? [...value.apps, a.id] : value.apps.filter((x) => x !== a.id) })
}
/>
</li>
))}
</ul>
</TabsContent>
{PERM_TABS.map((appId) => {
const catalog = PERMISSION_CATALOG.find((c) => c.appId === appId)
if (!catalog) return null
return (
<TabsContent key={appId} value={appId} className="mt-4 min-h-0 overflow-y-auto">
<div className="flex flex-col gap-4">
{catalog.sections.map((section) => {
const level = levelOf(value.permissions, appId, section.id, section.actions)
const options: Level[] = ['none', ...section.actions]
return (
<Field key={section.id}>
<FieldLabel>{section.title}</FieldLabel>
<Select
value={level}
onValueChange={(v) =>
v && onChange({ ...value, permissions: withLevel(value.permissions, appId, section.id, section.actions, v as Level) })
}
items={options.map((o) => ({ value: o, label: LEVEL_NAME[o] }))}
>
<SelectTrigger className="w-full" aria-label={`${section.title}: уровень доступа`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{options.map((o) => (
<SelectItem key={o} value={o}>
{LEVEL_NAME[o]}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FieldDescription>{level === 'none' ? 'Раздел скрыт в приложении' : 'Иерархия: админ ⊃ запись ⊃ чтение'}</FieldDescription>
</Field>
)
})}
</div>
</TabsContent>
)
})}
<TabsContent value="journal" className="mt-4 flex min-h-0 flex-col gap-3 overflow-y-auto">
<p className="text-muted-foreground text-sm">Последние события пользователя из журнала портала.</p>
<CodeBlock label={`${user.email} · 24 часа`}>{USER_JOURNAL}</CodeBlock>
</TabsContent>
</Tabs>
</div>
)
}
@@ -0,0 +1,53 @@
import { StatusBadge } from '@/components/portal/bits'
import type { Tone } from './mock'
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
type MatrixRow = {
id: string
name: string
sub: string
tone: Tone
status: string
total: string
read: string
write: string
admin: string
}
/** Данные доски «Доступ по приложениям»: сколько пользователей имеет доступ и с каким уровнем. */
const ROWS: MatrixRow[] = [
{ id: 'cfdm', name: 'Cloudflare Domain Manager', sub: 'cfdm · JWT', tone: 'ok', status: 'Выдан', total: '3', read: '1', write: '2', admin: '2' },
{ id: 'vps', name: 'VPS Tracker', sub: 'vps · JWT', tone: 'ok', status: 'Выдан', total: '3', read: '1', write: '1', admin: '1' },
{ id: 'bgp', name: 'EvoBGP', sub: 'bgp · JWT', tone: 'ok', status: 'Выдан', total: '3', read: '0', write: '1', admin: '2' },
{ id: 'fw', name: 'EvoFirewall', sub: 'fw · JWT', tone: 'ok', status: 'Выдан', total: '3', read: '0', write: '1', admin: '2' },
{ id: 'dns', name: 'Technitium DNS', sub: 'dns · OIDC', tone: 'ok', status: 'Выдан', total: '2', read: '0', write: '1', admin: '1' },
{ id: 'cdn', name: 'CDN Manager', sub: 'cdn · JWT', tone: 'wn', status: 'Мало админов', total: '3', read: '1', write: '1', admin: '1' },
{ id: 'mm', name: 'MikrotikManager', sub: 'mm · JWT', tone: 'ok', status: 'Выдан', total: '3', read: '1', write: '1', admin: '1' },
]
const mono = (v: string) => <span className="font-mono text-[12.5px]">{v}</span>
const COLUMNS: GridColumn<MatrixRow>[] = [
{ id: 'app', title: 'Приложение', size: 260, sortValue: (r) => r.name, cell: (r) => <TwoLineCell dot={r.tone} title={r.name} sub={r.sub} /> },
{ id: 'status', title: 'Статус', size: 150, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> },
{ id: 'total', title: 'Пользователей', size: 140, cell: (r) => mono(r.total) },
{ id: 'read', title: 'Чтение', size: 100, cell: (r) => mono(r.read) },
{ id: 'write', title: 'Запись', size: 100, cell: (r) => mono(r.write) },
{ id: 'admin', title: 'Админ', size: 100, cell: (r) => mono(r.admin) },
]
/** Матрица доступа по доске: сколько пользователей имеет доступ к каждому приложению и с каким уровнем. */
export function AccessMatrix() {
return (
<SectionGrid
title="Матрица доступа"
description="Сколько пользователей имеет доступ к каждому приложению и с каким уровнем"
rows={ROWS}
columns={COLUMNS}
getRowId={(r) => r.id}
views={[{ label: 'Все', match: () => true }]}
searchText={(r) => `${r.name} ${r.id}`}
bulk={{ onLabel: 'Выдать доступ', offLabel: 'Снять доступ' }}
/>
)
}
+59 -1
View File
@@ -1,4 +1,10 @@
import type { AppId } from '@authportal/shared' import {
PERMISSION_CATALOG,
hasPermission,
permissionKey,
type AppId,
type PermissionAction,
} from '@authportal/shared'
import { APP_SHORT } from './mock' import { APP_SHORT } from './mock'
export type AccessValue = { apps: AppId[]; permissions: string[] } export type AccessValue = { apps: AppId[]; permissions: string[] }
@@ -11,3 +17,55 @@ export function diffAccess(before: AccessValue, after: AccessValue): string[] {
for (const key of before.permissions) if (!after.permissions.includes(key)) lines.push(`− ${key}`) for (const key of before.permissions) if (!after.permissions.includes(key)) lines.push(`− ${key}`)
return lines return lines
} }
export type Level = 'none' | 'read' | 'write' | 'admin'
export const LEVEL_LABEL: Record<Level, string> = {
none: 'Нет',
read: 'Чтение',
write: 'Запись',
admin: 'Админ',
}
/** Текущий уровень раздела: иерархия админ ⊃ запись ⊃ чтение. */
export function levelOf(
perms: readonly string[],
appId: AppId,
sectionId: string,
actions: readonly PermissionAction[],
): Level {
for (const action of ['admin', 'write', 'read'] as const) {
if (actions.includes(action) && hasPermission(perms, permissionKey(appId, sectionId, action))) return action
}
return 'none'
}
/** Ключи прав для уровня раздела. */
export function keysForLevel(appId: AppId, sectionId: string, actions: readonly PermissionAction[], level: Level): string[] {
if (level === 'none') return []
const order: PermissionAction[] = ['read', 'write', 'admin']
return order
.slice(0, order.indexOf(level) + 1)
.filter((a) => actions.includes(a))
.map((a) => permissionKey(appId, sectionId, a))
}
/** Сколько разделов приложения открыто (уровень выше «Нет»). */
export function grantCount(perms: readonly string[], appId: AppId): { granted: number; total: number } {
const catalog = PERMISSION_CATALOG.find((c) => c.appId === appId)
const sections = catalog?.sections ?? []
const granted = sections.filter((s) => levelOf(perms, appId, s.id, s.actions) !== 'none').length
return { granted, total: sections.length }
}
/** Новое множество прав после смены уровня одного раздела. */
export function withLevel(
perms: readonly string[],
appId: AppId,
sectionId: string,
actions: readonly PermissionAction[],
level: Level,
): string[] {
const prefix = `${appId}:${sectionId}:`
return [...perms.filter((k) => !k.startsWith(prefix)), ...keysForLevel(appId, sectionId, actions, level)].sort()
}
@@ -0,0 +1,52 @@
import { useMemo, useState } from 'react'
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 { PortalSheet } from '@/components/portal/bits'
/** Окно «Права доступа»: широкая правая панель, чтобы таблица оставалась в контексте. */
export function AccessSheet({
user,
onClose,
onSave,
}: {
user: AdminUser
onClose: () => void
onSave: (value: AccessValue) => void
}) {
const [value, setValue] = useState<AccessValue>({ apps: user.apps, permissions: user.permissions })
const changes = useMemo(() => diffAccess({ apps: user.apps, permissions: user.permissions }, value).length, [user, value])
return (
<PortalSheet
open
onOpenChange={(o) => !o && onClose()}
size="lg"
dirty={changes > 0}
title={`Права доступа · ${user.email}`}
description="Доступ к приложениям и разделам · иерархия: админ ⊃ запись ⊃ чтение"
footer={
<>
<span className="text-muted-foreground mr-auto self-center text-xs">
{changes > 0 ? `Изменений: ${changes}` : 'Без изменений'}
</span>
<Button variant="outline" onClick={onClose}>
Отмена
</Button>
<Button
disabled={changes === 0}
onClick={() => {
onSave(value)
onClose()
}}
>
Сохранить права
</Button>
</>
}
>
<AccessEditor user={user} value={value} onChange={setValue} />
</PortalSheet>
)
}
@@ -0,0 +1,194 @@
import { useState } from 'react'
import type { CreateUserRequest } from '@authportal/shared'
import type { CreateOidcClientValues } from '@/components/reui-kit/create-oidc-client-sheet'
import { Button } from '@authportal/ui/components/button'
import { Field, FieldDescription, FieldError, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input'
import { Switch } from '@authportal/ui/components/switch'
import { Textarea } from '@authportal/ui/components/textarea'
import { PortalSheet, SignalAlert } from '@/components/portal/bits'
/** Окно «Создать пользователя» по доске: email, имя, временный пароль, администратор и заметка про журнал. */
export function CreateUserPortalSheet({
open,
onOpenChange,
onSubmit,
}: {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (values: CreateUserRequest) => void
}) {
const [email, setEmail] = useState('')
const [name, setName] = useState('')
const [password, setPassword] = useState('')
const [admin, setAdmin] = useState(false)
const [touched, setTouched] = useState(false)
const passwordError = password.length < 12 ? 'Минимум 12 символов' : null
const emailError = !/^\S+@\S+\.\S+$/.test(email) ? 'Укажите email' : null
const valid = !passwordError && !emailError
function reset() {
setEmail('')
setName('')
setPassword('')
setAdmin(false)
setTouched(false)
}
return (
<PortalSheet
open={open}
onOpenChange={(o) => {
if (!o) reset()
onOpenChange(o)
}}
dirty={Boolean(email || name || password || admin)}
title="Создать пользователя"
description="После создания откроются права доступа"
footer={
<>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<Button
disabled={touched && !valid}
onClick={() => {
setTouched(true)
if (!valid) return
onSubmit({ email, name: name || email.split('@')[0]!, password, is_admin: admin, apps: [], permissions: [] })
reset()
}}
>
Создать
</Button>
</>
}
>
<Field data-invalid={touched && Boolean(emailError)}>
<FieldLabel htmlFor="cu-email">
Email <span aria-hidden>*</span>
</FieldLabel>
<Input id="cu-email" type="email" value={email} placeholder="[email protected]" aria-invalid={touched && Boolean(emailError)} onChange={(e) => setEmail(e.target.value)} />
{touched && emailError ? <FieldError>{emailError}</FieldError> : <FieldDescription>Станет логином</FieldDescription>}
</Field>
<Field>
<FieldLabel htmlFor="cu-name">Имя</FieldLabel>
<Input id="cu-name" value={name} placeholder="Новый пользователь" onChange={(e) => setName(e.target.value)} />
</Field>
<Field data-invalid={touched && Boolean(passwordError)}>
<FieldLabel htmlFor="cu-pw">
Временный пароль <span aria-hidden>*</span>
</FieldLabel>
<Input id="cu-pw" type="password" value={password} aria-invalid={touched && Boolean(passwordError)} onChange={(e) => setPassword(e.target.value)} />
{touched && passwordError ? <FieldError>✕ {passwordError}</FieldError> : <FieldDescription>Пользователь заменит его на passkey</FieldDescription>}
</Field>
<Field orientation="horizontal">
<Switch id="cu-admin" checked={admin} onCheckedChange={setAdmin} />
<div className="flex flex-col">
<FieldLabel htmlFor="cu-admin">Администратор</FieldLabel>
<FieldDescription>Полный доступ к порталу и ко всем приложениям</FieldDescription>
</div>
</Field>
<SignalAlert tone="wn" title="Журнал">
Создание записывается в журнал «Изменения» с вашим именем и IP.
</SignalAlert>
</PortalSheet>
)
}
/** Окно «Создать OIDC-клиент» по доске: название, redirect URI, включён. */
export function CreateOidcPortalSheet({
open,
onOpenChange,
onSubmit,
}: {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (values: CreateOidcClientValues) => void
}) {
const [name, setName] = useState('')
const [uris, setUris] = useState('')
const [enabled, setEnabled] = useState(true)
const [touched, setTouched] = useState(false)
const list = uris
.split('\n')
.map((x) => x.trim())
.filter(Boolean)
const urisError = list.length === 0 || list.some((u) => !/^https:\/\/[^\s*]+$/.test(u)) ? 'Укажите точный https-адрес колбэка приложения.' : null
const nameError = name.trim() ? null : 'Введите название'
const valid = !urisError && !nameError
function reset() {
setName('')
setUris('')
setEnabled(true)
setTouched(false)
}
return (
<PortalSheet
open={open}
onOpenChange={(o) => {
if (!o) reset()
onOpenChange(o)
}}
dirty={Boolean(name || uris)}
title="Создать OIDC-клиент"
description="Секрет будет показан один раз после создания"
footer={
<>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<Button
disabled={touched && !valid}
onClick={() => {
setTouched(true)
if (!valid) return
onSubmit({ name: name.trim(), redirect_uris: list, scopes: ['openid', 'profile', 'email'], enabled })
reset()
}}
>
Создать
</Button>
</>
}
>
<Field data-invalid={touched && Boolean(nameError)}>
<FieldLabel htmlFor="co-name">
Название <span aria-hidden>*</span>
</FieldLabel>
<Input id="co-name" value={name} placeholder="Technitium DNS" aria-invalid={touched && Boolean(nameError)} onChange={(e) => setName(e.target.value)} />
{touched && nameError ? <FieldError>{nameError}</FieldError> : null}
</Field>
<Field data-invalid={touched && Boolean(urisError)}>
<FieldLabel htmlFor="co-uri">
Redirect URI <span aria-hidden>*</span>
</FieldLabel>
<Textarea
id="co-uri"
rows={4}
value={uris}
placeholder="https://dns.example.com/sso/callback"
aria-invalid={touched && Boolean(urisError)}
className="font-mono"
onChange={(e) => setUris(e.target.value)}
/>
{touched && urisError ? (
<FieldError>✕ {urisError}</FieldError>
) : (
<FieldDescription>По одному на строку · только https · точное совпадение</FieldDescription>
)}
</Field>
<Field orientation="horizontal">
<Switch id="co-on" checked={enabled} onCheckedChange={setEnabled} />
<div className="flex flex-col">
<FieldLabel htmlFor="co-on">Клиент включён</FieldLabel>
<FieldDescription>Выключенный клиент не может получить токены</FieldDescription>
</div>
</Field>
</PortalSheet>
)
}
@@ -1,28 +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;
}
html[data-preview-contrast='high'] {
--muted-foreground: color-mix(in oklch, var(--foreground) 82%, var(--background));
--border: color-mix(in oklch, var(--foreground) 32%, var(--background));
--input: color-mix(in oklch, var(--foreground) 40%, var(--background));
}
/* Шрифты дизайн-системы (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);
}
-46
View File
@@ -1,46 +0,0 @@
import { createElement } from 'react'
import type { LucideIcon } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import type { KpiStatItem } from '@/components/reui-kit/kpi-stat-grid'
export type KpiTone = 'neutral' | 'ok' | 'wn' | 'er' | 'in'
const ICON_CLASS: Record<KpiTone, string> = {
neutral: 'text-muted-foreground [&_svg]:text-current',
ok: 'text-success [&_svg]:text-current',
wn: 'text-warning [&_svg]:text-current',
er: 'text-destructive [&_svg]:text-current',
in: 'text-info [&_svg]:text-current',
}
const BADGE: Record<KpiTone, 'outline' | 'success-light' | 'warning-light' | 'destructive-light' | 'info-light'> = {
neutral: 'outline',
ok: 'success-light',
wn: 'warning-light',
er: 'destructive-light',
in: 'info-light',
}
/** Плитка KPI: иконка по тону, значение с пояснением, бейдж статуса. */
export function kpi(
id: string,
label: string,
value: string,
opts: { icon: LucideIcon; tone?: KpiTone; badge: string; sub?: string },
): KpiStatItem {
const tone = opts.tone ?? 'neutral'
return {
id,
label,
icon: createElement(opts.icon),
iconClassName: ICON_CLASS[tone],
variant: tone === 'wn' ? 'warning' : tone === 'er' ? 'destructive' : 'default',
value: createElement(
'span',
{ className: 'flex flex-wrap items-baseline gap-x-2' },
value,
opts.sub ? createElement('span', { className: 'text-muted-foreground text-xs font-normal' }, opts.sub) : null,
),
footer: createElement(Badge, { variant: BADGE[tone], size: 'sm', className: 'whitespace-nowrap' }, opts.badge),
}
}
+47 -2
View File
@@ -21,7 +21,8 @@ import {
type AuditLogEntry, type AuditLogEntry,
} from '@authportal/shared' } from '@authportal/shared'
export type Tone = 'ok' | 'wn' | 'er' | 'mu' | 'in' import type { Tone } from '@/components/portal/tone'
export type { Tone }
export type NavItem = { export type NavItem = {
id: string id: string
@@ -151,7 +152,7 @@ export const MOCK_USERS: AdminUser[] = [
'[email protected]', '[email protected]',
'Operations', 'Operations',
false, false,
['bgp', 'fw', 'mm'], ['bgp', 'fw', 'mm', 'dns'],
['bgp:dashboard:read', 'bgp:modules:write', 'fw:agents:write', 'fw:lists:write', 'fw:policies:read', 'mm:servers:read'], ['bgp:dashboard:read', 'bgp:modules:write', 'fw:agents:write', 'fw:lists:write', 'fw:policies:read', 'mm:servers:read'],
{ {
created_at: '2026-03-14T08:00:00.000Z', created_at: '2026-03-14T08:00:00.000Z',
@@ -455,3 +456,47 @@ export const USER_META: Record<string, { sub: string; activity: string }> = {
'u-6': { sub: 'Сервисная запись · OIDC', activity: '3 ч назад · 10.0.0.5' }, 'u-6': { sub: 'Сервисная запись · OIDC', activity: '3 ч назад · 10.0.0.5' },
'u-7': { sub: 'Бывший сотрудник · отключён 12.10', activity: '15 дн назад · 192.0.2.88' }, 'u-7': { sub: 'Бывший сотрудник · отключён 12.10', activity: '15 дн назад · 192.0.2.88' },
} }
/** Источник и свежесть данных по разделам — для панели «Источники и свежесть данных» в шапке. */
export const FRESH_BY_PAGE: Record<string, { badge: string; text: string }> = {
apps: { badge: 'Портал', text: 'Получено 14:02:11 · сессия действует до 22:02 · права из базы портала, не из токена' },
account: { badge: 'WebAuthn', text: 'Получено 14:02:11 · браузер поддерживает passkeys · домен auth.shnt.top' },
users: { badge: 'Портал', text: 'Получено 14:02:11 · GET /api/v1/admin/users · 7 учётных записей' },
logins: { badge: 'Журнал', text: 'Получено 14:02:11 · GET /api/v1/admin/audit?kind=logins · хранение 90 дней' },
audit: { badge: 'Журнал', text: 'Получено 14:02:11 · GET /api/v1/admin/audit?kind=changes · ingest от 5 приложений' },
oidc: { badge: 'OIDC', text: 'Получено 14:02:11 · discovery и JWKS доступны · подпись RS256' },
'app-links': { badge: 'Портал', text: 'Получено 14:02:11 · portal_settings.app_switcher_json · публичный GET /api/v1/app-switcher' },
}
export type AuditRow = {
id: string
action: string
summary: string
tone: Tone
label: string
source: string
who: string
ip: string
when: string
}
/** Таблица журнала с доски «Изменения» (значения те же, что на доске). */
export const AUDIT_ROWS: AuditRow[] = [
{ id: 'e1', action: 'user.update', summary: 'Отключён [email protected]', tone: 'wn', label: 'Warning', source: 'Auth Portal', who: 'admin', ip: '203.0.113.10', when: '14:02' },
{ id: 'e2', action: 'user.access_update', summary: '[email protected] · +EvoFirewall:agents:write', tone: 'ok', label: 'Info', source: 'Auth Portal', who: 'admin', ip: '203.0.113.10', when: '13:50' },
{ id: 'e3', action: 'fw.policy_apply', summary: 'db-01 · поколение 128 · ошибка nft', tone: 'er', label: 'Critical', source: 'EvoFirewall', who: 'system', ip: '—', when: '12:31' },
{ id: 'e4', action: 'oidc.client_update', summary: 'Technitium DNS · новый секрет', tone: 'ok', label: 'Info', source: 'Auth Portal', who: 'admin', ip: '203.0.113.10', when: '09:12' },
{ id: 'e5', action: 'app_switcher.update', summary: '+CDN Manager в меню приложений', tone: 'ok', label: 'Info', source: 'Auth Portal', who: 'admin', ip: '203.0.113.10', when: 'вчера 18:20' },
{ id: 'e6', action: 'mm.server_delete', summary: 'Удалён сервер old-edge', tone: 'wn', label: 'Warning', source: 'MikrotikManager', who: 'denis', ip: '198.51.100.4', when: 'вчера 15:02' },
{ id: 'e7', action: 'user.create', summary: 'Создан [email protected]', tone: 'ok', label: 'Info', source: 'Auth Portal', who: 'admin', ip: '203.0.113.10', when: '19.06' },
{ id: 'e8', action: 'audit.purge', summary: 'Очищено 212 записей старше 90 дн.', tone: 'wn', label: 'Warning', source: 'Auth Portal', who: 'admin', ip: '203.0.113.10', when: '01.10' },
]
/** Лента с доски «Изменения». */
export const AUDIT_FEED: Array<{ t: string; title: string; text: string; label: string; tone: Tone }> = [
{ t: '14:02', title: 'Отключён пользователь [email protected]', text: 'admin · Auth Portal · 203.0.113.10', label: 'Warning', tone: 'wn' },
{ t: '13:50', title: 'Права обновлены: [email protected]', text: '+EvoFirewall:agents:write · admin · Auth Portal', label: 'Info', tone: 'ok' },
{ t: '12:31', title: 'Применена политика firewall (поколение 128)', text: 'system · EvoFirewall · db-01 — ошибка применения', label: 'Critical', tone: 'er' },
{ t: '09:12', title: 'Обновлён OIDC-клиент Technitium DNS', text: 'новый client_secret · admin · Auth Portal', label: 'Info', tone: 'ok' },
{ t: 'вчера', title: 'Изменён список приложений в переключателе', text: '+CDN Manager · admin · Auth Portal', label: 'Info', tone: 'in' },
]
@@ -1,33 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { import { ChartColumnIcon, CloudIcon, EyeIcon, KeyRoundIcon, LayoutDashboardIcon, LayoutGridIcon, LinkIcon, NetworkIcon, ServerIcon, type LucideIcon } from 'lucide-react'
ChartColumnIcon,
CloudIcon,
EyeIcon,
FingerprintIcon,
KeyRoundIcon,
LayoutDashboardIcon,
LayoutGridIcon,
MoreHorizontalIcon,
NetworkIcon,
PencilIcon,
PlusIcon,
RotateCcwIcon,
ServerIcon,
type LucideIcon,
} from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import type { AppId, AppSwitcherIconName } from '@authportal/shared' import type { AppId, AppSwitcherIconName } from '@authportal/shared'
import { QuickActionGrid } from '@/components/reui-kit/quick-action-grid'
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu'
import { Field, FieldDescription, FieldLabel } from '@authportal/ui/components/field' import { Field, FieldDescription, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input' import { Input } from '@authportal/ui/components/input'
import { import {
@@ -36,16 +11,17 @@ import {
SelectGroup, SelectGroup,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue
} from '@authportal/ui/components/select' } from '@authportal/ui/components/select'
import { Switch } from '@authportal/ui/components/switch' import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' import { KpiStrip } from '@/components/portal/kpi-strip'
import { PageHead, PreviewSheet, StatusBadge } from '../bits' import { PageHead, PortalSheet, StatusBadge, PageTab, PageTabsList, FormFrame, SettingRow } from '@/components/portal/bits'
import { kpi } from '../kpi' import { AppCards } from '@/components/portal/canvas-blocks'
import { kpi } from '@/components/portal/kpi'
import { MOCK_SWITCHER } from '../mock' import { MOCK_SWITCHER } from '../mock'
import { usePreviewStore } from '../preview-store' 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 = { type LinkRow = {
id: AppId id: AppId
@@ -61,9 +37,12 @@ const ICONS: Record<AppSwitcherIconName, { label: string; Icon: LucideIcon }> =
server: { label: 'Сервер', Icon: ServerIcon }, server: { label: 'Сервер', Icon: ServerIcon },
globe: { label: 'Сеть', Icon: NetworkIcon }, globe: { label: 'Сеть', Icon: NetworkIcon },
dashboard: { label: 'Дашборд', Icon: LayoutDashboardIcon }, dashboard: { label: 'Дашборд', Icon: LayoutDashboardIcon },
chart: { label: 'График', Icon: ChartColumnIcon }, chart: { label: 'График', Icon: ChartColumnIcon }
} }
/** Названия иконок, как они подписаны на доске. */
const BOARD_ICON: Record<string, string> = { cfdm: 'cloud', vps: 'server', bgp: 'globe', fw: 'shield', dns: 'globe', cdn: 'cloud', mm: 'layout' }
const MODES = [ const MODES = [
{ value: 'JWT', label: 'JWT SSO (fragment)' }, { value: 'JWT', label: 'JWT SSO (fragment)' },
{ value: 'OIDC', label: 'OIDC' }, { value: 'OIDC', label: 'OIDC' },
@@ -75,24 +54,12 @@ const INITIAL: LinkRow[] = MOCK_SWITCHER.apps.map((a) => ({
url: a.url, url: a.url,
icon: a.icon, icon: a.icon,
mode: (a.authMode ?? 'jwt').toUpperCase() as 'JWT' | 'OIDC', mode: (a.authMode ?? 'jwt').toUpperCase() as 'JWT' | 'OIDC',
enabled: a.id !== 'mm', enabled: a.id !== 'mm'
})) }))
/** Строка настройки в Frame: слева название и описание, справа элемент управления (settings-10). */
function SettingRow({ title, description, children }: { title: string; description: string; children: ReactNode }) {
return (
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b px-4 py-3 last:border-b-0">
<div className="flex min-w-0 flex-1 basis-60 flex-col gap-0.5">
<span className="text-sm font-medium">{title}</span>
<span className="text-muted-foreground text-xs">{description}</span>
</div>
<div className="shrink-0">{children}</div>
</div>
)
}
export function AppLinksPage() { export function AppLinksPage() {
const { addChange } = usePreviewStore() const { addChange, marksFor } = usePreviewStore()
const marks = marksFor('app-links')
const [rows, setRows] = useState<LinkRow[]>(INITIAL) const [rows, setRows] = useState<LinkRow[]>(INITIAL)
const rowsRef = useRef(rows) const rowsRef = useRef(rows)
useEffect(() => { useEffect(() => {
@@ -113,7 +80,9 @@ export function AppLinksPage() {
kind: 'chg', kind: 'chg',
title: enabled ? 'Показать в меню' : 'Скрыть из меню', title: enabled ? 'Показать в меню' : 'Скрыть из меню',
lines: names.map((n) => `${enabled ? '+' : '−'} ${n}`), lines: names.map((n) => `${enabled ? '+' : '−'} ${n}`),
revert: () => setRows(before), resource: 'app-links',
marks: Object.fromEntries(ids.map((i) => [i, 'dirty' as const])),
revert: () => setRows(before)
}) })
}, },
[addChange], [addChange],
@@ -134,7 +103,9 @@ export function AppLinksPage() {
kind: 'chg', kind: 'chg',
title: 'Порядок изменён', title: 'Порядок изменён',
lines: [` ${item!.name}`, `− позиция ${from + 1}`, `+ позиция ${to + 1}`], lines: [` ${item!.name}`, `− позиция ${from + 1}`, `+ позиция ${to + 1}`],
revert: () => setRows(before), resource: 'app-links',
marks: { [item!.id]: 'dirty' },
revert: () => setRows(before)
}) })
}, },
[addChange], [addChange],
@@ -157,83 +128,21 @@ export function AppLinksPage() {
kind: 'chg', kind: 'chg',
title: 'Порядок сброшен', title: 'Порядок сброшен',
lines: ['− пользовательский порядок', '+ порядок каталога'], lines: ['− пользовательский порядок', '+ порядок каталога'],
revert: () => setRows(before), resource: 'app-links',
marks: Object.fromEntries(before.map((r) => [r.id, 'dirty' as const])),
revert: () => setRows(before)
}) })
}, [addChange]) }, [addChange])
const columns = useMemo<GridColumn<LinkRow>[]>( const columns = useMemo<GridColumn<LinkRow>[]>(
() => [ () => [
{ { id: 'name', title: 'Приложение', size: 260, cell: (r) => <TwoLineCell dot={r.enabled ? 'ok' : 'mu'} title={r.name} sub={r.id} /> },
id: 'order', { id: 'status', title: 'Статус', size: 130, cell: (r) => <StatusBadge tone={r.enabled ? 'ok' : 'mu'}>{r.enabled ? 'В меню' : 'Скрыто'}</StatusBadge> },
title: '№', { id: 'icon', title: 'Иконка', size: 120, cell: (r) => <span className="font-mono text-[12.5px]">{BOARD_ICON[r.id] ?? r.icon}</span> },
size: 52, { id: 'url', title: 'URL', size: 260, cell: (r) => <span className="block truncate font-mono text-[12.5px]">{r.url}</span> },
cell: (r) => ( { id: 'mode', title: 'Вход', size: 90, cell: (r) => <span className="font-mono text-[12.5px]">{r.mode}</span> },
<span className="text-muted-foreground text-sm tabular-nums">
{rows.findIndex((x) => x.id === r.id) + 1}
</span>
),
},
{
id: 'name',
title: 'Приложение',
size: 280,
cell: (r) => {
const { Icon } = ICONS[r.icon]
return (
<div className="flex min-w-0 items-center gap-3">
<IconTile variant="elevated" size="sm" aria-hidden className="text-muted-foreground">
<Icon />
</IconTile>
<TwoLineCell title={r.name} sub={r.id} />
</div>
)
},
},
{
id: 'menu',
title: 'В меню',
size: 170,
cell: (r) => (
<div className="flex items-center gap-3" onClick={(e) => e.stopPropagation()}>
<Switch
size="sm"
checked={r.enabled}
onCheckedChange={(on) => setEnabled([r.id], on)}
aria-label={`${r.name}: показывать в меню`}
/>
<StatusBadge tone={r.enabled ? 'ok' : 'mu'}>{r.enabled ? 'В меню' : 'Скрыто'}</StatusBadge>
</div>
),
},
{ id: 'url', title: 'URL', size: 260, cell: (r) => <code className="block truncate font-mono text-[13px]">{r.url}</code> },
{ id: 'mode', title: 'Вход', size: 100, cell: (r) => <StatusBadge tone={r.mode === 'OIDC' ? 'wn' : 'in'}>{r.mode}</StatusBadge> },
{
id: 'act',
title: '',
size: 56,
cell: (r) => (
<div onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger render={<Button type="button" variant="ghost" size="icon-sm" aria-label={`Действия: ${r.name}`} />}>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-48">
<DropdownMenuItem onClick={() => setEditing(r)}>
<PencilIcon aria-hidden />
Настроить
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => moveTo(r.id, () => 0)}>В начало</DropdownMenuItem>
<DropdownMenuItem onClick={() => moveTo(r.id, (i) => i - 1)}>Выше</DropdownMenuItem>
<DropdownMenuItem onClick={() => moveTo(r.id, (i) => i + 1)}>Ниже</DropdownMenuItem>
<DropdownMenuItem onClick={() => moveTo(r.id, (_i, n) => n - 1)}>В конец</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
),
},
], ],
[rows, setEnabled, moveTo], [],
) )
const shown = rows.filter((r) => r.enabled) const shown = rows.filter((r) => r.enabled)
@@ -248,37 +157,35 @@ export function AppLinksPage() {
variant="outline" variant="outline"
onClick={() => onClick={() =>
toast('Добавление недоступно в макете', { toast('Добавление недоступно в макете', {
description: 'Каталог фиксирован контрактом: cfdm · vps · bgp · fw · dns · cdn · mm.', description: 'Каталог фиксирован контрактом: cfdm · vps · bgp · fw · dns · cdn · mm.'
}) })
} }
> >
<PlusIcon aria-hidden />
Добавить приложение Добавить приложение
</Button> </Button>
} }
/> />
<KpiStatGrid <KpiStrip
aria-label="Показатели" aria-label="Показатели"
items={[ items={[
kpi('n', 'Приложений', String(rows.length), { icon: LayoutGridIcon, badge: 'в каталоге', sub: 'сервисов' }), kpi('n', 'Приложений', String(rows.length), { icon: LayoutGridIcon, badge: 'в каталоге' }),
kpi('sw', 'В переключателе', String(shown.length), { icon: EyeIcon, tone: 'ok', badge: 'видны всем', sub: `из ${rows.length}` }), kpi('sw', 'В переключателе', String(shown.length), { icon: EyeIcon, tone: 'ok', badge: 'видны всем' }),
kpi('jwt', 'JWT SSO', String(rows.filter((r) => r.mode === 'JWT').length), { icon: KeyRoundIcon, tone: 'in', badge: 'fragment-токен', sub: 'без пароля' }), kpi('jwt', 'JWT SSO', String(rows.filter((r) => r.mode === 'JWT').length), { icon: KeyRoundIcon, badge: 'fragment-токен' }),
kpi('oidc', 'OIDC', String(rows.filter((r) => r.mode === 'OIDC').length), { icon: FingerprintIcon, tone: 'in', badge: 'Technitium DNS', sub: 'внешний RP' }), kpi('oidc', 'OIDC', String(rows.filter((r) => r.mode === 'OIDC').length), { icon: LinkIcon, badge: 'Technitium DNS' }),
]} ]}
/> />
<Tabs defaultValue="list" className="min-w-0"> <Tabs defaultValue="list" className="min-w-0">
<TabsList> <PageTabsList>
<TabsTrigger value="list">Приложения · {rows.length}</TabsTrigger> <PageTab value="list" label="Приложения" count={rows.length} />
<TabsTrigger value="menu">Меню</TabsTrigger> <PageTab value="menu" label="Меню" />
</TabsList> </PageTabsList>
<TabsContent value="list" className="min-w-0"> <TabsContent value="list" className="min-w-0">
<PreviewSectionGrid <SectionGrid
title="Ссылки сервисов" title="Ссылки сервисов"
description="Перетащите строку за ручку: порядок строк = порядок в меню · изменения применяются после сохранения" description="Порядок строк = порядок в меню · переключатель управляет видимостью · изменения применяются после сохранения"
rows={rows} rows={rows}
columns={columns} columns={columns}
getRowId={(r) => r.id} getRowId={(r) => r.id}
selectable
pageSize={10} pageSize={10}
searchText={(r) => `${r.name} ${r.id} ${r.url}`} searchText={(r) => `${r.name} ${r.id} ${r.url}`}
searchPlaceholder="Название или адрес" searchPlaceholder="Название или адрес"
@@ -287,71 +194,60 @@ export function AppLinksPage() {
{ label: 'В меню', match: (r) => r.enabled }, { label: 'В меню', match: (r) => r.enabled },
{ label: 'Скрытые', match: (r) => !r.enabled }, { label: 'Скрытые', match: (r) => !r.enabled },
]} ]}
toolbar={ tools={
<Button type="button" size="sm" variant="outline" onClick={resetOrder}> <Button type="button" variant="outline" onClick={resetOrder}>
<RotateCcwIcon aria-hidden />
Сбросить порядок Сбросить порядок
</Button> </Button>
} }
bulkActions={(ids, clear) => ( bulk={{
<> onLabel: 'Показать в меню',
<Button type="button" size="sm" variant="outline" onClick={() => { setEnabled(ids as AppId[], true); clear() }}> offLabel: 'Скрыть',
Показать в меню onRun: (ids) => setEnabled(ids as AppId[], true),
</Button> offRun: (ids) => setEnabled(ids as AppId[], false)
<Button type="button" size="sm" variant="outline" onClick={() => { setEnabled(ids as AppId[], false); clear() }}> }}
Скрыть rowSwitch={{ value: (r) => r.enabled, onChange: (r, on) => setEnabled([r.id], on) }}
</Button> rowMenu={(r) => ({
</> onOpen: () => setEditing(r),
)} toggleLabel: r.enabled ? 'Скрыть из меню' : 'Показать в меню',
onToggle: () => setEnabled([r.id], !r.enabled)
})}
onReorder={dropOn} onReorder={dropOn}
rowStatus={(r) => marks[r.id]}
onRowClick={setEditing} onRowClick={setEditing}
/> />
</TabsContent> </TabsContent>
<TabsContent value="menu" className="flex min-w-0 flex-col gap-4"> <TabsContent value="menu" className="flex min-w-0 flex-col gap-4">
<Frame spacing="sm" className="w-full"> <FormFrame title="Переключатель приложений" description="Как выглядит меню в шапке каждого приложения" badge="Опубликовано">
<FrameHeader className="flex-row items-center justify-between gap-3"> <SettingRow title="Заголовок меню" description="Показывается над списком приложений">
<div className="flex flex-col gap-0.5"> <Input value={menuLabel} onChange={(e) => setMenuLabel(e.target.value)} aria-label="Заголовок меню" className="w-56 max-w-full" />
<FrameTitle>Переключатель приложений</FrameTitle> </SettingRow>
<FrameDescription>Как выглядит меню в шапке каждого приложения</FrameDescription> <SettingRow title="Показывать текущее приложение" description="Отмечать галочкой приложение, из которого открыто меню">
</div> <Switch checked={showCurrent} onCheckedChange={setShowCurrent} aria-label="Показывать текущее приложение" />
<StatusBadge tone="ok">Опубликовано</StatusBadge> </SettingRow>
</FrameHeader> <SettingRow title="Открывать в новой вкладке" description="Иначе переход в текущей вкладке">
<FramePanel className="p-0"> <Switch checked={newTab} onCheckedChange={setNewTab} aria-label="Открывать в новой вкладке" />
<SettingRow title="Заголовок меню" description="Показывается над списком приложений"> </SettingRow>
<Input </FormFrame>
value={menuLabel} <AppCards
onChange={(e) => setMenuLabel(e.target.value)}
aria-label="Заголовок меню"
className="w-56 max-w-full"
/>
</SettingRow>
<SettingRow title="Показывать текущее приложение" description="Отмечать галочкой приложение, из которого открыто меню">
<Switch checked={showCurrent} onCheckedChange={setShowCurrent} aria-label="Показывать текущее приложение" />
</SettingRow>
<SettingRow title="Открывать в новой вкладке" description="Иначе переход в текущей вкладке">
<Switch checked={newTab} onCheckedChange={setNewTab} aria-label="Открывать в новой вкладке" />
</SettingRow>
</FramePanel>
</Frame>
<QuickActionGrid
title="Предпросмотр меню" title="Предпросмотр меню"
description={`«${menuLabel}» · так увидят пользователи с доступом ко всем приложениям`} description={`«${menuLabel}» · так увидят пользователи с доступом ко всем приложениям`}
actions={shown.slice(0, 4).map((r) => { items={shown.slice(0, 4).map((r) => {
const { Icon } = ICONS[r.icon] const { Icon } = ICONS[r.icon]
return { return {
id: r.id, id: r.id,
title: r.name, title: r.name,
description: r.url.replace('https://', ''), desc: r.url.replace('https://', ''),
badgeLabel: r.mode, badge: r.mode,
badgeVariant: r.mode === 'OIDC' ? ('warning-light' as const) : ('info-light' as const), badgeVariant: r.mode === 'OIDC' ? ('warning-light' as const) : ('info-light' as const),
icon: <Icon />, meta: r.id,
icon: <Icon />
} }
})} })}
/> />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<PreviewSheet <PortalSheet
open={editing !== null} open={editing !== null}
onOpenChange={(o) => !o && setEditing(null)} onOpenChange={(o) => !o && setEditing(null)}
title="Приложение" title="Приложение"
@@ -427,7 +323,7 @@ export function AppLinksPage() {
</Field> </Field>
</> </>
) : null} ) : null}
</PreviewSheet> </PortalSheet>
</> </>
) )
} }
+46 -40
View File
@@ -1,11 +1,13 @@
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { CloudIcon, GlobeIcon, LayoutDashboardIcon, ServerIcon, ShieldIcon } from 'lucide-react' import { BadgeCheckIcon, CloudIcon, GlobeIcon, KeyRoundIcon, LayoutDashboardIcon, LayoutGridIcon, ServerIcon, ShieldIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { APPS, type AppId } from '@authportal/shared' import { APPS, type AppId } from '@authportal/shared'
import { QuickActionGrid } from '@/components/reui-kit/quick-action-grid' import { KpiStrip } from '@/components/portal/kpi-strip'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { PageHead } from '../bits' 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> = { const ICONS: Record<AppId, typeof CloudIcon> = {
cfdm: CloudIcon, cfdm: CloudIcon,
@@ -14,7 +16,7 @@ const ICONS: Record<AppId, typeof CloudIcon> = {
fw: ShieldIcon, fw: ShieldIcon,
dns: GlobeIcon, dns: GlobeIcon,
cdn: CloudIcon, cdn: CloudIcon,
mm: LayoutDashboardIcon, mm: LayoutDashboardIcon
} }
const NO_ACCESS: AppId[] = ['cdn'] const NO_ACCESS: AppId[] = ['cdn']
@@ -23,57 +25,61 @@ export function AppsPage() {
const allowed = APPS.filter((a) => !NO_ACCESS.includes(a.id)) const allowed = APPS.filter((a) => !NO_ACCESS.includes(a.id))
const denied = APPS.filter((a) => NO_ACCESS.includes(a.id)) const denied = APPS.filter((a) => NO_ACCESS.includes(a.id))
const toCard = (app: (typeof APPS)[number], open: boolean): AppCardItem => {
const Icon = ICONS[app.id]
const meta = `${app.url.replace('https://', '')} · ${app.authMode === '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
? toast(`Открыть ${app.title}`, { description: `${meta} (макет)` })
: toast.warning(`Нет доступа к ${app.title}`, { description: 'Запросите права у администратора.' })
}
}
return ( return (
<> <>
<PageHead <PageHead
title="Приложения" title="Приложения"
sub="Открыть приложение с текущей сессией · SSO handoff без повторного ввода пароля" sub="Открыть приложение с текущей сессией · SSO handoff без повторного ввода пароля"
actions={ actions={
<Button variant="outline" size="sm" render={<Link to="/design-preview/app-links" />} nativeButton={false}> <Button variant="outline" render={<Link to="/design-preview/app-links" />} nativeButton={false}>
Ссылки приложений Ссылки приложений
</Button> </Button>
} }
/> />
<KpiStrip
aria-label="Показатели"
items={[
kpi('avail', 'Доступно приложений', String(allowed.length), { icon: LayoutGridIcon, tone: 'ok', badge: `из ${APPS.length}` }),
kpi('jwt', 'JWT SSO', String(allowed.filter((a) => a.authMode === 'jwt').length), { icon: KeyRoundIcon, badge: 'fragment-токен' }),
kpi('oidc', 'OIDC', String(allowed.filter((a) => a.authMode === 'oidc').length), { icon: BadgeCheckIcon, badge: 'Technitium DNS' }),
kpi('role', 'Роль', 'Админ', { icon: ShieldIcon, badge: 'is_admin' }),
]}
/>
<Tabs defaultValue="all" className="min-w-0"> <Tabs defaultValue="all" className="min-w-0">
<TabsList> <PageTabsList>
<TabsTrigger value="all">Доступные · {allowed.length}</TabsTrigger> <PageTab value="all" label="Доступные" count={allowed.length} />
<TabsTrigger value="no">Без доступа · {denied.length}</TabsTrigger> <PageTab value="no" label="Без доступа" count={denied.length} />
</TabsList> </PageTabsList>
<TabsContent value="all" className="flex flex-col gap-4"> <TabsContent value="all" className="min-w-0">
<QuickActionGrid <AppCards
title="Мои приложения" title="Мои приложения"
description="Клик открывает приложение · портал заново выпускает токен с актуальными правами" description="Клик открывает приложение в новой вкладке · портал заново выпускает токен с актуальными правами"
actions={allowed.map((app) => { items={allowed.map((a) => toCard(a, true))}
const Icon = ICONS[app.id]
return {
id: app.id,
title: app.title,
description: app.description,
badgeLabel: 'Открыть',
badgeVariant: 'info-light' as const,
meta: `${app.url.replace('https://', '')} · ${app.authMode === 'oidc' ? 'OIDC' : 'JWT'}`,
icon: <Icon />,
onSelect: () => toast(`Открыть ${app.title}`, { description: `${app.url.replace('https://', '')} · ${app.authMode.toUpperCase()} (макет)` }),
}
})}
/> />
</TabsContent> </TabsContent>
<TabsContent value="no"> <TabsContent value="no" className="min-w-0">
<QuickActionGrid <AppCards
title="Недоступные приложения" title="Недоступные приложения"
description="Права не выданы · запросите у администратора" description="Права не выданы · запросите у администратора"
actions={denied.map((app) => { items={denied.map((a) => toCard(a, false))}
const Icon = ICONS[app.id]
return {
id: app.id,
title: app.title,
description: app.description,
badgeLabel: 'Нет доступа',
meta: `${app.url.replace('https://', '')} · ${app.authMode === 'oidc' ? 'OIDC' : 'JWT'}`,
icon: <Icon />,
onSelect: () => toast.warning(`Нет доступа к ${app.title}`, { description: 'Запросите права у администратора.' }),
}
})}
/> />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
@@ -1,48 +1,38 @@
import { HistoryIcon, NetworkIcon, OctagonAlertIcon, UserCogIcon } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { DownloadIcon, Trash2Icon, HistoryIcon, UserCogIcon, ShieldAlertIcon, NetworkIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import type { AuditLogEntry, AuditSeverity } from '@authportal/shared' import { KpiStrip } from '@/components/portal/kpi-strip'
import { AuditLogTimeline } from '@/components/reui-kit/audit-log-timeline'
import { AuditRetentionPanel } from '@/components/reui-kit/audit-retention-panel'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' import { Input } from '@authportal/ui/components/input'
import { CodeBlock, KvList, PageHead, PreviewSheet, StatusBadge, TypedConfirm } from '../bits' import { Switch } from '@authportal/ui/components/switch'
import { kpi } from '../kpi' import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { MOCK_AUDIT, APP_SHORT, type Tone } from '../mock' import { CodeBlock, FormFrame, KvList, PageHead, PageTab, PageTabsList, PortalSheet, SettingRow, StatusBadge, TypedConfirm } from '@/components/portal/bits'
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid' import { ActivityFeed } from '@/components/portal/canvas-blocks'
import { kpi } from '@/components/portal/kpi'
import { AUDIT_FEED, AUDIT_ROWS, type AuditRow } from '../mock'
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
const SEVERITY_TONE: Record<AuditSeverity, Tone> = { info: 'ok', warning: 'wn', critical: 'er' } const mono = (v: string) => <span className="font-mono text-[12.5px] whitespace-nowrap">{v}</span>
const SEVERITY_LABEL: Record<AuditSeverity, string> = { info: 'Info', warning: 'Warning', critical: 'Critical' }
function sourceLabel(e: AuditLogEntry) { const columns: GridColumn<AuditRow>[] = [
return e.source_app === 'portal' ? 'Auth Portal' : APP_SHORT[e.source_app] { 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) => mono(r.source) },
function whenLabel(iso: string) { { id: 'who', title: 'Кто', size: 120, cell: (r) => mono(r.who) },
return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }).format(new Date(iso)) { id: 'ip', title: 'IP', size: 140, cell: (r) => mono(r.ip) },
} { id: 'when', title: 'Когда', size: 140, cell: (r) => mono(r.when) },
const columns: GridColumn<AuditLogEntry>[] = [
{ id: 'action', title: 'Событие', size: 340, sortValue: (r) => r.action, cell: (r) => <TwoLineCell dot={SEVERITY_TONE[r.severity]} title={<span className="font-mono text-[13px]">{r.action}</span>} sub={r.summary} /> },
{ id: 'sev', title: 'Важность', size: 120, sortValue: (r) => r.severity, cell: (r) => <StatusBadge tone={SEVERITY_TONE[r.severity]}>{SEVERITY_LABEL[r.severity]}</StatusBadge> },
{ id: 'src', title: 'Источник', size: 160, sortValue: (r) => r.source_app, cell: (r) => <span className="text-sm">{sourceLabel(r)}</span> },
{ id: 'who', title: 'Кто', size: 140, cell: (r) => <span className="text-sm">{r.actor_name ?? '—'}</span> },
{ id: 'ip', title: 'IP', size: 140, cell: (r) => <span className="font-mono text-[13px]">{r.ip ?? '—'}</span> },
{ id: 'when', title: 'Когда', size: 140, sortValue: (r) => r.created_at, cell: (r) => <span className="text-sm whitespace-nowrap">{whenLabel(r.created_at)}</span> },
] ]
const VIEWS = [ const VIEWS = [
{ label: 'Все', match: () => true }, { label: 'Все', match: () => true },
{ label: 'Auth', match: (r: AuditLogEntry) => r.action.startsWith('auth.') }, { label: 'Warning и выше', match: (r: AuditRow) => r.tone === 'wn' || r.tone === 'er' },
{ label: 'Пользователи', match: (r: AuditLogEntry) => r.action.startsWith('user.') }, { label: 'Critical', match: (r: AuditRow) => r.tone === 'er' },
{ label: 'Настройки', match: (r: AuditLogEntry) => /^(oidc|app_switcher|audit)\./.test(r.action) },
{ label: 'Критичные', match: (r: AuditLogEntry) => r.severity === 'critical' },
] ]
export function AuditPage() { export function AuditPage() {
const [entry, setEntry] = useState<AuditLogEntry | null>(null) const [entry, setEntry] = useState<AuditRow | null>(null)
const [purgeOpen, setPurgeOpen] = useState(false) const [purgeOpen, setPurgeOpen] = useState(false)
const [tab, setTab] = useState('feed')
const [retention, setRetention] = useState(90) const [retention, setRetention] = useState(90)
return ( return (
@@ -52,63 +42,78 @@ export function AuditPage() {
sub="Журнал действий администраторов и приложений · портал, EvoFirewall, EvoBGP, MikrotikManager и другие через ingest" sub="Журнал действий администраторов и приложений · портал, EvoFirewall, EvoBGP, MikrotikManager и другие через ingest"
actions={ actions={
<Button variant="outline" onClick={() => setPurgeOpen(true)}> <Button variant="outline" onClick={() => setPurgeOpen(true)}>
<Trash2Icon aria-hidden />
Очистить старые записи… Очистить старые записи…
</Button> </Button>
} }
/> />
<KpiStatGrid <KpiStrip
aria-label="Показатели" aria-label="Показатели"
items={[ items={[
kpi('all', 'Событий', '148', { icon: HistoryIcon, badge: 'за 7 дней', sub: 'во всех источниках' }), kpi('all', 'Событий', '148', { icon: HistoryIcon, badge: 'за 7 дней' }),
kpi('acc', 'Изменений прав', '22', { icon: UserCogIcon, tone: 'ok', badge: 'user.access_update', sub: 'в портале' }), kpi('acc', 'Изменений прав', '22', { icon: UserCogIcon, tone: 'ok', badge: 'user.access_update' }),
kpi('crit', 'Критичных', String(MOCK_AUDIT.filter((e) => e.severity === 'critical').length), { icon: ShieldAlertIcon, tone: 'er', badge: 'critical', sub: 'требуют внимания' }), kpi('crit', 'Критичных', '3', { icon: OctagonAlertIcon, tone: 'er', badge: 'critical' }),
kpi('src', 'Источников', '6', { icon: NetworkIcon, tone: 'in', badge: 'ingest', sub: 'портал + 5 приложений' }), kpi('src', 'Источников', '6', { icon: NetworkIcon, badge: 'портал +5 приложений' }),
]} ]}
/> />
<Tabs defaultValue="feed" className="min-w-0"> <Tabs value={tab} onValueChange={setTab} className="min-w-0">
<TabsList> <PageTabsList>
<TabsTrigger value="feed">Лента</TabsTrigger> <PageTab value="feed" label="Лента" />
<TabsTrigger value="table">Таблица · {MOCK_AUDIT.length}</TabsTrigger> <PageTab value="table" label="Таблица" count={148} />
<TabsTrigger value="retention">Ротация</TabsTrigger> <PageTab value="retention" label="Ротация" />
</TabsList> </PageTabsList>
<TabsContent value="feed" className="min-w-0"> <TabsContent value="feed" className="min-w-0">
<AuditLogTimeline entries={MOCK_AUDIT} totalCount={MOCK_AUDIT.length} /> <ActivityFeed
</TabsContent> title="Лента изменений"
<TabsContent value="table" className="min-w-0"> description="Последние события · иконка отражает тип, цвет — важность"
<PreviewSectionGrid items={AUDIT_FEED}
title="Журнал изменений" action={
description="Поиск по событию, пользователю и IP" <Button variant="outline" onClick={() => setTab('table')}>
rows={MOCK_AUDIT} Весь журнал
columns={columns}
getRowId={(r) => r.id}
searchText={(r) => `${r.action} ${r.summary} ${r.actor_email ?? ''} ${r.ip ?? ''}`}
searchPlaceholder="Событие, пользователь, IP"
views={VIEWS}
onRowClick={setEntry}
toolbar={
<Button variant="outline" size="sm" onClick={() => toast.success('CSV сформирован (макет)')}>
<DownloadIcon aria-hidden />
Экспорт CSV
</Button> </Button>
} }
/> />
</TabsContent> </TabsContent>
<TabsContent value="retention"> <TabsContent value="table" className="min-w-0">
<AuditRetentionPanel <SectionGrid
retentionDays={retention} title="Журнал изменений"
saving={false} description="Поиск по событию, пользователю и IP · виды по важности"
purging={false} rows={AUDIT_ROWS}
onSave={(days) => { columns={columns}
setRetention(days) getRowId={(r) => r.id}
toast.success(`Срок хранения: ${days} дн. (макет)`) searchText={(r) => `${r.action} ${r.summary} ${r.who} ${r.ip}`}
}} views={VIEWS}
onPurge={() => toast.success('Старые записи очищены (макет)')} footer="Показано 8 из 148"
onRowClick={setEntry}
/> />
</TabsContent> </TabsContent>
<TabsContent value="retention">
<FormFrame title="Ротация журнала" description="Сколько хранить записи и как очищать" badge={`${retention} дней`}>
<SettingRow title="Срок хранения" description="Записи старше будут удаляться автоматически">
<Input
key={retention}
defaultValue={`${retention} дней`}
aria-label="Срок хранения"
className="w-40 max-w-full font-mono"
onBlur={(e) => {
const n = Number.parseInt(e.target.value, 10)
if (Number.isFinite(n) && n >= 7 && n <= 3650 && n !== retention) {
setRetention(n)
toast.success(`Срок хранения: ${n} дн. (макет)`)
}
}}
/>
</SettingRow>
<SettingRow title="Автоматическая очистка" description="Раз в сутки в 03:00">
<Switch defaultChecked aria-label="Автоматическая очистка" />
</SettingRow>
<SettingRow title="Записей в журнале" description="Из них старше срока хранения — 212">
<StatusBadge tone="mu">1 482</StatusBadge>
</SettingRow>
</FormFrame>
</TabsContent>
</Tabs> </Tabs>
<PreviewSheet <PortalSheet
open={entry !== null} open={entry !== null}
onOpenChange={(o) => !o && setEntry(null)} onOpenChange={(o) => !o && setEntry(null)}
title="Событие журнала" title="Событие журнала"
@@ -117,22 +122,22 @@ export function AuditPage() {
> >
{entry ? ( {entry ? (
<> <>
<KvList items={[['Событие', `${entry.action} · ${entry.summary}`]]} />
<div> <div>
<StatusBadge tone={SEVERITY_TONE[entry.severity]}>{SEVERITY_LABEL[entry.severity]}</StatusBadge> <StatusBadge tone={entry.tone}>{entry.label}</StatusBadge>
</div> </div>
<KvList <KvList
items={[ items={[
['Событие', `${entry.action} · ${entry.summary}`], ['Источник', entry.source],
['Источник', sourceLabel(entry)], ['Кто', `${entry.who}@shnt.top · ${entry.ip}`],
['Кто', `${entry.actor_email ?? 'system'}${entry.ip ? ` · ${entry.ip}` : ''}`],
]} ]}
/> />
<CodeBlock label="Подробности"> <CodeBlock label="Подробности">
{JSON.stringify({ target_type: entry.target_type, target_id: entry.target_id, ...entry.details }, null, 2)} {'target_type: user\ntarget_id: 7c1e…a3\nbefore: { disabled: false }\nafter: { disabled: true }'}
</CodeBlock> </CodeBlock>
</> </>
) : null} ) : null}
</PreviewSheet> </PortalSheet>
<TypedConfirm <TypedConfirm
open={purgeOpen} 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 { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input' import { Input } from '@authportal/ui/components/input'
import { Separator } from '@authportal/ui/components/separator' import { Separator } from '@authportal/ui/components/separator'
import { SignalAlert } from '../bits' import { SignalAlert } from '@/components/portal/bits'
const PERKS = [ const PERKS = [
{ icon: FingerprintIcon, title: 'Passkey вместо пароля', text: 'Отпечаток, Face ID или ключ безопасности' }, { icon: FingerprintIcon, title: 'Passkey вместо пароля', text: 'Отпечаток, Face ID или ключ безопасности' },
@@ -1,14 +1,15 @@
import { CircleCheckIcon, GlobeIcon, MonitorSmartphoneIcon, ShieldAlertIcon } from 'lucide-react'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { Link, useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { DownloadIcon, LogInIcon, ShieldAlertIcon, GlobeIcon, MonitorSmartphoneIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' import { KpiStrip } from '@/components/portal/kpi-strip'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { Segmented, CodeBlock, KvList, PageHead, PreviewSheet, SignalAlert, StatusBadge } from '../bits' import { ExplainBanner } from '@/components/portal/explain-banner'
import { kpi } from '../kpi' 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 { 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 д'] const PERIODS = ['24 ч', '7 д', '30 д', '90 д']
@@ -21,11 +22,11 @@ export function LoginsPage() {
const loginCols = useMemo<GridColumn<LoginRow>[]>( const loginCols = useMemo<GridColumn<LoginRow>[]>(
() => [ () => [
{ id: 'login', title: 'Логин', size: 260, sortValue: (r) => r.login, cell: (r) => <TwoLineCell dot={r.tone} title={r.login} sub={r.client} /> }, { 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: 120, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> }, { id: 'status', title: 'Статус', size: 120, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> },
{ id: 'time', title: 'Время', size: 130, cell: (r) => <span className="text-sm whitespace-nowrap">{r.time}</span> }, { id: 'time', title: 'Время', size: 130, 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-[13px]">{r.ip}</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="text-sm">{r.app}</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="text-sm">{r.reason}</span> }, { id: 'reason', title: 'Причина', size: 200, cell: (r) => <span className="font-mono text-[12.5px]">{r.reason}</span> },
], ],
[], [],
) )
@@ -34,28 +35,9 @@ export function LoginsPage() {
() => [ () => [
{ id: 'user', title: 'Пользователь', size: 240, sortValue: (r) => r.user, cell: (r) => <TwoLineCell dot={r.tone} title={r.user} sub={r.note || undefined} /> }, { id: 'user', title: 'Пользователь', size: 240, sortValue: (r) => r.user, cell: (r) => <TwoLineCell dot={r.tone} title={r.user} sub={r.note || undefined} /> },
{ id: 'status', title: 'Статус', size: 110, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> }, { id: 'status', title: 'Статус', size: 110, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> },
{ id: 'ip', title: 'IP · клиент', size: 260, cell: (r) => <span className="text-sm">{r.ipClient}</span> }, { id: 'ip', title: 'IP · клиент', size: 260, cell: (r) => <span className="font-mono text-[12.5px]">{r.ipClient}</span> },
{ id: 'created', title: 'Создана', size: 140, cell: (r) => <span className="text-sm whitespace-nowrap">{r.created}</span> }, { id: 'created', title: 'Создана', size: 140, cell: (r) => <span className="font-mono text-[12.5px] whitespace-nowrap">{r.created}</span> },
{ id: 'exp', title: 'Истекает', size: 120, cell: (r) => <span className="text-sm whitespace-nowrap">{r.expires}</span> }, { id: 'exp', title: 'Истекает', size: 120, cell: (r) => <span className="font-mono text-[12.5px] whitespace-nowrap">{r.expires}</span> },
{
id: 'act',
title: '',
size: 130,
cell: (r) => (
<Button
type="button"
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation()
setSessions((s) => s.filter((x) => x.id !== r.id))
toast.success(`Сессия ${r.user} завершена (макет)`)
}}
>
Завершить
</Button>
),
},
], ],
[], [],
) )
@@ -67,44 +49,40 @@ export function LoginsPage() {
sub="Журнал входов и активные сессии · портал и SSO в приложения" sub="Журнал входов и активные сессии · портал и SSO в приложения"
actions={ actions={
<Button variant="outline" onClick={() => toast.success('CSV сформирован (макет)')}> <Button variant="outline" onClick={() => toast.success('CSV сформирован (макет)')}>
<DownloadIcon aria-hidden />
Экспорт CSV Экспорт CSV
</Button> </Button>
} }
/> />
<SignalAlert <ExplainBanner
tone="er" tone="er"
title="Подозрительная серия неудач: [email protected]." title="Подозрительная серия неудач: [email protected]."
action={ text="6 неудачных входов с 203.0.113.58 за 10 минут. Действия: «Показать», отключить пользователя в разделе «Пользователи»."
<Button variant="outline" size="sm" render={<Link to="/design-preview/users" />} nativeButton={false}>
К пользователям
</Button>
}
> >
6 неудачных входов с 203.0.113.58 за 10 минут. При необходимости отключите учётную запись в разделе «Пользователи». 6 неудачных входов с 203.0.113.58 за 10 минут. Действия: «Показать», отключить пользователя в разделе «Пользователи».
</SignalAlert> </ExplainBanner>
<Segmented label="Период" options={PERIODS} value={period} onChange={setPeriod} /> <Segmented label="Период" options={PERIODS} value={period} onChange={setPeriod} />
<KpiStatGrid <KpiStrip
aria-label="Показатели" aria-label="Показатели"
items={[ items={[
kpi('ok', 'Успешные (24ч)', '38', { icon: LogInIcon, tone: 'ok', badge: 'входы и SSO', sub: 'без ошибок' }), kpi('ok', 'Успешные (24ч)', '38', { icon: CircleCheckIcon, tone: 'ok', badge: 'входы и SSO' }),
kpi('fail', 'Неудачи (24ч)', '6', { icon: ShieldAlertIcon, tone: 'er', badge: 'неверный пароль', sub: 'с одного IP' }), kpi('fail', 'Неудачи (24ч)', '6', { icon: ShieldAlertIcon, tone: 'er', badge: 'неверный пароль' }),
kpi('ip', 'Уникальные IP (24ч)', '9', { icon: GlobeIcon, badge: '2 новых', sub: 'адреса' }), kpi('ip', 'Уникальные IP (24ч)', '9', { icon: GlobeIcon, badge: '2 новых' }),
kpi('sess', 'Активные сессии', String(sessions.length), { icon: MonitorSmartphoneIcon, tone: 'in', badge: '3 пользователя', sub: 'токены' }), kpi('sess', 'Активные сессии', String(sessions.length), { icon: MonitorSmartphoneIcon, badge: '3 пользователя' }),
]} ]}
/> />
<Tabs defaultValue="hist" className="min-w-0"> <Tabs defaultValue="hist" className="min-w-0">
<TabsList> <PageTabsList>
<TabsTrigger value="hist">История · 44</TabsTrigger> <PageTab value="hist" label="История" count={'44'} />
<TabsTrigger value="sess">Активные сессии · {sessions.length}</TabsTrigger> <PageTab value="sess" label="Активные сессии" count={sessions.length} />
</TabsList> </PageTabsList>
<TabsContent value="hist" className="flex min-w-0 flex-col gap-4"> <TabsContent value="hist" className="flex min-w-0 flex-col gap-4">
<PreviewSectionGrid <SectionGrid
title="Журнал входов" title="Журнал входов"
description={`Поиск по логину и IP · период: ${PERIODS[period]}`} description="Поиск по логину и IP · фильтры: результат, приложение, пользователь · период выбирается выше"
rows={MOCK_LOGINS} rows={MOCK_LOGINS}
columns={loginCols} columns={loginCols}
getRowId={(r) => r.id} getRowId={(r) => r.id}
footer="Показано 8 из 44"
searchText={(r) => `${r.login} ${r.ip} ${r.app}`} searchText={(r) => `${r.login} ${r.ip} ${r.app}`}
searchPlaceholder="Логин или IP" searchPlaceholder="Логин или IP"
views={[ views={[
@@ -116,18 +94,25 @@ export function LoginsPage() {
/> />
</TabsContent> </TabsContent>
<TabsContent value="sess" className="flex min-w-0 flex-col gap-4"> <TabsContent value="sess" className="flex min-w-0 flex-col gap-4">
<PreviewSectionGrid <SectionGrid
title="Активные сессии" title="Активные сессии"
description="Токены, действующие сейчас · завершение сессии выкидывает пользователя из портала и приложений" description="Токены, действующие сейчас · завершение сессии выкидывает пользователя из портала и приложений"
rows={sessions} rows={sessions}
columns={sessionCols} columns={sessionCols}
getRowId={(r) => r.id} getRowId={(r) => r.id}
emptyMessage="Нет активных сессий." emptyMessage="Нет активных сессий."
rowMenu={(r) => ({
toggleLabel: 'Завершить сессию',
onToggle: () => {
setSessions((s) => s.filter((x) => x.id !== r.id))
toast.success(`Сессия ${r.user} завершена (макет)`)
}
})}
/> />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<PreviewSheet <PortalSheet
open={event !== null} open={event !== null}
onOpenChange={(o) => !o && setEvent(null)} onOpenChange={(o) => !o && setEvent(null)}
title="Событие входа" title="Событие входа"
@@ -163,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> <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} ) : null}
</PreviewSheet> </PortalSheet>
</> </>
) )
} }
+50 -49
View File
@@ -1,8 +1,9 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { CopyIcon, PlusIcon, RefreshCwIcon, AppWindowIcon, PowerIcon, TicketIcon, KeyRoundIcon } from 'lucide-react' import { CircleCheckIcon, CopyIcon, KeyRoundIcon, LockKeyholeIcon, TicketIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { CreateOidcClientSheet, type CreateOidcClientValues } from '@/components/reui-kit/create-oidc-client-sheet' import type { CreateOidcClientValues } from '@/components/reui-kit/create-oidc-client-sheet'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' import { CreateOidcPortalSheet } from '../create-sheets'
import { KpiStrip } from '@/components/portal/kpi-strip'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import { import {
AlertDialog, AlertDialog,
@@ -10,24 +11,25 @@ import {
AlertDialogDescription, AlertDialogDescription,
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle
} from '@authportal/ui/components/alert-dialog' } from '@authportal/ui/components/alert-dialog'
import { Field, FieldDescription, FieldLabel } from '@authportal/ui/components/field' import { Field, FieldDescription, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input' import { Input } from '@authportal/ui/components/input'
import { Switch } from '@authportal/ui/components/switch' import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { Textarea } from '@authportal/ui/components/textarea' import { Textarea } from '@authportal/ui/components/textarea'
import { CopyList, KvFrame, PageHead, PreviewSheet, SignalAlert, StatusBadge, TypedConfirm } from '../bits' import { ExplainBanner } from '@/components/portal/explain-banner'
import { kpi } from '../kpi' import { CopyList, KvFrame, PageHead, PortalSheet, StatusBadge, TypedConfirm, PageTab, PageTabsList } from '@/components/portal/bits'
import { kpi } from '@/components/portal/kpi'
import { import {
MOCK_CLIENT_SECRET, MOCK_CLIENT_SECRET,
MOCK_OIDC_CLIENTS, MOCK_OIDC_CLIENTS,
OIDC_CAPABILITIES, OIDC_CAPABILITIES,
OIDC_ENDPOINTS, OIDC_ENDPOINTS,
type OidcClientRow, type OidcClientRow,
type Tone, type Tone
} from '../mock' } 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 { function tone(c: OidcClientRow): Tone {
return !c.enabled ? 'mu' : c.stale ? 'wn' : 'ok' return !c.enabled ? 'mu' : c.stale ? 'wn' : 'ok'
@@ -47,29 +49,10 @@ export function OidcPage() {
() => [ () => [
{ id: 'name', title: 'Клиент', size: 260, sortValue: (r) => r.name, cell: (r) => <TwoLineCell dot={tone(r)} title={r.name} sub={r.hint} /> }, { id: 'name', title: 'Клиент', size: 260, sortValue: (r) => r.name, cell: (r) => <TwoLineCell dot={tone(r)} title={r.name} sub={r.hint} /> },
{ id: 'status', title: 'Статус', size: 150, cell: (r) => <StatusBadge tone={tone(r)}>{statusLabel(r)}</StatusBadge> }, { id: 'status', title: 'Статус', size: 150, cell: (r) => <StatusBadge tone={tone(r)}>{statusLabel(r)}</StatusBadge> },
{ id: 'cid', title: 'client_id', size: 150, cell: (r) => <code className="font-mono text-[13px]">{r.clientId}</code> }, { id: 'cid', title: 'client_id', size: 150, cell: (r) => <code className="font-mono text-[12.5px]">{r.clientId}</code> },
{ id: 'redirect', title: 'Redirect URI', size: 320, cell: (r) => <code className="block truncate font-mono text-[13px]">{r.redirect}</code> }, { id: 'redirect', title: 'Redirect URI', size: 320, cell: (r) => <code className="block truncate font-mono text-[12.5px]">{r.redirect}</code> },
{ id: 'secret', title: 'Секрет', size: 140, cell: (r) => <span className="text-sm">{r.secret}</span> }, { id: 'secret', title: 'Секрет', size: 140, cell: (r) => <span className="font-mono text-[12.5px]">{r.secret}</span> },
{ id: 'created', title: 'Создан', size: 120, cell: (r) => <span className="text-sm tabular-nums">{r.created}</span> }, { id: 'created', title: 'Создан', size: 120, cell: (r) => <span className="font-mono text-[12.5px]">{r.created}</span> },
{
id: 'act',
title: '',
size: 170,
cell: (r) => (
<Button
type="button"
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation()
setRotating(r)
}}
>
<RefreshCwIcon aria-hidden />
Обновить секрет
</Button>
),
},
], ],
[], [],
) )
@@ -84,7 +67,7 @@ export function OidcPage() {
secret: 'действует', secret: 'действует',
created: '17.10.2026', created: '17.10.2026',
enabled: values.enabled, enabled: values.enabled,
stale: false, stale: false
} }
setClients((c) => [...c, created]) setClients((c) => [...c, created])
setCreateOpen(false) setCreateOpen(false)
@@ -100,32 +83,35 @@ export function OidcPage() {
sub="Портал как провайдер OpenID Connect · внешние приложения входят через auth.shnt.top" sub="Портал как провайдер OpenID Connect · внешние приложения входят через auth.shnt.top"
actions={ actions={
<Button onClick={() => setCreateOpen(true)}> <Button onClick={() => setCreateOpen(true)}>
<PlusIcon aria-hidden />
Создать клиента Создать клиента
</Button> </Button>
} }
/> />
{staleCount > 0 ? ( {staleCount > 0 ? (
<SignalAlert tone="wn" title="Секрет клиента Technitium DNS старше 90 дней."> <ExplainBanner
tone="wn"
title="Секрет клиента Technitium DNS старше 90 дней."
text="Обновите client_secret и внесите новое значение в Technitium (Settings → SSO). Старый секрет перестанет работать сразу после обновления."
>
Обновите client_secret и внесите новое значение в Technitium (Settings → SSO). Старый секрет перестанет работать сразу после обновления. Обновите client_secret и внесите новое значение в Technitium (Settings → SSO). Старый секрет перестанет работать сразу после обновления.
</SignalAlert> </ExplainBanner>
) : null} ) : null}
<KpiStatGrid <KpiStrip
aria-label="Показатели" aria-label="Показатели"
items={[ items={[
kpi('n', 'Клиентов', String(clients.length), { icon: AppWindowIcon, badge: 'зарегистрировано', sub: 'внешних RP' }), kpi('n', 'Клиентов', String(clients.length), { icon: KeyRoundIcon, badge: 'зарегистрировано' }),
kpi('on', 'Включено', String(clients.filter((c) => c.enabled).length), { icon: PowerIcon, tone: 'ok', badge: 'принимают вход', sub: 'активны' }), kpi('on', 'Включено', String(clients.filter((c) => c.enabled).length), { icon: CircleCheckIcon, tone: 'ok', badge: 'принимают вход' }),
kpi('tok', 'Токенов за 24 ч', '41', { icon: TicketIcon, tone: 'in', badge: 'authorization_code', sub: 'выдано' }), kpi('tok', 'Токенов за 24 ч', '41', { icon: TicketIcon, badge: 'authorization_code' }),
kpi('key', 'Ключ подписи', 'RS256', { icon: KeyRoundIcon, tone: staleCount > 0 ? 'wn' : 'ok', badge: 'ротация вручную', sub: 'JWKS активен' }), kpi('key', 'Ключ подписи', 'RS256', { icon: LockKeyholeIcon, badge: 'ротация вручную' }),
]} ]}
/> />
<Tabs defaultValue="clients" className="min-w-0"> <Tabs defaultValue="clients" className="min-w-0">
<TabsList> <PageTabsList>
<TabsTrigger value="clients">Клиенты · {clients.length}</TabsTrigger> <PageTab value="clients" label="Клиенты" count={clients.length} />
<TabsTrigger value="meta">Параметры issuer</TabsTrigger> <PageTab value="meta" label="Параметры issuer" />
</TabsList> </PageTabsList>
<TabsContent value="clients" className="flex min-w-0 flex-col gap-4"> <TabsContent value="clients" className="flex min-w-0 flex-col gap-4">
<PreviewSectionGrid <SectionGrid
title="Зарегистрированные клиенты" title="Зарегистрированные клиенты"
description="Каждый клиент — внешнее приложение (RP) · секрет показывается один раз при создании и обновлении" description="Каждый клиент — внешнее приложение (RP) · секрет показывается один раз при создании и обновлении"
rows={clients} rows={clients}
@@ -138,6 +124,21 @@ export function OidcPage() {
{ label: 'Включённые', match: (r) => r.enabled }, { label: 'Включённые', match: (r) => r.enabled },
{ label: 'Выключенные', match: (r) => !r.enabled }, { label: 'Выключенные', match: (r) => !r.enabled },
]} ]}
tools={
<Button
type="button"
variant="outline"
onClick={() => setRotating(clients.find((c) => c.stale) ?? clients[0] ?? null)}
>
Обновить секрет…
</Button>
}
rowSwitch={{ value: (r) => r.enabled, onChange: (r, on) => setClients((c) => c.map((x) => (x.id === r.id ? { ...x, enabled: on } : x))) }}
rowMenu={(r) => ({
onOpen: () => setEditing(r),
toggleLabel: r.enabled ? 'Выключить' : 'Включить',
onToggle: () => setClients((c) => c.map((x) => (x.id === r.id ? { ...x, enabled: !x.enabled } : x)))
})}
onRowClick={setEditing} onRowClick={setEditing}
/> />
</TabsContent> </TabsContent>
@@ -147,9 +148,9 @@ export function OidcPage() {
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<CreateOidcClientSheet open={createOpen} onOpenChange={setCreateOpen} pending={false} onSubmit={onCreate} /> <CreateOidcPortalSheet open={createOpen} onOpenChange={setCreateOpen} onSubmit={onCreate} />
<PreviewSheet <PortalSheet
open={editing !== null} open={editing !== null}
onOpenChange={(o) => !o && setEditing(null)} onOpenChange={(o) => !o && setEditing(null)}
title="OIDC-клиент" title="OIDC-клиент"
@@ -192,7 +193,7 @@ export function OidcPage() {
</Field> </Field>
</> </>
) : null} ) : null}
</PreviewSheet> </PortalSheet>
<TypedConfirm <TypedConfirm
open={rotating !== null} open={rotating !== null}
@@ -1,17 +1,17 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { KeyRoundIcon, PlusIcon, TrashIcon, FingerprintIcon, ClockIcon, LockKeyholeIcon, MonitorSmartphoneIcon } from 'lucide-react' import { ClockIcon, FingerprintIcon, KeyRoundIcon, LockIcon, MonitorSmartphoneIcon, TrashIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' import { KpiStrip } from '@/components/portal/kpi-strip'
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import { Field, FieldDescription, FieldGroup, FieldLabel } from '@authportal/ui/components/field' import { Field, FieldDescription, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input' import { Input } from '@authportal/ui/components/input'
import { Switch } from '@authportal/ui/components/switch' import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { KvFrame, KvList, PageHead, PreviewSheet, SignalAlert, StatusBadge, TypedConfirm } from '../bits' import { ExplainBanner } from '@/components/portal/explain-banner'
import { kpi } from '../kpi' 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 { 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() { export function SecurityPage() {
const [keys, setKeys] = useState<PasskeyRow[]>(MOCK_PASSKEYS) const [keys, setKeys] = useState<PasskeyRow[]>(MOCK_PASSKEYS)
@@ -21,13 +21,19 @@ export function SecurityPage() {
const [renameValue, setRenameValue] = useState('') const [renameValue, setRenameValue] = useState('')
const [deleting, setDeleting] = useState<PasskeyRow | null>(null) const [deleting, setDeleting] = useState<PasskeyRow | null>(null)
const [revoked, setRevoked] = useState<string[]>([])
const view = useMemo(
() => keys.map((k) => (revoked.includes(k.id) ? { ...k, tone: 'mu' as const, status: 'Отозван' } : k)),
[keys, revoked],
)
const columns = useMemo<GridColumn<PasskeyRow>[]>( const columns = useMemo<GridColumn<PasskeyRow>[]>(
() => [ () => [
{ id: 'name', title: 'Passkey', size: 280, sortValue: (r) => r.name, cell: (r) => <TwoLineCell dot={r.tone} title={r.name} sub={r.note} /> }, { id: 'name', title: 'Passkey', size: 280, sortValue: (r) => r.name, cell: (r) => <TwoLineCell dot={r.tone} title={r.name} sub={r.note} /> },
{ id: 'kind', title: 'Тип', size: 160, cell: (r) => <span className="text-sm">{r.kind}</span> }, { id: 'status', title: 'Статус', size: 210, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> },
{ id: 'added', title: 'Добавлен', size: 120, cell: (r) => <span className="text-sm tabular-nums">{r.added}</span> }, { id: 'kind', title: 'Тип', size: 160, cell: (r) => <span className="font-mono text-[12.5px]">{r.kind}</span> },
{ id: 'last', title: 'Последний вход', size: 160, cell: (r) => <span className="text-sm">{r.lastUsed}</span> }, { id: 'added', title: 'Добавлен', size: 120, cell: (r) => <span className="font-mono text-[12.5px]">{r.added}</span> },
{ id: 'status', title: 'Статус', size: 200, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> }, { id: 'last', title: 'Последний вход', size: 160, cell: (r) => <span className="font-mono text-[12.5px]">{r.lastUsed}</span> },
], ],
[], [],
) )
@@ -39,42 +45,60 @@ export function SecurityPage() {
sub="Passkey как альтернатива паролю · пароль остаётся запасным входом" sub="Passkey как альтернатива паролю · пароль остаётся запасным входом"
actions={ actions={
<Button onClick={() => setAddOpen(true)}> <Button onClick={() => setAddOpen(true)}>
<PlusIcon aria-hidden />
Добавить passkey Добавить passkey
</Button> </Button>
} }
/> />
{keys.length === 1 ? ( <ExplainBanner
<SignalAlert tone="wn" title="Один passkey — единственная копия."> tone="wn"
title="Один passkey — единственная копия."
text="Если устройство будет потеряно, вход возможен только по паролю. Добавьте второй ключ на другом устройстве или ключ безопасности."
>
Если устройство будет потеряно, вход возможен только по паролю. Добавьте второй ключ на другом устройстве или ключ безопасности. Если устройство будет потеряно, вход возможен только по паролю. Добавьте второй ключ на другом устройстве или ключ безопасности.
</SignalAlert> </ExplainBanner>
) : null} <KpiStrip
<KpiStatGrid
aria-label="Показатели" aria-label="Показатели"
items={[ items={[
kpi('keys', 'Passkeys', String(keys.length), { icon: FingerprintIcon, tone: keys.length > 1 ? 'ok' : 'wn', badge: keys.length > 1 ? 'резерв есть' : 'одна копия', sub: 'активны' }), kpi('keys', 'Passkeys', String(keys.length), { icon: FingerprintIcon, tone: 'ok', badge: 'активны' }),
kpi('last', 'Последний вход', 'сегодня', { icon: ClockIcon, badge: 'Touch ID', sub: '09:41' }), kpi('last', 'Последний вход', 'сегодня', { icon: ClockIcon, badge: 'MacBook Touch ID' }),
kpi('pw', 'Пароль', 'Задан', { icon: LockKeyholeIcon, tone: 'ok', badge: 'запасной вход', sub: 'сменён 11.03' }), kpi('pw', 'Пароль', 'Задан', { icon: LockIcon, badge: 'запасной вход' }),
kpi('sess', 'Сессий', '3', { icon: MonitorSmartphoneIcon, badge: 'устройства', sub: 'сейчас активны' }), kpi('sess', 'Сессий', '3', { icon: MonitorSmartphoneIcon, badge: 'устройства' }),
]} ]}
/> />
<Tabs defaultValue="keys" className="min-w-0"> <Tabs defaultValue="keys" className="min-w-0">
<TabsList className="max-w-full justify-start overflow-x-auto"> <PageTabsList>
<TabsTrigger value="keys">Passkeys · {keys.length}</TabsTrigger> <PageTab value="keys" label="Passkeys" count={keys.length} />
<TabsTrigger value="pass">Пароль</TabsTrigger> <PageTab value="pass" label="Пароль" />
<TabsTrigger value="acc">Учётная запись</TabsTrigger> <PageTab value="acc" label="Учётная запись" />
</TabsList> </PageTabsList>
<TabsContent value="keys" className="flex min-w-0 flex-col gap-4"> <TabsContent value="keys" className="flex min-w-0 flex-col gap-4">
<PreviewSectionGrid <SectionGrid
title="Мои passkeys" title="Мои passkeys"
description="Вход без пароля: отпечаток, Face ID, PIN или ключ безопасности · удаление необратимо" description="Вход без пароля: отпечаток, Face ID, PIN или ключ безопасности · удаление необратимо"
rows={keys} rows={view}
columns={columns} columns={columns}
getRowId={(r) => r.id} getRowId={(r) => r.id}
searchText={(r) => `${r.name} ${r.note} ${r.kind}`}
views={[ views={[
{ label: 'Все', match: () => true }, { label: 'Все', match: () => true },
{ label: 'Давно не используются', match: (r) => r.tone === 'wn' }, { label: 'Давно не используются', match: (r) => r.tone === 'wn' },
]} ]}
rowSwitch={{
value: (r) => r.status !== 'Отозван',
onChange: (r, on) => {
setRevoked((list) => (on ? list.filter((id) => id !== r.id) : [...list, r.id]))
toast(on ? 'Passkey включён (макет)' : 'Passkey отозван (макет)')
}
}}
rowMenu={(r) => ({
onOpen: () => {
setSelected(r)
setRenameValue(r.name)
},
toggleLabel: r.status === 'Отозван' ? 'Включить' : 'Отозвать',
onToggle: () => setRevoked((list) => (list.includes(r.id) ? list.filter((id) => id !== r.id) : [...list, r.id])),
onDelete: () => setDeleting(r)
})}
onRowClick={(r) => { onRowClick={(r) => {
setSelected(r) setSelected(r)
setRenameValue(r.name) setRenameValue(r.name)
@@ -82,41 +106,20 @@ export function SecurityPage() {
/> />
</TabsContent> </TabsContent>
<TabsContent value="pass"> <TabsContent value="pass">
<Frame spacing="sm" className="w-full"> <FormFrame title="Пароль" description="Запасной вход, если passkey недоступен" badge="Задан">
<FrameHeader className="flex-row items-center justify-between gap-3"> <SettingRow title="Последняя смена" description="Рекомендуем менять раз в год">
<div className="flex flex-col gap-0.5"> <StatusBadge tone="mu">11.03.2026</StatusBadge>
<FrameTitle>Пароль</FrameTitle> </SettingRow>
<FrameDescription>Запасной вход, если passkey недоступен</FrameDescription> <SettingRow title="Текущий пароль" description="Для подтверждения смены">
</div> <Input type="password" defaultValue="••••••••••" autoComplete="current-password" aria-label="Текущий пароль" className="w-56 max-w-full font-mono" />
<StatusBadge tone="ok">Задан</StatusBadge> </SettingRow>
</FrameHeader> <SettingRow title="Новый пароль" description="Не короче 12 символов">
<FramePanel> <Input type="password" autoComplete="new-password" aria-label="Новый пароль" className="w-56 max-w-full font-mono" />
<FieldGroup className="max-w-xl"> </SettingRow>
<Field> <SettingRow title="Завершить остальные сессии" description="После смены пароля выйти на всех других устройствах">
<FieldLabel htmlFor="pw-cur">Текущий пароль</FieldLabel> <Switch defaultChecked aria-label="Завершить остальные сессии" />
<Input id="pw-cur" type="password" defaultValue="••••••••••" autoComplete="current-password" /> </SettingRow>
<FieldDescription>Для подтверждения смены · последняя смена 11.03.2026</FieldDescription> </FormFrame>
</Field>
<Field>
<FieldLabel htmlFor="pw-new">Новый пароль</FieldLabel>
<Input id="pw-new" type="password" autoComplete="new-password" />
<FieldDescription>Не короче 12 символов</FieldDescription>
</Field>
<Field orientation="horizontal">
<Switch id="pw-out" defaultChecked />
<div className="flex flex-col">
<FieldLabel htmlFor="pw-out">Завершить остальные сессии</FieldLabel>
<FieldDescription>После смены пароля выйти на всех других устройствах</FieldDescription>
</div>
</Field>
<div>
<Button variant="outline" onClick={() => toast.success('Пароль изменён (макет)')}>
Сменить пароль
</Button>
</div>
</FieldGroup>
</FramePanel>
</Frame>
</TabsContent> </TabsContent>
<TabsContent value="acc"> <TabsContent value="acc">
<KvFrame <KvFrame
@@ -134,7 +137,7 @@ export function SecurityPage() {
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<PreviewSheet <PortalSheet
open={addOpen} open={addOpen}
onOpenChange={setAddOpen} onOpenChange={setAddOpen}
title="Добавить passkey" title="Добавить passkey"
@@ -166,9 +169,9 @@ export function SecurityPage() {
<SignalAlert tone="in" title="Что произойдёт дальше"> <SignalAlert tone="in" title="Что произойдёт дальше">
Откроется системный диалог: подтвердите отпечатком, Face ID, PIN или ключом безопасности. Закрытый ключ не покидает устройство. Откроется системный диалог: подтвердите отпечатком, Face ID, PIN или ключом безопасности. Закрытый ключ не покидает устройство.
</SignalAlert> </SignalAlert>
</PreviewSheet> </PortalSheet>
<PreviewSheet <PortalSheet
open={selected !== null} open={selected !== null}
onOpenChange={(o) => !o && setSelected(null)} onOpenChange={(o) => !o && setSelected(null)}
title="Passkey" title="Passkey"
@@ -210,7 +213,7 @@ export function SecurityPage() {
['Последний вход', `${selected?.lastUsed ?? ''} · 203.0.113.10`], ['Последний вход', `${selected?.lastUsed ?? ''} · 203.0.113.10`],
]} ]}
/> />
</PreviewSheet> </PortalSheet>
<TypedConfirm <TypedConfirm
open={deleting !== null} open={deleting !== null}
+77 -115
View File
@@ -1,52 +1,35 @@
import { UsersIcon, ShieldCheckIcon, FingerprintIcon, UserXIcon } from 'lucide-react' import { FingerprintIcon, ShieldIcon, UserXIcon, UsersIcon } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import type { AdminUser, CreateUserRequest } from '@authportal/shared' import type { AdminUser, CreateUserRequest } from '@authportal/shared'
import { CreateUserSheet } from '@/components/reui-kit/create-user-sheet' import { CreateUserPortalSheet } from '../create-sheets'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' import { KpiStrip } from '@/components/portal/kpi-strip'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { UserPlusIcon } from 'lucide-react' import { AccessSheet } from '../access-sheet'
import { AccessDialog } from '../access-dialog'
import { diffAccess, type AccessValue } from '../access-model' import { diffAccess, type AccessValue } from '../access-model'
import { CodeBlock, KvList, PageHead, PreviewSheet, TypedConfirm } from '../bits' import { PageHead, TypedConfirm, PageTab, PageTabsList } from '@/components/portal/bits'
import { kpi } from '../kpi' import { kpi } from '@/components/portal/kpi'
import { ALL_APP_IDS, APP_SHORT, MOCK_USERS, USER_JOURNAL, type Tone } from '../mock' import { ALL_APP_IDS } from '../mock'
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid' import { ExplainBanner } from '@/components/portal/explain-banner'
import { StatusBadge } from '../bits' import { AccessMatrix } from '../access-matrix'
import { UsersGrid, type UsersGridHandlers } from '../users-grid' import { UsersGrid, type UsersGridHandlers } from '../users-grid'
import { usePreviewStore } from '../preview-store' import { usePreviewStore } from '../preview-store'
import { usePatchUsers } from '../user-actions'
export function UsersPage() { export function UsersPage() {
const { addChange } = usePreviewStore() const { addChange, marksFor, users, setUsers } = usePreviewStore()
const [users, setUsers] = useState<AdminUser[]>(MOCK_USERS) const marks = marksFor('users')
const usersRef = useRef(users) const usersRef = useRef(users)
useEffect(() => { useEffect(() => {
usersRef.current = users usersRef.current = users
}, [users]) }, [users])
const patch = usePatchUsers()
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const [journalFor, setJournalFor] = useState<AdminUser | null>(null)
const [accessFor, setAccessFor] = useState<AdminUser | null>(null) const [accessFor, setAccessFor] = useState<AdminUser | null>(null)
const [deleteFor, setDeleteFor] = useState<AdminUser | null>(null) const [deleteFor, setDeleteFor] = useState<AdminUser | null>(null)
const [resetFor, setResetFor] = useState<AdminUser | null>(null) const [bulkDeleteIds, setBulkDeleteIds] = useState<string[] | null>(null)
const [bulkResetIds, setBulkResetIds] = useState<string[] | null>(null)
/** Меняет поля у выбранных пользователей и кладёт изменение в «Не сохранено». */
const patch = useCallback(
(ids: string[], apply: (u: AdminUser) => AdminUser, title: string, lineFor: (before: AdminUser, after: AdminUser) => string[]) => {
const before = usersRef.current.filter((u) => ids.includes(u.id))
const after = before.map(apply)
const lines = before.flatMap((b, i) => [` ${b.email}`, ...lineFor(b, after[i]!)])
setUsers((prev) => prev.map((u) => after.find((a) => a.id === u.id) ?? u))
addChange({
kind: 'chg',
title,
lines,
revert: () => setUsers((prev) => prev.map((u) => before.find((b) => b.id === u.id) ?? u)),
})
},
[addChange],
)
const onDeactivate = useCallback( const onDeactivate = useCallback(
(user: AdminUser) => (user: AdminUser) =>
@@ -71,46 +54,39 @@ export function UsersPage() {
last_login_ip: null, last_login_ip: null,
passkey_count: 0, passkey_count: 0,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
updated_at: new Date().toISOString(), updated_at: new Date().toISOString()
} }
setUsers((prev) => [...prev, created]) setUsers((prev) => [...prev, created])
addChange({ addChange({
kind: 'add', kind: 'add',
title: `Создан ${created.email}`, title: `Создан ${created.email}`,
lines: [`+ email: ${created.email}`, `+ name: ${created.name}`, `+ is_admin: ${String(created.is_admin)}`], lines: [`+ email: ${created.email}`, `+ name: ${created.name}`, `+ is_admin: ${String(created.is_admin)}`],
revert: () => setUsers((prev) => prev.filter((u) => u.id !== created.id)), resource: 'users',
marks: { [created.id]: 'new' },
revert: () => setUsers((prev) => prev.filter((u) => u.id !== created.id))
}) })
setCreateOpen(false) setCreateOpen(false)
setAccessFor(created) setAccessFor(created)
}, },
[addChange], [addChange, setUsers],
) )
const onDelete = useCallback((user: AdminUser) => setDeleteFor(user), []) const onDelete = useCallback((user: AdminUser) => setDeleteFor(user), [])
const onResetPasskeys = useCallback((user: AdminUser) => setResetFor(user), [])
const onOpenAudit = useCallback((user: AdminUser) => setJournalFor(user), [])
const onOpenAccess = useCallback((user: AdminUser) => setAccessFor(user), []) const onOpenAccess = useCallback((user: AdminUser) => setAccessFor(user), [])
const handlers: UsersGridHandlers = { const handlers: UsersGridHandlers = {
onOpenAccess: onOpenAccess, onOpenAccess: onOpenAccess,
onOpenJournal: onOpenAudit,
onToggleDisabled: onDeactivate, onToggleDisabled: onDeactivate,
onResetPasskeys,
onDelete, onDelete,
onBulkDelete: (ids) => setBulkDeleteIds(ids),
onBulkResetPasskeys: (ids) => setBulkResetIds(ids),
onBulkDisabled: (ids, disabled) => onBulkDisabled: (ids, disabled) =>
patch(ids, (u) => ({ ...u, disabled }), `${disabled ? 'Отключено' : 'Включено'} пользователей: ${ids.length}`, () => [ patch(ids, (u) => ({ ...u, disabled }), `${disabled ? 'Отключено' : 'Включено'} пользователей: ${ids.length}`, () => [
`− disabled: ${String(!disabled)}`, `− disabled: ${String(!disabled)}`,
`+ disabled: ${String(disabled)}`, `+ disabled: ${String(disabled)}`,
]), ])
} }
const matrix: MatrixRow[] = ALL_APP_IDS.map((id) => {
const mine = users.filter((u) => u.apps.includes(id))
const level = (action: string) => mine.filter((u) => u.is_admin || u.permissions.some((k) => k.startsWith(`${id}:`) && k.endsWith(`:${action}`))).length
const admins = mine.filter((u) => u.is_admin || u.permissions.some((k) => k.startsWith(`${id}:`) && k.endsWith(':admin'))).length
return { id, name: APP_SHORT[id], total: mine.length, read: level('read'), write: level('write'), admin: admins, tone: admins < 2 ? 'wn' : 'ok' }
})
const admins = users.filter((u) => u.is_admin).length const admins = users.filter((u) => u.is_admin).length
const noPasskey = users.filter((u) => !u.disabled && u.passkey_count === 0).length const noPasskey = users.filter((u) => !u.disabled && u.passkey_count === 0).length
const disabled = users.filter((u) => u.disabled).length const disabled = users.filter((u) => u.disabled).length
@@ -122,43 +98,43 @@ export function UsersPage() {
sub="Каталог учётных записей · роли, приложения, passkeys · изменения прав записываются в журнал" sub="Каталог учётных записей · роли, приложения, passkeys · изменения прав записываются в журнал"
actions={ actions={
<Button onClick={() => setCreateOpen(true)}> <Button onClick={() => setCreateOpen(true)}>
<UserPlusIcon aria-hidden />
Создать пользователя Создать пользователя
</Button> </Button>
} }
/> />
<KpiStatGrid <ExplainBanner
tone="er"
title="Подозрительная активность: [email protected]."
text="6 неудачных входов с 203.0.113.58 за 10 минут. Проверьте журнал входов и при необходимости отключите учётную запись. Действие: «Показать»."
>
6 неудачных входов с 203.0.113.58 за 10 минут. Проверьте журнал входов и при необходимости отключите учётную запись. Действие: «Показать».
</ExplainBanner>
<KpiStrip
aria-label="Показатели" aria-label="Показатели"
items={[ items={[
kpi('total', 'Пользователей', String(users.length), { icon: UsersIcon, badge: 'в каталоге', sub: 'учётных записей' }), kpi('total', 'Пользователей', String(users.length), { icon: UsersIcon, badge: 'в каталоге' }),
kpi('admins', 'Администраторов', String(admins), { icon: ShieldCheckIcon, tone: 'wn', badge: 'is_admin', sub: 'полный доступ' }), kpi('admins', 'Администраторов', String(admins), { icon: ShieldIcon, tone: 'wn', badge: 'is_admin' }),
kpi('nopk', 'Без passkey', String(noPasskey), { icon: FingerprintIcon, tone: noPasskey > 0 ? 'wn' : 'ok', badge: 'только пароль', sub: 'нет резервного входа' }), kpi('nopk', 'Без passkey', String(noPasskey), { icon: FingerprintIcon, tone: 'wn', badge: 'только пароль' }),
kpi('off', 'Отключённых', String(disabled), { icon: UserXIcon, badge: 'вход закрыт', sub: 'не удаляются' }), kpi('off', 'Отключённых', String(disabled), { icon: UserXIcon, badge: 'вход закрыт' }),
]} ]}
/> />
<Tabs defaultValue="users" className="min-w-0"> <Tabs defaultValue="users" className="min-w-0">
<TabsList> <PageTabsList>
<TabsTrigger value="users">Участники · {users.length}</TabsTrigger> <PageTab value="users" label="Участники" count={users.length} />
<TabsTrigger value="matrix">Доступ по приложениям · {ALL_APP_IDS.length}</TabsTrigger> <PageTab value="matrix" label="Доступ по приложениям" count={ALL_APP_IDS.length} />
</TabsList> </PageTabsList>
<TabsContent value="users" className="min-w-0"> <TabsContent value="users" className="min-w-0">
<UsersGrid users={users} handlers={handlers} /> <UsersGrid users={users} handlers={handlers} marks={marks} />
</TabsContent> </TabsContent>
<TabsContent value="matrix" className="min-w-0"> <TabsContent value="matrix" className="min-w-0">
<PreviewSectionGrid <AccessMatrix />
title="Матрица доступа"
description="Сколько пользователей имеет доступ к каждому приложению и с каким уровнем"
rows={matrix}
columns={matrixColumns}
getRowId={(r) => r.id}
/>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<CreateUserSheet open={createOpen} onOpenChange={setCreateOpen} pending={false} error={null} onSubmit={onCreate} /> <CreateUserPortalSheet open={createOpen} onOpenChange={setCreateOpen} onSubmit={onCreate} />
{accessFor ? ( {accessFor ? (
<AccessDialog <AccessSheet
key={accessFor.id} key={accessFor.id}
user={accessFor} user={accessFor}
onClose={() => setAccessFor(null)} onClose={() => setAccessFor(null)}
@@ -174,22 +150,6 @@ export function UsersPage() {
/> />
) : null} ) : null}
<PreviewSheet
open={journalFor !== null}
onOpenChange={(o) => !o && setJournalFor(null)}
title="Журнал пользователя"
description="Последние события пользователя из журнала портала"
footer={<Button variant="outline" onClick={() => setJournalFor(null)}>Закрыть</Button>}
>
<KvList
items={[
['Email', journalFor?.email ?? ''],
['Последний вход', `${journalFor?.last_login_ip ?? '—'}`],
['Passkeys', String(journalFor?.passkey_count ?? 0)],
]}
/>
<CodeBlock label={`${journalFor?.email ?? ''} · 24 часа`}>{USER_JOURNAL}</CodeBlock>
</PreviewSheet>
<TypedConfirm <TypedConfirm
open={deleteFor !== null} open={deleteFor !== null}
@@ -201,52 +161,54 @@ export function UsersPage() {
onConfirm={() => { onConfirm={() => {
const target = deleteFor const target = deleteFor
if (!target) return if (!target) return
const index = usersRef.current.findIndex((u) => u.id === target.id)
setUsers((prev) => prev.filter((u) => u.id !== target.id))
addChange({ addChange({
kind: 'del', kind: 'del',
title: `Удалён ${target.email}`, title: `Удалён ${target.email}`,
lines: [`− email: ${target.email}`, `− apps: ${target.apps.length}`], lines: [`− email: ${target.email}`, `− apps: ${target.apps.length}`],
revert: () => resource: 'users',
setUsers((prev) => { marks: { [target.id]: 'deleted' },
const copy = [...prev] commit: () => setUsers((prev) => prev.filter((u) => u.id !== target.id))
copy.splice(Math.min(index, copy.length), 0, target)
return copy
}),
}) })
setDeleteFor(null) setDeleteFor(null)
}} }}
/> />
<TypedConfirm <TypedConfirm
open={resetFor !== null} open={bulkDeleteIds !== null}
onOpenChange={(o) => !o && setResetFor(null)} onOpenChange={(o) => !o && setBulkDeleteIds(null)}
title="Сбросить passkeys пользователя?" title="Удалить выбранных пользователей?"
description={`Все ключи ${resetFor?.email ?? ''} будут удалены. Войти можно будет только по паролю, пока не добавлен новый ключ. Действие попадёт в журнал.`} description={`Будут удалены учётные записи: ${bulkDeleteIds?.length ?? 0}. Действие попадёт в панель «Не сохранено» и в журнал.`}
expected="удалить"
confirmLabel="Удалить"
onConfirm={() => {
const ids = bulkDeleteIds ?? []
const targets = usersRef.current.filter((u) => ids.includes(u.id))
addChange({
kind: 'del',
title: `Удалено пользователей: ${targets.length}`,
lines: targets.map((t) => `− ${t.email}`),
resource: 'users',
marks: Object.fromEntries(targets.map((t) => [t.id, 'deleted' as const])),
commit: () => setUsers((prev) => prev.filter((u) => !ids.includes(u.id)))
})
setBulkDeleteIds(null)
}}
/>
<TypedConfirm
open={bulkResetIds !== null}
onOpenChange={(o) => !o && setBulkResetIds(null)}
title="Сбросить passkeys выбранных пользователей?"
description={`Все ключи у выбранных учётных записей (${bulkResetIds?.length ?? 0}) будут удалены. Войти можно будет только по паролю, пока не добавлен новый ключ. Действие попадёт в журнал.`}
expected="сбросить" expected="сбросить"
confirmLabel="Сбросить" confirmLabel="Сбросить"
onConfirm={() => { onConfirm={() => {
const target = resetFor const ids = bulkResetIds ?? []
if (!target) return patch(ids, (u) => ({ ...u, passkey_count: 0 }), `Сброс passkeys: ${ids.length}`, (b) => [`− passkeys: ${b.passkey_count}`, '+ passkeys: 0'])
patch([target.id], (u) => ({ ...u, passkey_count: 0 }), `Сброс passkeys: ${target.email}`, (b) => [ setBulkResetIds(null)
`− passkeys: ${b.passkey_count}`,
'+ passkeys: 0',
])
toast.warning('Passkeys сброшены в черновике', { description: 'Сохраните изменения, чтобы применить.' })
setResetFor(null)
}} }}
/> />
</> </>
) )
} }
type MatrixRow = { id: string; name: string; total: number; read: number; write: number; admin: number; tone: Tone }
const matrixColumns: GridColumn<MatrixRow>[] = [
{ id: 'app', title: 'Приложение', size: 240, sortValue: (r) => r.name, cell: (r) => <TwoLineCell dot={r.tone} title={r.name} sub={r.id} /> },
{ id: 'state', title: 'Статус', size: 160, cell: (r) => <StatusBadge tone={r.tone}>{r.tone === 'wn' ? 'Мало админов' : 'Выдан'}</StatusBadge> },
{ id: 'total', title: 'Пользователей', size: 140, sortValue: (r) => r.total, cell: (r) => <span className="text-sm tabular-nums">{r.total}</span> },
{ id: 'read', title: 'Чтение', size: 100, cell: (r) => <span className="text-sm tabular-nums">{r.read}</span> },
{ id: 'write', title: 'Запись', size: 100, cell: (r) => <span className="text-sm tabular-nums">{r.write}</span> },
{ id: 'admin', title: 'Админ', size: 100, cell: (r) => <span className="text-sm tabular-nums">{r.admin}</span> },
]
+281 -258
View File
@@ -1,33 +1,31 @@
import { useEffect, useMemo, useState } from 'react' import { useMemo, useState, type ComponentType } from 'react'
import { Link, useNavigate, useRouterState } from '@tanstack/react-router' import { Link, useNavigate, useRouterState } from '@tanstack/react-router'
import { import {
ActivityIcon, ActivityIcon,
ArrowRightIcon,
CheckIcon, CheckIcon,
ChevronDownIcon,
ChevronsUpDownIcon, ChevronsUpDownIcon,
CloudIcon, CloudIcon,
ContrastIcon,
FingerprintIcon, FingerprintIcon,
GlobeIcon, GlobeIcon,
InfoIcon, InfoIcon,
KeyRoundIcon, KeyRoundIcon,
ShieldCheckIcon,
LayoutDashboardIcon, LayoutDashboardIcon,
LogOutIcon, LogOutIcon,
MonitorIcon,
MoonIcon,
PaletteIcon,
RowsIcon,
SearchIcon, SearchIcon,
ServerIcon, ServerIcon,
StarIcon, StarIcon,
ShieldIcon, ShieldIcon,
SunIcon, MoonIcon,
UserPlusIcon, PlayIcon,
PlusIcon,
} from 'lucide-react' } from 'lucide-react'
import { useTheme } from 'next-themes' import { useTheme } from 'next-themes'
import { toast } from 'sonner' import { toast } from 'sonner'
import { APPS, type AppId } from '@authportal/shared' import { APPS, type AppId } from '@authportal/shared'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { Avatar, AvatarFallback } from '@authportal/ui/components/avatar'
import { import {
Breadcrumb, Breadcrumb,
BreadcrumbItem, BreadcrumbItem,
@@ -47,11 +45,9 @@ import {
DropdownMenuGroup, DropdownMenuGroup,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu' } from '@authportal/ui/components/dropdown-menu'
import { Kbd } from '@authportal/ui/components/kbd' import { Kbd } from '@authportal/ui/components/kbd'
import { Switch } from '@authportal/ui/components/switch'
import { Popover, PopoverContent, PopoverTrigger } from '@authportal/ui/components/popover' import { Popover, PopoverContent, PopoverTrigger } from '@authportal/ui/components/popover'
import { Separator } from '@authportal/ui/components/separator' import { Separator } from '@authportal/ui/components/separator'
import { import {
@@ -71,7 +67,8 @@ import {
useSidebar, useSidebar,
} from '@authportal/ui/components/sidebar' } from '@authportal/ui/components/sidebar'
import { cn } from '@authportal/ui/lib/utils' import { cn } from '@authportal/ui/lib/utils'
import { APP_SHORT, MOCK_USERS, MONITOR_ITEMS, NAV_GROUPS, NAV_ITEMS, type NavItem } from './mock' 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' import { usePreviewStore } from './preview-store'
const APP_ICON: Record<AppId, typeof CloudIcon> = { const APP_ICON: Record<AppId, typeof CloudIcon> = {
@@ -94,11 +91,11 @@ function PreviewAppSwitcher() {
render={<SidebarMenuButton size="lg" aria-label="Переключатель приложений: Auth Portal" className="aria-expanded:bg-muted" />} render={<SidebarMenuButton size="lg" aria-label="Переключатель приложений: Auth Portal" className="aria-expanded:bg-muted" />}
> >
<div className="bg-primary text-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md" aria-hidden> <div className="bg-primary text-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md" aria-hidden>
<KeyRoundIcon className="size-4" /> <ShieldCheckIcon className="size-4" />
</div> </div>
<div className="grid flex-1 text-left text-sm leading-tight"> <div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">Auth Portal</span> <span className="truncate font-semibold">Auth Portal</span>
<span className="text-muted-foreground truncate text-xs">auth.shnt.top</span> <span className="text-muted-foreground truncate text-xs">Единый вход · SSO</span>
</div> </div>
<ChevronsUpDownIcon className="ml-auto size-4" /> <ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -136,163 +133,90 @@ function PreviewAppSwitcher() {
) )
} }
const THEMES = [ const LABEL_CLASS = 'text-muted-foreground text-[11px] font-medium uppercase'
{ value: 'light', label: 'Светлая', icon: SunIcon },
{ value: 'dark', label: 'Тёмная', icon: MoonIcon },
{ value: 'system', label: 'Системная', icon: MonitorIcon },
] as const
function Segmented<T extends string>({
label,
value,
options,
onChange,
}: {
label: string
value: T
options: ReadonlyArray<{ value: T; label: string; icon?: typeof SunIcon }>
onChange: (v: T) => void
}) {
return (
<div
role="radiogroup"
aria-label={label}
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
{options.map((o) => {
const active = value === o.value
const Icon = o.icon
return (
<Button
key={o.value}
type="button"
role="radio"
aria-checked={active}
aria-label={o.label}
title={o.label}
variant="ghost"
size={Icon ? 'icon-xs' : 'xs'}
onClick={() => onChange(o.value)}
className={cn(
'rounded-full',
active ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
)}
>
{Icon ? <Icon className="size-3.5" aria-hidden /> : o.label}
</Button>
)
})}
</div>
)
}
function PreviewNavUser() { function PreviewNavUser() {
const { isMobile } = useSidebar() const { isMobile } = useSidebar()
const { theme, setTheme } = useTheme() const { theme, setTheme, resolvedTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
const { density, setDensity, contrast, setContrast } = usePreviewStore() const { density, setDensity, contrast, setContrast } = usePreviewStore()
const navigate = useNavigate() const navigate = useNavigate()
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
return ( return (
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<DropdownMenu> <Popover>
<DropdownMenuTrigger <PopoverTrigger
render={ render={
<SidebarMenuButton <SidebarMenuButton
size="lg" size="lg"
aria-label="Профиль: Admin, [email protected]" aria-label="Профиль и настройки интерфейса: Admin, [email protected]"
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground" className="bg-muted/40 aria-expanded:bg-muted"
/> />
} }
> >
<Avatar className="size-8 rounded-lg"> <div className="bg-background text-foreground flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
<AvatarFallback className="rounded-lg text-xs">AD</AvatarFallback> AD
</Avatar> </div>
<div className="grid flex-1 text-left text-sm leading-tight"> <div className="grid min-w-0 flex-1 text-left leading-tight">
<span className="truncate font-semibold">Admin</span> <span className="truncate text-sm font-semibold">Admin</span>
<span className="text-muted-foreground truncate text-xs">admin@shnt.top</span> <span className="text-muted-foreground truncate text-xs">admin@shnt.top</span>
</div> </div>
<ChevronsUpDownIcon className="ml-auto size-4" /> <ChevronsUpDownIcon className="text-muted-foreground ml-auto size-4" aria-hidden />
</DropdownMenuTrigger> </PopoverTrigger>
<DropdownMenuContent <PopoverContent side={isMobile ? 'top' : 'right'} align="end" sideOffset={8} className="w-72 gap-3">
className="min-w-64 rounded-lg" <div className="flex items-center gap-2.5">
side={isMobile ? 'bottom' : 'right'} <div className="bg-background flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
align="end" AD
sideOffset={4} </div>
> <div className="grid leading-tight">
<DropdownMenuGroup> <span className="text-sm font-semibold">Admin</span>
<DropdownMenuLabel className="text-foreground flex items-center gap-2 py-2 font-normal"> <span className="text-muted-foreground text-xs">admin@shnt.top</span>
<Avatar className="size-8 rounded-lg"> </div>
<AvatarFallback className="rounded-lg text-xs">AD</AvatarFallback> </div>
</Avatar> <Separator />
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight"> <button
<span className="truncate font-semibold">Admin</span> type="button"
<span className="text-muted-foreground truncate text-xs">admin@shnt.top</span> onClick={() => void navigate({ to: '/design-preview/account' })}
</div> className="hover:bg-muted flex items-center gap-2 rounded-md px-1 py-1 text-left text-sm"
</DropdownMenuLabel> >
</DropdownMenuGroup> <FingerprintIcon className="text-muted-foreground size-4" aria-hidden />
<DropdownMenuSeparator /> Безопасность
<DropdownMenuGroup> </button>
<DropdownMenuItem onClick={() => void navigate({ to: '/design-preview/account' })}> <div className="flex flex-col gap-1.5">
<FingerprintIcon aria-hidden /> <span className={LABEL_CLASS}>Тема</span>
Безопасность <Segmented
</DropdownMenuItem> label="Тема"
<DropdownMenuItem className="cursor-default focus:bg-transparent"> options={['Тёмная', 'Светлая']}
<PaletteIcon aria-hidden /> value={current === 'light' ? 1 : 0}
Тема onChange={(i) => setTheme(i === 1 ? 'light' : 'dark')}
<div className="ml-auto"> />
<Segmented <span className={LABEL_CLASS}>Плотность</span>
label="Тема" <Segmented
value={(mounted ? theme : 'system') ?? 'system'} label="Плотность"
options={THEMES} options={['Обычная', 'Компактная']}
onChange={setTheme} value={density === 'compact' ? 1 : 0}
/> onChange={(i) => setDensity(i === 1 ? 'compact' : 'comfortable')}
</div> />
</DropdownMenuItem> <span className={LABEL_CLASS}>Контраст</span>
<DropdownMenuItem className="cursor-default focus:bg-transparent"> <Segmented
<RowsIcon aria-hidden /> label="Контраст"
Плотность options={['Стандартный', 'Высокий']}
<div className="ml-auto"> value={contrast === 'high' ? 1 : 0}
<Segmented onChange={(i) => setContrast(i === 1 ? 'high' : 'standard')}
label="Плотность" />
value={density} </div>
options={[ <Separator />
{ value: 'comfortable', label: 'Обычная' }, <button
{ value: 'compact', label: 'Компактная' }, type="button"
]} onClick={() => void navigate({ to: '/design-preview/login' })}
onChange={setDensity} className="text-muted-foreground hover:text-foreground flex items-center gap-2 text-left text-sm"
/> >
</div> <LogOutIcon className="size-4" aria-hidden />
</DropdownMenuItem> Выйти
<DropdownMenuItem className="cursor-default focus:bg-transparent"> </button>
<ContrastIcon aria-hidden /> </PopoverContent>
Контраст </Popover>
<div className="ml-auto">
<Segmented
label="Контраст"
value={contrast}
options={[
{ value: 'standard', label: 'Обычный' },
{ value: 'high', label: 'Высокий' },
]}
onChange={setContrast}
/>
</div>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => void navigate({ to: '/design-preview/login' })}>
<LogOutIcon aria-hidden />
Выйти
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
) )
@@ -308,19 +232,21 @@ function NavEntry({ item, pathname, pinned, onTogglePin }: { item: NavItem; path
<SidebarMenuButton <SidebarMenuButton
tooltip={item.problem ? `${item.label} · ${item.problem.label}` : item.label} tooltip={item.problem ? `${item.label} · ${item.problem.label}` : item.label}
isActive={active} isActive={active}
className="relative pr-8 group-data-[collapsible=icon]:pr-2!"
render={<Link to={item.to} />} render={<Link to={item.to} />}
> >
<span className="relative flex shrink-0"> <Icon className="size-4" />
<Icon className="size-4" />
{item.problem ? (
<span
role="img"
aria-label={item.problem.label}
className={cn('ring-sidebar absolute -top-1 -right-1 size-2 rounded-full ring-2', DOT_CLASS[item.problem.tone])}
/>
) : null}
</span>
<span>{item.label}</span> <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> </SidebarMenuButton>
<SidebarMenuAction <SidebarMenuAction
showOnHover={!pinned} showOnHover={!pinned}
@@ -337,16 +263,17 @@ function NavEntry({ item, pathname, pinned, onTogglePin }: { item: NavItem; path
export function PreviewSidebar() { export function PreviewSidebar() {
const pathname = useRouterState({ select: (s) => s.location.pathname }) const pathname = useRouterState({ select: (s) => s.location.pathname })
const { pins, togglePin } = usePreviewStore() const { pins, togglePin } = usePreviewStore()
const [closed, setClosed] = useState<Record<string, boolean>>({})
const pinned = NAV_ITEMS.filter((i) => pins.includes(i.id)) const pinned = NAV_ITEMS.filter((i) => pins.includes(i.id))
return ( return (
<Sidebar collapsible="icon"> <Sidebar collapsible="icon">
<SidebarHeader> <SidebarHeader className="pb-1.5">
<PreviewAppSwitcher /> <PreviewAppSwitcher />
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
{pinned.length > 0 ? ( {pinned.length > 0 ? (
<SidebarGroup className="group-data-[collapsible=icon]:hidden"> <SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Закреплённые</SidebarGroupLabel> <SidebarGroupLabel className="text-[11px] font-semibold tracking-wide uppercase">Закреплённые</SidebarGroupLabel>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{pinned.map((item) => ( {pinned.map((item) => (
@@ -358,11 +285,23 @@ export function PreviewSidebar() {
) : null} ) : null}
{NAV_GROUPS.map((group, gi) => ( {NAV_GROUPS.map((group, gi) => (
<div key={group} className="flex flex-col"> <div key={group} className="flex flex-col">
{gi > 0 ? <SidebarSeparator className="mx-2 my-1 hidden group-data-[collapsible=icon]:block" /> : null} {gi > 0 ? <SidebarSeparator className="mx-2 my-1 h-px shrink-0 hidden group-data-[collapsible=icon]:block" /> : null}
<SidebarGroup> <SidebarGroup className="py-0">
<SidebarGroupLabel>{group}</SidebarGroupLabel> <SidebarGroupLabel
<SidebarGroupContent> render={
<SidebarMenu> <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">
{NAV_ITEMS.filter((i) => i.group === group).map((item) => ( {NAV_ITEMS.filter((i) => i.group === group).map((item) => (
<NavEntry <NavEntry
key={item.id} key={item.id}
@@ -385,86 +324,114 @@ export function PreviewSidebar() {
) )
} }
const MONITOR_METRICS = [
{ l: 'Пользователи без passkey', v: '3 из 7', p: 57, tone: 'warning' },
{ l: 'Приложения доступны', v: '7 из 7', p: 100, tone: 'success' },
{ l: 'OIDC-клиенты', v: '1 из 2', p: 50, tone: 'warning' },
{ l: 'Сессии', v: '5', p: 40, tone: 'success' },
] as const
/** Единый центр состояния: внимание + сводка + список проблем. Адаптивен по ширине окна, как на досках. */
function MonitorPopover() { function MonitorPopover() {
const bad = MONITOR_ITEMS.filter((m) => m.tone !== 'ok') const bad = MONITOR_ITEMS.filter((m) => m.tone !== 'ok')
const errors = bad.filter((m) => m.tone === 'er').length const errors = bad.filter((m) => m.tone === 'er').length
const warnings = bad.length - errors const warnings = bad.length - errors
const ok = MONITOR_ITEMS.length - bad.length
return ( return (
<Popover> <Popover>
<PopoverTrigger <PopoverTrigger
render={ render={
<button <Button
variant="outline"
type="button" type="button"
aria-label={`Монитор Auth Portal: внимание, ${errors} ошибка, ${warnings} предупреждения`} aria-label={`Состояние системы: внимание, ${errors} сбой, ${warnings} предупреждения`}
className="border-warning/40 bg-warning/10 text-warning hover:bg-warning/15 focus-visible:ring-ring inline-flex h-9 items-center gap-2 rounded-lg border px-3 text-sm font-medium outline-none focus-visible:ring-2" 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 aria-hidden className="size-4" /> <ActivityIcon className="text-warning size-4" aria-hidden />
<span className="hidden @md/header:inline">Внимание</span> <span className="text-warning hidden text-sm font-medium min-[1000px]:inline">Внимание</span>
<span className="bg-destructive inline-flex size-5 items-center justify-center rounded-full text-xs font-semibold text-white tabular-nums"> <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} {bad.length}
</span> </span>
<span className="bg-destructive ring-background absolute top-1 right-1 size-2 rounded-full ring-2 min-[760px]:hidden" aria-hidden />
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-0! p-0!"> <PopoverContent align="end" className="flex w-[24rem] max-w-[calc(100vw-1.5rem)] flex-col gap-2 p-2">
<div className="border-border flex items-center justify-between border-b px-3 py-2.5"> <div className="flex items-start justify-between gap-2 px-1.5 pt-1.5">
<div className="flex flex-col"> <div className="grid leading-tight">
<span className="text-xs font-medium">Монитор Auth Portal</span> <b className="text-sm font-semibold">Состояние системы</b>
<span className="text-muted-foreground text-[11px]">обновлено 14:02</span> <span className="text-muted-foreground text-xs">обновлено 14:02</span>
</div> </div>
<Badge variant="warning-light" size="sm">Внимание</Badge> <Badge variant="warning-light" size="sm">Внимание</Badge>
</div> </div>
<div className="text-muted-foreground flex gap-3 border-b px-3 py-2 text-xs"> <div className="grid grid-cols-3 gap-1.5">
<span><b className="text-destructive font-semibold">{errors}</b> ошибка</span> {[
<span><b className="text-warning font-semibold">{warnings}</b> предупреждения</span> { n: errors, l: 'сбой', c: 'text-destructive' },
<span><b className="text-success font-semibold">{MONITOR_ITEMS.length - bad.length}</b> в норме</span> { 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> </div>
<ul className="flex flex-col"> <ul className="flex flex-col gap-0.5">
{MONITOR_ITEMS.map((m) => ( {MONITOR_ITEMS.filter((m) => m.tone !== 'ok').map((m) => (
<li key={m.id} className="border-border border-b last:border-b-0"> <li key={m.id}>
<Link to={m.to} className="hover:bg-accent flex items-start gap-2.5 px-3 py-2.5 outline-none focus-visible:bg-accent"> <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 <span aria-hidden className={cn('size-2 shrink-0 rounded-full', m.tone === 'er' ? 'bg-destructive' : 'bg-warning')} />
aria-hidden <span className="grid min-w-0 flex-1 leading-tight">
className={cn( <b className="truncate text-[13px] font-medium">{m.title}</b>
'mt-1.5 size-2 shrink-0 rounded-full', <span className="text-muted-foreground truncate text-xs">{m.text}</span>
m.tone === 'er' ? 'bg-destructive' : m.tone === 'wn' ? 'bg-warning' : 'bg-success',
)}
/>
<span className="flex min-w-0 flex-1 flex-col">
<span className="text-sm font-medium">{m.title}</span>
<span className="text-muted-foreground text-xs">{m.text}</span>
</span> </span>
<span className="text-muted-foreground text-[11px] whitespace-nowrap">{m.age}</span> <span className="text-muted-foreground shrink-0 text-[11.5px]">{m.age}</span>
</Link> </Link>
</li> </li>
))} ))}
</ul> </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="/design-preview/logins" className="text-muted-foreground hover:text-foreground flex items-center justify-between px-1.5 pt-1 pb-0.5 text-sm">
Журнал входов
<ArrowRightIcon className="size-3.5" aria-hidden />
</Link>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
) )
} }
function FreshnessPopover() { function FreshnessPopover() {
const [auto, setAuto] = useState(true) const pathname = useRouterState({ select: (st) => st.location.pathname })
const key = pathname.split('/')[2] ?? 'apps'
const fresh = FRESH_BY_PAGE[key] ?? FRESH_BY_PAGE.apps!
return ( return (
<Popover> <Popover>
<PopoverTrigger <PopoverTrigger
render={ render={
<Button type="button" variant="ghost" size="icon-sm" aria-label="Источники и свежесть данных" title="Источники и свежесть данных" /> <Button type="button" variant="ghost" size="icon" aria-label="Источники и свежесть данных" title="Источники и свежесть данных" />
} }
> >
<InfoIcon aria-hidden /> <InfoIcon aria-hidden />
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="flex w-72 max-w-[calc(100vw-2rem)] flex-col gap-3"> <PopoverContent align="end" sideOffset={8} className="flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2">
<div className="flex items-center gap-2"> <Badge variant="success-light" size="sm" className="w-fit">{fresh.badge}</Badge>
<span className="bg-success size-2 rounded-full" aria-hidden /> <p className="text-muted-foreground text-sm">{fresh.text}</p>
<span className="text-sm font-medium">Обновлено 14:02:11 · Live</span>
</div>
<p className="text-muted-foreground text-xs">Данные портала: GET /api/v1/admin/* · сессия и права из базы портала. В макете запросы не отправляются.</p>
<label className="flex items-center justify-between gap-2 text-sm">
Автообновление
<Switch checked={auto} onCheckedChange={setAuto} aria-label="Автообновление" />
</label>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
) )
@@ -476,7 +443,7 @@ export function PreviewHeader() {
const current = NAV_ITEMS.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`)) const current = NAV_ITEMS.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`))
return ( return (
<header className="bg-background @container/header sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6"> <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" /> <SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" /> <Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb className="min-w-0"> <Breadcrumb className="min-w-0">
@@ -491,7 +458,7 @@ export function PreviewHeader() {
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="icon-sm" size="icon"
aria-label="Командная палитра (⌘K)" aria-label="Командная палитра (⌘K)"
className="md:hidden" className="md:hidden"
onClick={() => setPaletteOpen(true)} onClick={() => setPaletteOpen(true)}
@@ -509,31 +476,85 @@ type PaletteEntry = {
group: string group: string
label: string label: string
hint: string hint: string
icon?: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
dot?: 'ok' | 'er' | 'mu'
run: () => void run: () => void
} }
const PALETTE_USERS = [
{ name: '[email protected]', role: 'Админ · 7 приложений', dot: 'ok' },
{ name: '[email protected]', role: 'Пользователь · 4 приложения', dot: 'er' },
{ name: '[email protected]', role: 'Админ · 7 приложений', dot: 'ok' },
{ name: '[email protected]', role: 'Пользователь · Technitium DNS', dot: 'ok' },
{ name: '[email protected]', role: 'Отключён', dot: 'mu' },
] as const
const PALETTE_APPS = [
{ name: 'Cloudflare Domain Manager', role: 'SSO · JWT' },
{ name: 'VPS Tracker', role: 'SSO · JWT' },
{ name: 'EvoBGP', role: 'SSO · JWT' },
{ name: 'EvoFirewall', role: 'SSO · JWT' },
{ name: 'Technitium DNS', role: 'OIDC' },
]
const PALETTE_OIDC = [
{ name: 'Technitium DNS', role: 'client_id dns-shnt · включён' },
{ name: 'Grafana (тест)', role: 'client_id grafana-test · выключен' },
]
const DOT_BG = { ok: 'bg-success', er: 'bg-destructive', mu: 'bg-muted-foreground' } as const
/** Палитра ⌘K как на доске: 640px, группы заглавными, подсказки моноширинным справа, подвал с подсказкой. */
export function CommandPalette() { export function CommandPalette() {
const { paletteOpen, setPaletteOpen } = usePreviewStore() const { paletteOpen, setPaletteOpen } = usePreviewStore()
const navigate = useNavigate() const navigate = useNavigate()
const { theme, setTheme, resolvedTheme } = useTheme()
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [active, setActive] = useState(0) const [active, setActive] = useState(0)
const entries = useMemo<PaletteEntry[]>(() => {
const go = (to: string) => () => void navigate({ to })
return [
...NAV_ITEMS.map((i) => ({ id: `nav-${i.id}`, group: 'Разделы', label: i.label, hint: i.group, run: go(i.to) })),
...MOCK_USERS.map((u) => ({ id: `user-${u.id}`, group: 'Пользователи', label: u.email, hint: u.is_admin ? 'Админ' : 'Пользователь', run: go('/design-preview/users') })),
...APPS.map((a) => ({ id: `app-${a.id}`, group: 'Приложения', label: APP_SHORT[a.id], hint: a.url.replace('https://', ''), run: go('/design-preview/apps') })),
{ id: 'act-user', group: 'Действия', label: 'Создать пользователя', hint: 'Пользователи', run: go('/design-preview/users') },
{ id: 'act-client', group: 'Действия', label: 'Создать OIDC-клиента', hint: 'OIDC-клиенты', run: go('/design-preview/oidc') },
{ id: 'act-passkey', group: 'Действия', label: 'Добавить passkey', hint: 'Безопасность', run: go('/design-preview/account') },
]
}, [navigate])
const shown = useMemo(() => { const shown = useMemo(() => {
const q = query.trim().toLowerCase() const q = query.trim().toLowerCase()
return q ? entries.filter((e) => `${e.label} ${e.hint}`.toLowerCase().includes(q)) : entries const match = (...t: string[]) => !q || t.some((x) => x.toLowerCase().includes(q))
}, [entries, query]) const go = (to: string) => () => void navigate({ to })
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
const list: PaletteEntry[] = [
...[
{ id: 'act-user', label: 'Создать пользователя', hint: 'U', icon: PlusIcon, run: go('/design-preview/users') },
{ id: 'act-client', label: 'Создать OIDC-клиент', hint: 'O', icon: PlusIcon, run: go('/design-preview/oidc') },
{ id: 'act-passkey', label: 'Добавить passkey', hint: 'K', icon: PlusIcon, run: go('/design-preview/account') },
{ id: 'act-logins', label: 'Открыть журнал входов', hint: 'L', icon: PlayIcon, run: go('/design-preview/logins') },
{ id: 'act-theme', label: 'Переключить тему', hint: 'T', icon: MoonIcon, run: () => setTheme(current === 'dark' ? 'light' : 'dark') },
]
.filter((e) => match(e.label))
.map((e) => ({ ...e, group: 'Действия' })),
...NAV_ITEMS.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) })),
...PALETTE_USERS.filter((u) => match(u.name, u.role))
.slice(0, 5)
.map((u) => ({ id: `user-${u.name}`, group: 'Пользователи', label: u.name, hint: u.role, dot: u.dot, run: go('/design-preview/users') })),
...PALETTE_APPS.filter((a) => match(a.name))
.slice(0, 4)
.map((a) => ({ id: `app-${a.name}`, group: 'Приложения', label: a.name, hint: a.role, dot: 'mu' as const, run: go('/design-preview/apps') })),
...PALETTE_OIDC.filter((c) => match(c.name))
.slice(0, 4)
.map((c) => ({ id: `oidc-${c.name}`, group: 'OIDC-клиенты', label: c.name, hint: c.role, dot: 'mu' as const, run: go('/design-preview/oidc') })),
]
// Запрос из трёх и более слов — черновик ассистента (как на доске «Палитра ⌘K»).
if (query.trim().split(/\s+/).filter(Boolean).length >= 3) {
return [
{
id: 'ai',
group: 'Ассистент · запрос на естественном языке',
label: `Понял так: «${query.trim()}»`,
hint: 'Найдено 6 событий у 2 пользователей. Это черновик — результат нужно проверить.',
run: () => void navigate({ to: '/design-preview/logins' }),
} as PaletteEntry,
...list,
]
}
return list
}, [query, navigate, theme, resolvedTheme, setTheme])
const groups = useMemo(() => { const groups = useMemo(() => {
const map = new Map<string, Array<PaletteEntry & { index: number }>>() const map = new Map<string, Array<PaletteEntry & { index: number }>>()
@@ -553,17 +574,17 @@ export function CommandPalette() {
return ( return (
<Dialog open={paletteOpen} onOpenChange={(o) => (o ? setPaletteOpen(true) : close())}> <Dialog open={paletteOpen} onOpenChange={(o) => (o ? setPaletteOpen(true) : close())}>
<DialogContent className="top-[20%] w-[min(40rem,calc(100vw-2rem))] max-w-none translate-y-0 gap-0 p-0 sm:max-w-none" showCloseButton={false}> <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> <DialogTitle className="sr-only">Командная палитра</DialogTitle>
<DialogDescription className="sr-only">Разделы, пользователи, приложения и действия</DialogDescription> <DialogDescription className="sr-only">Разделы, пользователи, приложения и действия</DialogDescription>
<div className="flex items-center gap-2 border-b px-3"> <div className="flex items-center gap-2.5 border-b px-4 py-3.5">
<SearchIcon className="text-muted-foreground size-4" aria-hidden /> <SearchIcon className="text-muted-foreground size-[18px]" aria-hidden />
<input <input
autoFocus autoFocus
value={query} value={query}
aria-label="Поиск и команды" aria-label="Поиск и команды"
placeholder="Раздел, пользователь, приложение или действие…" placeholder="Раздел, пользователь, приложение или действие…"
className="h-11 min-w-0 flex-1 bg-transparent text-sm outline-none" className="min-w-0 flex-1 bg-transparent text-[15px] outline-none"
onChange={(e) => { onChange={(e) => {
setQuery(e.target.value) setQuery(e.target.value)
setActive(0) setActive(0)
@@ -584,42 +605,44 @@ export function CommandPalette() {
/> />
<Kbd>Esc</Kbd> <Kbd>Esc</Kbd>
</div> </div>
<div className="max-h-[50svh] overflow-y-auto p-1.5" role="listbox" aria-label="Результаты"> <div className="max-h-[430px] overflow-y-auto" role="listbox" aria-label="Результаты">
{groups.length === 0 ? ( {groups.length === 0 ? (
<p className="text-muted-foreground px-3 py-6 text-center text-sm">Ничего не найдено</p> <p className="text-muted-foreground px-4 py-6 text-center text-sm">Ничего не найдено</p>
) : ( ) : (
groups.map(([group, list]) => ( groups.map(([group, list]) => (
<div key={group} className="flex flex-col"> <div key={group} className="flex flex-col">
<div className="text-muted-foreground px-2 py-1.5 text-xs font-medium">{group}</div> <div className="text-muted-foreground px-4 pt-2.5 pb-1 text-[11px] tracking-[0.05em] uppercase">{group}</div>
{list.map((e) => ( {list.map((e) => {
<button const Icon = e.icon
key={e.id} return (
type="button" <button
role="option" key={e.id}
aria-selected={e.index === active} type="button"
onMouseEnter={() => setActive(e.index)} role="option"
onClick={() => { aria-selected={e.index === active}
e.run() onMouseEnter={() => setActive(e.index)}
close() onClick={() => {
}} e.run()
className={cn( close()
'flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm outline-none', }}
e.index === active && 'bg-accent text-accent-foreground', 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}
{group === 'Действия' ? <UserPlusIcon className="size-4" 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 flex-1 truncate">{e.label}</span> <span className="min-w-0 truncate">{e.label}</span>
<span className="text-muted-foreground truncate text-xs">{e.hint}</span> <span className="text-muted-foreground ml-auto truncate font-mono text-xs">{e.hint}</span>
</button> </button>
))} )
})}
</div> </div>
)) ))
)} )}
</div> </div>
<div className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 border-t px-3 py-2 text-xs"> <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>↵ открыть</span> <span>↵ открыть</span>
<span>Esc закрыть</span> <span>Esc закрыть</span>
<span className="ml-auto">Подсказка: «неудачные входы ops за сутки»</span>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -1,10 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import type { AdminUser } from '@authportal/shared'
import { MOCK_USERS } from './mock'
import { import {
PreviewStoreContext, PreviewStoreContext,
type Contrast, type Contrast,
type Density, type Density,
type PendingChange, type PendingChange,
type RowMark,
type PreviewStore, type PreviewStore,
} from './preview-store' } from './preview-store'
@@ -15,6 +18,7 @@ import {
export function PreviewProvider({ children }: { children: ReactNode }) { export function PreviewProvider({ children }: { children: ReactNode }) {
const [pending, setPending] = useState<PendingChange[]>([]) const [pending, setPending] = useState<PendingChange[]>([])
const [paletteOpen, setPaletteOpen] = useState(false) const [paletteOpen, setPaletteOpen] = useState(false)
const [users, setUsers] = useState<AdminUser[]>(MOCK_USERS)
const [density, setDensity] = useState<Density>('comfortable') const [density, setDensity] = useState<Density>('comfortable')
const [contrast, setContrast] = useState<Contrast>('standard') const [contrast, setContrast] = useState<Contrast>('standard')
const [pins, setPins] = useState<string[]>(() => { const [pins, setPins] = useState<string[]>(() => {
@@ -44,11 +48,11 @@ export function PreviewProvider({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
const root = document.documentElement const root = document.documentElement
root.dataset.previewDensity = density root.dataset.density = density
root.dataset.previewContrast = contrast root.dataset.contrast = contrast
return () => { return () => {
delete root.dataset.previewDensity delete root.dataset.density
delete root.dataset.previewContrast delete root.dataset.contrast
} }
}, [density, contrast]) }, [density, contrast])
@@ -77,20 +81,39 @@ export function PreviewProvider({ children }: { children: ReactNode }) {
const applyAll = useCallback(() => { const applyAll = useCallback(() => {
const count = pendingRef.current.length const count = pendingRef.current.length
for (const c of pendingRef.current) c.commit?.()
setPending([]) setPending([])
toast.success(`Сохранено изменений: ${count}`, { toast.success(`Сохранено изменений: ${count}`, {
description: 'Записано в портал и журнал «Изменения» (макет: ничего не отправляется).', description: 'Записано в портал и журнал «Изменения» (макет: ничего не отправляется).',
}) })
}, []) }, [])
const marksFor = useCallback(
(resource: string) => {
const out: Record<string, RowMark> = {}
for (const c of pending) {
if (c.resource !== resource || !c.marks) continue
for (const [id, mark] of Object.entries(c.marks)) {
// «Будет удалено» сильнее остальных пометок.
if (out[id] !== 'deleted') out[id] = mark
}
}
return out
},
[pending],
)
const value = useMemo<PreviewStore>( const value = useMemo<PreviewStore>(
() => ({ () => ({
pending, pending,
marksFor,
addChange, addChange,
discardAll, discardAll,
applyAll, applyAll,
paletteOpen, paletteOpen,
setPaletteOpen, setPaletteOpen,
users,
setUsers,
pins, pins,
togglePin, togglePin,
density, density,
@@ -98,7 +121,7 @@ export function PreviewProvider({ children }: { children: ReactNode }) {
contrast, contrast,
setContrast, setContrast,
}), }),
[pending, addChange, discardAll, applyAll, paletteOpen, pins, togglePin, density, contrast], [pending, marksFor, addChange, discardAll, applyAll, paletteOpen, users, pins, togglePin, density, contrast],
) )
return ( return (
@@ -4,7 +4,6 @@ import { SidebarInset, SidebarProvider } from '@authportal/ui/components/sidebar
import { TooltipProvider } from '@authportal/ui/components/tooltip' import { TooltipProvider } from '@authportal/ui/components/tooltip'
import { CommandPalette, PreviewHeader, PreviewSidebar } from './preview-chrome' import { CommandPalette, PreviewHeader, PreviewSidebar } from './preview-chrome'
import { UnsavedBar } from './unsaved-bar' import { UnsavedBar } from './unsaved-bar'
import './design-preview.css'
/** Оболочка макета: сайдбар, шапка, палитра и панель «Не сохранено». Монтируется после гидрации. */ /** Оболочка макета: сайдбар, шапка, палитра и панель «Не сохранено». Монтируется после гидрации. */
export function PreviewShell({ children }: { children: ReactNode }) { export function PreviewShell({ children }: { children: ReactNode }) {
+16 -1
View File
@@ -1,11 +1,21 @@
import { createContext, useContext } from 'react' import { createContext, useContext, type Dispatch, type SetStateAction } from 'react'
import type { AdminUser } from '@authportal/shared'
export type ChangeKind = 'add' | 'chg' | 'del' export type ChangeKind = 'add' | 'chg' | 'del'
import type { RowMark } from '@/components/portal/tone'
export type { RowMark }
export type PendingChange = { export type PendingChange = {
id: string id: string
kind: ChangeKind kind: ChangeKind
title: string title: string
/** Раздел, к которому относятся пометки строк («users», «app-links»). */
resource?: string
/** Пометки строк: «Не сохранено» (dirty/new) и «Будет удалено» (deleted). */
marks?: Record<string, RowMark>
/** Выполняется при «Сохранить»: окончательно применяет правку (например, убирает удалённую строку). */
commit?: () => void
/** Строки diff: «+ …», «− …», « … ». */ /** Строки diff: «+ …», «− …», « … ». */
lines: string[] lines: string[]
/** Откатывает локальное состояние страницы при «Отменить». */ /** Откатывает локальное состояние страницы при «Отменить». */
@@ -17,11 +27,16 @@ export type Contrast = 'standard' | 'high'
export type PreviewStore = { export type PreviewStore = {
pending: PendingChange[] pending: PendingChange[]
/** Пометки строк раздела из накопленных правок. */
marksFor: (resource: string) => Record<string, RowMark>
addChange: (change: Omit<PendingChange, 'id'>) => void addChange: (change: Omit<PendingChange, 'id'>) => void
discardAll: () => void discardAll: () => void
applyAll: () => void applyAll: () => void
paletteOpen: boolean paletteOpen: boolean
setPaletteOpen: (open: boolean) => void setPaletteOpen: (open: boolean) => void
/** Каталог пользователей: общий для списка и профиля. */
users: AdminUser[]
setUsers: Dispatch<SetStateAction<AdminUser[]>>
pins: string[] pins: string[]
togglePin: (id: string) => void togglePin: (id: string) => void
density: Density density: Density
@@ -1,289 +0,0 @@
import { useMemo, useState, type ReactNode } from 'react'
import { SearchIcon } from 'lucide-react'
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 {
DataGridTableDndRowHandle,
DataGridTableDndRows,
} from '@/components/reui/data-grid/data-grid-table-dnd-rows'
import type { DragEndEvent } from '@dnd-kit/core'
import {
DataGridTable,
DataGridTableRowSelect,
DataGridTableRowSelectAll,
} from '@/components/reui/data-grid/data-grid-table'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Button } from '@authportal/ui/components/button'
import { Separator } from '@authportal/ui/components/separator'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@authportal/ui/components/input-group'
import { cn } from '@authportal/ui/lib/utils'
import { Segmented } from './bits'
import type { Tone } from './mock'
import { TONE_DOT } from './tone'
export type GridColumn<T> = {
id: string
title: string
/** Ширина колонки; сумма задаёт минимальную ширину таблицы. */
size: number
cell: (row: T) => ReactNode
/** Ключ сортировки; без него колонка не сортируется. */
sortValue?: (row: T) => string | number
}
export type GridView<T> = { label: string; match: (row: T) => boolean }
const SELECT_COLUMN_SIZE = 44
const DRAG_COLUMN_SIZE = 44
/**
* Секция-таблица макета: Frame + Filters-подобная панель + DataGrid на DataGridScrollArea.
* Минимальная ширина таблицы = сумма колонок, поэтому на узком экране таблица
* прокручивается по горизонтали внутри контейнера, а не ломает страницу.
*/
export function PreviewSectionGrid<T extends object>({
title,
description,
rows,
columns,
getRowId,
views,
searchText,
searchPlaceholder = 'Поиск…',
onRowClick,
selectable = false,
bulkActions,
toolbar,
onReorder,
pageSize = 8,
emptyMessage = 'Нет записей по текущим фильтрам.',
className,
}: {
title: string
description: string
rows: T[]
columns: GridColumn<T>[]
getRowId: (row: T) => string
views?: GridView<T>[]
searchText?: (row: T) => string
searchPlaceholder?: string
onRowClick?: (row: T) => void
selectable?: boolean
bulkActions?: (selectedIds: string[], clear: () => void) => ReactNode
toolbar?: ReactNode
/** Включает перетаскивание строк (dnd-kit). Передаёт id строки и id строки, на место которой её бросили. */
onReorder?: (activeId: string, overId: string) => void
pageSize?: number
emptyMessage?: string
className?: string
}) {
const storageKey = `design-preview:view:${title}`
const [view, setViewState] = useState(() => {
try {
const v = Number(window.localStorage.getItem(storageKey))
return Number.isInteger(v) && v >= 0 && v < (views?.length ?? 0) ? v : 0
} catch {
return 0
}
})
const setView = (i: number) => {
setViewState(i)
try {
window.localStorage.setItem(storageKey, String(i))
} catch {
/* без сохранения вида */
}
}
const [query, setQuery] = useState('')
const [sorting, setSorting] = useState<SortingState>([])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize })
const data = useMemo(() => {
const matchView = views?.[view]?.match
const q = query.trim().toLowerCase()
return rows.filter((row) => {
if (matchView && !matchView(row)) return false
if (q && searchText && !searchText(row).toLowerCase().includes(q)) return false
return true
})
}, [rows, views, view, query, searchText])
const defs = useMemo<ColumnDef<DataGridFeatures, T>[]>(() => {
const list: ColumnDef<DataGridFeatures, T>[] = []
if (onReorder) {
list.push({
id: 'drag',
header: () => <span className="sr-only">Порядок</span>,
cell: () => <DataGridTableDndRowHandle />,
enableSorting: false,
enableResizing: false,
size: DRAG_COLUMN_SIZE,
})
}
if (selectable) {
list.push({
id: 'select',
header: () => <DataGridTableRowSelectAll />,
cell: ({ row }) => (
<div onClick={(e) => e.stopPropagation()}>
<DataGridTableRowSelect row={row} />
</div>
),
enableSorting: false,
enableResizing: false,
size: SELECT_COLUMN_SIZE,
})
}
for (const col of columns) {
list.push({
id: col.id,
accessorFn: col.sortValue ?? (() => ''),
header: ({ column }) => <DataGridColumnHeader title={col.title} column={column} />,
cell: ({ row }) => col.cell(row.original),
enableSorting: Boolean(col.sortValue),
enableResizing: false,
size: col.size,
meta: { headerTitle: col.title },
})
}
return list
}, [columns, selectable, onReorder])
const table = useTable({
features: dataGridFeatures,
columns: defs,
data,
getRowId,
state: { sorting, rowSelection, pagination },
enableRowSelection: selectable,
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
})
const dataIds = table.getRowModel().rows.map((r) => r.id)
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!onReorder || !active || !over || active.id === over.id) return
onReorder(String(active.id), String(over.id))
}
const minWidth = columns.reduce(
(sum, c) => sum + c.size,
(selectable ? SELECT_COLUMN_SIZE : 0) + (onReorder ? DRAG_COLUMN_SIZE : 0),
)
const selectedIds = table.getSelectedRowModel().rows.map((r) => r.id)
const paged = data.length > pageSize
return (
<DataGrid
table={table}
i18n={DATA_GRID_RU}
recordCount={data.length}
onRowClick={onRowClick}
emptyMessage={emptyMessage}
tableLayout={{ headerSticky: false, dense: true, width: 'auto', rowsDraggable: Boolean(onReorder) }}
>
<Frame spacing="sm" className={cn('w-full min-w-0', className)}>
<FrameHeader>
<FrameTitle>{title}</FrameTitle>
<FrameDescription className="text-xs text-pretty">{description}</FrameDescription>
</FrameHeader>
<FramePanel className="min-w-0 p-0 shadow-none">
{views || searchText || toolbar ? (
<>
<div className="flex flex-wrap items-center justify-between gap-2 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{views ? (
<Segmented
label="Вид"
options={views.map((v) => `${v.label} · ${rows.filter(v.match).length}`)}
value={view}
onChange={(i) => {
setView(i)
setPagination((p) => ({ ...p, pageIndex: 0 }))
}}
/>
) : null}
{searchText ? (
<InputGroup className="w-56 max-w-full">
<InputGroupAddon>
<SearchIcon aria-hidden />
</InputGroupAddon>
<InputGroupInput
value={query}
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
onChange={(e) => {
setQuery(e.target.value)
setPagination((p) => ({ ...p, pageIndex: 0 }))
}}
/>
</InputGroup>
) : null}
</div>
{toolbar ? <div className="flex flex-wrap items-center gap-2">{toolbar}</div> : null}
</div>
<Separator />
</>
) : null}
{selectable && selectedIds.length > 0 && bulkActions ? (
<>
<div className="flex flex-wrap items-center justify-between gap-2 px-(--frame-panel-header-px) py-2">
<span className="text-sm font-medium">Выбрано: {selectedIds.length}</span>
<div className="flex flex-wrap items-center gap-2">
{bulkActions(selectedIds, () => setRowSelection({}))}
<Button type="button" size="sm" variant="ghost" onClick={() => setRowSelection({})}>
Сбросить
</Button>
</div>
</div>
<Separator />
</>
) : null}
<DataGridScrollArea>
<div className="pb-3" 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">
Показано {table.getRowModel().rows.length} из {data.length}
</span>
{paged ? <DataGridPagination sizes={[8, 16, 32]} /> : null}
</FrameFooter>
</FramePanel>
</Frame>
</DataGrid>
)
}
/** Двухстрочная ячейка: заголовок + подпись. */
export function TwoLineCell({ title, sub, dot }: { title: ReactNode; sub?: ReactNode; dot?: Tone }) {
return (
<div className="flex min-w-0 items-start gap-2">
{dot ? <span aria-hidden className={cn('mt-1.5 size-2 shrink-0 rounded-full', TONE_DOT[dot])} /> : null}
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{title}</span>
{sub ? <span className="text-muted-foreground truncate font-mono text-[11px]">{sub}</span> : null}
</div>
</div>
)
}
+3 -3
View File
@@ -37,13 +37,13 @@ export function UnsavedBar() {
<b className="font-semibold">{pending.length}</b> · изменения ещё не записаны в портал <b className="font-semibold">{pending.length}</b> · изменения ещё не записаны в портал
</span> </span>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button type="button" size="sm" variant="outline" onClick={() => setDiffOpen(true)}> <Button type="button" variant="outline" onClick={() => setDiffOpen(true)}>
Просмотреть diff Просмотреть diff
</Button> </Button>
<Button type="button" size="sm" variant="outline" onClick={discardAll}> <Button type="button" variant="outline" onClick={discardAll}>
Отменить Отменить
</Button> </Button>
<Button type="button" size="sm" onClick={applyAll}> <Button type="button" onClick={applyAll}>
Сохранить Сохранить
</Button> </Button>
</div> </div>
@@ -0,0 +1,35 @@
import { useCallback, useEffect, useRef } from 'react'
import type { AdminUser } from '@authportal/shared'
import { usePreviewStore } from './preview-store'
/** Меняет поля у выбранных пользователей и кладёт правку в «Не сохранено» (с откатом). */
export function usePatchUsers() {
const { users, setUsers, addChange } = usePreviewStore()
const usersRef = useRef(users)
useEffect(() => {
usersRef.current = users
}, [users])
return useCallback(
(
ids: string[],
apply: (u: AdminUser) => AdminUser,
title: string,
lineFor: (before: AdminUser, after: AdminUser) => string[],
) => {
const before = usersRef.current.filter((u) => ids.includes(u.id))
const after = before.map(apply)
const lines = before.flatMap((b, i) => [` ${b.email}`, ...lineFor(b, after[i]!)])
setUsers((prev) => prev.map((u) => after.find((a) => a.id === u.id) ?? u))
addChange({
kind: 'chg',
title,
lines,
resource: 'users',
marks: Object.fromEntries(before.map((b) => [b.id, 'dirty' as const])),
revert: () => setUsers((prev) => prev.map((u) => before.find((b) => b.id === u.id) ?? u)),
})
},
[addChange, setUsers],
)
}
+54 -76
View File
@@ -1,25 +1,11 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import {
MoreHorizontalIcon,
PauseCircleIcon,
PlayCircleIcon,
ScrollTextIcon,
ShieldCheckIcon,
Trash2Icon,
FingerprintIcon,
} from 'lucide-react'
import type { AdminUser } from '@authportal/shared' import type { AdminUser } from '@authportal/shared'
import { toast } from 'sonner'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import { import { PendingChip, StatusBadge } from '@/components/portal/bits'
DropdownMenu, import type { RowMark } from './preview-store'
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu'
import { StatusBadge } from './bits'
import { ALL_APP_IDS, APP_SHORT, USER_META, type Tone } from './mock' 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 } { function userStatus(u: AdminUser): { tone: Tone; label: string } {
if (u.disabled) return { tone: 'mu', label: 'Отключён' } if (u.disabled) return { tone: 'mu', label: 'Отключён' }
@@ -41,15 +27,15 @@ function passkeyLabel(n: number) {
export type UsersGridHandlers = { export type UsersGridHandlers = {
onOpenAccess: (u: AdminUser) => void onOpenAccess: (u: AdminUser) => void
onOpenJournal: (u: AdminUser) => void
onToggleDisabled: (u: AdminUser) => void onToggleDisabled: (u: AdminUser) => void
onResetPasskeys: (u: AdminUser) => void
onDelete: (u: AdminUser) => void onDelete: (u: AdminUser) => void
onBulkDisabled: (ids: string[], disabled: boolean) => void onBulkDisabled: (ids: string[], disabled: boolean) => void
onBulkDelete: (ids: string[]) => void
onBulkResetPasskeys: (ids: string[]) => void
} }
/** Каталог пользователей по канвасу: участник, статус, роль, приложения, passkey, активность. */ /** Каталог пользователей по доске: участник, статус, роль, приложения, passkey, активность · IP. */
export function UsersGrid({ users, handlers }: { users: AdminUser[]; handlers: UsersGridHandlers }) { export function UsersGrid({ users, handlers, marks }: { users: AdminUser[]; handlers: UsersGridHandlers; marks: Record<string, RowMark> }) {
const columns = useMemo<GridColumn<AdminUser>[]>( const columns = useMemo<GridColumn<AdminUser>[]>(
() => [ () => [
{ {
@@ -59,80 +45,72 @@ export function UsersGrid({ users, handlers }: { users: AdminUser[]; handlers: U
sortValue: (u) => u.email, sortValue: (u) => u.email,
cell: (u) => <TwoLineCell dot={userStatus(u).tone} title={u.email} sub={USER_META[u.id]?.sub ?? u.name} />, cell: (u) => <TwoLineCell dot={userStatus(u).tone} title={u.email} sub={USER_META[u.id]?.sub ?? u.name} />,
}, },
{ id: 'status', title: 'Статус', size: 140, cell: (u) => <StatusBadge tone={userStatus(u).tone}>{userStatus(u).label}</StatusBadge> },
{ id: 'role', title: 'Роль', size: 130, sortValue: (u) => (u.is_admin ? 'a' : 'u'), cell: (u) => <span className="text-sm">{u.is_admin ? 'Админ' : 'Пользователь'}</span> },
{ id: 'apps', title: 'Приложения', size: 220, cell: (u) => <span className="block truncate text-sm">{appsSummary(u)}</span> },
{ id: 'passkey', title: 'Passkey', size: 100, sortValue: (u) => u.passkey_count, cell: (u) => <span className="text-sm">{passkeyLabel(u.passkey_count)}</span> },
{ id: 'activity', title: 'Активность · IP', size: 240, cell: (u) => <span className="font-mono text-[13px] whitespace-nowrap">{USER_META[u.id]?.activity ?? '—'}</span> },
{ {
id: 'menu', id: 'status',
title: '', title: 'Статус',
size: 56, size: 150,
cell: (u) => ( cell: (u) => (
<div onClick={(e) => e.stopPropagation()}> <div className="flex flex-wrap items-center gap-1.5">
<DropdownMenu> <StatusBadge tone={userStatus(u).tone}>{userStatus(u).label}</StatusBadge>
<DropdownMenuTrigger {marks[u.id] ? <PendingChip mark={marks[u.id]!} /> : null}
render={<Button type="button" variant="ghost" size="icon-sm" aria-label={`Действия: ${u.email}`} />}
>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-52">
<DropdownMenuItem onClick={() => handlers.onOpenAccess(u)}>
<ShieldCheckIcon aria-hidden />
Права доступа
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handlers.onOpenJournal(u)}>
<ScrollTextIcon aria-hidden />
Журнал
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handlers.onToggleDisabled(u)}>
{u.disabled ? <PlayCircleIcon aria-hidden /> : <PauseCircleIcon aria-hidden />}
{u.disabled ? 'Включить' : 'Отключить'}
</DropdownMenuItem>
<DropdownMenuItem disabled={u.passkey_count === 0} onClick={() => handlers.onResetPasskeys(u)}>
<FingerprintIcon aria-hidden />
Сбросить passkeys…
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => handlers.onDelete(u)}>
<Trash2Icon aria-hidden />
Удалить…
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
), ),
}, },
{ id: 'role', title: 'Роль', size: 130, sortValue: (u) => (u.is_admin ? 'a' : 'u'), cell: (u) => <span className="font-mono text-[12.5px]">{u.is_admin ? 'Админ' : 'Пользователь'}</span> },
{ id: 'apps', title: 'Приложения', size: 220, cell: (u) => <span className="block truncate font-mono text-[12.5px]">{appsSummary(u)}</span> },
{ id: 'passkey', title: 'Passkey', size: 100, sortValue: (u) => u.passkey_count, cell: (u) => <span className="font-mono text-[12.5px]">{passkeyLabel(u.passkey_count)}</span> },
{ id: 'activity', title: 'Активность · IP', size: 240, cell: (u) => <span className="font-mono text-[12.5px]">{USER_META[u.id]?.activity ?? '—'}</span> },
], ],
[handlers], [marks],
) )
return ( return (
<PreviewSectionGrid <SectionGrid
title="Пользователи портала" title="Пользователи портала"
description="Поиск по email и имени · виды по статусу · строка открывает права доступа" description="Поиск по email и имени · фильтры: роль, статус, приложения, passkey · колонки настраиваются, вид сохраняется"
rows={users} rows={users}
columns={columns} columns={columns}
getRowId={(u) => u.id} getRowId={(u) => u.id}
selectable
searchText={(u) => `${u.email} ${u.name}`} searchText={(u) => `${u.email} ${u.name}`}
searchPlaceholder="Email или имя"
views={[ views={[
{ label: 'Все', match: () => true }, { label: 'Все', match: () => true },
{ label: 'Активные', match: (u) => !u.disabled }, { label: 'Активные', match: (u) => !u.disabled && u.passkey_count > 0 },
{ label: 'Без passkey', match: (u) => !u.disabled && u.passkey_count === 0 }, { label: 'Без passkey', match: (u) => !u.disabled && u.passkey_count === 0 },
{ label: 'Отключённые', match: (u) => u.disabled }, { label: 'Отключённые', match: (u) => u.disabled },
]} ]}
onRowClick={handlers.onOpenAccess} onRowClick={handlers.onOpenAccess}
bulkActions={(ids, clear) => ( rowStatus={(u) => marks[u.id]}
<> rowSwitch={{ value: (u) => !u.disabled, onChange: (u) => handlers.onToggleDisabled(u) }}
<Button type="button" size="sm" variant="outline" onClick={() => { handlers.onBulkDisabled(ids, false); clear() }}> rowMenu={(u) => ({
Включить onOpen: () => handlers.onOpenAccess(u),
</Button> toggleLabel: u.disabled ? 'Включить' : 'Выключить',
<Button type="button" size="sm" variant="outline" onClick={() => { handlers.onBulkDisabled(ids, true); clear() }}> onToggle: () => handlers.onToggleDisabled(u),
Отключить onDuplicate: () => toast('Дублирование пользователя недоступно в макете'),
</Button> onDelete: () => handlers.onDelete(u),
</> })}
card={(u) => ({
name: u.email,
sub: USER_META[u.id]?.sub ?? u.name,
tone: userStatus(u).tone,
status: userStatus(u).label,
cells: [
{ l: 'Роль', v: u.is_admin ? 'Админ' : 'Пользователь' },
{ l: 'Приложения', v: appsSummary(u) },
{ l: 'Passkey', v: passkeyLabel(u.passkey_count) },
{ l: 'Активность · IP', v: USER_META[u.id]?.activity ?? '—' },
],
})}
bulk={{
onLabel: 'Включить',
offLabel: 'Отключить',
onRun: (ids) => handlers.onBulkDisabled(ids, false),
offRun: (ids) => handlers.onBulkDisabled(ids, true),
onDelete: handlers.onBulkDelete,
}}
tools={(ids) => (
<Button type="button" variant="outline" onClick={() => handlers.onBulkResetPasskeys(ids.length > 0 ? ids : ['u-3'])}>
Сбросить passkeys…
</Button>
)} )}
/> />
) )
+1
View File
@@ -6,6 +6,7 @@ import { ThemeProvider } from 'next-themes'
import { Toaster } from 'sonner' import { Toaster } from 'sonner'
import { routeTree } from './routeTree.gen' import { routeTree } from './routeTree.gen'
import '@authportal/ui/globals.css' import '@authportal/ui/globals.css'
import './portal-theme.css'
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { 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;
}
}
+22 -21
View File
@@ -30,8 +30,8 @@ import { Route as DesignPreviewShellAppsRouteImport } from './routes/design-prev
import { Route as DesignPreviewShellAuditRouteImport } from './routes/design-preview._shell.audit' import { Route as DesignPreviewShellAuditRouteImport } from './routes/design-preview._shell.audit'
import { Route as DesignPreviewShellLoginsRouteImport } from './routes/design-preview._shell.logins' import { Route as DesignPreviewShellLoginsRouteImport } from './routes/design-preview._shell.logins'
import { Route as DesignPreviewShellOidcRouteImport } from './routes/design-preview._shell.oidc' import { Route as DesignPreviewShellOidcRouteImport } from './routes/design-preview._shell.oidc'
import { Route as DesignPreviewShellUsersRouteImport } from './routes/design-preview._shell.users'
import { Route as AuthAdminUsersUserIdRouteImport } from './routes/_auth.admin.users.$userId' import { Route as AuthAdminUsersUserIdRouteImport } from './routes/_auth.admin.users.$userId'
import { Route as DesignPreviewShellUsersIndexRouteImport } from './routes/design-preview._shell.users.index'
const IndexRoute = IndexRouteImport.update({ const IndexRoute = IndexRouteImport.update({
id: '/', id: '/',
@@ -139,16 +139,17 @@ const DesignPreviewShellOidcRoute = DesignPreviewShellOidcRouteImport.update({
path: '/oidc', path: '/oidc',
getParentRoute: () => DesignPreviewShellRoute, getParentRoute: () => DesignPreviewShellRoute,
} as any) } as any)
const DesignPreviewShellUsersRoute = DesignPreviewShellUsersRouteImport.update({
id: '/users',
path: '/users',
getParentRoute: () => DesignPreviewShellRoute,
} as any)
const AuthAdminUsersUserIdRoute = AuthAdminUsersUserIdRouteImport.update({ const AuthAdminUsersUserIdRoute = AuthAdminUsersUserIdRouteImport.update({
id: '/users/$userId', id: '/users/$userId',
path: '/users/$userId', path: '/users/$userId',
getParentRoute: () => AuthAdminRoute, getParentRoute: () => AuthAdminRoute,
} as any) } as any)
const DesignPreviewShellUsersIndexRoute =
DesignPreviewShellUsersIndexRouteImport.update({
id: '/users/',
path: '/users/',
getParentRoute: () => DesignPreviewShellRoute,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
@@ -169,9 +170,9 @@ export interface FileRoutesByFullPath {
'/design-preview/audit': typeof DesignPreviewShellAuditRoute '/design-preview/audit': typeof DesignPreviewShellAuditRoute
'/design-preview/logins': typeof DesignPreviewShellLoginsRoute '/design-preview/logins': typeof DesignPreviewShellLoginsRoute
'/design-preview/oidc': typeof DesignPreviewShellOidcRoute '/design-preview/oidc': typeof DesignPreviewShellOidcRoute
'/design-preview/users': typeof DesignPreviewShellUsersRoute
'/admin/': typeof AuthAdminIndexRoute '/admin/': typeof AuthAdminIndexRoute
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute '/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
'/design-preview/users/': typeof DesignPreviewShellUsersIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
@@ -190,9 +191,9 @@ export interface FileRoutesByTo {
'/design-preview/audit': typeof DesignPreviewShellAuditRoute '/design-preview/audit': typeof DesignPreviewShellAuditRoute
'/design-preview/logins': typeof DesignPreviewShellLoginsRoute '/design-preview/logins': typeof DesignPreviewShellLoginsRoute
'/design-preview/oidc': typeof DesignPreviewShellOidcRoute '/design-preview/oidc': typeof DesignPreviewShellOidcRoute
'/design-preview/users': typeof DesignPreviewShellUsersRoute
'/admin': typeof AuthAdminIndexRoute '/admin': typeof AuthAdminIndexRoute
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute '/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
'/design-preview/users': typeof DesignPreviewShellUsersIndexRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
@@ -216,9 +217,9 @@ export interface FileRoutesById {
'/design-preview/_shell/audit': typeof DesignPreviewShellAuditRoute '/design-preview/_shell/audit': typeof DesignPreviewShellAuditRoute
'/design-preview/_shell/logins': typeof DesignPreviewShellLoginsRoute '/design-preview/_shell/logins': typeof DesignPreviewShellLoginsRoute
'/design-preview/_shell/oidc': typeof DesignPreviewShellOidcRoute '/design-preview/_shell/oidc': typeof DesignPreviewShellOidcRoute
'/design-preview/_shell/users': typeof DesignPreviewShellUsersRoute
'/_auth/admin/': typeof AuthAdminIndexRoute '/_auth/admin/': typeof AuthAdminIndexRoute
'/_auth/admin/users/$userId': typeof AuthAdminUsersUserIdRoute '/_auth/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
'/design-preview/_shell/users/': typeof DesignPreviewShellUsersIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
@@ -241,9 +242,9 @@ export interface FileRouteTypes {
| '/design-preview/audit' | '/design-preview/audit'
| '/design-preview/logins' | '/design-preview/logins'
| '/design-preview/oidc' | '/design-preview/oidc'
| '/design-preview/users'
| '/admin/' | '/admin/'
| '/admin/users/$userId' | '/admin/users/$userId'
| '/design-preview/users/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
@@ -262,9 +263,9 @@ export interface FileRouteTypes {
| '/design-preview/audit' | '/design-preview/audit'
| '/design-preview/logins' | '/design-preview/logins'
| '/design-preview/oidc' | '/design-preview/oidc'
| '/design-preview/users'
| '/admin' | '/admin'
| '/admin/users/$userId' | '/admin/users/$userId'
| '/design-preview/users'
id: id:
| '__root__' | '__root__'
| '/' | '/'
@@ -287,9 +288,9 @@ export interface FileRouteTypes {
| '/design-preview/_shell/audit' | '/design-preview/_shell/audit'
| '/design-preview/_shell/logins' | '/design-preview/_shell/logins'
| '/design-preview/_shell/oidc' | '/design-preview/_shell/oidc'
| '/design-preview/_shell/users'
| '/_auth/admin/' | '/_auth/admin/'
| '/_auth/admin/users/$userId' | '/_auth/admin/users/$userId'
| '/design-preview/_shell/users/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
@@ -448,13 +449,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DesignPreviewShellOidcRouteImport preLoaderRoute: typeof DesignPreviewShellOidcRouteImport
parentRoute: typeof DesignPreviewShellRoute parentRoute: typeof DesignPreviewShellRoute
} }
'/design-preview/_shell/users': {
id: '/design-preview/_shell/users'
path: '/users'
fullPath: '/design-preview/users'
preLoaderRoute: typeof DesignPreviewShellUsersRouteImport
parentRoute: typeof DesignPreviewShellRoute
}
'/_auth/admin/users/$userId': { '/_auth/admin/users/$userId': {
id: '/_auth/admin/users/$userId' id: '/_auth/admin/users/$userId'
path: '/users/$userId' path: '/users/$userId'
@@ -462,6 +456,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthAdminUsersUserIdRouteImport preLoaderRoute: typeof AuthAdminUsersUserIdRouteImport
parentRoute: typeof AuthAdminRoute parentRoute: typeof AuthAdminRoute
} }
'/design-preview/_shell/users/': {
id: '/design-preview/_shell/users/'
path: '/users'
fullPath: '/design-preview/users/'
preLoaderRoute: typeof DesignPreviewShellUsersIndexRouteImport
parentRoute: typeof DesignPreviewShellRoute
}
} }
} }
@@ -508,7 +509,7 @@ interface DesignPreviewShellRouteChildren {
DesignPreviewShellAuditRoute: typeof DesignPreviewShellAuditRoute DesignPreviewShellAuditRoute: typeof DesignPreviewShellAuditRoute
DesignPreviewShellLoginsRoute: typeof DesignPreviewShellLoginsRoute DesignPreviewShellLoginsRoute: typeof DesignPreviewShellLoginsRoute
DesignPreviewShellOidcRoute: typeof DesignPreviewShellOidcRoute DesignPreviewShellOidcRoute: typeof DesignPreviewShellOidcRoute
DesignPreviewShellUsersRoute: typeof DesignPreviewShellUsersRoute DesignPreviewShellUsersIndexRoute: typeof DesignPreviewShellUsersIndexRoute
} }
const DesignPreviewShellRouteChildren: DesignPreviewShellRouteChildren = { const DesignPreviewShellRouteChildren: DesignPreviewShellRouteChildren = {
@@ -518,7 +519,7 @@ const DesignPreviewShellRouteChildren: DesignPreviewShellRouteChildren = {
DesignPreviewShellAuditRoute: DesignPreviewShellAuditRoute, DesignPreviewShellAuditRoute: DesignPreviewShellAuditRoute,
DesignPreviewShellLoginsRoute: DesignPreviewShellLoginsRoute, DesignPreviewShellLoginsRoute: DesignPreviewShellLoginsRoute,
DesignPreviewShellOidcRoute: DesignPreviewShellOidcRoute, DesignPreviewShellOidcRoute: DesignPreviewShellOidcRoute,
DesignPreviewShellUsersRoute: DesignPreviewShellUsersRoute, DesignPreviewShellUsersIndexRoute: DesignPreviewShellUsersIndexRoute,
} }
const DesignPreviewShellRouteWithChildren = const DesignPreviewShellRouteWithChildren =
+270 -15
View File
@@ -1,25 +1,280 @@
/** import { useMemo, useState } from 'react'
* Account security — passkeys.
* Preview: https://reui.io/preview/base/settings-10 · https://reui.io/preview/base/settings-16
*/
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { PageShell } from '@/components/page-shell' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { PasskeySettingsPanel } from '@/components/reui-kit/passkey-settings-panel' 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')({ export const Route = createFileRoute('/_auth/account')({
component: AccountPage, 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() { 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 ( return (
<PageShell> <>
<div className="flex flex-col gap-1"> <PageHead
<h1 className="text-2xl font-semibold tracking-tight">Безопасность</h1> title="Безопасность"
<p className="text-muted-foreground text-sm"> sub="Passkey как альтернатива паролю · пароль остаётся запасным входом"
Passkey как альтернатива паролю. Пароль остаётся запасным входом. actions={
</p> <Button disabled={!supported} onClick={() => setAddOpen(true)}>
</div> Добавить passkey
<PasskeySettingsPanel /> </Button>
</PageShell> }
/>
{!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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner' import { toast } from 'sonner'
import { defaultAppSwitcherConfig } from '@authportal/shared' 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 { AppSwitcherAdminEditor } from '@/components/reui-kit/app-switcher-admin-editor'
import { import {
Frame, Frame,
@@ -47,13 +47,8 @@ function AdminAppsPage() {
}) })
return ( return (
<PageShell> <>
<div className="flex flex-col gap-px"> <PageHead title="Ссылки приложений" sub="URL и подписи для App Switcher (CFDM, VPS Tracker, EvoBGP)" />
<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>
<Frame dense spacing="sm" className="w-full"> <Frame dense spacing="sm" className="w-full">
<FrameHeader> <FrameHeader>
@@ -84,6 +79,6 @@ function AdminAppsPage() {
)} )}
</FramePanel> </FramePanel>
</Frame> </Frame>
</PageShell> </>
) )
} }
+242 -73
View File
@@ -1,105 +1,274 @@
import { useMemo, useState } from 'react'
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { HistoryIcon, NetworkIcon, OctagonAlertIcon, UserCogIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import type { import type { AuditLogEntry, AuditPurgeResponse, AuditSettings, AuditSeverity } from '@authportal/shared'
AuditLogEntry, import { actionMeta, exportAuditCsv, severityLabel, sourceAppLabel } from '@/components/reui-kit/audit-log-helpers'
AuditPurgeResponse, import { CodeBlock, FormFrame, KvList, PageHead, PageTab, PageTabsList, PortalSheet, SettingRow, StatusBadge, TypedConfirm } from '@/components/portal/bits'
AuditSettings, import { ActivityFeed } from '@/components/portal/canvas-blocks'
} from '@authportal/shared' import { fmtTime, fmtWhen } from '@/components/portal/format'
import { PageShell } from '@/components/page-shell' import { kpi } from '@/components/portal/kpi'
import { AuditLogTimeline } from '@/components/reui-kit/audit-log-timeline' import { KpiStrip } from '@/components/portal/kpi-strip'
import { AuditRetentionPanel } from '@/components/reui-kit/audit-retention-panel' import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
import { api, ApiError } from '@/lib/api-client' import type { Tone } from '@/components/portal/tone'
import {
auditQueryKey,
auditQueryOptions,
auditSettingsQueryKey,
auditSettingsQueryOptions,
} from '@/queries/audit'
import { Skeleton } from '@authportal/ui/components/skeleton'
import { Button } from '@authportal/ui/components/button' 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')({ export const Route = createFileRoute('/_auth/admin/audit')({
component: AdminAuditPage, 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() { function AdminAuditPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { const [tab, setTab] = useState('feed')
data: entries = [], const [entry, setEntry] = useState<Row | null>(null)
isLoading, const [purgeOpen, setPurgeOpen] = useState(false)
error, const [daysDraft, setDaysDraft] = useState<string | null>(null)
refetch,
} = useQuery(auditQueryOptions({ kind: 'changes', limit: 500 })) const entriesQ = useQuery(auditQueryOptions({ kind: 'changes', limit: 500 }))
const { data: settings } = useQuery(auditSettingsQueryOptions) const { data: settings } = useQuery(auditSettingsQueryOptions)
const retention = settings?.retention_days ?? 90
const entries = useMemo(() => entriesQ.data ?? [], [entriesQ.data])
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: (retention_days: number) => mutationFn: (retention_days: number) => api.put<AuditSettings>('/api/v1/admin/audit/settings', { retention_days }),
api.put<AuditSettings>('/api/v1/admin/audit/settings', {
retention_days,
}),
onSuccess: async (data) => { onSuccess: async (data) => {
await queryClient.invalidateQueries({ queryKey: auditSettingsQueryKey }) await queryClient.invalidateQueries({ queryKey: auditSettingsQueryKey })
await queryClient.invalidateQueries({ queryKey: auditQueryKey }) await queryClient.invalidateQueries({ queryKey: auditQueryKey })
setDaysDraft(null)
toast.success(`Срок хранения: ${data.retention_days} дн.`) toast.success(`Срок хранения: ${data.retention_days} дн.`)
}, },
onError: (err) => { onError: (err) => toast.error(err instanceof ApiError ? err.message : 'Не удалось сохранить'),
toast.error(
err instanceof ApiError ? err.message : 'Не удалось сохранить',
)
},
}) })
const purgeMutation = useMutation({ const purgeMutation = useMutation({
mutationFn: () => mutationFn: () => api.post<AuditPurgeResponse>('/api/v1/admin/audit/purge'),
api.post<AuditPurgeResponse>('/api/v1/admin/audit/purge'),
onSuccess: async (data) => { onSuccess: async (data) => {
await queryClient.invalidateQueries({ queryKey: auditQueryKey }) await queryClient.invalidateQueries({ queryKey: auditQueryKey })
setPurgeOpen(false)
toast.success(`Удалено записей: ${data.deleted}`) toast.success(`Удалено записей: ${data.deleted}`)
}, },
onError: (err) => { onError: (err) => toast.error(err instanceof ApiError ? err.message : 'Ошибка очистки'),
toast.error(err instanceof ApiError ? err.message : 'Ошибка очистки')
},
}) })
return ( const rows = useMemo<Row[]>(
<PageShell> () =>
<div className="flex flex-col gap-6"> entries.map((e) => ({
<AuditRetentionPanel id: e.id,
retentionDays={settings?.retention_days ?? 90} action: e.action,
saving={saveMutation.isPending} summary: e.summary,
purging={purgeMutation.isPending} tone: SEVERITY_TONE[e.severity],
onSave={(days) => saveMutation.mutate(days)} label: severityLabel[e.severity],
onPurge={() => purgeMutation.mutate()} source: sourceAppLabel(e.source_app),
/> who: e.actor_email?.split('@')[0] ?? 'system',
ip: e.ip ?? '—',
when: fmtWhen(e.created_at),
entry: e,
})),
[entries],
)
{error ? ( const feed = useMemo(
<div className="flex flex-col gap-2"> () =>
<p className="text-destructive text-sm"> rows.slice(0, 5).map((r) => ({
{error instanceof ApiError ? error.message : 'Ошибка загрузки'} t: fmtTime(r.entry.created_at),
</p> title: actionMeta(r.action).label,
<Button text: `${r.summary} · ${r.who} · ${r.source}`,
variant="outline" label: r.label,
size="sm" tone: r.tone,
className="w-fit" })),
onClick={() => refetch()} [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> </Button>
</div> <Button variant="outline" onClick={() => setPurgeOpen(true)}>
) : isLoading ? ( Очистить старые записи…
<div className="flex flex-col gap-3"> </Button>
{Array.from({ length: 6 }).map((_, i) => ( </>
<Skeleton key={i} className="h-16 w-full" /> }
))} />
</div> {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">
<AuditLogTimeline <span>{entriesQ.error instanceof ApiError ? entriesQ.error.message : 'Не удалось загрузить журнал. Повторите запрос.'}</span>
entries={entries as AuditLogEntry[]} <Button variant="outline" onClick={() => void entriesQ.refetch()}>
totalCount={entries.length} Повторить
/> </Button>
)} </div>
</div> ) : null}
</PageShell> <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 { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner' import { toast } from 'sonner'
import { ShieldCheckIcon, UserCheckIcon, UserXIcon, UsersIcon } from 'lucide-react'
import type { AdminUser, CreateUserRequest } from '@authportal/shared' 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 { AdminUsersGrid } from '@/components/reui-kit/admin-users-grid'
import { CreateUserSheet } from '@/components/reui-kit/create-user-sheet' import { CreateUserSheet } from '@/components/reui-kit/create-user-sheet'
import { UserAccessSheet } from '@/components/reui-kit/user-access-sheet' import { UserAccessSheet } from '@/components/reui-kit/user-access-sheet'
@@ -174,8 +177,21 @@ function AdminUsersPage() {
[invalidateUsers], [invalidateUsers],
) )
const admins = users.filter((u) => u.is_admin).length
const disabled = users.filter((u) => u.disabled).length
return ( 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 ? ( {error ? (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<p className="text-destructive text-sm"> <p className="text-destructive text-sm">
@@ -229,6 +245,6 @@ function AdminUsersPage() {
if (!next) setAuditUser(null) 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 { 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { import { CircleCheckIcon, GlobeIcon, MonitorSmartphoneIcon, ShieldAlertIcon } from 'lucide-react'
type ColumnDef,
type PaginationState,
type SortingState,
useTable,
} from '@tanstack/react-table'
import { toast } from 'sonner' import { toast } from 'sonner'
import type { AdminSession, AuditLogEntry } from '@authportal/shared' import type { AdminSession, AuditLogEntry } from '@authportal/shared'
import { import { actionMeta, detailString, exportAuditCsv, matchesRange, sourceAppLabel, type AuditRange } from '@/components/reui-kit/audit-log-helpers'
KeyRoundIcon, import { ExplainBanner } from '@/components/portal/explain-banner'
LogInIcon, import { CodeBlock, KvList, PageHead, PageTab, PageTabsList, PortalSheet, Segmented, StatusBadge } from '@/components/portal/bits'
ShieldAlertIcon, import { fmtWhen } from '@/components/portal/format'
Trash2Icon, import { kpi } from '@/components/portal/kpi'
} from 'lucide-react' import { KpiStrip } from '@/components/portal/kpi-strip'
import { PageShell } from '@/components/page-shell' import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' import type { Tone } from '@/components/portal/tone'
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 { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import { Skeleton } from '@authportal/ui/components/skeleton' import { Skeleton } from '@authportal/ui/components/skeleton'
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { import { api, ApiError } from '@/lib/api-client'
Select, import { auditQueryOptions } from '@/queries/audit'
SelectContent, import { sessionsListQueryKey, sessionsQueryOptions } from '@/queries/sessions'
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@authportal/ui/components/select'
import { RANGE_OPTIONS } from '@/components/reui-kit/audit-log-helpers'
export const Route = createFileRoute('/_auth/admin/logins')({ export const Route = createFileRoute('/_auth/admin/logins')({
component: AdminLoginsPage, 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) { type LoginRow = {
if (action === 'auth.login_failed') return 'warning-outline' as const id: string
if (action === 'auth.sso_handoff') return 'info-outline' as const login: string
return 'success-outline' as const 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() { function AdminLoginsPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [tab, setTab] = useState<'history' | 'sessions'>('history') const navigate = useNavigate()
const [resultFilter, setResultFilter] = useState<ResultFilter>('all') const [range, setRange] = useState(0)
const [range, setRange] = useState<AuditRange>('7d') const [event, setEvent] = useState<LoginRow | null>(null)
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 20,
})
const [sorting, setSorting] = useState<SortingState>([
{ id: 'created_at', desc: true },
])
const { const entriesQ = useQuery(auditQueryOptions({ kind: 'logins', limit: 500 }))
data: entries = [], const sessionsQ = useQuery(sessionsQueryOptions())
isLoading, const entries = useMemo(() => entriesQ.data ?? [], [entriesQ.data])
error, const sessions = useMemo(() => sessionsQ.data ?? [], [sessionsQ.data])
refetch, const period = PERIODS[range]!.value
} = useQuery(auditQueryOptions({ kind: 'logins', limit: 500 }))
const { const revoke = useMutation({
data: sessions = [],
isLoading: sessionsLoading,
error: sessionsError,
refetch: refetchSessions,
} = useQuery(sessionsQueryOptions())
const revokeMutation = useMutation({
mutationFn: (id: string) => api.delete(`/api/v1/admin/sessions/${id}`), mutationFn: (id: string) => api.delete(`/api/v1/admin/sessions/${id}`),
onSuccess: async () => { onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: sessionsListQueryKey() }) await queryClient.invalidateQueries({ queryKey: sessionsListQueryKey() })
toast.success('Сессия отозвана') toast.success('Сессия завершена')
},
onError: (err) => {
toast.error(
err instanceof ApiError ? err.message : 'Не удалось отозвать сессию',
)
}, },
onError: (err) => toast.error(err instanceof ApiError ? err.message : 'Не удалось завершить сессию'),
}) })
const filtered = useMemo(() => { const rows = useMemo<LoginRow[]>(
return entries.filter((e) => { () =>
if (!matchesRange(e, range)) return false entries
if (resultFilter === 'success') { .filter((e) => matchesRange(e, period))
return e.action === 'auth.login' || e.action === 'auth.sso_handoff' .map((e) => ({
} id: e.id,
if (resultFilter === 'failed') return e.action === 'auth.login_failed' login: e.actor_email ?? '—',
return true client: shortUa(detailString(e.details, 'user_agent')),
}) tone: toneOf(e.action),
}, [entries, range, resultFilter]) 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 day = entries.filter((e) => matchesRange(e, '24h'))
const ok = day.filter( return {
(e) => e.action === 'auth.login' || e.action === 'auth.sso_handoff', ok: day.filter((e) => isOk(e.action)).length,
).length fail: day.filter((e) => e.action === FAILED).length,
const fail = day.filter((e) => e.action === 'auth.login_failed').length ips: new Set(day.map((e) => e.ip).filter(Boolean)).size,
const ips = new Set( users: new Set(sessions.map((s) => s.user_id)).size,
day.map((e) => e.ip).filter((ip): ip is string => Boolean(ip)), }
) }, [entries, sessions])
return { ok, fail, ips: ips.size, sessions: sessions.length }
}, [entries, sessions.length])
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>[]>(
() => [ () => [
{ { id: 'login', title: 'Логин', size: 260, sortValue: (r) => r.login, cell: (r) => <TwoLineCell dot={r.tone} title={r.login} sub={r.client} /> },
accessorKey: 'created_at', { id: 'status', title: 'Статус', size: 130, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> },
header: ({ column }) => ( { 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> },
<DataGridColumnHeader title="Время" column={column} /> { 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> },
cell: ({ row }) => ( { id: 'reason', title: 'Причина', size: 200, cell: (r) => <span className="font-mono text-[12.5px]">{r.reason}</span> },
<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,
},
], ],
[], [],
) )
const historyTable = useTable({ const sessionCols = useMemo<GridColumn<SessionRow>[]>(
features: dataGridFeatures,
data: filtered,
columns: historyColumns,
state: { pagination, sorting },
onPaginationChange: setPagination,
onSortingChange: setSorting,
})
const sessionColumns = useMemo<ColumnDef<DataGridFeatures, AdminSession>[]>(
() => [ () => [
{ { id: 'user', title: 'Пользователь', size: 260, sortValue: (r) => r.user, cell: (r) => <TwoLineCell dot="ok" title={r.user} sub={r.note} /> },
accessorKey: 'email', { id: 'status', title: 'Статус', size: 110, cell: () => <StatusBadge tone="ok">Активна</StatusBadge> },
header: ({ column }) => ( { id: 'ip', title: 'IP · клиент', size: 280, cell: (r) => <span className="font-mono text-[12.5px]">{r.ipClient}</span> },
<DataGridColumnHeader title="Пользователь" column={column} /> { 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> },
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,
},
], ],
[revokeMutation], [],
) )
const [sessionPagination, setSessionPagination] = useState<PaginationState>({ const loading = entriesQ.isLoading || sessionsQ.isLoading
pageIndex: 0, const failedQuery = entriesQ.isError || sessionsQ.isError
pageSize: 20,
})
const sessionsTable = useTable({
features: dataGridFeatures,
data: sessions,
columns: sessionColumns,
state: { pagination: sessionPagination },
onPaginationChange: setSessionPagination,
})
return ( return (
<PageShell> <>
<div className="flex flex-col gap-6"> <PageHead
<KpiStatGrid title="Входы"
items={[ sub="Журнал входов и активные сессии · портал и SSO в приложения"
{ actions={
id: 'ok', <Button
label: 'Успешные (24ч)', variant="outline"
value: kpi.ok, disabled={rows.length === 0}
icon: <LogInIcon />, onClick={() => {
iconClassName: 'text-success', exportAuditCsv(rows.map((r) => r.entry))
hint: 'Вход и SSO', toast.success(`CSV сформирован: ${rows.length} записей`)
}, }}
{
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"
> >
<TabsTrigger value="history" className="px-1 text-sm after:-bottom-px!"> Экспорт CSV
История </Button>
</TabsTrigger> }
<TabsTrigger value="sessions" className="px-1 text-sm after:-bottom-px!"> />
Активные сессии {burst ? (
<span className="bg-muted text-muted-foreground ms-2 rounded-md px-1.5 text-xs tabular-nums"> <ExplainBanner
{sessions.length} tone="er"
</span> title={`Подозрительная серия неудач: ${burst.login}.`}
</TabsTrigger> text={`${burst.n} неудачных входов с ${burst.ip} за 10 минут. Действия: «Показать», отключить пользователя в разделе «Пользователи».`}
</TabsList> >
</Tabs> {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' ? ( <PortalSheet
<div className="flex flex-col gap-4"> open={event !== null}
<div className="flex flex-wrap items-center gap-2"> onOpenChange={(o) => !o && setEvent(null)}
<Tabs title="Событие входа"
value={resultFilter} description="Детали записи журнала"
onValueChange={(v) => { footer={
if (v === 'all' || v === 'success' || v === 'failed') { <>
setResultFilter(v) <Button variant="outline" onClick={() => setEvent(null)}>
} Закрыть
}} </Button>
>
<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>
<Button <Button
variant="outline" variant="outline"
size="sm" disabled={!event?.entry.actor_user_id}
className="w-fit" onClick={() => {
onClick={() => refetchSessions()} const id = event?.entry.actor_user_id
setEvent(null)
if (id) void navigate({ to: '/admin/users/$userId', params: { userId: id } })
}}
> >
Повторить Открыть пользователя
</Button> </Button>
</div> </>
) : sessionsLoading ? ( }
<Skeleton className="h-64 w-full" /> >
) : ( {event ? (
<DataGrid <>
i18n={DATA_GRID_RU} <div>
table={sessionsTable} <StatusBadge tone={event.tone}>{event.status}</StatusBadge>
recordCount={sessions.length} </div>
tableLayout={{ dense: true }} <KvList
> items={[
<Frame stacked dense> ['Логин', event.login],
<FrameHeader> ['IP', event.ip],
<FrameTitle>Активные сессии</FrameTitle> ['Клиент', event.client],
</FrameHeader> ['Приложение', event.app],
<FramePanel className="p-0"> ['Причина', event.reason],
<DataGridScrollArea> ]}
<DataGridTable /> />
</DataGridScrollArea> <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>
<FrameFooter> </>
<DataGridPagination sizes={[10, 20, 30]} /> ) : null}
</FrameFooter> </PortalSheet>
</FramePanel> </>
</Frame>
</DataGrid>
)}
</div>
</PageShell>
) )
} }
+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 { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router' 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 { toast } from 'sonner'
import type { OidcClientCreated, OidcClientPublic } from '@authportal/shared' import type { OidcClientCreated, OidcClientPublic } from '@authportal/shared'
import { PageShell } from '@/components/page-shell' import { CodeBlock, FormFrame, KvList, PageHead, PageTab, PageTabsList, PortalSheet, SettingRow, StatusBadge, TypedConfirm } from '@/components/portal/bits'
import { import { kpi } from '@/components/portal/kpi'
Frame, import { KpiStrip } from '@/components/portal/kpi-strip'
FrameDescription, import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import { Skeleton } from '@authportal/ui/components/skeleton' import { Skeleton } from '@authportal/ui/components/skeleton'
import { import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@authportal/ui/components/alert-dialog'
import { Badge } from '@/components/reui/badge'
import { CreateOidcClientSheet } from '@/components/reui-kit/create-oidc-client-sheet' import { CreateOidcClientSheet } from '@/components/reui-kit/create-oidc-client-sheet'
import { ApiError } from '@/lib/api-client' import { ApiError } from '@/lib/api-client'
import { import { createOidcClient, deleteOidcClient, oidcClientsQueryOptions, oidcMetaQueryOptions, rotateOidcClientSecret } from '@/queries/oidc'
createOidcClient,
deleteOidcClient,
oidcClientsQueryOptions,
oidcMetaQueryOptions,
rotateOidcClientSecret,
} from '@/queries/oidc'
export const Route = createFileRoute('/_auth/admin/oidc')({ export const Route = createFileRoute('/_auth/admin/oidc')({
component: AdminOidcPage, component: AdminOidcPage,
}) })
function copy(value: string) {
void navigator.clipboard.writeText(value)
toast.success('Скопировано')
}
function AdminOidcPage() { function AdminOidcPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { data: clients, isLoading, isError, error } = useQuery( const { data: clients = [], isLoading, isError, error, refetch } = useQuery(oidcClientsQueryOptions)
oidcClientsQueryOptions,
)
const { data: meta } = useQuery(oidcMetaQueryOptions) const { data: meta } = useQuery(oidcMetaQueryOptions)
const [tab, setTab] = useState('clients')
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const [secretOnce, setSecretOnce] = useState<OidcClientCreated | null>(null) 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({ const createMutation = useMutation({
mutationFn: createOidcClient, mutationFn: createOidcClient,
onSuccess: (created) => { onSuccess: (created) => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'oidc'] }) void invalidate()
setCreateOpen(false) setCreateOpen(false)
setSecretOnce(created) setSecretOnce(created)
toast.success('OIDC-клиент создан') toast.success('OIDC-клиент создан')
}, },
onError: (err) => { onError: fail('Не удалось создать клиента'),
toast.error(
err instanceof ApiError ? err.message : 'Не удалось создать клиента',
)
},
}) })
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: deleteOidcClient, mutationFn: deleteOidcClient,
onSuccess: () => { onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'oidc'] }) void invalidate()
setDeleteId(null) setDeleteTarget(null)
setSelected(null)
toast.success('Клиент удалён') toast.success('Клиент удалён')
}, },
onError: (err) => { onError: fail('Не удалось удалить'),
toast.error(
err instanceof ApiError ? err.message : 'Не удалось удалить',
)
},
}) })
const rotateMutation = useMutation({ const rotateMutation = useMutation({
mutationFn: rotateOidcClientSecret, mutationFn: rotateOidcClientSecret,
onSuccess: (created) => { onSuccess: (created) => {
setSecretOnce(created) setSecretOnce(created)
toast.success('Секрет обновлён') toast.success('Секрет обновлён')
}, },
onError: (err) => { onError: fail('Не удалось обновить секрет'),
toast.error(
err instanceof ApiError ? err.message : 'Не удалось обновить секрет',
)
},
}) })
return ( const columns = useMemo<GridColumn<OidcClientPublic>[]>(
<PageShell> () => [
<div className="flex flex-wrap items-end justify-between gap-3"> {
<div className="flex flex-col gap-px"> id: 'name',
<h1 className="text-2xl font-semibold tracking-tight">OIDC-клиенты</h1> title: 'Клиент',
<p className="text-muted-foreground text-sm"> size: 280,
Внешние сервисы (Technitium DNS и др.) через OpenID Connect sortValue: (c) => c.name,
</p> cell: (c) => <TwoLineCell dot={c.enabled ? 'ok' : 'wn'} title={c.name} sub={c.client_id} />,
</div> },
<Button size="sm" onClick={() => setCreateOpen(true)}> { id: 'status', title: 'Статус', size: 120, cell: (c) => <StatusBadge tone={c.enabled ? 'ok' : 'wn'}>{c.enabled ? 'enabled' : 'disabled'}</StatusBadge> },
<PlusIcon className="size-4" /> { 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> },
</Button> ],
</div> [],
)
{meta ? ( const enabled = clients.filter((c) => c.enabled).length
<Frame dense className="w-full">
<FrameHeader> return (
<FrameTitle>Issuer</FrameTitle> <>
<FrameDescription> <PageHead
Metadata для Relying Party title="OIDC-клиенты"
</FrameDescription> sub="Внешние сервисы (Technitium DNS и др.) через OpenID Connect · Authorization Code"
</FrameHeader> actions={
<FramePanel className="flex flex-col gap-2 text-sm"> <Button onClick={() => setCreateOpen(true)}>
<CopyRow label="Issuer" value={meta.issuer} /> <PlusIcon /> Добавить клиента
<CopyRow label="Discovery" value={meta.discovery_url} /> </Button>
<CopyRow label="JWKS" value={meta.jwks_url} /> }
</FramePanel> />
</Frame> {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} ) : null}
<KpiStrip
<Frame dense className="w-full"> aria-label="Показатели"
<FrameHeader> items={[
<FrameTitle>Клиенты</FrameTitle> kpi('all', 'Клиентов', String(clients.length), { icon: KeyRoundIcon, badge: 'confidential' }),
<FrameDescription> kpi('on', 'Включено', String(enabled), { icon: PowerIcon, tone: 'ok', badge: 'enabled' }),
Confidential clients (Authorization Code) kpi('off', 'Отключено', String(clients.length - enabled), { icon: ShieldCheckIcon, tone: clients.length - enabled ? 'wn' : 'neutral', badge: 'disabled' }),
</FrameDescription> kpi('iss', 'Issuer', meta ? 'готов' : '—', { icon: LinkIcon, badge: 'discovery + JWKS' }),
</FrameHeader> ]}
<FramePanel> />
<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 ? ( {isLoading ? (
<div className="flex flex-col gap-3"> <Skeleton className="h-64 w-full rounded-xl" />
<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>
) : ( ) : (
<ul className="flex flex-col gap-3"> <SectionGrid
{clients.map((c) => ( title="Клиенты"
<OidcClientRow description="Клик по строке — секрет, redirect URI и удаление"
key={c.id} rows={clients}
client={c} columns={columns}
onRotate={() => rotateMutation.mutate(c.id)} getRowId={(c) => c.id}
onDelete={() => setDeleteId(c.id)} searchText={(c) => `${c.name} ${c.client_id} ${c.redirect_uris.join(' ')}`}
rotating={rotateMutation.isPending} selectable={false}
/> views={[
))} { label: 'Все', match: () => true },
</ul> { label: 'Включённые', match: (c) => c.enabled },
{ label: 'Отключённые', match: (c) => !c.enabled },
]}
onRowClick={setSelected}
emptyMessage="Пока нет OIDC-клиентов. Создайте клиент для Technitium DNS."
/>
)} )}
</FramePanel> </TabsContent>
</Frame> <TabsContent value="issuer">
<FormFrame title="Issuer" description="Metadata для Relying Party" badge="OpenID Connect">
<CreateOidcClientSheet {meta ? (
open={createOpen} <>
onOpenChange={setCreateOpen} {(
pending={createMutation.isPending} [
onSubmit={(values) => createMutation.mutate(values)} ['Issuer', meta.issuer],
/> ['Discovery', meta.discovery_url],
['JWKS', meta.jwks_url],
<SecretRevealDialog ] as const
client={secretOnce} ).map(([label, value]) => (
onClose={() => setSecretOnce(null)} <SettingRow key={label} title={label} description={value}>
/> <Button variant="outline" onClick={() => copy(value)} aria-label={`Копировать ${label}`}>
<CopyIcon /> Копировать
<AlertDialog </Button>
open={deleteId != null} </SettingRow>
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>
) : ( ) : (
<Badge variant="warning-light" size="sm"> <Skeleton className="h-24 w-full" />
disabled
</Badge>
)} )}
</div> </FormFrame>
<code className="text-muted-foreground text-xs"> </TabsContent>
{client.client_id} </Tabs>
</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>
)
}
function SecretRevealDialog({ <PortalSheet
client, open={selected !== null}
onClose, onOpenChange={(o) => !o && setSelected(null)}
}: { title={selected?.name ?? 'Клиент'}
client: OidcClientCreated | null description="OIDC-клиент"
onClose: () => void footer={
}) { <>
const open = client != null <Button variant="outline" onClick={() => setSelected(null)}>
const text = useMemo(() => { Закрыть
if (!client) return '' </Button>
return `client_id: ${client.client_id}\nclient_secret: ${client.client_secret}` <Button
}, [client]) variant="outline"
disabled={!selected || rotateMutation.isPending}
return ( onClick={() => selected && rotateMutation.mutate(selected.id)}
<AlertDialog >
open={open} <KeyRoundIcon /> Новый секрет
onOpenChange={(o: boolean) => { </Button>
if (!o) onClose() <Button variant="destructive" onClick={() => selected && setDeleteTarget(selected)}>
}} Удалить
> </Button>
<AlertDialogContent size="sm"> </>
<AlertDialogHeader> }
<AlertDialogTitle>Сохраните client_secret</AlertDialogTitle> >
<AlertDialogDescription> {selected ? (
Секрет показывается только один раз. Вставьте его в Technitium SSO. <>
</AlertDialogDescription> <div>
</AlertDialogHeader> <StatusBadge tone={selected.enabled ? 'ok' : 'wn'}>{selected.enabled ? 'enabled' : 'disabled'}</StatusBadge>
{client ? ( </div>
<pre className="bg-muted max-h-40 overflow-auto rounded-md p-3 text-xs"> <KvList items={[['client_id', selected.client_id], ['Scopes', selected.scopes.join(' ')]]} />
{text} <CodeBlock label="Redirect URI">{selected.redirect_uris.join('\n')}</CodeBlock>
</pre> </>
) : null} ) : null}
<AlertDialogFooter> </PortalSheet>
<AlertDialogCancel>Закрыть</AlertDialogCancel>
<AlertDialogAction <CreateOidcClientSheet open={createOpen} onOpenChange={setCreateOpen} pending={createMutation.isPending} onSubmit={(values) => createMutation.mutate(values)} />
onClick={() => {
void navigator.clipboard.writeText(text) <PortalSheet
toast.success('Скопировано') open={secretOnce !== null}
}} onOpenChange={(o) => !o && setSecretOnce(null)}
> title="Сохраните client_secret"
Копировать description="Секрет показывается только один раз · вставьте его в Technitium SSO"
</AlertDialogAction> footer={
</AlertDialogFooter> <>
</AlertDialogContent> <Button variant="outline" onClick={() => setSecretOnce(null)}>
</AlertDialog> Закрыть
</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 { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { import {
BadgeCheckIcon,
CloudIcon, CloudIcon,
GlobeIcon, GlobeIcon,
KeyRoundIcon,
LayoutDashboardIcon, LayoutDashboardIcon,
LayoutGridIcon, LayoutGridIcon,
ServerIcon, ServerIcon,
ShieldIcon, ShieldIcon,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner'
import type { AppAuthMode, AppId } from '@authportal/shared' import type { AppAuthMode, AppId } from '@authportal/shared'
import { PageShell } from '@/components/page-shell' import { PageHead, PageTab, PageTabsList } from '@/components/portal/bits'
import { IconTile } from '@/components/reui/icon-tile' import { AppCards, type AppCardItem } from '@/components/portal/canvas-blocks'
import { import { kpi } from '@/components/portal/kpi'
Frame, import { KpiStrip } from '@/components/portal/kpi-strip'
FramePanel,
} from '@/components/reui/frame'
import { QuickActionGrid } from '@/components/reui-kit/quick-action-grid'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import { Skeleton } from '@authportal/ui/components/skeleton' import { Skeleton } from '@authportal/ui/components/skeleton'
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { ssoOpenApp } from '@/lib/auth' import { ssoOpenApp } from '@/lib/auth'
import { catalogQueryOptions, meQueryOptions } from '@/queries/auth' import { catalogQueryOptions, meQueryOptions } from '@/queries/auth'
@@ -35,93 +36,106 @@ const APP_ICONS: Record<AppId, typeof CloudIcon> = {
mm: LayoutDashboardIcon, mm: LayoutDashboardIcon,
} }
async function openApp( async function openApp(baseUrl: string, authMode: AppAuthMode = 'jwt') {
_appId: AppId,
baseUrl: string,
authMode: AppAuthMode = 'jwt',
) {
try { try {
await ssoOpenApp(baseUrl, authMode) await ssoOpenApp(baseUrl, authMode)
} catch { } catch {
toast.error('Не удалось выпустить токен', { description: 'Открываем приложение по базовому адресу — войдите в нём отдельно.' })
window.location.href = baseUrl.replace(/\/$/, '') window.location.href = baseUrl.replace(/\/$/, '')
} }
} }
function AppsPage() { function AppsPage() {
const { data: me, isLoading: meLoading } = useQuery(meQueryOptions) const { data: me, isLoading: meLoading } = useQuery(meQueryOptions)
const { data: catalog, isLoading: catalogLoading } = useQuery( const { data: catalog, isLoading: catalogLoading, isError, refetch } = useQuery(catalogQueryOptions)
catalogQueryOptions,
) if (meLoading || catalogLoading) {
const isLoading = meLoading || catalogLoading return (
const allowed = new Set(me?.apps ?? []) <>
const apps = (catalog?.apps ?? []).filter((app) => allowed.has(app.id)) <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 ( return (
<PageShell> <>
<div className="flex flex-wrap items-end justify-between gap-3"> <PageHead
<div className="flex flex-col gap-1"> title="Приложения"
<h1 className="text-2xl font-semibold tracking-tight">Приложения</h1> sub="Открыть приложение с текущей сессией · SSO handoff без повторного ввода пароля"
<p className="text-muted-foreground text-sm"> actions={
Открыть приложение с текущей сессией (SSO handoff). me?.is_admin ? (
</p> <Button variant="outline" render={<Link to="/admin/apps" />} nativeButton={false}>
</div> Ссылки приложений
{me?.is_admin ? ( </Button>
<Button ) : undefined
variant="outline" }
size="sm" />
render={<Link to="/admin/apps" />} {isError ? (
nativeButton={false} <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> </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> </div>
) : apps.length === 0 ? ( ) : null}
<Frame dense className="w-full"> <KpiStrip
<FramePanel className="flex flex-col items-start gap-3 py-10"> aria-label="Показатели"
<IconTile items={[
variant="elevated" kpi('avail', 'Доступно приложений', String(allowed.length), { icon: LayoutGridIcon, tone: 'ok', badge: `из ${all.length}` }),
aria-hidden="true" kpi('jwt', 'JWT SSO', String(allowed.filter((a) => modeOf(a) === 'jwt').length), { icon: KeyRoundIcon, badge: 'fragment-токен' }),
className="size-10.5 text-muted-foreground" 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' : 'доступ по правам' }),
<LayoutGridIcon /> ]}
</IconTile> />
<div className="flex flex-col gap-1"> <Tabs defaultValue="all" className="min-w-0">
<p className="font-medium">Нет доступных приложений</p> <PageTabsList>
<p className="text-muted-foreground text-sm"> <PageTab value="all" label="Доступные" count={allowed.length} />
Обратитесь к администратору, чтобы получить доступ. <PageTab value="no" label="Без доступа" count={denied.length} />
</p> </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> </div>
</FramePanel> ) : (
</Frame> <AppCards
) : ( title="Мои приложения"
<QuickActionGrid description="Клик открывает приложение в новой вкладке · портал заново выпускает токен с актуальными правами"
title="" items={allowed.map((a) => toCard(a, true))}
actions={apps.map((app) => { />
const Icon = APP_ICONS[app.id] ?? LayoutGridIcon )}
return { </TabsContent>
id: app.id, <TabsContent value="no" className="min-w-0">
title: app.title, <AppCards title="Недоступные приложения" description="Права не выданы · запросите у администратора" items={denied.map((a) => toCard(a, false))} />
description: app.description, </TabsContent>
badgeLabel: 'Открыть', </Tabs>
icon: <Icon />, </>
onSelect: () =>
void openApp(
app.id,
app.url,
app.authMode ?? (app.id === 'dns' ? 'oidc' : 'jwt'),
),
}
})}
/>
)}
</PageShell>
) )
} }
+3 -3
View File
@@ -1,5 +1,5 @@
import { Outlet, createFileRoute, redirect } from '@tanstack/react-router' import { Outlet, createFileRoute, redirect } from '@tanstack/react-router'
import { AppShell } from '@/components/layout/app-shell' import { PortalShell } from '@/components/layout/portal-shell'
import { import {
clearToken, clearToken,
getToken, getToken,
@@ -36,8 +36,8 @@ export const Route = createFileRoute('/_auth')({
function AuthLayout() { function AuthLayout() {
return ( return (
<AppShell> <PortalShell>
<Outlet /> <Outlet />
</AppShell> </PortalShell>
) )
} }
@@ -1,6 +1,6 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { UsersPage } from '@/design-preview/pages/users-page' import { UsersPage } from '@/design-preview/pages/users-page'
export const Route = createFileRoute('/design-preview/_shell/users')({ export const Route = createFileRoute('/design-preview/_shell/users/')({
component: UsersPage, component: UsersPage,
}) })
+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: '[email protected]',
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 })] ? [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: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),
+13
View File
@@ -1,7 +1,20 @@
import path from 'path'
import { defineConfig } from 'vitest/config' import { defineConfig } from 'vitest/config'
export default defineConfig({ export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@authportal/shared': path.resolve(
__dirname,
'../../packages/shared/src/index.ts',
),
},
},
test: { test: {
passWithNoTests: true, passWithNoTests: true,
environment: 'jsdom',
include: ['test/**/*.test.ts'],
setupFiles: ['test/setup.ts'],
}, },
}) })
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff. Show more