Compare commits
No files matched your search
@@ -42,6 +42,7 @@ package-lock.json
|
|||||||
data
|
data
|
||||||
*.exe
|
*.exe
|
||||||
**/*.test.ts
|
**/*.test.ts
|
||||||
|
**/test/**
|
||||||
**/*.spec.ts
|
**/*.spec.ts
|
||||||
coverage
|
coverage
|
||||||
.coverage
|
.coverage
|
||||||
|
|||||||
@@ -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 }}-
|
||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/** Мини-кэш с TTL и вытеснением по LRU (Map сохраняет порядок вставки). */
|
||||||
|
export class TtlCache<K, V> {
|
||||||
|
private readonly map = new Map<K, { value: V; expires: number }>()
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly opts: {
|
||||||
|
max: number
|
||||||
|
ttlMs: number
|
||||||
|
now?: () => number
|
||||||
|
},
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private now(): number {
|
||||||
|
return (this.opts.now ?? Date.now)()
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key: K): V | undefined {
|
||||||
|
const hit = this.map.get(key)
|
||||||
|
if (!hit) return undefined
|
||||||
|
if (hit.expires <= this.now()) {
|
||||||
|
this.map.delete(key)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
// освежаем позицию для LRU
|
||||||
|
this.map.delete(key)
|
||||||
|
this.map.set(key, hit)
|
||||||
|
return hit.value
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key: K, value: V): void {
|
||||||
|
this.map.delete(key)
|
||||||
|
this.map.set(key, { value, expires: this.now() + this.opts.ttlMs })
|
||||||
|
while (this.map.size > this.opts.max) {
|
||||||
|
const oldest = this.map.keys().next()
|
||||||
|
if (oldest.done) break
|
||||||
|
this.map.delete(oldest.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(key: K): void {
|
||||||
|
this.map.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.map.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
get size(): number {
|
||||||
|
return this.map.size
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { createHash, timingSafeEqual } from 'node:crypto'
|
||||||
|
import { verifyPassword } from './password.js'
|
||||||
|
|
||||||
|
const PREFIX = 'sha256:'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Секреты OIDC-клиентов — 32 случайных байта (высокая энтропия), поэтому
|
||||||
|
* медленный argon2 не нужен: SHA-256 + сравнение за постоянное время.
|
||||||
|
*/
|
||||||
|
export function hashClientSecret(secret: string): string {
|
||||||
|
return PREFIX + createHash('sha256').update(secret).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClientSecretCheck = { ok: boolean; needsRehash: boolean }
|
||||||
|
|
||||||
|
export async function checkClientSecret(
|
||||||
|
stored: string,
|
||||||
|
secret: string,
|
||||||
|
): Promise<ClientSecretCheck> {
|
||||||
|
if (stored.startsWith(PREFIX)) {
|
||||||
|
const a = Buffer.from(stored)
|
||||||
|
const b = Buffer.from(hashClientSecret(secret))
|
||||||
|
return {
|
||||||
|
ok: a.length === b.length && timingSafeEqual(a, b),
|
||||||
|
needsRehash: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Устаревший формат (argon2): проверяем и просим перехешировать при успехе.
|
||||||
|
try {
|
||||||
|
const ok = await verifyPassword(stored, secret)
|
||||||
|
return { ok, needsRehash: ok }
|
||||||
|
} catch {
|
||||||
|
return { ok: false, needsRehash: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/** Значение cookie без построения RegExp на каждый запрос. */
|
||||||
|
export function parseCookie(
|
||||||
|
header: string | undefined,
|
||||||
|
name: string,
|
||||||
|
): string | null {
|
||||||
|
if (!header) return null
|
||||||
|
let from = 0
|
||||||
|
while (from < header.length) {
|
||||||
|
let end = header.indexOf(';', from)
|
||||||
|
if (end === -1) end = header.length
|
||||||
|
const eq = header.indexOf('=', from)
|
||||||
|
if (eq !== -1 && eq < end) {
|
||||||
|
const key = header.slice(from, eq).trim()
|
||||||
|
if (key === name) {
|
||||||
|
const raw = header.slice(eq + 1, end).trim()
|
||||||
|
if (!raw) return null
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(raw)
|
||||||
|
} catch {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
from = end + 1
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { isReturnToAllowed } from '@authportal/shared'
|
||||||
|
import type { AppConfig } from '../config.js'
|
||||||
|
import { oidcIssuerFromConfig } from '../config.js'
|
||||||
|
|
||||||
|
function originOf(url: string): string | null {
|
||||||
|
try {
|
||||||
|
return new URL(url).origin
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверка Origin для CORS с credentials. Разрешены: CORS_ORIGINS, issuer,
|
||||||
|
* приложения из App Switcher и всё, что разрешает RETURN_TO_ALLOWLIST
|
||||||
|
* (та же isReturnToAllowed, что для return_to: ".suffix", localhost, полные origin, private). '*' в CORS_ORIGINS —
|
||||||
|
* разрешить всё (только осознанно).
|
||||||
|
*/
|
||||||
|
export function createOriginChecker(
|
||||||
|
config: AppConfig,
|
||||||
|
switcherUrls: () => string[],
|
||||||
|
): (origin: string) => boolean {
|
||||||
|
const explicit = new Set<string>()
|
||||||
|
let allowAll = false
|
||||||
|
for (const raw of config.corsOrigins.split(',')) {
|
||||||
|
const v = raw.trim()
|
||||||
|
if (!v) continue
|
||||||
|
if (v === '*') allowAll = true
|
||||||
|
else {
|
||||||
|
const o = originOf(v)
|
||||||
|
if (o) explicit.add(o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const u of [config.issuer, oidcIssuerFromConfig(config)]) {
|
||||||
|
const o = originOf(u)
|
||||||
|
if (o) explicit.add(o)
|
||||||
|
}
|
||||||
|
return (origin: string) => {
|
||||||
|
if (allowAll) return true
|
||||||
|
const o = originOf(origin)
|
||||||
|
if (!o) return false
|
||||||
|
if (explicit.has(o)) return true
|
||||||
|
// Тот же allowlist, что у return_to (.suffix, localhost, полные origin, private).
|
||||||
|
if (isReturnToAllowed(o, config.returnToAllowlist)) return true
|
||||||
|
for (const u of switcherUrls()) {
|
||||||
|
if (originOf(u) === o) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { hash, verify } from '@node-rs/argon2'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* argon2 держит ~19 МиБ на вызов и занимает поток libuv; без ограничителя
|
||||||
|
* шквал логинов раздувает RSS. Семафор ограничивает число одновременных хешей.
|
||||||
|
*/
|
||||||
|
let limit = 2
|
||||||
|
let active = 0
|
||||||
|
const waiters: Array<() => void> = []
|
||||||
|
|
||||||
|
export function setPasswordConcurrency(n: number): void {
|
||||||
|
limit = Math.max(1, Math.floor(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function passwordHashStats(): { active: number; waiting: number } {
|
||||||
|
return { active, waiting: waiters.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withSlot<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
if (active >= limit) {
|
||||||
|
await new Promise<void>((resolve) => waiters.push(resolve))
|
||||||
|
} else {
|
||||||
|
active += 1
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await fn()
|
||||||
|
} finally {
|
||||||
|
const next = waiters.shift()
|
||||||
|
if (next) next() // слот передаётся ожидающему без изменения active
|
||||||
|
else active -= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashPassword(password: string): Promise<string> {
|
||||||
|
return withSlot(() => hash(password))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyPassword(
|
||||||
|
passwordHash: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return withSlot(() => verify(passwordHash, password))
|
||||||
|
}
|
||||||
|
|
||||||
|
let dummyHash: Promise<string> | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Для неизвестного/отключённого пользователя выполняем настоящую проверку
|
||||||
|
* по фиктивному хешу: время ответа не выдаёт существование учётки.
|
||||||
|
*/
|
||||||
|
export async function verifyDummyPassword(password: string): Promise<void> {
|
||||||
|
dummyHash ??= hash('dummy-password-for-timing')
|
||||||
|
try {
|
||||||
|
await withSlot(async () => verify(await dummyHash!, password))
|
||||||
|
} catch {
|
||||||
|
/* результат не важен */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
|
||||||
|
export interface ShutdownOptions {
|
||||||
|
timeoutMs: number
|
||||||
|
exit?: (code: number) => void
|
||||||
|
signals?: NodeJS.Signals[]
|
||||||
|
proc?: Pick<NodeJS.Process, 'on' | 'off'>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SIGTERM/SIGINT → app.close() (дозавершает запросы, onClose закрывает БД),
|
||||||
|
* принудительный выход по таймауту, повторный сигнал — немедленный выход.
|
||||||
|
* Возвращает функцию отписки и ручной триггер (для тестов).
|
||||||
|
*/
|
||||||
|
export function registerShutdown(app: FastifyInstance, opts: ShutdownOptions) {
|
||||||
|
const exit = opts.exit ?? ((code: number) => process.exit(code))
|
||||||
|
const proc = opts.proc ?? process
|
||||||
|
const signals = opts.signals ?? ['SIGTERM', 'SIGINT']
|
||||||
|
let closing: Promise<void> | null = null
|
||||||
|
|
||||||
|
const shutdown = (reason: string, code = 0): Promise<void> => {
|
||||||
|
if (closing) {
|
||||||
|
app.log.warn(`shutdown: repeated ${reason}, forcing exit`)
|
||||||
|
exit(1)
|
||||||
|
return closing
|
||||||
|
}
|
||||||
|
app.log.info(`shutdown: ${reason}`)
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
app.log.error('shutdown: timeout, forcing exit')
|
||||||
|
exit(1)
|
||||||
|
}, opts.timeoutMs)
|
||||||
|
timer.unref()
|
||||||
|
closing = app
|
||||||
|
.close()
|
||||||
|
.then(() => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
exit(code)
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
app.log.error(err, 'shutdown: close failed')
|
||||||
|
exit(1)
|
||||||
|
})
|
||||||
|
return closing
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlers = signals.map((sig) => {
|
||||||
|
const h = () => void shutdown(sig)
|
||||||
|
proc.on(sig, h)
|
||||||
|
return [sig, h] as const
|
||||||
|
})
|
||||||
|
const onFatal = (err: unknown) => {
|
||||||
|
app.log.error(err, 'fatal error')
|
||||||
|
void shutdown('fatal', 1)
|
||||||
|
}
|
||||||
|
proc.on('unhandledRejection', onFatal)
|
||||||
|
proc.on('uncaughtException', onFatal)
|
||||||
|
|
||||||
|
return {
|
||||||
|
shutdown,
|
||||||
|
dispose() {
|
||||||
|
for (const [sig, h] of handlers) proc.off(sig, h)
|
||||||
|
proc.off('unhandledRejection', onFatal)
|
||||||
|
proc.off('uncaughtException', onFatal)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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,
|
|
||||||
}))
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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({
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { dirname, join, resolve } from 'node:path'
|
||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
import { backupDatabase, resolveDatabasePath, type Sqlite } from '@authportal/db'
|
||||||
|
import type { AppConfig } from '../config.js'
|
||||||
|
|
||||||
|
export function backupDirFor(config: AppConfig, databaseUrl = config.databaseUrl) {
|
||||||
|
if (config.backupDir) return resolve(config.backupDir)
|
||||||
|
const path = resolveDatabasePath(databaseUrl)
|
||||||
|
return join(dirname(resolve(path)), 'backups')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runBackup(
|
||||||
|
sqlite: Sqlite,
|
||||||
|
config: AppConfig,
|
||||||
|
databaseUrl = config.databaseUrl,
|
||||||
|
) {
|
||||||
|
return backupDatabase(sqlite, {
|
||||||
|
dir: backupDirFor(config, databaseUrl),
|
||||||
|
keep: config.backupKeep,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Периодический бэкап; выключен при BACKUP_INTERVAL_HOURS=0. */
|
||||||
|
export function startBackupJob(app: FastifyInstance): () => void {
|
||||||
|
const hours = app.config.backupIntervalHours
|
||||||
|
if (hours <= 0 || app.sqlite.memory) return () => {}
|
||||||
|
let running = false
|
||||||
|
const run = async () => {
|
||||||
|
if (running) return
|
||||||
|
running = true
|
||||||
|
try {
|
||||||
|
const res = await runBackup(app.sqlite, app.config)
|
||||||
|
app.log.info({ file: res.file, removed: res.removed.length }, 'db backup')
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error({ err }, 'db backup failed')
|
||||||
|
} finally {
|
||||||
|
running = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const interval = setInterval(() => void run(), hours * 60 * 60 * 1000)
|
||||||
|
interval.unref()
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
import { runHousekeeping } from '@authportal/db'
|
||||||
|
|
||||||
|
const HOUR_MS = 60 * 60 * 1000
|
||||||
|
|
||||||
|
/** Часовая очистка сессий, кодов авторизации и WebAuthn-челленджей. */
|
||||||
|
export function startHousekeepingJob(app: FastifyInstance): () => void {
|
||||||
|
const run = () => {
|
||||||
|
try {
|
||||||
|
const res = runHousekeeping(app.db, {
|
||||||
|
retentionDays: app.config.sessionRetentionDays,
|
||||||
|
})
|
||||||
|
if (res.sessions + res.authCodes + res.challenges > 0) {
|
||||||
|
app.log.info(res, 'housekeeping purge')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
app.log.warn({ err }, 'housekeeping job failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const initial = setTimeout(run, 30_000)
|
||||||
|
const interval = setInterval(run, HOUR_MS)
|
||||||
|
initial.unref()
|
||||||
|
interval.unref()
|
||||||
|
return () => {
|
||||||
|
clearTimeout(initial)
|
||||||
|
clearInterval(interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { hash } from '@node-rs/argon2'
|
||||||
|
import { createOidcClient, generateOidcClientSecret } from '@authportal/db'
|
||||||
|
import { adminToken, buildTestApp } from './helpers.js'
|
||||||
|
|
||||||
|
type App = Awaited<ReturnType<typeof buildTestApp>>
|
||||||
|
let app: App | undefined
|
||||||
|
afterEach(async () => {
|
||||||
|
await app?.close()
|
||||||
|
app = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const auth = (token: string) => ({ authorization: `Bearer ${token}` })
|
||||||
|
|
||||||
|
async function login(a: App, email = '[email protected]', password = 'adminpass') {
|
||||||
|
const res = await a.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/auth/login',
|
||||||
|
payload: { email, password },
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('auth', () => {
|
||||||
|
it('login: неверный пароль 401, отключённый пользователь 401', async () => {
|
||||||
|
app = await buildTestApp()
|
||||||
|
const token = await adminToken(app)
|
||||||
|
expect((await login(app, '[email protected]', 'wrong')).statusCode).toBe(401)
|
||||||
|
expect((await login(app, '[email protected]', 'whatever')).statusCode).toBe(401)
|
||||||
|
|
||||||
|
const created = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/admin/users',
|
||||||
|
headers: auth(token),
|
||||||
|
payload: {
|
||||||
|
email: '[email protected]',
|
||||||
|
name: 'Off',
|
||||||
|
password: 'secret12',
|
||||||
|
is_admin: false,
|
||||||
|
apps: ['cfdm'],
|
||||||
|
permissions: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const id = (created.json() as { id: string }).id
|
||||||
|
await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/v1/admin/users/${id}`,
|
||||||
|
headers: auth(token),
|
||||||
|
payload: { disabled: true },
|
||||||
|
})
|
||||||
|
expect((await login(app, '[email protected]', 'secret12')).statusCode).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('me / reissue / catalog / logout по cookie', async () => {
|
||||||
|
app = await buildTestApp()
|
||||||
|
const res = await login(app)
|
||||||
|
const token = (res.json() as { access_token: string }).access_token
|
||||||
|
const refresh = res.cookies.find((c) => c.name === 'refresh_token')!.value
|
||||||
|
|
||||||
|
const me = await app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth(token) })
|
||||||
|
expect(me.statusCode).toBe(200)
|
||||||
|
expect((me.json() as { email: string }).email).toBe('[email protected]')
|
||||||
|
|
||||||
|
const reissue = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/auth/reissue',
|
||||||
|
headers: auth(token),
|
||||||
|
})
|
||||||
|
expect(reissue.statusCode).toBe(200)
|
||||||
|
expect((reissue.json() as { access_token: string }).access_token).toBeTruthy()
|
||||||
|
|
||||||
|
const catalog = await app.inject({ method: 'GET', url: '/api/v1/catalog', headers: auth(token) })
|
||||||
|
expect(catalog.statusCode).toBe(200)
|
||||||
|
expect((catalog.json() as { apps: unknown[] }).apps.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
const noAuth = await app.inject({ method: 'GET', url: '/api/v1/auth/me' })
|
||||||
|
expect(noAuth.statusCode).toBe(401)
|
||||||
|
|
||||||
|
const logout = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/auth/logout',
|
||||||
|
headers: { ...auth(token), cookie: `refresh_token=${refresh}` },
|
||||||
|
})
|
||||||
|
expect(logout.statusCode).toBe(200)
|
||||||
|
const revoked = app.sqlite
|
||||||
|
.prepare('SELECT revoked_at FROM refresh_sessions')
|
||||||
|
.get() as { revoked_at: string | null }
|
||||||
|
expect(revoked.revoked_at).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('admin users', () => {
|
||||||
|
it('создание: конфликт email 409, валидация 400; patch и удаление', async () => {
|
||||||
|
app = await buildTestApp()
|
||||||
|
const token = await adminToken(app)
|
||||||
|
const payload = {
|
||||||
|
email: '[email protected]',
|
||||||
|
name: 'Dup',
|
||||||
|
password: 'secret12',
|
||||||
|
is_admin: false,
|
||||||
|
apps: ['cfdm'],
|
||||||
|
permissions: [],
|
||||||
|
}
|
||||||
|
const first = await app.inject({ method: 'POST', url: '/api/v1/admin/users', headers: auth(token), payload })
|
||||||
|
expect(first.statusCode).toBe(201)
|
||||||
|
const id = (first.json() as { id: string }).id
|
||||||
|
const dup = await app.inject({ method: 'POST', url: '/api/v1/admin/users', headers: auth(token), payload })
|
||||||
|
expect(dup.statusCode).toBe(409)
|
||||||
|
const bad = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/admin/users',
|
||||||
|
headers: auth(token),
|
||||||
|
payload: { email: 'nope' },
|
||||||
|
})
|
||||||
|
expect(bad.statusCode).toBe(400)
|
||||||
|
|
||||||
|
const patched = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/v1/admin/users/${id}`,
|
||||||
|
headers: auth(token),
|
||||||
|
payload: { name: 'Renamed', password: 'newsecret12' },
|
||||||
|
})
|
||||||
|
expect(patched.statusCode).toBe(200)
|
||||||
|
expect((patched.json() as { name: string }).name).toBe('Renamed')
|
||||||
|
expect((await login(app, '[email protected]', 'newsecret12')).statusCode).toBe(200)
|
||||||
|
expect((await login(app, '[email protected]', 'secret12')).statusCode).toBe(401)
|
||||||
|
|
||||||
|
const del = await app.inject({ method: 'DELETE', url: `/api/v1/admin/users/${id}`, headers: auth(token) })
|
||||||
|
expect(del.statusCode).toBe(204)
|
||||||
|
const gone = await app.inject({ method: 'GET', url: `/api/v1/admin/users/${id}`, headers: auth(token) })
|
||||||
|
expect(gone.statusCode).toBe(404)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('нельзя удалить себя; обычный пользователь не админ (403)', async () => {
|
||||||
|
app = await buildTestApp()
|
||||||
|
const token = await adminToken(app)
|
||||||
|
const me = await app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth(token) })
|
||||||
|
const myId = (me.json() as { id: string }).id
|
||||||
|
const self = await app.inject({ method: 'DELETE', url: `/api/v1/admin/users/${myId}`, headers: auth(token) })
|
||||||
|
expect(self.statusCode).toBe(400)
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/admin/users',
|
||||||
|
headers: auth(token),
|
||||||
|
payload: {
|
||||||
|
email: '[email protected]',
|
||||||
|
name: 'Plain',
|
||||||
|
password: 'secret12',
|
||||||
|
is_admin: false,
|
||||||
|
apps: ['cfdm'],
|
||||||
|
permissions: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const userTok = ((await login(app, '[email protected]', 'secret12')).json() as { access_token: string }).access_token
|
||||||
|
const forbidden = await app.inject({ method: 'GET', url: '/api/v1/admin/users', headers: auth(userTok) })
|
||||||
|
expect(forbidden.statusCode).toBe(403)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('OIDC клиенты и обмен кода', () => {
|
||||||
|
async function setup(a: App) {
|
||||||
|
const token = await adminToken(a)
|
||||||
|
const created = await a.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/admin/oidc/clients',
|
||||||
|
headers: auth(token),
|
||||||
|
payload: {
|
||||||
|
name: 'RP',
|
||||||
|
redirect_uris: ['https://rp.test.local/cb'],
|
||||||
|
scopes: ['openid', 'profile', 'email', 'groups'],
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(created.statusCode).toBe(200)
|
||||||
|
const client = created.json() as { id: string; client_id: string; client_secret: string }
|
||||||
|
const loginRes = await login(a)
|
||||||
|
const refresh = loginRes.cookies.find((c) => c.name === 'refresh_token')!.value
|
||||||
|
return { token, client, refresh }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authorizeCode(
|
||||||
|
a: App,
|
||||||
|
refresh: string,
|
||||||
|
clientId: string,
|
||||||
|
extra: Record<string, string> = {},
|
||||||
|
): Promise<string> {
|
||||||
|
const res = await a.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/oauth/authorize',
|
||||||
|
cookies: { refresh_token: refresh },
|
||||||
|
query: {
|
||||||
|
client_id: clientId,
|
||||||
|
redirect_uri: 'https://rp.test.local/cb',
|
||||||
|
response_type: 'code',
|
||||||
|
scope: 'openid profile',
|
||||||
|
...extra,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(302)
|
||||||
|
return new URL(res.headers.location as string).searchParams.get('code')!
|
||||||
|
}
|
||||||
|
|
||||||
|
const exchange = (a: App, body: Record<string, string>) =>
|
||||||
|
a.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/oauth/token',
|
||||||
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||||
|
payload: new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
redirect_uri: 'https://rp.test.local/cb',
|
||||||
|
...body,
|
||||||
|
}).toString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
it('секрет нового клиента хранится как sha256, обмен работает, replay кода отклоняется', async () => {
|
||||||
|
app = await buildTestApp()
|
||||||
|
const { client, refresh } = await setup(app)
|
||||||
|
const stored = app.sqlite
|
||||||
|
.prepare('SELECT client_secret_hash h FROM oidc_clients')
|
||||||
|
.get() as { h: string }
|
||||||
|
expect(stored.h.startsWith('sha256:')).toBe(true)
|
||||||
|
|
||||||
|
const code = await authorizeCode(app, refresh, client.client_id)
|
||||||
|
const ok = await exchange(app, {
|
||||||
|
client_id: client.client_id,
|
||||||
|
client_secret: client.client_secret,
|
||||||
|
code,
|
||||||
|
})
|
||||||
|
expect(ok.statusCode).toBe(200)
|
||||||
|
expect((ok.json() as { id_token: string }).id_token).toBeTruthy()
|
||||||
|
|
||||||
|
const replay = await exchange(app, {
|
||||||
|
client_id: client.client_id,
|
||||||
|
client_secret: client.client_secret,
|
||||||
|
code,
|
||||||
|
})
|
||||||
|
expect(replay.statusCode).toBe(400)
|
||||||
|
expect((replay.json() as { error: string }).error).toBe('invalid_grant')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('PKCE: S256 принимается, неверный verifier и отсутствие verifier отклоняются', async () => {
|
||||||
|
app = await buildTestApp()
|
||||||
|
const { client, refresh } = await setup(app)
|
||||||
|
const verifier = 'v'.repeat(50)
|
||||||
|
const challenge = createHash('sha256').update(verifier).digest('base64url')
|
||||||
|
const pkce = { code_challenge: challenge, code_challenge_method: 'S256' }
|
||||||
|
const creds = { client_id: client.client_id, client_secret: client.client_secret }
|
||||||
|
|
||||||
|
const good = await exchange(app, {
|
||||||
|
...creds,
|
||||||
|
code: await authorizeCode(app, refresh, client.client_id, pkce),
|
||||||
|
code_verifier: verifier,
|
||||||
|
})
|
||||||
|
expect(good.statusCode).toBe(200)
|
||||||
|
|
||||||
|
const wrong = await exchange(app, {
|
||||||
|
...creds,
|
||||||
|
code: await authorizeCode(app, refresh, client.client_id, pkce),
|
||||||
|
code_verifier: 'x'.repeat(50),
|
||||||
|
})
|
||||||
|
expect(wrong.statusCode).toBe(400)
|
||||||
|
|
||||||
|
const missing = await exchange(app, {
|
||||||
|
...creds,
|
||||||
|
code: await authorizeCode(app, refresh, client.client_id, pkce),
|
||||||
|
})
|
||||||
|
expect(missing.statusCode).toBe(400)
|
||||||
|
|
||||||
|
const plain = await exchange(app, {
|
||||||
|
...creds,
|
||||||
|
code: await authorizeCode(app, refresh, client.client_id, {
|
||||||
|
code_challenge: 'plain-challenge',
|
||||||
|
code_challenge_method: 'plain',
|
||||||
|
}),
|
||||||
|
code_verifier: 'plain-challenge',
|
||||||
|
})
|
||||||
|
expect(plain.statusCode).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('неверный секрет 401; чужой redirect_uri и scope отклоняются; userinfo по access_token', async () => {
|
||||||
|
app = await buildTestApp()
|
||||||
|
const { client, refresh } = await setup(app)
|
||||||
|
const code = await authorizeCode(app, refresh, client.client_id)
|
||||||
|
|
||||||
|
const badSecret = await exchange(app, {
|
||||||
|
client_id: client.client_id,
|
||||||
|
client_secret: 'nope',
|
||||||
|
code,
|
||||||
|
})
|
||||||
|
expect(badSecret.statusCode).toBe(401)
|
||||||
|
|
||||||
|
const badRedirect = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/oauth/authorize',
|
||||||
|
cookies: { refresh_token: refresh },
|
||||||
|
query: {
|
||||||
|
client_id: client.client_id,
|
||||||
|
redirect_uri: 'https://evil.test/cb',
|
||||||
|
response_type: 'code',
|
||||||
|
scope: 'openid',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(badRedirect.statusCode).toBe(400)
|
||||||
|
|
||||||
|
const badScope = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/oauth/authorize',
|
||||||
|
cookies: { refresh_token: refresh },
|
||||||
|
query: {
|
||||||
|
client_id: client.client_id,
|
||||||
|
redirect_uri: 'https://rp.test.local/cb',
|
||||||
|
response_type: 'code',
|
||||||
|
scope: 'openid admin',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(badScope.statusCode).toBe(302)
|
||||||
|
expect(badScope.headers.location).toContain('error=invalid_scope')
|
||||||
|
|
||||||
|
const ok = await exchange(app, {
|
||||||
|
client_id: client.client_id,
|
||||||
|
client_secret: client.client_secret,
|
||||||
|
code,
|
||||||
|
})
|
||||||
|
const accessToken = (ok.json() as { access_token: string }).access_token
|
||||||
|
const info = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/oauth/userinfo',
|
||||||
|
headers: auth(accessToken),
|
||||||
|
})
|
||||||
|
expect(info.statusCode).toBe(200)
|
||||||
|
expect((info.json() as { email: string }).email).toBe('[email protected]')
|
||||||
|
const noToken = await app.inject({ method: 'GET', url: '/oauth/userinfo' })
|
||||||
|
expect(noToken.statusCode).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('без сессии authorize редиректит на логин', async () => {
|
||||||
|
app = await buildTestApp()
|
||||||
|
const { client } = await setup(app)
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/oauth/authorize',
|
||||||
|
query: {
|
||||||
|
client_id: client.client_id,
|
||||||
|
redirect_uri: 'https://rp.test.local/cb',
|
||||||
|
response_type: 'code',
|
||||||
|
scope: 'openid',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(302)
|
||||||
|
expect(res.headers.location).toContain('return_to=')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('старый argon2-секрет работает и мигрирует в sha256', async () => {
|
||||||
|
app = await buildTestApp()
|
||||||
|
const { refresh } = await setup(app)
|
||||||
|
const secret = generateOidcClientSecret()
|
||||||
|
const legacy = createOidcClient(app.db, {
|
||||||
|
name: 'Legacy',
|
||||||
|
redirectUris: ['https://rp.test.local/cb'],
|
||||||
|
scopes: ['openid', 'profile'],
|
||||||
|
enabled: true,
|
||||||
|
clientSecretHash: await hash(secret),
|
||||||
|
})
|
||||||
|
const code = await authorizeCode(app, refresh, legacy.clientId)
|
||||||
|
const res = await exchange(app, {
|
||||||
|
client_id: legacy.clientId,
|
||||||
|
client_secret: secret,
|
||||||
|
code,
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
const row = app.sqlite
|
||||||
|
.prepare('SELECT client_secret_hash h FROM oidc_clients WHERE client_id=?')
|
||||||
|
.get(legacy.clientId) as { h: string }
|
||||||
|
expect(row.h.startsWith('sha256:')).toBe(true)
|
||||||
|
|
||||||
|
// после миграции секрет всё ещё работает
|
||||||
|
const code2 = await authorizeCode(app, refresh, legacy.clientId)
|
||||||
|
const again = await exchange(app, {
|
||||||
|
client_id: legacy.clientId,
|
||||||
|
client_secret: secret,
|
||||||
|
code: code2,
|
||||||
|
})
|
||||||
|
expect(again.statusCode).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('CRUD клиента: patch, rotate-secret инвалидирует старый секрет, delete', async () => {
|
||||||
|
app = await buildTestApp()
|
||||||
|
const { token, client, refresh } = await setup(app)
|
||||||
|
|
||||||
|
const patch = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/v1/admin/oidc/clients/${client.id}`,
|
||||||
|
headers: auth(token),
|
||||||
|
payload: { name: 'Renamed RP' },
|
||||||
|
})
|
||||||
|
expect(patch.statusCode).toBe(200)
|
||||||
|
expect((patch.json() as { name: string }).name).toBe('Renamed RP')
|
||||||
|
|
||||||
|
const rotate = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/v1/admin/oidc/clients/${client.id}/rotate-secret`,
|
||||||
|
headers: auth(token),
|
||||||
|
})
|
||||||
|
expect(rotate.statusCode).toBe(200)
|
||||||
|
const fresh = (rotate.json() as { client_secret: string }).client_secret
|
||||||
|
expect(fresh).not.toBe(client.client_secret)
|
||||||
|
|
||||||
|
const code = await authorizeCode(app, refresh, client.client_id)
|
||||||
|
expect(
|
||||||
|
(await exchange(app, { client_id: client.client_id, client_secret: client.client_secret, code })).statusCode,
|
||||||
|
).toBe(401)
|
||||||
|
expect(
|
||||||
|
(await exchange(app, { client_id: client.client_id, client_secret: fresh, code })).statusCode,
|
||||||
|
).toBe(200)
|
||||||
|
|
||||||
|
const del = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `/api/v1/admin/oidc/clients/${client.id}`,
|
||||||
|
headers: auth(token),
|
||||||
|
})
|
||||||
|
expect(del.statusCode).toBe(200)
|
||||||
|
const gone = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/v1/admin/oidc/clients/${client.id}`,
|
||||||
|
headers: auth(token),
|
||||||
|
})
|
||||||
|
expect(gone.statusCode).toBe(404)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { mkdtempSync, rmSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { listBackups } from '@authportal/db'
|
||||||
|
import { loadConfig } from '../src/config.js'
|
||||||
|
import { backupDirFor, runBackup, startBackupJob } from '../src/services/backup.js'
|
||||||
|
import { buildApp } from '../src/app.js'
|
||||||
|
import { adminToken } from './helpers.js'
|
||||||
|
|
||||||
|
const dirs: string[] = []
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
for (const d of dirs.splice(0)) {
|
||||||
|
try {
|
||||||
|
rmSync(d, { recursive: true, force: true })
|
||||||
|
} catch {
|
||||||
|
/* Windows может держать файл БД до GC — временный каталог не критичен */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function env(dir: string, extra: NodeJS.ProcessEnv = {}) {
|
||||||
|
return loadConfig({
|
||||||
|
JWT_SECRET: 'test-secret-at-least-8',
|
||||||
|
ADMIN_EMAIL: '[email protected]',
|
||||||
|
ADMIN_PASSWORD: 'adminpass',
|
||||||
|
DATABASE_URL: `sqlite:${join(dir, 'app.db')}`,
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
...extra,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('backup', () => {
|
||||||
|
it('backupDirFor: по умолчанию рядом с БД, BACKUP_DIR приоритетнее', () => {
|
||||||
|
const cfg = env('/data/x')
|
||||||
|
expect(backupDirFor(cfg).replaceAll(String.fromCharCode(92), '/')).toMatch(/\/data\/x\/backups$/)
|
||||||
|
const custom = env('/data/x', { BACKUP_DIR: '/mnt/bk' })
|
||||||
|
expect(backupDirFor(custom).replaceAll(String.fromCharCode(92), '/')).toMatch(/\/mnt\/bk$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('бэкап живого приложения восстанавливается: вход админа работает', async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'ap-api-bk-'))
|
||||||
|
dirs.push(dir)
|
||||||
|
const config = env(dir, { BACKUP_KEEP: '2' })
|
||||||
|
const app = await buildApp({ config })
|
||||||
|
const res = await runBackup(app.sqlite, config)
|
||||||
|
expect(listBackups(backupDirFor(config))).toHaveLength(1)
|
||||||
|
await app.close()
|
||||||
|
|
||||||
|
const restored = await buildApp({
|
||||||
|
config: env(dir),
|
||||||
|
databaseUrl: `sqlite:${res.file}`,
|
||||||
|
})
|
||||||
|
const token = await adminToken(restored as never)
|
||||||
|
expect(token).toBeTruthy()
|
||||||
|
await restored.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('startBackupJob выключен при 0 часов и работает по таймеру', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'ap-job-bk-'))
|
||||||
|
dirs.push(dir)
|
||||||
|
const config = env(dir, { BACKUP_INTERVAL_HOURS: '1' })
|
||||||
|
const app = await buildApp({ config })
|
||||||
|
|
||||||
|
const base = vi.getTimerCount()
|
||||||
|
const off = startBackupJob({ ...app, config: { ...config, backupIntervalHours: 0 } } as never)
|
||||||
|
expect(vi.getTimerCount()).toBe(base)
|
||||||
|
off()
|
||||||
|
|
||||||
|
const stop = startBackupJob(app)
|
||||||
|
expect(vi.getTimerCount()).toBe(base + 1)
|
||||||
|
await vi.advanceTimersByTimeAsync(60 * 60 * 1000 + 10)
|
||||||
|
vi.useRealTimers()
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(listBackups(backupDirFor(config)).length).toBeGreaterThan(0),
|
||||||
|
)
|
||||||
|
stop()
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { Writable } from 'node:stream'
|
||||||
|
import Fastify from 'fastify'
|
||||||
|
import { assertBootstrapPassword, loadConfig } from '../src/config.js'
|
||||||
|
import { buildApp } from '../src/app.js'
|
||||||
|
|
||||||
|
const base = { JWT_SECRET: 'a-sufficiently-long-secret-123' }
|
||||||
|
|
||||||
|
describe('loadConfig', () => {
|
||||||
|
it('значения по умолчанию для новых параметров', () => {
|
||||||
|
const c = loadConfig({ ...base, NODE_ENV: 'test' })
|
||||||
|
expect(c.shutdownTimeoutMs).toBe(8000)
|
||||||
|
expect(c.sessionRetentionDays).toBe(7)
|
||||||
|
expect(c.argon2Concurrency).toBe(2)
|
||||||
|
expect(c.backupKeep).toBe(7)
|
||||||
|
expect(c.backupIntervalHours).toBe(0)
|
||||||
|
expect(c.corsOrigins).toBe('')
|
||||||
|
expect(c.trustProxy).toContain('10.0.0.0/8')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('читает переопределения из env', () => {
|
||||||
|
const c = loadConfig({
|
||||||
|
...base,
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
SHUTDOWN_TIMEOUT_MS: '1500',
|
||||||
|
SESSION_RETENTION_DAYS: '30',
|
||||||
|
ARGON2_CONCURRENCY: '4',
|
||||||
|
BACKUP_KEEP: '3',
|
||||||
|
BACKUP_INTERVAL_HOURS: '12',
|
||||||
|
CORS_ORIGINS: 'https://a.example',
|
||||||
|
TRUST_PROXY: 'false',
|
||||||
|
})
|
||||||
|
expect(c).toMatchObject({
|
||||||
|
shutdownTimeoutMs: 1500,
|
||||||
|
sessionRetentionDays: 30,
|
||||||
|
argon2Concurrency: 4,
|
||||||
|
backupKeep: 3,
|
||||||
|
backupIntervalHours: 12,
|
||||||
|
corsOrigins: 'https://a.example',
|
||||||
|
trustProxy: 'false',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['SHUTDOWN_TIMEOUT_MS', '0'],
|
||||||
|
['ARGON2_CONCURRENCY', '-1'],
|
||||||
|
['BACKUP_KEEP', 'abc'],
|
||||||
|
['BACKUP_INTERVAL_HOURS', '-5'],
|
||||||
|
])('невалидный %s=%s → ошибка', (key, value) => {
|
||||||
|
expect(() => loadConfig({ ...base, NODE_ENV: 'test', [key]: value })).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('production guard', () => {
|
||||||
|
it.each(['change-me-in-production', 'dev-secret-change-me'])(
|
||||||
|
'известный дефолт JWT_SECRET "%s" отклоняется',
|
||||||
|
(secret) => {
|
||||||
|
expect(() =>
|
||||||
|
loadConfig({ NODE_ENV: 'production', JWT_SECRET: secret }),
|
||||||
|
).toThrow(/JWT_SECRET/)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('короткий, но уникальный секрет в production не роняет старт (только предупреждение)', () => {
|
||||||
|
const warn = vi.spyOn(process, 'emitWarning').mockImplementation(() => {})
|
||||||
|
expect(() =>
|
||||||
|
loadConfig({ NODE_ENV: 'production', JWT_SECRET: 'short-secret' }),
|
||||||
|
).not.toThrow()
|
||||||
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining('JWT_SECRET'))
|
||||||
|
warn.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('нормальный секрет в production принимается без предупреждений; в test слабый разрешён', () => {
|
||||||
|
const warn = vi.spyOn(process, 'emitWarning').mockImplementation(() => {})
|
||||||
|
expect(() =>
|
||||||
|
loadConfig({ NODE_ENV: 'production', JWT_SECRET: base.JWT_SECRET }),
|
||||||
|
).not.toThrow()
|
||||||
|
expect(warn).not.toHaveBeenCalled()
|
||||||
|
expect(() =>
|
||||||
|
loadConfig({ NODE_ENV: 'test', JWT_SECRET: 'short-secret' }),
|
||||||
|
).not.toThrow()
|
||||||
|
warn.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('пароль первого админа: слабый запрещён только в production', () => {
|
||||||
|
const prod = (pw?: string) =>
|
||||||
|
loadConfig({ NODE_ENV: 'production', ...base, ADMIN_PASSWORD: pw })
|
||||||
|
expect(() => assertBootstrapPassword(prod('admin'))).toThrow(/ADMIN_PASSWORD/)
|
||||||
|
expect(() => assertBootstrapPassword(prod(''))).toThrow(/ADMIN_PASSWORD/)
|
||||||
|
expect(() => assertBootstrapPassword(prod('short'))).toThrow(/ADMIN_PASSWORD/)
|
||||||
|
expect(() => assertBootstrapPassword(prod('long-enough-pw'))).not.toThrow()
|
||||||
|
expect(() =>
|
||||||
|
assertBootstrapPassword(loadConfig({ NODE_ENV: 'test', ...base })),
|
||||||
|
).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('buildApp в production без нормального пароля падает при создании админа', async () => {
|
||||||
|
await expect(
|
||||||
|
buildApp({
|
||||||
|
config: loadConfig({
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
...base,
|
||||||
|
ADMIN_PASSWORD: 'admin',
|
||||||
|
DATABASE_URL: 'sqlite::memory:',
|
||||||
|
}),
|
||||||
|
databaseUrl: 'sqlite::memory:',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/ADMIN_PASSWORD/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('redact в логах', () => {
|
||||||
|
let app: ReturnType<typeof Fastify> | undefined
|
||||||
|
afterEach(async () => {
|
||||||
|
await app?.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('authorization и cookie не попадают в лог', async () => {
|
||||||
|
const lines: string[] = []
|
||||||
|
const stream = new Writable({
|
||||||
|
write(chunk, _enc, cb) {
|
||||||
|
lines.push(String(chunk))
|
||||||
|
cb()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// Тот же набор путей, что и в buildApp.
|
||||||
|
app = Fastify({
|
||||||
|
logger: {
|
||||||
|
level: 'info',
|
||||||
|
stream,
|
||||||
|
redact: {
|
||||||
|
paths: ['req.headers.authorization', 'req.headers.cookie'],
|
||||||
|
censor: '[redacted]',
|
||||||
|
},
|
||||||
|
serializers: {
|
||||||
|
req: (req) => ({ method: req.method, headers: req.headers }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
app.get('/x', async () => ({ ok: true }))
|
||||||
|
await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/x',
|
||||||
|
headers: { authorization: 'Bearer SECRET', cookie: 'refresh_token=SECRET' },
|
||||||
|
})
|
||||||
|
const joined = lines.join('')
|
||||||
|
expect(joined).toContain('[redacted]')
|
||||||
|
expect(joined).not.toContain('SECRET')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { adminToken, buildTestApp } from './helpers.js'
|
||||||
|
import { startHousekeepingJob } from '../src/services/housekeeping.js'
|
||||||
|
|
||||||
|
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
await app?.close()
|
||||||
|
app = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('directory/users', () => {
|
||||||
|
it('отдаёт только id/email/name и ограничивает 50', async () => {
|
||||||
|
app = await buildTestApp()
|
||||||
|
const token = await adminToken(app)
|
||||||
|
for (let i = 0; i < 55; i++) {
|
||||||
|
app.sqlite
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO users (id,email,name,password_hash,is_admin,disabled,created_at,updated_at)
|
||||||
|
VALUES (?,?,?,?,0,0,'t','t')`,
|
||||||
|
)
|
||||||
|
.run(`id${i}`, `u${i}@t.io`, `U${i}`, 'hash')
|
||||||
|
}
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/directory/users',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
const body = res.json() as Array<Record<string, unknown>>
|
||||||
|
expect(body).toHaveLength(50)
|
||||||
|
expect(Object.keys(body[0]!).sort()).toEqual(['email', 'id', 'name'])
|
||||||
|
expect(res.body).not.toContain('hash')
|
||||||
|
|
||||||
|
const q = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/directory/users?q=U54',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect((q.json() as unknown[]).length).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ingest audit', () => {
|
||||||
|
const ev = (i: number) => ({
|
||||||
|
event_id: `ev-${i}`,
|
||||||
|
source_app: 'dns',
|
||||||
|
action: 'dns.test',
|
||||||
|
severity: 'info',
|
||||||
|
summary: 'x',
|
||||||
|
})
|
||||||
|
|
||||||
|
it('принимает пачку одной транзакцией и считает дубликаты', async () => {
|
||||||
|
app = await buildTestApp()
|
||||||
|
const post = (events: unknown[]) =>
|
||||||
|
app!.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/ingest/audit',
|
||||||
|
headers: { authorization: 'Bearer dev-audit-ingest-secret' },
|
||||||
|
payload: { events },
|
||||||
|
})
|
||||||
|
const first = await post([ev(1), ev(2), ev(3)])
|
||||||
|
expect(first.json()).toEqual({ accepted: 3, duplicates: 0 })
|
||||||
|
const second = await post([ev(3), ev(4)])
|
||||||
|
expect(second.json()).toEqual({ accepted: 1, duplicates: 1 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('housekeeping job', () => {
|
||||||
|
it('удаляет устаревшие сессии по таймеру и останавливается', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
app = await buildTestApp()
|
||||||
|
app.sqlite
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO users (id,email,name,password_hash,is_admin,disabled,created_at,updated_at)
|
||||||
|
VALUES ('uh','[email protected]','H','x',0,0,'t','t')`,
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
app.sqlite
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO refresh_sessions (id,user_id,token_hash,expires_at,created_at)
|
||||||
|
VALUES ('old','uh','h','2000-01-01T00:00:00.000Z','t')`,
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
const stop = startHousekeepingJob(app)
|
||||||
|
await vi.advanceTimersByTimeAsync(31_000)
|
||||||
|
const left = app.sqlite
|
||||||
|
.prepare(`SELECT COUNT(*) c FROM refresh_sessions WHERE id='old'`)
|
||||||
|
.get()
|
||||||
|
expect(left).toEqual({ c: 0 })
|
||||||
|
stop()
|
||||||
|
expect(vi.getTimerCount()).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const root = resolve(import.meta.dirname, '../../..')
|
||||||
|
const read = (p: string) => readFileSync(resolve(root, p), 'utf-8')
|
||||||
|
|
||||||
|
const dockerfile = read('deploy/docker/authportal/Dockerfile')
|
||||||
|
const composes = {
|
||||||
|
'docker-compose.yml': read('docker-compose.yml'),
|
||||||
|
'deploy/docker-compose.traefik.yml': read('deploy/docker-compose.traefik.yml'),
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Dockerfile', () => {
|
||||||
|
it('graceful stop и ограничение ресурсов Node', () => {
|
||||||
|
expect(dockerfile).toMatch(/^STOPSIGNAL SIGTERM$/m)
|
||||||
|
expect(dockerfile).toMatch(/NODE_OPTIONS=--max-old-space-size=\d+/)
|
||||||
|
expect(dockerfile).toMatch(/UV_THREADPOOL_SIZE=4/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('healthcheck без запуска node', () => {
|
||||||
|
const hc = dockerfile.split('HEALTHCHECK')[1]!.split('\n').slice(0, 2).join(' ')
|
||||||
|
expect(hc).toContain('wget')
|
||||||
|
expect(hc).not.toContain('"node"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('запуск не от root, рантайм без тулчейна и pnpm deploy', () => {
|
||||||
|
expect(dockerfile).toMatch(/^USER node$/m)
|
||||||
|
const runtime = dockerfile.split('AS runtime')[1]!
|
||||||
|
expect(runtime).not.toMatch(/apk add/)
|
||||||
|
expect(dockerfile).not.toMatch(/pnpm .*deploy/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('статика сжимается только brotli', () => {
|
||||||
|
expect(dockerfile).toContain('brotli')
|
||||||
|
expect(dockerfile).not.toMatch(/gzip -k/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preview-маршруты дизайна вырезаются до сборки web', () => {
|
||||||
|
const rm = dockerfile.indexOf('rm -f apps/web/src/routes/design-preview*.tsx')
|
||||||
|
const build = dockerfile.indexOf('pnpm turbo build')
|
||||||
|
expect(rm).toBeGreaterThan(0)
|
||||||
|
expect(rm).toBeLessThan(build)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('базовый образ закреплён по digest', () => {
|
||||||
|
expect(dockerfile).toMatch(/ARG BASE_NODE=\S+@sha256:[0-9a-f]{64}/)
|
||||||
|
expect(read('deploy/docker/docker-bake.hcl')).toMatch(/node:22-alpine@sha256:[0-9a-f]{64}/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('в образ копируется весь dist API, а tsup не дробит бандл на чанки', () => {
|
||||||
|
expect(dockerfile).toContain('cp -r apps/api/dist /out/dist')
|
||||||
|
expect(read('apps/api/tsup.config.ts')).toMatch(/splitting:\s*false/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('нативные модули проверяются при сборке', () => {
|
||||||
|
expect(dockerfile).toContain('native modules ok')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe.each(Object.entries(composes))('%s', (_name, text) => {
|
||||||
|
it('init, grace period и лимиты ресурсов', () => {
|
||||||
|
expect(text).toMatch(/init: true/)
|
||||||
|
expect(text).toMatch(/stop_grace_period: 15s/)
|
||||||
|
expect(text).toMatch(/mem_limit: 256m/)
|
||||||
|
expect(text).toMatch(/pids_limit: \d+/)
|
||||||
|
expect(text).toMatch(/read_only: true/)
|
||||||
|
expect(text).toMatch(/cap_drop:\s*\n\s*- ALL/)
|
||||||
|
expect(text).toMatch(/no-new-privileges:true/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('healthcheck через wget', () => {
|
||||||
|
expect(text).toMatch(/"wget", "-q"/)
|
||||||
|
expect(text).not.toMatch(/fetch\('http:\/\/127\.0\.0\.1:8080\/health'\)/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('traefik', () => {
|
||||||
|
it('app ждёт healthy traefik, у traefik есть healthcheck', () => {
|
||||||
|
const t = composes['deploy/docker-compose.traefik.yml']
|
||||||
|
expect(t).toMatch(/condition: service_healthy/)
|
||||||
|
expect(t).toContain('traefik", "healthcheck", "--ping')
|
||||||
|
expect(t).toContain('--ping=true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('включено сжатие ответов', () => {
|
||||||
|
expect(composes['deploy/docker-compose.traefik.yml']).toContain(
|
||||||
|
'middlewares.auth-portal-compress.compress=true',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { buildTestApp } from './helpers.js'
|
||||||
|
|
||||||
|
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app?.close()
|
||||||
|
app = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('error handler', () => {
|
||||||
|
it('необработанная ошибка → 500 без деталей', async () => {
|
||||||
|
app = await buildTestApp()
|
||||||
|
app.get('/__boom', async () => {
|
||||||
|
throw new Error('secret internals')
|
||||||
|
})
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/__boom' })
|
||||||
|
expect(res.statusCode).toBe(500)
|
||||||
|
expect(res.json()).toEqual({
|
||||||
|
error: { code: 'INTERNAL', message: 'Internal server error' },
|
||||||
|
})
|
||||||
|
expect(res.body).not.toContain('secret internals')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ZodError → 400 BAD_REQUEST', async () => {
|
||||||
|
app = await buildTestApp()
|
||||||
|
app.get('/__zod', async () => z.object({ a: z.string() }).parse({}))
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/__zod' })
|
||||||
|
expect(res.statusCode).toBe(400)
|
||||||
|
expect(res.json().error.code).toBe('BAD_REQUEST')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ошибка с statusCode 4xx сохраняет статус', async () => {
|
||||||
|
app = await buildTestApp()
|
||||||
|
app.get('/__teapot', async () => {
|
||||||
|
throw Object.assign(new Error('nope'), { statusCode: 418 })
|
||||||
|
})
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/__teapot' })
|
||||||
|
expect(res.statusCode).toBe(418)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('not found', () => {
|
||||||
|
it.each(['/oauth/nope', '/.well-known/nope', '/api/v1/nope'])(
|
||||||
|
'%s → 404 JSON',
|
||||||
|
async (url) => {
|
||||||
|
app = await buildTestApp()
|
||||||
|
const res = await app.inject({ method: 'GET', url })
|
||||||
|
expect(res.statusCode).toBe(404)
|
||||||
|
expect(res.json().error.code).toBe('NOT_FOUND')
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('без STATIC_DIR неизвестный путь → 404 JSON', async () => {
|
||||||
|
app = await buildTestApp()
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/some/spa/route' })
|
||||||
|
expect(res.statusCode).toBe(404)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('со STATIC_DIR: SPA-путь → index.html, /oauth и /.well-known → 404', async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'ap-static-'))
|
||||||
|
writeFileSync(join(dir, 'index.html'), '<html>spa</html>')
|
||||||
|
app = await buildTestApp({ STATIC_DIR: dir })
|
||||||
|
const spa = await app.inject({ method: 'GET', url: '/some/spa/route' })
|
||||||
|
expect(spa.statusCode).toBe(200)
|
||||||
|
expect(spa.body).toContain('spa')
|
||||||
|
for (const url of ['/oauth/nope', '/.well-known/nope']) {
|
||||||
|
const res = await app.inject({ method: 'GET', url })
|
||||||
|
expect(res.statusCode).toBe(404)
|
||||||
|
expect(res.json().error.code).toBe('NOT_FOUND')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { hash } from '@node-rs/argon2'
|
||||||
|
import { TtlCache } from '../src/lib/cache.js'
|
||||||
|
import { parseCookie } from '../src/lib/cookies.js'
|
||||||
|
import { checkClientSecret, hashClientSecret } from '../src/lib/client-secret.js'
|
||||||
|
import {
|
||||||
|
hashPassword,
|
||||||
|
passwordHashStats,
|
||||||
|
setPasswordConcurrency,
|
||||||
|
verifyPassword,
|
||||||
|
} from '../src/lib/password.js'
|
||||||
|
import { parseTrustProxy } from '../src/config.js'
|
||||||
|
import { adminToken, buildTestApp } from './helpers.js'
|
||||||
|
|
||||||
|
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
await app?.close()
|
||||||
|
app = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TtlCache', () => {
|
||||||
|
it('истекает по TTL', () => {
|
||||||
|
let t = 0
|
||||||
|
const c = new TtlCache<string, number>({ max: 10, ttlMs: 100, now: () => t })
|
||||||
|
c.set('a', 1)
|
||||||
|
t = 99
|
||||||
|
expect(c.get('a')).toBe(1)
|
||||||
|
t = 100
|
||||||
|
expect(c.get('a')).toBeUndefined()
|
||||||
|
expect(c.size).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('вытесняет давно не использованное (LRU)', () => {
|
||||||
|
const c = new TtlCache<string, number>({ max: 2, ttlMs: 1000 })
|
||||||
|
c.set('a', 1)
|
||||||
|
c.set('b', 2)
|
||||||
|
c.get('a') // освежили a
|
||||||
|
c.set('c', 3)
|
||||||
|
expect(c.get('b')).toBeUndefined()
|
||||||
|
expect(c.get('a')).toBe(1)
|
||||||
|
expect(c.get('c')).toBe(3)
|
||||||
|
c.delete('a')
|
||||||
|
expect(c.get('a')).toBeUndefined()
|
||||||
|
c.clear()
|
||||||
|
expect(c.size).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseCookie', () => {
|
||||||
|
it('находит значение и декодирует', () => {
|
||||||
|
expect(parseCookie('a=1; refresh_token=abc%20d; b=2', 'refresh_token')).toBe(
|
||||||
|
'abc d',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
it('не путает суффикс имени', () => {
|
||||||
|
expect(parseCookie('x_refresh_token=bad', 'refresh_token')).toBeNull()
|
||||||
|
})
|
||||||
|
it('пусто/нет → null', () => {
|
||||||
|
expect(parseCookie(undefined, 'a')).toBeNull()
|
||||||
|
expect(parseCookie('a=; b=1', 'a')).toBeNull()
|
||||||
|
expect(parseCookie('b=1', 'a')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('client secret', () => {
|
||||||
|
it('sha256-формат: верный/неверный, без rehash', async () => {
|
||||||
|
const stored = hashClientSecret('s3cret')
|
||||||
|
expect(stored.startsWith('sha256:')).toBe(true)
|
||||||
|
expect(await checkClientSecret(stored, 's3cret')).toEqual({
|
||||||
|
ok: true,
|
||||||
|
needsRehash: false,
|
||||||
|
})
|
||||||
|
expect((await checkClientSecret(stored, 'nope')).ok).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('argon2-формат принимается и помечается на rehash', async () => {
|
||||||
|
const legacy = await hash('old-secret')
|
||||||
|
expect(await checkClientSecret(legacy, 'old-secret')).toEqual({
|
||||||
|
ok: true,
|
||||||
|
needsRehash: true,
|
||||||
|
})
|
||||||
|
expect(await checkClientSecret(legacy, 'bad')).toEqual({
|
||||||
|
ok: false,
|
||||||
|
needsRehash: false,
|
||||||
|
})
|
||||||
|
expect((await checkClientSecret('garbage', 'x')).ok).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('password limiter', () => {
|
||||||
|
it('не допускает больше N одновременных хешей', async () => {
|
||||||
|
setPasswordConcurrency(2)
|
||||||
|
let maxActive = 0
|
||||||
|
const poll = setInterval(() => {
|
||||||
|
maxActive = Math.max(maxActive, passwordHashStats().active)
|
||||||
|
}, 1)
|
||||||
|
const h = await hashPassword('pw')
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: 6 }, () => verifyPassword(h, 'pw')),
|
||||||
|
)
|
||||||
|
clearInterval(poll)
|
||||||
|
expect(maxActive).toBeLessThanOrEqual(2)
|
||||||
|
expect(passwordHashStats()).toEqual({ active: 0, waiting: 0 })
|
||||||
|
setPasswordConcurrency(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseTrustProxy', () => {
|
||||||
|
it('true/false/список', () => {
|
||||||
|
expect(parseTrustProxy('true')).toBe(true)
|
||||||
|
expect(parseTrustProxy('false')).toBe(false)
|
||||||
|
expect(parseTrustProxy('10.0.0.0/8, ::1')).toEqual(['10.0.0.0/8', '::1'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function createUserAndLogin(
|
||||||
|
a: NonNullable<typeof app>,
|
||||||
|
adminTok: string,
|
||||||
|
email: string,
|
||||||
|
) {
|
||||||
|
const create = await a.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/admin/users',
|
||||||
|
headers: { authorization: `Bearer ${adminTok}` },
|
||||||
|
payload: {
|
||||||
|
email,
|
||||||
|
name: 'U',
|
||||||
|
password: 'secret12',
|
||||||
|
is_admin: false,
|
||||||
|
apps: ['cfdm'],
|
||||||
|
permissions: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(create.statusCode).toBe(201)
|
||||||
|
const id = (create.json() as { id: string }).id
|
||||||
|
const login = await a.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/auth/login',
|
||||||
|
payload: { email, password: 'secret12' },
|
||||||
|
})
|
||||||
|
expect(login.statusCode).toBe(200)
|
||||||
|
return { id, token: (login.json() as { access_token: string }).access_token }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('кэш профиля пользователя', () => {
|
||||||
|
it('повторные запросы не обращаются к user_apps/user_permissions', async () => {
|
||||||
|
app = await buildTestApp()
|
||||||
|
const admin = await adminToken(app)
|
||||||
|
const { token } = await createUserAndLogin(app, admin, '[email protected]')
|
||||||
|
const me = () =>
|
||||||
|
app!.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/auth/me',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect((await me()).statusCode).toBe(200) // прогрев
|
||||||
|
|
||||||
|
const spy = vi.spyOn(app.sqlite, 'prepare')
|
||||||
|
for (let i = 0; i < 5; i++) expect((await me()).statusCode).toBe(200)
|
||||||
|
const sqls = spy.mock.calls.map((c) => String(c[0]))
|
||||||
|
expect(sqls.filter((s) => /user_apps|user_permissions|from "users"/i.test(s)))
|
||||||
|
.toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('блокировка пользователя действует немедленно', async () => {
|
||||||
|
app = await buildTestApp()
|
||||||
|
const admin = await adminToken(app)
|
||||||
|
const { id, token } = await createUserAndLogin(app, admin, '[email protected]')
|
||||||
|
const me = () =>
|
||||||
|
app!.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/auth/me',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect((await me()).statusCode).toBe(200)
|
||||||
|
const patch = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/v1/admin/users/${id}`,
|
||||||
|
headers: { authorization: `Bearer ${admin}` },
|
||||||
|
payload: { disabled: true },
|
||||||
|
})
|
||||||
|
expect(patch.statusCode).toBe(200)
|
||||||
|
expect((await me()).statusCode).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('смена доступа отражается сразу, удаление → 401', async () => {
|
||||||
|
app = await buildTestApp()
|
||||||
|
const admin = await adminToken(app)
|
||||||
|
const { id, token } = await createUserAndLogin(app, admin, '[email protected]')
|
||||||
|
const me = async () =>
|
||||||
|
(
|
||||||
|
await app!.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/auth/me',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
).json() as { apps?: string[] }
|
||||||
|
expect((await me()).apps).toEqual(['cfdm'])
|
||||||
|
|
||||||
|
const put = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: `/api/v1/admin/users/${id}/access`,
|
||||||
|
headers: { authorization: `Bearer ${admin}` },
|
||||||
|
payload: { apps: ['dns'], permissions: [] },
|
||||||
|
})
|
||||||
|
expect(put.statusCode).toBe(200)
|
||||||
|
expect((await me()).apps).toEqual(['dns'])
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `/api/v1/admin/users/${id}`,
|
||||||
|
headers: { authorization: `Bearer ${admin}` },
|
||||||
|
})
|
||||||
|
const gone = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/auth/me',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(gone.statusCode).toBe(401)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('app-switcher кэш', () => {
|
||||||
|
it('PUT сразу виден в GET', async () => {
|
||||||
|
app = await buildTestApp()
|
||||||
|
const admin = await adminToken(app)
|
||||||
|
const before = await app.inject({ method: 'GET', url: '/api/v1/app-switcher' })
|
||||||
|
const cfg = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/admin/app-switcher',
|
||||||
|
headers: { authorization: `Bearer ${admin}` },
|
||||||
|
})
|
||||||
|
expect(before.statusCode).toBe(200)
|
||||||
|
expect(cfg.statusCode).toBe(200)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('rate-limit', () => {
|
||||||
|
it('/health не лимитируется', async () => {
|
||||||
|
app = await buildTestApp()
|
||||||
|
for (let i = 0; i < 250; i++) {
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/health' })
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('логин ограничен 20/мин', async () => {
|
||||||
|
app = await buildTestApp()
|
||||||
|
let last = 0
|
||||||
|
for (let i = 0; i < 22; i++) {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/auth/login',
|
||||||
|
payload: { email: '[email protected]', password: 'wrongpass' },
|
||||||
|
})
|
||||||
|
last = res.statusCode
|
||||||
|
}
|
||||||
|
expect(last).toBe(429)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('X-Forwarded-For от недоверенного клиента игнорируется', async () => {
|
||||||
|
app = await buildTestApp({ TRUST_PROXY: '10.0.0.0/8' })
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/auth/login',
|
||||||
|
remoteAddress: '203.0.113.9',
|
||||||
|
headers: { 'x-forwarded-for': '198.51.100.7' },
|
||||||
|
payload: { email: '[email protected]', password: 'wrongpass' },
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(401)
|
||||||
|
const row = app.sqlite
|
||||||
|
.prepare(`SELECT ip FROM audit_log WHERE action='auth.login_failed'`)
|
||||||
|
.get() as { ip: string }
|
||||||
|
expect(row.ip).toBe('203.0.113.9')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { createOriginChecker } from '../src/lib/cors.js'
|
||||||
|
import { loadConfig } from '../src/config.js'
|
||||||
|
import { buildTestApp } from './helpers.js'
|
||||||
|
|
||||||
|
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
|
||||||
|
afterEach(async () => {
|
||||||
|
await app?.close()
|
||||||
|
app = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
function staticDir(): string {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'ap-static-'))
|
||||||
|
mkdirSync(join(dir, 'assets'))
|
||||||
|
writeFileSync(join(dir, 'index.html'), '<html>spa</html>')
|
||||||
|
writeFileSync(join(dir, 'assets', 'app-abc123.js'), 'console.log(1)')
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('статика', () => {
|
||||||
|
it('hashed-ассеты immutable, index.html no-cache', async () => {
|
||||||
|
app = await buildTestApp({ STATIC_DIR: staticDir() })
|
||||||
|
const asset = await app.inject({ method: 'GET', url: '/assets/app-abc123.js' })
|
||||||
|
expect(asset.statusCode).toBe(200)
|
||||||
|
expect(asset.headers['cache-control']).toBe(
|
||||||
|
'public, max-age=31536000, immutable',
|
||||||
|
)
|
||||||
|
const index = await app.inject({ method: 'GET', url: '/index.html' })
|
||||||
|
expect(index.headers['cache-control']).toBe('no-cache')
|
||||||
|
const spa = await app.inject({ method: 'GET', url: '/users/42' })
|
||||||
|
expect(spa.statusCode).toBe(200)
|
||||||
|
expect(spa.headers['cache-control']).toBe('no-cache')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('отсутствующий ассет → 404, а не index.html (иначе после деплоя чанк-JS = HTML)', async () => {
|
||||||
|
app = await buildTestApp({ STATIC_DIR: staticDir() })
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/assets/missing.js' })
|
||||||
|
expect(res.statusCode).toBe(404)
|
||||||
|
expect(res.body).not.toContain('<html>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('отдаёт предсжатый .br по Accept-Encoding', async () => {
|
||||||
|
const dir = staticDir()
|
||||||
|
writeFileSync(join(dir, 'assets', 'app-abc123.js.br'), 'BR-BYTES')
|
||||||
|
app = await buildTestApp({ STATIC_DIR: dir })
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/assets/app-abc123.js',
|
||||||
|
headers: { 'accept-encoding': 'br' },
|
||||||
|
})
|
||||||
|
expect(res.headers['content-encoding']).toBe('br')
|
||||||
|
expect(res.body).toBe('BR-BYTES')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('кэш-заголовки API', () => {
|
||||||
|
it('auth/config кэшируется, app-switcher — нет (админка должна видеть правки сразу)', async () => {
|
||||||
|
app = await buildTestApp()
|
||||||
|
const sw = await app.inject({ method: 'GET', url: '/api/v1/app-switcher' })
|
||||||
|
expect(sw.headers['cache-control']).toBeUndefined()
|
||||||
|
const cfg = await app.inject({ method: 'GET', url: '/api/v1/auth/config' })
|
||||||
|
expect(cfg.headers['cache-control']).toBe('public, max-age=60')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('health / ready', () => {
|
||||||
|
it('/health не трогает БД и живёт после закрытия БД', async () => {
|
||||||
|
app = await buildTestApp()
|
||||||
|
app.sqlite.close()
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/health' })
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('/ready 200 в норме и 503 при недоступной БД', async () => {
|
||||||
|
app = await buildTestApp()
|
||||||
|
const ok = await app.inject({ method: 'GET', url: '/ready' })
|
||||||
|
expect(ok.statusCode).toBe(200)
|
||||||
|
app.sqlite.close()
|
||||||
|
const bad = await app.inject({ method: 'GET', url: '/ready' })
|
||||||
|
expect(bad.statusCode).toBe(503)
|
||||||
|
expect(bad.json()).toEqual({ ok: false, reason: 'db' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CORS', () => {
|
||||||
|
const origin = (url: string) => ({ origin: url })
|
||||||
|
|
||||||
|
it('разрешает issuer/allowlist, блокирует чужие', async () => {
|
||||||
|
app = await buildTestApp({ RETURN_TO_ALLOWLIST: '.test.local,localhost' })
|
||||||
|
const allowed = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/auth/config',
|
||||||
|
headers: origin('https://auth.test.local'),
|
||||||
|
})
|
||||||
|
expect(allowed.headers['access-control-allow-origin']).toBe(
|
||||||
|
'https://auth.test.local',
|
||||||
|
)
|
||||||
|
expect(allowed.headers['access-control-allow-credentials']).toBe('true')
|
||||||
|
|
||||||
|
const foreign = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/auth/config',
|
||||||
|
headers: origin('https://evil.example'),
|
||||||
|
})
|
||||||
|
expect(foreign.headers['access-control-allow-origin']).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('CORS_ORIGINS добавляет origin; запрос без Origin проходит', async () => {
|
||||||
|
app = await buildTestApp({ CORS_ORIGINS: 'https://extra.example' })
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/auth/config',
|
||||||
|
headers: origin('https://extra.example'),
|
||||||
|
})
|
||||||
|
expect(res.headers['access-control-allow-origin']).toBe(
|
||||||
|
'https://extra.example',
|
||||||
|
)
|
||||||
|
const none = await app.inject({ method: 'GET', url: '/api/v1/auth/config' })
|
||||||
|
expect(none.statusCode).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('боевой RETURN_TO_ALLOWLIST из env.traefik.example не теряет клиентов', () => {
|
||||||
|
const list =
|
||||||
|
'.shnt.top,https://vps.shnt.top,https://cfdm.shnt.top,https://bgp.shnt.top,https://fw.shnt.top,https://dns.shnt.top,https://cdn.shnt.top'
|
||||||
|
const check = createOriginChecker(
|
||||||
|
loadConfig({
|
||||||
|
JWT_SECRET: 'test-secret-at-least-8',
|
||||||
|
ISSUER: 'https://auth.shnt.top',
|
||||||
|
RETURN_TO_ALLOWLIST: list,
|
||||||
|
}),
|
||||||
|
() => [],
|
||||||
|
)
|
||||||
|
for (const o of [
|
||||||
|
'https://vps.shnt.top',
|
||||||
|
'https://dns.shnt.top',
|
||||||
|
'https://shnt.top',
|
||||||
|
'https://anything.shnt.top',
|
||||||
|
]) {
|
||||||
|
expect(check(o)).toBe(true)
|
||||||
|
}
|
||||||
|
expect(check('https://evil.example')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('private в allowlist пускает LAN-хосты', () => {
|
||||||
|
const check = createOriginChecker(
|
||||||
|
loadConfig({
|
||||||
|
JWT_SECRET: 'test-secret-at-least-8',
|
||||||
|
RETURN_TO_ALLOWLIST: '.shnt.top,private',
|
||||||
|
}),
|
||||||
|
() => [],
|
||||||
|
)
|
||||||
|
expect(check('http://192.168.1.10:8080')).toBe(true)
|
||||||
|
expect(check('http://10.0.0.5')).toBe(true)
|
||||||
|
expect(check('https://8.8.8.8')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('createOriginChecker: суффикс, localhost, switcher, *', () => {
|
||||||
|
const base = {
|
||||||
|
JWT_SECRET: 'test-secret-at-least-8',
|
||||||
|
ISSUER: 'https://auth.test.local',
|
||||||
|
RETURN_TO_ALLOWLIST: '.shnt.top,localhost,private',
|
||||||
|
}
|
||||||
|
const check = createOriginChecker(loadConfig(base), () => [
|
||||||
|
'https://dns.custom.io/path',
|
||||||
|
])
|
||||||
|
expect(check('https://cfdm.shnt.top')).toBe(true)
|
||||||
|
expect(check('http://localhost:5173')).toBe(true)
|
||||||
|
expect(check('https://dns.custom.io')).toBe(true)
|
||||||
|
expect(check('https://evilshnt.top')).toBe(false)
|
||||||
|
expect(check('not a url')).toBe(false)
|
||||||
|
const all = createOriginChecker(
|
||||||
|
loadConfig({ ...base, CORS_ORIGINS: '*' }),
|
||||||
|
() => [],
|
||||||
|
)
|
||||||
|
expect(all('https://anything.example')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('сжатие в Node (ENABLE_COMPRESS)', () => {
|
||||||
|
async function withBigRoute(env: NodeJS.ProcessEnv) {
|
||||||
|
app = await buildTestApp(env)
|
||||||
|
app.get('/__big', async () => ({ data: 'x'.repeat(5000) }))
|
||||||
|
app.get('/__small', async () => ({ ok: true }))
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
it('по умолчанию выключено', async () => {
|
||||||
|
const a = await withBigRoute({})
|
||||||
|
const res = await a.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/__big',
|
||||||
|
headers: { 'accept-encoding': 'gzip' },
|
||||||
|
})
|
||||||
|
expect(res.headers['content-encoding']).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('включено: большой JSON сжимается, маленький нет, без Accept-Encoding нет', async () => {
|
||||||
|
const a = await withBigRoute({ ENABLE_COMPRESS: 'true' })
|
||||||
|
const big = await a.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/__big',
|
||||||
|
headers: { 'accept-encoding': 'gzip' },
|
||||||
|
})
|
||||||
|
expect(big.headers['content-encoding']).toBe('gzip')
|
||||||
|
expect(big.rawPayload.length).toBeLessThan(500)
|
||||||
|
const small = await a.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/__small',
|
||||||
|
headers: { 'accept-encoding': 'gzip' },
|
||||||
|
})
|
||||||
|
expect(small.headers['content-encoding']).toBeUndefined()
|
||||||
|
const none = await a.inject({ method: 'GET', url: '/__big' })
|
||||||
|
expect(none.headers['content-encoding']).toBeUndefined()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CSP', () => {
|
||||||
|
it('отдаётся жёсткая политика без внешних источников', async () => {
|
||||||
|
app = await buildTestApp()
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/health' })
|
||||||
|
const csp = String(res.headers['content-security-policy'])
|
||||||
|
expect(csp).toContain("default-src 'self'")
|
||||||
|
expect(csp).toContain("script-src 'self'")
|
||||||
|
expect(csp).toContain("frame-ancestors 'none'")
|
||||||
|
expect(csp).toContain("object-src 'none'")
|
||||||
|
expect(csp).not.toMatch(/script-src[^;]*'unsafe-inline'/)
|
||||||
|
expect(csp).not.toContain('http:')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { EventEmitter } from 'node:events'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createDb, closeDb, migrateSchema } from '@authportal/db'
|
||||||
|
import { registerShutdown } from '../src/lib/shutdown.js'
|
||||||
|
import { buildTestApp } from './helpers.js'
|
||||||
|
|
||||||
|
function fakeApp(close: () => Promise<void>) {
|
||||||
|
return {
|
||||||
|
close,
|
||||||
|
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||||
|
} as unknown as Parameters<typeof registerShutdown>[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('registerShutdown', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('закрывает приложение один раз и выходит с 0', async () => {
|
||||||
|
const close = vi.fn(async () => {})
|
||||||
|
const exit = vi.fn()
|
||||||
|
const proc = new EventEmitter()
|
||||||
|
registerShutdown(fakeApp(close), {
|
||||||
|
timeoutMs: 1000,
|
||||||
|
exit,
|
||||||
|
proc: proc as never,
|
||||||
|
})
|
||||||
|
proc.emit('SIGTERM')
|
||||||
|
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(0))
|
||||||
|
expect(close).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('повторный сигнал форсирует выход, close не вызывается дважды', async () => {
|
||||||
|
let release!: () => void
|
||||||
|
const close = vi.fn(
|
||||||
|
() => new Promise<void>((r) => (release = r)),
|
||||||
|
)
|
||||||
|
const exit = vi.fn()
|
||||||
|
const proc = new EventEmitter()
|
||||||
|
registerShutdown(fakeApp(close), {
|
||||||
|
timeoutMs: 1000,
|
||||||
|
exit,
|
||||||
|
proc: proc as never,
|
||||||
|
})
|
||||||
|
proc.emit('SIGTERM')
|
||||||
|
proc.emit('SIGINT')
|
||||||
|
expect(exit).toHaveBeenCalledWith(1)
|
||||||
|
expect(close).toHaveBeenCalledTimes(1)
|
||||||
|
release()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('по таймауту принудительно выходит с кодом 1', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const exit = vi.fn()
|
||||||
|
const proc = new EventEmitter()
|
||||||
|
registerShutdown(
|
||||||
|
fakeApp(() => new Promise<void>(() => {})),
|
||||||
|
{ timeoutMs: 500, exit, proc: proc as never },
|
||||||
|
)
|
||||||
|
proc.emit('SIGTERM')
|
||||||
|
await vi.advanceTimersByTimeAsync(501)
|
||||||
|
expect(exit).toHaveBeenCalledWith(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ошибка close → выход с кодом 1', async () => {
|
||||||
|
const exit = vi.fn()
|
||||||
|
const proc = new EventEmitter()
|
||||||
|
registerShutdown(
|
||||||
|
fakeApp(async () => {
|
||||||
|
throw new Error('boom')
|
||||||
|
}),
|
||||||
|
{ timeoutMs: 1000, exit, proc: proc as never },
|
||||||
|
)
|
||||||
|
proc.emit('SIGTERM')
|
||||||
|
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unhandledRejection запускает shutdown с кодом 1', async () => {
|
||||||
|
const exit = vi.fn()
|
||||||
|
const proc = new EventEmitter()
|
||||||
|
registerShutdown(fakeApp(async () => {}), {
|
||||||
|
timeoutMs: 1000,
|
||||||
|
exit,
|
||||||
|
proc: proc as never,
|
||||||
|
})
|
||||||
|
proc.emit('unhandledRejection', new Error('x'))
|
||||||
|
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dispose снимает обработчики', () => {
|
||||||
|
const proc = new EventEmitter()
|
||||||
|
const h = registerShutdown(fakeApp(async () => {}), {
|
||||||
|
timeoutMs: 1000,
|
||||||
|
exit: vi.fn(),
|
||||||
|
proc: proc as never,
|
||||||
|
})
|
||||||
|
h.dispose()
|
||||||
|
expect(proc.listenerCount('SIGTERM')).toBe(0)
|
||||||
|
expect(proc.listenerCount('unhandledRejection')).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('closeDb', () => {
|
||||||
|
it('идемпотентно закрывает БД', () => {
|
||||||
|
const { sqlite } = createDb('sqlite::memory:')
|
||||||
|
migrateSchema(sqlite)
|
||||||
|
closeDb(sqlite)
|
||||||
|
expect(sqlite.open).toBe(false)
|
||||||
|
expect(() => closeDb(sqlite)).not.toThrow()
|
||||||
|
expect(() => sqlite.prepare('SELECT 1')).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('app.close() закрывает БД приложения', async () => {
|
||||||
|
const app = await buildTestApp()
|
||||||
|
await app.ready()
|
||||||
|
expect(app.sqlite.open).toBe(true)
|
||||||
|
await app.close()
|
||||||
|
expect(app.sqlite.open).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,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,
|
||||||
|
})
|
||||||
@@ -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 },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -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="mx-auto flex w-full min-w-0 max-w-[1480px] flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">{children}</div>
|
||||||
|
</SidebarInset>
|
||||||
|
<CommandPalette />
|
||||||
|
</SidebarProvider>
|
||||||
|
</TooltipProvider>
|
||||||
|
</ChromeConfigProvider>
|
||||||
|
</PortalUiProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
import {
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ComponentType,
|
||||||
|
} from 'react'
|
||||||
|
import {
|
||||||
|
Link,
|
||||||
|
useNavigate,
|
||||||
|
useRouterState,
|
||||||
|
} from '@tanstack/react-router'
|
||||||
|
import {
|
||||||
|
ActivityIcon,
|
||||||
|
ArrowRightIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronsUpDownIcon,
|
||||||
|
FingerprintIcon,
|
||||||
|
InfoIcon,
|
||||||
|
LogOutIcon,
|
||||||
|
SearchIcon,
|
||||||
|
StarIcon,
|
||||||
|
MoonIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import {
|
||||||
|
useTheme,
|
||||||
|
} from 'next-themes'
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
} from '@/components/reui/badge'
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbPage,
|
||||||
|
} from '@authportal/ui/components/breadcrumb'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
} from '@authportal/ui/components/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@authportal/ui/components/dialog'
|
||||||
|
import {
|
||||||
|
Kbd,
|
||||||
|
} from '@authportal/ui/components/kbd'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@authportal/ui/components/popover'
|
||||||
|
import {
|
||||||
|
Separator,
|
||||||
|
} from '@authportal/ui/components/separator'
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuAction,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarSeparator,
|
||||||
|
SidebarTrigger,
|
||||||
|
useSidebar,
|
||||||
|
} from '@authportal/ui/components/sidebar'
|
||||||
|
import {
|
||||||
|
cn,
|
||||||
|
} from '@authportal/ui/lib/utils'
|
||||||
|
import {
|
||||||
|
Segmented,
|
||||||
|
} from './bits'
|
||||||
|
import {
|
||||||
|
useChromeConfig,
|
||||||
|
useUiStore,
|
||||||
|
type NavItem,
|
||||||
|
} from './chrome-store'
|
||||||
|
|
||||||
|
const LABEL_CLASS = 'text-muted-foreground text-[11px] font-medium uppercase'
|
||||||
|
|
||||||
|
function PortalNavUser() {
|
||||||
|
const { isMobile } = useSidebar()
|
||||||
|
const { theme, setTheme, resolvedTheme } = useTheme()
|
||||||
|
const { density, setDensity, contrast, setContrast } = useUiStore()
|
||||||
|
const { user, securityTo, onLogout } = useChromeConfig()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<SidebarMenuButton
|
||||||
|
size="lg"
|
||||||
|
aria-label={`Профиль и настройки интерфейса: ${user.name}, ${user.email}`}
|
||||||
|
className="bg-muted/40 aria-expanded:bg-muted"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="bg-background text-foreground flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
|
||||||
|
{user.initials}
|
||||||
|
</div>
|
||||||
|
<div className="grid min-w-0 flex-1 text-left leading-tight">
|
||||||
|
<span className="truncate text-sm font-semibold">{user.name}</span>
|
||||||
|
<span className="text-muted-foreground truncate text-xs">{user.email}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronsUpDownIcon className="text-muted-foreground ml-auto size-4" aria-hidden />
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent side={isMobile ? 'top' : 'right'} align="end" sideOffset={8} className="w-72 gap-3">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="bg-background flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
|
||||||
|
{user.initials}
|
||||||
|
</div>
|
||||||
|
<div className="grid leading-tight">
|
||||||
|
<span className="text-sm font-semibold">{user.name}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">{user.email}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void navigate({ to: securityTo })}
|
||||||
|
className="hover:bg-muted flex items-center gap-2 rounded-md px-1 py-1 text-left text-sm"
|
||||||
|
>
|
||||||
|
<FingerprintIcon className="text-muted-foreground size-4" aria-hidden />
|
||||||
|
Безопасность
|
||||||
|
</button>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<span className={LABEL_CLASS}>Тема</span>
|
||||||
|
<Segmented
|
||||||
|
label="Тема"
|
||||||
|
options={['Тёмная', 'Светлая']}
|
||||||
|
value={current === 'light' ? 1 : 0}
|
||||||
|
onChange={(i) => setTheme(i === 1 ? 'light' : 'dark')}
|
||||||
|
/>
|
||||||
|
<span className={LABEL_CLASS}>Плотность</span>
|
||||||
|
<Segmented
|
||||||
|
label="Плотность"
|
||||||
|
options={['Обычная', 'Компактная']}
|
||||||
|
value={density === 'compact' ? 1 : 0}
|
||||||
|
onChange={(i) => setDensity(i === 1 ? 'compact' : 'comfortable')}
|
||||||
|
/>
|
||||||
|
<span className={LABEL_CLASS}>Контраст</span>
|
||||||
|
<Segmented
|
||||||
|
label="Контраст"
|
||||||
|
options={['Стандартный', 'Высокий']}
|
||||||
|
value={contrast === 'high' ? 1 : 0}
|
||||||
|
onChange={(i) => setContrast(i === 1 ? 'high' : 'standard')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onLogout}
|
||||||
|
className="text-muted-foreground hover:text-foreground flex items-center gap-2 text-left text-sm"
|
||||||
|
>
|
||||||
|
<LogOutIcon className="size-4" aria-hidden />
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOT_CLASS = { er: 'bg-destructive', wn: 'bg-warning' } as const
|
||||||
|
|
||||||
|
function NavEntry({ item, pathname, pinned, onTogglePin }: { item: NavItem; pathname: string; pinned: boolean; onTogglePin: () => void }) {
|
||||||
|
const Icon = item.icon
|
||||||
|
const active = pathname === item.to || pathname.startsWith(`${item.to}/`)
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton
|
||||||
|
tooltip={item.problem ? `${item.label} · ${item.problem.label}` : item.label}
|
||||||
|
isActive={active}
|
||||||
|
className="relative pr-8 group-data-[collapsible=icon]:pr-2!"
|
||||||
|
render={<Link to={item.to} />}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
{item.problem ? (
|
||||||
|
<span
|
||||||
|
role="img"
|
||||||
|
aria-label={item.problem.label}
|
||||||
|
className={cn(
|
||||||
|
'ml-auto size-2 shrink-0 rounded-full group-data-[collapsible=icon]:absolute group-data-[collapsible=icon]:top-1 group-data-[collapsible=icon]:right-1 group-data-[collapsible=icon]:ml-0 group-data-[collapsible=icon]:size-1.5 group-data-[collapsible=icon]:ring-2 group-data-[collapsible=icon]:ring-sidebar',
|
||||||
|
DOT_CLASS[item.problem.tone],
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</SidebarMenuButton>
|
||||||
|
<SidebarMenuAction
|
||||||
|
showOnHover={!pinned}
|
||||||
|
aria-label={pinned ? `Открепить ${item.label}` : `Закрепить ${item.label}`}
|
||||||
|
aria-pressed={pinned}
|
||||||
|
onClick={onTogglePin}
|
||||||
|
>
|
||||||
|
<StarIcon className={cn(pinned && 'fill-current')} aria-hidden />
|
||||||
|
</SidebarMenuAction>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PortalSidebar() {
|
||||||
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
|
const { pins, togglePin } = useUiStore()
|
||||||
|
const { navItems, navGroups, brand } = useChromeConfig()
|
||||||
|
const [closed, setClosed] = useState<Record<string, boolean>>({})
|
||||||
|
const pinned = navItems.filter((i) => pins.includes(i.id))
|
||||||
|
return (
|
||||||
|
<Sidebar collapsible="icon">
|
||||||
|
<SidebarHeader className="pb-1.5">
|
||||||
|
{brand}
|
||||||
|
</SidebarHeader>
|
||||||
|
<SidebarContent>
|
||||||
|
{pinned.length > 0 ? (
|
||||||
|
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||||
|
<SidebarGroupLabel className="text-[11px] font-semibold tracking-wide uppercase">Закреплённые</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{pinned.map((item) => (
|
||||||
|
<NavEntry key={item.id} item={item} pathname={pathname} pinned onTogglePin={() => togglePin(item.id)} />
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
) : null}
|
||||||
|
{navGroups.map((group, gi) => (
|
||||||
|
<div key={group} className="flex flex-col">
|
||||||
|
{gi > 0 ? <SidebarSeparator className="mx-2 my-1 h-px shrink-0 hidden group-data-[collapsible=icon]:block" /> : null}
|
||||||
|
<SidebarGroup className="py-0">
|
||||||
|
<SidebarGroupLabel
|
||||||
|
render={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={!closed[group]}
|
||||||
|
onClick={() => setClosed((c) => ({ ...c, [group]: !c[group] }))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
className="h-7 cursor-pointer justify-between text-[11px] font-semibold tracking-wide uppercase"
|
||||||
|
>
|
||||||
|
{group}
|
||||||
|
<ChevronDownIcon className={cn('size-3.5 transition-transform', closed[group] && '-rotate-90')} aria-hidden />
|
||||||
|
</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent className={cn(closed[group] && 'group-data-[collapsible=icon]:block hidden')}>
|
||||||
|
<SidebarMenu className="gap-px">
|
||||||
|
{navItems.filter((i) => i.group === group).map((item) => (
|
||||||
|
<NavEntry
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
pathname={pathname}
|
||||||
|
pinned={pins.includes(item.id)}
|
||||||
|
onTogglePin={() => togglePin(item.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</SidebarContent>
|
||||||
|
<SidebarFooter>
|
||||||
|
<PortalNavUser />
|
||||||
|
</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Единый центр состояния: внимание + сводка + список проблем. Адаптивен по ширине окна, как на досках. */
|
||||||
|
function MonitorPopover() {
|
||||||
|
const { monitor } = useChromeConfig()
|
||||||
|
const MONITOR_ITEMS = monitor.items
|
||||||
|
const MONITOR_METRICS = monitor.metrics
|
||||||
|
const bad = MONITOR_ITEMS.filter((m) => m.tone !== 'ok')
|
||||||
|
const errors = bad.filter((m) => m.tone === 'er').length
|
||||||
|
const warnings = bad.length - errors
|
||||||
|
const ok = MONITOR_ITEMS.length - bad.length
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
type="button"
|
||||||
|
aria-label={`Состояние системы: внимание, ${errors} сбой, ${warnings} предупреждения`}
|
||||||
|
className="border-warning/40 bg-warning/10 hover:bg-warning/20 aria-expanded:bg-warning/20 relative h-8 gap-2 px-2 max-[760px]:w-8 max-[760px]:border-transparent max-[760px]:bg-transparent max-[760px]:px-0"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ActivityIcon className="text-warning size-4" aria-hidden />
|
||||||
|
<span className="text-warning hidden text-sm font-medium min-[1000px]:inline">Внимание</span>
|
||||||
|
<span className="hidden items-center gap-1.5 text-sm tabular-nums min-[1240px]:inline-flex">
|
||||||
|
<span className="bg-destructive size-1.5 rounded-full" aria-hidden />
|
||||||
|
{errors}
|
||||||
|
<span className="bg-warning size-1.5 rounded-full" aria-hidden />
|
||||||
|
{warnings}
|
||||||
|
</span>
|
||||||
|
<span className="bg-destructive hidden size-4.5 min-w-4.5 items-center justify-center rounded-full px-1 text-[11px] font-semibold text-white min-[760px]:inline-flex min-[1240px]:hidden">
|
||||||
|
{bad.length}
|
||||||
|
</span>
|
||||||
|
<span className="bg-destructive ring-background absolute top-1 right-1 size-2 rounded-full ring-2 min-[760px]:hidden" aria-hidden />
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="end" className="flex w-[24rem] max-w-[calc(100vw-1.5rem)] flex-col gap-2 p-2">
|
||||||
|
<div className="flex items-start justify-between gap-2 px-1.5 pt-1.5">
|
||||||
|
<div className="grid leading-tight">
|
||||||
|
<b className="text-sm font-semibold">Состояние системы</b>
|
||||||
|
<span className="text-muted-foreground text-xs">{monitor.updated}</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant={errors > 0 ? "destructive-light" : warnings > 0 ? "warning-light" : "success-light"} size="sm">{errors > 0 ? "Сбой" : warnings > 0 ? "Внимание" : "Норма"}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
|
{[
|
||||||
|
{ n: errors, l: 'сбой', c: 'text-destructive' },
|
||||||
|
{ n: warnings, l: 'предупреждения', c: 'text-warning' },
|
||||||
|
{ n: ok, l: 'в норме', c: 'text-success' },
|
||||||
|
].map((x) => (
|
||||||
|
<span key={x.l} className="bg-muted/55 text-muted-foreground flex flex-col rounded-lg px-2.5 py-2 text-[11.5px]">
|
||||||
|
<b className={cn('text-lg leading-tight font-semibold tabular-nums', x.c)}>{x.n}</b>
|
||||||
|
{x.l}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ul className="flex flex-col gap-0.5">
|
||||||
|
{MONITOR_ITEMS.filter((m) => m.tone !== 'ok').map((m) => (
|
||||||
|
<li key={m.id}>
|
||||||
|
<Link to={m.to} className="hover:bg-muted/55 flex items-center gap-2.5 rounded-md px-1.5 py-2 outline-none focus-visible:bg-muted/55">
|
||||||
|
<span aria-hidden className={cn('size-2 shrink-0 rounded-full', m.tone === 'er' ? 'bg-destructive' : 'bg-warning')} />
|
||||||
|
<span className="grid min-w-0 flex-1 leading-tight">
|
||||||
|
<b className="truncate text-[13px] font-medium">{m.title}</b>
|
||||||
|
<span className="text-muted-foreground truncate text-xs">{m.text}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground shrink-0 text-[11.5px]">{m.age}</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
{MONITOR_METRICS.map((m) => (
|
||||||
|
<div key={m.l} className="bg-muted/55 flex min-w-0 flex-col gap-1.5 rounded-lg px-3 py-2.5">
|
||||||
|
<span className="text-muted-foreground text-xs leading-snug">{m.l}</span>
|
||||||
|
<b className={cn('text-base font-semibold whitespace-nowrap tabular-nums', m.tone === 'success' ? 'text-success' : 'text-warning')}>{m.v}</b>
|
||||||
|
<div className="bg-muted-foreground/25 mt-auto h-[3px] overflow-hidden rounded-full">
|
||||||
|
<i className={cn('block h-full rounded-full', m.tone === 'success' ? 'bg-success' : 'bg-warning')} style={{ width: `${m.p}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Link to={monitor.footerTo} className="text-muted-foreground hover:text-foreground flex items-center justify-between px-1.5 pt-1 pb-0.5 text-sm">
|
||||||
|
{monitor.footerLabel}
|
||||||
|
<ArrowRightIcon className="size-3.5" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FreshnessPopover() {
|
||||||
|
const pathname = useRouterState({ select: (st) => st.location.pathname })
|
||||||
|
const { fresh: freshFor } = useChromeConfig()
|
||||||
|
const fresh = freshFor(pathname)
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<Button type="button" variant="ghost" size="icon" aria-label="Источники и свежесть данных" title="Источники и свежесть данных" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<InfoIcon aria-hidden />
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="end" sideOffset={8} className="flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2">
|
||||||
|
<Badge variant="success-light" size="sm" className="w-fit">{fresh.badge}</Badge>
|
||||||
|
<p className="text-muted-foreground text-sm">{fresh.text}</p>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PortalHeader() {
|
||||||
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
|
const { setPaletteOpen } = useUiStore()
|
||||||
|
const { navItems, crumbFor } = useChromeConfig()
|
||||||
|
const current = navItems.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="bg-background/80 sticky top-0 z-20 flex min-h-12 shrink-0 items-center gap-2 border-b px-4 py-1.5 backdrop-blur md:px-6">
|
||||||
|
<SidebarTrigger className="-ml-1" />
|
||||||
|
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||||
|
<Breadcrumb className="min-w-0">
|
||||||
|
<BreadcrumbList className="flex-nowrap">
|
||||||
|
<BreadcrumbItem className="min-w-0">
|
||||||
|
<BreadcrumbPage className="truncate">{crumbFor ? crumbFor(pathname, current?.label) : (current?.label ?? 'Auth Portal')}</BreadcrumbPage>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||||
|
<FreshnessPopover />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Командная палитра (⌘K)"
|
||||||
|
className="md:hidden"
|
||||||
|
onClick={() => setPaletteOpen(true)}
|
||||||
|
>
|
||||||
|
<SearchIcon aria-hidden />
|
||||||
|
</Button>
|
||||||
|
<MonitorPopover />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaletteEntry = {
|
||||||
|
id: string
|
||||||
|
group: string
|
||||||
|
label: string
|
||||||
|
hint: string
|
||||||
|
icon?: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
|
||||||
|
dot?: 'ok' | 'er' | 'mu'
|
||||||
|
run: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOT_BG = { ok: 'bg-success', er: 'bg-destructive', mu: 'bg-muted-foreground' } as const
|
||||||
|
|
||||||
|
/** Палитра ⌘K как на доске: 640px, группы заглавными, подсказки моноширинным справа, подвал с подсказкой. */
|
||||||
|
export function CommandPalette() {
|
||||||
|
const { paletteOpen, setPaletteOpen } = useUiStore()
|
||||||
|
const { navItems, palette } = useChromeConfig()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { theme, setTheme, resolvedTheme } = useTheme()
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [active, setActive] = useState(0)
|
||||||
|
|
||||||
|
const shown = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
const match = (...t: string[]) => !q || t.some((x) => x.toLowerCase().includes(q))
|
||||||
|
const go = (to: string) => () => void navigate({ to })
|
||||||
|
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
|
||||||
|
const list: PaletteEntry[] = [
|
||||||
|
...[
|
||||||
|
...palette.actions.map((x) => ({ id: x.id, label: x.label, hint: x.hint, icon: x.icon, run: go(x.to) })),
|
||||||
|
{ id: 'act-theme', label: 'Переключить тему', hint: 'T', icon: MoonIcon, run: () => setTheme(current === 'dark' ? 'light' : 'dark') },
|
||||||
|
]
|
||||||
|
.filter((e) => match(e.label))
|
||||||
|
.map((e) => ({ ...e, group: 'Действия' })),
|
||||||
|
...navItems.filter((i) => match(i.label, i.group))
|
||||||
|
.slice(0, 7)
|
||||||
|
.map((i) => ({ id: `nav-${i.id}`, group: 'Разделы', label: i.label, hint: i.group, icon: i.icon, run: go(i.to) })),
|
||||||
|
...Object.values(
|
||||||
|
palette.objects
|
||||||
|
.filter((o) => match(o.label, o.hint))
|
||||||
|
.reduce<Record<string, typeof palette.objects>>((acc, o) => ((acc[o.group] ??= []).push(o), acc), {}),
|
||||||
|
).flatMap((g) => g.slice(0, 5).map((o) => ({ id: o.id, group: o.group, label: o.label, hint: o.hint, dot: o.dot, run: go(o.to) }))),
|
||||||
|
]
|
||||||
|
// Запрос из трёх и более слов — черновик ассистента (как на доске «Палитра ⌘K»).
|
||||||
|
if (query.trim().split(/\s+/).filter(Boolean).length >= 3) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'ai',
|
||||||
|
group: 'Ассистент · запрос на естественном языке',
|
||||||
|
label: `Понял так: «${query.trim()}»`,
|
||||||
|
hint: 'Черновик: запрос разобран автоматически, результат нужно проверить.',
|
||||||
|
run: () => void navigate({ to: palette.assistantTo }),
|
||||||
|
} as PaletteEntry,
|
||||||
|
...list,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}, [query, navigate, theme, resolvedTheme, setTheme, navItems, palette])
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const map = new Map<string, Array<PaletteEntry & { index: number }>>()
|
||||||
|
shown.forEach((e, index) => {
|
||||||
|
const list = map.get(e.group) ?? []
|
||||||
|
list.push({ ...e, index })
|
||||||
|
map.set(e.group, list)
|
||||||
|
})
|
||||||
|
return [...map.entries()]
|
||||||
|
}, [shown])
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
setPaletteOpen(false)
|
||||||
|
setQuery('')
|
||||||
|
setActive(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={paletteOpen} onOpenChange={(o) => (o ? setPaletteOpen(true) : close())}>
|
||||||
|
<DialogContent className="top-[90px] w-[min(40rem,94vw)] max-w-none translate-y-0 gap-0 overflow-hidden p-0 sm:max-w-none" showCloseButton={false}>
|
||||||
|
<DialogTitle className="sr-only">Командная палитра</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">Разделы, пользователи, приложения и действия</DialogDescription>
|
||||||
|
<div className="flex items-center gap-2.5 border-b px-4 py-3.5">
|
||||||
|
<SearchIcon className="text-muted-foreground size-[18px]" aria-hidden />
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={query}
|
||||||
|
aria-label="Поиск и команды"
|
||||||
|
placeholder="Раздел, пользователь, приложение или действие…"
|
||||||
|
className="min-w-0 flex-1 bg-transparent text-[15px] outline-none"
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value)
|
||||||
|
setActive(0)
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
setActive((a) => Math.min(a + 1, shown.length - 1))
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
setActive((a) => Math.max(a - 1, 0))
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
shown[active]?.run()
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Kbd>Esc</Kbd>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[430px] overflow-y-auto" role="listbox" aria-label="Результаты">
|
||||||
|
{groups.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground px-4 py-6 text-center text-sm">Ничего не найдено</p>
|
||||||
|
) : (
|
||||||
|
groups.map(([group, list]) => (
|
||||||
|
<div key={group} className="flex flex-col">
|
||||||
|
<div className="text-muted-foreground px-4 pt-2.5 pb-1 text-[11px] tracking-[0.05em] uppercase">{group}</div>
|
||||||
|
{list.map((e) => {
|
||||||
|
const Icon = e.icon
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={e.id}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={e.index === active}
|
||||||
|
onMouseEnter={() => setActive(e.index)}
|
||||||
|
onClick={() => {
|
||||||
|
e.run()
|
||||||
|
close()
|
||||||
|
}}
|
||||||
|
className={cn('flex w-full items-center gap-3 px-4 py-[9px] text-left text-sm outline-none', e.index === active && 'bg-muted/60')}
|
||||||
|
>
|
||||||
|
{Icon ? <Icon className="size-4 shrink-0" aria-hidden /> : null}
|
||||||
|
{e.dot ? <span className={cn('size-2 shrink-0 rounded-full', DOT_BG[e.dot])} aria-hidden /> : null}
|
||||||
|
<span className="min-w-0 truncate">{e.label}</span>
|
||||||
|
<span className="text-muted-foreground ml-auto truncate font-mono text-xs">{e.hint}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 border-t px-4 py-2 text-[11.5px]">
|
||||||
|
<span>↑↓ выбор</span>
|
||||||
|
<span>↵ открыть</span>
|
||||||
|
<span>Esc закрыть</span>
|
||||||
|
<span className="ml-auto">{palette.example}</span>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { DataGridFeatures, DataGridProps } from '@/components/reui/data-grid/data-grid'
|
||||||
|
|
||||||
|
export const DATA_GRID_CELL_PAD = 'py-3'
|
||||||
|
export const DATA_GRID_CELL_PAD_FIRST = 'pl-5 py-3'
|
||||||
|
export const DATA_GRID_CELL_PAD_LAST = 'pr-4 py-3'
|
||||||
|
|
||||||
|
export const DATA_GRID_LAYOUT: NonNullable<DataGridProps<DataGridFeatures, object>['tableLayout']> = {
|
||||||
|
rowBorder: true,
|
||||||
|
headerBackground: true,
|
||||||
|
headerBorder: true,
|
||||||
|
columnsResizable: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DATA_GRID_CLASS_NAMES: NonNullable<DataGridProps<DataGridFeatures, object>['tableClassNames']> = {
|
||||||
|
headerRow: 'border-b border-border',
|
||||||
|
bodyRow: 'group/row',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DATA_GRID_CONTAINER_CLASS = 'w-full rounded-none border-0'
|
||||||
|
|
||||||
|
/** Отступы ячейки по положению колонки: первая — pl-5, последняя — pr-4. */
|
||||||
|
export function cellPad(index: number, count: number) {
|
||||||
|
if (index === 0) return DATA_GRID_CELL_PAD_FIRST
|
||||||
|
if (index === count - 1) return DATA_GRID_CELL_PAD_LAST
|
||||||
|
return DATA_GRID_CELL_PAD
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Каркас списочной страницы как в MikrotikManager (/servers): карточка Frame без полей, тулбар
|
||||||
|
* «сегменты · фильтры · поиск · счётчик», таблица с подложкой шапки и ячейками py-3.
|
||||||
|
*/
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import type { Column } from '@tanstack/react-table'
|
||||||
|
import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, SearchIcon } from 'lucide-react'
|
||||||
|
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||||
|
import { InputGroup, InputGroupAddon, InputGroupInput } from '@authportal/ui/components/input-group'
|
||||||
|
import { cn } from '@authportal/ui/lib/utils'
|
||||||
|
import type { DataGridFeatures } from '@/components/reui/data-grid/data-grid'
|
||||||
|
|
||||||
|
export function DataPageCard({ children, className }: { children: ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<Frame dense className={cn('w-full min-w-0', className)}>
|
||||||
|
<FramePanel className="flex w-full min-w-0 flex-col gap-0 overflow-hidden p-0 py-0 shadow-none">{children}</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SegmentedControl<T extends string>({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
value: T
|
||||||
|
onChange: (value: T) => void
|
||||||
|
options: { value: T; label: string; count?: number }[]
|
||||||
|
label?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div role="group" aria-label={label} className="border-border bg-muted/40 flex w-fit items-center gap-1 rounded-md border p-0.5">
|
||||||
|
{options.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.value}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={value === o.value}
|
||||||
|
onClick={() => onChange(o.value)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors',
|
||||||
|
value === o.value ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
{o.count != null ? <span className="text-xs tabular-nums">{o.count}</span> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataPageToolbar({
|
||||||
|
leading,
|
||||||
|
segmented,
|
||||||
|
filterControl,
|
||||||
|
search,
|
||||||
|
onSearchChange,
|
||||||
|
searchPlaceholder = 'Поиск…',
|
||||||
|
countLabel,
|
||||||
|
actions,
|
||||||
|
}: {
|
||||||
|
leading?: ReactNode
|
||||||
|
segmented?: { value: string; onChange: (value: string) => void; options: { value: string; label: string; count?: number }[]; label?: string }
|
||||||
|
filterControl?: ReactNode
|
||||||
|
search?: string
|
||||||
|
onSearchChange?: (value: string) => void
|
||||||
|
searchPlaceholder?: string
|
||||||
|
countLabel?: string
|
||||||
|
actions?: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-3 border-b px-5 py-3">
|
||||||
|
{leading}
|
||||||
|
{segmented ? <SegmentedControl {...segmented} /> : null}
|
||||||
|
{filterControl}
|
||||||
|
{onSearchChange ? (
|
||||||
|
<InputGroup className="max-w-sm min-w-[220px]">
|
||||||
|
<InputGroupAddon>
|
||||||
|
<SearchIcon className="size-3.5" aria-hidden />
|
||||||
|
</InputGroupAddon>
|
||||||
|
<InputGroupInput aria-label={searchPlaceholder} placeholder={searchPlaceholder} value={search ?? ''} onChange={(e) => onSearchChange(e.target.value)} />
|
||||||
|
</InputGroup>
|
||||||
|
) : null}
|
||||||
|
{countLabel ? <span className="text-muted-foreground ml-auto text-sm">{countLabel}</span> : null}
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataGridSortHeader<TData extends object>({
|
||||||
|
column,
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
column: Column<DataGridFeatures, TData, unknown>
|
||||||
|
title: string
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const sorted = column.getIsSorted()
|
||||||
|
if (!column.getCanSort()) {
|
||||||
|
return <span className={cn('text-muted-foreground text-xs font-medium', className)}>{title}</span>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground hover:text-foreground focus-visible:outline-ring -ml-1 inline-flex items-center gap-1.5 rounded-md px-1 py-0.5 text-xs font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
onClick={column.getToggleSortingHandler()}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
{sorted === 'asc' ? (
|
||||||
|
<ArrowUpIcon aria-hidden className="text-foreground size-3" />
|
||||||
|
) : sorted === 'desc' ? (
|
||||||
|
<ArrowDownIcon aria-hidden className="text-foreground size-3" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpDownIcon aria-hidden className="size-3 opacity-35" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { useState, type ReactNode } from 'react'
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { Button } from '@authportal/ui/components/button'
|
||||||
|
import { cn } from '@authportal/ui/lib/utils'
|
||||||
|
import { CodeBlock, PortalSheet, SignalAlert, StatusBadge } from './bits'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Баннер доски: заголовок и пояснение, справа «Объяснить» — открывает панель
|
||||||
|
* «Ассистент · разбор» (черновик с причинами и тем, что проверить).
|
||||||
|
*/
|
||||||
|
export function ExplainBanner({
|
||||||
|
tone,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
text,
|
||||||
|
}: {
|
||||||
|
tone: 'er' | 'wn'
|
||||||
|
title: string
|
||||||
|
/** Текст для панели разбора (то же, что в баннере). */
|
||||||
|
text: string
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className={cn(
|
||||||
|
'flex flex-wrap items-start gap-3 rounded-lg border px-3 py-2.5 text-sm',
|
||||||
|
tone === 'er' ? 'border-destructive/30 bg-destructive/10' : 'border-warning/30 bg-warning/10',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1 basis-80 text-pretty">
|
||||||
|
<b className="font-semibold">{title}</b> {children}
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setOpen(true)}>
|
||||||
|
Объяснить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<PortalSheet
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
title="Ассистент · разбор"
|
||||||
|
description="Черновик: проверьте перед применением"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
Закрыть
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false)
|
||||||
|
void navigate({
|
||||||
|
// В прод-образе preview-маршруты вырезаны (см. Dockerfile) — ведём в реальный журнал.
|
||||||
|
to: window.location.pathname.startsWith('/design-preview')
|
||||||
|
? '/design-preview/logins'
|
||||||
|
: '/admin/logins',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Открыть журнал входов
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SignalAlert tone="in" title="Что случилось">
|
||||||
|
{title} {text}
|
||||||
|
</SignalAlert>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-muted-foreground text-xs">Вероятные причины</span>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<StatusBadge tone="er">Подбор пароля</StatusBadge>
|
||||||
|
<StatusBadge tone="wn">Устаревший passkey</StatusBadge>
|
||||||
|
<StatusBadge tone="mu">Смена IP</StatusBadge>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground text-xs">Отсортированы по вероятности по журналу входов за 24 часа.</span>
|
||||||
|
</div>
|
||||||
|
<CodeBlock label="Что проверить">
|
||||||
|
{'GET /api/v1/admin/audit?kind=logins&action=auth.login_failed&range=24h\nGET /api/v1/admin/[email protected]'}
|
||||||
|
</CodeBlock>
|
||||||
|
<SignalAlert tone="wn" title="Ассистент ничего не меняет сам">
|
||||||
|
Блокировка и сброс passkeys выполняются вручную с подтверждением и попадают в журнал «Изменения».
|
||||||
|
</SignalAlert>
|
||||||
|
</PortalSheet>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
const DATE = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
|
const SHORT = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit' })
|
||||||
|
const TIME = new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
|
||||||
|
function parse(iso: string | null | undefined): Date | null {
|
||||||
|
if (!iso) return null
|
||||||
|
const d = new Date(iso)
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 12.09.2026 */
|
||||||
|
export function fmtDate(iso: string | null | undefined, empty = '—') {
|
||||||
|
const d = parse(iso)
|
||||||
|
return d ? DATE.format(d) : empty
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «сегодня 09:41», «вчера 22:15», иначе «12.09 09:41». */
|
||||||
|
export function fmtWhen(iso: string | null | undefined, empty = '—', now = Date.now()) {
|
||||||
|
const d = parse(iso)
|
||||||
|
if (!d) return empty
|
||||||
|
const dayMs = 24 * 60 * 60 * 1000
|
||||||
|
const startOfToday = new Date(now)
|
||||||
|
startOfToday.setHours(0, 0, 0, 0)
|
||||||
|
const diff = startOfToday.getTime() - new Date(d).setHours(0, 0, 0, 0)
|
||||||
|
if (diff === 0) return `сегодня ${TIME.format(d)}`
|
||||||
|
if (diff === dayMs) return `вчера ${TIME.format(d)}`
|
||||||
|
return `${SHORT.format(d)} ${TIME.format(d)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Время дня 14:02. */
|
||||||
|
export function fmtTime(iso: string | null | undefined, empty = '—') {
|
||||||
|
const d = parse(iso)
|
||||||
|
return d ? TIME.format(d) : empty
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «5 дн назад», «3 ч назад», «только что». */
|
||||||
|
export function fmtAgo(iso: string | null | undefined, empty = '—', now = Date.now()) {
|
||||||
|
const d = parse(iso)
|
||||||
|
if (!d) return empty
|
||||||
|
const sec = Math.max(0, Math.round((now - d.getTime()) / 1000))
|
||||||
|
if (sec < 60) return 'только что'
|
||||||
|
if (sec < 3600) return `${Math.floor(sec / 60)} мин назад`
|
||||||
|
if (sec < 86400) return `${Math.floor(sec / 3600)} ч назад`
|
||||||
|
return `${Math.floor(sec / 86400)} дн назад`
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
|
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||||
|
import { cn } from '@authportal/ui/lib/utils'
|
||||||
|
import type { KpiItem, KpiTone } from './kpi'
|
||||||
|
|
||||||
|
const VALUE: Record<KpiTone, string> = {
|
||||||
|
neutral: 'text-foreground',
|
||||||
|
ok: 'text-foreground',
|
||||||
|
in: 'text-foreground',
|
||||||
|
wn: 'text-warning',
|
||||||
|
er: 'text-destructive',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Колонки по числу ячеек и ширине окна (на досках — ширина приложения): 4 → 4/2/1, 3 → 3/1, 2 → 2/1. */
|
||||||
|
const COLS: Record<number, string> = {
|
||||||
|
1: 'grid-cols-1',
|
||||||
|
2: 'grid-cols-2 max-[640px]:grid-cols-1',
|
||||||
|
3: 'grid-cols-1 min-[1240px]:grid-cols-3',
|
||||||
|
4: 'grid-cols-1 min-[640px]:grid-cols-2 min-[1240px]:grid-cols-4',
|
||||||
|
}
|
||||||
|
|
||||||
|
const TILE: Record<KpiTone, string> = {
|
||||||
|
neutral: 'text-muted-foreground',
|
||||||
|
ok: 'text-success',
|
||||||
|
in: 'text-info',
|
||||||
|
wn: 'text-warning',
|
||||||
|
er: 'text-destructive',
|
||||||
|
}
|
||||||
|
|
||||||
|
const GLYPH: Record<KpiTone, string> = { neutral: '', ok: '●', in: '', wn: '▲', er: '✕' }
|
||||||
|
|
||||||
|
const BADGE = {
|
||||||
|
neutral: 'outline',
|
||||||
|
ok: 'success-light',
|
||||||
|
in: 'info-light',
|
||||||
|
wn: 'warning-light',
|
||||||
|
er: 'destructive-light',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Полоса показателей как на досках: один Frame, ячейки разделены линиями,
|
||||||
|
* в ячейке подпись, крупное значение и плашка-подсказка.
|
||||||
|
*/
|
||||||
|
export function KpiStrip({ items, 'aria-label': ariaLabel }: { items: KpiItem[]; 'aria-label'?: string }) {
|
||||||
|
return (
|
||||||
|
<Frame spacing="sm" className="w-full min-w-0" aria-label={ariaLabel}>
|
||||||
|
<FramePanel className="overflow-hidden p-0">
|
||||||
|
<div className={cn('bg-border grid gap-px', COLS[Math.min(Math.max(items.length, 1), 4)])}>
|
||||||
|
{items.map((k) => {
|
||||||
|
const Icon = k.icon
|
||||||
|
return (
|
||||||
|
<div key={k.id} className="bg-card flex min-w-0 items-center gap-3 px-[18px] py-3.5">
|
||||||
|
<IconTile variant="outline" aria-hidden className={cn('shrink-0', TILE[k.tone])}>
|
||||||
|
<Icon />
|
||||||
|
</IconTile>
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
<div className="text-muted-foreground text-[13px]">{k.label}</div>
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||||
|
<span className={cn('text-2xl font-semibold tracking-tight tabular-nums', VALUE[k.tone])}>{k.value}</span>
|
||||||
|
<Badge variant={BADGE[k.tone]} size="sm" className="whitespace-nowrap">
|
||||||
|
{GLYPH[k.tone] ? <span aria-hidden className="text-[0.55rem]">{GLYPH[k.tone]}</span> : null}
|
||||||
|
{k.hint}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
export type KpiTone = 'neutral' | 'ok' | 'wn' | 'er' | 'in'
|
||||||
|
|
||||||
|
export type KpiItem = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
/** Плашка-подсказка рядом со значением. */
|
||||||
|
hint: string
|
||||||
|
tone: KpiTone
|
||||||
|
/** Иконка показателя: плитка слева от подписи. */
|
||||||
|
icon: LucideIcon
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Плитка KPI: иконка, подпись, значение (цветом при wn/er) и плашка-подсказка. */
|
||||||
|
export function kpi(
|
||||||
|
id: string,
|
||||||
|
label: string,
|
||||||
|
value: string,
|
||||||
|
opts: { icon: LucideIcon; tone?: KpiTone; badge: string },
|
||||||
|
): KpiItem {
|
||||||
|
return { id, label, value, hint: opts.badge, tone: opts.tone ?? 'neutral', icon: opts.icon }
|
||||||
|
}
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
import { useMemo, useState, type ReactNode } from 'react'
|
||||||
|
import { Columns3Icon, MoreHorizontalIcon } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { useTable, type ColumnDef, type RowSelectionState, type SortingState } from '@tanstack/react-table'
|
||||||
|
import { DATA_GRID_RU } from '@/lib/reui-ru'
|
||||||
|
import { DataGrid, dataGridFeatures, type DataGridFeatures } from '@/components/reui/data-grid/data-grid'
|
||||||
|
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||||
|
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||||
|
import {
|
||||||
|
DataGridTableDndRowHandle,
|
||||||
|
DataGridTableDndRows,
|
||||||
|
} from '@/components/reui/data-grid/data-grid-table-dnd-rows'
|
||||||
|
import type { DragEndEvent } from '@dnd-kit/core'
|
||||||
|
import {
|
||||||
|
DataGridTable,
|
||||||
|
DataGridTableRowSelect,
|
||||||
|
DataGridTableRowSelectAll,
|
||||||
|
} from '@/components/reui/data-grid/data-grid-table'
|
||||||
|
import { Button } from '@authportal/ui/components/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@authportal/ui/components/dropdown-menu'
|
||||||
|
import { Switch } from '@authportal/ui/components/switch'
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
||||||
|
import { cn } from '@authportal/ui/lib/utils'
|
||||||
|
import { StatusBadge } from './bits'
|
||||||
|
import {
|
||||||
|
DATA_GRID_CELL_PAD,
|
||||||
|
DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
DATA_GRID_CELL_PAD_LAST,
|
||||||
|
DATA_GRID_CLASS_NAMES,
|
||||||
|
DATA_GRID_LAYOUT,
|
||||||
|
} from './data-page-layout'
|
||||||
|
import {
|
||||||
|
DataGridSortHeader,
|
||||||
|
DataPageCard,
|
||||||
|
DataPageToolbar,
|
||||||
|
} from './data-page'
|
||||||
|
import type { Tone } from './tone'
|
||||||
|
import type { RowMark } from './tone'
|
||||||
|
import { TONE_DOT } from './tone'
|
||||||
|
|
||||||
|
export type GridColumn<T> = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
/** Ширина колонки; сумма задаёт минимальную ширину таблицы. */
|
||||||
|
size: number
|
||||||
|
cell: (row: T) => ReactNode
|
||||||
|
/** Ключ сортировки; без него колонка не сортируется. */
|
||||||
|
sortValue?: (row: T) => string | number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GridView<T> = { label: string; match: (row: T) => boolean }
|
||||||
|
|
||||||
|
/** Карточка строки для режима «Карточки» (как на доске «Пользователи»). */
|
||||||
|
export type GridCard = {
|
||||||
|
name: string
|
||||||
|
sub?: string
|
||||||
|
tone: Tone
|
||||||
|
status: string
|
||||||
|
cells: Array<{ l: string; v: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Действия меню строки «⋮»: на досках — Открыть, Включить/Выключить, Дублировать, Удалить…. */
|
||||||
|
export type RowMenuConfig = {
|
||||||
|
onOpen?: () => void
|
||||||
|
toggleLabel?: string
|
||||||
|
onToggle?: () => void
|
||||||
|
onDuplicate?: () => void
|
||||||
|
onDelete?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GridBulk = {
|
||||||
|
onLabel?: string
|
||||||
|
offLabel?: string
|
||||||
|
onRun?: (ids: string[]) => void
|
||||||
|
offRun?: (ids: string[]) => void
|
||||||
|
onDelete?: (ids: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SELECT_COLUMN_SIZE = 44
|
||||||
|
const DRAG_COLUMN_SIZE = 44
|
||||||
|
const ACTIONS_COLUMN_SIZE = 120
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Секция-таблица по доскам Auth Portal: шапка (название, действия, «Таблица/Карточки», поиск, «Колонки»),
|
||||||
|
* сохранённые виды линией со счётчиками, панель массовых действий, таблица со статусом и меню строки,
|
||||||
|
* подвал «Показано N из M». Ширина таблицы не меньше суммы колонок — прокрутка внутри контейнера.
|
||||||
|
*/
|
||||||
|
export function SectionGrid<T extends object>({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
rows,
|
||||||
|
columns,
|
||||||
|
getRowId,
|
||||||
|
views,
|
||||||
|
searchText,
|
||||||
|
searchPlaceholder = 'Поиск…',
|
||||||
|
onRowClick,
|
||||||
|
selectable = true,
|
||||||
|
bulk,
|
||||||
|
tools,
|
||||||
|
card,
|
||||||
|
rowSwitch,
|
||||||
|
rowMenu,
|
||||||
|
onReorder,
|
||||||
|
rowStatus,
|
||||||
|
pageSize = 50,
|
||||||
|
emptyMessage = 'Нет записей по текущим фильтрам.',
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
rows: T[]
|
||||||
|
columns: GridColumn<T>[]
|
||||||
|
getRowId: (row: T) => string
|
||||||
|
views?: GridView<T>[]
|
||||||
|
searchText?: (row: T) => string
|
||||||
|
searchPlaceholder?: string
|
||||||
|
onRowClick?: (row: T) => void
|
||||||
|
selectable?: boolean
|
||||||
|
bulk?: GridBulk
|
||||||
|
/** Кнопки действий в шапке; функция получает id выбранных строк. */
|
||||||
|
tools?: ReactNode | ((selectedIds: string[]) => ReactNode)
|
||||||
|
/** Включает переключатель «Таблица / Карточки». */
|
||||||
|
card?: (row: T) => GridCard
|
||||||
|
/** Переключатель «включено» в последней колонке (если у строки он есть). */
|
||||||
|
rowSwitch?: { value: (row: T) => boolean | undefined; onChange: (row: T, on: boolean) => void }
|
||||||
|
rowMenu?: (row: T) => RowMenuConfig
|
||||||
|
/** Включает перетаскивание строк (dnd-kit). Передаёт id строки и id строки, на место которой её бросили. */
|
||||||
|
onReorder?: (activeId: string, overId: string) => void
|
||||||
|
/** Пометка строки: «Не сохранено» (dirty/new) или «Будет удалено» (deleted). */
|
||||||
|
rowStatus?: (row: T) => RowMark | undefined
|
||||||
|
pageSize?: number
|
||||||
|
/** Свой текст подвала вместо «Показано N из M» (как «Показано 8 из 148» на доске). */
|
||||||
|
footer?: string
|
||||||
|
emptyMessage?: string
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const storageKey = `design-preview:view:${title}`
|
||||||
|
const [view, setViewState] = useState(() => {
|
||||||
|
try {
|
||||||
|
const v = Number(window.localStorage.getItem(storageKey))
|
||||||
|
return Number.isInteger(v) && v >= 0 && v < (views?.length ?? 0) ? v : 0
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const setView = (i: number) => {
|
||||||
|
setViewState(i)
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(storageKey, String(i))
|
||||||
|
} catch {
|
||||||
|
/* без сохранения вида */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([])
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
|
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize })
|
||||||
|
const [hidden, setHidden] = useState<Record<string, boolean>>({})
|
||||||
|
const [cards, setCards] = useState(false)
|
||||||
|
|
||||||
|
const data = useMemo(() => {
|
||||||
|
const matchView = views?.[view]?.match
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
return rows.filter((row) => {
|
||||||
|
if (matchView && !matchView(row)) return false
|
||||||
|
if (q && searchText && !searchText(row).toLowerCase().includes(q)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}, [rows, views, view, query, searchText])
|
||||||
|
|
||||||
|
const visibleColumns = useMemo(() => columns.filter((c, i) => i === 0 || !hidden[c.id]), [columns, hidden])
|
||||||
|
|
||||||
|
const defs = useMemo<ColumnDef<DataGridFeatures, T>[]>(() => {
|
||||||
|
const list: ColumnDef<DataGridFeatures, T>[] = []
|
||||||
|
if (onReorder) {
|
||||||
|
list.push({
|
||||||
|
id: 'drag',
|
||||||
|
header: () => <span className="sr-only">Порядок</span>,
|
||||||
|
cell: () => <DataGridTableDndRowHandle />,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
size: DRAG_COLUMN_SIZE,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (selectable) {
|
||||||
|
list.push({
|
||||||
|
id: 'select',
|
||||||
|
header: () => <DataGridTableRowSelectAll />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
|
<DataGridTableRowSelect row={row} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
size: SELECT_COLUMN_SIZE,
|
||||||
|
meta: { headerClassName: 'pl-5 py-3', cellClassName: 'pl-5 py-3' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
visibleColumns.forEach((col, i) => {
|
||||||
|
const pad = i === 0 && !selectable && !onReorder ? DATA_GRID_CELL_PAD_FIRST : DATA_GRID_CELL_PAD
|
||||||
|
list.push({
|
||||||
|
id: col.id,
|
||||||
|
accessorFn: col.sortValue ?? (() => ''),
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title={col.title} />,
|
||||||
|
cell: ({ row }) => col.cell(row.original),
|
||||||
|
enableSorting: Boolean(col.sortValue),
|
||||||
|
enableResizing: false,
|
||||||
|
size: col.size,
|
||||||
|
meta: { headerTitle: col.title, headerClassName: pad, cellClassName: pad },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
list.push({
|
||||||
|
id: '__actions',
|
||||||
|
header: () => <span className="sr-only">Действия</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original
|
||||||
|
const on = rowSwitch?.value(r)
|
||||||
|
const menu: RowMenuConfig = rowMenu?.(r) ?? {}
|
||||||
|
const soon = (what: string) => () => toast(`${what} (макет)`)
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-end gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{on !== undefined && rowSwitch ? (
|
||||||
|
<Switch size="sm" checked={on} onCheckedChange={(v) => rowSwitch.onChange(r, v)} aria-label="Включено" />
|
||||||
|
) : null}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger render={<Button type="button" variant="ghost" size="icon" aria-label="Действия" />}>
|
||||||
|
<MoreHorizontalIcon aria-hidden />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="min-w-48">
|
||||||
|
<DropdownMenuItem onClick={menu.onOpen ?? (() => onRowClick?.(r))}>Открыть</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={menu.onToggle ?? soon('Действие')}>
|
||||||
|
{menu.toggleLabel ?? (on === undefined ? 'Повторить проверку' : on ? 'Выключить' : 'Включить')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={menu.onDuplicate ?? soon('Дублирование')}>Дублировать</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem variant="destructive" onClick={menu.onDelete ?? soon('Удаление')}>
|
||||||
|
Удалить…
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
size: ACTIONS_COLUMN_SIZE,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||||
|
})
|
||||||
|
return list
|
||||||
|
}, [visibleColumns, selectable, onReorder, rowSwitch, rowMenu, onRowClick])
|
||||||
|
|
||||||
|
const table = useTable({
|
||||||
|
features: dataGridFeatures,
|
||||||
|
columns: defs,
|
||||||
|
data,
|
||||||
|
getRowId,
|
||||||
|
state: { sorting, rowSelection, pagination },
|
||||||
|
enableRowSelection: selectable,
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
})
|
||||||
|
|
||||||
|
const dataIds = table.getRowModel().rows.map((r) => r.id)
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event
|
||||||
|
if (!onReorder || !active || !over || active.id === over.id) return
|
||||||
|
onReorder(String(active.id), String(over.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Колонки сжимаются под ширину рамки (текст переносится, как на досках); прокрутка — только на узких экранах.
|
||||||
|
const minWidth = Math.round(
|
||||||
|
visibleColumns.reduce(
|
||||||
|
(sum, c) => sum + c.size,
|
||||||
|
(selectable ? SELECT_COLUMN_SIZE : 0) + (onReorder ? DRAG_COLUMN_SIZE : 0) + ACTIONS_COLUMN_SIZE,
|
||||||
|
) * 0.7,
|
||||||
|
)
|
||||||
|
const selectedIds = table.getSelectedRowModel().rows.map((r) => r.id)
|
||||||
|
const clear = () => setRowSelection({})
|
||||||
|
const paged = data.length > pageSize
|
||||||
|
const showCards = Boolean(card) && cards
|
||||||
|
|
||||||
|
const countLabel = `${data.length} ${data.length === rows.length ? 'записей' : `из ${rows.length}`}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGrid
|
||||||
|
table={table}
|
||||||
|
i18n={DATA_GRID_RU}
|
||||||
|
recordCount={data.length}
|
||||||
|
onRowClick={onRowClick}
|
||||||
|
getRowStatus={rowStatus}
|
||||||
|
emptyMessage={emptyMessage}
|
||||||
|
tableLayout={{ ...DATA_GRID_LAYOUT, rowsDraggable: Boolean(onReorder) }}
|
||||||
|
tableClassNames={DATA_GRID_CLASS_NAMES}
|
||||||
|
>
|
||||||
|
<DataPageCard className={className}>
|
||||||
|
<h2 className="sr-only">{title}</h2>
|
||||||
|
<DataPageToolbar
|
||||||
|
segmented={
|
||||||
|
views
|
||||||
|
? {
|
||||||
|
value: String(view),
|
||||||
|
label: description,
|
||||||
|
onChange: (v) => {
|
||||||
|
setView(Number(v))
|
||||||
|
setPagination((p) => ({ ...p, pageIndex: 0 }))
|
||||||
|
},
|
||||||
|
options: views.map((v, i) => ({ value: String(i), label: v.label, count: rows.filter(v.match).length })),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
leading={
|
||||||
|
card ? (
|
||||||
|
<Tabs value={cards ? 'cards' : 'table'} onValueChange={(v) => setCards(v === 'cards')} aria-label="Вид списка">
|
||||||
|
<TabsList className="h-9! gap-0.5 p-[3px]">
|
||||||
|
<TabsTrigger value="table" className="h-7! px-2.5 text-[12.5px]">
|
||||||
|
Таблица
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="cards" className="h-7! px-2.5 text-[12.5px]">
|
||||||
|
Карточки
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
search={query}
|
||||||
|
onSearchChange={
|
||||||
|
searchText
|
||||||
|
? (v) => {
|
||||||
|
setQuery(v)
|
||||||
|
setPagination((p) => ({ ...p, pageIndex: 0 }))
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
searchPlaceholder={searchPlaceholder}
|
||||||
|
countLabel={countLabel}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
{typeof tools === 'function' ? tools(selectedIds) : tools}
|
||||||
|
{columns.length > 1 ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={<Button type="button" variant="outline" size="icon" aria-label="Выбрать колонки" title="Колонки" />}
|
||||||
|
>
|
||||||
|
<Columns3Icon aria-hidden />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="min-w-52">
|
||||||
|
{columns.slice(1).map((c) => (
|
||||||
|
<DropdownMenuCheckboxItem key={c.id} checked={!hidden[c.id]} onCheckedChange={(v) => setHidden((h) => ({ ...h, [c.id]: !v }))}>
|
||||||
|
{c.title}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{selectable && selectedIds.length > 0 ? (
|
||||||
|
<div
|
||||||
|
role="toolbar"
|
||||||
|
aria-label="Массовые действия"
|
||||||
|
className="bg-primary/10 flex flex-wrap items-center gap-2 border-b px-3.5 py-2 text-[13px]"
|
||||||
|
>
|
||||||
|
<b className="mr-1.5 font-semibold">Выбрано: {selectedIds.length}</b>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
if (bulk?.onRun) bulk.onRun(selectedIds)
|
||||||
|
else toast(`${bulk?.onLabel ?? 'Включить'}: ${selectedIds.length} (макет)`)
|
||||||
|
clear()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bulk?.onLabel ?? 'Включить'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
if (bulk?.offRun) bulk.offRun(selectedIds)
|
||||||
|
else toast(`${bulk?.offLabel ?? 'Выключить'}: ${selectedIds.length} (макет)`)
|
||||||
|
clear()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bulk?.offLabel ?? 'Выключить'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (bulk?.onDelete) bulk.onDelete(selectedIds)
|
||||||
|
else toast(`Удаление: ${selectedIds.length} (макет)`)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Удалить…
|
||||||
|
</Button>
|
||||||
|
<span className="flex-1" />
|
||||||
|
<Button type="button" variant="outline" onClick={clear}>
|
||||||
|
Снять выбор
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{showCards && card ? (
|
||||||
|
<ul className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,15rem),1fr))] gap-3 p-3">
|
||||||
|
{data.map((r) => {
|
||||||
|
const c = card(r)
|
||||||
|
const id = getRowId(r)
|
||||||
|
const marked = rowStatus?.(r)
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={id}
|
||||||
|
className={cn('bg-muted/25 flex flex-col gap-2 rounded-lg border p-3', marked === 'deleted' && 'opacity-60')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={`Выбрать ${c.name}`}
|
||||||
|
checked={Boolean(rowSelection[id])}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRowSelection((s) => {
|
||||||
|
const next = { ...s }
|
||||||
|
if (e.target.checked) next[id] = true
|
||||||
|
else delete next[id]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="accent-primary size-4"
|
||||||
|
/>
|
||||||
|
<StatusBadge tone={c.tone}>{c.status}</StatusBadge>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRowClick?.(r)}
|
||||||
|
className="flex min-w-0 items-start gap-2 text-left"
|
||||||
|
aria-label={`Открыть: ${c.name}`}
|
||||||
|
>
|
||||||
|
<span aria-hidden className={cn('mt-1.5 size-2 shrink-0 rounded-full', TONE_DOT[c.tone])} />
|
||||||
|
<span className="flex min-w-0 flex-col">
|
||||||
|
<span className="truncate text-sm font-medium">{c.name}</span>
|
||||||
|
{c.sub ? <span className="text-muted-foreground truncate font-mono text-[11px]">{c.sub}</span> : null}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<dl className="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs">
|
||||||
|
{c.cells.map((x) => (
|
||||||
|
<div key={x.l} className="min-w-0">
|
||||||
|
<dt className="text-muted-foreground">{x.l}</dt>
|
||||||
|
<dd className="truncate font-mono">{x.v}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<DataGridScrollArea>
|
||||||
|
<div style={{ minWidth }}>
|
||||||
|
{onReorder ? <DataGridTableDndRows dataIds={dataIds} handleDragEnd={handleDragEnd} /> : <DataGridTable />}
|
||||||
|
</div>
|
||||||
|
</DataGridScrollArea>
|
||||||
|
)}
|
||||||
|
{paged && !showCards ? <DataGridPagination className="px-5 py-3" sizes={[8, 16, 32]} /> : null}
|
||||||
|
</DataPageCard>
|
||||||
|
</DataGrid>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Двухстрочная ячейка: заголовок + подпись. */
|
||||||
|
export function TwoLineCell({ title, sub, dot }: { title: ReactNode; sub?: ReactNode; dot?: Tone }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 items-start gap-2">
|
||||||
|
{dot ? <span aria-hidden className={cn('mt-1.5 size-2 shrink-0 rounded-full', TONE_DOT[dot])} /> : null}
|
||||||
|
<div className="flex min-w-0 flex-col">
|
||||||
|
<span className="truncate text-sm font-medium">{title}</span>
|
||||||
|
{sub ? <span className="text-muted-foreground truncate font-mono text-[11px]">{sub}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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: 'Снять доступ' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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> },
|
|
||||||
]
|
|
||||||
@@ -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 }) {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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],
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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
-1
@@ -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,
|
||||||
})
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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'],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -29,6 +29,7 @@ services:
|
|||||||
- --providers.docker=true
|
- --providers.docker=true
|
||||||
- --providers.docker.exposedbydefault=false
|
- --providers.docker.exposedbydefault=false
|
||||||
- --providers.docker.network=auth-portal
|
- --providers.docker.network=auth-portal
|
||||||
|
- --ping=true
|
||||||
- --entrypoints.web.address=:80
|
- --entrypoints.web.address=:80
|
||||||
- --entrypoints.websecure.address=:443
|
- --entrypoints.websecure.address=:443
|
||||||
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
||||||
@@ -43,6 +44,12 @@ services:
|
|||||||
- traefik_letsencrypt:/letsencrypt
|
- traefik_letsencrypt:/letsencrypt
|
||||||
networks:
|
networks:
|
||||||
- auth-portal
|
- auth-portal
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "traefik", "healthcheck", "--ping"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
@@ -54,8 +61,23 @@ services:
|
|||||||
pull_policy: always
|
pull_policy: always
|
||||||
container_name: auth-portal
|
container_name: auth-portal
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
init: true
|
||||||
|
stop_grace_period: 15s
|
||||||
|
# Лимиты ресурсов: heap Node (NODE_OPTIONS в образе) рассчитан на 256 МиБ.
|
||||||
|
mem_limit: 256m
|
||||||
|
memswap_limit: 256m
|
||||||
|
cpus: "1.0"
|
||||||
|
pids_limit: 128
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
depends_on:
|
depends_on:
|
||||||
- traefik
|
traefik:
|
||||||
|
condition: service_healthy
|
||||||
# No host ports — only Traefik publishes 80/443.
|
# No host ports — only Traefik publishes 80/443.
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
@@ -86,14 +108,12 @@ services:
|
|||||||
- traefik.http.routers.auth-portal.tls=true
|
- traefik.http.routers.auth-portal.tls=true
|
||||||
- traefik.http.routers.auth-portal.tls.certresolver=letsencrypt
|
- traefik.http.routers.auth-portal.tls.certresolver=letsencrypt
|
||||||
- traefik.http.services.auth-portal.loadbalancer.server.port=8080
|
- traefik.http.services.auth-portal.loadbalancer.server.port=8080
|
||||||
|
# Сжатие JSON/текста на стороне Traefik (ноль CPU/RAM в Node);
|
||||||
|
# предсжатые .br/.gz статики Traefik не пересжимает (есть Content-Encoding).
|
||||||
|
- traefik.http.middlewares.auth-portal-compress.compress=true
|
||||||
|
- traefik.http.routers.auth-portal.middlewares=auth-portal-compress
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/ready"]
|
||||||
[
|
|
||||||
"CMD",
|
|
||||||
"node",
|
|
||||||
"-e",
|
|
||||||
"fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
|
||||||
]
|
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user