fix(mcp): подготовить доступ и отзыв разрешений для приёмки
This commit is contained in:
commit
ef7bb79d4e
311 files changed
+73119
No files matched your search
@@ -0,0 +1,53 @@
|
||||
# Build context = repository root (deploy/docker/docker-bake.hcl).
|
||||
.git
|
||||
.gitea
|
||||
.github
|
||||
.cursor
|
||||
.claude
|
||||
.codegraph
|
||||
.agents
|
||||
.opencode
|
||||
memory-bank
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
**/.DS_Store
|
||||
**/Thumbs.db
|
||||
**/.env
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
!**/.env.*.example
|
||||
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/.turbo
|
||||
# Генерируется `tsr generate` в `web` build (tsc идёт до Vite-плагина).
|
||||
apps/web/src/routeTree.gen.ts
|
||||
apps/web/playwright-report
|
||||
apps/web/test-results
|
||||
|
||||
*.md
|
||||
AGENTS.md
|
||||
CONTRIBUTING.md
|
||||
LICENSE
|
||||
docs
|
||||
|
||||
.pre-commit-config.yaml
|
||||
.releaserc.json
|
||||
.commitlintrc.*
|
||||
commitlint.config.cjs
|
||||
package-lock.json
|
||||
|
||||
data
|
||||
*.exe
|
||||
**/*.test.ts
|
||||
**/test/**
|
||||
**/*.spec.ts
|
||||
coverage
|
||||
.coverage
|
||||
*.out
|
||||
.release-version
|
||||
CHANGELOG.md
|
||||
deploy/docker/docker-bake.override.hcl
|
||||
deploy/compose/runtime-logs
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"reui": {
|
||||
"httpUrl": "https://mcp.reui.io/api/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${REUI_LICENSE_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
# Gitea Actions
|
||||
|
||||
| Workflow | Когда | Что |
|
||||
|----------|--------|-----|
|
||||
| [workflows/ci.yaml](workflows/ci.yaml) | pull request в main/master | quality gates + commitlint |
|
||||
| [workflows/cd.yaml](workflows/cd.yaml) | push в main/master | quality gates + semantic-release + docker push |
|
||||
| [workflows/quality.yaml](workflows/quality.yaml) | reusable (`workflow_call`) | changes, web, api, commitlint, docker-check |
|
||||
|
||||
Подробнее: [docs/releasing.md](../docs/releasing.md).
|
||||
|
||||
## CI (quality gates)
|
||||
|
||||
Job **changes** вычисляет флаги по путям в diff. Полный прогон: `.gitea/workflows/*`, `scripts/*`, корневой `package.json` / `pnpm-lock.yaml` / `.releaserc.json`. Правки `.cursor/`, `.claude/`, `*.md` quality jobs не запускают.
|
||||
|
||||
На **pull request** — **commitlint**. При изменении `deploy/docker/**` / `.dockerignore` — job **docker-check** (`bake --print`, bake без `--push` если есть доступ к registry).
|
||||
|
||||
Кэш зависимостей — нативный `actions/cache` (cache server act_runner), ключ `sha256sum` lockfile (не `hashFiles`). Пути **абсолютные** (`$HOME/.pnpm-store`): тильда `~` на Gitea часто не раскрывается и даёт вечный miss.
|
||||
|
||||
Кэшируется целиком: pnpm store + `node_modules` + corepack. При hit: `pnpm install --offline`. `HUSKY=0` в `scripts/ci/pnpm-ci.sh`.
|
||||
|
||||
Если restore пишет `connect ECONNREFUSED` / `cache server not configured` — на runner включите cache server (см. ниже). Иначе каждый job снова качает пакеты (~минуты).
|
||||
|
||||
Runner: `ubuntu-latest`, Docker для **docker-check** (PR) и **publish** (CD).
|
||||
|
||||
## CD (job publish)
|
||||
|
||||
После успешных quality gates на **push в main** job **publish**:
|
||||
|
||||
1. `pnpm exec semantic-release` — тег `vX.Y.Z` на **текущий commit** (без дополнительного commit в main).
|
||||
2. Gitea Release + `CHANGELOG.md` как attachment (не в git).
|
||||
3. Зеркало base-образов в `authportal-buildcache:base-*` (`deploy/docker/mirror-base-images.sh`; skip существующих тегов, `linux/amd64`, retry при 429).
|
||||
4. `docker buildx bake default --push` с `VERSION=X.Y.Z`, `pull=false`, named builder `authportal` (`cleanup: false`).
|
||||
|
||||
Если releasable-коммитов нет — semantic-release no-op, образы не публикуются.
|
||||
|
||||
Повтор упавшего **publish** (тег уже есть, bake нет): detect берёт `v*` на `HEAD` и всё равно пушит образы. Подробнее: [docs/releasing.md](../docs/releasing.md#перезапуск-упавшего-job-publish).
|
||||
|
||||
### Секреты
|
||||
|
||||
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Для git tag fallback: `gitea.token`. Push OCI — **только PAT** (у job token Gitea нет права packages).
|
||||
|
||||
### Теги образов
|
||||
|
||||
```text
|
||||
git.shx.one/<owner>/authportal:latest
|
||||
git.shx.one/<owner>/authportal:v1.2.3
|
||||
git.shx.one/<owner>/authportal:1.2.3
|
||||
git.shx.one/<owner>/authportal:<short-sha>
|
||||
git.shx.one/<owner>/authportal:sha-<full-sha>
|
||||
```
|
||||
|
||||
Тот же манифест публикуется как `auth-portal` (drop-in для текущего `docker pull` / Traefik compose).
|
||||
|
||||
Кэш сборки: `authportal-buildcache:node-buildcache` и `authportal-buildcache:base-*`.
|
||||
|
||||
Пример:
|
||||
|
||||
```bash
|
||||
docker pull git.shx.one/denozord/authportal:latest
|
||||
docker pull git.shx.one/denozord/auth-portal:latest
|
||||
```
|
||||
|
||||
См. [deploy/docker/README.md](../deploy/docker/README.md).
|
||||
|
||||
## act_runner: cache server
|
||||
|
||||
`actions/cache` ходит в **встроенный cache server** runner (не GitHub `type=gha`). Кэш локален для этого runner.
|
||||
|
||||
В `config.yaml` runner:
|
||||
|
||||
```yaml
|
||||
cache:
|
||||
enabled: true
|
||||
dir: "" # по умолчанию $HOME/.cache/actcache
|
||||
host: "" # IP, доступный из job-контейнера (не 0.0.0.0)
|
||||
port: 8088
|
||||
```
|
||||
|
||||
Если runner в Docker, а jobs — отдельные контейнеры: пробросьте порт и задайте `host` (LAN IP хоста) или `external_server: "http://<host>:8088/"`. Иначе restore — timeout/ECONNREFUSED и пакеты качаются снова.
|
||||
|
||||
Не делайте `docker system prune -a` по cron: сотрётся и Docker-кэш FROM, и пользы от `cleanup: false` у buildx не будет.
|
||||
@@ -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 }}-
|
||||
@@ -0,0 +1,152 @@
|
||||
name: CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
uses: ./.gitea/workflows/quality.yaml
|
||||
with:
|
||||
is_pull_request: false
|
||||
before_sha: ${{ github.event.before }}
|
||||
head_sha: ${{ github.sha }}
|
||||
allow_registry_login: false
|
||||
secrets:
|
||||
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
|
||||
|
||||
publish:
|
||||
needs: [quality]
|
||||
if: >-
|
||||
always() &&
|
||||
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
|
||||
needs.quality.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
releases: write
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
token: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||
persist-credentials: true
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Export cache paths
|
||||
run: sh scripts/ci/export-cache-env.sh
|
||||
- id: pnpm-hash
|
||||
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
- id: pnpm-cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: |
|
||||
${{ env.PNPM_STORE_DIR }}
|
||||
${{ env.COREPACK_HOME }}
|
||||
node_modules
|
||||
apps/web/node_modules
|
||||
apps/api/node_modules
|
||||
packages/ui/node_modules
|
||||
packages/shared/node_modules
|
||||
packages/db/node_modules
|
||||
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
- name: Install release tooling
|
||||
env:
|
||||
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||
run: sh scripts/ci/pnpm-ci.sh
|
||||
- name: Verify releasable commit messages
|
||||
run: pnpm exec node scripts/commit/verify-release-commits.mjs
|
||||
- name: Semantic release
|
||||
run: pnpm exec semantic-release
|
||||
env:
|
||||
GITEA_URL: https://git.shx.one
|
||||
GITEA_TOKEN: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||
- name: Detect new release
|
||||
id: rel
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version=""
|
||||
if [ -f .release-version ]; then
|
||||
version="$(tr -d '[:space:]' < .release-version)"
|
||||
echo "New release from semantic-release: $version"
|
||||
else
|
||||
git fetch --tags --force origin || true
|
||||
tag="$(git tag --points-at HEAD --list 'v*.*.*' | sort -V | tail -n1 || true)"
|
||||
if [ -n "${tag:-}" ]; then
|
||||
version="${tag#v}"
|
||||
echo "Reuse existing tag $tag on HEAD (release retry)"
|
||||
fi
|
||||
fi
|
||||
if [ -n "${version:-}" ]; then
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "released=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "released=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No releasable commits — skipping image publish"
|
||||
fi
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
with:
|
||||
name: authportal
|
||||
driver: docker-container
|
||||
cleanup: false
|
||||
- name: Prepare image metadata
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
id: meta
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "version=${{ steps.rel.outputs.version }}" >> "$GITHUB_OUTPUT"
|
||||
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||
echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT"
|
||||
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
|
||||
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
|
||||
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
- name: Log in to Gitea Registry
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
registry: git.shx.one
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.ACTIONS_PAT }}
|
||||
- name: Mirror base images into buildcache
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
env:
|
||||
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
|
||||
MIRROR_ENV_FILE: ${{ runner.temp }}/mirror-base.env
|
||||
run: sh deploy/docker/mirror-base-images.sh
|
||||
- name: Build and push images (bake)
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
env:
|
||||
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
|
||||
IMAGE_TAG: latest
|
||||
# provenance + SBOM как attestations образа (только при push в registry)
|
||||
ATTEST: "true"
|
||||
VERSION: ${{ steps.meta.outputs.version }}
|
||||
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
|
||||
SHA_FULL: ${{ github.sha }}
|
||||
BUILD_TIME: ${{ steps.meta.outputs.build_time }}
|
||||
CACHE_REF_NODE: git.shx.one/${{ steps.meta.outputs.owner_lc }}/authportal-buildcache:node-buildcache
|
||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||
MIRROR_ENV_FILE: ${{ runner.temp }}/mirror-base.env
|
||||
working-directory: deploy/docker
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
if [ -f "${MIRROR_ENV_FILE}" ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "${MIRROR_ENV_FILE}"
|
||||
set +a
|
||||
fi
|
||||
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
|
||||
-f docker-bake.hcl default --push
|
||||
@@ -0,0 +1,23 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
uses: ./.gitea/workflows/quality.yaml
|
||||
with:
|
||||
is_pull_request: true
|
||||
base_sha: ${{ github.event.pull_request.base.sha }}
|
||||
head_sha: ${{ github.event.pull_request.head.sha }}
|
||||
allow_registry_login: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
secrets:
|
||||
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
|
||||
@@ -0,0 +1,108 @@
|
||||
name: MCP isolated portal acceptance preview
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
expected_sha:
|
||||
description: Exact reviewed full commit SHA on the selected ref; includes all accepted source changes
|
||||
required: true
|
||||
type: string
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
jobs:
|
||||
portal-preview:
|
||||
if: ${{ gitea.event_name == 'workflow_dispatch' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
env:
|
||||
COMPONENT: portal
|
||||
EXPECTED_SHA: ${{ inputs.expected_sha }}
|
||||
DISPATCH_SHA: ${{ gitea.sha }}
|
||||
DISPATCH_EVENT: ${{ gitea.event_name }}
|
||||
REPOSITORY: ${{ gitea.repository }}
|
||||
RUN_ID: ${{ gitea.run_id }}
|
||||
RUN_ATTEMPT: ${{ gitea.run_attempt }}
|
||||
steps:
|
||||
- name: Checkout exact dispatch commit
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ gitea.sha }}
|
||||
persist-credentials: false
|
||||
- name: Use existing Linux Node22 build toolchain
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Verify source and isolated preview destination
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$DISPATCH_EVENT" = workflow_dispatch
|
||||
[[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]]
|
||||
test "$EXPECTED_SHA" = "$DISPATCH_SHA"
|
||||
test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
|
||||
git diff --exit-code
|
||||
git diff --cached --exit-code
|
||||
[[ "$RUN_ID" =~ ^[0-9]+$ && "$RUN_ATTEMPT" =~ ^[0-9]+$ ]]
|
||||
owner=$(printf '%s' "$REPOSITORY" | cut -d/ -f1 | tr '[:upper:]' '[:lower:]')
|
||||
name=$(printf '%s' "$REPOSITORY" | cut -d/ -f2 | tr '[:upper:]' '[:lower:]')
|
||||
[[ "$owner" =~ ^[a-z0-9][a-z0-9._-]*$ && "$name" =~ ^[a-z0-9][a-z0-9._-]*$ ]]
|
||||
stem=$(printf '%s' "$name" | sed -E 's/-[0-9]+$//')
|
||||
echo "IMAGE_NAME=git.shx.one/$owner/$stem-$COMPONENT-mcp-acceptance" >> "$GITHUB_ENV"
|
||||
echo "PREVIEW_TAG=mcp-acceptance-$EXPECTED_SHA-$RUN_ID-$RUN_ATTEMPT" >> "$GITHUB_ENV"
|
||||
echo "PREVIEW_CACHE=git.shx.one/$owner/$stem-$COMPONENT-mcp-acceptance-buildcache:node-buildcache" >> "$GITHUB_ENV"
|
||||
mkdir -p .ci/acceptance-evidence
|
||||
node <<'NODE'
|
||||
const fs=require('node:fs'),cp=require('node:child_process'),crypto=require('node:crypto');
|
||||
const files=cp.execFileSync('git',['ls-files','-z'],{encoding:'utf8'}).split('\0').filter(Boolean);
|
||||
const inputs=files.map(path=>{const info=fs.lstatSync(path);if(!info.isFile()||info.isSymbolicLink())throw Error('Regular tracked source inputs required');const data=fs.readFileSync(path);return {path,sha256:crypto.createHash('sha256').update(data).digest('hex'),bytes:data.length}});
|
||||
fs.writeFileSync('.ci/acceptance-evidence/source.json',JSON.stringify({format:'mcp-preview-source-v1',repository:process.env.REPOSITORY,sourceSha:process.env.EXPECTED_SHA,sourceTree:cp.execFileSync('git',['rev-parse','HEAD^{tree}'],{encoding:'utf8'}).trim(),platform:'linux/amd64',component:process.env.COMPONENT,runId:process.env.RUN_ID,runAttempt:process.env.RUN_ATTEMPT,authPortalOrigin:process.env.AUTH_PORTAL_ORIGIN??null,inputs},null,2)+'\n');
|
||||
NODE
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to existing Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.shx.one
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.ACTIONS_PAT }}
|
||||
- name: Build and push isolated acceptance image
|
||||
id: build
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: deploy/docker/authportal/Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ env.IMAGE_NAME }}:${{ env.PREVIEW_TAG }}
|
||||
build-args: |-
|
||||
VERSION=${{ env.PREVIEW_TAG }}
|
||||
GIT_SHA=${{ gitea.sha }}
|
||||
cache-from: type=registry,ref=${{ env.PREVIEW_CACHE }}
|
||||
cache-to: type=registry,ref=${{ env.PREVIEW_CACHE }},mode=max
|
||||
provenance: mode=min
|
||||
sbom: true
|
||||
labels: |-
|
||||
org.opencontainers.image.revision=${{ gitea.sha }}
|
||||
org.opencontainers.image.version=${{ env.PREVIEW_TAG }}
|
||||
io.mmapp.acceptance-only=true
|
||||
- name: Record exact digest and build metadata
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE_DIGEST: ${{ steps.build.outputs.digest }}
|
||||
BUILD_METADATA: ${{ steps.build.outputs.metadata }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node <<'NODE'
|
||||
const fs=require('node:fs'),crypto=require('node:crypto');
|
||||
const digest=process.env.IMAGE_DIGEST;if(!/^sha256:[a-f0-9]{64}$/.test(digest))throw Error('Exact registry digest required');
|
||||
const metadata=JSON.parse(process.env.BUILD_METADATA);const source=fs.readFileSync('.ci/acceptance-evidence/source.json');
|
||||
fs.writeFileSync('.ci/acceptance-evidence/image.json',JSON.stringify({format:'mcp-preview-image-v1',sourceSha:process.env.EXPECTED_SHA,sourceProofSha256:crypto.createHash('sha256').update(source).digest('hex'),image:process.env.IMAGE_NAME,tag:process.env.PREVIEW_TAG,digest,pinnedReference:process.env.IMAGE_NAME+'@'+digest,platform:'linux/amd64',component:process.env.COMPONENT,authPortalOrigin:process.env.AUTH_PORTAL_ORIGIN??null,buildMetadata:metadata,productionDeployed:false,wholeInstallation2GbAccepted:false},null,2)+'\n');
|
||||
console.log('Acceptance image digest: '+process.env.IMAGE_NAME+'@'+digest);
|
||||
NODE
|
||||
- name: Retain source and image binding artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: mcp-acceptance-${{ env.COMPONENT }}-${{ gitea.sha }}-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||
path: .ci/acceptance-evidence/*.json
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
@@ -0,0 +1,60 @@
|
||||
# Ночной нагрузочный smoke: образ под лимитами compose + autocannon + бюджеты p99/RPS/RSS.
|
||||
name: nightly-perf
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
load:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
- name: Build image
|
||||
working-directory: deploy/docker
|
||||
env:
|
||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl --set 'authportal.output=type=docker' --set 'authportal.tags=authportal:load' authportal
|
||||
- name: Run container with production limits
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
docker volume create load-data
|
||||
docker run --rm -v load-data:/data --user root --entrypoint sh authportal:load -c 'chown 1000:1000 /data'
|
||||
docker run -d --name authportal-load --init --memory 256m --memory-swap 256m --cpus 1.0 \
|
||||
--read-only --tmpfs /tmp --cap-drop ALL -v load-data:/data \
|
||||
-e JWT_SECRET=load-jwt-secret-0123456789 -e ADMIN_PASSWORD=load-admin-pass-1 \
|
||||
-e ADMIN_EMAIL=admin@smoke.local -e ISSUER=http://127.0.0.1:8080 \
|
||||
-e RATE_LIMIT_MAX=1000000 authportal:load
|
||||
for i in $(seq 1 40); do
|
||||
docker exec authportal-load wget -q -O /dev/null http://127.0.0.1:8080/ready && break
|
||||
sleep 0.5
|
||||
done
|
||||
# Раннер сам работает в контейнере: нагрузку гоним из соседнего контейнера
|
||||
# в сетевом namespace приложения (127.0.0.1:8080 — это приложение).
|
||||
- name: Load test (autocannon в namespace приложения)
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
docker run --rm -i --network container:authportal-load \
|
||||
-e LOAD_PASSWORD=load-admin-pass-1 -e LOAD_DURATION_S=15 \
|
||||
node:22-alpine sh -c 'mkdir /w && cd /w && npm i --silent --no-audit --no-fund autocannon@7.15.0 && cat > load.mjs && node load.mjs http://127.0.0.1:8080' \
|
||||
< scripts/perf/load.mjs
|
||||
- name: RSS после нагрузки
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
rss=$(docker stats --no-stream --format '{{.MemUsage}}' authportal-load \
|
||||
| sed -E 's/^([0-9.]+)([KMG]i?B).*/\1 \2/' \
|
||||
| awk '{u=$2; v=$1; if(u ~ /^G/) v*=1024; else if(u ~ /^K/) v/=1024; printf "%d", v}')
|
||||
echo "RSS: ${rss} MiB (budget 200)"
|
||||
[ "$rss" -le 200 ]
|
||||
- name: Container logs on failure
|
||||
if: failure()
|
||||
run: docker logs --tail 100 authportal-load || true
|
||||
@@ -0,0 +1,267 @@
|
||||
# Quality gates (reusable). Callers: ci.yaml (PR), cd.yaml (push main).
|
||||
name: quality
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
is_pull_request:
|
||||
type: boolean
|
||||
required: true
|
||||
base_sha:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
head_sha:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
before_sha:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
allow_registry_login:
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
secrets:
|
||||
ACTIONS_PAT:
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
web: ${{ steps.detect.outputs.web }}
|
||||
api: ${{ steps.detect.outputs.api }}
|
||||
docker: ${{ steps.detect.outputs.docker }}
|
||||
steps:
|
||||
- if: ${{ inputs.is_pull_request }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- if: ${{ inputs.is_pull_request == false }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
# github.event.before может быть глубже HEAD~1 при мульти-коммит пуше
|
||||
fetch-depth: 0
|
||||
- id: detect
|
||||
name: Detect changed paths per module
|
||||
env:
|
||||
IS_PR: ${{ inputs.is_pull_request }}
|
||||
BASE_SHA: ${{ inputs.base_sha }}
|
||||
HEAD_SHA: ${{ inputs.head_sha }}
|
||||
BEFORE_SHA: ${{ inputs.before_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
web=false
|
||||
api=false
|
||||
docker=false
|
||||
|
||||
set_all_flags_true() {
|
||||
web=true
|
||||
api=true
|
||||
docker=true
|
||||
}
|
||||
|
||||
write_outputs() {
|
||||
for v in web api docker; do
|
||||
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
|
||||
done
|
||||
}
|
||||
|
||||
if [ "$IS_PR" = "true" ]; then
|
||||
FILES="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")"
|
||||
else
|
||||
after="${HEAD_SHA:-$(git rev-parse HEAD)}"
|
||||
before="$BEFORE_SHA"
|
||||
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
|
||||
# before может отсутствовать в локальном клоне (история переписана) —
|
||||
# это не повод ронять пайплайн, прогоняем всё.
|
||||
if ! FILES="$(git diff --name-only "$before" "$after" 2>/dev/null)"; then
|
||||
set_all_flags_true
|
||||
write_outputs
|
||||
echo "before sha $before not available — full pipeline fallback"
|
||||
exit 0
|
||||
fi
|
||||
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
||||
FILES="$(git diff --name-only HEAD~1 HEAD)"
|
||||
else
|
||||
set_all_flags_true
|
||||
write_outputs
|
||||
echo "No parent commit — full pipeline (all modules)"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$(printf '%s' "$FILES" | tr -d '[:space:]')" ]; then
|
||||
set_all_flags_true
|
||||
write_outputs
|
||||
echo "Empty diff — full pipeline fallback"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
full_pipeline=false
|
||||
|
||||
while IFS= read -r f || [ -n "${f:-}" ]; do
|
||||
[ -z "${f:-}" ] && continue
|
||||
case "$f" in
|
||||
.gitea/workflows/*|.gitea/actions/*|scripts/*)
|
||||
full_pipeline=true
|
||||
;;
|
||||
.cursor/*|.claude/*|.codegraph/*|.agents/*)
|
||||
;;
|
||||
*.md|AGENTS.md)
|
||||
;;
|
||||
apps/web/README.md|apps/web/components.json|packages/ui/components.json)
|
||||
;;
|
||||
apps/web/*|packages/ui/*)
|
||||
web=true
|
||||
;;
|
||||
apps/api/*|packages/db/*)
|
||||
api=true
|
||||
;;
|
||||
packages/shared/*)
|
||||
web=true
|
||||
api=true
|
||||
;;
|
||||
deploy/compose/*|deploy/docker/*|.dockerignore|docker-compose.yml)
|
||||
docker=true
|
||||
;;
|
||||
docs/*)
|
||||
;;
|
||||
package.json|pnpm-lock.yaml|pnpm-workspace.yaml|turbo.json|.releaserc.json|commitlint.config.cjs)
|
||||
full_pipeline=true
|
||||
;;
|
||||
*)
|
||||
;;
|
||||
esac
|
||||
done <<< "$FILES"
|
||||
|
||||
if $full_pipeline; then
|
||||
set_all_flags_true
|
||||
fi
|
||||
|
||||
write_outputs
|
||||
|
||||
echo "Changed files (first 30):"
|
||||
printf '%s\n' "$FILES" | head -n 30
|
||||
echo "--- flags ---"
|
||||
echo "web=$web api=$api docker=$docker full_pipeline=$full_pipeline"
|
||||
|
||||
web:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.web == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- id: setup
|
||||
uses: ./.gitea/actions/setup-pnpm
|
||||
- name: pnpm install, lint, typecheck, build
|
||||
env:
|
||||
PNPM_CACHE_HIT: ${{ steps.setup.outputs.cache-hit }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
sh scripts/ci/pnpm-ci.sh
|
||||
pnpm --filter web lint
|
||||
pnpm exec turbo run typecheck --filter=web
|
||||
pnpm exec turbo run build --filter=web
|
||||
pnpm --filter web test
|
||||
node scripts/ci/web-budget.mjs apps/web/dist/assets
|
||||
|
||||
api:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.api == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- id: setup
|
||||
uses: ./.gitea/actions/setup-pnpm
|
||||
- name: pnpm install, lint, test, typecheck, build
|
||||
env:
|
||||
PNPM_CACHE_HIT: ${{ steps.setup.outputs.cache-hit }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
sh scripts/ci/pnpm-ci.sh
|
||||
pnpm exec turbo run lint --filter=@authportal/api --filter=@authportal/db --filter=@authportal/shared
|
||||
pnpm exec turbo run test:coverage --filter=@authportal/api --filter=@authportal/db --filter=@authportal/shared
|
||||
pnpm exec turbo run typecheck --filter=@authportal/api
|
||||
pnpm exec turbo run build --filter=@authportal/api
|
||||
|
||||
commitlint:
|
||||
if: inputs.is_pull_request
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: setup
|
||||
uses: ./.gitea/actions/setup-pnpm
|
||||
- name: Lint commit messages
|
||||
env:
|
||||
BASE_SHA: ${{ inputs.base_sha }}
|
||||
HEAD_SHA: ${{ inputs.head_sha }}
|
||||
PNPM_CACHE_HIT: ${{ steps.setup.outputs.cache-hit }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
sh scripts/ci/pnpm-ci.sh
|
||||
pnpm exec commitlint --from "$BASE_SHA" --to "$HEAD_SHA"
|
||||
|
||||
docker-check:
|
||||
needs: [changes]
|
||||
# Образ собирается из api/web/deploy: проверяем и на push в main (до публикации),
|
||||
# чтобы сломанный Dockerfile не обнаруживался уже после создания тега релиза.
|
||||
if: needs.changes.outputs.docker == 'true' || needs.changes.outputs.api == 'true' || needs.changes.outputs.web == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
with:
|
||||
name: authportal
|
||||
driver: docker-container
|
||||
cleanup: false
|
||||
- name: Log in to Gitea Registry
|
||||
if: inputs.allow_registry_login
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
registry: git.shx.one
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.ACTIONS_PAT }}
|
||||
- name: bake --print
|
||||
working-directory: deploy/docker
|
||||
env:
|
||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||
run: docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl --print default
|
||||
- name: bake (no push)
|
||||
if: inputs.allow_registry_login
|
||||
working-directory: deploy/docker
|
||||
env:
|
||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||
export CACHE_REF_NODE="git.shx.one/${owner_lc}/authportal-buildcache:node-buildcache"
|
||||
docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl default
|
||||
- name: build image + smoke test (ресурсы, graceful stop, сохранность данных)
|
||||
working-directory: deploy/docker
|
||||
env:
|
||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl --set 'authportal.output=type=docker' --set 'authportal.tags=authportal:smoke' authportal
|
||||
bash "${{ github.workspace }}/scripts/ci/smoke-image.sh" authportal:smoke
|
||||
- name: trivy (HIGH/CRITICAL; информативно, пайплайн не блокирует)
|
||||
continue-on-error: true
|
||||
run: |
|
||||
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:0.58.1 image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 0 authportal:smoke
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# Node / pnpm monorepo
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
.turbo/
|
||||
apps/web/dist/
|
||||
apps/web/.tanstack/
|
||||
apps/api/dist/
|
||||
packages/*/dist/
|
||||
|
||||
# Data
|
||||
data/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.local
|
||||
apps/web/.env.local
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Build
|
||||
version.txt
|
||||
# Local MCP configs (may contain REUI license Bearer)
|
||||
.cursor/mcp.json
|
||||
.mcp.json
|
||||
.codegraph/daemon.pid
|
||||
.release-version
|
||||
CHANGELOG.md
|
||||
.claude/settings.local.json
|
||||
|
||||
coverage
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"branches": ["main", "master"],
|
||||
"tagFormat": "v${version}",
|
||||
"plugins": [
|
||||
[
|
||||
"@semantic-release/commit-analyzer",
|
||||
{
|
||||
"preset": "conventionalcommits",
|
||||
"releaseRules": [
|
||||
{ "type": "feat", "release": "minor" },
|
||||
{ "type": "fix", "release": "patch" },
|
||||
{ "type": "perf", "release": "patch" },
|
||||
{ "type": "ci", "release": "patch" },
|
||||
{ "type": "refactor", "release": "patch" },
|
||||
{ "breaking": true, "release": "major" }
|
||||
]
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/release-notes-generator",
|
||||
{
|
||||
"preset": "conventionalcommits"
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/changelog",
|
||||
{
|
||||
"changelogFile": "CHANGELOG.md"
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/exec",
|
||||
{
|
||||
"successCmd": "echo ${nextRelease.version} > .release-version"
|
||||
}
|
||||
],
|
||||
[
|
||||
"@markwylde/semantic-release-gitea",
|
||||
{
|
||||
"giteaUrl": "https://git.shx.one",
|
||||
"assets": [
|
||||
{
|
||||
"path": "CHANGELOG.md",
|
||||
"label": "Changelog"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
# Auth Portal — Руководство для ИИ
|
||||
|
||||
## Стек
|
||||
|
||||
- Monorepo: `apps/web` + `apps/api` (`@authportal/api`, Fastify + SQLite/better-sqlite3) + `packages/ui` (`@authportal/ui`) + `packages/db` + `packages/shared`
|
||||
- Vite + React + TanStack + shadcn **base-nova** + ReUI Frame
|
||||
- App Switcher: CFDM / vps / bgp / fw / **dns** (Technitium)
|
||||
- Auth: JWT fragment SSO для своих SPA **и** OIDC IdP для внешних RP (Technitium)
|
||||
|
||||
## OIDC
|
||||
|
||||
- Endpoints: `/.well-known/openid-configuration`, `/.well-known/jwks.json`, `/oauth/authorize`, `/oauth/token`, `/oauth/userinfo`
|
||||
- Admin UI: `/admin/oidc`
|
||||
- Docs: [`docs/integrate-technitium.md`](docs/integrate-technitium.md)
|
||||
|
||||
## UI
|
||||
|
||||
- Hierarchy: **ReUI PRO > shadcn**
|
||||
- Surface: **frame**
|
||||
- Contract: [`docs/ui-design-contract.md`](docs/ui-design-contract.md)
|
||||
- Docs: [Introduction](https://reui.io/docs) · [llms.txt](https://reui.io/llms.txt) · [MCP](https://reui.io/docs/mcp) · [Agent Skills](https://reui.io/docs/agent-skills)
|
||||
- Skill: `.claude/skills/reui` (`68f0e6fa91`, 27 free components)
|
||||
- Rules: `.cursor/rules/reui-mcp.mdc`, `frontend-shadcn.mdc`, `frontend-ui-patterns.mdc`, `shadcn-mcp.mdc`, `shadcn-ui-production.mdc`
|
||||
- Primary MCP: `user-reui`; primitives: `plugin-shadcn-shadcn`
|
||||
- Chrome: header AppsMenu → SystemMonitorPopover; NavUser in sidebar footer (theme segmented); no ModeToggle in header
|
||||
- Kit: `KpiStatGrid`, `QuickActionGrid` (SoT markup = EvoBGP + IconTile); lists = `AdminUsersGrid` / PageShell + Frame + DataGrid
|
||||
|
||||
## License
|
||||
|
||||
`REUI_LICENSE_KEY` in `apps/web/.env.local` + Bearer in `apps/web/components.json`.
|
||||
|
||||
## Команды
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm --filter web dev
|
||||
pnpm --filter web build
|
||||
pnpm --filter @authportal/api test
|
||||
```
|
||||
|
||||
## CI / Docker
|
||||
|
||||
Gitea: `.gitea/workflows/{ci,cd,quality}.yaml`. Образы: `deploy/docker` (`node:22-alpine`, bake + registry cache). Теги `authportal` + drop-in `auth-portal`. Не корневой `Dockerfile`. Подробнее: [`docs/releasing.md`](docs/releasing.md), [`.gitea/README.md`](.gitea/README.md).
|
||||
|
||||
<!-- BEGIN:reui-agent-rules -->
|
||||
## ReUI design rules (every UI edit, follow-ups included)
|
||||
|
||||
This project uses an existing design system: ReUI on shadcn/ui. Keep its look. Generic frontend advice (vary fonts or themes, gradients, page-load or staggered reveals, "2-3 intentional motions", Framer Motion) does not apply here, and other design skills lose where they disagree.
|
||||
|
||||
1. Use installed components first (`components/ui`, `components/reui`): tables `data-grid`, filtering `filters`, boards `kanban`, modals `Dialog` or `AlertDialog`, toasts `sonner`. Never set height, radius, padding, shadow or motion on them; use `size`, `variant`, `spacing`.
|
||||
2. Color: semantic tokens. State: `<Badge variant="success-light">` from `@/components/reui/badge` at default size, or Alert variants. Raw palette only for categorical identity, with its `dark:` pair.
|
||||
3. Spacing: `gap-*` on the parent: `gap-1` to `gap-2` in a row, `gap-3` to `gap-4` between groups, `gap-6` to `gap-8` between sections. Type: `text-sm` body, `text-xs` meta, nothing smaller; never resize FrameTitle or CardTitle.
|
||||
4. Motion: installed parts already animate; never retime them. Yours: hover `transition-colors`; reveal `duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]`; transform and opacity only; never `transition-all`, bounce, or a stagger on table rows or lists of 20+; at most 1 entrance per view; `MotionConfig reducedMotion="user"` plus `motion-reduce:`.
|
||||
5. Every data region: empty state with an action, `Skeleton` loading, inline error with Retry.
|
||||
6. Before UI code, write the plan from `.agents/skills/reui/SKILL.md` in your reply. Before you finish, run its self-check on the files you changed and fix every hit (2 rounds at most). Full rules: `.agents/skills/reui/SKILL.md`. If that folder is missing, call the `reui` MCP tool `get_agent_skill`.
|
||||
|
||||
Managed by the ReUI installer. Edits between the markers are replaced on the next run.
|
||||
<!-- END:reui-agent-rules -->
|
||||
@@ -0,0 +1,84 @@
|
||||
# Auth Portal
|
||||
|
||||
Единый auth-портал для CFDM, VPS Tracker и EvoBGP (`auth.shnt.top`).
|
||||
|
||||
## Стек
|
||||
|
||||
- Monorepo: pnpm workspaces + turbo
|
||||
- `apps/web` — Vite + React 19 + TanStack Router/Query + shadcn/ui + ReUI
|
||||
- `apps/api` — Fastify 5 + JWT + SQLite (better-sqlite3 / Drizzle)
|
||||
- `packages/ui` — `@authportal/ui` (shadcn primitives)
|
||||
- `packages/shared` — Zod-контракты
|
||||
- `packages/db` — схема и клиент SQLite
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
cp .env.example .env
|
||||
# ReUI PRO key уже в apps/web/.env.local (gitignored) — или скопируйте из другого проекта
|
||||
pnpm --filter @authportal/api dev # :8080
|
||||
pnpm --filter web dev # :5175
|
||||
```
|
||||
|
||||
## Интеграция с VPS Tracker
|
||||
|
||||
См. [`docs/integrate-vps-tracker.md`](docs/integrate-vps-tracker.md) — SSO handoff (`return_to` + `#access_token`), общий `JWT_SECRET`, RBAC `vps:*`.
|
||||
|
||||
См. [`docs/integrate-cfdm.md`](docs/integrate-cfdm.md) — то же для Cloudflare Domain Manager (`cfdm:*`, порт Vite `5174`).
|
||||
|
||||
См. [`docs/integrate-evobgp.md`](docs/integrate-evobgp.md) — EvoBGP (`bgp:*`).
|
||||
См. [`docs/integrate-evofirewall.md`](docs/integrate-evofirewall.md) — EvoFirewall (`fw:*`).
|
||||
См. [`docs/integrate-cdnmanager.md`](docs/integrate-cdnmanager.md) — CDN Manager (`cdn:*`, порт Vite `5176`).
|
||||
См. [`docs/integrate-mikrotikmanager.md`](docs/integrate-mikrotikmanager.md) — MikrotikManager (`mm:*`).
|
||||
См. [`docs/integrate-technitium.md`](docs/integrate-technitium.md) — Technitium DNS (`dns:*`, OIDC IdP).
|
||||
|
||||
Корень:
|
||||
|
||||
```bash
|
||||
pnpm dev # turbo: api + web
|
||||
pnpm build
|
||||
pnpm test
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
## ReUI PRO
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
# нужен REUI_LICENSE_KEY в .env.local
|
||||
pnpm dlx shadcn@latest add @reui/auth-13 --yes
|
||||
```
|
||||
|
||||
`components.json` → registry `@reui` с `Authorization: Bearer ${REUI_LICENSE_KEY}`.
|
||||
|
||||
## Docker / Gitea CI
|
||||
|
||||
- Workflows: `.gitea/workflows/{ci,cd,quality}.yaml` — как CFDM / EvoFW
|
||||
- Релизы: [`docs/releasing.md`](docs/releasing.md)
|
||||
- Secrets: `ACTIONS_PAT` (tags, releases, Container Registry)
|
||||
- Образы alpine all-in-one: `git.shx.one/denozord/authportal` и drop-in `git.shx.one/denozord/auth-portal`
|
||||
- Bake: [`deploy/docker`](deploy/docker) (`node:22-alpine`, без Python/g++ в runtime)
|
||||
|
||||
Локально / без reverse proxy (нужен уже собранный образ или bake):
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**Production (один Compose: Traefik + auth-portal, HTTPS Cloudflare DNS challenge):**
|
||||
[`docs/deploy-traefik.md`](docs/deploy-traefik.md) · [`deploy/docker-compose.traefik.yml`](deploy/docker-compose.traefik.yml) · [`deploy/env.traefik.example`](deploy/env.traefik.example)
|
||||
|
||||
OIDC IdP (Technitium): в `.env` достаточно `ISSUER` (опционально `OIDC_ISSUER` / `OIDC_RSA_PRIVATE_KEY`); после старта — Admin → OIDC-клиенты. См. [`docs/integrate-technitium.md`](docs/integrate-technitium.md).
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
apps/web/src/ # SPA (routes, components/reui, reui-kit)
|
||||
apps/api/src/ # Fastify
|
||||
packages/ui/ # shadcn CLI output
|
||||
packages/shared/ # contracts
|
||||
packages/db/ # drizzle schema
|
||||
.gitea/workflows/ # CI/CD (ci / cd / quality)
|
||||
deploy/docker/ # bake + alpine Dockerfile
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist', 'data']),
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
extends: [js.configs.recommended, tseslint.configs.recommended],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ ignoreRestSiblings: true, argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@authportal/api",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "tsup",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"start": "node dist/server.js",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@authportal/db": "workspace:*",
|
||||
"@authportal/shared": "workspace:*",
|
||||
"@fastify/compress": "^8.3.1",
|
||||
"@fastify/cors": "^11.0.1",
|
||||
"@fastify/formbody": "^9.0.0",
|
||||
"@fastify/helmet": "^13.0.1",
|
||||
"@fastify/jwt": "^9.1.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/static": "^8.2.0",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@simplewebauthn/server": "^13.3.2",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"fastify": "^5.4.0",
|
||||
"jose": "^6.2.8",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"eslint": "catalog:",
|
||||
"globals": "catalog:",
|
||||
"tsup": "^8.5.0",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "catalog:",
|
||||
"typescript-eslint": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import type { FastifyError, FastifyInstance } from 'fastify'
|
||||
import Fastify from 'fastify'
|
||||
import compress from '@fastify/compress'
|
||||
import cors from '@fastify/cors'
|
||||
import helmet from '@fastify/helmet'
|
||||
import rateLimit from '@fastify/rate-limit'
|
||||
import formbody from '@fastify/formbody'
|
||||
import fjwt from '@fastify/jwt'
|
||||
import fastifyStatic from '@fastify/static'
|
||||
import { existsSync, mkdirSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import {
|
||||
APP_IDS,
|
||||
allPermissionKeys,
|
||||
} from '@authportal/shared'
|
||||
import {
|
||||
closeDb,
|
||||
createDb,
|
||||
getAppSwitcherConfig,
|
||||
getUserApps,
|
||||
hasAnyUser,
|
||||
listAdmins,
|
||||
healthCheck,
|
||||
migrateSchema,
|
||||
setUserAccess,
|
||||
users,
|
||||
type AppDb,
|
||||
type Sqlite,
|
||||
} from '@authportal/db'
|
||||
import { hashPassword, setPasswordConcurrency } from './lib/password.js'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
assertBootstrapPassword,
|
||||
parseTrustProxy,
|
||||
type AppConfig,
|
||||
} from './config.js'
|
||||
import { authRoutes } from './routes/auth.js'
|
||||
import { adminRoutes } from './routes/admin.js'
|
||||
import { auditAdminRoutes } from './routes/audit.js'
|
||||
import { auditIngestRoutes } from './routes/ingest-audit.js'
|
||||
import { oidcRoutes } from './routes/oidc.js'
|
||||
import { webauthnRoutes } from './routes/webauthn.js'
|
||||
import { startAuditRetentionJob } from './services/audit-retention.js'
|
||||
import { startHousekeepingJob } from './services/housekeeping.js'
|
||||
import { configurePortalRevocationSender } from './services/portal-revocations.js'
|
||||
import { startBackupJob } from './services/backup.js'
|
||||
import {
|
||||
ensureOidcSigningKey,
|
||||
isOidcKeyReady,
|
||||
resetOidcKeyCache,
|
||||
} from './lib/oidc/keys.js'
|
||||
import { createOriginChecker } from './lib/cors.js'
|
||||
|
||||
/** Пути сервера: для них SPA-fallback не применяется (404 JSON вместо index.html). */
|
||||
const API_PREFIXES = ['/api', '/oauth', '/.well-known', '/assets/']
|
||||
function boolFlag(v: string): boolean {
|
||||
return v === '1' || v.toLowerCase() === 'true'
|
||||
}
|
||||
const HASHED_ASSET_RE = /[\\/]assets[\\/]/
|
||||
const INDEX_HTML_RE = /[\\/]index\.html$/
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
config: AppConfig
|
||||
db: AppDb
|
||||
sqlite: Sqlite
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureBootstrapAdmin(app: FastifyInstance): Promise<void> {
|
||||
if (!hasAnyUser(app.db)) {
|
||||
assertBootstrapPassword(app.config)
|
||||
const now = new Date().toISOString()
|
||||
const passwordHash = await hashPassword(app.config.adminPassword)
|
||||
const id = randomUUID()
|
||||
app.db
|
||||
.insert(users)
|
||||
.values({
|
||||
id,
|
||||
email: app.config.adminEmail.toLowerCase(),
|
||||
name: app.config.adminName,
|
||||
passwordHash,
|
||||
isAdmin: true,
|
||||
disabled: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run()
|
||||
|
||||
setUserAccess(app.db, id, [...APP_IDS], allPermissionKeys())
|
||||
app.log.info(`bootstrap admin created: ${app.config.adminEmail}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure existing admins have app tiles if access was never set
|
||||
for (const user of listAdmins(app.db)) {
|
||||
const apps = getUserApps(app.db, user.id)
|
||||
if (apps.length === 0) {
|
||||
setUserAccess(app.db, user.id, [...APP_IDS], allPermissionKeys())
|
||||
app.log.info(`granted full catalog access to admin ${user.email}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildApp(opts: {
|
||||
config: AppConfig
|
||||
databaseUrl?: string
|
||||
}): Promise<FastifyInstance> {
|
||||
const config = opts.config
|
||||
const dbUrl = opts.databaseUrl ?? config.databaseUrl
|
||||
|
||||
const path = dbUrl.replace(/^sqlite:/, '')
|
||||
if (path !== ':memory:') {
|
||||
mkdirSync(dirname(resolve(path)), { recursive: true })
|
||||
}
|
||||
|
||||
setPasswordConcurrency(config.argon2Concurrency)
|
||||
const { db, sqlite } = createDb(dbUrl)
|
||||
migrateSchema(sqlite)
|
||||
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: config.logLevel,
|
||||
// Секреты не должны попадать в логи, даже если сериализатор начнёт отдавать заголовки.
|
||||
redact: {
|
||||
paths: [
|
||||
'req.headers.authorization',
|
||||
'req.headers.cookie',
|
||||
'res.headers["set-cookie"]',
|
||||
],
|
||||
censor: '[redacted]',
|
||||
},
|
||||
},
|
||||
trustProxy: parseTrustProxy(config.trustProxy),
|
||||
// keepAlive выше idle-таймаута Traefik (60 с), иначе 502 на переиспользованном соединении
|
||||
keepAliveTimeout: 65_000,
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 30_000,
|
||||
bodyLimit: 1_048_576,
|
||||
})
|
||||
|
||||
app.decorate('config', config)
|
||||
app.decorate('db', db)
|
||||
app.decorate('sqlite', sqlite)
|
||||
// Configure before bootstrap/access writes; failed opt-in must close this owned handle.
|
||||
try { configurePortalRevocationSender(app) } catch (error) { closeDb(sqlite); throw error }
|
||||
app.addHook('onClose', async () => {
|
||||
closeDb(sqlite)
|
||||
})
|
||||
|
||||
app.setErrorHandler((err: FastifyError | Error, req, reply) => {
|
||||
const status =
|
||||
err.name === 'ZodError'
|
||||
? 400
|
||||
: (err as FastifyError).statusCode && (err as FastifyError).statusCode! >= 400
|
||||
? (err as FastifyError).statusCode!
|
||||
: 500
|
||||
if (status >= 500) {
|
||||
req.log.error(err)
|
||||
return reply.status(500).send({
|
||||
error: { code: 'INTERNAL', message: 'Internal server error' },
|
||||
})
|
||||
}
|
||||
const code =
|
||||
status === 429
|
||||
? 'RATE_LIMITED'
|
||||
: status === 400
|
||||
? 'BAD_REQUEST'
|
||||
: ((err as FastifyError).code ?? 'ERROR')
|
||||
return reply.status(status).send({ error: { code, message: err.message } })
|
||||
})
|
||||
|
||||
|
||||
const originAllowed = createOriginChecker(config, () =>
|
||||
getAppSwitcherConfig(db).apps.map((a) => a.url),
|
||||
)
|
||||
if (boolFlag(config.enableCompress)) {
|
||||
// Предсжатая статика уже с Content-Encoding — плагин её не трогает.
|
||||
await app.register(compress, { threshold: 1024, encodings: ['br', 'gzip'] })
|
||||
}
|
||||
await app.register(formbody)
|
||||
await app.register(cors, {
|
||||
origin: (origin, cb) => cb(null, !origin || originAllowed(origin)),
|
||||
credentials: true,
|
||||
})
|
||||
await app.register(helmet, {
|
||||
// SPA целиком на своём origin; inline-стили нужны React/Tailwind (style-атрибуты).
|
||||
contentSecurityPolicy: {
|
||||
useDefaults: false,
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:', 'blob:'],
|
||||
fontSrc: ["'self'", 'data:'],
|
||||
connectSrc: ["'self'"],
|
||||
objectSrc: ["'none'"],
|
||||
baseUri: ["'self'"],
|
||||
frameAncestors: ["'none'"],
|
||||
},
|
||||
},
|
||||
})
|
||||
await app.register(rateLimit, {
|
||||
max: config.rateLimitMax,
|
||||
timeWindow: '1 minute',
|
||||
// пробы Docker/Traefik не должны упираться в лимит
|
||||
allowList: (req) => req.url === '/health' || req.url === '/ready',
|
||||
})
|
||||
await app.register(fjwt, { secret: config.jwtSecret })
|
||||
|
||||
// liveness: процесс жив и отвечает; БД не трогаем, чтобы пробы не рестартили
|
||||
// контейнер из-за временной блокировки.
|
||||
app.get('/health', async () => ({ ok: true }))
|
||||
|
||||
// readiness: БД отвечает и ключ подписи OIDC загружен.
|
||||
app.get('/ready', async (_request, reply) => {
|
||||
try {
|
||||
healthCheck(sqlite)
|
||||
} catch {
|
||||
return reply.status(503).send({ ok: false, reason: 'db' })
|
||||
}
|
||||
if (!isOidcKeyReady()) {
|
||||
return reply.status(503).send({ ok: false, reason: 'oidc_key' })
|
||||
}
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
app.get('/api/v1/health', async () => ({ ok: true, service: 'auth-portal' }))
|
||||
|
||||
resetOidcKeyCache()
|
||||
await ensureOidcSigningKey(app)
|
||||
await ensureBootstrapAdmin(app)
|
||||
await app.register(authRoutes)
|
||||
await app.register(adminRoutes)
|
||||
await app.register(auditAdminRoutes)
|
||||
await app.register(auditIngestRoutes)
|
||||
await app.register(oidcRoutes)
|
||||
await app.register(webauthnRoutes)
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
const stopRetention = startAuditRetentionJob(app)
|
||||
const stopHousekeeping = startHousekeepingJob(app)
|
||||
const stopBackup = startBackupJob(app)
|
||||
app.addHook('onClose', async () => {
|
||||
stopRetention()
|
||||
stopHousekeeping()
|
||||
stopBackup()
|
||||
})
|
||||
if (!config.oidcRsaPrivateKey && config.backupIntervalHours <= 0) {
|
||||
app.log.warn(
|
||||
'OIDC-ключ подписи хранится только в SQLite, а встроенный бэкап выключен: ' +
|
||||
'задайте OIDC_RSA_PRIVATE_KEY или BACKUP_INTERVAL_HOURS / запускайте `backup` по cron',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (config.staticDir && existsSync(config.staticDir)) {
|
||||
await app.register(fastifyStatic, {
|
||||
root: resolve(config.staticDir),
|
||||
wildcard: false,
|
||||
// .gz/.br для статики готовит Dockerfile на этапе сборки, здесь только отдаём.
|
||||
preCompressed: true,
|
||||
// Cache-Control выставляем сами (иначе плагин перезаписывает заголовок).
|
||||
cacheControl: false,
|
||||
setHeaders(res, filePath) {
|
||||
if (HASHED_ASSET_RE.test(filePath)) {
|
||||
// Vite кладёт хеш в имя файла → кэш на год без ревалидации.
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||||
} else if (INDEX_HTML_RE.test(filePath)) {
|
||||
// Точка входа должна ревалидироваться, иначе клиент застрянет на старых чанках.
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
} else {
|
||||
res.setHeader('Cache-Control', 'public, max-age=0')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
const spa = Boolean(config.staticDir && existsSync(config.staticDir))
|
||||
app.setNotFoundHandler((req, reply) => {
|
||||
if (
|
||||
spa &&
|
||||
req.method === 'GET' &&
|
||||
!API_PREFIXES.some((p) => req.url.startsWith(p))
|
||||
) {
|
||||
return reply.sendFile('index.html')
|
||||
}
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const boolFromEnv = (v: string | undefined, fallback: boolean) => {
|
||||
if (v === undefined || v === '') return fallback
|
||||
return v === '1' || v.toLowerCase() === 'true'
|
||||
}
|
||||
|
||||
/** Loopback + приватные сети (Docker/Traefik). */
|
||||
export const DEFAULT_TRUST_PROXY =
|
||||
'127.0.0.1/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7'
|
||||
|
||||
export function parseTrustProxy(raw: string): boolean | string[] {
|
||||
const v = raw.trim()
|
||||
if (v === 'true') return true
|
||||
if (v === 'false' || v === '') return false
|
||||
return v.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
export const configSchema = z.object({
|
||||
databaseUrl: z.string().default('sqlite:data/app.db'),
|
||||
jwtSecret: z.string().min(8),
|
||||
jwtTtlHours: z.coerce.number().positive().default(1),
|
||||
refreshTtlDays: z.coerce.number().positive().default(14),
|
||||
issuer: z.string().url().default('https://auth.shnt.top'),
|
||||
/** OIDC issuer URL; defaults to `issuer`. */
|
||||
oidcIssuer: z.string().url().optional(),
|
||||
/** Optional PEM PKCS8 RSA private key; otherwise auto-generated & persisted. */
|
||||
oidcRsaPrivateKey: z.string().optional(),
|
||||
adminEmail: z.string().email().default('admin@shnt.top'),
|
||||
adminPassword: z.string().default('admin'),
|
||||
adminName: z.string().default('Admin'),
|
||||
returnToAllowlist: z.string().default('.shnt.top,localhost,private'),
|
||||
serverPort: z.coerce.number().int().positive().default(8080),
|
||||
staticDir: z.string().optional(),
|
||||
logLevel: z.string().default('info'),
|
||||
/** Каталог бэкапов SQLite; по умолчанию <каталог БД>/backups. */
|
||||
backupDir: z.string().optional(),
|
||||
/** Сколько последних копий хранить. */
|
||||
backupKeep: z.coerce.number().int().positive().default(7),
|
||||
/** Период встроенного бэкапа в часах; 0 — выключен (используйте cron/CLI). */
|
||||
backupIntervalHours: z.coerce.number().min(0).default(0),
|
||||
/** Сжатие ответов в Node (без Traefik compress). Включайте, только если перед приложением нет сжимающего прокси. */
|
||||
enableCompress: z.string().default('false'),
|
||||
/** Глобальный лимит запросов на IP в минуту (per-route лимиты задаются отдельно). */
|
||||
rateLimitMax: z.coerce.number().int().positive().default(200),
|
||||
/** Доп. разрешённые CORS-origins через запятую ('*' — все). */
|
||||
corsOrigins: z.string().default(''),
|
||||
/** Одновременных argon2-хешей (каждый ~19 МиБ). */
|
||||
argon2Concurrency: z.coerce.number().int().positive().default(2),
|
||||
/** 'true' | 'false' | список IP/CIDR доверенных прокси. */
|
||||
trustProxy: z.string().default(DEFAULT_TRUST_PROXY),
|
||||
/** Сколько дней хранить истёкшие/отозванные сессии и коды перед удалением. */
|
||||
sessionRetentionDays: z.coerce.number().positive().default(7),
|
||||
/** Максимум на graceful shutdown, затем принудительный выход. */
|
||||
shutdownTimeoutMs: z.coerce.number().int().positive().default(8000),
|
||||
isProd: z.boolean(),
|
||||
auditIngestSecret: z.string().min(8).optional(),
|
||||
portalRevocationsEnabled: z.boolean().default(false),
|
||||
portalRevocationsTarget: z.string().optional(),
|
||||
portalRevocationsSecret: z.string().optional(),
|
||||
webauthnRpID: z.string().min(1),
|
||||
webauthnRpName: z.string().min(1).default('Auth Portal'),
|
||||
webauthnOrigins: z.array(z.string().url()).min(1),
|
||||
})
|
||||
|
||||
export type AppConfig = z.infer<typeof configSchema>
|
||||
|
||||
const WEAK_JWT_SECRETS = new Set([
|
||||
'change-me-in-production',
|
||||
'dev-secret-change-me',
|
||||
'secret',
|
||||
'changeme',
|
||||
])
|
||||
|
||||
/** В production запрещены известные дефолты JWT_SECRET; короткий секрет — только предупреждение. */
|
||||
function assertProductionSecrets(config: AppConfig): void {
|
||||
if (!config.isProd) return
|
||||
if (WEAK_JWT_SECRETS.has(config.jwtSecret)) {
|
||||
throw new Error(
|
||||
'JWT_SECRET: в production нельзя использовать значение по умолчанию — задайте уникальный секрет',
|
||||
)
|
||||
}
|
||||
if (config.jwtSecret.length < 16) {
|
||||
// Не роняем старые установки с коротким, но уникальным секретом.
|
||||
process.emitWarning(
|
||||
'JWT_SECRET короче 16 символов — смените на более длинный (смена разлогинит пользователей)',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Пароль первого администратора: в production — не дефолтный и не короче 8 символов. */
|
||||
export function assertBootstrapPassword(config: AppConfig): void {
|
||||
if (!config.isProd) return
|
||||
if (!config.adminPassword || config.adminPassword === 'admin' || config.adminPassword.length < 8) {
|
||||
throw new Error(
|
||||
'ADMIN_PASSWORD: для создания первого администратора в production задайте пароль не короче 8 символов (не "admin")',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
const isProd = env.NODE_ENV === 'production'
|
||||
const jwtSecret = env.JWT_SECRET ?? (isProd ? '' : 'dev-secret-change-me')
|
||||
const auditIngestSecret =
|
||||
env.AUDIT_INGEST_SECRET ??
|
||||
(isProd ? undefined : 'dev-audit-ingest-secret')
|
||||
const issuer = env.ISSUER ?? 'https://auth.shnt.top'
|
||||
const { rpID, origins } = webauthnFromIssuer(issuer, env, isProd)
|
||||
|
||||
const config = configSchema.parse({
|
||||
databaseUrl: env.DATABASE_URL ?? 'sqlite:data/app.db',
|
||||
jwtSecret,
|
||||
jwtTtlHours: env.JWT_TTL_HOURS ?? 1,
|
||||
refreshTtlDays: env.REFRESH_TTL_DAYS ?? 14,
|
||||
issuer,
|
||||
oidcIssuer: env.OIDC_ISSUER || issuer,
|
||||
oidcRsaPrivateKey: env.OIDC_RSA_PRIVATE_KEY || undefined,
|
||||
adminEmail: env.ADMIN_EMAIL ?? 'admin@shnt.top',
|
||||
adminPassword: env.ADMIN_PASSWORD ?? 'admin',
|
||||
adminName: env.ADMIN_NAME ?? 'Admin',
|
||||
returnToAllowlist:
|
||||
env.RETURN_TO_ALLOWLIST ?? '.shnt.top,localhost,private',
|
||||
serverPort: env.SERVER_PORT ?? 8080,
|
||||
staticDir: env.STATIC_DIR || undefined,
|
||||
logLevel: env.LOG_LEVEL ?? 'info',
|
||||
shutdownTimeoutMs: env.SHUTDOWN_TIMEOUT_MS ?? 8000,
|
||||
sessionRetentionDays: env.SESSION_RETENTION_DAYS ?? 7,
|
||||
backupDir: env.BACKUP_DIR || undefined,
|
||||
backupKeep: env.BACKUP_KEEP ?? 7,
|
||||
backupIntervalHours: env.BACKUP_INTERVAL_HOURS ?? 0,
|
||||
enableCompress: env.ENABLE_COMPRESS ?? 'false',
|
||||
rateLimitMax: env.RATE_LIMIT_MAX ?? 200,
|
||||
corsOrigins: env.CORS_ORIGINS ?? '',
|
||||
argon2Concurrency: env.ARGON2_CONCURRENCY ?? 2,
|
||||
trustProxy: env.TRUST_PROXY ?? DEFAULT_TRUST_PROXY,
|
||||
isProd:
|
||||
boolFromEnv(env.NODE_ENV === 'production' ? 'true' : undefined, false) ||
|
||||
isProd,
|
||||
auditIngestSecret,
|
||||
portalRevocationsEnabled: boolFromEnv(env.MCP_REVOCATIONS_ENABLED, false),
|
||||
portalRevocationsTarget: env.MCP_REVOCATIONS_TARGET || undefined,
|
||||
portalRevocationsSecret: env.MCP_REVOCATIONS_SECRET || undefined,
|
||||
webauthnRpID: rpID,
|
||||
webauthnRpName: env.WEBAUTHN_RP_NAME || 'Auth Portal',
|
||||
webauthnOrigins: origins,
|
||||
})
|
||||
assertProductionSecrets(config)
|
||||
return config
|
||||
}
|
||||
|
||||
function webauthnFromIssuer(
|
||||
issuer: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
isProd: boolean,
|
||||
): { rpID: string; origins: string[] } {
|
||||
let issuerUrl: URL
|
||||
try {
|
||||
issuerUrl = new URL(issuer)
|
||||
} catch {
|
||||
issuerUrl = new URL('https://auth.shnt.top')
|
||||
}
|
||||
const rpID = (env.WEBAUTHN_RP_ID || issuerUrl.hostname).trim()
|
||||
const origins = new Set<string>()
|
||||
origins.add(issuerUrl.origin)
|
||||
const extra = env.WEBAUTHN_ORIGINS ?? ''
|
||||
for (const raw of extra.split(',')) {
|
||||
const value = raw.trim().replace(/\/$/, '')
|
||||
if (!value) continue
|
||||
try {
|
||||
origins.add(new URL(value).origin)
|
||||
} catch {
|
||||
/* skip invalid */
|
||||
}
|
||||
}
|
||||
if (!isProd) {
|
||||
origins.add('http://localhost:5173')
|
||||
origins.add('http://localhost:8080')
|
||||
}
|
||||
return { rpID, origins: [...origins] }
|
||||
}
|
||||
|
||||
export function oidcIssuerFromConfig(config: AppConfig): string {
|
||||
return (config.oidcIssuer ?? config.issuer).replace(/\/$/, '')
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify'
|
||||
import {
|
||||
appendAudit,
|
||||
type AppendAuditInput,
|
||||
} from '@authportal/db'
|
||||
|
||||
export function clientIp(request: FastifyRequest): string | null {
|
||||
// request.ip учитывает X-Forwarded-For только от доверенных прокси (TRUST_PROXY).
|
||||
return request.ip ?? null
|
||||
}
|
||||
|
||||
export function safeAudit(
|
||||
app: FastifyInstance,
|
||||
input: AppendAuditInput,
|
||||
): void {
|
||||
try {
|
||||
appendAudit(app.db, input)
|
||||
} catch (err) {
|
||||
app.log.warn({ err }, 'audit_log append failed')
|
||||
}
|
||||
}
|
||||
|
||||
export function actorFromRequest(request: FastifyRequest): Pick<
|
||||
AppendAuditInput,
|
||||
'actorUserId' | 'actorEmail' | 'actorName'
|
||||
> {
|
||||
const u = request.authUser
|
||||
if (!u) {
|
||||
return {
|
||||
actorUserId: null,
|
||||
actorEmail: null,
|
||||
actorName: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
actorUserId: u.id,
|
||||
actorEmail: u.email,
|
||||
actorName: u.name,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/** Мини-кэш с TTL и вытеснением по LRU (Map сохраняет порядок вставки). */
|
||||
export class TtlCache<K, V> {
|
||||
private readonly map = new Map<K, { value: V; expires: number }>()
|
||||
|
||||
constructor(
|
||||
private readonly opts: {
|
||||
max: number
|
||||
ttlMs: number
|
||||
now?: () => number
|
||||
},
|
||||
) {}
|
||||
|
||||
private now(): number {
|
||||
return (this.opts.now ?? Date.now)()
|
||||
}
|
||||
|
||||
get(key: K): V | undefined {
|
||||
const hit = this.map.get(key)
|
||||
if (!hit) return undefined
|
||||
if (hit.expires <= this.now()) {
|
||||
this.map.delete(key)
|
||||
return undefined
|
||||
}
|
||||
// освежаем позицию для LRU
|
||||
this.map.delete(key)
|
||||
this.map.set(key, hit)
|
||||
return hit.value
|
||||
}
|
||||
|
||||
set(key: K, value: V): void {
|
||||
this.map.delete(key)
|
||||
this.map.set(key, { value, expires: this.now() + this.opts.ttlMs })
|
||||
while (this.map.size > this.opts.max) {
|
||||
const oldest = this.map.keys().next()
|
||||
if (oldest.done) break
|
||||
this.map.delete(oldest.value)
|
||||
}
|
||||
}
|
||||
|
||||
delete(key: K): void {
|
||||
this.map.delete(key)
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.map.clear()
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.map.size
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createHash, timingSafeEqual } from 'node:crypto'
|
||||
import { verifyPassword } from './password.js'
|
||||
|
||||
const PREFIX = 'sha256:'
|
||||
|
||||
/**
|
||||
* Секреты OIDC-клиентов — 32 случайных байта (высокая энтропия), поэтому
|
||||
* медленный argon2 не нужен: SHA-256 + сравнение за постоянное время.
|
||||
*/
|
||||
export function hashClientSecret(secret: string): string {
|
||||
return PREFIX + createHash('sha256').update(secret).digest('hex')
|
||||
}
|
||||
|
||||
export type ClientSecretCheck = { ok: boolean; needsRehash: boolean }
|
||||
|
||||
export async function checkClientSecret(
|
||||
stored: string,
|
||||
secret: string,
|
||||
): Promise<ClientSecretCheck> {
|
||||
if (stored.startsWith(PREFIX)) {
|
||||
const a = Buffer.from(stored)
|
||||
const b = Buffer.from(hashClientSecret(secret))
|
||||
return {
|
||||
ok: a.length === b.length && timingSafeEqual(a, b),
|
||||
needsRehash: false,
|
||||
}
|
||||
}
|
||||
// Устаревший формат (argon2): проверяем и просим перехешировать при успехе.
|
||||
try {
|
||||
const ok = await verifyPassword(stored, secret)
|
||||
return { ok, needsRehash: ok }
|
||||
} catch {
|
||||
return { ok: false, needsRehash: false }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import {
|
||||
createRefreshSession,
|
||||
touchLastLogin,
|
||||
type UserRow,
|
||||
} from '@authportal/db'
|
||||
import { issueAccessToken } from './issue-access-token.js'
|
||||
import { safeAudit } from './audit.js'
|
||||
import { targetAppFromReturnTo } from './target-app.js'
|
||||
|
||||
export const REFRESH_COOKIE = 'refresh_token'
|
||||
|
||||
export function completeLogin(
|
||||
app: FastifyInstance,
|
||||
reply: FastifyReply,
|
||||
user: UserRow,
|
||||
opts: {
|
||||
method: 'password' | 'passkey'
|
||||
returnTo?: string
|
||||
ip: string | null
|
||||
userAgent: string | null
|
||||
},
|
||||
) {
|
||||
const body = issueAccessToken(app, user)
|
||||
const refreshRaw = randomBytes(32).toString('hex')
|
||||
const refreshExpires = new Date(
|
||||
Date.now() + app.config.refreshTtlDays * 24 * 60 * 60 * 1000,
|
||||
)
|
||||
createRefreshSession(app.db, user.id, refreshRaw, refreshExpires, {
|
||||
ip: opts.ip,
|
||||
userAgent: opts.userAgent,
|
||||
})
|
||||
touchLastLogin(app.db, user.id, opts.ip)
|
||||
|
||||
reply.header(
|
||||
'Set-Cookie',
|
||||
`${REFRESH_COOKIE}=${refreshRaw}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${app.config.refreshTtlDays * 86400}${app.config.isProd ? '; Secure' : ''}`,
|
||||
)
|
||||
|
||||
const targetApp = targetAppFromReturnTo(opts.returnTo)
|
||||
const isPasskey = opts.method === 'passkey'
|
||||
safeAudit(app, {
|
||||
action: isPasskey ? 'auth.passkey_login' : 'auth.login',
|
||||
severity: 'info',
|
||||
actorUserId: user.id,
|
||||
actorEmail: user.email,
|
||||
actorName: user.name,
|
||||
targetType: 'session',
|
||||
targetId: user.id,
|
||||
summary: isPasskey
|
||||
? `Вход (passkey): ${user.email}`
|
||||
: `Вход: ${user.email}`,
|
||||
details: {
|
||||
method: opts.method,
|
||||
user_agent: opts.userAgent,
|
||||
return_to: opts.returnTo ?? null,
|
||||
target_app: targetApp,
|
||||
},
|
||||
ip: opts.ip,
|
||||
})
|
||||
|
||||
return body
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/** Значение cookie без построения RegExp на каждый запрос. */
|
||||
export function parseCookie(
|
||||
header: string | undefined,
|
||||
name: string,
|
||||
): string | null {
|
||||
if (!header) return null
|
||||
let from = 0
|
||||
while (from < header.length) {
|
||||
let end = header.indexOf(';', from)
|
||||
if (end === -1) end = header.length
|
||||
const eq = header.indexOf('=', from)
|
||||
if (eq !== -1 && eq < end) {
|
||||
const key = header.slice(from, eq).trim()
|
||||
if (key === name) {
|
||||
const raw = header.slice(eq + 1, end).trim()
|
||||
if (!raw) return null
|
||||
try {
|
||||
return decodeURIComponent(raw)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
}
|
||||
from = end + 1
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { isReturnToAllowed } from '@authportal/shared'
|
||||
import type { AppConfig } from '../config.js'
|
||||
import { oidcIssuerFromConfig } from '../config.js'
|
||||
|
||||
function originOf(url: string): string | null {
|
||||
try {
|
||||
return new URL(url).origin
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка Origin для CORS с credentials. Разрешены: CORS_ORIGINS, issuer,
|
||||
* приложения из App Switcher и всё, что разрешает RETURN_TO_ALLOWLIST
|
||||
* (та же isReturnToAllowed, что для return_to: ".suffix", localhost, полные origin, private). '*' в CORS_ORIGINS —
|
||||
* разрешить всё (только осознанно).
|
||||
*/
|
||||
export function createOriginChecker(
|
||||
config: AppConfig,
|
||||
switcherUrls: () => string[],
|
||||
): (origin: string) => boolean {
|
||||
const explicit = new Set<string>()
|
||||
let allowAll = false
|
||||
for (const raw of config.corsOrigins.split(',')) {
|
||||
const v = raw.trim()
|
||||
if (!v) continue
|
||||
if (v === '*') allowAll = true
|
||||
else {
|
||||
const o = originOf(v)
|
||||
if (o) explicit.add(o)
|
||||
}
|
||||
}
|
||||
for (const u of [config.issuer, oidcIssuerFromConfig(config)]) {
|
||||
const o = originOf(u)
|
||||
if (o) explicit.add(o)
|
||||
}
|
||||
return (origin: string) => {
|
||||
if (allowAll) return true
|
||||
const o = originOf(origin)
|
||||
if (!o) return false
|
||||
if (explicit.has(o)) return true
|
||||
// Тот же allowlist, что у return_to (.suffix, localhost, полные origin, private).
|
||||
if (isReturnToAllowed(o, config.returnToAllowlist)) return true
|
||||
for (const u of switcherUrls()) {
|
||||
if (originOf(u) === o) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import {
|
||||
allPermissionKeys,
|
||||
normalizePermissionKeys,
|
||||
tenantsClaimForUser,
|
||||
type LoginResponse,
|
||||
} from '@authportal/shared'
|
||||
import {
|
||||
getAppSwitcherConfig,
|
||||
getUserApps,
|
||||
getUserPermissions,
|
||||
type UserRow,
|
||||
} from '@authportal/db'
|
||||
import { toMe } from '../plugins/auth-guards.js'
|
||||
import { getUserProfile } from './user-profile.js'
|
||||
|
||||
/** Mint access JWT + Me from current DB state (login / reissue). */
|
||||
export function issueAccessToken(
|
||||
app: FastifyInstance,
|
||||
user: UserRow,
|
||||
): LoginResponse {
|
||||
// Профиль из кэша (инвалидируется записями в users/доступ); user — свежая строка вызывающего.
|
||||
const profile = getUserProfile(app.db, user.id)
|
||||
const apps = profile?.apps ?? getUserApps(app.db, user.id)
|
||||
const permissions = user.isAdmin
|
||||
? allPermissionKeys()
|
||||
: (profile?.permissions ??
|
||||
normalizePermissionKeys(getUserPermissions(app.db, user.id)))
|
||||
const switcher = getAppSwitcherConfig(app.db)
|
||||
const tenants = tenantsClaimForUser(switcher, apps)
|
||||
const me = toMe(user, apps, permissions)
|
||||
const expiresAt = new Date(
|
||||
Date.now() + app.config.jwtTtlHours * 60 * 60 * 1000,
|
||||
)
|
||||
const accessToken = app.jwt.sign(
|
||||
{
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
apps,
|
||||
permissions,
|
||||
tenants,
|
||||
bgp_tenant_id: tenants.bgp,
|
||||
is_admin: user.isAdmin,
|
||||
iss: app.config.issuer,
|
||||
},
|
||||
{ expiresIn: `${app.config.jwtTtlHours}h` },
|
||||
)
|
||||
return {
|
||||
access_token: accessToken,
|
||||
expires_at: expiresAt.toISOString(),
|
||||
token_type: 'Bearer',
|
||||
user: me,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import {
|
||||
exportJWK,
|
||||
exportPKCS8,
|
||||
generateKeyPair,
|
||||
importJWK,
|
||||
importPKCS8,
|
||||
jwtVerify,
|
||||
SignJWT,
|
||||
type JWK,
|
||||
} from 'jose'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import {
|
||||
getActiveOidcSigningKey,
|
||||
insertOidcSigningKey,
|
||||
listOidcSigningKeys,
|
||||
} from '@authportal/db'
|
||||
import { oidcIssuerFromConfig, type AppConfig } from '../../config.js'
|
||||
|
||||
export type OidcKeyMaterial = {
|
||||
kid: string
|
||||
privateKey: CryptoKey
|
||||
publicJwk: JWK
|
||||
}
|
||||
|
||||
let cached: OidcKeyMaterial | null = null
|
||||
// importJWK — асинхронный разбор JWK; без кэша он выполнялся на каждый /oauth/userinfo.
|
||||
const importedPublicKeys = new Map<string, CryptoKey>()
|
||||
|
||||
function publicJwkFromPrivateExport(jwk: JWK, kid: string): JWK {
|
||||
return {
|
||||
kty: jwk.kty,
|
||||
n: jwk.n,
|
||||
e: jwk.e,
|
||||
alg: 'RS256',
|
||||
use: 'sig',
|
||||
kid,
|
||||
}
|
||||
}
|
||||
|
||||
async function materialFromPem(
|
||||
kid: string,
|
||||
privatePem: string,
|
||||
publicJwkJson?: string,
|
||||
): Promise<OidcKeyMaterial> {
|
||||
// jose defaults extractable=false for private keys → exportJWK throws
|
||||
const privateKey = await importPKCS8(privatePem, 'RS256', {
|
||||
extractable: true,
|
||||
})
|
||||
|
||||
if (publicJwkJson) {
|
||||
try {
|
||||
const stored = JSON.parse(publicJwkJson) as JWK
|
||||
return {
|
||||
kid,
|
||||
privateKey,
|
||||
publicJwk: publicJwkFromPrivateExport({ ...stored, kid }, kid),
|
||||
}
|
||||
} catch {
|
||||
// fall through to derive from private key
|
||||
}
|
||||
}
|
||||
|
||||
const full = await exportJWK(privateKey)
|
||||
return {
|
||||
kid,
|
||||
privateKey,
|
||||
publicJwk: publicJwkFromPrivateExport(full, kid),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure an active RSA signing key exists (env PEM or DB / generate).
|
||||
*/
|
||||
export async function ensureOidcSigningKey(
|
||||
app: FastifyInstance,
|
||||
): Promise<OidcKeyMaterial> {
|
||||
if (cached) return cached
|
||||
|
||||
const envPem = app.config.oidcRsaPrivateKey?.trim()
|
||||
if (envPem) {
|
||||
const kid = createHash('sha256').update(envPem).digest('hex').slice(0, 16)
|
||||
cached = await materialFromPem(kid, envPem)
|
||||
return cached
|
||||
}
|
||||
|
||||
const existing = getActiveOidcSigningKey(app.db)
|
||||
if (existing) {
|
||||
cached = await materialFromPem(
|
||||
existing.kid,
|
||||
existing.privatePem,
|
||||
existing.publicJwkJson,
|
||||
)
|
||||
return cached
|
||||
}
|
||||
|
||||
const { privateKey, publicKey } = await generateKeyPair('RS256', {
|
||||
extractable: true,
|
||||
})
|
||||
const kid = randomUUID().replace(/-/g, '').slice(0, 16)
|
||||
const privatePem = await exportPKCS8(privateKey)
|
||||
const publicJwk = await exportJWK(publicKey)
|
||||
publicJwk.alg = 'RS256'
|
||||
publicJwk.use = 'sig'
|
||||
publicJwk.kid = kid
|
||||
insertOidcSigningKey(app.db, {
|
||||
kid,
|
||||
privatePem,
|
||||
publicJwkJson: JSON.stringify(publicJwk),
|
||||
})
|
||||
cached = { kid, privateKey, publicJwk }
|
||||
app.log.info({ kid }, 'OIDC RS256 signing key generated')
|
||||
return cached
|
||||
}
|
||||
|
||||
/** Ключ подписи загружен — приложение готово выдавать токены. */
|
||||
export function isOidcKeyReady(): boolean {
|
||||
return cached !== null
|
||||
}
|
||||
|
||||
/** Reset cache (tests). */
|
||||
export function resetOidcKeyCache(): void {
|
||||
cached = null
|
||||
importedPublicKeys.clear()
|
||||
jwksCache = new WeakMap()
|
||||
}
|
||||
|
||||
export function buildOidcDiscovery(config: AppConfig) {
|
||||
const issuer = oidcIssuerFromConfig(config)
|
||||
return {
|
||||
issuer,
|
||||
authorization_endpoint: `${issuer}/oauth/authorize`,
|
||||
token_endpoint: `${issuer}/oauth/token`,
|
||||
userinfo_endpoint: `${issuer}/oauth/userinfo`,
|
||||
jwks_uri: `${issuer}/.well-known/jwks.json`,
|
||||
response_types_supported: ['code'],
|
||||
subject_types_supported: ['public'],
|
||||
id_token_signing_alg_values_supported: ['RS256'],
|
||||
scopes_supported: ['openid', 'profile', 'email', 'groups'],
|
||||
token_endpoint_auth_methods_supported: [
|
||||
'client_secret_post',
|
||||
'client_secret_basic',
|
||||
],
|
||||
claims_supported: [
|
||||
'sub',
|
||||
'iss',
|
||||
'aud',
|
||||
'exp',
|
||||
'iat',
|
||||
'email',
|
||||
'email_verified',
|
||||
'name',
|
||||
'preferred_username',
|
||||
'groups',
|
||||
'roles',
|
||||
],
|
||||
grant_types_supported: ['authorization_code'],
|
||||
code_challenge_methods_supported: ['S256', 'plain'],
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildJwks(
|
||||
app: FastifyInstance,
|
||||
): Promise<{ keys: JWK[] }> {
|
||||
await ensureOidcSigningKey(app)
|
||||
const hit = jwksCache.get(app.db)
|
||||
if (hit && hit.kid === cached?.kid) return hit.jwks
|
||||
const jwks = computeJwks(app)
|
||||
jwksCache.set(app.db, { kid: cached?.kid ?? '', jwks })
|
||||
return jwks
|
||||
}
|
||||
|
||||
/** Мемоизация JWKS: набор ключей меняется только при выпуске нового (меняется kid). */
|
||||
let jwksCache = new WeakMap<object, { kid: string; jwks: { keys: JWK[] } }>()
|
||||
|
||||
function computeJwks(app: FastifyInstance): { keys: JWK[] } {
|
||||
const keys = listOidcSigningKeys(app.db)
|
||||
.map((row): JWK | null => {
|
||||
try {
|
||||
const jwk = JSON.parse(row.publicJwkJson) as JWK
|
||||
return { ...jwk, kid: row.kid, alg: 'RS256', use: 'sig' }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter((k): k is JWK => k !== null)
|
||||
|
||||
if (cached && !keys.some((k) => k.kid === cached!.kid)) {
|
||||
keys.unshift(cached.publicJwk)
|
||||
}
|
||||
return { keys }
|
||||
}
|
||||
|
||||
/** Принудительный сброс JWKS (после ручных правок ключей в БД). */
|
||||
export function invalidateJwks(app: FastifyInstance): void {
|
||||
jwksCache.delete(app.db)
|
||||
}
|
||||
|
||||
export async function signOidcJwt(
|
||||
app: FastifyInstance,
|
||||
claims: Record<string, unknown>,
|
||||
expiresInSeconds: number,
|
||||
): Promise<string> {
|
||||
const key = await ensureOidcSigningKey(app)
|
||||
const issuer = oidcIssuerFromConfig(app.config)
|
||||
return new SignJWT(claims)
|
||||
.setProtectedHeader({ alg: 'RS256', kid: key.kid, typ: 'JWT' })
|
||||
.setIssuer(issuer)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(`${expiresInSeconds}s`)
|
||||
.sign(key.privateKey)
|
||||
}
|
||||
|
||||
/** Verify OIDC access token (RS256) and return payload. */
|
||||
export async function verifyOidcAccessToken(
|
||||
app: FastifyInstance,
|
||||
token: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const key = await ensureOidcSigningKey(app)
|
||||
const issuer = oidcIssuerFromConfig(app.config)
|
||||
let publicKey: CryptoKey | undefined = importedPublicKeys.get(key.kid)
|
||||
if (!publicKey) {
|
||||
// Для RS256 + RSA JWK importJWK всегда возвращает CryptoKey.
|
||||
publicKey = (await importJWK(key.publicJwk, 'RS256')) as CryptoKey
|
||||
importedPublicKeys.set(key.kid, publicKey)
|
||||
}
|
||||
const { payload } = await jwtVerify(token, publicKey, {
|
||||
issuer,
|
||||
algorithms: ['RS256'],
|
||||
})
|
||||
return payload as Record<string, unknown>
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { hash, verify } from '@node-rs/argon2'
|
||||
|
||||
/**
|
||||
* argon2 держит ~19 МиБ на вызов и занимает поток libuv; без ограничителя
|
||||
* шквал логинов раздувает RSS. Семафор ограничивает число одновременных хешей.
|
||||
*/
|
||||
let limit = 2
|
||||
let active = 0
|
||||
const waiters: Array<() => void> = []
|
||||
|
||||
export function setPasswordConcurrency(n: number): void {
|
||||
limit = Math.max(1, Math.floor(n))
|
||||
}
|
||||
|
||||
export function passwordHashStats(): { active: number; waiting: number } {
|
||||
return { active, waiting: waiters.length }
|
||||
}
|
||||
|
||||
async function withSlot<T>(fn: () => Promise<T>): Promise<T> {
|
||||
if (active >= limit) {
|
||||
await new Promise<void>((resolve) => waiters.push(resolve))
|
||||
} else {
|
||||
active += 1
|
||||
}
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
const next = waiters.shift()
|
||||
if (next) next() // слот передаётся ожидающему без изменения active
|
||||
else active -= 1
|
||||
}
|
||||
}
|
||||
|
||||
export function hashPassword(password: string): Promise<string> {
|
||||
return withSlot(() => hash(password))
|
||||
}
|
||||
|
||||
export function verifyPassword(
|
||||
passwordHash: string,
|
||||
password: string,
|
||||
): Promise<boolean> {
|
||||
return withSlot(() => verify(passwordHash, password))
|
||||
}
|
||||
|
||||
let dummyHash: Promise<string> | null = null
|
||||
|
||||
/**
|
||||
* Для неизвестного/отключённого пользователя выполняем настоящую проверку
|
||||
* по фиктивному хешу: время ответа не выдаёт существование учётки.
|
||||
*/
|
||||
export async function verifyDummyPassword(password: string): Promise<void> {
|
||||
dummyHash ??= hash('dummy-password-for-timing')
|
||||
try {
|
||||
await withSlot(async () => verify(await dummyHash!, password))
|
||||
} catch {
|
||||
/* результат не важен */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
|
||||
export interface ShutdownOptions {
|
||||
timeoutMs: number
|
||||
exit?: (code: number) => void
|
||||
signals?: NodeJS.Signals[]
|
||||
proc?: Pick<NodeJS.Process, 'on' | 'off'>
|
||||
}
|
||||
|
||||
/**
|
||||
* SIGTERM/SIGINT → app.close() (дозавершает запросы, onClose закрывает БД),
|
||||
* принудительный выход по таймауту, повторный сигнал — немедленный выход.
|
||||
* Возвращает функцию отписки и ручной триггер (для тестов).
|
||||
*/
|
||||
export function registerShutdown(app: FastifyInstance, opts: ShutdownOptions) {
|
||||
const exit = opts.exit ?? ((code: number) => process.exit(code))
|
||||
const proc = opts.proc ?? process
|
||||
const signals = opts.signals ?? ['SIGTERM', 'SIGINT']
|
||||
let closing: Promise<void> | null = null
|
||||
|
||||
const shutdown = (reason: string, code = 0): Promise<void> => {
|
||||
if (closing) {
|
||||
app.log.warn(`shutdown: repeated ${reason}, forcing exit`)
|
||||
exit(1)
|
||||
return closing
|
||||
}
|
||||
app.log.info(`shutdown: ${reason}`)
|
||||
const timer = setTimeout(() => {
|
||||
app.log.error('shutdown: timeout, forcing exit')
|
||||
exit(1)
|
||||
}, opts.timeoutMs)
|
||||
timer.unref()
|
||||
closing = app
|
||||
.close()
|
||||
.then(() => {
|
||||
clearTimeout(timer)
|
||||
exit(code)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
clearTimeout(timer)
|
||||
app.log.error(err, 'shutdown: close failed')
|
||||
exit(1)
|
||||
})
|
||||
return closing
|
||||
}
|
||||
|
||||
const handlers = signals.map((sig) => {
|
||||
const h = () => void shutdown(sig)
|
||||
proc.on(sig, h)
|
||||
return [sig, h] as const
|
||||
})
|
||||
const onFatal = (err: unknown) => {
|
||||
app.log.error(err, 'fatal error')
|
||||
void shutdown('fatal', 1)
|
||||
}
|
||||
proc.on('unhandledRejection', onFatal)
|
||||
proc.on('uncaughtException', onFatal)
|
||||
|
||||
return {
|
||||
shutdown,
|
||||
dispose() {
|
||||
for (const [sig, h] of handlers) proc.off(sig, h)
|
||||
proc.off('unhandledRejection', onFatal)
|
||||
proc.off('uncaughtException', onFatal)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { AuditSourceApp } from '@authportal/shared'
|
||||
|
||||
/** Resolve SSO target app from return_to URL host/path (and OIDC authorize unwrap). */
|
||||
export function targetAppFromReturnTo(
|
||||
returnTo: string | undefined | null,
|
||||
): AuditSourceApp {
|
||||
if (!returnTo) return 'portal'
|
||||
let u: URL
|
||||
try {
|
||||
u = new URL(returnTo)
|
||||
} catch {
|
||||
return 'portal'
|
||||
}
|
||||
const host = u.hostname.toLowerCase()
|
||||
const path = u.pathname.toLowerCase()
|
||||
const search = u.search
|
||||
|
||||
// Portal OIDC authorize as return_to → map via client's redirect_uri
|
||||
if (path === '/oauth/authorize' || path.endsWith('/oauth/authorize')) {
|
||||
try {
|
||||
const redirectUri = new URLSearchParams(search).get('redirect_uri')
|
||||
if (redirectUri) return targetAppFromReturnTo(redirectUri)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
const hay = `${host} ${path}`
|
||||
if (/\bvps\b/.test(hay) || host.includes('vps')) return 'vps'
|
||||
if (
|
||||
/\bcfdm\b/.test(hay) ||
|
||||
host.includes('cfdm') ||
|
||||
host.includes('domain')
|
||||
) {
|
||||
return 'cfdm'
|
||||
}
|
||||
if (/\bbgp\b/.test(hay) || host.includes('bgp')) return 'bgp'
|
||||
if (
|
||||
/\bfw\b/.test(hay) ||
|
||||
host.includes('firewall') ||
|
||||
host.includes('evofw')
|
||||
) {
|
||||
return 'fw'
|
||||
}
|
||||
if (
|
||||
/\bdns\b/.test(hay) ||
|
||||
host.includes('technitium') ||
|
||||
path.includes('/sso/')
|
||||
) {
|
||||
return 'dns'
|
||||
}
|
||||
if (
|
||||
/\bcdn\b/.test(hay) ||
|
||||
host.includes('cdnmanager') ||
|
||||
host.includes('cdn-manager')
|
||||
) {
|
||||
return 'cdn'
|
||||
}
|
||||
if (
|
||||
/\bmm\b/.test(hay) ||
|
||||
host.includes('mikrotik') ||
|
||||
host.includes('mmapp')
|
||||
) {
|
||||
return 'mm'
|
||||
}
|
||||
return 'portal'
|
||||
}
|
||||
|
||||
export function clientUserAgent(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
): string | null {
|
||||
const ua = headers['user-agent']
|
||||
if (typeof ua === 'string' && ua.trim()) return ua.trim().slice(0, 512)
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
getUserApps,
|
||||
getUserById,
|
||||
getUserDataGeneration,
|
||||
getUserPermissions,
|
||||
type AppDb,
|
||||
type UserRow,
|
||||
} from '@authportal/db'
|
||||
import { allPermissionKeys, normalizePermissionKeys } from '@authportal/shared'
|
||||
import { TtlCache } from './cache.js'
|
||||
|
||||
export type UserProfile = {
|
||||
row: UserRow
|
||||
/** Сырые app_id из БД. */
|
||||
apps: string[]
|
||||
/** Нормализованные права; у админа — полный каталог. */
|
||||
permissions: string[]
|
||||
}
|
||||
|
||||
type Entry = { profile: UserProfile; generation: number }
|
||||
|
||||
/** Страховочный TTL: основная инвалидация — по счётчику поколения из @authportal/db. */
|
||||
const PROFILE_TTL_MS = 10_000
|
||||
const PROFILE_MAX = 500
|
||||
|
||||
const caches = new WeakMap<AppDb, TtlCache<string, Entry>>()
|
||||
|
||||
function cacheFor(db: AppDb): TtlCache<string, Entry> {
|
||||
let c = caches.get(db)
|
||||
if (!c) {
|
||||
c = new TtlCache({ max: PROFILE_MAX, ttlMs: PROFILE_TTL_MS })
|
||||
caches.set(db, c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
export function loadUserProfile(db: AppDb, id: string): UserProfile | undefined {
|
||||
const row = getUserById(db, id)
|
||||
if (!row) return undefined
|
||||
return profileFromRow(db, row)
|
||||
}
|
||||
|
||||
function profileFromRow(db: AppDb, row: UserRow): UserProfile {
|
||||
const apps = getUserApps(db, row.id)
|
||||
const permissions = row.isAdmin
|
||||
? allPermissionKeys()
|
||||
: normalizePermissionKeys(getUserPermissions(db, row.id))
|
||||
return { row, apps, permissions }
|
||||
}
|
||||
|
||||
/**
|
||||
* Профиль пользователя (строка + приложения + права) с кэшем: 3 SELECT на
|
||||
* запрос → 0 пока данные пользователей не менялись.
|
||||
*/
|
||||
export function getUserProfile(
|
||||
db: AppDb,
|
||||
id: string,
|
||||
): UserProfile | undefined {
|
||||
const cache = cacheFor(db)
|
||||
const generation = getUserDataGeneration()
|
||||
const hit = cache.get(id)
|
||||
if (hit && hit.generation === generation) return hit.profile
|
||||
const profile = loadUserProfile(db, id)
|
||||
if (!profile) {
|
||||
cache.delete(id)
|
||||
return undefined
|
||||
}
|
||||
cache.set(id, { profile, generation })
|
||||
return profile
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers'
|
||||
import type { AuthenticatorTransportFuture } from '@simplewebauthn/server'
|
||||
|
||||
export function webauthnRelyingParty(app: FastifyInstance): {
|
||||
rpID: string
|
||||
rpName: string
|
||||
origins: string[]
|
||||
} {
|
||||
return {
|
||||
rpID: app.config.webauthnRpID,
|
||||
rpName: app.config.webauthnRpName,
|
||||
origins: app.config.webauthnOrigins,
|
||||
}
|
||||
}
|
||||
|
||||
export function userIdToBytes(userId: string): Uint8Array<ArrayBuffer> {
|
||||
// Копия через конструктор даёт Uint8Array<ArrayBuffer>, которого требует SimpleWebAuthn v13.
|
||||
return new Uint8Array(isoUint8Array.fromUTF8String(userId))
|
||||
}
|
||||
|
||||
export function encodePublicKey(publicKey: Uint8Array<ArrayBuffer>): string {
|
||||
return isoBase64URL.fromBuffer(publicKey)
|
||||
}
|
||||
|
||||
export function decodePublicKey(stored: string): Uint8Array<ArrayBuffer> {
|
||||
return new Uint8Array(isoBase64URL.toBuffer(stored))
|
||||
}
|
||||
|
||||
export function asTransports(
|
||||
values: string[],
|
||||
): AuthenticatorTransportFuture[] | undefined {
|
||||
if (values.length === 0) return undefined
|
||||
return values as AuthenticatorTransportFuture[]
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify'
|
||||
import type { UserRow } from '@authportal/db'
|
||||
import type { AppId, MeResponse } from '@authportal/shared'
|
||||
import { APP_IDS } from '@authportal/shared'
|
||||
import { getUserProfile, type UserProfile } from '../lib/user-profile.js'
|
||||
|
||||
export type AuthUser = {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
isAdmin: boolean
|
||||
apps: AppId[]
|
||||
permissions: string[]
|
||||
}
|
||||
|
||||
declare module '@fastify/jwt' {
|
||||
interface FastifyJWT {
|
||||
payload: {
|
||||
sub: string
|
||||
email: string
|
||||
name: string
|
||||
apps: string[]
|
||||
permissions: string[]
|
||||
tenants?: Record<string, string>
|
||||
bgp_tenant_id?: string
|
||||
is_admin?: boolean
|
||||
iss: string
|
||||
}
|
||||
user: {
|
||||
sub: string
|
||||
email: string
|
||||
name: string
|
||||
apps: string[]
|
||||
permissions: string[]
|
||||
tenants?: Record<string, string>
|
||||
bgp_tenant_id?: string
|
||||
is_admin?: boolean
|
||||
iss: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
authUser?: AuthUser
|
||||
}
|
||||
}
|
||||
|
||||
function asAppIds(apps: string[]): AppId[] {
|
||||
return apps.filter((a): a is AppId =>
|
||||
(APP_IDS as readonly string[]).includes(a),
|
||||
)
|
||||
}
|
||||
|
||||
export function toMe(user: UserRow, apps: string[], permissions: string[]): MeResponse {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
is_admin: user.isAdmin,
|
||||
apps: asAppIds(apps),
|
||||
permissions,
|
||||
}
|
||||
}
|
||||
|
||||
export function authUserFromProfile(profile: UserProfile): AuthUser {
|
||||
const { row } = profile
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
isAdmin: row.isAdmin,
|
||||
apps: asAppIds(profile.apps),
|
||||
permissions: profile.permissions,
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireAuth(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await request.jwtVerify()
|
||||
} catch {
|
||||
return reply.status(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Требуется авторизация' },
|
||||
})
|
||||
}
|
||||
|
||||
const sub = request.user.sub
|
||||
const profile = getUserProfile(request.server.db, sub)
|
||||
if (!profile || profile.row.disabled) {
|
||||
return reply.status(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
|
||||
})
|
||||
}
|
||||
|
||||
request.authUser = authUserFromProfile(profile)
|
||||
}
|
||||
|
||||
export async function requireAdmin(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
): Promise<void> {
|
||||
await requireAuth(request, reply)
|
||||
if (reply.sent) return
|
||||
if (!request.authUser?.isAdmin) {
|
||||
return reply.status(403).send({
|
||||
error: { code: 'FORBIDDEN', message: 'Только для администраторов' },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { hashPassword } from '../lib/password.js'
|
||||
import { hashClientSecret } from '../lib/client-secret.js'
|
||||
import {
|
||||
countWebauthnCredentials,
|
||||
countWebauthnCredentialsBatch,
|
||||
createOidcClient,
|
||||
createUser,
|
||||
deleteOidcClient,
|
||||
deleteUser,
|
||||
deleteWebauthnCredentialsForUser,
|
||||
generateOidcClientSecret,
|
||||
getAppSwitcherConfig,
|
||||
getOidcClientById,
|
||||
getUserApps,
|
||||
getUserByEmail,
|
||||
getUserById,
|
||||
getUserPermissions,
|
||||
listActiveSessions,
|
||||
listOidcClients,
|
||||
listUserAppsBatch,
|
||||
listUserPermissionsBatch,
|
||||
listUsers,
|
||||
parseJsonStringArray,
|
||||
revokeAllSessionsForUser,
|
||||
revokeSessionById,
|
||||
setAppSwitcherConfig,
|
||||
setUserAccess,
|
||||
updateOidcClient,
|
||||
updateUser,
|
||||
withTransaction,
|
||||
} from '@authportal/db'
|
||||
import {
|
||||
APP_IDS,
|
||||
OIDC_SCOPES,
|
||||
allPermissionKeys,
|
||||
appSwitcherConfigSchema,
|
||||
createOidcClientRequestSchema,
|
||||
createUserRequestSchema,
|
||||
normalizePermissionKeys,
|
||||
patchOidcClientRequestSchema,
|
||||
patchUserRequestSchema,
|
||||
putUserAccessRequestSchema,
|
||||
type AdminUser,
|
||||
type AppId,
|
||||
type OidcClientPublic,
|
||||
} from '@authportal/shared'
|
||||
import { requireAdmin } from '../plugins/auth-guards.js'
|
||||
import { actorFromRequest, clientIp, safeAudit } from '../lib/audit.js'
|
||||
import { oidcIssuerFromConfig } from '../config.js'
|
||||
|
||||
const allowedPermissions = new Set(allPermissionKeys())
|
||||
|
||||
function toAdminUser(
|
||||
user: NonNullable<ReturnType<typeof getUserById>>,
|
||||
apps: string[],
|
||||
permissions: string[],
|
||||
passkeyCount: number,
|
||||
): AdminUser {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
is_admin: user.isAdmin,
|
||||
disabled: user.disabled,
|
||||
apps: apps.filter((a): a is AppId => (APP_IDS as readonly string[]).includes(a)),
|
||||
permissions: normalizePermissionKeys(permissions),
|
||||
last_login_at: user.lastLoginAt ?? null,
|
||||
last_login_ip: user.lastLoginIp ?? null,
|
||||
passkey_count: passkeyCount,
|
||||
created_at: user.createdAt,
|
||||
updated_at: user.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
function mapUser(
|
||||
db: FastifyInstance['db'],
|
||||
user: NonNullable<ReturnType<typeof getUserById>>,
|
||||
): AdminUser {
|
||||
return toAdminUser(
|
||||
user,
|
||||
getUserApps(db, user.id),
|
||||
getUserPermissions(db, user.id),
|
||||
countWebauthnCredentials(db, user.id),
|
||||
)
|
||||
}
|
||||
|
||||
function validateAccess(apps: string[], permissions: string[]): string | null {
|
||||
for (const app of apps) {
|
||||
if (!(APP_IDS as readonly string[]).includes(app)) {
|
||||
return `Неизвестное приложение: ${app}`
|
||||
}
|
||||
}
|
||||
for (const p of permissions) {
|
||||
if (!allowedPermissions.has(p)) {
|
||||
return `Неизвестное право: ${p}`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function mapOidcClient(
|
||||
row: NonNullable<ReturnType<typeof getOidcClientById>>,
|
||||
): OidcClientPublic {
|
||||
return {
|
||||
id: row.id,
|
||||
client_id: row.clientId,
|
||||
name: row.name,
|
||||
redirect_uris: parseJsonStringArray(row.redirectUrisJson),
|
||||
scopes: parseJsonStringArray(row.scopesJson),
|
||||
enabled: row.enabled,
|
||||
created_at: row.createdAt,
|
||||
updated_at: row.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
if (!request.url.startsWith('/api/v1/admin')) return
|
||||
await requireAdmin(request, reply)
|
||||
})
|
||||
|
||||
app.get('/api/v1/admin/users', async () => {
|
||||
// Батч-запросы вместо 3 SELECT'ов на пользователя (N+1 при N пользователях).
|
||||
const usersRows = listUsers(app.db)
|
||||
const appsByUser = listUserAppsBatch(app.db)
|
||||
const permsByUser = listUserPermissionsBatch(app.db)
|
||||
const passkeysByUser = countWebauthnCredentialsBatch(app.db)
|
||||
return usersRows.map((u) =>
|
||||
toAdminUser(
|
||||
u,
|
||||
appsByUser.get(u.id) ?? [],
|
||||
permsByUser.get(u.id) ?? [],
|
||||
passkeysByUser.get(u.id) ?? 0,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
app.get<{ Params: { id: string } }>(
|
||||
'/api/v1/admin/users/:id',
|
||||
async (request, reply) => {
|
||||
const user = getUserById(app.db, request.params.id)
|
||||
if (!user) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
|
||||
})
|
||||
}
|
||||
return mapUser(app.db, user)
|
||||
},
|
||||
)
|
||||
|
||||
app.post('/api/v1/admin/users', async (request, reply) => {
|
||||
const parsed = createUserRequestSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||
})
|
||||
}
|
||||
const data = parsed.data
|
||||
const permissions = normalizePermissionKeys(data.permissions)
|
||||
if (getUserByEmail(app.db, data.email)) {
|
||||
return reply.status(409).send({
|
||||
error: { code: 'CONFLICT', message: 'Email уже занят' },
|
||||
})
|
||||
}
|
||||
const accessError = validateAccess(data.apps, permissions)
|
||||
if (accessError) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: accessError },
|
||||
})
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(data.password)
|
||||
const user = withTransaction(app.db, () => {
|
||||
const created = createUser(app.db, {
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
passwordHash,
|
||||
isAdmin: data.is_admin,
|
||||
})
|
||||
setUserAccess(app.db, created.id, data.apps, permissions)
|
||||
return created
|
||||
})
|
||||
const mapped = mapUser(app.db, getUserById(app.db, user.id)!)
|
||||
safeAudit(app, {
|
||||
action: 'user.create',
|
||||
severity: 'info',
|
||||
...actorFromRequest(request),
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
summary: `Создан пользователь ${mapped.email}`,
|
||||
details: {
|
||||
is_admin: mapped.is_admin,
|
||||
apps: mapped.apps,
|
||||
},
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return reply.status(201).send(mapped)
|
||||
})
|
||||
|
||||
app.patch<{ Params: { id: string } }>(
|
||||
'/api/v1/admin/users/:id',
|
||||
async (request, reply) => {
|
||||
const parsed = patchUserRequestSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||
})
|
||||
}
|
||||
const existing = getUserById(app.db, request.params.id)
|
||||
if (!existing) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
|
||||
})
|
||||
}
|
||||
if (parsed.data.email && parsed.data.email !== existing.email) {
|
||||
const clash = getUserByEmail(app.db, parsed.data.email)
|
||||
if (clash) {
|
||||
return reply.status(409).send({
|
||||
error: { code: 'CONFLICT', message: 'Email уже занят' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const passwordHash = parsed.data.password
|
||||
? await hashPassword(parsed.data.password)
|
||||
: undefined
|
||||
const updated = updateUser(app.db, request.params.id, {
|
||||
email: parsed.data.email,
|
||||
name: parsed.data.name,
|
||||
passwordHash,
|
||||
isAdmin: parsed.data.is_admin,
|
||||
disabled: parsed.data.disabled,
|
||||
})
|
||||
const mapped = mapUser(app.db, updated!)
|
||||
safeAudit(app, {
|
||||
action: 'user.update',
|
||||
severity: parsed.data.disabled ? 'warning' : 'info',
|
||||
...actorFromRequest(request),
|
||||
targetType: 'user',
|
||||
targetId: mapped.id,
|
||||
summary: `Изменён пользователь ${mapped.email}`,
|
||||
details: {
|
||||
...parsed.data,
|
||||
password: parsed.data.password ? '[changed]' : undefined,
|
||||
},
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return mapped
|
||||
},
|
||||
)
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
'/api/v1/admin/users/:id/passkeys',
|
||||
async (request, reply) => {
|
||||
const existing = getUserById(app.db, request.params.id)
|
||||
if (!existing) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
|
||||
})
|
||||
}
|
||||
const removed = deleteWebauthnCredentialsForUser(app.db, existing.id)
|
||||
safeAudit(app, {
|
||||
action: 'admin.passkey_reset',
|
||||
severity: 'warning',
|
||||
...actorFromRequest(request),
|
||||
targetType: 'user',
|
||||
targetId: existing.id,
|
||||
summary: `Сброшены passkeys: ${existing.email} (${removed})`,
|
||||
details: { removed },
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return { ok: true, removed, user: mapUser(app.db, existing) }
|
||||
},
|
||||
)
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
'/api/v1/admin/users/:id',
|
||||
async (request, reply) => {
|
||||
if (request.authUser?.id === request.params.id) {
|
||||
return reply.status(400).send({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Нельзя удалить себя',
|
||||
},
|
||||
})
|
||||
}
|
||||
const existing = getUserById(app.db, request.params.id)
|
||||
if (!existing) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
|
||||
})
|
||||
}
|
||||
const ok = deleteUser(app.db, request.params.id)
|
||||
if (!ok) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
|
||||
})
|
||||
}
|
||||
safeAudit(app, {
|
||||
action: 'user.delete',
|
||||
severity: 'critical',
|
||||
...actorFromRequest(request),
|
||||
targetType: 'user',
|
||||
targetId: existing.id,
|
||||
summary: `Удалён пользователь ${existing.email}`,
|
||||
details: { email: existing.email, name: existing.name },
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return reply.status(204).send()
|
||||
},
|
||||
)
|
||||
|
||||
app.put<{ Params: { id: string } }>(
|
||||
'/api/v1/admin/users/:id/access',
|
||||
async (request, reply) => {
|
||||
const parsed = putUserAccessRequestSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||
})
|
||||
}
|
||||
const existing = getUserById(app.db, request.params.id)
|
||||
if (!existing) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
|
||||
})
|
||||
}
|
||||
const permissions = normalizePermissionKeys(parsed.data.permissions)
|
||||
const accessError = validateAccess(parsed.data.apps, permissions)
|
||||
if (accessError) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: accessError },
|
||||
})
|
||||
}
|
||||
setUserAccess(
|
||||
app.db,
|
||||
request.params.id,
|
||||
parsed.data.apps,
|
||||
permissions,
|
||||
)
|
||||
const mapped = mapUser(app.db, getUserById(app.db, request.params.id)!)
|
||||
safeAudit(app, {
|
||||
action: 'user.access_update',
|
||||
severity: 'info',
|
||||
...actorFromRequest(request),
|
||||
targetType: 'user',
|
||||
targetId: mapped.id,
|
||||
summary: `Обновлены права: ${mapped.email}`,
|
||||
details: {
|
||||
apps: mapped.apps,
|
||||
permissions_count: mapped.permissions.length,
|
||||
},
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return mapped
|
||||
},
|
||||
)
|
||||
|
||||
app.get('/api/v1/admin/app-switcher', async () =>
|
||||
getAppSwitcherConfig(app.db),
|
||||
)
|
||||
|
||||
app.put('/api/v1/admin/app-switcher', async (request, reply) => {
|
||||
const parsed = appSwitcherConfigSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||
})
|
||||
}
|
||||
const result = setAppSwitcherConfig(app.db, parsed.data)
|
||||
safeAudit(app, {
|
||||
action: 'app_switcher.update',
|
||||
severity: 'info',
|
||||
...actorFromRequest(request),
|
||||
targetType: 'settings',
|
||||
targetId: 'app_switcher',
|
||||
summary: 'Обновлены ссылки App Switcher',
|
||||
details: {
|
||||
menuLabel: result.menuLabel,
|
||||
apps: result.apps.map((a) => a.id),
|
||||
},
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return result
|
||||
})
|
||||
|
||||
app.get('/api/v1/admin/sessions', async (request) => {
|
||||
const userId = (request.query as { user_id?: string }).user_id
|
||||
return listActiveSessions(app.db, { userId }).map((s) => ({
|
||||
id: s.id,
|
||||
user_id: s.userId,
|
||||
email: s.email,
|
||||
name: s.name,
|
||||
ip: s.ip,
|
||||
user_agent: s.userAgent,
|
||||
created_at: s.createdAt,
|
||||
expires_at: s.expiresAt,
|
||||
}))
|
||||
})
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
'/api/v1/admin/sessions/:id',
|
||||
async (request, reply) => {
|
||||
const ok = revokeSessionById(app.db, request.params.id)
|
||||
if (!ok) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Сессия не найдена' },
|
||||
})
|
||||
}
|
||||
safeAudit(app, {
|
||||
action: 'auth.logout',
|
||||
severity: 'warning',
|
||||
...actorFromRequest(request),
|
||||
targetType: 'session',
|
||||
targetId: request.params.id,
|
||||
summary: `Админ отозвал сессию ${request.params.id.slice(0, 8)}`,
|
||||
details: { source: 'admin_revoke' },
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/v1/admin/users/:id/sessions/revoke-all',
|
||||
async (request, reply) => {
|
||||
const user = getUserById(app.db, request.params.id)
|
||||
if (!user) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
|
||||
})
|
||||
}
|
||||
const revoked = revokeAllSessionsForUser(app.db, user.id)
|
||||
safeAudit(app, {
|
||||
action: 'auth.logout',
|
||||
severity: 'warning',
|
||||
...actorFromRequest(request),
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
summary: `Отозваны все сессии: ${user.email} (${revoked})`,
|
||||
details: { revoked, source: 'admin_revoke_all' },
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return { revoked }
|
||||
},
|
||||
)
|
||||
|
||||
app.get('/api/v1/admin/oidc/meta', async () => {
|
||||
const issuer = oidcIssuerFromConfig(app.config)
|
||||
return {
|
||||
issuer,
|
||||
discovery_url: `${issuer}/.well-known/openid-configuration`,
|
||||
jwks_url: `${issuer}/.well-known/jwks.json`,
|
||||
scopes: [...OIDC_SCOPES],
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/v1/admin/oidc/clients', async () =>
|
||||
listOidcClients(app.db).map(mapOidcClient),
|
||||
)
|
||||
|
||||
app.post('/api/v1/admin/oidc/clients', async (request, reply) => {
|
||||
const parsed = createOidcClientRequestSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||
})
|
||||
}
|
||||
const secret = generateOidcClientSecret()
|
||||
const secretHash = hashClientSecret(secret)
|
||||
const row = createOidcClient(app.db, {
|
||||
name: parsed.data.name,
|
||||
clientSecretHash: secretHash,
|
||||
redirectUris: parsed.data.redirect_uris,
|
||||
scopes: parsed.data.scopes,
|
||||
enabled: parsed.data.enabled,
|
||||
})
|
||||
const mapped = mapOidcClient(row)
|
||||
safeAudit(app, {
|
||||
action: 'app_switcher.update',
|
||||
severity: 'info',
|
||||
...actorFromRequest(request),
|
||||
targetType: 'settings',
|
||||
targetId: row.id,
|
||||
summary: `OIDC client создан: ${row.name}`,
|
||||
details: { client_id: row.clientId },
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return { ...mapped, client_secret: secret }
|
||||
})
|
||||
|
||||
app.get<{ Params: { id: string } }>(
|
||||
'/api/v1/admin/oidc/clients/:id',
|
||||
async (request, reply) => {
|
||||
const row = getOidcClientById(app.db, request.params.id)
|
||||
if (!row) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Клиент не найден' },
|
||||
})
|
||||
}
|
||||
return mapOidcClient(row)
|
||||
},
|
||||
)
|
||||
|
||||
app.patch<{ Params: { id: string } }>(
|
||||
'/api/v1/admin/oidc/clients/:id',
|
||||
async (request, reply) => {
|
||||
const parsed = patchOidcClientRequestSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||
})
|
||||
}
|
||||
const row = updateOidcClient(app.db, request.params.id, {
|
||||
name: parsed.data.name,
|
||||
redirectUris: parsed.data.redirect_uris,
|
||||
scopes: parsed.data.scopes,
|
||||
enabled: parsed.data.enabled,
|
||||
})
|
||||
if (!row) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Клиент не найден' },
|
||||
})
|
||||
}
|
||||
return mapOidcClient(row)
|
||||
},
|
||||
)
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
'/api/v1/admin/oidc/clients/:id',
|
||||
async (request, reply) => {
|
||||
const ok = deleteOidcClient(app.db, request.params.id)
|
||||
if (!ok) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Клиент не найден' },
|
||||
})
|
||||
}
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/v1/admin/oidc/clients/:id/rotate-secret',
|
||||
async (request, reply) => {
|
||||
const existing = getOidcClientById(app.db, request.params.id)
|
||||
if (!existing) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Клиент не найден' },
|
||||
})
|
||||
}
|
||||
const secret = generateOidcClientSecret()
|
||||
const secretHash = hashClientSecret(secret)
|
||||
const row = updateOidcClient(app.db, request.params.id, {
|
||||
clientSecretHash: secretHash,
|
||||
})!
|
||||
return { ...mapOidcClient(row), client_secret: secret }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import {
|
||||
getAuditRetentionDays,
|
||||
listAudit,
|
||||
purgeAuditOlderThan,
|
||||
setAuditRetentionDays,
|
||||
} from '@authportal/db'
|
||||
import {
|
||||
auditListQuerySchema,
|
||||
putAuditSettingsSchema,
|
||||
} from '@authportal/shared'
|
||||
import { actorFromRequest, clientIp, safeAudit } from '../lib/audit.js'
|
||||
import { requireAdmin } from '../plugins/auth-guards.js'
|
||||
|
||||
export async function auditAdminRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
if (!request.url.startsWith('/api/v1/admin/audit')) return
|
||||
await requireAdmin(request, reply)
|
||||
})
|
||||
|
||||
app.get('/api/v1/admin/audit', async (request, reply) => {
|
||||
const parsed = auditListQuerySchema.safeParse(request.query)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные параметры' },
|
||||
})
|
||||
}
|
||||
const q = parsed.data
|
||||
return listAudit(app.db, {
|
||||
action: q.action,
|
||||
severity: q.severity,
|
||||
userId: q.user_id,
|
||||
actorEmail: q.actor_email,
|
||||
sourceApp: q.source_app,
|
||||
kind: q.kind,
|
||||
limit: q.limit,
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/api/v1/admin/audit/settings', async () => ({
|
||||
retention_days: getAuditRetentionDays(app.db),
|
||||
}))
|
||||
|
||||
app.put('/api/v1/admin/audit/settings', async (request, reply) => {
|
||||
const parsed = putAuditSettingsSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'retention_days: от 7 до 3650',
|
||||
},
|
||||
})
|
||||
}
|
||||
const prev = getAuditRetentionDays(app.db)
|
||||
const days = setAuditRetentionDays(app.db, parsed.data.retention_days)
|
||||
safeAudit(app, {
|
||||
action: 'audit.settings_update',
|
||||
severity: 'info',
|
||||
...actorFromRequest(request),
|
||||
targetType: 'settings',
|
||||
targetId: 'audit_retention',
|
||||
summary: `Срок хранения журнала: ${prev} → ${days} дн.`,
|
||||
details: { previous: prev, retention_days: days },
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return { retention_days: days }
|
||||
})
|
||||
|
||||
app.post('/api/v1/admin/audit/purge', async (request) => {
|
||||
const days = getAuditRetentionDays(app.db)
|
||||
const deleted = purgeAuditOlderThan(app.db, days)
|
||||
safeAudit(app, {
|
||||
action: 'audit.purge',
|
||||
severity: 'warning',
|
||||
...actorFromRequest(request),
|
||||
targetType: 'system',
|
||||
summary: `Ручная очистка: удалено ${deleted} записей старше ${days} дн.`,
|
||||
details: { deleted, retention_days: days, source: 'manual' },
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return { deleted, retention_days: days }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import {
|
||||
PERMISSION_CATALOG,
|
||||
appsMetaFromSwitcher,
|
||||
loginRequestSchema,
|
||||
publicAppSwitcherConfig,
|
||||
ssoAccessRequestSchema,
|
||||
} from '@authportal/shared'
|
||||
import {
|
||||
getAppSwitcherConfig,
|
||||
getUserByEmail,
|
||||
getUserById,
|
||||
searchDirectoryUsers,
|
||||
revokeRefreshSession,
|
||||
} from '@authportal/db'
|
||||
import { verifyDummyPassword, verifyPassword } from '../lib/password.js'
|
||||
import { parseCookie } from '../lib/cookies.js'
|
||||
import { requireAuth } from '../plugins/auth-guards.js'
|
||||
import { issueAccessToken } from '../lib/issue-access-token.js'
|
||||
import { completeLogin, REFRESH_COOKIE } from '../lib/complete-login.js'
|
||||
import { clientIp, safeAudit } from '../lib/audit.js'
|
||||
import { clientUserAgent, targetAppFromReturnTo } from '../lib/target-app.js'
|
||||
|
||||
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
/** Public — SPA reads allowlist at runtime (Docker-friendly). */
|
||||
app.get('/api/v1/auth/config', async (_request, reply) => {
|
||||
reply.header('Cache-Control', 'public, max-age=60')
|
||||
return {
|
||||
return_to_allowlist: app.config.returnToAllowlist,
|
||||
issuer: app.config.issuer,
|
||||
webauthn: true,
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/v1/auth/login', {
|
||||
config: { rateLimit: { max: 20, timeWindow: '1 minute' } },
|
||||
handler: async (request, reply) => {
|
||||
const parsed = loginRequestSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||
})
|
||||
}
|
||||
|
||||
const { email, password, return_to: returnTo } = parsed.data
|
||||
const ip = clientIp(request)
|
||||
const userAgent = clientUserAgent(request.headers)
|
||||
const targetApp = targetAppFromReturnTo(returnTo)
|
||||
const user = getUserByEmail(app.db, email)
|
||||
if (!user || user.disabled) {
|
||||
await verifyDummyPassword(password)
|
||||
safeAudit(app, {
|
||||
action: 'auth.login_failed',
|
||||
severity: 'warning',
|
||||
actorEmail: email.toLowerCase(),
|
||||
targetType: 'session',
|
||||
summary: `Неудачный вход: ${email}`,
|
||||
details: {
|
||||
reason: !user ? 'unknown_user' : 'disabled',
|
||||
user_agent: userAgent,
|
||||
return_to: returnTo ?? null,
|
||||
target_app: targetApp,
|
||||
},
|
||||
ip,
|
||||
})
|
||||
return reply.status(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Неверный email или пароль' },
|
||||
})
|
||||
}
|
||||
|
||||
const ok = await verifyPassword(user.passwordHash, password)
|
||||
if (!ok) {
|
||||
safeAudit(app, {
|
||||
action: 'auth.login_failed',
|
||||
severity: 'warning',
|
||||
actorUserId: user.id,
|
||||
actorEmail: user.email,
|
||||
actorName: user.name,
|
||||
targetType: 'session',
|
||||
targetId: user.id,
|
||||
summary: `Неудачный вход: ${user.email}`,
|
||||
details: {
|
||||
reason: 'bad_password',
|
||||
user_agent: userAgent,
|
||||
return_to: returnTo ?? null,
|
||||
target_app: targetApp,
|
||||
},
|
||||
ip,
|
||||
})
|
||||
return reply.status(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Неверный email или пароль' },
|
||||
})
|
||||
}
|
||||
|
||||
return completeLogin(app, reply, user, {
|
||||
method: 'password',
|
||||
returnTo,
|
||||
ip,
|
||||
userAgent,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Re-mint access JWT from DB (apps/permissions/is_admin/tenants).
|
||||
* Fixes stale localStorage JWT after role changes or account switch.
|
||||
*/
|
||||
app.post(
|
||||
'/api/v1/auth/reissue',
|
||||
{
|
||||
onRequest: requireAuth,
|
||||
config: { rateLimit: { max: 60, timeWindow: '1 minute' } },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const auth = request.authUser!
|
||||
const user = getUserById(app.db, auth.id)
|
||||
if (!user || user.disabled) {
|
||||
return reply.status(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
|
||||
})
|
||||
}
|
||||
return issueAccessToken(app, user)
|
||||
},
|
||||
)
|
||||
|
||||
/** Record SSO handoff to a connected app (already authenticated). */
|
||||
app.post(
|
||||
'/api/v1/auth/sso-access',
|
||||
{
|
||||
onRequest: requireAuth,
|
||||
config: { rateLimit: { max: 60, timeWindow: '1 minute' } },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const parsed = ssoAccessRequestSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||
})
|
||||
}
|
||||
const auth = request.authUser!
|
||||
const user = getUserById(app.db, auth.id)
|
||||
if (!user || user.disabled) {
|
||||
return reply.status(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
|
||||
})
|
||||
}
|
||||
const returnTo = parsed.data.return_to
|
||||
const targetApp = targetAppFromReturnTo(returnTo)
|
||||
const ip = clientIp(request)
|
||||
const userAgent = clientUserAgent(request.headers)
|
||||
safeAudit(app, {
|
||||
action: 'auth.sso_handoff',
|
||||
severity: 'info',
|
||||
actorUserId: user.id,
|
||||
actorEmail: user.email,
|
||||
actorName: user.name,
|
||||
targetType: 'session',
|
||||
targetId: user.id,
|
||||
summary: `SSO: ${user.email} → ${targetApp}`,
|
||||
details: {
|
||||
return_to: returnTo,
|
||||
target_app: targetApp,
|
||||
user_agent: userAgent,
|
||||
},
|
||||
ip,
|
||||
})
|
||||
return { ok: true, target_app: targetApp }
|
||||
},
|
||||
)
|
||||
|
||||
app.post(
|
||||
'/api/v1/auth/logout',
|
||||
{ config: { rateLimit: { max: 60, timeWindow: '1 minute' } } },
|
||||
async (request, reply) => {
|
||||
const refresh = parseCookie(request.headers.cookie, REFRESH_COOKIE)
|
||||
if (refresh) {
|
||||
revokeRefreshSession(app.db, refresh)
|
||||
}
|
||||
reply.header(
|
||||
'Set-Cookie',
|
||||
`${REFRESH_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`,
|
||||
)
|
||||
|
||||
// Best-effort actor from JWT if present
|
||||
let actorUserId: string | null = null
|
||||
let actorEmail: string | null = null
|
||||
let actorName: string | null = null
|
||||
try {
|
||||
await request.jwtVerify()
|
||||
const sub = request.user.sub
|
||||
const row = getUserById(app.db, sub)
|
||||
if (row) {
|
||||
actorUserId = row.id
|
||||
actorEmail = row.email
|
||||
actorName = row.name
|
||||
}
|
||||
} catch {
|
||||
/* anonymous logout */
|
||||
}
|
||||
|
||||
safeAudit(app, {
|
||||
action: 'auth.logout',
|
||||
severity: 'info',
|
||||
actorUserId,
|
||||
actorEmail,
|
||||
actorName,
|
||||
targetType: 'session',
|
||||
targetId: actorUserId,
|
||||
summary: actorEmail ? `Выход: ${actorEmail}` : 'Выход',
|
||||
details: { user_agent: clientUserAgent(request.headers) },
|
||||
ip: clientIp(request),
|
||||
})
|
||||
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
/** Public — apps chrome (CFDM/VPS) fetch switcher URLs without portal JWT. */
|
||||
// Без Cache-Control: после сохранения в админке SPA должна сразу видеть новый конфиг.
|
||||
app.get('/api/v1/app-switcher', async () =>
|
||||
publicAppSwitcherConfig(getAppSwitcherConfig(app.db)),
|
||||
)
|
||||
|
||||
app.get(
|
||||
'/api/v1/auth/me',
|
||||
{ onRequest: requireAuth },
|
||||
async (request) => {
|
||||
const auth = request.authUser!
|
||||
return {
|
||||
id: auth.id,
|
||||
email: auth.email,
|
||||
name: auth.name,
|
||||
is_admin: auth.isAdmin,
|
||||
apps: auth.apps,
|
||||
permissions: auth.permissions,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
app.get(
|
||||
'/api/v1/directory/users',
|
||||
{ onRequest: requireAuth },
|
||||
async (request) => {
|
||||
const q = String(
|
||||
(request.query as { q?: string }).q ?? '',
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
return searchDirectoryUsers(app.db, q, 50)
|
||||
},
|
||||
)
|
||||
|
||||
app.get('/api/v1/catalog', { onRequest: requireAuth }, async () => {
|
||||
const switcher = getAppSwitcherConfig(app.db)
|
||||
return {
|
||||
apps: appsMetaFromSwitcher(switcher),
|
||||
permissions: PERMISSION_CATALOG,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { appendAuditBatch } from '@authportal/db'
|
||||
import {
|
||||
ingestAuditRequestSchema,
|
||||
type AuditSourceApp,
|
||||
} from '@authportal/shared'
|
||||
import { timingSafeEqual } from 'node:crypto'
|
||||
|
||||
function secretsEqual(a: string, b: string): boolean {
|
||||
const ba = Buffer.from(a)
|
||||
const bb = Buffer.from(b)
|
||||
if (ba.length !== bb.length) return false
|
||||
return timingSafeEqual(ba, bb)
|
||||
}
|
||||
|
||||
export async function auditIngestRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.post(
|
||||
'/api/v1/ingest/audit',
|
||||
{
|
||||
config: { rateLimit: { max: 120, timeWindow: '1 minute' } },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const secret = app.config.auditIngestSecret
|
||||
if (!secret) {
|
||||
return reply.status(503).send({
|
||||
error: {
|
||||
code: 'UNAVAILABLE',
|
||||
message: 'Audit ingest не настроен',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const auth = request.headers.authorization ?? ''
|
||||
const token = auth.startsWith('Bearer ') ? auth.slice(7).trim() : ''
|
||||
if (!token || !secretsEqual(token, secret)) {
|
||||
return reply.status(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Неверный ingest secret' },
|
||||
})
|
||||
}
|
||||
|
||||
const parsed = ingestAuditRequestSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||
})
|
||||
}
|
||||
|
||||
const { accepted, duplicates, failed } = appendAuditBatch(
|
||||
app.db,
|
||||
parsed.data.events.map((event) => ({
|
||||
eventId: event.event_id,
|
||||
sourceApp: event.source_app as AuditSourceApp,
|
||||
action: event.action,
|
||||
severity: event.severity,
|
||||
actorUserId: event.actor_user_id,
|
||||
actorEmail: event.actor_email,
|
||||
actorName: event.actor_name,
|
||||
targetType: event.target_type,
|
||||
targetId: event.target_id,
|
||||
summary: event.summary,
|
||||
details: event.details,
|
||||
ip: event.ip,
|
||||
createdAt: event.created_at,
|
||||
})),
|
||||
)
|
||||
if (failed > 0) {
|
||||
app.log.warn({ failed }, 'ingest append failed for some events')
|
||||
}
|
||||
|
||||
return { accepted, duplicates }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
|
||||
import {
|
||||
updateOidcClient,
|
||||
createOidcAuthCode,
|
||||
consumeOidcAuthCode,
|
||||
getOidcClientByClientId,
|
||||
getUserById,
|
||||
getUserByRefreshToken,
|
||||
getUserApps,
|
||||
getUserPermissions,
|
||||
parseJsonStringArray,
|
||||
type OidcClientRow,
|
||||
type UserRow,
|
||||
} from '@authportal/db'
|
||||
import {
|
||||
oidcGroupsForUser,
|
||||
normalizePermissionKeys,
|
||||
} from '@authportal/shared'
|
||||
import { oidcIssuerFromConfig } from '../config.js'
|
||||
import { clientIp, safeAudit } from '../lib/audit.js'
|
||||
import { checkClientSecret, hashClientSecret } from '../lib/client-secret.js'
|
||||
import { parseCookie } from '../lib/cookies.js'
|
||||
import {
|
||||
buildJwks,
|
||||
buildOidcDiscovery,
|
||||
ensureOidcSigningKey,
|
||||
signOidcJwt,
|
||||
verifyOidcAccessToken,
|
||||
} from '../lib/oidc/keys.js'
|
||||
import { clientUserAgent, targetAppFromReturnTo } from '../lib/target-app.js'
|
||||
|
||||
const REFRESH_COOKIE = 'refresh_token'
|
||||
const CODE_TTL_MS = 5 * 60 * 1000
|
||||
const ACCESS_TTL_SEC = 3600
|
||||
const ID_TOKEN_TTL_SEC = 3600
|
||||
|
||||
function parseBasicAuth(
|
||||
header: string | undefined,
|
||||
): { clientId: string; clientSecret: string } | null {
|
||||
if (!header?.startsWith('Basic ')) return null
|
||||
try {
|
||||
const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8')
|
||||
const idx = decoded.indexOf(':')
|
||||
if (idx < 0) return null
|
||||
return {
|
||||
clientId: decoded.slice(0, idx),
|
||||
clientSecret: decoded.slice(idx + 1),
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticateClient(
|
||||
app: FastifyInstance,
|
||||
client: OidcClientRow,
|
||||
secret: string,
|
||||
): Promise<boolean> {
|
||||
const res = await checkClientSecret(client.clientSecretHash, secret)
|
||||
if (res.ok && res.needsRehash) {
|
||||
// Миграция argon2 → SHA-256 при первом успешном обмене.
|
||||
try {
|
||||
updateOidcClient(app.db, client.id, {
|
||||
clientSecretHash: hashClientSecret(secret),
|
||||
})
|
||||
} catch (err) {
|
||||
app.log.warn({ err }, 'client secret rehash failed')
|
||||
}
|
||||
}
|
||||
return res.ok
|
||||
}
|
||||
|
||||
function redirectError(
|
||||
reply: FastifyReply,
|
||||
redirectUri: string | undefined,
|
||||
error: string,
|
||||
description: string,
|
||||
state?: string | null,
|
||||
) {
|
||||
if (!redirectUri) {
|
||||
return reply.status(400).send({
|
||||
error,
|
||||
error_description: description,
|
||||
})
|
||||
}
|
||||
const url = new URL(redirectUri)
|
||||
url.searchParams.set('error', error)
|
||||
url.searchParams.set('error_description', description)
|
||||
if (state) url.searchParams.set('state', state)
|
||||
return reply.redirect(url.toString())
|
||||
}
|
||||
|
||||
function resolveSessionUser(
|
||||
app: FastifyInstance,
|
||||
request: FastifyRequest,
|
||||
): UserRow | undefined {
|
||||
const raw = parseCookie(request.headers.cookie, REFRESH_COOKIE)
|
||||
if (!raw) return undefined
|
||||
return getUserByRefreshToken(app.db, raw)
|
||||
}
|
||||
|
||||
function buildUserInfoClaims(
|
||||
app: FastifyInstance,
|
||||
user: UserRow,
|
||||
scope: string,
|
||||
) {
|
||||
const scopes = new Set(scope.split(/\s+/).filter(Boolean))
|
||||
const permissions = normalizePermissionKeys(
|
||||
getUserPermissions(app.db, user.id),
|
||||
)
|
||||
const groups = oidcGroupsForUser({
|
||||
isAdmin: user.isAdmin,
|
||||
permissions,
|
||||
})
|
||||
|
||||
const claims: Record<string, unknown> = {
|
||||
sub: user.id,
|
||||
}
|
||||
if (scopes.has('email') || scopes.has('profile') || scopes.has('openid')) {
|
||||
claims.email = user.email
|
||||
claims.email_verified = true
|
||||
}
|
||||
if (scopes.has('profile') || scopes.has('openid')) {
|
||||
claims.name = user.name
|
||||
claims.preferred_username = user.email
|
||||
}
|
||||
if (scopes.has('groups')) {
|
||||
claims.groups = groups
|
||||
claims.roles = groups
|
||||
}
|
||||
return claims
|
||||
}
|
||||
|
||||
function safeEqualStr(a: string, b: string): boolean {
|
||||
const ba = Buffer.from(a)
|
||||
const bb = Buffer.from(b)
|
||||
if (ba.length !== bb.length) return false
|
||||
return timingSafeEqual(ba, bb)
|
||||
}
|
||||
|
||||
function verifyPkce(
|
||||
codeVerifier: string | undefined,
|
||||
challenge: string | null,
|
||||
method: string | null,
|
||||
): boolean {
|
||||
if (!challenge) return true
|
||||
if (!codeVerifier) return false
|
||||
if (method === 'plain' || !method) {
|
||||
return safeEqualStr(codeVerifier, challenge)
|
||||
}
|
||||
if (method === 'S256') {
|
||||
const hash = createHash('sha256')
|
||||
.update(codeVerifier)
|
||||
.digest('base64url')
|
||||
return hash === challenge
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export async function oidcRoutes(app: FastifyInstance): Promise<void> {
|
||||
await ensureOidcSigningKey(app)
|
||||
|
||||
app.get('/.well-known/openid-configuration', async (_request, reply) => {
|
||||
// Статичный документ — RP не должен перечитывать его на каждый запрос.
|
||||
reply.header('Cache-Control', 'public, max-age=300')
|
||||
return buildOidcDiscovery(app.config)
|
||||
})
|
||||
|
||||
app.get('/.well-known/jwks.json', async (_request, reply) => {
|
||||
reply.header('Cache-Control', 'public, max-age=300')
|
||||
return buildJwks(app)
|
||||
})
|
||||
|
||||
app.get('/oauth/authorize', {
|
||||
config: { rateLimit: { max: 120, timeWindow: '1 minute' } },
|
||||
}, async (request, reply) => {
|
||||
const q = request.query as Record<string, string | undefined>
|
||||
const clientId = q.client_id
|
||||
const redirectUri = q.redirect_uri
|
||||
const responseType = q.response_type
|
||||
const scope = q.scope ?? 'openid'
|
||||
const state = q.state
|
||||
const nonce = q.nonce
|
||||
const codeChallenge = q.code_challenge
|
||||
const codeChallengeMethod = q.code_challenge_method
|
||||
|
||||
if (!clientId || !redirectUri) {
|
||||
return reply.status(400).send({
|
||||
error: 'invalid_request',
|
||||
error_description: 'client_id and redirect_uri are required',
|
||||
})
|
||||
}
|
||||
if (responseType !== 'code') {
|
||||
return redirectError(
|
||||
reply,
|
||||
redirectUri,
|
||||
'unsupported_response_type',
|
||||
'Only response_type=code is supported',
|
||||
state,
|
||||
)
|
||||
}
|
||||
|
||||
const client = getOidcClientByClientId(app.db, clientId)
|
||||
if (!client || !client.enabled) {
|
||||
return reply.status(400).send({
|
||||
error: 'invalid_client',
|
||||
error_description: 'Unknown or disabled client',
|
||||
})
|
||||
}
|
||||
const allowedRedirects = parseJsonStringArray(client.redirectUrisJson)
|
||||
if (!allowedRedirects.includes(redirectUri)) {
|
||||
return reply.status(400).send({
|
||||
error: 'invalid_request',
|
||||
error_description: 'redirect_uri is not registered',
|
||||
})
|
||||
}
|
||||
|
||||
const requestedScopes = scope.split(/\s+/).filter(Boolean)
|
||||
const clientScopes = new Set(parseJsonStringArray(client.scopesJson))
|
||||
if (!requestedScopes.includes('openid')) {
|
||||
return redirectError(
|
||||
reply,
|
||||
redirectUri,
|
||||
'invalid_scope',
|
||||
'openid scope is required',
|
||||
state,
|
||||
)
|
||||
}
|
||||
for (const s of requestedScopes) {
|
||||
if (!clientScopes.has(s)) {
|
||||
return redirectError(
|
||||
reply,
|
||||
redirectUri,
|
||||
'invalid_scope',
|
||||
`Scope not allowed: ${s}`,
|
||||
state,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const user = resolveSessionUser(app, request)
|
||||
if (!user) {
|
||||
const issuer = oidcIssuerFromConfig(app.config)
|
||||
const authorizeUrl = new URL(`${issuer}/oauth/authorize`)
|
||||
for (const [k, v] of Object.entries(q)) {
|
||||
if (v != null) authorizeUrl.searchParams.set(k, v)
|
||||
}
|
||||
const login = new URL(`${issuer}/`)
|
||||
login.searchParams.set('return_to', authorizeUrl.toString())
|
||||
return reply.redirect(login.toString())
|
||||
}
|
||||
|
||||
const apps = getUserApps(app.db, user.id)
|
||||
// Soft check: prefer dns app assignment for Technitium-like clients
|
||||
if (!user.isAdmin && apps.length === 0) {
|
||||
return redirectError(
|
||||
reply,
|
||||
redirectUri,
|
||||
'access_denied',
|
||||
'User has no application access',
|
||||
state,
|
||||
)
|
||||
}
|
||||
|
||||
const rawCode = randomBytes(32).toString('base64url')
|
||||
createOidcAuthCode(app.db, {
|
||||
rawCode,
|
||||
clientId: client.clientId,
|
||||
userId: user.id,
|
||||
redirectUri,
|
||||
scope: requestedScopes.join(' '),
|
||||
nonce: nonce ?? null,
|
||||
codeChallenge: codeChallenge ?? null,
|
||||
codeChallengeMethod: codeChallengeMethod ?? null,
|
||||
expiresAt: new Date(Date.now() + CODE_TTL_MS),
|
||||
})
|
||||
|
||||
const targetApp = targetAppFromReturnTo(redirectUri)
|
||||
safeAudit(app, {
|
||||
action: 'auth.sso_handoff',
|
||||
severity: 'info',
|
||||
actorUserId: user.id,
|
||||
actorEmail: user.email,
|
||||
actorName: user.name,
|
||||
targetType: 'session',
|
||||
targetId: user.id,
|
||||
summary: `SSO OIDC: ${user.email} → ${targetApp}`,
|
||||
details: {
|
||||
return_to: redirectUri,
|
||||
target_app: targetApp,
|
||||
user_agent: clientUserAgent(request.headers),
|
||||
oidc_client_id: client.clientId,
|
||||
oidc_client_name: client.name,
|
||||
auth_mode: 'oidc',
|
||||
},
|
||||
ip: clientIp(request),
|
||||
})
|
||||
|
||||
const dest = new URL(redirectUri)
|
||||
dest.searchParams.set('code', rawCode)
|
||||
if (state) dest.searchParams.set('state', state)
|
||||
return reply.redirect(dest.toString())
|
||||
})
|
||||
|
||||
app.post('/oauth/token', {
|
||||
config: { rateLimit: { max: 60, timeWindow: '1 minute' } },
|
||||
handler: async (request, reply) => {
|
||||
const raw = request.body
|
||||
const body: Record<string, string | undefined> =
|
||||
typeof raw === 'string'
|
||||
? Object.fromEntries(new URLSearchParams(raw))
|
||||
: raw && typeof raw === 'object'
|
||||
? (raw as Record<string, string | undefined>)
|
||||
: {}
|
||||
const basic = parseBasicAuth(request.headers.authorization)
|
||||
const clientId = body.client_id ?? basic?.clientId
|
||||
const clientSecret = body.client_secret ?? basic?.clientSecret
|
||||
const grantType = body.grant_type
|
||||
const code = body.code
|
||||
const redirectUri = body.redirect_uri
|
||||
const codeVerifier = body.code_verifier
|
||||
|
||||
if (grantType !== 'authorization_code') {
|
||||
return reply.status(400).send({
|
||||
error: 'unsupported_grant_type',
|
||||
error_description: 'Only authorization_code is supported',
|
||||
})
|
||||
}
|
||||
if (!clientId || !clientSecret || !code || !redirectUri) {
|
||||
return reply.status(400).send({
|
||||
error: 'invalid_request',
|
||||
error_description:
|
||||
'client_id, client_secret, code, and redirect_uri are required',
|
||||
})
|
||||
}
|
||||
|
||||
const client = getOidcClientByClientId(app.db, clientId)
|
||||
if (!client || !client.enabled) {
|
||||
return reply.status(401).send({
|
||||
error: 'invalid_client',
|
||||
error_description: 'Unknown or disabled client',
|
||||
})
|
||||
}
|
||||
const secretOk = await authenticateClient(app, client, clientSecret)
|
||||
if (!secretOk) {
|
||||
return reply.status(401).send({
|
||||
error: 'invalid_client',
|
||||
error_description: 'Invalid client credentials',
|
||||
})
|
||||
}
|
||||
|
||||
const authCode = consumeOidcAuthCode(app.db, code)
|
||||
if (!authCode) {
|
||||
return reply.status(400).send({
|
||||
error: 'invalid_grant',
|
||||
error_description: 'Invalid or expired authorization code',
|
||||
})
|
||||
}
|
||||
if (authCode.clientId !== client.clientId) {
|
||||
return reply.status(400).send({
|
||||
error: 'invalid_grant',
|
||||
error_description: 'Code was not issued to this client',
|
||||
})
|
||||
}
|
||||
if (authCode.redirectUri !== redirectUri) {
|
||||
return reply.status(400).send({
|
||||
error: 'invalid_grant',
|
||||
error_description: 'redirect_uri mismatch',
|
||||
})
|
||||
}
|
||||
if (
|
||||
!verifyPkce(
|
||||
codeVerifier,
|
||||
authCode.codeChallenge,
|
||||
authCode.codeChallengeMethod,
|
||||
)
|
||||
) {
|
||||
return reply.status(400).send({
|
||||
error: 'invalid_grant',
|
||||
error_description: 'PKCE verification failed',
|
||||
})
|
||||
}
|
||||
|
||||
const user = getUserById(app.db, authCode.userId)
|
||||
if (!user || user.disabled) {
|
||||
return reply.status(400).send({
|
||||
error: 'invalid_grant',
|
||||
error_description: 'User unavailable',
|
||||
})
|
||||
}
|
||||
|
||||
const info = buildUserInfoClaims(app, user, authCode.scope)
|
||||
const idClaims: Record<string, unknown> = {
|
||||
...info,
|
||||
aud: client.clientId,
|
||||
}
|
||||
if (authCode.nonce) idClaims.nonce = authCode.nonce
|
||||
|
||||
const accessClaims: Record<string, unknown> = {
|
||||
sub: user.id,
|
||||
aud: client.clientId,
|
||||
scope: authCode.scope,
|
||||
token_use: 'access',
|
||||
}
|
||||
if (info.groups) {
|
||||
accessClaims.groups = info.groups
|
||||
accessClaims.roles = info.roles
|
||||
}
|
||||
|
||||
const [idToken, accessToken] = await Promise.all([
|
||||
signOidcJwt(app, idClaims, ID_TOKEN_TTL_SEC),
|
||||
signOidcJwt(app, accessClaims, ACCESS_TTL_SEC),
|
||||
])
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
token_type: 'Bearer',
|
||||
expires_in: ACCESS_TTL_SEC,
|
||||
id_token: idToken,
|
||||
scope: authCode.scope,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
app.get('/oauth/userinfo', {
|
||||
config: { rateLimit: { max: 120, timeWindow: '1 minute' } },
|
||||
}, async (request, reply) => {
|
||||
const auth = request.headers.authorization
|
||||
if (!auth?.startsWith('Bearer ')) {
|
||||
return reply.status(401).send({
|
||||
error: 'invalid_token',
|
||||
error_description: 'Bearer token required',
|
||||
})
|
||||
}
|
||||
const token = auth.slice(7)
|
||||
let payload: Record<string, unknown>
|
||||
try {
|
||||
payload = await verifyOidcAccessToken(app, token)
|
||||
} catch {
|
||||
return reply.status(401).send({
|
||||
error: 'invalid_token',
|
||||
error_description: 'Token invalid or expired',
|
||||
})
|
||||
}
|
||||
const sub = typeof payload.sub === 'string' ? payload.sub : null
|
||||
if (!sub) {
|
||||
return reply.status(401).send({
|
||||
error: 'invalid_token',
|
||||
error_description: 'Missing sub',
|
||||
})
|
||||
}
|
||||
const user = getUserById(app.db, sub)
|
||||
if (!user || user.disabled) {
|
||||
return reply.status(401).send({
|
||||
error: 'invalid_token',
|
||||
error_description: 'User unavailable',
|
||||
})
|
||||
}
|
||||
const scope =
|
||||
typeof payload.scope === 'string'
|
||||
? payload.scope
|
||||
: 'openid profile email groups'
|
||||
return buildUserInfoClaims(app, user, scope)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
type AuthenticationResponseJSON,
|
||||
type RegistrationResponseJSON,
|
||||
} from '@simplewebauthn/server'
|
||||
import {
|
||||
consumeWebauthnChallenge,
|
||||
createWebauthnChallenge,
|
||||
createWebauthnCredential,
|
||||
deleteWebauthnCredential,
|
||||
getUserById,
|
||||
getWebauthnCredentialByCredentialId,
|
||||
getWebauthnCredentialById,
|
||||
listWebauthnCredentials,
|
||||
parseTransportsJson,
|
||||
updateWebauthnCredentialName,
|
||||
touchWebauthnCredential,
|
||||
type WebauthnCredentialRow,
|
||||
} from '@authportal/db'
|
||||
import {
|
||||
patchPasskeyRequestSchema,
|
||||
webauthnVerifyRequestSchema,
|
||||
type PasskeyCredential,
|
||||
} from '@authportal/shared'
|
||||
import { requireAuth } from '../plugins/auth-guards.js'
|
||||
import { completeLogin } from '../lib/complete-login.js'
|
||||
import { clientIp, safeAudit } from '../lib/audit.js'
|
||||
import { clientUserAgent, targetAppFromReturnTo } from '../lib/target-app.js'
|
||||
import {
|
||||
asTransports,
|
||||
decodePublicKey,
|
||||
encodePublicKey,
|
||||
userIdToBytes,
|
||||
webauthnRelyingParty,
|
||||
} from '../lib/webauthn.js'
|
||||
|
||||
const LOGIN_RATE = { max: 20, timeWindow: '1 minute' } as const
|
||||
|
||||
function toPasskeyDto(row: WebauthnCredentialRow): PasskeyCredential {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
created_at: row.createdAt,
|
||||
last_used_at: row.lastUsedAt ?? null,
|
||||
device_type: row.deviceType ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function defaultPasskeyName(userAgent: string | null): string {
|
||||
const date = new Date().toLocaleDateString('ru-RU')
|
||||
if (!userAgent) return `Passkey ${date}`
|
||||
if (/iPhone|iPad|Macintosh/i.test(userAgent)) return `Apple ${date}`
|
||||
if (/Windows/i.test(userAgent)) return `Windows Hello ${date}`
|
||||
if (/Android/i.test(userAgent)) return `Android ${date}`
|
||||
return `Passkey ${date}`
|
||||
}
|
||||
|
||||
function asRegistrationResponse(
|
||||
raw: unknown,
|
||||
): RegistrationResponseJSON | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
if (!('id' in raw) || typeof (raw as { id: unknown }).id !== 'string') {
|
||||
return null
|
||||
}
|
||||
return raw as RegistrationResponseJSON
|
||||
}
|
||||
|
||||
function asAuthenticationResponse(
|
||||
raw: unknown,
|
||||
): AuthenticationResponseJSON | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
if (!('id' in raw) || typeof (raw as { id: unknown }).id !== 'string') {
|
||||
return null
|
||||
}
|
||||
return raw as AuthenticationResponseJSON
|
||||
}
|
||||
|
||||
export async function webauthnRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.post(
|
||||
'/api/v1/webauthn/register/options',
|
||||
{
|
||||
onRequest: requireAuth,
|
||||
config: { rateLimit: LOGIN_RATE },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const auth = request.authUser!
|
||||
const user = getUserById(app.db, auth.id)
|
||||
if (!user || user.disabled) {
|
||||
return reply.status(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
|
||||
})
|
||||
}
|
||||
const rp = webauthnRelyingParty(app)
|
||||
const existing = listWebauthnCredentials(app.db, user.id)
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: rp.rpName,
|
||||
rpID: rp.rpID,
|
||||
userName: user.email,
|
||||
userDisplayName: user.name,
|
||||
userID: userIdToBytes(user.id),
|
||||
attestationType: 'none',
|
||||
authenticatorSelection: {
|
||||
residentKey: 'preferred',
|
||||
userVerification: 'preferred',
|
||||
},
|
||||
excludeCredentials: existing.map((cred) => ({
|
||||
id: cred.credentialId,
|
||||
transports: asTransports(parseTransportsJson(cred.transportsJson)),
|
||||
})),
|
||||
})
|
||||
const row = createWebauthnChallenge(app.db, {
|
||||
purpose: 'register',
|
||||
challenge: options.challenge,
|
||||
userId: user.id,
|
||||
})
|
||||
return { challenge_id: row.id, options }
|
||||
},
|
||||
)
|
||||
|
||||
app.post(
|
||||
'/api/v1/webauthn/register',
|
||||
{
|
||||
onRequest: requireAuth,
|
||||
config: { rateLimit: LOGIN_RATE },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const parsed = webauthnVerifyRequestSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||
})
|
||||
}
|
||||
const auth = request.authUser!
|
||||
const user = getUserById(app.db, auth.id)
|
||||
if (!user || user.disabled) {
|
||||
return reply.status(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
|
||||
})
|
||||
}
|
||||
const response = asRegistrationResponse(parsed.data.response)
|
||||
if (!response) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректный ответ passkey' },
|
||||
})
|
||||
}
|
||||
const challenge = consumeWebauthnChallenge(
|
||||
app.db,
|
||||
parsed.data.challenge_id,
|
||||
'register',
|
||||
user.id,
|
||||
)
|
||||
if (!challenge) {
|
||||
return reply.status(400).send({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Срок действия challenge истёк, повторите регистрацию',
|
||||
},
|
||||
})
|
||||
}
|
||||
const rp = webauthnRelyingParty(app)
|
||||
let verification
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge: challenge.challenge,
|
||||
expectedOrigin: rp.origins,
|
||||
expectedRPID: rp.rpID,
|
||||
requireUserVerification: true,
|
||||
})
|
||||
} catch (err) {
|
||||
app.log.warn({ err }, 'webauthn register verify failed')
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Не удалось проверить passkey' },
|
||||
})
|
||||
}
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Passkey не подтверждён' },
|
||||
})
|
||||
}
|
||||
const info = verification.registrationInfo
|
||||
const duplicate = getWebauthnCredentialByCredentialId(
|
||||
app.db,
|
||||
info.credential.id,
|
||||
)
|
||||
if (duplicate) {
|
||||
return reply.status(409).send({
|
||||
error: { code: 'CONFLICT', message: 'Этот passkey уже зарегистрирован' },
|
||||
})
|
||||
}
|
||||
const name =
|
||||
parsed.data.name?.trim() ||
|
||||
defaultPasskeyName(clientUserAgent(request.headers))
|
||||
const row = createWebauthnCredential(app.db, {
|
||||
userId: user.id,
|
||||
credentialId: info.credential.id,
|
||||
publicKey: encodePublicKey(info.credential.publicKey),
|
||||
counter: info.credential.counter,
|
||||
deviceType: info.credentialDeviceType,
|
||||
backedUp: info.credentialBackedUp,
|
||||
transports: info.credential.transports,
|
||||
name,
|
||||
})
|
||||
safeAudit(app, {
|
||||
action: 'auth.passkey_register',
|
||||
severity: 'info',
|
||||
actorUserId: user.id,
|
||||
actorEmail: user.email,
|
||||
actorName: user.name,
|
||||
targetType: 'credential',
|
||||
targetId: row.id,
|
||||
summary: `Passkey добавлен: ${user.email}`,
|
||||
details: { name: row.name, device_type: row.deviceType },
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return toPasskeyDto(row)
|
||||
},
|
||||
)
|
||||
|
||||
app.get(
|
||||
'/api/v1/webauthn/credentials',
|
||||
{ onRequest: requireAuth },
|
||||
async (request) => {
|
||||
const auth = request.authUser!
|
||||
return listWebauthnCredentials(app.db, auth.id).map(toPasskeyDto)
|
||||
},
|
||||
)
|
||||
|
||||
app.patch<{ Params: { id: string } }>(
|
||||
'/api/v1/webauthn/credentials/:id',
|
||||
{ onRequest: requireAuth },
|
||||
async (request, reply) => {
|
||||
const parsed = patchPasskeyRequestSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||
})
|
||||
}
|
||||
const auth = request.authUser!
|
||||
const row = updateWebauthnCredentialName(
|
||||
app.db,
|
||||
request.params.id,
|
||||
auth.id,
|
||||
parsed.data.name.trim(),
|
||||
)
|
||||
if (!row) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Passkey не найден' },
|
||||
})
|
||||
}
|
||||
return toPasskeyDto(row)
|
||||
},
|
||||
)
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
'/api/v1/webauthn/credentials/:id',
|
||||
{ onRequest: requireAuth },
|
||||
async (request, reply) => {
|
||||
const auth = request.authUser!
|
||||
const existing = getWebauthnCredentialById(app.db, request.params.id)
|
||||
if (!existing || existing.userId !== auth.id) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Passkey не найден' },
|
||||
})
|
||||
}
|
||||
deleteWebauthnCredential(app.db, request.params.id, auth.id)
|
||||
const user = getUserById(app.db, auth.id)
|
||||
safeAudit(app, {
|
||||
action: 'auth.passkey_delete',
|
||||
severity: 'warning',
|
||||
actorUserId: auth.id,
|
||||
actorEmail: auth.email,
|
||||
actorName: auth.name,
|
||||
targetType: 'credential',
|
||||
targetId: request.params.id,
|
||||
summary: `Passkey удалён: ${user?.email ?? auth.email}`,
|
||||
details: { name: existing.name },
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
app.post(
|
||||
'/api/v1/webauthn/login/options',
|
||||
{ config: { rateLimit: LOGIN_RATE } },
|
||||
async () => {
|
||||
const rp = webauthnRelyingParty(app)
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: rp.rpID,
|
||||
userVerification: 'preferred',
|
||||
})
|
||||
const row = createWebauthnChallenge(app.db, {
|
||||
purpose: 'authenticate',
|
||||
challenge: options.challenge,
|
||||
})
|
||||
return { challenge_id: row.id, options }
|
||||
},
|
||||
)
|
||||
|
||||
app.post(
|
||||
'/api/v1/webauthn/login',
|
||||
{ config: { rateLimit: LOGIN_RATE } },
|
||||
async (request, reply) => {
|
||||
const parsed = webauthnVerifyRequestSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||
})
|
||||
}
|
||||
const ip = clientIp(request)
|
||||
const userAgent = clientUserAgent(request.headers)
|
||||
const returnTo = parsed.data.return_to
|
||||
const fail = (reason: string) => {
|
||||
safeAudit(app, {
|
||||
action: 'auth.passkey_login_failed',
|
||||
severity: 'warning',
|
||||
targetType: 'session',
|
||||
summary: 'Неудачный вход по passkey',
|
||||
details: {
|
||||
reason,
|
||||
user_agent: userAgent,
|
||||
return_to: returnTo ?? null,
|
||||
target_app: targetAppFromReturnTo(returnTo),
|
||||
},
|
||||
ip,
|
||||
})
|
||||
return reply.status(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Не удалось войти с passkey' },
|
||||
})
|
||||
}
|
||||
|
||||
const response = asAuthenticationResponse(parsed.data.response)
|
||||
if (!response) return fail('bad_response')
|
||||
|
||||
const challenge = consumeWebauthnChallenge(
|
||||
app.db,
|
||||
parsed.data.challenge_id,
|
||||
'authenticate',
|
||||
)
|
||||
if (!challenge) return fail('expired_challenge')
|
||||
|
||||
const cred = getWebauthnCredentialByCredentialId(app.db, response.id)
|
||||
if (!cred) return fail('unknown_credential')
|
||||
|
||||
const user = getUserById(app.db, cred.userId)
|
||||
if (!user || user.disabled) return fail(user ? 'disabled' : 'unknown_user')
|
||||
|
||||
const rp = webauthnRelyingParty(app)
|
||||
let verification
|
||||
try {
|
||||
verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge: challenge.challenge,
|
||||
expectedOrigin: rp.origins,
|
||||
expectedRPID: rp.rpID,
|
||||
requireUserVerification: true,
|
||||
credential: {
|
||||
id: cred.credentialId,
|
||||
publicKey: decodePublicKey(cred.publicKey),
|
||||
counter: cred.counter,
|
||||
transports: asTransports(parseTransportsJson(cred.transportsJson)),
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
app.log.warn({ err }, 'webauthn login verify failed')
|
||||
return fail('verify_error')
|
||||
}
|
||||
if (!verification.verified) return fail('not_verified')
|
||||
|
||||
touchWebauthnCredential(
|
||||
app.db,
|
||||
cred.id,
|
||||
verification.authenticationInfo.newCounter,
|
||||
)
|
||||
return completeLogin(app, reply, user, {
|
||||
method: 'passkey',
|
||||
returnTo,
|
||||
ip,
|
||||
userAgent,
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { buildApp } from './app.js'
|
||||
import { loadConfig } from './config.js'
|
||||
import { registerShutdown } from './lib/shutdown.js'
|
||||
|
||||
for (const path of [
|
||||
resolve(import.meta.dirname, '../../../.env'),
|
||||
'.env',
|
||||
'../.env',
|
||||
]) {
|
||||
if (!existsSync(path)) continue
|
||||
const content = readFileSync(path, 'utf-8')
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) continue
|
||||
const eq = trimmed.indexOf('=')
|
||||
if (eq === -1) continue
|
||||
const key = trimmed.slice(0, eq).trim()
|
||||
let value = trimmed.slice(eq + 1).trim()
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
if (!(key in process.env)) process.env[key] = value
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
const config = loadConfig()
|
||||
|
||||
// `node dist/server.js backup` — разовый онлайн-бэкап без запуска сервера
|
||||
// (для cron / `docker exec auth-portal node dist/server.js backup`).
|
||||
if (process.argv[2] === 'backup') {
|
||||
const { createDb, closeDb } = await import('@authportal/db')
|
||||
const { runBackup } = await import('./services/backup.js')
|
||||
const { sqlite } = createDb(config.databaseUrl)
|
||||
try {
|
||||
const res = await runBackup(sqlite, config)
|
||||
console.log(`backup ok: ${res.file} (removed ${res.removed.length} old)`)
|
||||
closeDb(sqlite)
|
||||
process.exit(0)
|
||||
} catch (err) {
|
||||
console.error('backup failed:', err)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
const app = await buildApp({ config })
|
||||
|
||||
registerShutdown(app, { timeoutMs: config.shutdownTimeoutMs })
|
||||
|
||||
try {
|
||||
await app.listen({ port: config.serverPort, host: '0.0.0.0' })
|
||||
app.log.info(`auth-portal listening on ${config.serverPort}`)
|
||||
} catch (err) {
|
||||
app.log.error(err)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import {
|
||||
getAuditRetentionDays,
|
||||
purgeAuditOlderThan,
|
||||
} from '@authportal/db'
|
||||
import { safeAudit } from '../lib/audit.js'
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000
|
||||
|
||||
export function startAuditRetentionJob(app: FastifyInstance): () => void {
|
||||
const run = () => {
|
||||
try {
|
||||
const days = getAuditRetentionDays(app.db)
|
||||
const deleted = purgeAuditOlderThan(app.db, days)
|
||||
if (deleted > 0) {
|
||||
safeAudit(app, {
|
||||
action: 'audit.purge',
|
||||
severity: 'info',
|
||||
targetType: 'system',
|
||||
summary: `Автоочистка: удалено ${deleted} записей старше ${days} дн.`,
|
||||
details: { deleted, retention_days: days, source: 'scheduler' },
|
||||
})
|
||||
app.log.info(
|
||||
{ deleted, retention_days: days },
|
||||
'audit_log retention purge',
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
app.log.warn({ err }, 'audit_log retention job failed')
|
||||
}
|
||||
}
|
||||
|
||||
// Defer first run slightly so bootstrap finishes
|
||||
const initial = setTimeout(run, 15_000)
|
||||
const interval = setInterval(run, HOUR_MS)
|
||||
|
||||
return () => {
|
||||
clearTimeout(initial)
|
||||
clearInterval(interval)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { backupDatabase, resolveDatabasePath, type Sqlite } from '@authportal/db'
|
||||
import type { AppConfig } from '../config.js'
|
||||
|
||||
export function backupDirFor(config: AppConfig, databaseUrl = config.databaseUrl) {
|
||||
if (config.backupDir) return resolve(config.backupDir)
|
||||
const path = resolveDatabasePath(databaseUrl)
|
||||
return join(dirname(resolve(path)), 'backups')
|
||||
}
|
||||
|
||||
export async function runBackup(
|
||||
sqlite: Sqlite,
|
||||
config: AppConfig,
|
||||
databaseUrl = config.databaseUrl,
|
||||
) {
|
||||
return backupDatabase(sqlite, {
|
||||
dir: backupDirFor(config, databaseUrl),
|
||||
keep: config.backupKeep,
|
||||
})
|
||||
}
|
||||
|
||||
/** Периодический бэкап; выключен при BACKUP_INTERVAL_HOURS=0. */
|
||||
export function startBackupJob(app: FastifyInstance): () => void {
|
||||
const hours = app.config.backupIntervalHours
|
||||
if (hours <= 0 || app.sqlite.memory) return () => {}
|
||||
let running = false
|
||||
const run = async () => {
|
||||
if (running) return
|
||||
running = true
|
||||
try {
|
||||
const res = await runBackup(app.sqlite, app.config)
|
||||
app.log.info({ file: res.file, removed: res.removed.length }, 'db backup')
|
||||
} catch (err) {
|
||||
app.log.error({ err }, 'db backup failed')
|
||||
} finally {
|
||||
running = false
|
||||
}
|
||||
}
|
||||
const interval = setInterval(() => void run(), hours * 60 * 60 * 1000)
|
||||
interval.unref()
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { runHousekeeping } from '@authportal/db'
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000
|
||||
|
||||
/** Часовая очистка сессий, кодов авторизации и WebAuthn-челленджей. */
|
||||
export function startHousekeepingJob(app: FastifyInstance): () => void {
|
||||
const run = () => {
|
||||
try {
|
||||
const res = runHousekeeping(app.db, {
|
||||
retentionDays: app.config.sessionRetentionDays,
|
||||
})
|
||||
if (res.sessions + res.authCodes + res.challenges > 0) {
|
||||
app.log.info(res, 'housekeeping purge')
|
||||
}
|
||||
} catch (err) {
|
||||
app.log.warn({ err }, 'housekeeping job failed')
|
||||
}
|
||||
}
|
||||
const initial = setTimeout(run, 30_000)
|
||||
const interval = setInterval(run, HOUR_MS)
|
||||
initial.unref()
|
||||
interval.unref()
|
||||
return () => {
|
||||
clearTimeout(initial)
|
||||
clearInterval(interval)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { createHmac } from 'node:crypto'
|
||||
import https from 'node:https'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { completePortalRevocation, enablePortalRevocations, nextPortalRevocation } from '@authportal/db'
|
||||
|
||||
export function portalRevocationTarget(raw: string): URL {
|
||||
if (raw.length > 512) throw new Error('PORTAL_REVOCATION_TARGET_INVALID')
|
||||
let url: URL
|
||||
try { url = new URL(raw) } catch { throw new Error('PORTAL_REVOCATION_TARGET_INVALID') }
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash
|
||||
|| url.pathname !== '/_internal/mcp/portal/revocations' || url.href !== raw) throw new Error('PORTAL_REVOCATION_TARGET_INVALID')
|
||||
return url
|
||||
}
|
||||
/** One fixed operator-selected HTTPS destination. Core https never follows redirects. */
|
||||
export function sendPortalRevocation(target: URL, secret: string, body: string, signal: AbortSignal): Promise<boolean> {
|
||||
if (Buffer.byteLength(body) > 4096) return Promise.resolve(false)
|
||||
const timestamp = String(Math.floor(Date.now() / 1000))
|
||||
const signature = 'sha256=' + createHmac('sha256', secret).update(timestamp + '.').update(body).digest('hex')
|
||||
return new Promise(resolve => {
|
||||
let settled = false
|
||||
let outcome = false
|
||||
const finish = (accepted: boolean) => {
|
||||
if (!settled) { settled = true; outcome = accepted; clearTimeout(deadline); request.destroy() }
|
||||
}
|
||||
const request = https.request(target, { method: 'POST', agent: false, rejectUnauthorized: true, signal, headers: {
|
||||
'content-type': 'application/json', 'content-length': Buffer.byteLength(body),
|
||||
'x-mm-timestamp': timestamp, 'x-mm-signature': signature,
|
||||
} }, response => {
|
||||
const chunks: Buffer[] = []; let size = 0
|
||||
response.on('data', (chunk: Buffer) => {
|
||||
size += chunk.length
|
||||
if (size > 4096) { response.destroy(); request.destroy(); finish(false) }
|
||||
else chunks.push(chunk)
|
||||
})
|
||||
response.once('error', () => finish(false))
|
||||
response.once('aborted', () => finish(false))
|
||||
response.once('end', () => {
|
||||
let accepted = false
|
||||
try {
|
||||
const value: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
||||
accepted = response.statusCode === 200 && !!value && typeof value === 'object' && !Array.isArray(value)
|
||||
&& Object.keys(value).sort().join(',') === 'duplicate,ok' && (value as {ok?: unknown}).ok === true
|
||||
&& typeof (value as {duplicate?: unknown}).duplicate === 'boolean'
|
||||
} catch { /* No raw transport/response/error material is logged. */ }
|
||||
finish(accepted)
|
||||
})
|
||||
})
|
||||
const deadline = setTimeout(() => { request.destroy(); finish(false) }, 3000)
|
||||
request.once('error', () => finish(false))
|
||||
request.once('close', () => { clearTimeout(deadline); resolve(outcome) })
|
||||
request.end(body)
|
||||
})
|
||||
}
|
||||
|
||||
export function configurePortalRevocationSender(app: FastifyInstance): void {
|
||||
if (!app.config.portalRevocationsEnabled) return
|
||||
const target = portalRevocationTarget(app.config.portalRevocationsTarget ?? '')
|
||||
const secret = app.config.portalRevocationsSecret
|
||||
if (!secret || secret.length < 32 || Buffer.byteLength(secret) > 1024) throw new Error('PORTAL_REVOCATION_SECRET_REQUIRED')
|
||||
enablePortalRevocations(app.db, target.href)
|
||||
let stopped = false, running: Promise<void> | undefined
|
||||
const abort = new AbortController()
|
||||
const run = async () => {
|
||||
// Bounded work per tick, single in-flight request; rows survive all failures/restarts.
|
||||
for (let i = 0; i < 4 && !stopped; i++) {
|
||||
const event = nextPortalRevocation(app.db)
|
||||
if (!event) return
|
||||
const delivered = await sendPortalRevocation(target, secret, event.body, abort.signal)
|
||||
completePortalRevocation(app.db, event, delivered)
|
||||
if (!delivered) return
|
||||
}
|
||||
}
|
||||
const tick = () => {
|
||||
if (stopped || running) return
|
||||
running = run().catch(() => { app.log.error({event: 'portal.revocation.delivery-unavailable'}, 'Revocation delivery unavailable') })
|
||||
.finally(() => { running = undefined })
|
||||
}
|
||||
let interval: ReturnType<typeof setInterval> | undefined
|
||||
app.addHook('onReady', async () => { interval = setInterval(tick, 1000); interval.unref(); tick() })
|
||||
// preClose executes before onClose closes SQLite. Abort then drain exact in-flight work.
|
||||
app.addHook('preClose', async () => { stopped = true; clearInterval(interval); abort.abort(); await running })
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { hash } from '@node-rs/argon2'
|
||||
import { createOidcClient, generateOidcClientSecret } from '@authportal/db'
|
||||
import { adminToken, buildTestApp } from './helpers.js'
|
||||
|
||||
type App = Awaited<ReturnType<typeof buildTestApp>>
|
||||
let app: App | undefined
|
||||
afterEach(async () => {
|
||||
await app?.close()
|
||||
app = undefined
|
||||
})
|
||||
|
||||
const auth = (token: string) => ({ authorization: `Bearer ${token}` })
|
||||
|
||||
async function login(a: App, email = 'admin@test.local', password = 'adminpass') {
|
||||
const res = await a.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email, password },
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
describe('auth', () => {
|
||||
it('login: неверный пароль 401, отключённый пользователь 401', async () => {
|
||||
app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
expect((await login(app, 'admin@test.local', 'wrong')).statusCode).toBe(401)
|
||||
expect((await login(app, 'ghost@test.local', 'whatever')).statusCode).toBe(401)
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/users',
|
||||
headers: auth(token),
|
||||
payload: {
|
||||
email: 'off@test.local',
|
||||
name: 'Off',
|
||||
password: 'secret12',
|
||||
is_admin: false,
|
||||
apps: ['cfdm'],
|
||||
permissions: [],
|
||||
},
|
||||
})
|
||||
const id = (created.json() as { id: string }).id
|
||||
await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/v1/admin/users/${id}`,
|
||||
headers: auth(token),
|
||||
payload: { disabled: true },
|
||||
})
|
||||
expect((await login(app, 'off@test.local', 'secret12')).statusCode).toBe(401)
|
||||
})
|
||||
|
||||
it('me / reissue / catalog / logout по cookie', async () => {
|
||||
app = await buildTestApp()
|
||||
const res = await login(app)
|
||||
const token = (res.json() as { access_token: string }).access_token
|
||||
const refresh = res.cookies.find((c) => c.name === 'refresh_token')!.value
|
||||
|
||||
const me = await app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth(token) })
|
||||
expect(me.statusCode).toBe(200)
|
||||
expect((me.json() as { email: string }).email).toBe('admin@test.local')
|
||||
|
||||
const reissue = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/reissue',
|
||||
headers: auth(token),
|
||||
})
|
||||
expect(reissue.statusCode).toBe(200)
|
||||
expect((reissue.json() as { access_token: string }).access_token).toBeTruthy()
|
||||
|
||||
const catalog = await app.inject({ method: 'GET', url: '/api/v1/catalog', headers: auth(token) })
|
||||
expect(catalog.statusCode).toBe(200)
|
||||
expect((catalog.json() as { apps: unknown[] }).apps.length).toBeGreaterThan(0)
|
||||
|
||||
const noAuth = await app.inject({ method: 'GET', url: '/api/v1/auth/me' })
|
||||
expect(noAuth.statusCode).toBe(401)
|
||||
|
||||
const logout = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/logout',
|
||||
headers: { ...auth(token), cookie: `refresh_token=${refresh}` },
|
||||
})
|
||||
expect(logout.statusCode).toBe(200)
|
||||
const revoked = app.sqlite
|
||||
.prepare('SELECT revoked_at FROM refresh_sessions')
|
||||
.get() as { revoked_at: string | null }
|
||||
expect(revoked.revoked_at).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('admin users', () => {
|
||||
it('создание: конфликт email 409, валидация 400; patch и удаление', async () => {
|
||||
app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
const payload = {
|
||||
email: 'dup@test.local',
|
||||
name: 'Dup',
|
||||
password: 'secret12',
|
||||
is_admin: false,
|
||||
apps: ['cfdm'],
|
||||
permissions: [],
|
||||
}
|
||||
const first = await app.inject({ method: 'POST', url: '/api/v1/admin/users', headers: auth(token), payload })
|
||||
expect(first.statusCode).toBe(201)
|
||||
const id = (first.json() as { id: string }).id
|
||||
const dup = await app.inject({ method: 'POST', url: '/api/v1/admin/users', headers: auth(token), payload })
|
||||
expect(dup.statusCode).toBe(409)
|
||||
const bad = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/users',
|
||||
headers: auth(token),
|
||||
payload: { email: 'nope' },
|
||||
})
|
||||
expect(bad.statusCode).toBe(400)
|
||||
|
||||
const patched = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/v1/admin/users/${id}`,
|
||||
headers: auth(token),
|
||||
payload: { name: 'Renamed', password: 'newsecret12' },
|
||||
})
|
||||
expect(patched.statusCode).toBe(200)
|
||||
expect((patched.json() as { name: string }).name).toBe('Renamed')
|
||||
expect((await login(app, 'dup@test.local', 'newsecret12')).statusCode).toBe(200)
|
||||
expect((await login(app, 'dup@test.local', 'secret12')).statusCode).toBe(401)
|
||||
|
||||
const del = await app.inject({ method: 'DELETE', url: `/api/v1/admin/users/${id}`, headers: auth(token) })
|
||||
expect(del.statusCode).toBe(204)
|
||||
const gone = await app.inject({ method: 'GET', url: `/api/v1/admin/users/${id}`, headers: auth(token) })
|
||||
expect(gone.statusCode).toBe(404)
|
||||
})
|
||||
|
||||
it('нельзя удалить себя; обычный пользователь не админ (403)', async () => {
|
||||
app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
const me = await app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth(token) })
|
||||
const myId = (me.json() as { id: string }).id
|
||||
const self = await app.inject({ method: 'DELETE', url: `/api/v1/admin/users/${myId}`, headers: auth(token) })
|
||||
expect(self.statusCode).toBe(400)
|
||||
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/users',
|
||||
headers: auth(token),
|
||||
payload: {
|
||||
email: 'plain@test.local',
|
||||
name: 'Plain',
|
||||
password: 'secret12',
|
||||
is_admin: false,
|
||||
apps: ['cfdm'],
|
||||
permissions: [],
|
||||
},
|
||||
})
|
||||
const userTok = ((await login(app, 'plain@test.local', 'secret12')).json() as { access_token: string }).access_token
|
||||
const forbidden = await app.inject({ method: 'GET', url: '/api/v1/admin/users', headers: auth(userTok) })
|
||||
expect(forbidden.statusCode).toBe(403)
|
||||
})
|
||||
})
|
||||
|
||||
describe('OIDC клиенты и обмен кода', () => {
|
||||
async function setup(a: App) {
|
||||
const token = await adminToken(a)
|
||||
const created = await a.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/oidc/clients',
|
||||
headers: auth(token),
|
||||
payload: {
|
||||
name: 'RP',
|
||||
redirect_uris: ['https://rp.test.local/cb'],
|
||||
scopes: ['openid', 'profile', 'email', 'groups'],
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
expect(created.statusCode).toBe(200)
|
||||
const client = created.json() as { id: string; client_id: string; client_secret: string }
|
||||
const loginRes = await login(a)
|
||||
const refresh = loginRes.cookies.find((c) => c.name === 'refresh_token')!.value
|
||||
return { token, client, refresh }
|
||||
}
|
||||
|
||||
async function authorizeCode(
|
||||
a: App,
|
||||
refresh: string,
|
||||
clientId: string,
|
||||
extra: Record<string, string> = {},
|
||||
): Promise<string> {
|
||||
const res = await a.inject({
|
||||
method: 'GET',
|
||||
url: '/oauth/authorize',
|
||||
cookies: { refresh_token: refresh },
|
||||
query: {
|
||||
client_id: clientId,
|
||||
redirect_uri: 'https://rp.test.local/cb',
|
||||
response_type: 'code',
|
||||
scope: 'openid profile',
|
||||
...extra,
|
||||
},
|
||||
})
|
||||
expect(res.statusCode).toBe(302)
|
||||
return new URL(res.headers.location as string).searchParams.get('code')!
|
||||
}
|
||||
|
||||
const exchange = (a: App, body: Record<string, string>) =>
|
||||
a.inject({
|
||||
method: 'POST',
|
||||
url: '/oauth/token',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
payload: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: 'https://rp.test.local/cb',
|
||||
...body,
|
||||
}).toString(),
|
||||
})
|
||||
|
||||
it('секрет нового клиента хранится как sha256, обмен работает, replay кода отклоняется', async () => {
|
||||
app = await buildTestApp()
|
||||
const { client, refresh } = await setup(app)
|
||||
const stored = app.sqlite
|
||||
.prepare('SELECT client_secret_hash h FROM oidc_clients')
|
||||
.get() as { h: string }
|
||||
expect(stored.h.startsWith('sha256:')).toBe(true)
|
||||
|
||||
const code = await authorizeCode(app, refresh, client.client_id)
|
||||
const ok = await exchange(app, {
|
||||
client_id: client.client_id,
|
||||
client_secret: client.client_secret,
|
||||
code,
|
||||
})
|
||||
expect(ok.statusCode).toBe(200)
|
||||
expect((ok.json() as { id_token: string }).id_token).toBeTruthy()
|
||||
|
||||
const replay = await exchange(app, {
|
||||
client_id: client.client_id,
|
||||
client_secret: client.client_secret,
|
||||
code,
|
||||
})
|
||||
expect(replay.statusCode).toBe(400)
|
||||
expect((replay.json() as { error: string }).error).toBe('invalid_grant')
|
||||
})
|
||||
|
||||
it('PKCE: S256 принимается, неверный verifier и отсутствие verifier отклоняются', async () => {
|
||||
app = await buildTestApp()
|
||||
const { client, refresh } = await setup(app)
|
||||
const verifier = 'v'.repeat(50)
|
||||
const challenge = createHash('sha256').update(verifier).digest('base64url')
|
||||
const pkce = { code_challenge: challenge, code_challenge_method: 'S256' }
|
||||
const creds = { client_id: client.client_id, client_secret: client.client_secret }
|
||||
|
||||
const good = await exchange(app, {
|
||||
...creds,
|
||||
code: await authorizeCode(app, refresh, client.client_id, pkce),
|
||||
code_verifier: verifier,
|
||||
})
|
||||
expect(good.statusCode).toBe(200)
|
||||
|
||||
const wrong = await exchange(app, {
|
||||
...creds,
|
||||
code: await authorizeCode(app, refresh, client.client_id, pkce),
|
||||
code_verifier: 'x'.repeat(50),
|
||||
})
|
||||
expect(wrong.statusCode).toBe(400)
|
||||
|
||||
const missing = await exchange(app, {
|
||||
...creds,
|
||||
code: await authorizeCode(app, refresh, client.client_id, pkce),
|
||||
})
|
||||
expect(missing.statusCode).toBe(400)
|
||||
|
||||
const plain = await exchange(app, {
|
||||
...creds,
|
||||
code: await authorizeCode(app, refresh, client.client_id, {
|
||||
code_challenge: 'plain-challenge',
|
||||
code_challenge_method: 'plain',
|
||||
}),
|
||||
code_verifier: 'plain-challenge',
|
||||
})
|
||||
expect(plain.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('неверный секрет 401; чужой redirect_uri и scope отклоняются; userinfo по access_token', async () => {
|
||||
app = await buildTestApp()
|
||||
const { client, refresh } = await setup(app)
|
||||
const code = await authorizeCode(app, refresh, client.client_id)
|
||||
|
||||
const badSecret = await exchange(app, {
|
||||
client_id: client.client_id,
|
||||
client_secret: 'nope',
|
||||
code,
|
||||
})
|
||||
expect(badSecret.statusCode).toBe(401)
|
||||
|
||||
const badRedirect = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/oauth/authorize',
|
||||
cookies: { refresh_token: refresh },
|
||||
query: {
|
||||
client_id: client.client_id,
|
||||
redirect_uri: 'https://evil.test/cb',
|
||||
response_type: 'code',
|
||||
scope: 'openid',
|
||||
},
|
||||
})
|
||||
expect(badRedirect.statusCode).toBe(400)
|
||||
|
||||
const badScope = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/oauth/authorize',
|
||||
cookies: { refresh_token: refresh },
|
||||
query: {
|
||||
client_id: client.client_id,
|
||||
redirect_uri: 'https://rp.test.local/cb',
|
||||
response_type: 'code',
|
||||
scope: 'openid admin',
|
||||
},
|
||||
})
|
||||
expect(badScope.statusCode).toBe(302)
|
||||
expect(badScope.headers.location).toContain('error=invalid_scope')
|
||||
|
||||
const ok = await exchange(app, {
|
||||
client_id: client.client_id,
|
||||
client_secret: client.client_secret,
|
||||
code,
|
||||
})
|
||||
const accessToken = (ok.json() as { access_token: string }).access_token
|
||||
const info = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/oauth/userinfo',
|
||||
headers: auth(accessToken),
|
||||
})
|
||||
expect(info.statusCode).toBe(200)
|
||||
expect((info.json() as { email: string }).email).toBe('admin@test.local')
|
||||
const noToken = await app.inject({ method: 'GET', url: '/oauth/userinfo' })
|
||||
expect(noToken.statusCode).toBe(401)
|
||||
})
|
||||
|
||||
it('без сессии authorize редиректит на логин', async () => {
|
||||
app = await buildTestApp()
|
||||
const { client } = await setup(app)
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/oauth/authorize',
|
||||
query: {
|
||||
client_id: client.client_id,
|
||||
redirect_uri: 'https://rp.test.local/cb',
|
||||
response_type: 'code',
|
||||
scope: 'openid',
|
||||
},
|
||||
})
|
||||
expect(res.statusCode).toBe(302)
|
||||
expect(res.headers.location).toContain('return_to=')
|
||||
})
|
||||
|
||||
it('старый argon2-секрет работает и мигрирует в sha256', async () => {
|
||||
app = await buildTestApp()
|
||||
const { refresh } = await setup(app)
|
||||
const secret = generateOidcClientSecret()
|
||||
const legacy = createOidcClient(app.db, {
|
||||
name: 'Legacy',
|
||||
redirectUris: ['https://rp.test.local/cb'],
|
||||
scopes: ['openid', 'profile'],
|
||||
enabled: true,
|
||||
clientSecretHash: await hash(secret),
|
||||
})
|
||||
const code = await authorizeCode(app, refresh, legacy.clientId)
|
||||
const res = await exchange(app, {
|
||||
client_id: legacy.clientId,
|
||||
client_secret: secret,
|
||||
code,
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
const row = app.sqlite
|
||||
.prepare('SELECT client_secret_hash h FROM oidc_clients WHERE client_id=?')
|
||||
.get(legacy.clientId) as { h: string }
|
||||
expect(row.h.startsWith('sha256:')).toBe(true)
|
||||
|
||||
// после миграции секрет всё ещё работает
|
||||
const code2 = await authorizeCode(app, refresh, legacy.clientId)
|
||||
const again = await exchange(app, {
|
||||
client_id: legacy.clientId,
|
||||
client_secret: secret,
|
||||
code: code2,
|
||||
})
|
||||
expect(again.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('CRUD клиента: patch, rotate-secret инвалидирует старый секрет, delete', async () => {
|
||||
app = await buildTestApp()
|
||||
const { token, client, refresh } = await setup(app)
|
||||
|
||||
const patch = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/v1/admin/oidc/clients/${client.id}`,
|
||||
headers: auth(token),
|
||||
payload: { name: 'Renamed RP' },
|
||||
})
|
||||
expect(patch.statusCode).toBe(200)
|
||||
expect((patch.json() as { name: string }).name).toBe('Renamed RP')
|
||||
|
||||
const rotate = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/admin/oidc/clients/${client.id}/rotate-secret`,
|
||||
headers: auth(token),
|
||||
})
|
||||
expect(rotate.statusCode).toBe(200)
|
||||
const fresh = (rotate.json() as { client_secret: string }).client_secret
|
||||
expect(fresh).not.toBe(client.client_secret)
|
||||
|
||||
const code = await authorizeCode(app, refresh, client.client_id)
|
||||
expect(
|
||||
(await exchange(app, { client_id: client.client_id, client_secret: client.client_secret, code })).statusCode,
|
||||
).toBe(401)
|
||||
expect(
|
||||
(await exchange(app, { client_id: client.client_id, client_secret: fresh, code })).statusCode,
|
||||
).toBe(200)
|
||||
|
||||
const del = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/admin/oidc/clients/${client.id}`,
|
||||
headers: auth(token),
|
||||
})
|
||||
expect(del.statusCode).toBe(200)
|
||||
const gone = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/admin/oidc/clients/${client.id}`,
|
||||
headers: auth(token),
|
||||
})
|
||||
expect(gone.statusCode).toBe(404)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildTestApp } from './helpers.js'
|
||||
|
||||
describe('app-switcher API', () => {
|
||||
it('GET /api/v1/app-switcher is public and returns defaults', async () => {
|
||||
const app = await buildTestApp()
|
||||
const res = await app.inject({ method: 'GET', url: '/api/v1/app-switcher' })
|
||||
expect(res.statusCode).toBe(200)
|
||||
const body = res.json() as { menuLabel: string; apps: { id: string }[] }
|
||||
expect(body.menuLabel).toBeTruthy()
|
||||
expect(body.apps.map((a) => a.id).sort()).toEqual([
|
||||
'bgp',
|
||||
'cdn',
|
||||
'cfdm',
|
||||
'dns',
|
||||
'fw',
|
||||
'mm',
|
||||
'vps',
|
||||
])
|
||||
expect(body.apps.find((a) => a.id === 'cdn')).toMatchObject({
|
||||
authMode: 'jwt',
|
||||
})
|
||||
expect(body.apps.find((a) => a.id === 'mm')).toMatchObject({
|
||||
authMode: 'jwt',
|
||||
})
|
||||
expect(body.apps.find((a) => a.id === 'dns')).toMatchObject({
|
||||
authMode: 'oidc',
|
||||
})
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('PUT /api/v1/admin/app-switcher requires admin and persists', async () => {
|
||||
const app = await buildTestApp()
|
||||
|
||||
const denied = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/v1/admin/app-switcher',
|
||||
payload: { menuLabel: 'Apps', apps: [] },
|
||||
})
|
||||
expect(denied.statusCode).toBe(401)
|
||||
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email: 'admin@test.local', password: 'adminpass' },
|
||||
})
|
||||
expect(login.statusCode).toBe(200)
|
||||
const token = (login.json() as { access_token: string }).access_token
|
||||
|
||||
const getBefore = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/app-switcher',
|
||||
})
|
||||
const before = getBefore.json() as {
|
||||
menuLabel: string
|
||||
apps: {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
icon: string
|
||||
enabled: boolean
|
||||
}[]
|
||||
}
|
||||
|
||||
const updated = {
|
||||
menuLabel: 'Сервисы',
|
||||
apps: before.apps.map((a) =>
|
||||
a.id === 'cfdm'
|
||||
? { ...a, url: 'https://cfdm.example.test', name: 'CFDM Test' }
|
||||
: a,
|
||||
),
|
||||
}
|
||||
|
||||
const put = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/v1/admin/app-switcher',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: updated,
|
||||
})
|
||||
expect(put.statusCode).toBe(200)
|
||||
expect(put.json()).toMatchObject({ menuLabel: 'Сервисы' })
|
||||
|
||||
const getAfter = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/app-switcher',
|
||||
})
|
||||
const after = getAfter.json() as typeof before
|
||||
expect(after.menuLabel).toBe('Сервисы')
|
||||
expect(after.apps.find((a) => a.id === 'cfdm')?.url).toBe(
|
||||
'https://cfdm.example.test',
|
||||
)
|
||||
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,302 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { appendAudit, listAudit, purgeAuditOlderThan } from '@authportal/db'
|
||||
import { adminToken, buildTestApp } from './helpers.js'
|
||||
|
||||
describe('audit log API', () => {
|
||||
it('ingests external events with secret and dedupes by event_id', async () => {
|
||||
const app = await buildTestApp()
|
||||
const denied = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/ingest/audit',
|
||||
payload: { events: [] },
|
||||
})
|
||||
expect(denied.statusCode).toBe(401)
|
||||
|
||||
const eventId = 'evt-test-1'
|
||||
const ok = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/ingest/audit',
|
||||
headers: { authorization: 'Bearer dev-audit-ingest-secret' },
|
||||
payload: {
|
||||
events: [
|
||||
{
|
||||
event_id: eventId,
|
||||
source_app: 'vps',
|
||||
action: 'vps.vps.create',
|
||||
summary: 'Создан VPS',
|
||||
actor_email: 'ops@test.local',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(ok.statusCode).toBe(200)
|
||||
expect(ok.json()).toMatchObject({ accepted: 1, duplicates: 0 })
|
||||
|
||||
const dup = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/ingest/audit',
|
||||
headers: { authorization: 'Bearer dev-audit-ingest-secret' },
|
||||
payload: {
|
||||
events: [
|
||||
{
|
||||
event_id: eventId,
|
||||
source_app: 'vps',
|
||||
action: 'vps.vps.create',
|
||||
summary: 'Создан VPS',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(dup.json()).toMatchObject({ accepted: 0, duplicates: 1 })
|
||||
|
||||
const token = await adminToken(app)
|
||||
const list = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit?source_app=vps',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(list.statusCode).toBe(200)
|
||||
const entries = list.json() as { source_app: string; action: string }[]
|
||||
expect(entries.some((e) => e.action === 'vps.vps.create')).toBe(true)
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('records login and lists for admin', async () => {
|
||||
const app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
|
||||
const list = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(list.statusCode).toBe(200)
|
||||
const entries = list.json() as { action: string }[]
|
||||
expect(entries.some((e) => e.action === 'auth.login')).toBe(true)
|
||||
|
||||
const denied = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit',
|
||||
})
|
||||
expect(denied.statusCode).toBe(401)
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('updates retention and purges old rows', async () => {
|
||||
const app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
|
||||
appendAudit(app.db, {
|
||||
action: 'user.create',
|
||||
summary: 'old event',
|
||||
})
|
||||
// Backdate the last inserted row
|
||||
app.sqlite
|
||||
.prepare(
|
||||
`UPDATE audit_log SET created_at = ? WHERE summary = 'old event'`,
|
||||
)
|
||||
.run(new Date(Date.now() - 40 * 24 * 60 * 60 * 1000).toISOString())
|
||||
|
||||
const settings = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/v1/admin/audit/settings',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { retention_days: 30 },
|
||||
})
|
||||
expect(settings.statusCode).toBe(200)
|
||||
expect(settings.json()).toEqual({ retention_days: 30 })
|
||||
|
||||
const before = listAudit(app.db, { limit: 500 })
|
||||
expect(before.some((e) => e.summary === 'old event')).toBe(true)
|
||||
|
||||
const deleted = purgeAuditOlderThan(app.db, 30)
|
||||
expect(deleted).toBeGreaterThanOrEqual(1)
|
||||
expect(listAudit(app.db, { limit: 500 }).some((e) => e.summary === 'old event')).toBe(
|
||||
false,
|
||||
)
|
||||
|
||||
const purge = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/audit/purge',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(purge.statusCode).toBe(200)
|
||||
expect((purge.json() as { retention_days: number }).retention_days).toBe(30)
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('records user.create from admin mutation', async () => {
|
||||
const app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
|
||||
const create = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/users',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
email: 'new@test.local',
|
||||
name: 'New User',
|
||||
password: 'secret12',
|
||||
is_admin: false,
|
||||
apps: ['cfdm'],
|
||||
permissions: [],
|
||||
},
|
||||
})
|
||||
expect(create.statusCode).toBe(201)
|
||||
|
||||
const list = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit?action=user.create',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(list.statusCode).toBe(200)
|
||||
const entries = list.json() as { action: string; summary: string }[]
|
||||
expect(entries.length).toBeGreaterThanOrEqual(1)
|
||||
expect(entries[0]?.summary).toContain('new@test.local')
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('records login with IP, UA and last_login_ip', async () => {
|
||||
const app = await buildTestApp()
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
headers: {
|
||||
'x-forwarded-for': '203.0.113.10',
|
||||
'user-agent': 'VitestBrowser/1.0',
|
||||
},
|
||||
payload: {
|
||||
email: 'admin@test.local',
|
||||
password: 'adminpass',
|
||||
return_to: 'https://vps.example.test/dashboard',
|
||||
},
|
||||
})
|
||||
expect(login.statusCode).toBe(200)
|
||||
const token = (login.json() as { access_token: string }).access_token
|
||||
|
||||
const users = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/users',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
const admin = (
|
||||
users.json() as { email: string; last_login_ip: string | null }[]
|
||||
).find((u) => u.email === 'admin@test.local')
|
||||
expect(admin?.last_login_ip).toBe('203.0.113.10')
|
||||
|
||||
const list = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit?kind=logins',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
const entries = list.json() as {
|
||||
action: string
|
||||
ip: string | null
|
||||
details: Record<string, unknown> | null
|
||||
}[]
|
||||
const loginEvt = entries.find((e) => e.action === 'auth.login')
|
||||
expect(loginEvt?.ip).toBe('203.0.113.10')
|
||||
expect(loginEvt?.details?.user_agent).toBe('VitestBrowser/1.0')
|
||||
expect(loginEvt?.details?.target_app).toBe('vps')
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('records failed login with email and IP', async () => {
|
||||
const app = await buildTestApp()
|
||||
const fail = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
headers: { 'x-forwarded-for': '198.51.100.7' },
|
||||
payload: { email: 'nobody@test.local', password: 'wrong' },
|
||||
})
|
||||
expect(fail.statusCode).toBe(401)
|
||||
|
||||
const token = await adminToken(app)
|
||||
const list = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit?kind=logins&actor_email=nobody@test.local',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
const entries = list.json() as {
|
||||
action: string
|
||||
actor_email: string | null
|
||||
ip: string | null
|
||||
}[]
|
||||
expect(entries.some((e) => e.action === 'auth.login_failed')).toBe(true)
|
||||
expect(entries[0]?.actor_email).toBe('nobody@test.local')
|
||||
expect(entries[0]?.ip).toBe('198.51.100.7')
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('records sso-access and separates kind=changes', async () => {
|
||||
const app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
|
||||
const sso = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/sso-access',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { return_to: 'https://bgp.example.test/' },
|
||||
})
|
||||
expect(sso.statusCode).toBe(200)
|
||||
expect((sso.json() as { target_app: string }).target_app).toBe('bgp')
|
||||
|
||||
const logins = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit?kind=logins',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
const loginEntries = logins.json() as { action: string }[]
|
||||
expect(loginEntries.every((e) => e.action.startsWith('auth.'))).toBe(true)
|
||||
expect(loginEntries.some((e) => e.action === 'auth.sso_handoff')).toBe(true)
|
||||
|
||||
const changes = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit?kind=changes',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
const changeEntries = changes.json() as { action: string }[]
|
||||
expect(changeEntries.every((e) => !e.action.startsWith('auth.'))).toBe(true)
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('lists and revokes refresh sessions', async () => {
|
||||
const app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
|
||||
const sessions = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/sessions',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(sessions.statusCode).toBe(200)
|
||||
const rows = sessions.json() as { id: string; user_id: string }[]
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1)
|
||||
const sessionId = rows[0]!.id
|
||||
|
||||
const revoke = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/admin/sessions/${sessionId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(revoke.statusCode).toBe(200)
|
||||
|
||||
const after = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/sessions',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(
|
||||
(after.json() as { id: string }[]).some((s) => s.id === sessionId),
|
||||
).toBe(false)
|
||||
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { listBackups } from '@authportal/db'
|
||||
import { loadConfig } from '../src/config.js'
|
||||
import { backupDirFor, runBackup, startBackupJob } from '../src/services/backup.js'
|
||||
import { buildApp } from '../src/app.js'
|
||||
import { adminToken } from './helpers.js'
|
||||
|
||||
const dirs: string[] = []
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
for (const d of dirs.splice(0)) {
|
||||
try {
|
||||
rmSync(d, { recursive: true, force: true })
|
||||
} catch {
|
||||
/* Windows может держать файл БД до GC — временный каталог не критичен */
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function env(dir: string, extra: NodeJS.ProcessEnv = {}) {
|
||||
return loadConfig({
|
||||
JWT_SECRET: 'test-secret-at-least-8',
|
||||
ADMIN_EMAIL: 'admin@test.local',
|
||||
ADMIN_PASSWORD: 'adminpass',
|
||||
DATABASE_URL: `sqlite:${join(dir, 'app.db')}`,
|
||||
NODE_ENV: 'test',
|
||||
...extra,
|
||||
})
|
||||
}
|
||||
|
||||
describe('backup', () => {
|
||||
it('backupDirFor: по умолчанию рядом с БД, BACKUP_DIR приоритетнее', () => {
|
||||
const cfg = env('/data/x')
|
||||
expect(backupDirFor(cfg).replaceAll(String.fromCharCode(92), '/')).toMatch(/\/data\/x\/backups$/)
|
||||
const custom = env('/data/x', { BACKUP_DIR: '/mnt/bk' })
|
||||
expect(backupDirFor(custom).replaceAll(String.fromCharCode(92), '/')).toMatch(/\/mnt\/bk$/)
|
||||
})
|
||||
|
||||
it('бэкап живого приложения восстанавливается: вход админа работает', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ap-api-bk-'))
|
||||
dirs.push(dir)
|
||||
const config = env(dir, { BACKUP_KEEP: '2' })
|
||||
const app = await buildApp({ config })
|
||||
const res = await runBackup(app.sqlite, config)
|
||||
expect(listBackups(backupDirFor(config))).toHaveLength(1)
|
||||
await app.close()
|
||||
|
||||
const restored = await buildApp({
|
||||
config: env(dir),
|
||||
databaseUrl: `sqlite:${res.file}`,
|
||||
})
|
||||
const token = await adminToken(restored as never)
|
||||
expect(token).toBeTruthy()
|
||||
await restored.close()
|
||||
})
|
||||
|
||||
it('startBackupJob выключен при 0 часов и работает по таймеру', async () => {
|
||||
vi.useFakeTimers()
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ap-job-bk-'))
|
||||
dirs.push(dir)
|
||||
const config = env(dir, { BACKUP_INTERVAL_HOURS: '1' })
|
||||
const app = await buildApp({ config })
|
||||
|
||||
const base = vi.getTimerCount()
|
||||
const off = startBackupJob({ ...app, config: { ...config, backupIntervalHours: 0 } } as never)
|
||||
expect(vi.getTimerCount()).toBe(base)
|
||||
off()
|
||||
|
||||
const stop = startBackupJob(app)
|
||||
expect(vi.getTimerCount()).toBe(base + 1)
|
||||
await vi.advanceTimersByTimeAsync(60 * 60 * 1000 + 10)
|
||||
vi.useRealTimers()
|
||||
await vi.waitFor(() =>
|
||||
expect(listBackups(backupDirFor(config)).length).toBeGreaterThan(0),
|
||||
)
|
||||
stop()
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
@@ -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','h@t.io','H','x',0,0,'t','t')`,
|
||||
)
|
||||
.run()
|
||||
app.sqlite
|
||||
.prepare(
|
||||
`INSERT INTO refresh_sessions (id,user_id,token_hash,expires_at,created_at)
|
||||
VALUES ('old','uh','h','2000-01-01T00:00:00.000Z','t')`,
|
||||
)
|
||||
.run()
|
||||
const stop = startHousekeepingJob(app)
|
||||
await vi.advanceTimersByTimeAsync(31_000)
|
||||
const left = app.sqlite
|
||||
.prepare(`SELECT COUNT(*) c FROM refresh_sessions WHERE id='old'`)
|
||||
.get()
|
||||
expect(left).toEqual({ c: 0 })
|
||||
stop()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const root = resolve(import.meta.dirname, '../../..')
|
||||
const read = (p: string) => readFileSync(resolve(root, p), 'utf-8')
|
||||
|
||||
const dockerfile = read('deploy/docker/authportal/Dockerfile')
|
||||
const composes = {
|
||||
'docker-compose.yml': read('docker-compose.yml'),
|
||||
'deploy/docker-compose.traefik.yml': read('deploy/docker-compose.traefik.yml'),
|
||||
}
|
||||
|
||||
describe('Dockerfile', () => {
|
||||
it('graceful stop и ограничение ресурсов Node', () => {
|
||||
expect(dockerfile).toMatch(/^STOPSIGNAL SIGTERM$/m)
|
||||
expect(dockerfile).toMatch(/NODE_OPTIONS=--max-old-space-size=\d+/)
|
||||
expect(dockerfile).toMatch(/UV_THREADPOOL_SIZE=4/)
|
||||
})
|
||||
|
||||
it('healthcheck без запуска node', () => {
|
||||
const hc = dockerfile.split('HEALTHCHECK')[1]!.split('\n').slice(0, 2).join(' ')
|
||||
expect(hc).toContain('wget')
|
||||
expect(hc).not.toContain('"node"')
|
||||
})
|
||||
|
||||
it('запуск не от root, рантайм без тулчейна и pnpm deploy', () => {
|
||||
expect(dockerfile).toMatch(/^USER node$/m)
|
||||
const runtime = dockerfile.split('AS runtime')[1]!
|
||||
expect(runtime).not.toMatch(/apk add/)
|
||||
expect(dockerfile).not.toMatch(/pnpm .*deploy/)
|
||||
})
|
||||
|
||||
it('статика сжимается только brotli', () => {
|
||||
expect(dockerfile).toContain('brotli')
|
||||
expect(dockerfile).not.toMatch(/gzip -k/)
|
||||
})
|
||||
|
||||
it('preview-маршруты дизайна вырезаются до сборки web', () => {
|
||||
const rm = dockerfile.indexOf('rm -f apps/web/src/routes/design-preview*.tsx')
|
||||
const build = dockerfile.indexOf('pnpm turbo build')
|
||||
expect(rm).toBeGreaterThan(0)
|
||||
expect(rm).toBeLessThan(build)
|
||||
})
|
||||
|
||||
it('базовый образ закреплён по digest', () => {
|
||||
expect(dockerfile).toMatch(/ARG BASE_NODE=\S+@sha256:[0-9a-f]{64}/)
|
||||
expect(read('deploy/docker/docker-bake.hcl')).toMatch(/node:22-alpine@sha256:[0-9a-f]{64}/)
|
||||
})
|
||||
|
||||
it('в образ копируется весь dist API, а tsup не дробит бандл на чанки', () => {
|
||||
expect(dockerfile).toContain('cp -r apps/api/dist /out/dist')
|
||||
expect(read('apps/api/tsup.config.ts')).toMatch(/splitting:\s*false/)
|
||||
})
|
||||
|
||||
it('нативные модули проверяются при сборке', () => {
|
||||
expect(dockerfile).toContain('native modules ok')
|
||||
})
|
||||
})
|
||||
|
||||
describe.each(Object.entries(composes))('%s', (_name, text) => {
|
||||
it('init, grace period и лимиты ресурсов', () => {
|
||||
expect(text).toMatch(/init: true/)
|
||||
expect(text).toMatch(/stop_grace_period: 15s/)
|
||||
expect(text).toMatch(/mem_limit: 256m/)
|
||||
expect(text).toMatch(/pids_limit: \d+/)
|
||||
expect(text).toMatch(/read_only: true/)
|
||||
expect(text).toMatch(/cap_drop:\s*\n\s*- ALL/)
|
||||
expect(text).toMatch(/no-new-privileges:true/)
|
||||
})
|
||||
|
||||
it('healthcheck через wget', () => {
|
||||
expect(text).toMatch(/"wget", "-q"/)
|
||||
expect(text).not.toMatch(/fetch\('http:\/\/127\.0\.0\.1:8080\/health'\)/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('traefik', () => {
|
||||
it('app ждёт healthy traefik, у traefik есть healthcheck', () => {
|
||||
const t = composes['deploy/docker-compose.traefik.yml']
|
||||
expect(t).toMatch(/condition: service_healthy/)
|
||||
expect(t).toContain('traefik", "healthcheck", "--ping')
|
||||
expect(t).toContain('--ping=true')
|
||||
})
|
||||
|
||||
it('включено сжатие ответов', () => {
|
||||
expect(composes['deploy/docker-compose.traefik.yml']).toContain(
|
||||
'middlewares.auth-portal-compress.compress=true',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import { buildTestApp } from './helpers.js'
|
||||
|
||||
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await app?.close()
|
||||
app = undefined
|
||||
})
|
||||
|
||||
describe('error handler', () => {
|
||||
it('необработанная ошибка → 500 без деталей', async () => {
|
||||
app = await buildTestApp()
|
||||
app.get('/__boom', async () => {
|
||||
throw new Error('secret internals')
|
||||
})
|
||||
const res = await app.inject({ method: 'GET', url: '/__boom' })
|
||||
expect(res.statusCode).toBe(500)
|
||||
expect(res.json()).toEqual({
|
||||
error: { code: 'INTERNAL', message: 'Internal server error' },
|
||||
})
|
||||
expect(res.body).not.toContain('secret internals')
|
||||
})
|
||||
|
||||
it('ZodError → 400 BAD_REQUEST', async () => {
|
||||
app = await buildTestApp()
|
||||
app.get('/__zod', async () => z.object({ a: z.string() }).parse({}))
|
||||
const res = await app.inject({ method: 'GET', url: '/__zod' })
|
||||
expect(res.statusCode).toBe(400)
|
||||
expect(res.json().error.code).toBe('BAD_REQUEST')
|
||||
})
|
||||
|
||||
it('ошибка с statusCode 4xx сохраняет статус', async () => {
|
||||
app = await buildTestApp()
|
||||
app.get('/__teapot', async () => {
|
||||
throw Object.assign(new Error('nope'), { statusCode: 418 })
|
||||
})
|
||||
const res = await app.inject({ method: 'GET', url: '/__teapot' })
|
||||
expect(res.statusCode).toBe(418)
|
||||
})
|
||||
})
|
||||
|
||||
describe('not found', () => {
|
||||
it.each(['/oauth/nope', '/.well-known/nope', '/api/v1/nope'])(
|
||||
'%s → 404 JSON',
|
||||
async (url) => {
|
||||
app = await buildTestApp()
|
||||
const res = await app.inject({ method: 'GET', url })
|
||||
expect(res.statusCode).toBe(404)
|
||||
expect(res.json().error.code).toBe('NOT_FOUND')
|
||||
},
|
||||
)
|
||||
|
||||
it('без STATIC_DIR неизвестный путь → 404 JSON', async () => {
|
||||
app = await buildTestApp()
|
||||
const res = await app.inject({ method: 'GET', url: '/some/spa/route' })
|
||||
expect(res.statusCode).toBe(404)
|
||||
})
|
||||
|
||||
it('со STATIC_DIR: SPA-путь → index.html, /oauth и /.well-known → 404', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ap-static-'))
|
||||
writeFileSync(join(dir, 'index.html'), '<html>spa</html>')
|
||||
app = await buildTestApp({ STATIC_DIR: dir })
|
||||
const spa = await app.inject({ method: 'GET', url: '/some/spa/route' })
|
||||
expect(spa.statusCode).toBe(200)
|
||||
expect(spa.body).toContain('spa')
|
||||
for (const url of ['/oauth/nope', '/.well-known/nope']) {
|
||||
const res = await app.inject({ method: 'GET', url })
|
||||
expect(res.statusCode).toBe(404)
|
||||
expect(res.json().error.code).toBe('NOT_FOUND')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import { expect } from 'vitest'
|
||||
import { buildApp } from '../src/app.js'
|
||||
import { loadConfig } from '../src/config.js'
|
||||
import { resetOidcKeyCache } from '../src/lib/oidc/keys.js'
|
||||
|
||||
/** Фиксированный тестовый RSA-ключ: importPKCS8 (~мс) вместо generateKeyPair
|
||||
* (~100–300 мс) при построении приложения в каждом тесте. */
|
||||
const TEST_OIDC_RSA_PRIVATE_KEY = [
|
||||
'-----BEGIN PRIVATE KEY-----',
|
||||
'MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCt/+Vwi6txs4ET',
|
||||
'/OczfcOX0mVCOfxT3qGejj1xX19A4gKeGGjQ5LSBDGzcztncYMd0J/H1ozUWn0NU',
|
||||
'ctolNRSTFchAYj1MoC2Pacfhe445nLR4bOssdADO9bwhHmWy8WQFyYc/lqs/xPSd',
|
||||
'haZFwDRBRRfh9uxzzUPR8FXCovHEJG5HiLuvmALL1W7nkDg+L+vyLYCNje482hS+',
|
||||
'ZcW3IH17gQpXgpqgXPaGHfZT1teIiYwhelaqPsApMQroOisKYGoQi1z/G/mdvaOF',
|
||||
'/jravyBqhr5Hm03lFOMBvMptGrXsjopy+BVlqBVcvdwdyFfz94dayhnCmfEIvK7W',
|
||||
'ynYGtoLnAgMBAAECggEAKqld04DjOb8FzGVLOKRCwyQ6YIkAFX42CT6d0QDNxYZE',
|
||||
'lwesF1+j5CosPN1YrEBsVs3+dLgd6ZyF8Rwx4UKI92E1VpffqLxzP0Dz2ZSmM/j4',
|
||||
'hLhL1vvgQN4eUM53p+6olASr7QrflQSQ51hgAqUUCtbKesREiEYRNVWkaVLzTSAr',
|
||||
'KrwWAF81hzEms0KuHw6/EgThBkWYGAMzZjECFa173yBHklt1ikdB1AOmieL9PZbB',
|
||||
'anjIequkUE2yoTJM/wOd2GN7sy61PmnMApwl1ke6k4VhrJeQhfahck0D2ynDvWqX',
|
||||
'fnn1Wr/BRmSi38RNEOySWkrCVjM0XSjO3wEuHFFyuQKBgQDwgOgzgL1W5b13LKXo',
|
||||
'8F/AJNRmi4vpM1lftMRalx5NU5ugw1iQ2zJzLG3Tl1/nvyt8tGFkXB7Dw9M9H0as',
|
||||
'jjk7D8ZzL1a7COWQx7wya1l8vMxwv40ERvoWypwpuVo12SvRnHWgGwsbqW4wxYIG',
|
||||
'9HsdvbxglFiVcSq+b6FDdg2nyQKBgQC5NgKatFPPed/DFb6MCkW+5Wnw2HzdKHxD',
|
||||
'fEkAJeb3xX+9k7OifRHppurwaDV4jUPJ5W1Whly77cg+xmEYAbUdPS1kgfYJaDn1',
|
||||
'mPN9dI7RP15v4X46/+5v5EZYAKcdUdMzr9gJ2iQVpoDi5ojp+LJknPLBuo7XTfp3',
|
||||
'kj9lifONLwKBgHa3SudRXyTr1rBjC6qGLN2N0IKl03RTcoR5mIsXGVAWOzYJ90fZ',
|
||||
'y3dqsPPHPwIILZErAQAnMNB1c2oybBJ1Se7LhKwK7Ab7B0aGMXG557xY/ZmSFCvO',
|
||||
'VVi2vosiAVOfVa/sSWu0rbED226br+l6+OLfa4NI9skdyXmjZLkGJwtxAoGBALDp',
|
||||
'LkOnzAZ6W4p00rXheg2GBVPQ9jQp38h2hyRlD+HL/OCQnR2wadPNZMyOPcUQ6peH',
|
||||
'VpE3dozr7EJKK6RKvT1YmmtO2DzUGcjGeI51L5Yf+YzUz0Z8AL1QRwD4Xqha6GqO',
|
||||
'pMWEYMNtb99MZatXqs8AO8Fg+eAe1Agy4euTgdhxAoGBAPAgrLUl7uJMdCsrHFEG',
|
||||
'K8CH3BZwz2ytSj6429IdX4OCzkkmdsWnjx5/FK2hc+fXMluhybaIziSntgs55vzZ',
|
||||
'1hd/Xw1T2FSTS6BuWegxVtjO6BQjbVXrxOumA0PPmFYaGMPvvBQrbZy8OSeuaKD/',
|
||||
'a9jKZApDKnT8vmYvmyMMtsmE',
|
||||
'-----END PRIVATE KEY-----',
|
||||
].join('\n')
|
||||
|
||||
export async function buildTestApp(env: NodeJS.ProcessEnv = {}) {
|
||||
resetOidcKeyCache()
|
||||
const config = loadConfig({
|
||||
...process.env,
|
||||
JWT_SECRET: 'test-secret-at-least-8',
|
||||
ADMIN_EMAIL: 'admin@test.local',
|
||||
ADMIN_PASSWORD: 'adminpass',
|
||||
DATABASE_URL: 'sqlite::memory:',
|
||||
ISSUER: 'https://auth.test.local',
|
||||
OIDC_ISSUER: 'https://auth.test.local',
|
||||
NODE_ENV: 'test',
|
||||
AUDIT_INGEST_SECRET: 'dev-audit-ingest-secret',
|
||||
OIDC_RSA_PRIVATE_KEY: TEST_OIDC_RSA_PRIVATE_KEY,
|
||||
...env,
|
||||
})
|
||||
return buildApp({ config, databaseUrl: 'sqlite::memory:' })
|
||||
}
|
||||
|
||||
export async function adminToken(
|
||||
app: Awaited<ReturnType<typeof buildTestApp>>,
|
||||
): Promise<string> {
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email: 'admin@test.local', password: 'adminpass' },
|
||||
})
|
||||
expect(login.statusCode).toBe(200)
|
||||
return (login.json() as { access_token: string }).access_token
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { hash } from '@node-rs/argon2'
|
||||
import { TtlCache } from '../src/lib/cache.js'
|
||||
import { parseCookie } from '../src/lib/cookies.js'
|
||||
import { checkClientSecret, hashClientSecret } from '../src/lib/client-secret.js'
|
||||
import {
|
||||
hashPassword,
|
||||
passwordHashStats,
|
||||
setPasswordConcurrency,
|
||||
verifyPassword,
|
||||
} from '../src/lib/password.js'
|
||||
import { parseTrustProxy } from '../src/config.js'
|
||||
import { adminToken, buildTestApp } from './helpers.js'
|
||||
|
||||
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
await app?.close()
|
||||
app = undefined
|
||||
})
|
||||
|
||||
describe('TtlCache', () => {
|
||||
it('истекает по TTL', () => {
|
||||
let t = 0
|
||||
const c = new TtlCache<string, number>({ max: 10, ttlMs: 100, now: () => t })
|
||||
c.set('a', 1)
|
||||
t = 99
|
||||
expect(c.get('a')).toBe(1)
|
||||
t = 100
|
||||
expect(c.get('a')).toBeUndefined()
|
||||
expect(c.size).toBe(0)
|
||||
})
|
||||
|
||||
it('вытесняет давно не использованное (LRU)', () => {
|
||||
const c = new TtlCache<string, number>({ max: 2, ttlMs: 1000 })
|
||||
c.set('a', 1)
|
||||
c.set('b', 2)
|
||||
c.get('a') // освежили a
|
||||
c.set('c', 3)
|
||||
expect(c.get('b')).toBeUndefined()
|
||||
expect(c.get('a')).toBe(1)
|
||||
expect(c.get('c')).toBe(3)
|
||||
c.delete('a')
|
||||
expect(c.get('a')).toBeUndefined()
|
||||
c.clear()
|
||||
expect(c.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseCookie', () => {
|
||||
it('находит значение и декодирует', () => {
|
||||
expect(parseCookie('a=1; refresh_token=abc%20d; b=2', 'refresh_token')).toBe(
|
||||
'abc d',
|
||||
)
|
||||
})
|
||||
it('не путает суффикс имени', () => {
|
||||
expect(parseCookie('x_refresh_token=bad', 'refresh_token')).toBeNull()
|
||||
})
|
||||
it('пусто/нет → null', () => {
|
||||
expect(parseCookie(undefined, 'a')).toBeNull()
|
||||
expect(parseCookie('a=; b=1', 'a')).toBeNull()
|
||||
expect(parseCookie('b=1', 'a')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('client secret', () => {
|
||||
it('sha256-формат: верный/неверный, без rehash', async () => {
|
||||
const stored = hashClientSecret('s3cret')
|
||||
expect(stored.startsWith('sha256:')).toBe(true)
|
||||
expect(await checkClientSecret(stored, 's3cret')).toEqual({
|
||||
ok: true,
|
||||
needsRehash: false,
|
||||
})
|
||||
expect((await checkClientSecret(stored, 'nope')).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('argon2-формат принимается и помечается на rehash', async () => {
|
||||
const legacy = await hash('old-secret')
|
||||
expect(await checkClientSecret(legacy, 'old-secret')).toEqual({
|
||||
ok: true,
|
||||
needsRehash: true,
|
||||
})
|
||||
expect(await checkClientSecret(legacy, 'bad')).toEqual({
|
||||
ok: false,
|
||||
needsRehash: false,
|
||||
})
|
||||
expect((await checkClientSecret('garbage', 'x')).ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('password limiter', () => {
|
||||
it('не допускает больше N одновременных хешей', async () => {
|
||||
setPasswordConcurrency(2)
|
||||
let maxActive = 0
|
||||
const poll = setInterval(() => {
|
||||
maxActive = Math.max(maxActive, passwordHashStats().active)
|
||||
}, 1)
|
||||
const h = await hashPassword('pw')
|
||||
await Promise.all(
|
||||
Array.from({ length: 6 }, () => verifyPassword(h, 'pw')),
|
||||
)
|
||||
clearInterval(poll)
|
||||
expect(maxActive).toBeLessThanOrEqual(2)
|
||||
expect(passwordHashStats()).toEqual({ active: 0, waiting: 0 })
|
||||
setPasswordConcurrency(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseTrustProxy', () => {
|
||||
it('true/false/список', () => {
|
||||
expect(parseTrustProxy('true')).toBe(true)
|
||||
expect(parseTrustProxy('false')).toBe(false)
|
||||
expect(parseTrustProxy('10.0.0.0/8, ::1')).toEqual(['10.0.0.0/8', '::1'])
|
||||
})
|
||||
})
|
||||
|
||||
async function createUserAndLogin(
|
||||
a: NonNullable<typeof app>,
|
||||
adminTok: string,
|
||||
email: string,
|
||||
) {
|
||||
const create = await a.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/users',
|
||||
headers: { authorization: `Bearer ${adminTok}` },
|
||||
payload: {
|
||||
email,
|
||||
name: 'U',
|
||||
password: 'secret12',
|
||||
is_admin: false,
|
||||
apps: ['cfdm'],
|
||||
permissions: [],
|
||||
},
|
||||
})
|
||||
expect(create.statusCode).toBe(201)
|
||||
const id = (create.json() as { id: string }).id
|
||||
const login = await a.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email, password: 'secret12' },
|
||||
})
|
||||
expect(login.statusCode).toBe(200)
|
||||
return { id, token: (login.json() as { access_token: string }).access_token }
|
||||
}
|
||||
|
||||
describe('кэш профиля пользователя', () => {
|
||||
it('повторные запросы не обращаются к user_apps/user_permissions', async () => {
|
||||
app = await buildTestApp()
|
||||
const admin = await adminToken(app)
|
||||
const { token } = await createUserAndLogin(app, admin, 'cache@test.local')
|
||||
const me = () =>
|
||||
app!.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/me',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect((await me()).statusCode).toBe(200) // прогрев
|
||||
|
||||
const spy = vi.spyOn(app.sqlite, 'prepare')
|
||||
for (let i = 0; i < 5; i++) expect((await me()).statusCode).toBe(200)
|
||||
const sqls = spy.mock.calls.map((c) => String(c[0]))
|
||||
expect(sqls.filter((s) => /user_apps|user_permissions|from "users"/i.test(s)))
|
||||
.toHaveLength(0)
|
||||
})
|
||||
|
||||
it('блокировка пользователя действует немедленно', async () => {
|
||||
app = await buildTestApp()
|
||||
const admin = await adminToken(app)
|
||||
const { id, token } = await createUserAndLogin(app, admin, 'ban@test.local')
|
||||
const me = () =>
|
||||
app!.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/me',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect((await me()).statusCode).toBe(200)
|
||||
const patch = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/v1/admin/users/${id}`,
|
||||
headers: { authorization: `Bearer ${admin}` },
|
||||
payload: { disabled: true },
|
||||
})
|
||||
expect(patch.statusCode).toBe(200)
|
||||
expect((await me()).statusCode).toBe(401)
|
||||
})
|
||||
|
||||
it('смена доступа отражается сразу, удаление → 401', async () => {
|
||||
app = await buildTestApp()
|
||||
const admin = await adminToken(app)
|
||||
const { id, token } = await createUserAndLogin(app, admin, 'acc@test.local')
|
||||
const me = async () =>
|
||||
(
|
||||
await app!.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/me',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
).json() as { apps?: string[] }
|
||||
expect((await me()).apps).toEqual(['cfdm'])
|
||||
|
||||
const put = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/v1/admin/users/${id}/access`,
|
||||
headers: { authorization: `Bearer ${admin}` },
|
||||
payload: { apps: ['dns'], permissions: [] },
|
||||
})
|
||||
expect(put.statusCode).toBe(200)
|
||||
expect((await me()).apps).toEqual(['dns'])
|
||||
|
||||
await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/admin/users/${id}`,
|
||||
headers: { authorization: `Bearer ${admin}` },
|
||||
})
|
||||
const gone = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/me',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(gone.statusCode).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('app-switcher кэш', () => {
|
||||
it('PUT сразу виден в GET', async () => {
|
||||
app = await buildTestApp()
|
||||
const admin = await adminToken(app)
|
||||
const before = await app.inject({ method: 'GET', url: '/api/v1/app-switcher' })
|
||||
const cfg = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/app-switcher',
|
||||
headers: { authorization: `Bearer ${admin}` },
|
||||
})
|
||||
expect(before.statusCode).toBe(200)
|
||||
expect(cfg.statusCode).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rate-limit', () => {
|
||||
it('/health не лимитируется', async () => {
|
||||
app = await buildTestApp()
|
||||
for (let i = 0; i < 250; i++) {
|
||||
const res = await app.inject({ method: 'GET', url: '/health' })
|
||||
expect(res.statusCode).toBe(200)
|
||||
}
|
||||
})
|
||||
|
||||
it('логин ограничен 20/мин', async () => {
|
||||
app = await buildTestApp()
|
||||
let last = 0
|
||||
for (let i = 0; i < 22; i++) {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email: 'nobody@test.local', password: 'wrongpass' },
|
||||
})
|
||||
last = res.statusCode
|
||||
}
|
||||
expect(last).toBe(429)
|
||||
})
|
||||
|
||||
it('X-Forwarded-For от недоверенного клиента игнорируется', async () => {
|
||||
app = await buildTestApp({ TRUST_PROXY: '10.0.0.0/8' })
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
remoteAddress: '203.0.113.9',
|
||||
headers: { 'x-forwarded-for': '198.51.100.7' },
|
||||
payload: { email: 'nobody@test.local', password: 'wrongpass' },
|
||||
})
|
||||
expect(res.statusCode).toBe(401)
|
||||
const row = app.sqlite
|
||||
.prepare(`SELECT ip FROM audit_log WHERE action='auth.login_failed'`)
|
||||
.get() as { ip: string }
|
||||
expect(row.ip).toBe('203.0.113.9')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,232 @@
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createOriginChecker } from '../src/lib/cors.js'
|
||||
import { loadConfig } from '../src/config.js'
|
||||
import { buildTestApp } from './helpers.js'
|
||||
|
||||
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
|
||||
afterEach(async () => {
|
||||
await app?.close()
|
||||
app = undefined
|
||||
})
|
||||
|
||||
function staticDir(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ap-static-'))
|
||||
mkdirSync(join(dir, 'assets'))
|
||||
writeFileSync(join(dir, 'index.html'), '<html>spa</html>')
|
||||
writeFileSync(join(dir, 'assets', 'app-abc123.js'), 'console.log(1)')
|
||||
return dir
|
||||
}
|
||||
|
||||
describe('статика', () => {
|
||||
it('hashed-ассеты immutable, index.html no-cache', async () => {
|
||||
app = await buildTestApp({ STATIC_DIR: staticDir() })
|
||||
const asset = await app.inject({ method: 'GET', url: '/assets/app-abc123.js' })
|
||||
expect(asset.statusCode).toBe(200)
|
||||
expect(asset.headers['cache-control']).toBe(
|
||||
'public, max-age=31536000, immutable',
|
||||
)
|
||||
const index = await app.inject({ method: 'GET', url: '/index.html' })
|
||||
expect(index.headers['cache-control']).toBe('no-cache')
|
||||
const spa = await app.inject({ method: 'GET', url: '/users/42' })
|
||||
expect(spa.statusCode).toBe(200)
|
||||
expect(spa.headers['cache-control']).toBe('no-cache')
|
||||
})
|
||||
|
||||
it('отсутствующий ассет → 404, а не index.html (иначе после деплоя чанк-JS = HTML)', async () => {
|
||||
app = await buildTestApp({ STATIC_DIR: staticDir() })
|
||||
const res = await app.inject({ method: 'GET', url: '/assets/missing.js' })
|
||||
expect(res.statusCode).toBe(404)
|
||||
expect(res.body).not.toContain('<html>')
|
||||
})
|
||||
|
||||
it('отдаёт предсжатый .br по Accept-Encoding', async () => {
|
||||
const dir = staticDir()
|
||||
writeFileSync(join(dir, 'assets', 'app-abc123.js.br'), 'BR-BYTES')
|
||||
app = await buildTestApp({ STATIC_DIR: dir })
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/assets/app-abc123.js',
|
||||
headers: { 'accept-encoding': 'br' },
|
||||
})
|
||||
expect(res.headers['content-encoding']).toBe('br')
|
||||
expect(res.body).toBe('BR-BYTES')
|
||||
})
|
||||
})
|
||||
|
||||
describe('кэш-заголовки API', () => {
|
||||
it('auth/config кэшируется, app-switcher — нет (админка должна видеть правки сразу)', async () => {
|
||||
app = await buildTestApp()
|
||||
const sw = await app.inject({ method: 'GET', url: '/api/v1/app-switcher' })
|
||||
expect(sw.headers['cache-control']).toBeUndefined()
|
||||
const cfg = await app.inject({ method: 'GET', url: '/api/v1/auth/config' })
|
||||
expect(cfg.headers['cache-control']).toBe('public, max-age=60')
|
||||
})
|
||||
})
|
||||
|
||||
describe('health / ready', () => {
|
||||
it('/health не трогает БД и живёт после закрытия БД', async () => {
|
||||
app = await buildTestApp()
|
||||
app.sqlite.close()
|
||||
const res = await app.inject({ method: 'GET', url: '/health' })
|
||||
expect(res.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('/ready 200 в норме и 503 при недоступной БД', async () => {
|
||||
app = await buildTestApp()
|
||||
const ok = await app.inject({ method: 'GET', url: '/ready' })
|
||||
expect(ok.statusCode).toBe(200)
|
||||
app.sqlite.close()
|
||||
const bad = await app.inject({ method: 'GET', url: '/ready' })
|
||||
expect(bad.statusCode).toBe(503)
|
||||
expect(bad.json()).toEqual({ ok: false, reason: 'db' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('CORS', () => {
|
||||
const origin = (url: string) => ({ origin: url })
|
||||
|
||||
it('разрешает issuer/allowlist, блокирует чужие', async () => {
|
||||
app = await buildTestApp({ RETURN_TO_ALLOWLIST: '.test.local,localhost' })
|
||||
const allowed = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/config',
|
||||
headers: origin('https://auth.test.local'),
|
||||
})
|
||||
expect(allowed.headers['access-control-allow-origin']).toBe(
|
||||
'https://auth.test.local',
|
||||
)
|
||||
expect(allowed.headers['access-control-allow-credentials']).toBe('true')
|
||||
|
||||
const foreign = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/config',
|
||||
headers: origin('https://evil.example'),
|
||||
})
|
||||
expect(foreign.headers['access-control-allow-origin']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('CORS_ORIGINS добавляет origin; запрос без Origin проходит', async () => {
|
||||
app = await buildTestApp({ CORS_ORIGINS: 'https://extra.example' })
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/config',
|
||||
headers: origin('https://extra.example'),
|
||||
})
|
||||
expect(res.headers['access-control-allow-origin']).toBe(
|
||||
'https://extra.example',
|
||||
)
|
||||
const none = await app.inject({ method: 'GET', url: '/api/v1/auth/config' })
|
||||
expect(none.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('боевой RETURN_TO_ALLOWLIST из env.traefik.example не теряет клиентов', () => {
|
||||
const list =
|
||||
'.shnt.top,https://vps.shnt.top,https://cfdm.shnt.top,https://bgp.shnt.top,https://fw.shnt.top,https://dns.shnt.top,https://cdn.shnt.top'
|
||||
const check = createOriginChecker(
|
||||
loadConfig({
|
||||
JWT_SECRET: 'test-secret-at-least-8',
|
||||
ISSUER: 'https://auth.shnt.top',
|
||||
RETURN_TO_ALLOWLIST: list,
|
||||
}),
|
||||
() => [],
|
||||
)
|
||||
for (const o of [
|
||||
'https://vps.shnt.top',
|
||||
'https://dns.shnt.top',
|
||||
'https://shnt.top',
|
||||
'https://anything.shnt.top',
|
||||
]) {
|
||||
expect(check(o)).toBe(true)
|
||||
}
|
||||
expect(check('https://evil.example')).toBe(false)
|
||||
})
|
||||
|
||||
it('private в allowlist пускает LAN-хосты', () => {
|
||||
const check = createOriginChecker(
|
||||
loadConfig({
|
||||
JWT_SECRET: 'test-secret-at-least-8',
|
||||
RETURN_TO_ALLOWLIST: '.shnt.top,private',
|
||||
}),
|
||||
() => [],
|
||||
)
|
||||
expect(check('http://192.168.1.10:8080')).toBe(true)
|
||||
expect(check('http://10.0.0.5')).toBe(true)
|
||||
expect(check('https://8.8.8.8')).toBe(false)
|
||||
})
|
||||
|
||||
it('createOriginChecker: суффикс, localhost, switcher, *', () => {
|
||||
const base = {
|
||||
JWT_SECRET: 'test-secret-at-least-8',
|
||||
ISSUER: 'https://auth.test.local',
|
||||
RETURN_TO_ALLOWLIST: '.shnt.top,localhost,private',
|
||||
}
|
||||
const check = createOriginChecker(loadConfig(base), () => [
|
||||
'https://dns.custom.io/path',
|
||||
])
|
||||
expect(check('https://cfdm.shnt.top')).toBe(true)
|
||||
expect(check('http://localhost:5173')).toBe(true)
|
||||
expect(check('https://dns.custom.io')).toBe(true)
|
||||
expect(check('https://evilshnt.top')).toBe(false)
|
||||
expect(check('not a url')).toBe(false)
|
||||
const all = createOriginChecker(
|
||||
loadConfig({ ...base, CORS_ORIGINS: '*' }),
|
||||
() => [],
|
||||
)
|
||||
expect(all('https://anything.example')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('сжатие в Node (ENABLE_COMPRESS)', () => {
|
||||
async function withBigRoute(env: NodeJS.ProcessEnv) {
|
||||
app = await buildTestApp(env)
|
||||
app.get('/__big', async () => ({ data: 'x'.repeat(5000) }))
|
||||
app.get('/__small', async () => ({ ok: true }))
|
||||
return app
|
||||
}
|
||||
|
||||
it('по умолчанию выключено', async () => {
|
||||
const a = await withBigRoute({})
|
||||
const res = await a.inject({
|
||||
method: 'GET',
|
||||
url: '/__big',
|
||||
headers: { 'accept-encoding': 'gzip' },
|
||||
})
|
||||
expect(res.headers['content-encoding']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('включено: большой JSON сжимается, маленький нет, без Accept-Encoding нет', async () => {
|
||||
const a = await withBigRoute({ ENABLE_COMPRESS: 'true' })
|
||||
const big = await a.inject({
|
||||
method: 'GET',
|
||||
url: '/__big',
|
||||
headers: { 'accept-encoding': 'gzip' },
|
||||
})
|
||||
expect(big.headers['content-encoding']).toBe('gzip')
|
||||
expect(big.rawPayload.length).toBeLessThan(500)
|
||||
const small = await a.inject({
|
||||
method: 'GET',
|
||||
url: '/__small',
|
||||
headers: { 'accept-encoding': 'gzip' },
|
||||
})
|
||||
expect(small.headers['content-encoding']).toBeUndefined()
|
||||
const none = await a.inject({ method: 'GET', url: '/__big' })
|
||||
expect(none.headers['content-encoding']).toBeUndefined()
|
||||
})
|
||||
});
|
||||
|
||||
describe('CSP', () => {
|
||||
it('отдаётся жёсткая политика без внешних источников', async () => {
|
||||
app = await buildTestApp()
|
||||
const res = await app.inject({ method: 'GET', url: '/health' })
|
||||
const csp = String(res.headers['content-security-policy'])
|
||||
expect(csp).toContain("default-src 'self'")
|
||||
expect(csp).toContain("script-src 'self'")
|
||||
expect(csp).toContain("frame-ancestors 'none'")
|
||||
expect(csp).toContain("object-src 'none'")
|
||||
expect(csp).not.toMatch(/script-src[^;]*'unsafe-inline'/)
|
||||
expect(csp).not.toContain('http:')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,311 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { jwtVerify, createLocalJWKSet } from 'jose'
|
||||
import { buildApp } from '../src/app.js'
|
||||
import { loadConfig } from '../src/config.js'
|
||||
import { resetOidcKeyCache } from '../src/lib/oidc/keys.js'
|
||||
import { buildTestApp } from './helpers.js'
|
||||
|
||||
describe('OIDC IdP', () => {
|
||||
it('serves discovery and JWKS', async () => {
|
||||
const app = await buildTestApp()
|
||||
const discovery = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/.well-known/openid-configuration',
|
||||
})
|
||||
expect(discovery.statusCode).toBe(200)
|
||||
const meta = discovery.json() as {
|
||||
issuer: string
|
||||
authorization_endpoint: string
|
||||
jwks_uri: string
|
||||
}
|
||||
expect(meta.issuer).toBe('https://auth.test.local')
|
||||
expect(meta.authorization_endpoint).toContain('/oauth/authorize')
|
||||
expect(meta.jwks_uri).toContain('/.well-known/jwks.json')
|
||||
|
||||
const jwks = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/.well-known/jwks.json',
|
||||
})
|
||||
expect(jwks.statusCode).toBe(200)
|
||||
const keys = jwks.json() as { keys: { kid: string; kty: string }[] }
|
||||
expect(keys.keys.length).toBeGreaterThan(0)
|
||||
expect(keys.keys[0]?.kty).toBe('RSA')
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('records auth.sso_handoff with target_app dns on authorize', async () => {
|
||||
const app = await buildTestApp()
|
||||
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email: 'admin@test.local', password: 'adminpass' },
|
||||
})
|
||||
expect(login.statusCode).toBe(200)
|
||||
const token = (login.json() as { access_token: string }).access_token
|
||||
const refresh = login.cookies.find((c) => c.name === 'refresh_token')
|
||||
expect(refresh?.value).toBeTruthy()
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/oidc/clients',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
name: 'Technitium',
|
||||
redirect_uris: ['https://dns.test.local/sso/callback'],
|
||||
scopes: ['openid', 'profile', 'email', 'groups'],
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
const client = created.json() as { client_id: string }
|
||||
|
||||
const authorize = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/oauth/authorize',
|
||||
cookies: { refresh_token: refresh!.value },
|
||||
query: {
|
||||
client_id: client.client_id,
|
||||
redirect_uri: 'https://dns.test.local/sso/callback',
|
||||
response_type: 'code',
|
||||
scope: 'openid profile email groups',
|
||||
state: 'xyz',
|
||||
},
|
||||
})
|
||||
expect(authorize.statusCode).toBe(302)
|
||||
|
||||
const audit = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit?kind=logins&limit=50',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(audit.statusCode).toBe(200)
|
||||
const entries = audit.json() as {
|
||||
action: string
|
||||
details: Record<string, unknown> | null
|
||||
}[]
|
||||
const handoff = entries.find(
|
||||
(e) =>
|
||||
e.action === 'auth.sso_handoff' &&
|
||||
e.details?.target_app === 'dns' &&
|
||||
e.details?.auth_mode === 'oidc',
|
||||
)
|
||||
expect(handoff).toBeTruthy()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('reloads RS256 key from SQLite after restart (extractable import)', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'oidc-key-'))
|
||||
const dbPath = `sqlite:${join(dir, 'app.db')}`
|
||||
try {
|
||||
resetOidcKeyCache()
|
||||
const config = loadConfig({
|
||||
...process.env,
|
||||
JWT_SECRET: 'test-secret-at-least-8',
|
||||
ADMIN_EMAIL: 'admin@test.local',
|
||||
ADMIN_PASSWORD: 'adminpass',
|
||||
DATABASE_URL: dbPath,
|
||||
ISSUER: 'https://auth.test.local',
|
||||
OIDC_ISSUER: 'https://auth.test.local',
|
||||
NODE_ENV: 'test',
|
||||
})
|
||||
const first = await buildApp({ config, databaseUrl: dbPath })
|
||||
const jwks1 = await first.inject({
|
||||
method: 'GET',
|
||||
url: '/.well-known/jwks.json',
|
||||
})
|
||||
expect(jwks1.statusCode).toBe(200)
|
||||
const kid = (jwks1.json() as { keys: { kid: string }[] }).keys[0]?.kid
|
||||
expect(kid).toBeTruthy()
|
||||
await first.close()
|
||||
|
||||
resetOidcKeyCache()
|
||||
const second = await buildApp({ config, databaseUrl: dbPath })
|
||||
const jwks2 = await second.inject({
|
||||
method: 'GET',
|
||||
url: '/.well-known/jwks.json',
|
||||
})
|
||||
expect(jwks2.statusCode).toBe(200)
|
||||
expect((jwks2.json() as { keys: { kid: string }[] }).keys[0]?.kid).toBe(
|
||||
kid,
|
||||
)
|
||||
await second.close()
|
||||
} finally {
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// Windows may keep better-sqlite3 handle briefly
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('authorization code flow issues id_token with groups', async () => {
|
||||
const app = await buildTestApp()
|
||||
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email: 'admin@test.local', password: 'adminpass' },
|
||||
})
|
||||
expect(login.statusCode).toBe(200)
|
||||
const token = (login.json() as { access_token: string }).access_token
|
||||
const refresh = login.cookies.find((c) => c.name === 'refresh_token')
|
||||
expect(refresh?.value).toBeTruthy()
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/oidc/clients',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
name: 'Technitium',
|
||||
redirect_uris: ['https://dns.test.local/sso/callback'],
|
||||
scopes: ['openid', 'profile', 'email', 'groups'],
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
expect(created.statusCode).toBe(200)
|
||||
const client = created.json() as {
|
||||
client_id: string
|
||||
client_secret: string
|
||||
}
|
||||
|
||||
const authorize = await app.inject({
|
||||
method: 'GET',
|
||||
url:
|
||||
'/oauth/authorize?' +
|
||||
new URLSearchParams({
|
||||
client_id: client.client_id,
|
||||
redirect_uri: 'https://dns.test.local/sso/callback',
|
||||
response_type: 'code',
|
||||
scope: 'openid profile email groups',
|
||||
state: 'xyz',
|
||||
nonce: 'n1',
|
||||
}).toString(),
|
||||
cookies: { refresh_token: refresh!.value },
|
||||
})
|
||||
expect(authorize.statusCode).toBe(302)
|
||||
const location = authorize.headers.location!
|
||||
expect(location).toContain('https://dns.test.local/sso/callback')
|
||||
const code = new URL(location).searchParams.get('code')
|
||||
expect(code).toBeTruthy()
|
||||
|
||||
const tokenRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/oauth/token',
|
||||
payload: {
|
||||
grant_type: 'authorization_code',
|
||||
code: code!,
|
||||
redirect_uri: 'https://dns.test.local/sso/callback',
|
||||
client_id: client.client_id,
|
||||
client_secret: client.client_secret,
|
||||
},
|
||||
})
|
||||
expect(tokenRes.statusCode).toBe(200)
|
||||
const tokens = tokenRes.json() as {
|
||||
access_token: string
|
||||
id_token: string
|
||||
token_type: string
|
||||
}
|
||||
expect(tokens.token_type).toBe('Bearer')
|
||||
|
||||
const jwksRes = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/.well-known/jwks.json',
|
||||
})
|
||||
const jwks = createLocalJWKSet(jwksRes.json() as { keys: never[] })
|
||||
const { payload } = await jwtVerify(tokens.id_token, jwks, {
|
||||
issuer: 'https://auth.test.local',
|
||||
audience: client.client_id,
|
||||
})
|
||||
expect(payload.sub).toBeTruthy()
|
||||
expect(payload.email).toBe('admin@test.local')
|
||||
expect(payload.nonce).toBe('n1')
|
||||
const groups = payload.groups as string[]
|
||||
expect(groups).toContain('technitium_admins')
|
||||
expect(groups).toContain('technitium_dns_admins')
|
||||
|
||||
const userinfo = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/oauth/userinfo',
|
||||
headers: { authorization: `Bearer ${tokens.access_token}` },
|
||||
})
|
||||
expect(userinfo.statusCode).toBe(200)
|
||||
const info = userinfo.json() as { groups: string[]; email: string }
|
||||
expect(info.email).toBe('admin@test.local')
|
||||
expect(info.groups).toContain('technitium_admins')
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('rejects invalid client secret and redirect_uri mismatch', async () => {
|
||||
const app = await buildTestApp()
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email: 'admin@test.local', password: 'adminpass' },
|
||||
})
|
||||
const token = (login.json() as { access_token: string }).access_token
|
||||
const refresh = login.cookies.find((c) => c.name === 'refresh_token')!
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/oidc/clients',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
name: 'DNS',
|
||||
redirect_uris: ['https://dns.test.local/sso/callback'],
|
||||
scopes: ['openid', 'profile', 'email', 'groups'],
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
const client = created.json() as {
|
||||
client_id: string
|
||||
client_secret: string
|
||||
}
|
||||
|
||||
const badRedirect = await app.inject({
|
||||
method: 'GET',
|
||||
url:
|
||||
'/oauth/authorize?' +
|
||||
new URLSearchParams({
|
||||
client_id: client.client_id,
|
||||
redirect_uri: 'https://evil.test/callback',
|
||||
response_type: 'code',
|
||||
scope: 'openid',
|
||||
}).toString(),
|
||||
cookies: { refresh_token: refresh.value },
|
||||
})
|
||||
expect(badRedirect.statusCode).toBe(400)
|
||||
|
||||
const authorize = await app.inject({
|
||||
method: 'GET',
|
||||
url:
|
||||
'/oauth/authorize?' +
|
||||
new URLSearchParams({
|
||||
client_id: client.client_id,
|
||||
redirect_uri: 'https://dns.test.local/sso/callback',
|
||||
response_type: 'code',
|
||||
scope: 'openid',
|
||||
}).toString(),
|
||||
cookies: { refresh_token: refresh.value },
|
||||
})
|
||||
const code = new URL(authorize.headers.location!).searchParams.get('code')!
|
||||
|
||||
const badSecret = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/oauth/token',
|
||||
payload: {
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: 'https://dns.test.local/sso/callback',
|
||||
client_id: client.client_id,
|
||||
client_secret: 'wrong-secret',
|
||||
},
|
||||
})
|
||||
expect(badSecret.statusCode).toBe(401)
|
||||
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createDb, closeDb, migrateSchema } from '@authportal/db'
|
||||
import { registerShutdown } from '../src/lib/shutdown.js'
|
||||
import { buildTestApp } from './helpers.js'
|
||||
|
||||
function fakeApp(close: () => Promise<void>) {
|
||||
return {
|
||||
close,
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
} as unknown as Parameters<typeof registerShutdown>[0]
|
||||
}
|
||||
|
||||
describe('registerShutdown', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('закрывает приложение один раз и выходит с 0', async () => {
|
||||
const close = vi.fn(async () => {})
|
||||
const exit = vi.fn()
|
||||
const proc = new EventEmitter()
|
||||
registerShutdown(fakeApp(close), {
|
||||
timeoutMs: 1000,
|
||||
exit,
|
||||
proc: proc as never,
|
||||
})
|
||||
proc.emit('SIGTERM')
|
||||
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(0))
|
||||
expect(close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('повторный сигнал форсирует выход, close не вызывается дважды', async () => {
|
||||
let release!: () => void
|
||||
const close = vi.fn(
|
||||
() => new Promise<void>((r) => (release = r)),
|
||||
)
|
||||
const exit = vi.fn()
|
||||
const proc = new EventEmitter()
|
||||
registerShutdown(fakeApp(close), {
|
||||
timeoutMs: 1000,
|
||||
exit,
|
||||
proc: proc as never,
|
||||
})
|
||||
proc.emit('SIGTERM')
|
||||
proc.emit('SIGINT')
|
||||
expect(exit).toHaveBeenCalledWith(1)
|
||||
expect(close).toHaveBeenCalledTimes(1)
|
||||
release()
|
||||
})
|
||||
|
||||
it('по таймауту принудительно выходит с кодом 1', async () => {
|
||||
vi.useFakeTimers()
|
||||
const exit = vi.fn()
|
||||
const proc = new EventEmitter()
|
||||
registerShutdown(
|
||||
fakeApp(() => new Promise<void>(() => {})),
|
||||
{ timeoutMs: 500, exit, proc: proc as never },
|
||||
)
|
||||
proc.emit('SIGTERM')
|
||||
await vi.advanceTimersByTimeAsync(501)
|
||||
expect(exit).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('ошибка close → выход с кодом 1', async () => {
|
||||
const exit = vi.fn()
|
||||
const proc = new EventEmitter()
|
||||
registerShutdown(
|
||||
fakeApp(async () => {
|
||||
throw new Error('boom')
|
||||
}),
|
||||
{ timeoutMs: 1000, exit, proc: proc as never },
|
||||
)
|
||||
proc.emit('SIGTERM')
|
||||
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1))
|
||||
})
|
||||
|
||||
it('unhandledRejection запускает shutdown с кодом 1', async () => {
|
||||
const exit = vi.fn()
|
||||
const proc = new EventEmitter()
|
||||
registerShutdown(fakeApp(async () => {}), {
|
||||
timeoutMs: 1000,
|
||||
exit,
|
||||
proc: proc as never,
|
||||
})
|
||||
proc.emit('unhandledRejection', new Error('x'))
|
||||
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1))
|
||||
})
|
||||
|
||||
it('dispose снимает обработчики', () => {
|
||||
const proc = new EventEmitter()
|
||||
const h = registerShutdown(fakeApp(async () => {}), {
|
||||
timeoutMs: 1000,
|
||||
exit: vi.fn(),
|
||||
proc: proc as never,
|
||||
})
|
||||
h.dispose()
|
||||
expect(proc.listenerCount('SIGTERM')).toBe(0)
|
||||
expect(proc.listenerCount('unhandledRejection')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeDb', () => {
|
||||
it('идемпотентно закрывает БД', () => {
|
||||
const { sqlite } = createDb('sqlite::memory:')
|
||||
migrateSchema(sqlite)
|
||||
closeDb(sqlite)
|
||||
expect(sqlite.open).toBe(false)
|
||||
expect(() => closeDb(sqlite)).not.toThrow()
|
||||
expect(() => sqlite.prepare('SELECT 1')).toThrow()
|
||||
})
|
||||
|
||||
it('app.close() закрывает БД приложения', async () => {
|
||||
const app = await buildTestApp()
|
||||
await app.ready()
|
||||
expect(app.sqlite.open).toBe(true)
|
||||
await app.close()
|
||||
expect(app.sqlite.open).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { targetAppFromReturnTo } from '../src/lib/target-app.js'
|
||||
|
||||
describe('targetAppFromReturnTo', () => {
|
||||
it('maps dns host and /sso/ path', () => {
|
||||
expect(targetAppFromReturnTo('https://dns.shnt.top/sso/callback')).toBe(
|
||||
'dns',
|
||||
)
|
||||
})
|
||||
|
||||
it('unwraps portal /oauth/authorize return_to via redirect_uri', () => {
|
||||
const returnTo =
|
||||
'https://auth.shnt.top/oauth/authorize?client_id=abc&redirect_uri=' +
|
||||
encodeURIComponent('https://dns.shnt.top/sso/callback') +
|
||||
'&response_type=code&scope=openid'
|
||||
expect(targetAppFromReturnTo(returnTo)).toBe('dns')
|
||||
})
|
||||
|
||||
it('keeps portal for bare issuer authorize without redirect_uri', () => {
|
||||
expect(
|
||||
targetAppFromReturnTo('https://auth.shnt.top/oauth/authorize'),
|
||||
).toBe('portal')
|
||||
})
|
||||
|
||||
it('maps cdn host', () => {
|
||||
expect(targetAppFromReturnTo('https://cdn.shnt.top/auth/callback')).toBe(
|
||||
'cdn',
|
||||
)
|
||||
})
|
||||
|
||||
it('maps mm host', () => {
|
||||
expect(targetAppFromReturnTo('https://mm.shnt.top/auth/callback')).toBe(
|
||||
'mm',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createWebauthnCredential } from '@authportal/db'
|
||||
import { adminToken, buildTestApp } from './helpers.js'
|
||||
|
||||
describe('webauthn / passkeys', () => {
|
||||
it('exposes webauthn flag on auth config', async () => {
|
||||
const app = await buildTestApp()
|
||||
const res = await app.inject({ method: 'GET', url: '/api/v1/auth/config' })
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.json()).toMatchObject({ webauthn: true })
|
||||
expect(app.config.webauthnRpID).toBe('auth.test.local')
|
||||
expect(app.config.webauthnOrigins).toContain('https://auth.test.local')
|
||||
expect(app.config.webauthnOrigins).toContain('http://localhost:5173')
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('requires JWT for register options', async () => {
|
||||
const app = await buildTestApp()
|
||||
const denied = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/webauthn/register/options',
|
||||
})
|
||||
expect(denied.statusCode).toBe(401)
|
||||
|
||||
const token = await adminToken(app)
|
||||
const ok = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/webauthn/register/options',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(ok.statusCode).toBe(200)
|
||||
const body = ok.json() as {
|
||||
challenge_id: string
|
||||
options: { challenge: string; rp: { id: string } }
|
||||
}
|
||||
expect(body.challenge_id).toBeTruthy()
|
||||
expect(body.options.challenge).toBeTruthy()
|
||||
expect(body.options.rp.id).toBe('auth.test.local')
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('allows public login options and rejects a bogus assertion', async () => {
|
||||
const app = await buildTestApp()
|
||||
const options = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/webauthn/login/options',
|
||||
})
|
||||
expect(options.statusCode).toBe(200)
|
||||
const body = options.json() as { challenge_id: string; options: unknown }
|
||||
expect(body.challenge_id).toBeTruthy()
|
||||
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/webauthn/login',
|
||||
payload: {
|
||||
challenge_id: body.challenge_id,
|
||||
response: { id: 'not-a-credential', type: 'public-key' },
|
||||
},
|
||||
})
|
||||
expect(login.statusCode).toBe(401)
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('lists, deletes own credentials and reports passkey_count', async () => {
|
||||
const app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
const me = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/me',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
const userId = (me.json() as { id: string }).id
|
||||
|
||||
createWebauthnCredential(app.db, {
|
||||
userId,
|
||||
credentialId: 'dGVzdC1jcmVkLWlk',
|
||||
publicKey: 'dGVzdC1wdWJrZXk',
|
||||
counter: 0,
|
||||
name: 'Test key',
|
||||
})
|
||||
|
||||
const listed = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/webauthn/credentials',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(listed.statusCode).toBe(200)
|
||||
const creds = listed.json() as { id: string; name: string }[]
|
||||
expect(creds).toHaveLength(1)
|
||||
expect(creds[0]?.name).toBe('Test key')
|
||||
|
||||
const users = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/users',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
const admin = (
|
||||
users.json() as { email: string; passkey_count: number }[]
|
||||
).find((u) => u.email === 'admin@test.local')
|
||||
expect(admin?.passkey_count).toBe(1)
|
||||
|
||||
const renamed = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/v1/webauthn/credentials/${creds[0]!.id}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { name: 'Laptop' },
|
||||
})
|
||||
expect(renamed.statusCode).toBe(200)
|
||||
expect((renamed.json() as { name: string }).name).toBe('Laptop')
|
||||
|
||||
const deleted = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/webauthn/credentials/${creds[0]!.id}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(deleted.statusCode).toBe(200)
|
||||
|
||||
const empty = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/webauthn/credentials',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(empty.json()).toEqual([])
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('lets admin reset a user passkeys', async () => {
|
||||
const app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
const me = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/me',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
const userId = (me.json() as { id: string }).id
|
||||
createWebauthnCredential(app.db, {
|
||||
userId,
|
||||
credentialId: 'cmVzZXQta2V5',
|
||||
publicKey: 'cHVia2V5',
|
||||
counter: 1,
|
||||
name: 'To reset',
|
||||
})
|
||||
|
||||
const reset = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/admin/users/${userId}/passkeys`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(reset.statusCode).toBe(200)
|
||||
expect(reset.json()).toMatchObject({ ok: true, removed: 1 })
|
||||
|
||||
const listed = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/webauthn/credentials',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(listed.json()).toEqual([])
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from 'tsup'
|
||||
|
||||
// Нативные модули остаются внешними (ставятся в образ отдельно),
|
||||
// всё остальное (fastify, jose, drizzle, workspace-пакеты) бандлится в один файл.
|
||||
const NATIVE = ['better-sqlite3', '@node-rs/argon2']
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/server.ts'],
|
||||
format: ['esm'],
|
||||
target: 'node22',
|
||||
external: NATIVE,
|
||||
noExternal: [/^(?!better-sqlite3$|@node-rs\/argon2).+/],
|
||||
// CJS-зависимости внутри ESM-бандла используют require/__dirname.
|
||||
banner: {
|
||||
js: [
|
||||
"import { createRequire as __cr } from 'node:module';",
|
||||
"import { fileURLToPath as __fu } from 'node:url';",
|
||||
"import { dirname as __dn } from 'node:path';",
|
||||
'const require = __cr(import.meta.url);',
|
||||
'const __filename = __fu(import.meta.url);',
|
||||
'const __dirname = __dn(__filename);',
|
||||
].join('\n'),
|
||||
},
|
||||
// Один файл: динамические import() (напр. CLI backup) не должны порождать чанки.
|
||||
splitting: false,
|
||||
clean: true,
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
passWithNoTests: true,
|
||||
include: ['test/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['src/**/*.ts'],
|
||||
reporter: ['text-summary', 'lcov'],
|
||||
// Стартовые пороги: поднимать по мере роста покрытия, не снижать.
|
||||
thresholds: { lines: 75 },
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "../../packages/ui/src/styles/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"registries": {
|
||||
"@reui": {
|
||||
"url": "https://reui.io/r/{style}/{name}.json",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${REUI_LICENSE_KEY}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@authportal/ui/lib/utils",
|
||||
"ui": "@authportal/ui/components",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle"
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist', 'src/routeTree.gen.ts', 'src/components/reui/**']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['@/components/ui/*'],
|
||||
message: 'Импортируйте примитивы через @authportal/ui/components/*',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/routes/**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/components/reui-kit/**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': 'off',
|
||||
'react-hooks/incompatible-library': 'off',
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Auth Portal</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsr generate && vite build",
|
||||
"typecheck": "tsr generate && tsc -b",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"analyze": "vite build --mode analyze"
|
||||
},
|
||||
"dependencies": {
|
||||
"@authportal/shared": "workspace:*",
|
||||
"@authportal/ui": "workspace:*",
|
||||
"@base-ui/react": "catalog:",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-router": "^1.170.15",
|
||||
"@tanstack/react-table": "^9.3.1",
|
||||
"@tanstack/react-virtual": "^3.14.21",
|
||||
"@tanstack/router-vite-plugin": "^1.167.18",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"lucide-react": "catalog:",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-hook-form": "^7.79.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "catalog:",
|
||||
"@tanstack/router-cli": "^1.167.31",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "catalog:",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "catalog:",
|
||||
"jsdom": "^30.1.3",
|
||||
"rollup-plugin-visualizer": "^7.1.1",
|
||||
"tw-animate-css": "^1.0.0",
|
||||
"typescript": "catalog:",
|
||||
"typescript-eslint": "catalog:",
|
||||
"vite": "^8.0.12",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,142 @@
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
AppWindowIcon,
|
||||
FingerprintIcon,
|
||||
HistoryIcon,
|
||||
KeyRoundIcon,
|
||||
LayoutGridIcon,
|
||||
LogInIcon,
|
||||
UsersIcon,
|
||||
} from 'lucide-react'
|
||||
import { AppSwitcher } from '@/components/app-switcher'
|
||||
import { NavUser } from '@/components/nav-user'
|
||||
import { meQueryOptions } from '@/queries/auth'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@authportal/ui/components/sidebar'
|
||||
|
||||
function isActive(pathname: string, to: string, exact: boolean) {
|
||||
if (exact) return pathname === to
|
||||
return pathname === to || pathname.startsWith(`${to}/`)
|
||||
}
|
||||
|
||||
export function AppSidebar() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const { data: me } = useQuery(meQueryOptions)
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<AppSwitcher />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Портал</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
tooltip="Приложения"
|
||||
isActive={isActive(pathname, '/apps', true)}
|
||||
render={<Link to="/apps" />}
|
||||
>
|
||||
<LayoutGridIcon className="size-4" />
|
||||
<span>Приложения</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
tooltip="Безопасность"
|
||||
isActive={isActive(pathname, '/account', true)}
|
||||
render={<Link to="/account" />}
|
||||
>
|
||||
<FingerprintIcon className="size-4" />
|
||||
<span>Безопасность</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
{me?.is_admin ? (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Админ</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
tooltip="Пользователи"
|
||||
isActive={
|
||||
isActive(pathname, '/admin', false) &&
|
||||
!pathname.startsWith('/admin/apps') &&
|
||||
!pathname.startsWith('/admin/audit') &&
|
||||
!pathname.startsWith('/admin/logins') &&
|
||||
!pathname.startsWith('/admin/oidc')
|
||||
}
|
||||
render={<Link to="/admin" />}
|
||||
>
|
||||
<UsersIcon className="size-4" />
|
||||
<span>Пользователи</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
tooltip="Входы"
|
||||
isActive={isActive(pathname, '/admin/logins', false)}
|
||||
render={<Link to="/admin/logins" />}
|
||||
>
|
||||
<LogInIcon className="size-4" />
|
||||
<span>Входы</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
tooltip="Изменения"
|
||||
isActive={isActive(pathname, '/admin/audit', false)}
|
||||
render={<Link to="/admin/audit" />}
|
||||
>
|
||||
<HistoryIcon className="size-4" />
|
||||
<span>Изменения</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
tooltip="Приложения"
|
||||
isActive={isActive(pathname, '/admin/apps', false)}
|
||||
render={<Link to="/admin/apps" />}
|
||||
>
|
||||
<AppWindowIcon className="size-4" />
|
||||
<span>Ссылки приложений</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
tooltip="OIDC"
|
||||
isActive={isActive(pathname, '/admin/oidc', false)}
|
||||
render={<Link to="/admin/oidc" />}
|
||||
>
|
||||
<KeyRoundIcon className="size-4" />
|
||||
<span>OIDC-клиенты</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
) : null}
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronsUpDownIcon,
|
||||
KeyRoundIcon,
|
||||
CloudIcon,
|
||||
ServerIcon,
|
||||
NetworkIcon,
|
||||
LayoutDashboardIcon,
|
||||
ChartColumnIcon,
|
||||
} from 'lucide-react'
|
||||
import type { AppSwitcherIconName } from '@authportal/shared'
|
||||
import { appSwitcherQueryOptions } from '@/queries/app-switcher'
|
||||
import { ssoOpenApp } from '@/lib/auth'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@authportal/ui/components/dropdown-menu'
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@authportal/ui/components/sidebar'
|
||||
|
||||
const PORTAL = {
|
||||
id: 'portal',
|
||||
name: 'Auth Portal',
|
||||
subtitle: 'shnt.top',
|
||||
}
|
||||
|
||||
const ICON_MAP: Record<
|
||||
AppSwitcherIconName,
|
||||
React.ComponentType<{ className?: string }>
|
||||
> = {
|
||||
cloud: CloudIcon,
|
||||
server: ServerIcon,
|
||||
globe: NetworkIcon,
|
||||
dashboard: LayoutDashboardIcon,
|
||||
chart: ChartColumnIcon,
|
||||
}
|
||||
|
||||
export function AppSwitcher() {
|
||||
const { isMobile } = useSidebar()
|
||||
const { data } = useQuery(appSwitcherQueryOptions)
|
||||
const menuLabel = data?.menuLabel ?? 'Приложения'
|
||||
const apps = (data?.apps ?? []).filter((a) => a.enabled !== false)
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="bg-primary text-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md"
|
||||
aria-hidden
|
||||
>
|
||||
<KeyRoundIcon className="size-4" />
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{PORTAL.name}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{PORTAL.subtitle}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDownIcon className="ml-auto size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="min-w-56 rounded-lg"
|
||||
side={isMobile ? 'bottom' : 'right'}
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
>
|
||||
<div className="text-muted-foreground px-2 py-1.5 text-xs">
|
||||
{menuLabel}
|
||||
</div>
|
||||
<DropdownMenuItem disabled>
|
||||
<KeyRoundIcon />
|
||||
Auth Portal
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
</DropdownMenuItem>
|
||||
{apps.map((app) => {
|
||||
const Icon = ICON_MAP[app.icon] ?? ServerIcon
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={app.id}
|
||||
onClick={() => {
|
||||
void ssoOpenApp(app.url, app.authMode ?? 'jwt').catch(() => {
|
||||
window.location.href = app.url.replace(/\/$/, '')
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Icon />
|
||||
{app.name}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { cn } from "@authportal/ui/lib/utils"
|
||||
import { Item, ItemMedia } from "@authportal/ui/components/item"
|
||||
|
||||
export function AuthLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<Item
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"p-0",
|
||||
"text-primary flex size-8 shrink-0 items-center justify-center",
|
||||
className
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
<svg
|
||||
width="50"
|
||||
height="50"
|
||||
viewBox="25.668 25.1352 49.6644 50"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="size-4"
|
||||
>
|
||||
<circle cx="70.634" cy="29.8334" r="4.69799" fill="currentColor" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M25.668 57.0144V29.8332C25.668 27.2386 27.7713 25.1352 30.366 25.1352C32.9606 25.1352 35.0639 27.2386 35.0639 29.8332V57.0144C35.0639 61.833 38.9702 65.7392 43.7888 65.7392H57.2116C62.0302 65.7392 65.9364 61.833 65.9364 57.0144V43.7258C65.9364 41.1312 68.0398 39.0278 70.6344 39.0278C73.229 39.0278 75.3324 41.1312 75.3324 43.7258V57.0144C75.3324 67.0222 67.2194 75.1352 57.2116 75.1352H43.7888C33.7809 75.1352 25.668 67.0222 25.668 57.0144Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import { AppSidebar } from '@/components/app-sidebar'
|
||||
import { SiteHeader } from '@/components/layout/site-header'
|
||||
import { TooltipProvider } from '@authportal/ui/components/tooltip'
|
||||
import { SidebarInset, SidebarProvider } from '@authportal/ui/components/sidebar'
|
||||
|
||||
const SKIP_TO_CONTENT_CLASS =
|
||||
'bg-background text-foreground ring-ring sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:rounded-md focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-sm focus:ring-2'
|
||||
|
||||
/** Shared ops chrome — etalon app-shell-12. */
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<TooltipProvider delay={0}>
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': '240px',
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||||
К содержимому
|
||||
</a>
|
||||
<AppSidebar />
|
||||
<SidebarInset id="main-content">
|
||||
<SiteHeader />
|
||||
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||
{children}
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import {
|
||||
LayoutGridIcon,
|
||||
KeyRoundIcon,
|
||||
CloudIcon,
|
||||
ServerIcon,
|
||||
NetworkIcon,
|
||||
ShieldIcon,
|
||||
GlobeIcon,
|
||||
LayoutDashboardIcon,
|
||||
} from 'lucide-react'
|
||||
import { APPS, type AppId } from '@authportal/shared'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@authportal/ui/components/dropdown-menu'
|
||||
|
||||
const APP_ICONS: Record<
|
||||
AppId,
|
||||
ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
|
||||
> = {
|
||||
cfdm: CloudIcon,
|
||||
vps: ServerIcon,
|
||||
bgp: NetworkIcon,
|
||||
fw: ShieldIcon,
|
||||
dns: GlobeIcon,
|
||||
cdn: CloudIcon,
|
||||
mm: LayoutDashboardIcon,
|
||||
}
|
||||
|
||||
export function AppsMenu() {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon" aria-label="Приложения" />
|
||||
}
|
||||
>
|
||||
<LayoutGridIcon className="size-4.5" aria-hidden />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" sideOffset={8} className="w-72">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Приложения</DropdownMenuLabel>
|
||||
<div className="grid grid-cols-2 gap-1 p-1">
|
||||
<DropdownMenuItem
|
||||
disabled
|
||||
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
<KeyRoundIcon aria-hidden />
|
||||
</span>
|
||||
<span className="text-xs font-medium">Auth Portal</span>
|
||||
</DropdownMenuItem>
|
||||
{APPS.map((app) => {
|
||||
const Icon = APP_ICONS[app.id]
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={app.id}
|
||||
nativeButton={false}
|
||||
render={<a href={app.url} target="_blank" rel="noreferrer" />}
|
||||
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
<Icon aria-hidden />
|
||||
</span>
|
||||
<span className="text-xs font-medium">{app.title}</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
AppWindowIcon,
|
||||
FingerprintIcon,
|
||||
HistoryIcon,
|
||||
KeyRoundIcon,
|
||||
LayoutGridIcon,
|
||||
LogInIcon,
|
||||
PlusIcon,
|
||||
UsersIcon,
|
||||
} from 'lucide-react'
|
||||
import { AppSwitcher } from '@/components/app-switcher'
|
||||
import { CommandPalette, PortalHeader, PortalSidebar } from '@/components/portal/chrome'
|
||||
import { ChromeConfigProvider, PortalUiProvider } from '@/components/portal/chrome-context'
|
||||
import type { ChromeConfig, MonitorItem, MonitorMetric, NavItem, PaletteObject } from '@/components/portal/chrome-store'
|
||||
import { auditQueryOptions } from '@/queries/audit'
|
||||
import { catalogQueryOptions, meQueryOptions, usersQueryOptions } from '@/queries/auth'
|
||||
import { oidcClientsQueryOptions } from '@/queries/oidc'
|
||||
import { sessionsQueryOptions } from '@/queries/sessions'
|
||||
import { Skeleton } from '@authportal/ui/components/skeleton'
|
||||
import { SidebarInset, SidebarProvider } from '@authportal/ui/components/sidebar'
|
||||
import { TooltipProvider } from '@authportal/ui/components/tooltip'
|
||||
|
||||
const SKIP_TO_CONTENT_CLASS =
|
||||
'bg-background text-foreground ring-ring sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:rounded-md focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-sm focus:ring-2'
|
||||
|
||||
const FRESH: Record<string, { badge: string; text: string }> = {
|
||||
'/apps': { badge: 'Портал', text: 'Права из базы портала, а не из токена · SSO открывает приложение с текущей сессией' },
|
||||
'/account': { badge: 'WebAuthn', text: 'Passkeys привязаны к домену портала · пароль остаётся запасным входом' },
|
||||
'/admin/logins': { badge: 'Журнал', text: 'GET /api/v1/admin/audit?kind=logins · хранение по настройке ротации журнала' },
|
||||
'/admin/audit': { badge: 'Журнал', text: 'GET /api/v1/admin/audit?kind=changes · события портала и приложений через ingest' },
|
||||
'/admin/oidc': { badge: 'OIDC', text: 'Discovery и JWKS публикует портал · секрет клиента показывается один раз' },
|
||||
'/admin/apps': { badge: 'Портал', text: 'portal_settings.app_switcher_json · публичный GET /api/v1/app-switcher' },
|
||||
'/admin': { badge: 'Портал', text: 'GET /api/v1/admin/users · права на приложения и разделы' },
|
||||
}
|
||||
|
||||
function freshFor(pathname: string) {
|
||||
const key = Object.keys(FRESH)
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.find((k) => pathname === k || pathname.startsWith(`${k}/`))
|
||||
return FRESH[key ?? '/apps']!
|
||||
}
|
||||
|
||||
function initialsOf(name: string, email: string) {
|
||||
const src = (name || email).trim()
|
||||
const parts = src.split(/[\s@._-]+/).filter(Boolean)
|
||||
return ((parts[0]?.[0] ?? '?') + (parts[1]?.[0] ?? '')).toUpperCase()
|
||||
}
|
||||
|
||||
/** Меню портала: «Портал» и «Админ» (для администраторов). Точки проблем приходят из данных. */
|
||||
function useChromeData(): ChromeConfig {
|
||||
const navigate = useNavigate()
|
||||
const meQ = useQuery(meQueryOptions)
|
||||
const isAdmin = Boolean(meQ.data?.is_admin)
|
||||
const catalogQ = useQuery(catalogQueryOptions)
|
||||
const usersQ = useQuery({ ...usersQueryOptions, enabled: isAdmin, retry: false })
|
||||
const oidcQ = useQuery({ ...oidcClientsQueryOptions, enabled: isAdmin, retry: false })
|
||||
const sessionsQ = useQuery({ ...sessionsQueryOptions(), enabled: isAdmin, retry: false })
|
||||
const loginsQ = useQuery({ ...auditQueryOptions({ kind: 'logins', limit: 100 }), enabled: isAdmin, retry: false })
|
||||
|
||||
return useMemo<ChromeConfig>(() => {
|
||||
const users = usersQ.data ?? []
|
||||
const noPasskey = users.filter((u) => !u.disabled && u.passkey_count === 0)
|
||||
const clients = oidcQ.data ?? []
|
||||
const hourAgo = loginsQ.dataUpdatedAt - 60 * 60 * 1000
|
||||
const failed = (loginsQ.data ?? []).filter((e) => e.action === 'auth.login_failed' && new Date(e.created_at).getTime() >= hourAgo)
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ id: 'apps', label: 'Приложения', to: '/apps', icon: LayoutGridIcon, group: 'Портал' },
|
||||
{ id: 'account', label: 'Безопасность', to: '/account', icon: FingerprintIcon, group: 'Портал' },
|
||||
]
|
||||
if (isAdmin) {
|
||||
navItems.push(
|
||||
{ id: 'users', label: 'Пользователи', to: '/admin', icon: UsersIcon, group: 'Админ' },
|
||||
{
|
||||
id: 'logins',
|
||||
label: 'Входы',
|
||||
to: '/admin/logins',
|
||||
icon: LogInIcon,
|
||||
group: 'Админ',
|
||||
problem: failed.length >= 5 ? { tone: 'er', label: `${failed.length} неудачных входов за час` } : undefined,
|
||||
},
|
||||
{ id: 'audit', label: 'Изменения', to: '/admin/audit', icon: HistoryIcon, group: 'Админ' },
|
||||
{ id: 'oidc', label: 'OIDC-клиенты', to: '/admin/oidc', icon: KeyRoundIcon, group: 'Админ' },
|
||||
{ id: 'app-links', label: 'Ссылки приложений', to: '/admin/apps', icon: AppWindowIcon, group: 'Админ' },
|
||||
)
|
||||
}
|
||||
|
||||
const monitorItems: MonitorItem[] = []
|
||||
if (failed.length >= 5) {
|
||||
monitorItems.push({
|
||||
id: 'bruteforce',
|
||||
tone: 'er',
|
||||
title: `Неудачных входов за час: ${failed.length}`,
|
||||
text: 'Проверьте журнал входов и при необходимости отключите учётную запись',
|
||||
age: 'час',
|
||||
to: '/admin/logins',
|
||||
})
|
||||
}
|
||||
if (noPasskey.length > 0) {
|
||||
monitorItems.push({
|
||||
id: 'nopasskey',
|
||||
tone: 'wn',
|
||||
title: `${noPasskey.length} без passkey`,
|
||||
text: noPasskey.slice(0, 3).map((u) => u.email.split('@')[0]).join(', ') + ' · вход только по паролю',
|
||||
age: 'сейчас',
|
||||
to: '/admin',
|
||||
})
|
||||
}
|
||||
monitorItems.push({ id: 'api', tone: 'ok', title: 'API портала', text: 'GET /ready', age: 'сейчас', to: '/apps' })
|
||||
|
||||
const enabledClients = clients.filter((c) => c.enabled).length
|
||||
const metrics: MonitorMetric[] = isAdmin
|
||||
? [
|
||||
{ l: 'Пользователи без passkey', v: `${noPasskey.length} из ${users.length}`, p: users.length ? Math.round((noPasskey.length / users.length) * 100) : 0, tone: noPasskey.length ? 'warning' : 'success' },
|
||||
{ l: 'Приложения доступны', v: String(catalogQ.data?.apps.length ?? 0), p: 100, tone: 'success' },
|
||||
{ l: 'OIDC-клиенты', v: `${enabledClients} из ${clients.length}`, p: clients.length ? Math.round((enabledClients / clients.length) * 100) : 100, tone: enabledClients === clients.length ? 'success' : 'warning' },
|
||||
{ l: 'Сессии', v: String(sessionsQ.data?.length ?? 0), p: Math.min(100, (sessionsQ.data?.length ?? 0) * 10), tone: 'success' },
|
||||
]
|
||||
: [{ l: 'Приложения доступны', v: String(catalogQ.data?.apps.length ?? 0), p: 100, tone: 'success' }]
|
||||
|
||||
const objects: PaletteObject[] = [
|
||||
...(isAdmin
|
||||
? users.map<PaletteObject>((u) => ({
|
||||
id: `user-${u.id}`,
|
||||
group: 'Пользователи',
|
||||
label: u.email,
|
||||
hint: u.disabled ? 'Отключён' : u.is_admin ? 'Админ' : 'Пользователь',
|
||||
dot: u.disabled ? 'mu' : u.passkey_count === 0 ? 'er' : 'ok',
|
||||
to: `/admin/users/${u.id}`,
|
||||
}))
|
||||
: []),
|
||||
...(catalogQ.data?.apps ?? []).map<PaletteObject>((a) => ({ id: `app-${a.id}`, group: 'Приложения', label: a.title, hint: a.url.replace(/^https?:\/\//, ''), dot: 'mu', to: '/apps' })),
|
||||
...(isAdmin
|
||||
? clients.map<PaletteObject>((c) => ({ id: `oidc-${c.id}`, group: 'OIDC-клиенты', label: c.name, hint: `client_id ${c.client_id}`, dot: 'mu', to: '/admin/oidc' }))
|
||||
: []),
|
||||
]
|
||||
|
||||
const me = meQ.data
|
||||
return {
|
||||
navItems,
|
||||
navGroups: isAdmin ? ['Портал', 'Админ'] : ['Портал'],
|
||||
brand: <AppSwitcher />,
|
||||
user: { name: me?.name ?? '…', email: me?.email ?? '', initials: initialsOf(me?.name ?? '', me?.email ?? '') },
|
||||
securityTo: '/account',
|
||||
onLogout: () => void navigate({ to: '/logout' }),
|
||||
fresh: freshFor,
|
||||
monitor: { items: monitorItems, metrics, updated: 'обновлено сейчас', footerTo: isAdmin ? '/admin/logins' : '/apps', footerLabel: isAdmin ? 'Журнал входов' : 'Приложения' },
|
||||
palette: {
|
||||
actions: isAdmin
|
||||
? [
|
||||
{ id: 'act-user', label: 'Создать пользователя', hint: 'U', icon: PlusIcon, to: '/admin' },
|
||||
{ id: 'act-client', label: 'Создать OIDC-клиент', hint: 'O', icon: PlusIcon, to: '/admin/oidc' },
|
||||
{ id: 'act-passkey', label: 'Добавить passkey', hint: 'K', icon: PlusIcon, to: '/account' },
|
||||
{ id: 'act-logins', label: 'Открыть журнал входов', hint: 'L', icon: LogInIcon, to: '/admin/logins' },
|
||||
]
|
||||
: [{ id: 'act-passkey', label: 'Добавить passkey', hint: 'K', icon: PlusIcon, to: '/account' }],
|
||||
objects,
|
||||
assistantTo: isAdmin ? '/admin/logins' : '/apps',
|
||||
example: 'Подсказка: «неудачные входы ops за сутки»',
|
||||
},
|
||||
}
|
||||
}, [navigate, meQ.data, isAdmin, catalogQ.data, usersQ.data, oidcQ.data, sessionsQ.data, loginsQ.data, loginsQ.dataUpdatedAt])
|
||||
}
|
||||
|
||||
/** Оболочка портала: боковое меню, шапка, палитра ⌘K. Макет — `design-preview` на тех же блоках. */
|
||||
export function PortalShell({ children }: { children: ReactNode }) {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => {
|
||||
// До 1100px меню сворачивается в колонку иконок (на телефоне его заменяет Sheet).
|
||||
const mq = window.matchMedia('(min-width: 1100px)')
|
||||
const sync = () => setOpen(mq.matches)
|
||||
sync()
|
||||
mq.addEventListener('change', sync)
|
||||
setMounted(true)
|
||||
return () => mq.removeEventListener('change', sync)
|
||||
}, [])
|
||||
const config = useChromeData()
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col gap-4 p-6" aria-busy>
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalUiProvider>
|
||||
<ChromeConfigProvider value={config}>
|
||||
<TooltipProvider delay={0}>
|
||||
<SidebarProvider open={open} onOpenChange={setOpen} style={{ '--sidebar-width': '240px' } as CSSProperties}>
|
||||
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||||
К содержимому
|
||||
</a>
|
||||
<PortalSidebar />
|
||||
<SidebarInset id="main-content" className="min-w-0">
|
||||
<PortalHeader />
|
||||
<div className="flex w-full min-w-0 flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">{children}</div>
|
||||
</SidebarInset>
|
||||
<CommandPalette />
|
||||
</SidebarProvider>
|
||||
</TooltipProvider>
|
||||
</ChromeConfigProvider>
|
||||
</PortalUiProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Fragment, useMemo } from 'react'
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@authportal/ui/components/breadcrumb'
|
||||
import { Separator } from '@authportal/ui/components/separator'
|
||||
import { SidebarTrigger } from '@authportal/ui/components/sidebar'
|
||||
import { AppsMenu } from '@/components/layout/apps-menu'
|
||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||
|
||||
function breadcrumbs(pathname: string) {
|
||||
if (pathname.startsWith('/admin/apps')) {
|
||||
return [{ label: 'Ссылки приложений', href: '/admin/apps' }]
|
||||
}
|
||||
if (pathname.startsWith('/admin/oidc')) {
|
||||
return [{ label: 'OIDC-клиенты', href: '/admin/oidc' }]
|
||||
}
|
||||
if (pathname.startsWith('/admin/logins')) {
|
||||
return [{ label: 'Журнал входов', href: '/admin/logins' }]
|
||||
}
|
||||
if (pathname.startsWith('/admin/audit')) {
|
||||
return [{ label: 'Журнал изменений', href: '/admin/audit' }]
|
||||
}
|
||||
if (pathname.startsWith('/admin/users/')) {
|
||||
return [
|
||||
{ label: 'Пользователи', href: '/admin' },
|
||||
{ label: 'Права доступа', href: pathname },
|
||||
]
|
||||
}
|
||||
if (pathname.startsWith('/admin')) {
|
||||
return [{ label: 'Пользователи', href: '/admin' }]
|
||||
}
|
||||
if (pathname.startsWith('/apps')) {
|
||||
return [{ label: 'Приложения', href: '/apps' }]
|
||||
}
|
||||
return [{ label: 'Auth Portal', href: '/apps' }]
|
||||
}
|
||||
|
||||
export function SiteHeader() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const crumbs = useMemo(() => breadcrumbs(pathname), [pathname])
|
||||
|
||||
return (
|
||||
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mr-2 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{crumbs.map((crumb, i) => {
|
||||
const last = i === crumbs.length - 1
|
||||
return (
|
||||
<Fragment key={crumb.href}>
|
||||
{i > 0 ? <BreadcrumbSeparator /> : null}
|
||||
<BreadcrumbItem>
|
||||
{last ? (
|
||||
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink render={<Link to={crumb.href} />}>
|
||||
{crumb.label}
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<AppsMenu />
|
||||
<SystemMonitorPopover />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Activity, AppWindow, KeyRound, Users } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { cn } from '@authportal/ui/lib/utils'
|
||||
import { Item, ItemMedia } from '@authportal/ui/components/item'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@authportal/ui/components/popover'
|
||||
|
||||
import { catalogQueryOptions, meQueryOptions, usersQueryOptions } from '@/queries/auth'
|
||||
import { oidcClientsQueryOptions } from '@/queries/oidc'
|
||||
|
||||
type MonitorMetric = {
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
unit: string
|
||||
percent: number
|
||||
icon: ReactNode
|
||||
tone: 'success' | 'warning' | 'destructive' | 'info'
|
||||
alert: boolean
|
||||
}
|
||||
|
||||
function toneColor(tone: MonitorMetric['tone']) {
|
||||
switch (tone) {
|
||||
case 'success':
|
||||
return 'var(--color-success)'
|
||||
case 'warning':
|
||||
return 'var(--color-warning)'
|
||||
case 'destructive':
|
||||
return 'var(--color-destructive)'
|
||||
default:
|
||||
return 'var(--color-info)'
|
||||
}
|
||||
}
|
||||
|
||||
function MetricBar({ percent, color }: { percent: number; color: string }) {
|
||||
return (
|
||||
<div className="bg-muted h-1 w-full overflow-hidden rounded-full">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{
|
||||
width: `${Math.min(100, Math.max(0, percent))}%`,
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MetricCell({ metric }: { metric: MonitorMetric }) {
|
||||
const color = toneColor(metric.tone)
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Item
|
||||
className="flex size-5 shrink-0 items-center justify-center p-0"
|
||||
style={{ backgroundColor: `${color}18` }}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto" style={{ color }}>
|
||||
{metric.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<span className="text-muted-foreground truncate text-[11px]">{metric.label}</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs font-semibold tabular-nums" style={{ color }}>
|
||||
{metric.value}
|
||||
<span className="text-muted-foreground ml-0.5 text-[10px] font-normal">{metric.unit}</span>
|
||||
</span>
|
||||
</div>
|
||||
<MetricBar percent={metric.percent} color={color} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Live system monitor popover — app-shell-7, Auth Portal metrics. @see https://reui.io/preview/base/app-shell-7 */
|
||||
export function SystemMonitorPopover() {
|
||||
// Поллинг живёт только пока поповер открыт: закрытый монитор не должен
|
||||
// каждые 30 секунд тянуть /ready, каталог и админские списки.
|
||||
const [open, setOpen] = useState(false)
|
||||
const meQ = useQuery(meQueryOptions)
|
||||
const isAdmin = Boolean(meQ.data?.is_admin)
|
||||
|
||||
const readyQ = useQuery({
|
||||
queryKey: ['ready', 'monitor'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/ready')
|
||||
if (!res.ok) throw new Error('not ready')
|
||||
return (await res.json()) as { ok?: boolean }
|
||||
},
|
||||
enabled: open,
|
||||
refetchInterval: 30_000,
|
||||
retry: false,
|
||||
})
|
||||
const catalogQ = useQuery({
|
||||
...catalogQueryOptions,
|
||||
enabled: open,
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
const usersQ = useQuery({
|
||||
...usersQueryOptions,
|
||||
enabled: isAdmin && open,
|
||||
retry: false,
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
const oidcQ = useQuery({
|
||||
...oidcClientsQueryOptions,
|
||||
enabled: isAdmin && open,
|
||||
retry: false,
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
const monitorFetched = readyQ.isFetched || catalogQ.isFetched
|
||||
// До первого открытия данных нет — нейтральное состояние вместо ложной тревоги.
|
||||
const apiOk = readyQ.isFetched
|
||||
? readyQ.data?.ok === true || readyQ.isSuccess
|
||||
: true
|
||||
const appsCount = catalogQ.data?.apps.length ?? 0
|
||||
const usersCount = isAdmin ? (usersQ.data?.length ?? 0) : null
|
||||
const oidcCount = isAdmin ? (oidcQ.data?.length ?? 0) : null
|
||||
const disabledUsers = isAdmin
|
||||
? (usersQ.data ?? []).filter((u) => u.disabled).length
|
||||
: 0
|
||||
|
||||
const metrics = useMemo<MonitorMetric[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'ready',
|
||||
label: 'API',
|
||||
value: apiOk ? 'OK' : '—',
|
||||
unit: '',
|
||||
percent: apiOk ? 100 : 15,
|
||||
icon: <Activity aria-hidden />,
|
||||
tone: apiOk ? 'success' : 'destructive',
|
||||
alert: !apiOk,
|
||||
},
|
||||
{
|
||||
id: 'apps',
|
||||
label: 'Приложения',
|
||||
value: String(appsCount),
|
||||
unit: 'шт.',
|
||||
percent: Math.min(100, appsCount * 20),
|
||||
icon: <AppWindow aria-hidden />,
|
||||
tone: appsCount > 0 ? 'success' : 'warning',
|
||||
alert: appsCount === 0,
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
label: 'Пользователи',
|
||||
value: usersCount == null ? '—' : String(usersCount),
|
||||
unit: usersCount == null ? '' : 'шт.',
|
||||
percent:
|
||||
usersCount == null ? 0 : Math.min(100, Math.max(20, usersCount * 10)),
|
||||
icon: <Users aria-hidden />,
|
||||
tone:
|
||||
usersCount == null
|
||||
? 'info'
|
||||
: disabledUsers > 0
|
||||
? 'warning'
|
||||
: 'success',
|
||||
alert: disabledUsers > 0,
|
||||
},
|
||||
{
|
||||
id: 'oidc',
|
||||
label: 'OIDC',
|
||||
value: oidcCount == null ? '—' : String(oidcCount),
|
||||
unit: oidcCount == null ? '' : 'шт.',
|
||||
percent: oidcCount == null ? 0 : Math.min(100, Math.max(15, oidcCount * 25)),
|
||||
icon: <KeyRound aria-hidden />,
|
||||
tone: oidcCount == null ? 'info' : oidcCount > 0 ? 'success' : 'warning',
|
||||
alert: false,
|
||||
},
|
||||
],
|
||||
[apiOk, appsCount, disabledUsers, oidcCount, usersCount],
|
||||
)
|
||||
|
||||
const spiking = monitorFetched && (metrics.some((m) => m.alert) || !apiOk)
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Монитор системы"
|
||||
className={cn(
|
||||
'relative inline-flex h-8 items-center gap-1.5 rounded-md border px-2 transition-colors outline-none',
|
||||
'border-border hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring',
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className="relative flex size-3.5 items-center justify-center">
|
||||
<Activity
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'size-3.5 transition-colors',
|
||||
spiking ? 'text-destructive' : 'text-muted-foreground',
|
||||
)}
|
||||
/>
|
||||
{spiking ? (
|
||||
<span className="bg-destructive/25 absolute inset-0 animate-ping rounded-full" aria-hidden />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-foreground hidden text-xs font-medium sm:inline">Система</span>
|
||||
<Badge
|
||||
variant={spiking ? 'destructive-light' : 'success-light'}
|
||||
size="xs"
|
||||
className="h-4 px-1.5 text-[10px]"
|
||||
>
|
||||
{spiking ? 'Внимание' : 'Норма'}
|
||||
</Badge>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
|
||||
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
|
||||
<span className="text-foreground text-xs font-medium">Монитор Auth Portal</span>
|
||||
<span className="text-muted-foreground text-[11px] tabular-nums">
|
||||
{new Date().toLocaleTimeString('ru-RU')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2">
|
||||
{metrics.map((metric, i) => (
|
||||
<div
|
||||
key={metric.id}
|
||||
className={cn(
|
||||
i % 2 === 1 && 'border-border border-l',
|
||||
i >= 2 && 'border-border border-t',
|
||||
)}
|
||||
>
|
||||
<MetricCell metric={metric} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-border text-muted-foreground border-t px-3 py-2 text-[11px]">
|
||||
API:{' '}
|
||||
<span className="text-foreground font-medium">{apiOk ? 'OK' : '—'}</span>
|
||||
{isAdmin ? (
|
||||
<>
|
||||
{' · '}
|
||||
Отключено:{' '}
|
||||
<span className="text-foreground font-medium tabular-nums">{disabledUsers}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{' · '}
|
||||
Метрики пользователей — для админа
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
ChevronsUpDownIcon,
|
||||
FingerprintIcon,
|
||||
LogOutIcon,
|
||||
MonitorIcon,
|
||||
MoonIcon,
|
||||
PaletteIcon,
|
||||
SunIcon,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
import { Avatar, AvatarFallback } from '@authportal/ui/components/avatar'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@authportal/ui/components/dropdown-menu'
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@authportal/ui/components/sidebar'
|
||||
import { cn } from '@authportal/ui/lib/utils'
|
||||
|
||||
import { clearToken } from '@/lib/auth'
|
||||
import { logout, meQueryOptions } from '@/queries/auth'
|
||||
|
||||
const THEMES = [
|
||||
{
|
||||
value: 'light',
|
||||
label: 'Светлая',
|
||||
icon: <SunIcon className="size-3.5" aria-hidden />,
|
||||
},
|
||||
{
|
||||
value: 'dark',
|
||||
label: 'Тёмная',
|
||||
icon: <MoonIcon className="size-3.5" aria-hidden />,
|
||||
},
|
||||
{
|
||||
value: 'system',
|
||||
label: 'Системная',
|
||||
icon: <MonitorIcon className="size-3.5" aria-hidden />,
|
||||
},
|
||||
] as const
|
||||
|
||||
function ThemeSegmentedToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const currentTheme = mounted ? (theme ?? 'system') : 'system'
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Тема"
|
||||
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{THEMES.map(({ value, label, icon }) => {
|
||||
const isActive = currentTheme === value
|
||||
return (
|
||||
<Button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
aria-label={label}
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => setTheme(value)}
|
||||
className={cn(
|
||||
'rounded-full',
|
||||
isActive
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function initials(name: string, email: string): string {
|
||||
const base = name.trim() || email.trim()
|
||||
if (!base) return '?'
|
||||
const parts = base.split(/\s+/).filter(Boolean)
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase()
|
||||
}
|
||||
return base.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
export function NavUser() {
|
||||
const { isMobile } = useSidebar()
|
||||
const { data: me } = useQuery(meQueryOptions)
|
||||
const queryClient = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const name = me?.name?.trim() || 'Пользователь'
|
||||
const email = me?.email?.trim() || ''
|
||||
const fallback = initials(name, email)
|
||||
|
||||
async function handleSignOut() {
|
||||
try {
|
||||
await logout()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
clearToken()
|
||||
queryClient.clear()
|
||||
window.location.href = '/'
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Avatar className="size-8 rounded-lg">
|
||||
<AvatarFallback className="rounded-lg text-xs">
|
||||
{fallback}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{name}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{email || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDownIcon className="ml-auto size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--anchor-width) min-w-56 rounded-lg"
|
||||
side={isMobile ? 'bottom' : 'right'}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="flex items-center gap-2 py-2 font-normal text-foreground">
|
||||
<Avatar className="size-8 rounded-lg">
|
||||
<AvatarFallback className="rounded-lg text-xs">
|
||||
{fallback}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{name}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{email || '—'}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onClick={() => void navigate({ to: '/account' })}
|
||||
>
|
||||
<FingerprintIcon aria-hidden />
|
||||
Безопасность
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-default focus:bg-transparent">
|
||||
<PaletteIcon aria-hidden />
|
||||
Тема
|
||||
<div className="ml-auto">
|
||||
<ThemeSegmentedToggle />
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => void handleSignOut()}>
|
||||
<LogOutIcon aria-hidden />
|
||||
Выйти
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@authportal/ui/lib/utils'
|
||||
|
||||
export function PageShell({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-4 md:gap-6', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { EyeIcon, EyeOffIcon, FingerprintIcon, HistoryIcon, ShieldIcon } from 'lucide-react'
|
||||
import {
|
||||
browserSupportsWebAuthn,
|
||||
browserSupportsWebAuthnAutofill,
|
||||
startAuthentication,
|
||||
WebAuthnAbortService,
|
||||
} from '@simplewebauthn/browser'
|
||||
import type { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/browser'
|
||||
import {
|
||||
buildSsoRedirectUrl,
|
||||
isPortalOidcAuthorizeUrl,
|
||||
isReturnToAllowed,
|
||||
type LoginResponse,
|
||||
} from '@authportal/shared'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { Field, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
|
||||
import { Input } from '@authportal/ui/components/input'
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from '@authportal/ui/components/input-group'
|
||||
import { Separator } from '@authportal/ui/components/separator'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import { ensureAuthConfig, setToken } from '@/lib/auth'
|
||||
import { ApiError } from '@/lib/api-client'
|
||||
import { login, meQueryKey } from '@/queries/auth'
|
||||
import { webauthnLogin, webauthnLoginOptions } from '@/queries/webauthn'
|
||||
import { AuthLogo } from '@/components/auth-logo'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
|
||||
const PERKS = [
|
||||
{ icon: FingerprintIcon, title: 'Passkey вместо пароля', text: 'Отпечаток, Face ID или ключ безопасности' },
|
||||
{ icon: ShieldIcon, title: 'Права по приложениям и разделам', text: 'Чтение, запись и администрирование для каждого сервиса' },
|
||||
{ icon: HistoryIcon, title: 'Журнал входов и изменений', text: 'Кто, когда и откуда заходил в сервисы' },
|
||||
] as const
|
||||
|
||||
export function PortalLoginForm() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const search = useSearch({ from: '/' }) as { return_to?: string }
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pending, setPending] = useState(false)
|
||||
const [passkeySupported, setPasskeySupported] = useState(false)
|
||||
|
||||
async function applySession(res: LoginResponse) {
|
||||
setToken(res.access_token)
|
||||
queryClient.setQueryData(meQueryKey, res.user)
|
||||
|
||||
const returnTo = search.return_to
|
||||
const { returnToAllowlist: allowlist, issuer } = await ensureAuthConfig()
|
||||
if (returnTo && isReturnToAllowed(returnTo, allowlist)) {
|
||||
if (isPortalOidcAuthorizeUrl(returnTo, issuer)) {
|
||||
window.location.href = returnTo
|
||||
return
|
||||
}
|
||||
window.location.href = buildSsoRedirectUrl(
|
||||
returnTo,
|
||||
res.access_token,
|
||||
res.expires_at,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (res.user.is_admin) {
|
||||
await navigate({ to: '/admin' })
|
||||
} else {
|
||||
await navigate({ to: '/apps' })
|
||||
}
|
||||
}
|
||||
|
||||
async function runPasskeyLogin() {
|
||||
const { challenge_id, options } = await webauthnLoginOptions()
|
||||
const assertion = await startAuthentication({
|
||||
optionsJSON: options as unknown as PublicKeyCredentialRequestOptionsJSON,
|
||||
})
|
||||
const res = await webauthnLogin(challenge_id, assertion, search.return_to)
|
||||
await applySession(res)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!browserSupportsWebAuthn()) return
|
||||
setPasskeySupported(true)
|
||||
let cancelled = false
|
||||
|
||||
async function startConditional() {
|
||||
if (!(await browserSupportsWebAuthnAutofill())) return
|
||||
try {
|
||||
const { challenge_id, options } = await webauthnLoginOptions()
|
||||
if (cancelled) return
|
||||
const assertion = await startAuthentication({
|
||||
optionsJSON:
|
||||
options as unknown as PublicKeyCredentialRequestOptionsJSON,
|
||||
useBrowserAutofill: true,
|
||||
})
|
||||
if (cancelled) return
|
||||
setPending(true)
|
||||
setError(null)
|
||||
const res = await webauthnLogin(
|
||||
challenge_id,
|
||||
assertion,
|
||||
search.return_to,
|
||||
)
|
||||
await applySession(res)
|
||||
} catch {
|
||||
/* abort / unsupported / user dismissed */
|
||||
} finally {
|
||||
if (!cancelled) setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
void startConditional()
|
||||
return () => {
|
||||
cancelled = true
|
||||
WebAuthnAbortService.cancelCeremony()
|
||||
}
|
||||
// Login page mount only — return_to is stable for the visit.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
WebAuthnAbortService.cancelCeremony()
|
||||
setError(null)
|
||||
setPending(true)
|
||||
const form = new FormData(event.currentTarget)
|
||||
const email = String(form.get('email') ?? '')
|
||||
const password = String(form.get('password') ?? '')
|
||||
try {
|
||||
const res = await login(email, password, search.return_to)
|
||||
await applySession(res)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Не удалось войти')
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasskeyClick() {
|
||||
WebAuthnAbortService.cancelCeremony()
|
||||
setError(null)
|
||||
setPending(true)
|
||||
try {
|
||||
await runPasskeyLogin()
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError ? err.message : 'Не удалось войти с passkey',
|
||||
)
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const returnHost = (() => {
|
||||
try {
|
||||
return search.return_to ? new URL(search.return_to).host : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
|
||||
return (
|
||||
<section className="bg-background flex min-h-svh min-w-0 items-center justify-center px-6 py-8">
|
||||
<div className="grid w-full max-w-[1040px] items-center gap-12 md:grid-cols-2">
|
||||
<div className="hidden flex-col gap-6 md:flex">
|
||||
<div className="flex items-center gap-3">
|
||||
<AuthLogo />
|
||||
<div className="flex flex-col">
|
||||
<b className="text-base font-semibold">Auth Portal</b>
|
||||
<span className="text-muted-foreground text-[13px]">auth.shnt.top</span>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl leading-tight font-semibold tracking-[-0.02em] text-balance">
|
||||
Один вход для всех сервисов shnt.top
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-[15px] leading-relaxed text-pretty">
|
||||
Войдите один раз — и открывайте CFDM, VPS Tracker, EvoBGP, EvoFirewall, CDN Manager, MikrotikManager и
|
||||
Technitium DNS без повторного ввода пароля.
|
||||
</p>
|
||||
<Frame spacing="sm" className="max-w-[420px]">
|
||||
<FramePanel className="p-1">
|
||||
{PERKS.map((perk) => (
|
||||
<div key={perk.title} className="flex items-start gap-3 px-4 py-2.5">
|
||||
<IconTile variant="outline" aria-hidden className="shrink-0">
|
||||
<perk.icon />
|
||||
</IconTile>
|
||||
<div className="flex flex-col">
|
||||
<b className="text-sm font-medium">{perk.title}</b>
|
||||
<span className="text-muted-foreground text-[13px]">{perk.text}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</div>
|
||||
|
||||
<Frame spacing="sm" className="w-full max-w-[420px] justify-self-center">
|
||||
<FrameHeader className="gap-1 px-5 pt-5 pb-1">
|
||||
<FrameTitle className="text-xl">Вход в портал</FrameTitle>
|
||||
<FrameDescription className="text-sm">Введите email и пароль или используйте passkey</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex flex-col gap-4 px-5 pt-4 pb-5">
|
||||
{returnHost ? (
|
||||
<div className="bg-muted/40 flex items-start gap-2.5 rounded-lg border px-3 py-2.5 text-[13px]" role="status">
|
||||
<Badge variant="info-light" size="sm">
|
||||
SSO
|
||||
</Badge>
|
||||
<span>
|
||||
После входа вы вернётесь в <b className="font-semibold">{returnHost}</b>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<Alert variant="destructive" role="alert">
|
||||
<AlertTitle>Ошибка входа</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<FieldGroup className="gap-3.5">
|
||||
<Field className="gap-2">
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="username webauthn"
|
||||
placeholder="admin@shnt.top"
|
||||
aria-invalid={error ? true : undefined}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field className="gap-2">
|
||||
<FieldLabel htmlFor="password">Пароль</FieldLabel>
|
||||
<InputGroup className="w-full">
|
||||
<InputGroupInput
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
placeholder="Пароль"
|
||||
aria-invalid={error ? true : undefined}
|
||||
required
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
type="button"
|
||||
size="icon-xs"
|
||||
aria-label={showPassword ? 'Скрыть пароль' : 'Показать пароль'}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
>
|
||||
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={pending}>
|
||||
{pending ? 'Вход…' : 'Войти'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{passkeySupported ? (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
<Separator className="flex-1" />
|
||||
<span className="text-muted-foreground text-xs">или</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
<Button type="button" variant="outline" className="w-full" disabled={pending} onClick={() => void handlePasskeyClick()}>
|
||||
<FingerprintIcon aria-hidden="true" />
|
||||
Войти с passkey
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-center text-xs">
|
||||
Пароль остаётся запасным входом. Passkey добавляется в разделе «Безопасность».
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { CircleAlertIcon, InfoIcon, TriangleAlertIcon, XIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Alert, AlertAction, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@authportal/ui/components/alert-dialog'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { cn } from '@authportal/ui/lib/utils'
|
||||
import { Input } from '@authportal/ui/components/input'
|
||||
import { Label } from '@authportal/ui/components/label'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@authportal/ui/components/sheet'
|
||||
import type { Tone } from './tone'
|
||||
import { TONE_BADGE } from './tone'
|
||||
|
||||
const TONE_GLYPH: Record<Tone, string> = { ok: '●', wn: '▲', er: '✕', mu: '○', in: '' }
|
||||
|
||||
/** Плашка статуса: слово + глиф (● ▲ ✕ ○), чтобы цвет не был единственным признаком. */
|
||||
export function StatusBadge({ tone, children }: { tone: Tone; children: ReactNode }) {
|
||||
const glyph = TONE_GLYPH[tone]
|
||||
return (
|
||||
<Badge variant={TONE_BADGE[tone]} size="sm" className="whitespace-nowrap">
|
||||
{glyph ? <span aria-hidden className="text-[0.55rem]">{glyph}</span> : null}
|
||||
{children}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
/** Заголовок страницы: название, описание, метка свежести и действия. */
|
||||
export function PageHead({
|
||||
title,
|
||||
sub,
|
||||
actions,
|
||||
}: {
|
||||
title: string
|
||||
sub: string
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<header className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<h1 className="text-xl font-semibold tracking-[-0.01em]">{title}</h1>
|
||||
<p className="text-muted-foreground text-sm">{sub}</p>
|
||||
</div>
|
||||
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
const ALERT_ICON = {
|
||||
er: <CircleAlertIcon aria-hidden />,
|
||||
wn: <TriangleAlertIcon aria-hidden />,
|
||||
in: <InfoIcon aria-hidden />,
|
||||
} as const
|
||||
|
||||
const ALERT_VARIANT = {
|
||||
er: 'destructive',
|
||||
wn: 'warning',
|
||||
in: 'info',
|
||||
} as const
|
||||
|
||||
export function SignalAlert({
|
||||
tone,
|
||||
title,
|
||||
children,
|
||||
action,
|
||||
}: {
|
||||
tone: 'er' | 'wn' | 'in'
|
||||
title: string
|
||||
children: ReactNode
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Alert variant={ALERT_VARIANT[tone]}>
|
||||
{ALERT_ICON[tone]}
|
||||
<AlertTitle>{title}</AlertTitle>
|
||||
<AlertDescription>{children}</AlertDescription>
|
||||
{action ? <AlertAction>{action}</AlertAction> : null}
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
/** Боковое окно (как CreateUserSheet): заголовок, прокручиваемое тело, подвал. */
|
||||
export function PortalSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
size = 'md',
|
||||
dirty,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
description: string
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
/** Ширина панели: md — форма, lg — редактор с двумя колонками. */
|
||||
size?: 'md' | 'lg'
|
||||
/** Явный признак изменений (иначе ловим ввод в полях и переключатели). */
|
||||
dirty?: boolean
|
||||
}) {
|
||||
const [touchedState, setTouched] = useState(false)
|
||||
const touched = dirty ?? touchedState
|
||||
const [guard, setGuard] = useState(false)
|
||||
|
||||
function requestChange(next: boolean) {
|
||||
if (!next && touched) {
|
||||
setGuard(true)
|
||||
return
|
||||
}
|
||||
if (!next) setTouched(false)
|
||||
onOpenChange(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={requestChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
'inset-y-0 right-0 left-auto flex h-svh max-w-none flex-col gap-0 overflow-hidden rounded-none p-0 outline-none sm:max-w-none',
|
||||
'data-[side=right]:sm:max-w-none',
|
||||
size === 'lg'
|
||||
? 'data-[side=right]:w-[min(45rem,100vw)]'
|
||||
: 'data-[side=right]:w-[min(30rem,100vw)]',
|
||||
)}
|
||||
>
|
||||
<SheetHeader className="shrink-0 gap-0 p-0">
|
||||
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
|
||||
<SheetTitle className="min-w-0 truncate text-base font-semibold">{title}</SheetTitle>
|
||||
<SheetClose
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Закрыть"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</SheetClose>
|
||||
</div>
|
||||
<SheetDescription className="text-muted-foreground px-4 pt-3 text-sm">
|
||||
{description}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div
|
||||
className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 py-4"
|
||||
onInput={() => setTouched(true)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest('[role=switch]')) setTouched(true)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{footer ? (
|
||||
<SheetFooter
|
||||
className="flex-row flex-wrap justify-end gap-2 border-t px-4 py-3"
|
||||
onClickCapture={(e) => {
|
||||
// «Закрыть» и «Отмена» при изменённой форме тоже спрашивают подтверждение.
|
||||
const label = (e.target as HTMLElement).closest('button')?.textContent?.trim()
|
||||
if (touched && (label === 'Закрыть' || label === 'Отмена')) {
|
||||
e.stopPropagation()
|
||||
setGuard(true)
|
||||
}
|
||||
}}
|
||||
>{footer}</SheetFooter>
|
||||
) : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<AlertDialog open={guard} onOpenChange={setGuard}>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Закрыть без сохранения?</AlertDialogTitle>
|
||||
<AlertDialogDescription>Изменения в форме будут потеряны.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Остаться</AlertDialogCancel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setGuard(false)
|
||||
setTouched(false)
|
||||
onOpenChange(false)
|
||||
}}
|
||||
>
|
||||
Закрыть без сохранения
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Подтверждение опасного действия вводом слова (email, «сбросить», «очистить»…). */
|
||||
export function TypedConfirm({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
expected,
|
||||
confirmLabel,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
description: ReactNode
|
||||
expected: string
|
||||
confirmLabel: string
|
||||
onConfirm: () => void
|
||||
}) {
|
||||
const [value, setValue] = useState('')
|
||||
const matches = value.trim().toLowerCase() === expected.toLowerCase()
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setValue('')
|
||||
onOpenChange(next)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="typed-confirm">
|
||||
Введите <span className="font-mono font-semibold">{expected}</span> для подтверждения
|
||||
</Label>
|
||||
<Input
|
||||
id="typed-confirm"
|
||||
value={value}
|
||||
autoComplete="off"
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
aria-invalid={value.length > 0 && !matches}
|
||||
/>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={!matches}
|
||||
onClick={() => {
|
||||
setValue('')
|
||||
onConfirm()
|
||||
}}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function KvFrame({
|
||||
title,
|
||||
description,
|
||||
items,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
items: Array<{ label: string; value: ReactNode }>
|
||||
}) {
|
||||
return (
|
||||
<Frame className="[--frame-panel-header-py-adjust:4px] w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>{title}</FrameTitle>
|
||||
<FrameDescription>{description}</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
<dl className="grid divide-y @container sm:grid-cols-2 sm:divide-y-0">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="flex flex-col gap-0.5 border-b px-4 py-3 last:border-b-0 sm:odd:border-r"
|
||||
>
|
||||
<dt className="text-muted-foreground text-xs">{item.label}</dt>
|
||||
<dd className="text-sm font-medium break-words">{item.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
/** Список «значение + подпись + копировать» (параметры issuer). */
|
||||
export function CopyList({
|
||||
title,
|
||||
description,
|
||||
items,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
items: Array<{ label: string; value: string }>
|
||||
}) {
|
||||
return (
|
||||
<Frame className="[--frame-panel-header-py-adjust:4px] w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>{title}</FrameTitle>
|
||||
<FrameDescription>{description}</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
<ul className="flex flex-col">
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={item.label}
|
||||
className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b px-4 py-2.5 last:border-b-0"
|
||||
>
|
||||
<code className="min-w-0 flex-1 basis-72 font-mono text-[13px] break-all">{item.value}</code>
|
||||
<span className="text-muted-foreground min-w-0 flex-1 basis-48 text-[13px]">{item.label}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
aria-label={`Скопировать: ${item.label}`}
|
||||
onClick={() => {
|
||||
void navigator.clipboard?.writeText(item.value).catch(() => undefined)
|
||||
toast.success(`${item.label} скопирован`)
|
||||
}}
|
||||
>
|
||||
Копировать
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
/** Компактный список «ключ — значение» для боковых окон. */
|
||||
export function KvList({ items }: { items: Array<[string, string]> }) {
|
||||
return (
|
||||
<dl className="flex flex-col divide-y rounded-lg border">
|
||||
{items.map(([k, v]) => (
|
||||
<div key={k} className="flex flex-col gap-0.5 px-3 py-2">
|
||||
<dt className="text-muted-foreground text-xs">{k}</dt>
|
||||
<dd className="text-sm break-words">{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
|
||||
export function CodeBlock({ label, children }: { label: string; children: string }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs">{label}</span>
|
||||
<pre className="bg-muted/40 overflow-x-auto rounded-md border p-3 font-mono text-xs leading-relaxed">{children}</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Сегментированный переключатель на Tabs (вид таблицы, период). */
|
||||
export function Segmented({
|
||||
label,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
options: string[]
|
||||
value: number
|
||||
onChange: (index: number) => void
|
||||
}) {
|
||||
return (
|
||||
<Tabs value={String(value)} onValueChange={(v) => onChange(Number(v))} aria-label={label} className="max-w-full">
|
||||
<TabsList className="max-w-full justify-start overflow-x-auto">
|
||||
{options.map((o, i) => (
|
||||
<TabsTrigger key={o} value={String(i)}>
|
||||
{o}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
/** Плашка строки с накопленной правкой: «Не сохранено» или «Будет удалено». */
|
||||
export function PendingChip({ mark }: { mark: 'new' | 'dirty' | 'deleted' }) {
|
||||
return (
|
||||
<Badge variant={mark === 'deleted' ? 'destructive-light' : 'warning-light'} size="sm" className="whitespace-nowrap">
|
||||
{mark === 'deleted' ? 'Будет удалено' : 'Не сохранено'}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
/** Вкладки страницы как на досках: линия-подчёркивание, число — мелким индексом. */
|
||||
export function PageTabsList({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<TabsList variant="line" className="h-10! w-full justify-start gap-0.5 overflow-x-auto">
|
||||
{children}
|
||||
</TabsList>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageTab({ value, label, count }: { value: string; label: string; count?: number | string }) {
|
||||
return (
|
||||
<TabsTrigger value={value} className="gap-1.5 px-4 text-sm">
|
||||
{label}
|
||||
{count !== undefined ? <span className="text-muted-foreground font-mono text-xs font-normal">{count}</span> : null}
|
||||
</TabsTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
/** Строка настройки как на досках: слева название и описание, справа элемент управления. */
|
||||
export function SettingRow({ title, description, children }: { title: string; description: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b px-4 py-3.5 last:border-b-0">
|
||||
<div className="flex min-w-0 flex-1 basis-64 flex-col gap-0.5">
|
||||
<b className="text-sm font-medium">{title}</b>
|
||||
<span className="text-muted-foreground text-[13px]">{description}</span>
|
||||
</div>
|
||||
<div className="shrink-0">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Frame с заголовком, итоговой плашкой справа и строками настроек (блок «form» досок). */
|
||||
export function FormFrame({
|
||||
title,
|
||||
description,
|
||||
badge,
|
||||
tone = 'ok',
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
badge: string
|
||||
tone?: Tone
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
|
||||
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<FrameTitle>{title}</FrameTitle>
|
||||
<FrameDescription>{description}</FrameDescription>
|
||||
</div>
|
||||
<StatusBadge tone={tone}>{badge}</StatusBadge>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">{children}</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { cn } from '@authportal/ui/lib/utils'
|
||||
import type { Tone } from './tone'
|
||||
import { StatusBadge } from './bits'
|
||||
|
||||
export type AppCardItem = {
|
||||
id: string
|
||||
title: string
|
||||
desc: string
|
||||
badge: string
|
||||
badgeVariant?: 'info-light' | 'warning-light' | 'outline'
|
||||
meta: string
|
||||
icon: ReactNode
|
||||
onSelect?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Плитки приложений как на доске «Приложения»: Frame с шапкой, внутри сетка auto-fit от 260px,
|
||||
* в плитке иконка, название, плашка действия, описание и адрес моноширинным шрифтом.
|
||||
*/
|
||||
export function AppCards({
|
||||
title,
|
||||
description,
|
||||
items,
|
||||
action,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
items: AppCardItem[]
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
|
||||
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<FrameTitle>{title}</FrameTitle>
|
||||
<FrameDescription className="text-pretty">{description}</FrameDescription>
|
||||
</div>
|
||||
{action}
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-3">
|
||||
<ul className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,16.25rem),1fr))] gap-3">
|
||||
{items.map((c) => {
|
||||
const body = (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="flex min-w-0 items-center gap-2.5">
|
||||
<IconTile variant="elevated" size="sm" aria-hidden className="text-muted-foreground">
|
||||
{c.icon}
|
||||
</IconTile>
|
||||
<b className="min-w-0 text-sm leading-tight font-semibold">{c.title}</b>
|
||||
</span>
|
||||
<Badge variant={c.badgeVariant ?? 'info-light'} size="sm" className="shrink-0">
|
||||
{c.badge}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1.5 text-[13px] text-pretty">{c.desc}</p>
|
||||
<p className="text-muted-foreground mt-2 truncate font-mono text-[11px]">{c.meta}</p>
|
||||
</>
|
||||
)
|
||||
const cls = 'bg-muted/25 w-full rounded-lg border p-3.5 text-left'
|
||||
return (
|
||||
<li key={c.id} className="min-w-0">
|
||||
{c.onSelect ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={c.onSelect}
|
||||
aria-label={`${c.title}: ${c.badge}`}
|
||||
className={cn(cls, 'hover:bg-muted/50 focus-visible:ring-ring transition-colors outline-none focus-visible:ring-2')}
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
) : (
|
||||
<div className={cls}>{body}</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
const DOT: Record<Tone, string> = {
|
||||
ok: 'bg-success',
|
||||
wn: 'bg-warning',
|
||||
er: 'bg-destructive',
|
||||
mu: 'bg-muted-foreground',
|
||||
in: 'bg-chart-4',
|
||||
}
|
||||
|
||||
/**
|
||||
* Лента событий как на доске «Изменения»: время, точка важности с линией, плашка, заголовок
|
||||
* и строка «кто · источник · IP».
|
||||
*/
|
||||
export function ActivityFeed({
|
||||
title,
|
||||
description,
|
||||
items,
|
||||
action,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
items: Array<{ t: string; title: string; text: string; label: string; tone: Tone }>
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
|
||||
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<FrameTitle>{title}</FrameTitle>
|
||||
<FrameDescription className="text-pretty">{description}</FrameDescription>
|
||||
</div>
|
||||
{action}
|
||||
</FrameHeader>
|
||||
<FramePanel className="py-1.5">
|
||||
<ol>
|
||||
{items.map((e) => (
|
||||
<li key={`${e.t}-${e.title}`} className="grid grid-cols-[3.9rem_1rem_minmax(0,1fr)] items-start gap-2.5 px-4 py-2 text-[13px]">
|
||||
<span className="text-muted-foreground pt-px font-mono text-xs tabular-nums">{e.t}</span>
|
||||
<span aria-hidden className="flex h-full flex-col items-center">
|
||||
<i className={cn('mt-1 size-[9px] rounded-full', DOT[e.tone])} />
|
||||
<u className="bg-border mt-0.5 min-h-4.5 w-px flex-1 no-underline" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="mr-2 inline-block align-middle">
|
||||
<StatusBadge tone={e.tone}>{e.label}</StatusBadge>
|
||||
</span>
|
||||
<b className="font-medium">{e.title}</b>
|
||||
<span className="text-muted-foreground mt-0.5 block text-xs">{e.text}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { ChromeConfigContext, UiStoreContext, type ChromeConfig, type Contrast, type Density, type UiStore } from './chrome-store'
|
||||
|
||||
export function ChromeConfigProvider({ value, children }: { value: ChromeConfig; children: ReactNode }) {
|
||||
return <ChromeConfigContext.Provider value={value}>{children}</ChromeConfigContext.Provider>
|
||||
}
|
||||
|
||||
function readStored<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key)
|
||||
return raw ? (JSON.parse(raw) as T) : fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function writeStored(key: string, value: unknown) {
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(value))
|
||||
} catch {
|
||||
/* без сохранения настроек */
|
||||
}
|
||||
}
|
||||
|
||||
/** Плотность, контраст, закреплённые пункты и палитра ⌘K. Атрибуты html[data-density|contrast] читает portal-theme.css. */
|
||||
export function PortalUiProvider({ children, storageKey = 'portal-ui' }: { children: ReactNode; storageKey?: string }) {
|
||||
const [paletteOpen, setPaletteOpen] = useState(false)
|
||||
const [density, setDensityState] = useState<Density>(() => readStored(`${storageKey}:density`, 'comfortable'))
|
||||
const [contrast, setContrastState] = useState<Contrast>(() => readStored(`${storageKey}:contrast`, 'standard'))
|
||||
const [pins, setPins] = useState<string[]>(() => readStored(`${storageKey}:pins`, []))
|
||||
|
||||
const setDensity = useCallback(
|
||||
(v: Density) => {
|
||||
setDensityState(v)
|
||||
writeStored(`${storageKey}:density`, v)
|
||||
},
|
||||
[storageKey],
|
||||
)
|
||||
const setContrast = useCallback(
|
||||
(v: Contrast) => {
|
||||
setContrastState(v)
|
||||
writeStored(`${storageKey}:contrast`, v)
|
||||
},
|
||||
[storageKey],
|
||||
)
|
||||
const togglePin = useCallback(
|
||||
(id: string) => {
|
||||
setPins((prev) => {
|
||||
const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||
writeStored(`${storageKey}:pins`, next)
|
||||
return next
|
||||
})
|
||||
},
|
||||
[storageKey],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
root.dataset.density = density
|
||||
root.dataset.contrast = contrast
|
||||
return () => {
|
||||
delete root.dataset.density
|
||||
delete root.dataset.contrast
|
||||
}
|
||||
}, [density, contrast])
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault()
|
||||
setPaletteOpen((v) => !v)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [])
|
||||
|
||||
const value = useMemo<UiStore>(
|
||||
() => ({ paletteOpen, setPaletteOpen, pins, togglePin, density, setDensity, contrast, setContrast }),
|
||||
[paletteOpen, pins, togglePin, density, setDensity, contrast, setContrast],
|
||||
)
|
||||
return <UiStoreContext.Provider value={value}>{children}</UiStoreContext.Provider>
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { createContext, useContext, type ComponentType, type ReactNode } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
export type NavItem = {
|
||||
id: string
|
||||
label: string
|
||||
to: string
|
||||
icon: LucideIcon
|
||||
group: string
|
||||
/** Точка проблемы в углу иконки. */
|
||||
problem?: { tone: 'er' | 'wn'; label: string }
|
||||
}
|
||||
|
||||
export type MonitorItem = {
|
||||
id: string
|
||||
tone: 'er' | 'wn' | 'ok'
|
||||
title: string
|
||||
text: string
|
||||
age: string
|
||||
to: string
|
||||
}
|
||||
|
||||
export type MonitorMetric = { l: string; v: string; p: number; tone: 'warning' | 'success' }
|
||||
|
||||
export type PaletteAction = {
|
||||
id: string
|
||||
label: string
|
||||
/** Горячая буква справа. */
|
||||
hint: string
|
||||
icon: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
|
||||
to: string
|
||||
}
|
||||
|
||||
export type PaletteObject = {
|
||||
id: string
|
||||
group: string
|
||||
label: string
|
||||
hint: string
|
||||
dot?: 'ok' | 'er' | 'mu'
|
||||
to: string
|
||||
}
|
||||
|
||||
/** Данные оболочки: меню, монитор, палитра и пользователь. В макете — суррогатные, в портале — из API. */
|
||||
export type ChromeConfig = {
|
||||
navItems: NavItem[]
|
||||
navGroups: string[]
|
||||
/** Верх бокового меню: бренд и переключатель приложений. */
|
||||
brand: ReactNode
|
||||
user: { name: string; email: string; initials: string }
|
||||
securityTo: string
|
||||
onLogout: () => void
|
||||
fresh: (pathname: string) => { badge: string; text: string }
|
||||
crumbFor?: (pathname: string, label: string | undefined) => string
|
||||
monitor: { items: MonitorItem[]; metrics: MonitorMetric[]; updated: string; footerTo: string; footerLabel: string }
|
||||
palette: { actions: PaletteAction[]; objects: PaletteObject[]; assistantTo: string; example: string }
|
||||
}
|
||||
|
||||
export const ChromeConfigContext = createContext<ChromeConfig | null>(null)
|
||||
|
||||
export function useChromeConfig(): ChromeConfig {
|
||||
const ctx = useContext(ChromeConfigContext)
|
||||
if (!ctx) throw new Error('ChromeConfigProvider отсутствует')
|
||||
return ctx
|
||||
}
|
||||
|
||||
export type Density = 'comfortable' | 'compact'
|
||||
export type Contrast = 'standard' | 'high'
|
||||
|
||||
export type UiStore = {
|
||||
paletteOpen: boolean
|
||||
setPaletteOpen: (open: boolean) => void
|
||||
pins: string[]
|
||||
togglePin: (id: string) => void
|
||||
density: Density
|
||||
setDensity: (v: Density) => void
|
||||
contrast: Contrast
|
||||
setContrast: (v: Contrast) => void
|
||||
}
|
||||
|
||||
export const UiStoreContext = createContext<UiStore | null>(null)
|
||||
|
||||
export function useUiStore(): UiStore {
|
||||
const ctx = useContext(UiStoreContext)
|
||||
if (!ctx) throw new Error('PortalUiProvider отсутствует')
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
import {
|
||||
useMemo,
|
||||
useState,
|
||||
type ComponentType,
|
||||
} from 'react'
|
||||
import {
|
||||
Link,
|
||||
useNavigate,
|
||||
useRouterState,
|
||||
} from '@tanstack/react-router'
|
||||
import {
|
||||
ActivityIcon,
|
||||
ArrowRightIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronsUpDownIcon,
|
||||
FingerprintIcon,
|
||||
InfoIcon,
|
||||
LogOutIcon,
|
||||
SearchIcon,
|
||||
StarIcon,
|
||||
MoonIcon,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
useTheme,
|
||||
} from 'next-themes'
|
||||
import {
|
||||
Badge,
|
||||
} from '@/components/reui/badge'
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
} from '@authportal/ui/components/breadcrumb'
|
||||
import {
|
||||
Button,
|
||||
} from '@authportal/ui/components/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from '@authportal/ui/components/dialog'
|
||||
import {
|
||||
Kbd,
|
||||
} from '@authportal/ui/components/kbd'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@authportal/ui/components/popover'
|
||||
import {
|
||||
Separator,
|
||||
} from '@authportal/ui/components/separator'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from '@authportal/ui/components/sidebar'
|
||||
import {
|
||||
cn,
|
||||
} from '@authportal/ui/lib/utils'
|
||||
import {
|
||||
Segmented,
|
||||
} from './bits'
|
||||
import {
|
||||
useChromeConfig,
|
||||
useUiStore,
|
||||
type NavItem,
|
||||
} from './chrome-store'
|
||||
|
||||
const LABEL_CLASS = 'text-muted-foreground text-[11px] font-medium uppercase'
|
||||
|
||||
function PortalNavUser() {
|
||||
const { isMobile } = useSidebar()
|
||||
const { theme, setTheme, resolvedTheme } = useTheme()
|
||||
const { density, setDensity, contrast, setContrast } = useUiStore()
|
||||
const { user, securityTo, onLogout } = useChromeConfig()
|
||||
const navigate = useNavigate()
|
||||
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
aria-label={`Профиль и настройки интерфейса: ${user.name}, ${user.email}`}
|
||||
className="bg-muted/40 aria-expanded:bg-muted"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="bg-background text-foreground flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
|
||||
{user.initials}
|
||||
</div>
|
||||
<div className="grid min-w-0 flex-1 text-left leading-tight">
|
||||
<span className="truncate text-sm font-semibold">{user.name}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">{user.email}</span>
|
||||
</div>
|
||||
<ChevronsUpDownIcon className="text-muted-foreground ml-auto size-4" aria-hidden />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side={isMobile ? 'top' : 'right'} align="end" sideOffset={8} className="w-72 gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="bg-background flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
|
||||
{user.initials}
|
||||
</div>
|
||||
<div className="grid leading-tight">
|
||||
<span className="text-sm font-semibold">{user.name}</span>
|
||||
<span className="text-muted-foreground text-xs">{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void navigate({ to: securityTo })}
|
||||
className="hover:bg-muted flex items-center gap-2 rounded-md px-1 py-1 text-left text-sm"
|
||||
>
|
||||
<FingerprintIcon className="text-muted-foreground size-4" aria-hidden />
|
||||
Безопасность
|
||||
</button>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className={LABEL_CLASS}>Тема</span>
|
||||
<Segmented
|
||||
label="Тема"
|
||||
options={['Тёмная', 'Светлая']}
|
||||
value={current === 'light' ? 1 : 0}
|
||||
onChange={(i) => setTheme(i === 1 ? 'light' : 'dark')}
|
||||
/>
|
||||
<span className={LABEL_CLASS}>Плотность</span>
|
||||
<Segmented
|
||||
label="Плотность"
|
||||
options={['Обычная', 'Компактная']}
|
||||
value={density === 'compact' ? 1 : 0}
|
||||
onChange={(i) => setDensity(i === 1 ? 'compact' : 'comfortable')}
|
||||
/>
|
||||
<span className={LABEL_CLASS}>Контраст</span>
|
||||
<Segmented
|
||||
label="Контраст"
|
||||
options={['Стандартный', 'Высокий']}
|
||||
value={contrast === 'high' ? 1 : 0}
|
||||
onChange={(i) => setContrast(i === 1 ? 'high' : 'standard')}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
className="text-muted-foreground hover:text-foreground flex items-center gap-2 text-left text-sm"
|
||||
>
|
||||
<LogOutIcon className="size-4" aria-hidden />
|
||||
Выйти
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
|
||||
const DOT_CLASS = { er: 'bg-destructive', wn: 'bg-warning' } as const
|
||||
|
||||
function NavEntry({ item, pathname, pinned, onTogglePin }: { item: NavItem; pathname: string; pinned: boolean; onTogglePin: () => void }) {
|
||||
const Icon = item.icon
|
||||
const active = pathname === item.to || pathname.startsWith(`${item.to}/`)
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.problem ? `${item.label} · ${item.problem.label}` : item.label}
|
||||
isActive={active}
|
||||
className="relative pr-8 group-data-[collapsible=icon]:pr-2!"
|
||||
render={<Link to={item.to} />}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
<span>{item.label}</span>
|
||||
{item.problem ? (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={item.problem.label}
|
||||
className={cn(
|
||||
'ml-auto size-2 shrink-0 rounded-full group-data-[collapsible=icon]:absolute group-data-[collapsible=icon]:top-1 group-data-[collapsible=icon]:right-1 group-data-[collapsible=icon]:ml-0 group-data-[collapsible=icon]:size-1.5 group-data-[collapsible=icon]:ring-2 group-data-[collapsible=icon]:ring-sidebar',
|
||||
DOT_CLASS[item.problem.tone],
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</SidebarMenuButton>
|
||||
<SidebarMenuAction
|
||||
showOnHover={!pinned}
|
||||
aria-label={pinned ? `Открепить ${item.label}` : `Закрепить ${item.label}`}
|
||||
aria-pressed={pinned}
|
||||
onClick={onTogglePin}
|
||||
>
|
||||
<StarIcon className={cn(pinned && 'fill-current')} aria-hidden />
|
||||
</SidebarMenuAction>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
export function PortalSidebar() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const { pins, togglePin } = useUiStore()
|
||||
const { navItems, navGroups, brand } = useChromeConfig()
|
||||
const [closed, setClosed] = useState<Record<string, boolean>>({})
|
||||
const pinned = navItems.filter((i) => pins.includes(i.id))
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader className="pb-1.5">
|
||||
{brand}
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{pinned.length > 0 ? (
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupLabel className="text-[11px] font-semibold tracking-wide uppercase">Закреплённые</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{pinned.map((item) => (
|
||||
<NavEntry key={item.id} item={item} pathname={pathname} pinned onTogglePin={() => togglePin(item.id)} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
) : null}
|
||||
{navGroups.map((group, gi) => (
|
||||
<div key={group} className="flex flex-col">
|
||||
{gi > 0 ? <SidebarSeparator className="mx-2 my-1 h-px shrink-0 hidden group-data-[collapsible=icon]:block" /> : null}
|
||||
<SidebarGroup className="py-0">
|
||||
<SidebarGroupLabel
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={!closed[group]}
|
||||
onClick={() => setClosed((c) => ({ ...c, [group]: !c[group] }))}
|
||||
/>
|
||||
}
|
||||
className="h-7 cursor-pointer justify-between text-[11px] font-semibold tracking-wide uppercase"
|
||||
>
|
||||
{group}
|
||||
<ChevronDownIcon className={cn('size-3.5 transition-transform', closed[group] && '-rotate-90')} aria-hidden />
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent className={cn(closed[group] && 'group-data-[collapsible=icon]:block hidden')}>
|
||||
<SidebarMenu className="gap-px">
|
||||
{navItems.filter((i) => i.group === group).map((item) => (
|
||||
<NavEntry
|
||||
key={item.id}
|
||||
item={item}
|
||||
pathname={pathname}
|
||||
pinned={pins.includes(item.id)}
|
||||
onTogglePin={() => togglePin(item.id)}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</div>
|
||||
))}
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<PortalNavUser />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
|
||||
/** Единый центр состояния: внимание + сводка + список проблем. Адаптивен по ширине окна, как на досках. */
|
||||
function MonitorPopover() {
|
||||
const { monitor } = useChromeConfig()
|
||||
const MONITOR_ITEMS = monitor.items
|
||||
const MONITOR_METRICS = monitor.metrics
|
||||
const bad = MONITOR_ITEMS.filter((m) => m.tone !== 'ok')
|
||||
const errors = bad.filter((m) => m.tone === 'er').length
|
||||
const warnings = bad.length - errors
|
||||
const ok = MONITOR_ITEMS.length - bad.length
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
aria-label={`Состояние системы: внимание, ${errors} сбой, ${warnings} предупреждения`}
|
||||
className="border-warning/40 bg-warning/10 hover:bg-warning/20 aria-expanded:bg-warning/20 relative h-8 gap-2 px-2 max-[760px]:w-8 max-[760px]:border-transparent max-[760px]:bg-transparent max-[760px]:px-0"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ActivityIcon className="text-warning size-4" aria-hidden />
|
||||
<span className="text-warning hidden text-sm font-medium min-[1000px]:inline">Внимание</span>
|
||||
<span className="hidden items-center gap-1.5 text-sm tabular-nums min-[1240px]:inline-flex">
|
||||
<span className="bg-destructive size-1.5 rounded-full" aria-hidden />
|
||||
{errors}
|
||||
<span className="bg-warning size-1.5 rounded-full" aria-hidden />
|
||||
{warnings}
|
||||
</span>
|
||||
<span className="bg-destructive hidden size-4.5 min-w-4.5 items-center justify-center rounded-full px-1 text-[11px] font-semibold text-white min-[760px]:inline-flex min-[1240px]:hidden">
|
||||
{bad.length}
|
||||
</span>
|
||||
<span className="bg-destructive ring-background absolute top-1 right-1 size-2 rounded-full ring-2 min-[760px]:hidden" aria-hidden />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="flex w-[24rem] max-w-[calc(100vw-1.5rem)] flex-col gap-2 p-2">
|
||||
<div className="flex items-start justify-between gap-2 px-1.5 pt-1.5">
|
||||
<div className="grid leading-tight">
|
||||
<b className="text-sm font-semibold">Состояние системы</b>
|
||||
<span className="text-muted-foreground text-xs">{monitor.updated}</span>
|
||||
</div>
|
||||
<Badge variant={errors > 0 ? "destructive-light" : warnings > 0 ? "warning-light" : "success-light"} size="sm">{errors > 0 ? "Сбой" : warnings > 0 ? "Внимание" : "Норма"}</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{[
|
||||
{ n: errors, l: 'сбой', c: 'text-destructive' },
|
||||
{ n: warnings, l: 'предупреждения', c: 'text-warning' },
|
||||
{ n: ok, l: 'в норме', c: 'text-success' },
|
||||
].map((x) => (
|
||||
<span key={x.l} className="bg-muted/55 text-muted-foreground flex flex-col rounded-lg px-2.5 py-2 text-[11.5px]">
|
||||
<b className={cn('text-lg leading-tight font-semibold tabular-nums', x.c)}>{x.n}</b>
|
||||
{x.l}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{MONITOR_ITEMS.filter((m) => m.tone !== 'ok').map((m) => (
|
||||
<li key={m.id}>
|
||||
<Link to={m.to} className="hover:bg-muted/55 flex items-center gap-2.5 rounded-md px-1.5 py-2 outline-none focus-visible:bg-muted/55">
|
||||
<span aria-hidden className={cn('size-2 shrink-0 rounded-full', m.tone === 'er' ? 'bg-destructive' : 'bg-warning')} />
|
||||
<span className="grid min-w-0 flex-1 leading-tight">
|
||||
<b className="truncate text-[13px] font-medium">{m.title}</b>
|
||||
<span className="text-muted-foreground truncate text-xs">{m.text}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0 text-[11.5px]">{m.age}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{MONITOR_METRICS.map((m) => (
|
||||
<div key={m.l} className="bg-muted/55 flex min-w-0 flex-col gap-1.5 rounded-lg px-3 py-2.5">
|
||||
<span className="text-muted-foreground text-xs leading-snug">{m.l}</span>
|
||||
<b className={cn('text-base font-semibold whitespace-nowrap tabular-nums', m.tone === 'success' ? 'text-success' : 'text-warning')}>{m.v}</b>
|
||||
<div className="bg-muted-foreground/25 mt-auto h-[3px] overflow-hidden rounded-full">
|
||||
<i className={cn('block h-full rounded-full', m.tone === 'success' ? 'bg-success' : 'bg-warning')} style={{ width: `${m.p}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Link to={monitor.footerTo} className="text-muted-foreground hover:text-foreground flex items-center justify-between px-1.5 pt-1 pb-0.5 text-sm">
|
||||
{monitor.footerLabel}
|
||||
<ArrowRightIcon className="size-3.5" aria-hidden />
|
||||
</Link>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function FreshnessPopover() {
|
||||
const pathname = useRouterState({ select: (st) => st.location.pathname })
|
||||
const { fresh: freshFor } = useChromeConfig()
|
||||
const fresh = freshFor(pathname)
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button type="button" variant="ghost" size="icon" aria-label="Источники и свежесть данных" title="Источники и свежесть данных" />
|
||||
}
|
||||
>
|
||||
<InfoIcon aria-hidden />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" sideOffset={8} className="flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2">
|
||||
<Badge variant="success-light" size="sm" className="w-fit">{fresh.badge}</Badge>
|
||||
<p className="text-muted-foreground text-sm">{fresh.text}</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export function PortalHeader() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const { setPaletteOpen } = useUiStore()
|
||||
const { navItems, crumbFor } = useChromeConfig()
|
||||
const current = navItems.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`))
|
||||
|
||||
return (
|
||||
<header className="bg-background/80 sticky top-0 z-20 flex min-h-12 shrink-0 items-center gap-2 border-b px-4 py-1.5 backdrop-blur md:px-6">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||
<Breadcrumb className="min-w-0">
|
||||
<BreadcrumbList className="flex-nowrap">
|
||||
<BreadcrumbItem className="min-w-0">
|
||||
<BreadcrumbPage className="truncate">{crumbFor ? crumbFor(pathname, current?.label) : (current?.label ?? 'Auth Portal')}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
<FreshnessPopover />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Командная палитра (⌘K)"
|
||||
className="md:hidden"
|
||||
onClick={() => setPaletteOpen(true)}
|
||||
>
|
||||
<SearchIcon aria-hidden />
|
||||
</Button>
|
||||
<MonitorPopover />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
type PaletteEntry = {
|
||||
id: string
|
||||
group: string
|
||||
label: string
|
||||
hint: string
|
||||
icon?: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
|
||||
dot?: 'ok' | 'er' | 'mu'
|
||||
run: () => void
|
||||
}
|
||||
|
||||
const DOT_BG = { ok: 'bg-success', er: 'bg-destructive', mu: 'bg-muted-foreground' } as const
|
||||
|
||||
/** Палитра ⌘K как на доске: 640px, группы заглавными, подсказки моноширинным справа, подвал с подсказкой. */
|
||||
export function CommandPalette() {
|
||||
const { paletteOpen, setPaletteOpen } = useUiStore()
|
||||
const { navItems, palette } = useChromeConfig()
|
||||
const navigate = useNavigate()
|
||||
const { theme, setTheme, resolvedTheme } = useTheme()
|
||||
const [query, setQuery] = useState('')
|
||||
const [active, setActive] = useState(0)
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
const match = (...t: string[]) => !q || t.some((x) => x.toLowerCase().includes(q))
|
||||
const go = (to: string) => () => void navigate({ to })
|
||||
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
|
||||
const list: PaletteEntry[] = [
|
||||
...[
|
||||
...palette.actions.map((x) => ({ id: x.id, label: x.label, hint: x.hint, icon: x.icon, run: go(x.to) })),
|
||||
{ id: 'act-theme', label: 'Переключить тему', hint: 'T', icon: MoonIcon, run: () => setTheme(current === 'dark' ? 'light' : 'dark') },
|
||||
]
|
||||
.filter((e) => match(e.label))
|
||||
.map((e) => ({ ...e, group: 'Действия' })),
|
||||
...navItems.filter((i) => match(i.label, i.group))
|
||||
.slice(0, 7)
|
||||
.map((i) => ({ id: `nav-${i.id}`, group: 'Разделы', label: i.label, hint: i.group, icon: i.icon, run: go(i.to) })),
|
||||
...Object.values(
|
||||
palette.objects
|
||||
.filter((o) => match(o.label, o.hint))
|
||||
.reduce<Record<string, typeof palette.objects>>((acc, o) => ((acc[o.group] ??= []).push(o), acc), {}),
|
||||
).flatMap((g) => g.slice(0, 5).map((o) => ({ id: o.id, group: o.group, label: o.label, hint: o.hint, dot: o.dot, run: go(o.to) }))),
|
||||
]
|
||||
// Запрос из трёх и более слов — черновик ассистента (как на доске «Палитра ⌘K»).
|
||||
if (query.trim().split(/\s+/).filter(Boolean).length >= 3) {
|
||||
return [
|
||||
{
|
||||
id: 'ai',
|
||||
group: 'Ассистент · запрос на естественном языке',
|
||||
label: `Понял так: «${query.trim()}»`,
|
||||
hint: 'Черновик: запрос разобран автоматически, результат нужно проверить.',
|
||||
run: () => void navigate({ to: palette.assistantTo }),
|
||||
} as PaletteEntry,
|
||||
...list,
|
||||
]
|
||||
}
|
||||
return list
|
||||
}, [query, navigate, theme, resolvedTheme, setTheme, navItems, palette])
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, Array<PaletteEntry & { index: number }>>()
|
||||
shown.forEach((e, index) => {
|
||||
const list = map.get(e.group) ?? []
|
||||
list.push({ ...e, index })
|
||||
map.set(e.group, list)
|
||||
})
|
||||
return [...map.entries()]
|
||||
}, [shown])
|
||||
|
||||
function close() {
|
||||
setPaletteOpen(false)
|
||||
setQuery('')
|
||||
setActive(0)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={paletteOpen} onOpenChange={(o) => (o ? setPaletteOpen(true) : close())}>
|
||||
<DialogContent className="top-[90px] w-[min(40rem,94vw)] max-w-none translate-y-0 gap-0 overflow-hidden p-0 sm:max-w-none" showCloseButton={false}>
|
||||
<DialogTitle className="sr-only">Командная палитра</DialogTitle>
|
||||
<DialogDescription className="sr-only">Разделы, пользователи, приложения и действия</DialogDescription>
|
||||
<div className="flex items-center gap-2.5 border-b px-4 py-3.5">
|
||||
<SearchIcon className="text-muted-foreground size-[18px]" aria-hidden />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
aria-label="Поиск и команды"
|
||||
placeholder="Раздел, пользователь, приложение или действие…"
|
||||
className="min-w-0 flex-1 bg-transparent text-[15px] outline-none"
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setActive(0)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setActive((a) => Math.min(a + 1, shown.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setActive((a) => Math.max(a - 1, 0))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
shown[active]?.run()
|
||||
close()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Kbd>Esc</Kbd>
|
||||
</div>
|
||||
<div className="max-h-[430px] overflow-y-auto" role="listbox" aria-label="Результаты">
|
||||
{groups.length === 0 ? (
|
||||
<p className="text-muted-foreground px-4 py-6 text-center text-sm">Ничего не найдено</p>
|
||||
) : (
|
||||
groups.map(([group, list]) => (
|
||||
<div key={group} className="flex flex-col">
|
||||
<div className="text-muted-foreground px-4 pt-2.5 pb-1 text-[11px] tracking-[0.05em] uppercase">{group}</div>
|
||||
{list.map((e) => {
|
||||
const Icon = e.icon
|
||||
return (
|
||||
<button
|
||||
key={e.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={e.index === active}
|
||||
onMouseEnter={() => setActive(e.index)}
|
||||
onClick={() => {
|
||||
e.run()
|
||||
close()
|
||||
}}
|
||||
className={cn('flex w-full items-center gap-3 px-4 py-[9px] text-left text-sm outline-none', e.index === active && 'bg-muted/60')}
|
||||
>
|
||||
{Icon ? <Icon className="size-4 shrink-0" aria-hidden /> : null}
|
||||
{e.dot ? <span className={cn('size-2 shrink-0 rounded-full', DOT_BG[e.dot])} aria-hidden /> : null}
|
||||
<span className="min-w-0 truncate">{e.label}</span>
|
||||
<span className="text-muted-foreground ml-auto truncate font-mono text-xs">{e.hint}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 border-t px-4 py-2 text-[11.5px]">
|
||||
<span>↑↓ выбор</span>
|
||||
<span>↵ открыть</span>
|
||||
<span>Esc закрыть</span>
|
||||
<span className="ml-auto">{palette.example}</span>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { DataGridFeatures, DataGridProps } from '@/components/reui/data-grid/data-grid'
|
||||
|
||||
export const DATA_GRID_CELL_PAD = 'py-3'
|
||||
export const DATA_GRID_CELL_PAD_FIRST = 'pl-5 py-3'
|
||||
export const DATA_GRID_CELL_PAD_LAST = 'pr-4 py-3'
|
||||
|
||||
export const DATA_GRID_LAYOUT: NonNullable<DataGridProps<DataGridFeatures, object>['tableLayout']> = {
|
||||
rowBorder: true,
|
||||
headerBackground: true,
|
||||
headerBorder: true,
|
||||
columnsResizable: false,
|
||||
}
|
||||
|
||||
export const DATA_GRID_CLASS_NAMES: NonNullable<DataGridProps<DataGridFeatures, object>['tableClassNames']> = {
|
||||
headerRow: 'border-b border-border',
|
||||
bodyRow: 'group/row',
|
||||
}
|
||||
|
||||
export const DATA_GRID_CONTAINER_CLASS = 'w-full rounded-none border-0'
|
||||
|
||||
/** Отступы ячейки по положению колонки: первая — pl-5, последняя — pr-4. */
|
||||
export function cellPad(index: number, count: number) {
|
||||
if (index === 0) return DATA_GRID_CELL_PAD_FIRST
|
||||
if (index === count - 1) return DATA_GRID_CELL_PAD_LAST
|
||||
return DATA_GRID_CELL_PAD
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Каркас списочной страницы как в MikrotikManager (/servers): карточка Frame без полей, тулбар
|
||||
* «сегменты · фильтры · поиск · счётчик», таблица с подложкой шапки и ячейками py-3.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Column } from '@tanstack/react-table'
|
||||
import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, SearchIcon } from 'lucide-react'
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from '@authportal/ui/components/input-group'
|
||||
import { cn } from '@authportal/ui/lib/utils'
|
||||
import type { DataGridFeatures } from '@/components/reui/data-grid/data-grid'
|
||||
|
||||
export function DataPageCard({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<Frame dense className={cn('w-full min-w-0', className)}>
|
||||
<FramePanel className="flex w-full min-w-0 flex-col gap-0 overflow-hidden p-0 py-0 shadow-none">{children}</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
export function SegmentedControl<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
label,
|
||||
}: {
|
||||
value: T
|
||||
onChange: (value: T) => void
|
||||
options: { value: T; label: string; count?: number }[]
|
||||
label?: string
|
||||
}) {
|
||||
return (
|
||||
<div role="group" aria-label={label} className="border-border bg-muted/40 flex w-fit items-center gap-1 rounded-md border p-0.5">
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
aria-pressed={value === o.value}
|
||||
onClick={() => onChange(o.value)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors',
|
||||
value === o.value ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{o.label}
|
||||
{o.count != null ? <span className="text-xs tabular-nums">{o.count}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DataPageToolbar({
|
||||
leading,
|
||||
segmented,
|
||||
filterControl,
|
||||
search,
|
||||
onSearchChange,
|
||||
searchPlaceholder = 'Поиск…',
|
||||
countLabel,
|
||||
actions,
|
||||
}: {
|
||||
leading?: ReactNode
|
||||
segmented?: { value: string; onChange: (value: string) => void; options: { value: string; label: string; count?: number }[]; label?: string }
|
||||
filterControl?: ReactNode
|
||||
search?: string
|
||||
onSearchChange?: (value: string) => void
|
||||
searchPlaceholder?: string
|
||||
countLabel?: string
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 border-b px-5 py-3">
|
||||
{leading}
|
||||
{segmented ? <SegmentedControl {...segmented} /> : null}
|
||||
{filterControl}
|
||||
{onSearchChange ? (
|
||||
<InputGroup className="max-w-sm min-w-[220px]">
|
||||
<InputGroupAddon>
|
||||
<SearchIcon className="size-3.5" aria-hidden />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput aria-label={searchPlaceholder} placeholder={searchPlaceholder} value={search ?? ''} onChange={(e) => onSearchChange(e.target.value)} />
|
||||
</InputGroup>
|
||||
) : null}
|
||||
{countLabel ? <span className="text-muted-foreground ml-auto text-sm">{countLabel}</span> : null}
|
||||
{actions}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DataGridSortHeader<TData extends object>({
|
||||
column,
|
||||
title,
|
||||
className,
|
||||
}: {
|
||||
column: Column<DataGridFeatures, TData, unknown>
|
||||
title: string
|
||||
className?: string
|
||||
}) {
|
||||
const sorted = column.getIsSorted()
|
||||
if (!column.getCanSort()) {
|
||||
return <span className={cn('text-muted-foreground text-xs font-medium', className)}>{title}</span>
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground focus-visible:outline-ring -ml-1 inline-flex items-center gap-1.5 rounded-md px-1 py-0.5 text-xs font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
|
||||
className,
|
||||
)}
|
||||
onClick={column.getToggleSortingHandler()}
|
||||
>
|
||||
{title}
|
||||
{sorted === 'asc' ? (
|
||||
<ArrowUpIcon aria-hidden className="text-foreground size-3" />
|
||||
) : sorted === 'desc' ? (
|
||||
<ArrowDownIcon aria-hidden className="text-foreground size-3" />
|
||||
) : (
|
||||
<ArrowUpDownIcon aria-hidden className="size-3 opacity-35" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { cn } from '@authportal/ui/lib/utils'
|
||||
import { CodeBlock, PortalSheet, SignalAlert, StatusBadge } from './bits'
|
||||
|
||||
/**
|
||||
* Баннер доски: заголовок и пояснение, справа «Объяснить» — открывает панель
|
||||
* «Ассистент · разбор» (черновик с причинами и тем, что проверить).
|
||||
*/
|
||||
export function ExplainBanner({
|
||||
tone,
|
||||
title,
|
||||
children,
|
||||
text,
|
||||
}: {
|
||||
tone: 'er' | 'wn'
|
||||
title: string
|
||||
/** Текст для панели разбора (то же, что в баннере). */
|
||||
text: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role="status"
|
||||
className={cn(
|
||||
'flex flex-wrap items-start gap-3 rounded-lg border px-3 py-2.5 text-sm',
|
||||
tone === 'er' ? 'border-destructive/30 bg-destructive/10' : 'border-warning/30 bg-warning/10',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1 basis-80 text-pretty">
|
||||
<b className="font-semibold">{title}</b> {children}
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(true)}>
|
||||
Объяснить
|
||||
</Button>
|
||||
</div>
|
||||
<PortalSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Ассистент · разбор"
|
||||
description="Черновик: проверьте перед применением"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Закрыть
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
void navigate({
|
||||
// В прод-образе preview-маршруты вырезаны (см. Dockerfile) — ведём в реальный журнал.
|
||||
to: window.location.pathname.startsWith('/design-preview')
|
||||
? '/design-preview/logins'
|
||||
: '/admin/logins',
|
||||
})
|
||||
}}
|
||||
>
|
||||
Открыть журнал входов
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SignalAlert tone="in" title="Что случилось">
|
||||
{title} {text}
|
||||
</SignalAlert>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-muted-foreground text-xs">Вероятные причины</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<StatusBadge tone="er">Подбор пароля</StatusBadge>
|
||||
<StatusBadge tone="wn">Устаревший passkey</StatusBadge>
|
||||
<StatusBadge tone="mu">Смена IP</StatusBadge>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-xs">Отсортированы по вероятности по журналу входов за 24 часа.</span>
|
||||
</div>
|
||||
<CodeBlock label="Что проверить">
|
||||
{'GET /api/v1/admin/audit?kind=logins&action=auth.login_failed&range=24h\nGET /api/v1/admin/sessions?user=ops@shnt.top'}
|
||||
</CodeBlock>
|
||||
<SignalAlert tone="wn" title="Ассистент ничего не меняет сам">
|
||||
Блокировка и сброс passkeys выполняются вручную с подтверждением и попадают в журнал «Изменения».
|
||||
</SignalAlert>
|
||||
</PortalSheet>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
const DATE = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
const SHORT = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit' })
|
||||
const TIME = new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
function parse(iso: string | null | undefined): Date | null {
|
||||
if (!iso) return null
|
||||
const d = new Date(iso)
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
/** 12.09.2026 */
|
||||
export function fmtDate(iso: string | null | undefined, empty = '—') {
|
||||
const d = parse(iso)
|
||||
return d ? DATE.format(d) : empty
|
||||
}
|
||||
|
||||
/** «сегодня 09:41», «вчера 22:15», иначе «12.09 09:41». */
|
||||
export function fmtWhen(iso: string | null | undefined, empty = '—', now = Date.now()) {
|
||||
const d = parse(iso)
|
||||
if (!d) return empty
|
||||
const dayMs = 24 * 60 * 60 * 1000
|
||||
const startOfToday = new Date(now)
|
||||
startOfToday.setHours(0, 0, 0, 0)
|
||||
const diff = startOfToday.getTime() - new Date(d).setHours(0, 0, 0, 0)
|
||||
if (diff === 0) return `сегодня ${TIME.format(d)}`
|
||||
if (diff === dayMs) return `вчера ${TIME.format(d)}`
|
||||
return `${SHORT.format(d)} ${TIME.format(d)}`
|
||||
}
|
||||
|
||||
/** Время дня 14:02. */
|
||||
export function fmtTime(iso: string | null | undefined, empty = '—') {
|
||||
const d = parse(iso)
|
||||
return d ? TIME.format(d) : empty
|
||||
}
|
||||
|
||||
/** «5 дн назад», «3 ч назад», «только что». */
|
||||
export function fmtAgo(iso: string | null | undefined, empty = '—', now = Date.now()) {
|
||||
const d = parse(iso)
|
||||
if (!d) return empty
|
||||
const sec = Math.max(0, Math.round((now - d.getTime()) / 1000))
|
||||
if (sec < 60) return 'только что'
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)} мин назад`
|
||||
if (sec < 86400) return `${Math.floor(sec / 3600)} ч назад`
|
||||
return `${Math.floor(sec / 86400)} дн назад`
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { cn } from '@authportal/ui/lib/utils'
|
||||
import type { KpiItem, KpiTone } from './kpi'
|
||||
|
||||
const VALUE: Record<KpiTone, string> = {
|
||||
neutral: 'text-foreground',
|
||||
ok: 'text-foreground',
|
||||
in: 'text-foreground',
|
||||
wn: 'text-warning',
|
||||
er: 'text-destructive',
|
||||
}
|
||||
|
||||
/** Колонки по числу ячеек и ширине окна (на досках — ширина приложения): 4 → 4/2/1, 3 → 3/1, 2 → 2/1. */
|
||||
const COLS: Record<number, string> = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-2 max-[640px]:grid-cols-1',
|
||||
3: 'grid-cols-1 min-[1240px]:grid-cols-3',
|
||||
4: 'grid-cols-1 min-[640px]:grid-cols-2 min-[1240px]:grid-cols-4',
|
||||
}
|
||||
|
||||
const TILE: Record<KpiTone, string> = {
|
||||
neutral: 'text-muted-foreground',
|
||||
ok: 'text-success',
|
||||
in: 'text-info',
|
||||
wn: 'text-warning',
|
||||
er: 'text-destructive',
|
||||
}
|
||||
|
||||
const GLYPH: Record<KpiTone, string> = { neutral: '', ok: '●', in: '', wn: '▲', er: '✕' }
|
||||
|
||||
const BADGE = {
|
||||
neutral: 'outline',
|
||||
ok: 'success-light',
|
||||
in: 'info-light',
|
||||
wn: 'warning-light',
|
||||
er: 'destructive-light',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Полоса показателей как на досках: один Frame, ячейки разделены линиями,
|
||||
* в ячейке подпись, крупное значение и плашка-подсказка.
|
||||
*/
|
||||
export function KpiStrip({ items, 'aria-label': ariaLabel }: { items: KpiItem[]; 'aria-label'?: string }) {
|
||||
return (
|
||||
<Frame spacing="sm" className="w-full min-w-0" aria-label={ariaLabel}>
|
||||
<FramePanel className="overflow-hidden p-0">
|
||||
<div className={cn('bg-border grid gap-px', COLS[Math.min(Math.max(items.length, 1), 4)])}>
|
||||
{items.map((k) => {
|
||||
const Icon = k.icon
|
||||
return (
|
||||
<div key={k.id} className="bg-card flex min-w-0 items-center gap-3 px-[18px] py-3.5">
|
||||
<IconTile variant="outline" aria-hidden className={cn('shrink-0', TILE[k.tone])}>
|
||||
<Icon />
|
||||
</IconTile>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="text-muted-foreground text-[13px]">{k.label}</div>
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<span className={cn('text-2xl font-semibold tracking-tight tabular-nums', VALUE[k.tone])}>{k.value}</span>
|
||||
<Badge variant={BADGE[k.tone]} size="sm" className="whitespace-nowrap">
|
||||
{GLYPH[k.tone] ? <span aria-hidden className="text-[0.55rem]">{GLYPH[k.tone]}</span> : null}
|
||||
{k.hint}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
export type KpiTone = 'neutral' | 'ok' | 'wn' | 'er' | 'in'
|
||||
|
||||
export type KpiItem = {
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
/** Плашка-подсказка рядом со значением. */
|
||||
hint: string
|
||||
tone: KpiTone
|
||||
/** Иконка показателя: плитка слева от подписи. */
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
/** Плитка KPI: иконка, подпись, значение (цветом при wn/er) и плашка-подсказка. */
|
||||
export function kpi(
|
||||
id: string,
|
||||
label: string,
|
||||
value: string,
|
||||
opts: { icon: LucideIcon; tone?: KpiTone; badge: string },
|
||||
): KpiItem {
|
||||
return { id, label, value, hint: opts.badge, tone: opts.tone ?? 'neutral', icon: opts.icon }
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { Columns3Icon, MoreHorizontalIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useTable, type ColumnDef, type RowSelectionState, type SortingState } from '@tanstack/react-table'
|
||||
import { DATA_GRID_RU } from '@/lib/reui-ru'
|
||||
import { DataGrid, dataGridFeatures, type DataGridFeatures } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||
import {
|
||||
DataGridTableDndRowHandle,
|
||||
DataGridTableDndRows,
|
||||
} from '@/components/reui/data-grid/data-grid-table-dnd-rows'
|
||||
import type { DragEndEvent } from '@dnd-kit/core'
|
||||
import {
|
||||
DataGridTable,
|
||||
DataGridTableRowSelect,
|
||||
DataGridTableRowSelectAll,
|
||||
} from '@/components/reui/data-grid/data-grid-table'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@authportal/ui/components/dropdown-menu'
|
||||
import { Switch } from '@authportal/ui/components/switch'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
||||
import { cn } from '@authportal/ui/lib/utils'
|
||||
import { StatusBadge } from './bits'
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
DATA_GRID_CLASS_NAMES,
|
||||
DATA_GRID_LAYOUT,
|
||||
} from './data-page-layout'
|
||||
import {
|
||||
DataGridSortHeader,
|
||||
DataPageCard,
|
||||
DataPageToolbar,
|
||||
} from './data-page'
|
||||
import type { Tone } from './tone'
|
||||
import type { RowMark } from './tone'
|
||||
import { TONE_DOT } from './tone'
|
||||
|
||||
export type GridColumn<T> = {
|
||||
id: string
|
||||
title: string
|
||||
/** Ширина колонки; сумма задаёт минимальную ширину таблицы. */
|
||||
size: number
|
||||
cell: (row: T) => ReactNode
|
||||
/** Ключ сортировки; без него колонка не сортируется. */
|
||||
sortValue?: (row: T) => string | number
|
||||
}
|
||||
|
||||
export type GridView<T> = { label: string; match: (row: T) => boolean }
|
||||
|
||||
/** Карточка строки для режима «Карточки» (как на доске «Пользователи»). */
|
||||
export type GridCard = {
|
||||
name: string
|
||||
sub?: string
|
||||
tone: Tone
|
||||
status: string
|
||||
cells: Array<{ l: string; v: string }>
|
||||
}
|
||||
|
||||
/** Действия меню строки «⋮»: на досках — Открыть, Включить/Выключить, Дублировать, Удалить…. */
|
||||
export type RowMenuConfig = {
|
||||
onOpen?: () => void
|
||||
toggleLabel?: string
|
||||
onToggle?: () => void
|
||||
onDuplicate?: () => void
|
||||
onDelete?: () => void
|
||||
}
|
||||
|
||||
export type GridBulk = {
|
||||
onLabel?: string
|
||||
offLabel?: string
|
||||
onRun?: (ids: string[]) => void
|
||||
offRun?: (ids: string[]) => void
|
||||
onDelete?: (ids: string[]) => void
|
||||
}
|
||||
|
||||
const SELECT_COLUMN_SIZE = 44
|
||||
const DRAG_COLUMN_SIZE = 44
|
||||
const ACTIONS_COLUMN_SIZE = 120
|
||||
|
||||
/**
|
||||
* Секция-таблица по доскам Auth Portal: шапка (название, действия, «Таблица/Карточки», поиск, «Колонки»),
|
||||
* сохранённые виды линией со счётчиками, панель массовых действий, таблица со статусом и меню строки,
|
||||
* подвал «Показано N из M». Ширина таблицы не меньше суммы колонок — прокрутка внутри контейнера.
|
||||
*/
|
||||
export function SectionGrid<T extends object>({
|
||||
title,
|
||||
description,
|
||||
rows,
|
||||
columns,
|
||||
getRowId,
|
||||
views,
|
||||
searchText,
|
||||
searchPlaceholder = 'Поиск…',
|
||||
onRowClick,
|
||||
selectable = true,
|
||||
bulk,
|
||||
tools,
|
||||
card,
|
||||
rowSwitch,
|
||||
rowMenu,
|
||||
onReorder,
|
||||
rowStatus,
|
||||
pageSize = 50,
|
||||
emptyMessage = 'Нет записей по текущим фильтрам.',
|
||||
className,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
rows: T[]
|
||||
columns: GridColumn<T>[]
|
||||
getRowId: (row: T) => string
|
||||
views?: GridView<T>[]
|
||||
searchText?: (row: T) => string
|
||||
searchPlaceholder?: string
|
||||
onRowClick?: (row: T) => void
|
||||
selectable?: boolean
|
||||
bulk?: GridBulk
|
||||
/** Кнопки действий в шапке; функция получает id выбранных строк. */
|
||||
tools?: ReactNode | ((selectedIds: string[]) => ReactNode)
|
||||
/** Включает переключатель «Таблица / Карточки». */
|
||||
card?: (row: T) => GridCard
|
||||
/** Переключатель «включено» в последней колонке (если у строки он есть). */
|
||||
rowSwitch?: { value: (row: T) => boolean | undefined; onChange: (row: T, on: boolean) => void }
|
||||
rowMenu?: (row: T) => RowMenuConfig
|
||||
/** Включает перетаскивание строк (dnd-kit). Передаёт id строки и id строки, на место которой её бросили. */
|
||||
onReorder?: (activeId: string, overId: string) => void
|
||||
/** Пометка строки: «Не сохранено» (dirty/new) или «Будет удалено» (deleted). */
|
||||
rowStatus?: (row: T) => RowMark | undefined
|
||||
pageSize?: number
|
||||
/** Свой текст подвала вместо «Показано N из M» (как «Показано 8 из 148» на доске). */
|
||||
footer?: string
|
||||
emptyMessage?: string
|
||||
className?: string
|
||||
}) {
|
||||
const storageKey = `design-preview:view:${title}`
|
||||
const [view, setViewState] = useState(() => {
|
||||
try {
|
||||
const v = Number(window.localStorage.getItem(storageKey))
|
||||
return Number.isInteger(v) && v >= 0 && v < (views?.length ?? 0) ? v : 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
})
|
||||
const setView = (i: number) => {
|
||||
setViewState(i)
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, String(i))
|
||||
} catch {
|
||||
/* без сохранения вида */
|
||||
}
|
||||
}
|
||||
const [query, setQuery] = useState('')
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize })
|
||||
const [hidden, setHidden] = useState<Record<string, boolean>>({})
|
||||
const [cards, setCards] = useState(false)
|
||||
|
||||
const data = useMemo(() => {
|
||||
const matchView = views?.[view]?.match
|
||||
const q = query.trim().toLowerCase()
|
||||
return rows.filter((row) => {
|
||||
if (matchView && !matchView(row)) return false
|
||||
if (q && searchText && !searchText(row).toLowerCase().includes(q)) return false
|
||||
return true
|
||||
})
|
||||
}, [rows, views, view, query, searchText])
|
||||
|
||||
const visibleColumns = useMemo(() => columns.filter((c, i) => i === 0 || !hidden[c.id]), [columns, hidden])
|
||||
|
||||
const defs = useMemo<ColumnDef<DataGridFeatures, T>[]>(() => {
|
||||
const list: ColumnDef<DataGridFeatures, T>[] = []
|
||||
if (onReorder) {
|
||||
list.push({
|
||||
id: 'drag',
|
||||
header: () => <span className="sr-only">Порядок</span>,
|
||||
cell: () => <DataGridTableDndRowHandle />,
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
size: DRAG_COLUMN_SIZE,
|
||||
})
|
||||
}
|
||||
if (selectable) {
|
||||
list.push({
|
||||
id: 'select',
|
||||
header: () => <DataGridTableRowSelectAll />,
|
||||
cell: ({ row }) => (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<DataGridTableRowSelect row={row} />
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
size: SELECT_COLUMN_SIZE,
|
||||
meta: { headerClassName: 'pl-5 py-3', cellClassName: 'pl-5 py-3' },
|
||||
})
|
||||
}
|
||||
visibleColumns.forEach((col, i) => {
|
||||
const pad = i === 0 && !selectable && !onReorder ? DATA_GRID_CELL_PAD_FIRST : DATA_GRID_CELL_PAD
|
||||
list.push({
|
||||
id: col.id,
|
||||
accessorFn: col.sortValue ?? (() => ''),
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title={col.title} />,
|
||||
cell: ({ row }) => col.cell(row.original),
|
||||
enableSorting: Boolean(col.sortValue),
|
||||
enableResizing: false,
|
||||
size: col.size,
|
||||
meta: { headerTitle: col.title, headerClassName: pad, cellClassName: pad },
|
||||
})
|
||||
})
|
||||
list.push({
|
||||
id: '__actions',
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const on = rowSwitch?.value(r)
|
||||
const menu: RowMenuConfig = rowMenu?.(r) ?? {}
|
||||
const soon = (what: string) => () => toast(`${what} (макет)`)
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
{on !== undefined && rowSwitch ? (
|
||||
<Switch size="sm" checked={on} onCheckedChange={(v) => rowSwitch.onChange(r, v)} aria-label="Включено" />
|
||||
) : null}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button type="button" variant="ghost" size="icon" aria-label="Действия" />}>
|
||||
<MoreHorizontalIcon aria-hidden />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-48">
|
||||
<DropdownMenuItem onClick={menu.onOpen ?? (() => onRowClick?.(r))}>Открыть</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={menu.onToggle ?? soon('Действие')}>
|
||||
{menu.toggleLabel ?? (on === undefined ? 'Повторить проверку' : on ? 'Выключить' : 'Включить')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={menu.onDuplicate ?? soon('Дублирование')}>Дублировать</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" onClick={menu.onDelete ?? soon('Удаление')}>
|
||||
Удалить…
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
size: ACTIONS_COLUMN_SIZE,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||
})
|
||||
return list
|
||||
}, [visibleColumns, selectable, onReorder, rowSwitch, rowMenu, onRowClick])
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
columns: defs,
|
||||
data,
|
||||
getRowId,
|
||||
state: { sorting, rowSelection, pagination },
|
||||
enableRowSelection: selectable,
|
||||
onSortingChange: setSorting,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onPaginationChange: setPagination,
|
||||
})
|
||||
|
||||
const dataIds = table.getRowModel().rows.map((r) => r.id)
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (!onReorder || !active || !over || active.id === over.id) return
|
||||
onReorder(String(active.id), String(over.id))
|
||||
}
|
||||
|
||||
// Колонки сжимаются под ширину рамки (текст переносится, как на досках); прокрутка — только на узких экранах.
|
||||
const minWidth = Math.round(
|
||||
visibleColumns.reduce(
|
||||
(sum, c) => sum + c.size,
|
||||
(selectable ? SELECT_COLUMN_SIZE : 0) + (onReorder ? DRAG_COLUMN_SIZE : 0) + ACTIONS_COLUMN_SIZE,
|
||||
) * 0.7,
|
||||
)
|
||||
const selectedIds = table.getSelectedRowModel().rows.map((r) => r.id)
|
||||
const clear = () => setRowSelection({})
|
||||
const paged = data.length > pageSize
|
||||
const showCards = Boolean(card) && cards
|
||||
|
||||
const countLabel = `${data.length} ${data.length === rows.length ? 'записей' : `из ${rows.length}`}`
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
i18n={DATA_GRID_RU}
|
||||
recordCount={data.length}
|
||||
onRowClick={onRowClick}
|
||||
getRowStatus={rowStatus}
|
||||
emptyMessage={emptyMessage}
|
||||
tableLayout={{ ...DATA_GRID_LAYOUT, rowsDraggable: Boolean(onReorder) }}
|
||||
tableClassNames={DATA_GRID_CLASS_NAMES}
|
||||
>
|
||||
<DataPageCard className={className}>
|
||||
<h2 className="sr-only">{title}</h2>
|
||||
<DataPageToolbar
|
||||
segmented={
|
||||
views
|
||||
? {
|
||||
value: String(view),
|
||||
label: description,
|
||||
onChange: (v) => {
|
||||
setView(Number(v))
|
||||
setPagination((p) => ({ ...p, pageIndex: 0 }))
|
||||
},
|
||||
options: views.map((v, i) => ({ value: String(i), label: v.label, count: rows.filter(v.match).length })),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
leading={
|
||||
card ? (
|
||||
<Tabs value={cards ? 'cards' : 'table'} onValueChange={(v) => setCards(v === 'cards')} aria-label="Вид списка">
|
||||
<TabsList className="h-9! gap-0.5 p-[3px]">
|
||||
<TabsTrigger value="table" className="h-7! px-2.5 text-[12.5px]">
|
||||
Таблица
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="cards" className="h-7! px-2.5 text-[12.5px]">
|
||||
Карточки
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
) : undefined
|
||||
}
|
||||
search={query}
|
||||
onSearchChange={
|
||||
searchText
|
||||
? (v) => {
|
||||
setQuery(v)
|
||||
setPagination((p) => ({ ...p, pageIndex: 0 }))
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
countLabel={countLabel}
|
||||
actions={
|
||||
<>
|
||||
{typeof tools === 'function' ? tools(selectedIds) : tools}
|
||||
{columns.length > 1 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button type="button" variant="outline" size="icon" aria-label="Выбрать колонки" title="Колонки" />}
|
||||
>
|
||||
<Columns3Icon aria-hidden />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-52">
|
||||
{columns.slice(1).map((c) => (
|
||||
<DropdownMenuCheckboxItem key={c.id} checked={!hidden[c.id]} onCheckedChange={(v) => setHidden((h) => ({ ...h, [c.id]: !v }))}>
|
||||
{c.title}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{selectable && selectedIds.length > 0 ? (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label="Массовые действия"
|
||||
className="bg-primary/10 flex flex-wrap items-center gap-2 border-b px-3.5 py-2 text-[13px]"
|
||||
>
|
||||
<b className="mr-1.5 font-semibold">Выбрано: {selectedIds.length}</b>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (bulk?.onRun) bulk.onRun(selectedIds)
|
||||
else toast(`${bulk?.onLabel ?? 'Включить'}: ${selectedIds.length} (макет)`)
|
||||
clear()
|
||||
}}
|
||||
>
|
||||
{bulk?.onLabel ?? 'Включить'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (bulk?.offRun) bulk.offRun(selectedIds)
|
||||
else toast(`${bulk?.offLabel ?? 'Выключить'}: ${selectedIds.length} (макет)`)
|
||||
clear()
|
||||
}}
|
||||
>
|
||||
{bulk?.offLabel ?? 'Выключить'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
if (bulk?.onDelete) bulk.onDelete(selectedIds)
|
||||
else toast(`Удаление: ${selectedIds.length} (макет)`)
|
||||
}}
|
||||
>
|
||||
Удалить…
|
||||
</Button>
|
||||
<span className="flex-1" />
|
||||
<Button type="button" variant="outline" onClick={clear}>
|
||||
Снять выбор
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{showCards && card ? (
|
||||
<ul className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,15rem),1fr))] gap-3 p-3">
|
||||
{data.map((r) => {
|
||||
const c = card(r)
|
||||
const id = getRowId(r)
|
||||
const marked = rowStatus?.(r)
|
||||
return (
|
||||
<li
|
||||
key={id}
|
||||
className={cn('bg-muted/25 flex flex-col gap-2 rounded-lg border p-3', marked === 'deleted' && 'opacity-60')}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`Выбрать ${c.name}`}
|
||||
checked={Boolean(rowSelection[id])}
|
||||
onChange={(e) =>
|
||||
setRowSelection((s) => {
|
||||
const next = { ...s }
|
||||
if (e.target.checked) next[id] = true
|
||||
else delete next[id]
|
||||
return next
|
||||
})
|
||||
}
|
||||
className="accent-primary size-4"
|
||||
/>
|
||||
<StatusBadge tone={c.tone}>{c.status}</StatusBadge>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRowClick?.(r)}
|
||||
className="flex min-w-0 items-start gap-2 text-left"
|
||||
aria-label={`Открыть: ${c.name}`}
|
||||
>
|
||||
<span aria-hidden className={cn('mt-1.5 size-2 shrink-0 rounded-full', TONE_DOT[c.tone])} />
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium">{c.name}</span>
|
||||
{c.sub ? <span className="text-muted-foreground truncate font-mono text-[11px]">{c.sub}</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
<dl className="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs">
|
||||
{c.cells.map((x) => (
|
||||
<div key={x.l} className="min-w-0">
|
||||
<dt className="text-muted-foreground">{x.l}</dt>
|
||||
<dd className="truncate font-mono">{x.v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<DataGridScrollArea>
|
||||
<div style={{ minWidth }}>
|
||||
{onReorder ? <DataGridTableDndRows dataIds={dataIds} handleDragEnd={handleDragEnd} /> : <DataGridTable />}
|
||||
</div>
|
||||
</DataGridScrollArea>
|
||||
)}
|
||||
{paged && !showCards ? <DataGridPagination className="px-5 py-3" sizes={[8, 16, 32]} /> : null}
|
||||
</DataPageCard>
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
|
||||
/** Двухстрочная ячейка: заголовок + подпись. */
|
||||
export function TwoLineCell({ title, sub, dot }: { title: ReactNode; sub?: ReactNode; dot?: Tone }) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
{dot ? <span aria-hidden className={cn('mt-1.5 size-2 shrink-0 rounded-full', TONE_DOT[dot])} /> : null}
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium">{title}</span>
|
||||
{sub ? <span className="text-muted-foreground truncate font-mono text-[11px]">{sub}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export type Tone = 'ok' | 'wn' | 'er' | 'mu' | 'in'
|
||||
|
||||
/** Пометка строки с накопленной правкой. */
|
||||
export type RowMark = 'new' | 'dirty' | 'deleted'
|
||||
|
||||
|
||||
type BadgeVariant =
|
||||
| 'success-light'
|
||||
| 'warning-light'
|
||||
| 'destructive-light'
|
||||
| 'info-light'
|
||||
| 'outline'
|
||||
|
||||
export const TONE_BADGE: Record<Tone, BadgeVariant> = {
|
||||
ok: 'success-light',
|
||||
wn: 'warning-light',
|
||||
er: 'destructive-light',
|
||||
in: 'info-light',
|
||||
mu: 'outline',
|
||||
}
|
||||
|
||||
export const TONE_DOT: Record<Tone, string> = {
|
||||
ok: 'bg-success',
|
||||
wn: 'bg-warning',
|
||||
er: 'bg-destructive',
|
||||
in: 'bg-success',
|
||||
mu: 'bg-muted-foreground/50',
|
||||
}
|
||||
@@ -0,0 +1,912 @@
|
||||
/**
|
||||
* Admin users grid — DNA solution-users-1 (members-grid, columns, bulk-action-bar).
|
||||
* Preview: https://reui.io/preview/base/solution-users-1
|
||||
* Docs: https://reui.io/docs/components/base/filters · https://reui.io/docs/components/base/data-grid
|
||||
*/
|
||||
import { memo, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import type { AdminUser, AppId } from '@authportal/shared'
|
||||
import {
|
||||
CircleDotIcon,
|
||||
FilterIcon,
|
||||
FunnelXIcon,
|
||||
FingerprintIcon,
|
||||
LockIcon,
|
||||
MailIcon,
|
||||
MoreHorizontalIcon,
|
||||
PauseCircleIcon,
|
||||
PencilIcon,
|
||||
ScrollTextIcon,
|
||||
Settings2Icon,
|
||||
ShieldCheckIcon,
|
||||
Trash2Icon,
|
||||
TriangleAlertIcon,
|
||||
UserIcon,
|
||||
UserPlusIcon,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
type ColumnDef,
|
||||
type PaginationState,
|
||||
type Row,
|
||||
type RowSelectionState,
|
||||
type SortingState,
|
||||
type ColumnVisibilityState,
|
||||
useTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGrid, dataGridFeatures, type DataGridFeatures } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DataGridColumnVisibility } from '@/components/reui/data-grid/data-grid-column-visibility'
|
||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||
import {
|
||||
DataGridTable,
|
||||
DataGridTableRowSelect,
|
||||
DataGridTableRowSelectAll,
|
||||
} from '@/components/reui/data-grid/data-grid-table'
|
||||
import { DATA_GRID_RU, FILTERS_RU, FILTER_OPERATORS_RU } from '@/lib/reui-ru'
|
||||
import { cellPad } from '@/components/portal/data-page-layout'
|
||||
import {
|
||||
DATA_GRID_CLASS_NAMES,
|
||||
DATA_GRID_LAYOUT,
|
||||
} from '@/components/portal/data-page-layout'
|
||||
import { DataPageCard, DataPageToolbar } from '@/components/portal/data-page'
|
||||
import { cn } from '@authportal/ui/lib/utils'
|
||||
import { Filters } from '@/components/reui/filters/filters'
|
||||
import {
|
||||
createFilterQuery,
|
||||
flattenFilterConditions,
|
||||
type FilterCondition,
|
||||
} from '@/components/reui/filters/filters-query'
|
||||
import type {
|
||||
FilterField,
|
||||
FilterQuery,
|
||||
} from '@/components/reui/filters/filters-types'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@authportal/ui/components/alert-dialog'
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
} from '@authportal/ui/components/avatar'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@authportal/ui/components/dropdown-menu'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@authportal/ui/components/select'
|
||||
import { Skeleton } from '@authportal/ui/components/skeleton'
|
||||
import { TooltipProvider } from '@authportal/ui/components/tooltip'
|
||||
|
||||
const BULK_ROLE_OPTIONS = [
|
||||
{ value: 'admin', label: 'Админ' },
|
||||
{ value: 'user', label: 'Пользователь' },
|
||||
] as const
|
||||
|
||||
type BulkRole = (typeof BULK_ROLE_OPTIONS)[number]['value']
|
||||
|
||||
export interface AdminUsersGridProps {
|
||||
users: AdminUser[]
|
||||
isLoading?: boolean
|
||||
onCreateClick: () => void
|
||||
onOpenAudit: (user: AdminUser) => void
|
||||
onOpenAccess: (user: AdminUser) => void
|
||||
onDeactivate: (user: AdminUser) => void
|
||||
onDelete: (user: AdminUser) => void
|
||||
onResetPasskeys: (user: AdminUser) => void
|
||||
onBulkSetRole: (userIds: string[], isAdmin: boolean) => void
|
||||
onBulkDeactivate: (userIds: string[]) => void
|
||||
}
|
||||
|
||||
function serializeConditionsKey(conditions: FilterCondition[]) {
|
||||
return JSON.stringify(
|
||||
conditions.map((c) => ({
|
||||
field: c.field,
|
||||
operator: c.operator,
|
||||
values: c.values,
|
||||
negated: c.negated,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
function filterFieldValue(item: AdminUser, field: string): unknown {
|
||||
if (field === 'name') return item.name
|
||||
if (field === 'email') return item.email
|
||||
if (field === 'role') return item.is_admin ? 'admin' : 'user'
|
||||
if (field === 'status') return item.disabled ? 'disabled' : 'active'
|
||||
if (field === 'apps') return item.apps.join(' ')
|
||||
return undefined
|
||||
}
|
||||
|
||||
function matchesCondition(item: AdminUser, condition: FilterCondition): boolean {
|
||||
const { field, operator, values } = condition
|
||||
const raw = filterFieldValue(item, field)
|
||||
const fieldValue = raw != null ? String(raw) : ''
|
||||
const lower = fieldValue.toLowerCase()
|
||||
const tokens = values.map((v) => String(v).trim()).filter(Boolean)
|
||||
|
||||
switch (operator) {
|
||||
case 'is':
|
||||
return values.map(String).includes(fieldValue)
|
||||
case 'is_not':
|
||||
return !values.map(String).includes(fieldValue)
|
||||
case 'is_any_of':
|
||||
return values.map(String).includes(fieldValue)
|
||||
case 'is_none_of':
|
||||
return !values.map(String).includes(fieldValue)
|
||||
case 'contains':
|
||||
return tokens.length === 0 || tokens.some((t) => lower.includes(t.toLowerCase()))
|
||||
case 'not_contains':
|
||||
return !tokens.some((t) => lower.includes(t.toLowerCase()))
|
||||
case 'starts_with':
|
||||
return tokens.some((t) => lower.startsWith(t.toLowerCase()))
|
||||
case 'ends_with':
|
||||
return tokens.some((t) => lower.endsWith(t.toLowerCase()))
|
||||
case 'empty':
|
||||
return fieldValue === ''
|
||||
case 'not_empty':
|
||||
return fieldValue !== ''
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function applyConditionsToUsers(data: AdminUser[], conditions: FilterCondition[]): AdminUser[] {
|
||||
if (conditions.length === 0) return data
|
||||
return data.filter((item) =>
|
||||
conditions.every((c) => {
|
||||
const hit = matchesCondition(item, c)
|
||||
return c.negated ? !hit : hit
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function userInitials(name: string, email: string) {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean)
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase()
|
||||
}
|
||||
return (name || email).slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
function formatLastActive(iso: string | null) {
|
||||
if (!iso) return '—'
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
function RoleBadge({ isAdmin }: { isAdmin: boolean }) {
|
||||
return isAdmin ? (
|
||||
<Badge variant="info-outline">Админ</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Пользователь</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ disabled }: { disabled: boolean }) {
|
||||
return disabled ? (
|
||||
<Badge variant="outline">
|
||||
<span className="bg-muted-foreground size-1.5 shrink-0 rounded-full!" />
|
||||
Отключён
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">
|
||||
<span className="bg-success size-1.5 shrink-0 rounded-full!" />
|
||||
Активен
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
const AppTags = memo(function AppTags({ apps }: { apps: AppId[] }) {
|
||||
if (apps.length === 0) {
|
||||
return <span className="text-muted-foreground text-sm">—</span>
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{apps.map((appId) => (
|
||||
<Badge key={appId} variant="secondary" className="border-0">
|
||||
{appId.toUpperCase()}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const MemberCell = memo(function MemberCell({ row }: { row: Row<DataGridFeatures, AdminUser> }) {
|
||||
const user = row.original
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Avatar className="size-8 shrink-0">
|
||||
<AvatarFallback className="text-xs">
|
||||
{userInitials(user.name, user.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<div className="text-foreground line-clamp-1 font-medium">
|
||||
{user.name}
|
||||
</div>
|
||||
<div
|
||||
className="text-muted-foreground line-clamp-1 text-xs"
|
||||
title={user.email}
|
||||
>
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function AdminUsersBulkActionBar({
|
||||
selectedCount,
|
||||
roleValue,
|
||||
onRoleChange,
|
||||
onChangeRole,
|
||||
onDeactivate,
|
||||
onClear,
|
||||
}: {
|
||||
selectedCount: number
|
||||
roleValue: BulkRole
|
||||
onRoleChange: (value: BulkRole) => void
|
||||
onChangeRole: () => void
|
||||
onDeactivate: () => void
|
||||
onClear: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-muted/25 flex flex-col gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py) lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">
|
||||
Выбрано: {selectedCount}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Массовое изменение роли или отключение доступа.
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={roleValue}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return
|
||||
onRoleChange(value as BulkRole)
|
||||
}}
|
||||
items={BULK_ROLE_OPTIONS.map((o) => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
}))}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-[164px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectGroup>
|
||||
{BULK_ROLE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button type="button" size="sm" variant="outline" onClick={onChangeRole}>
|
||||
<PencilIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Сменить роль
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={onDeactivate}>
|
||||
<PauseCircleIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Отключить
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="ghost" onClick={onClear}>
|
||||
Сбросить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionsCell({
|
||||
row,
|
||||
onOpenAudit,
|
||||
onOpenAccess,
|
||||
onDeactivate,
|
||||
onDelete,
|
||||
onResetPasskeys,
|
||||
}: {
|
||||
row: Row<DataGridFeatures, AdminUser>
|
||||
onOpenAudit: (user: AdminUser) => void
|
||||
onOpenAccess: (user: AdminUser) => void
|
||||
onDeactivate: (user: AdminUser) => void
|
||||
onDelete: (user: AdminUser) => void
|
||||
onResetPasskeys: (user: AdminUser) => void
|
||||
}) {
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [resetOpen, setResetOpen] = useState(false)
|
||||
const user = row.original
|
||||
|
||||
return (
|
||||
<div onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7"
|
||||
aria-label="Действия"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-44">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => onOpenAudit(user)}>
|
||||
<ScrollTextIcon className="size-4" aria-hidden="true" />
|
||||
Журнал
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onOpenAccess(user)}>
|
||||
<ShieldCheckIcon className="size-4" aria-hidden="true" />
|
||||
Права
|
||||
</DropdownMenuItem>
|
||||
{!user.disabled ? (
|
||||
<DropdownMenuItem onClick={() => onDeactivate(user)}>
|
||||
<PauseCircleIcon className="size-4" aria-hidden="true" />
|
||||
Отключить
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{(user.passkey_count ?? 0) > 0 ? (
|
||||
<DropdownMenuItem onClick={() => setResetOpen(true)}>
|
||||
<FingerprintIcon className="size-4" aria-hidden="true" />
|
||||
Сбросить passkeys
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2Icon className="size-4" aria-hidden="true" />
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<AlertDialog open={resetOpen} onOpenChange={setResetOpen}>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Сбросить passkeys?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Все ключи пользователя{' '}
|
||||
<span className="text-foreground font-medium">{user.email}</span>{' '}
|
||||
будут удалены. Вход останется по паролю.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setResetOpen(false)
|
||||
onResetPasskeys(user)
|
||||
}}
|
||||
>
|
||||
Сбросить
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Удалить пользователя?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Учётная запись{' '}
|
||||
<span className="text-foreground font-medium">{user.email}</span>{' '}
|
||||
будет удалена без возможности восстановления.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setDeleteOpen(false)
|
||||
onDelete(user)
|
||||
}}
|
||||
>
|
||||
Удалить
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function createAdminUserColumns(handlers: {
|
||||
onOpenAudit: (user: AdminUser) => void
|
||||
onOpenAccess: (user: AdminUser) => void
|
||||
onDeactivate: (user: AdminUser) => void
|
||||
onDelete: (user: AdminUser) => void
|
||||
onResetPasskeys: (user: AdminUser) => void
|
||||
}): ColumnDef<DataGridFeatures, AdminUser>[] {
|
||||
return [
|
||||
{
|
||||
id: 'select',
|
||||
header: () => (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<DataGridTableRowSelectAll />
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<DataGridTableRowSelect row={row} />
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
size: 40,
|
||||
enableResizing: false,
|
||||
enableHiding: false,
|
||||
meta: {
|
||||
skeleton: <Skeleton className="mx-auto size-5" />,
|
||||
headerClassName:
|
||||
'[--data-grid-header-cell-ps:var(--frame-panel-header-px)]',
|
||||
cellClassName: '[--data-grid-body-cell-ps:var(--frame-panel-px)]',
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
id: 'name',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Участник" visibility column={column} />
|
||||
),
|
||||
cell: ({ row }) => <MemberCell row={row} />,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
enableResizing: true,
|
||||
minSize: 220,
|
||||
meta: {
|
||||
headerTitle: 'Участник',
|
||||
autoSize: true,
|
||||
skeleton: (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Skeleton className="size-8 shrink-0 rounded-full" />
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<Skeleton className="h-3.5 w-32" />
|
||||
<Skeleton className="h-3 w-40" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'role',
|
||||
accessorFn: (row) => (row.is_admin ? 'admin' : 'user'),
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Роль" visibility column={column} />
|
||||
),
|
||||
cell: ({ row }) => <RoleBadge isAdmin={row.original.is_admin} />,
|
||||
size: 130,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: 'Роль',
|
||||
skeleton: <Skeleton className="h-6 w-20 rounded-full" />,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'apps',
|
||||
accessorFn: (row) => row.apps.length,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Apps" visibility column={column} />
|
||||
),
|
||||
cell: ({ row }) => <AppTags apps={row.original.apps} />,
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: 'Apps',
|
||||
skeleton: (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<Skeleton className="h-5 w-12 rounded-full" />
|
||||
<Skeleton className="h-5 w-12 rounded-full" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorFn: (row) => (row.disabled ? 'disabled' : 'active'),
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Статус" visibility column={column} />
|
||||
),
|
||||
cell: ({ row }) => <StatusBadge disabled={row.original.disabled} />,
|
||||
size: 130,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: 'Статус',
|
||||
skeleton: <Skeleton className="h-6 w-24 rounded-full" />,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'auth',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Auth" visibility column={column} />
|
||||
),
|
||||
cell: () => (
|
||||
<Badge variant="warning-outline">
|
||||
<LockIcon aria-hidden="true" />
|
||||
Локальный
|
||||
</Badge>
|
||||
),
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: 'Auth',
|
||||
skeleton: <Skeleton className="h-5 w-20 rounded-full" />,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'twoFactor',
|
||||
accessorFn: (row) => (row.passkey_count ?? 0) > 0,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="2FA" visibility column={column} />
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
(row.original.passkey_count ?? 0) > 0 ? (
|
||||
<Badge variant="success-outline">
|
||||
<FingerprintIcon aria-hidden="true" />
|
||||
Passkey
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive-outline">
|
||||
<TriangleAlertIcon aria-hidden="true" />
|
||||
Выкл
|
||||
</Badge>
|
||||
),
|
||||
size: 100,
|
||||
enableSorting: false,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: '2FA',
|
||||
skeleton: <Skeleton className="h-5 w-16 rounded-full" />,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'last_login_at',
|
||||
id: 'lastActive',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader
|
||||
title="Last Active"
|
||||
visibility
|
||||
column={column}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{formatLastActive(row.original.last_login_at)}
|
||||
</span>
|
||||
),
|
||||
size: 150,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: 'Last Active',
|
||||
skeleton: <Skeleton className="h-4 w-28" />,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'last_login_ip',
|
||||
id: 'lastIp',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Последний IP" visibility column={column} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground font-mono text-xs tabular-nums">
|
||||
{row.original.last_login_ip ?? '—'}
|
||||
</span>
|
||||
),
|
||||
size: 140,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: 'Последний IP',
|
||||
skeleton: <Skeleton className="h-4 w-24" />,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
cell: ({ row }) => (
|
||||
<ActionsCell
|
||||
row={row}
|
||||
onOpenAudit={handlers.onOpenAudit}
|
||||
onOpenAccess={handlers.onOpenAccess}
|
||||
onDeactivate={handlers.onDeactivate}
|
||||
onDelete={handlers.onDelete}
|
||||
onResetPasskeys={handlers.onResetPasskeys}
|
||||
/>
|
||||
),
|
||||
size: 60,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
meta: {
|
||||
skeleton: <Skeleton className="mx-auto size-7 rounded-md" />,
|
||||
headerClassName:
|
||||
'[--data-grid-header-cell-pe:var(--frame-panel-header-px)]',
|
||||
cellClassName: '[--data-grid-body-cell-pe:var(--frame-panel-px)]',
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function AdminUsersGrid({
|
||||
users,
|
||||
isLoading = false,
|
||||
onCreateClick,
|
||||
onOpenAudit,
|
||||
onOpenAccess,
|
||||
onDeactivate,
|
||||
onDelete,
|
||||
onResetPasskeys,
|
||||
onBulkSetRole,
|
||||
onBulkDeactivate,
|
||||
}: AdminUsersGridProps) {
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
})
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: 'name', desc: false },
|
||||
])
|
||||
const [columnVisibility, setColumnVisibility] = useState<ColumnVisibilityState>({})
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
const [bulkRole, setBulkRole] = useState<BulkRole>('user')
|
||||
const lastAppliedKey = useRef<string>(serializeConditionsKey([]))
|
||||
|
||||
const filterFields: FilterField[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'name',
|
||||
label: 'Участник',
|
||||
icon: <UserIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
placeholder: 'Поиск…',
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
label: 'Email',
|
||||
icon: <MailIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
placeholder: 'Поиск…',
|
||||
},
|
||||
{
|
||||
id: 'role',
|
||||
label: 'Роль',
|
||||
icon: <ShieldCheckIcon className="size-3.5" aria-hidden />,
|
||||
type: 'select',
|
||||
searchable: false,
|
||||
options: BULK_ROLE_OPTIONS.map((o) => ({ value: o.value, label: o.label })),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
label: 'Статус',
|
||||
icon: <CircleDotIcon className="size-3.5" aria-hidden />,
|
||||
type: 'select',
|
||||
searchable: false,
|
||||
options: [
|
||||
{ value: 'active', label: 'Активен' },
|
||||
{ value: 'disabled', label: 'Отключён' },
|
||||
],
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const conditions = useMemo(() => flattenFilterConditions(query), [query])
|
||||
const filteredData = useMemo(() => applyConditionsToUsers(users, conditions), [users, conditions])
|
||||
|
||||
const handleQueryChange = useCallback((next: FilterQuery) => {
|
||||
setQuery(next)
|
||||
const nextKey = serializeConditionsKey(flattenFilterConditions(next))
|
||||
if (nextKey === lastAppliedKey.current) return
|
||||
lastAppliedKey.current = nextKey
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
|
||||
setRowSelection({})
|
||||
}, [])
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
createAdminUserColumns({
|
||||
onOpenAudit,
|
||||
onOpenAccess,
|
||||
onDeactivate,
|
||||
onDelete,
|
||||
onResetPasskeys,
|
||||
}).map((col, i, all) => {
|
||||
const pad = cellPad(i, all.length)
|
||||
return {
|
||||
...col,
|
||||
meta: {
|
||||
...col.meta,
|
||||
headerClassName: cn(col.meta?.headerClassName, pad),
|
||||
cellClassName: cn(col.meta?.cellClassName, pad),
|
||||
},
|
||||
}
|
||||
}),
|
||||
[onOpenAudit, onOpenAccess, onDeactivate, onDelete, onResetPasskeys],
|
||||
)
|
||||
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>(
|
||||
() => columns.map((c) => c.id as string),
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
columns,
|
||||
data: filteredData,
|
||||
pageCount: Math.ceil(filteredData.length / pagination.pageSize),
|
||||
getRowId: (row) => row.id,
|
||||
state: {
|
||||
pagination,
|
||||
sorting,
|
||||
columnOrder,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
columnResizeMode: 'onChange',
|
||||
onColumnOrderChange: setColumnOrder,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination,
|
||||
onSortingChange: setSorting,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
})
|
||||
|
||||
const selectedCount = table.getSelectedRowModel().rows.length
|
||||
const selectedIds = table
|
||||
.getSelectedRowModel()
|
||||
.rows.map((row) => row.original.id)
|
||||
const showClearButton = conditions.length > 0
|
||||
const gridLoading = isLoading
|
||||
|
||||
const handleClearSelection = useCallback(() => setRowSelection({}), [])
|
||||
|
||||
const handleChangeRole = useCallback(() => {
|
||||
if (selectedCount === 0) return
|
||||
onBulkSetRole(selectedIds, bulkRole === 'admin')
|
||||
setRowSelection({})
|
||||
}, [bulkRole, onBulkSetRole, selectedCount, selectedIds])
|
||||
|
||||
const handleBulkDeactivate = useCallback(() => {
|
||||
if (selectedCount === 0) return
|
||||
onBulkDeactivate(selectedIds)
|
||||
setRowSelection({})
|
||||
}, [onBulkDeactivate, selectedCount, selectedIds])
|
||||
|
||||
const handleClearFilters = useCallback(() => {
|
||||
lastAppliedKey.current = serializeConditionsKey([])
|
||||
setQuery(createFilterQuery())
|
||||
setRowSelection({})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<TooltipProvider delay={200}>
|
||||
<DataGrid
|
||||
table={table}
|
||||
i18n={DATA_GRID_RU}
|
||||
isLoading={gridLoading}
|
||||
loadingMode="skeleton"
|
||||
recordCount={filteredData.length}
|
||||
onRowClick={onOpenAudit}
|
||||
emptyMessage={
|
||||
!gridLoading && filteredData.length === 0
|
||||
? 'Нет пользователей по текущим фильтрам. Сбросьте фильтры или измените условия.'
|
||||
: undefined
|
||||
}
|
||||
tableLayout={{
|
||||
...DATA_GRID_LAYOUT,
|
||||
columnsResizable: true,
|
||||
columnsMovable: true,
|
||||
columnsVisibility: true,
|
||||
headerSticky: true,
|
||||
}}
|
||||
tableClassNames={DATA_GRID_CLASS_NAMES}
|
||||
>
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
filterControl={
|
||||
<Filters
|
||||
query={query}
|
||||
fields={filterFields}
|
||||
onQueryChange={handleQueryChange}
|
||||
labels={FILTERS_RU}
|
||||
operatorLabels={FILTER_OPERATORS_RU}
|
||||
size="default"
|
||||
trigger={
|
||||
<Button type="button" size="default" variant="outline" aria-label="Фильтры">
|
||||
<FilterIcon aria-hidden />
|
||||
Фильтры
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
countLabel={`${filteredData.length} пользователей`}
|
||||
actions={
|
||||
<>
|
||||
{showClearButton ? (
|
||||
<Button type="button" size="default" variant="outline" className="shrink-0" onClick={handleClearFilters} disabled={gridLoading}>
|
||||
<FunnelXIcon aria-hidden />
|
||||
Сбросить
|
||||
</Button>
|
||||
) : null}
|
||||
<DataGridColumnVisibility
|
||||
table={table}
|
||||
trigger={
|
||||
<Button type="button" size="default" variant="outline" aria-label="Настройки таблицы">
|
||||
<Settings2Icon aria-hidden="true" />
|
||||
Колонки
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Button type="button" size="default" onClick={onCreateClick}>
|
||||
<UserPlusIcon aria-hidden="true" />
|
||||
Создать
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{selectedCount > 0 ? (
|
||||
<AdminUsersBulkActionBar
|
||||
selectedCount={selectedCount}
|
||||
roleValue={bulkRole}
|
||||
onRoleChange={setBulkRole}
|
||||
onChangeRole={handleChangeRole}
|
||||
onDeactivate={handleBulkDeactivate}
|
||||
onClear={handleClearSelection}
|
||||
/>
|
||||
) : null}
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
<DataGridPagination className="px-5 py-3" sizes={[10, 20, 30]} />
|
||||
</DataPageCard>
|
||||
</DataGrid>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Admin editor for portal App Switcher URLs.
|
||||
* Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3
|
||||
*/
|
||||
import { useEffect } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import {
|
||||
APP_IDS,
|
||||
appSwitcherConfigSchema,
|
||||
type AppId,
|
||||
type AppSwitcherConfig,
|
||||
type AppSwitcherIconName,
|
||||
} from '@authportal/shared'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { Field, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
|
||||
import { Input } from '@authportal/ui/components/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@authportal/ui/components/select'
|
||||
import { Switch } from '@authportal/ui/components/switch'
|
||||
import { ItemSeparator } from '@authportal/ui/components/item'
|
||||
|
||||
const ICON_OPTIONS: AppSwitcherIconName[] = [
|
||||
'server',
|
||||
'cloud',
|
||||
'globe',
|
||||
'dashboard',
|
||||
'chart',
|
||||
]
|
||||
|
||||
interface AppSwitcherAdminEditorProps {
|
||||
defaultValues: AppSwitcherConfig
|
||||
onSave: (values: AppSwitcherConfig) => void
|
||||
isSaving?: boolean
|
||||
}
|
||||
|
||||
export function AppSwitcherAdminEditor({
|
||||
defaultValues,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: AppSwitcherAdminEditorProps) {
|
||||
const form = useForm({
|
||||
resolver: zodResolver(appSwitcherConfigSchema),
|
||||
defaultValues,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(defaultValues)
|
||||
}, [defaultValues, form])
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-5"
|
||||
onSubmit={(e) =>
|
||||
void form.handleSubmit((values) => onSave(values))(e)
|
||||
}
|
||||
>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="menu-label">Заголовок меню</FieldLabel>
|
||||
<Input id="menu-label" {...form.register('menuLabel')} />
|
||||
</Field>
|
||||
|
||||
{APP_IDS.map((appId, index) => {
|
||||
const apps = form.watch('apps')
|
||||
const appIndex = apps.findIndex((a) => a.id === appId)
|
||||
if (appIndex < 0) return null
|
||||
return (
|
||||
<div key={appId} className="flex flex-col gap-3">
|
||||
{index > 0 ? <ItemSeparator /> : null}
|
||||
<p className="text-sm font-medium tracking-tight">
|
||||
{appId.toUpperCase()}
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field className="sm:col-span-2">
|
||||
<FieldLabel htmlFor={`name-${appId}`}>Название</FieldLabel>
|
||||
<Input
|
||||
id={`name-${appId}`}
|
||||
{...form.register(`apps.${appIndex}.name`)}
|
||||
/>
|
||||
</Field>
|
||||
<Field className="sm:col-span-2">
|
||||
<FieldLabel htmlFor={`url-${appId}`}>URL</FieldLabel>
|
||||
<Input
|
||||
id={`url-${appId}`}
|
||||
{...form.register(`apps.${appIndex}.url`)}
|
||||
placeholder="https://…"
|
||||
/>
|
||||
</Field>
|
||||
{appId === 'bgp' ? (
|
||||
<Field className="sm:col-span-2">
|
||||
<FieldLabel htmlFor={`tenant-${appId}`}>
|
||||
EvoBGP tenant ID
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={`tenant-${appId}`}
|
||||
{...form.register(`apps.${appIndex}.tenantId`)}
|
||||
placeholder="UUID тенанта из EvoBGP"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Попадает в JWT (`bgp_tenant_id` / `tenants.bgp`). Публичный
|
||||
switcher его не отдаёт.
|
||||
</p>
|
||||
</Field>
|
||||
) : null}
|
||||
<Field className="sm:col-span-2">
|
||||
<FieldLabel htmlFor={`subtitle-${appId}`}>
|
||||
Описание
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={`subtitle-${appId}`}
|
||||
{...form.register(`apps.${appIndex}.subtitle`)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`icon-${appId}`}>Иконка</FieldLabel>
|
||||
<Select
|
||||
value={form.watch(`apps.${appIndex}.icon`)}
|
||||
onValueChange={(v) =>
|
||||
form.setValue(
|
||||
`apps.${appIndex}.icon`,
|
||||
(v ?? 'server') as AppSwitcherIconName,
|
||||
{ shouldDirty: true },
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={`icon-${appId}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ICON_OPTIONS.map((icon) => (
|
||||
<SelectItem key={icon} value={icon}>
|
||||
{icon}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field orientation="horizontal" className="items-center pt-6">
|
||||
<Switch
|
||||
checked={form.watch(`apps.${appIndex}.enabled`) !== false}
|
||||
onCheckedChange={(v) =>
|
||||
form.setValue(`apps.${appIndex}.enabled`, v, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<FieldLabel className="font-normal">Включено в switcher</FieldLabel>
|
||||
</Field>
|
||||
</div>
|
||||
<input
|
||||
type="hidden"
|
||||
{...form.register(`apps.${appIndex}.id`)}
|
||||
value={appId satisfies AppId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</FieldGroup>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-fit"
|
||||
disabled={isSaving || !form.formState.isDirty}
|
||||
>
|
||||
{isSaving ? 'Сохранение…' : 'Сохранить'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { AuditLogEntry, AuditSeverity, AuditSourceApp } from '@authportal/shared'
|
||||
import {
|
||||
KeyRoundIcon,
|
||||
LogInIcon,
|
||||
LogOutIcon,
|
||||
SettingsIcon,
|
||||
ShieldAlertIcon,
|
||||
Trash2Icon,
|
||||
UserCogIcon,
|
||||
UserPlusIcon,
|
||||
UserXIcon,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import type { BadgeProps } from '@/components/reui/badge'
|
||||
|
||||
export type AuditFilterId = 'all' | 'auth' | 'users' | 'settings'
|
||||
|
||||
export const AUDIT_FILTER_OPTIONS: { value: AuditFilterId; label: string }[] = [
|
||||
{ value: 'all', label: 'Все' },
|
||||
{ value: 'auth', label: 'Auth' },
|
||||
{ value: 'users', label: 'Пользователи' },
|
||||
{ value: 'settings', label: 'Настройки' },
|
||||
]
|
||||
|
||||
export const RANGE_OPTIONS = [
|
||||
{ value: '24h', label: 'За 24 часа' },
|
||||
{ value: '7d', label: 'За 7 дней' },
|
||||
{ value: '30d', label: 'За 30 дней' },
|
||||
{ value: '90d', label: 'За 90 дней' },
|
||||
{ value: 'all', label: 'Весь период' },
|
||||
] as const
|
||||
|
||||
export type AuditRange = (typeof RANGE_OPTIONS)[number]['value']
|
||||
|
||||
export const SOURCE_APP_OPTIONS: {
|
||||
value: AuditSourceApp | 'all'
|
||||
label: string
|
||||
}[] = [
|
||||
{ value: 'all', label: 'Все приложения' },
|
||||
{ value: 'portal', label: 'Auth Portal' },
|
||||
{ value: 'vps', label: 'VPS Tracker' },
|
||||
{ value: 'cfdm', label: 'CFDM' },
|
||||
{ value: 'bgp', label: 'EvoBGP' },
|
||||
{ value: 'fw', label: 'EvoFirewall' },
|
||||
{ value: 'dns', label: 'Technitium DNS' },
|
||||
{ value: 'cdn', label: 'CDN Manager' },
|
||||
{ value: 'mm', label: 'MikrotikManager' },
|
||||
]
|
||||
|
||||
export const severityVariant: Record<AuditSeverity, BadgeProps['variant']> = {
|
||||
info: 'success-outline',
|
||||
warning: 'warning-outline',
|
||||
critical: 'destructive-outline',
|
||||
}
|
||||
|
||||
export const severityLabel: Record<AuditSeverity, string> = {
|
||||
info: 'Info',
|
||||
warning: 'Warning',
|
||||
critical: 'Critical',
|
||||
}
|
||||
|
||||
export const severityDotClass: Record<AuditSeverity, string> = {
|
||||
info: 'bg-success',
|
||||
warning: 'bg-warning',
|
||||
critical: 'bg-destructive',
|
||||
}
|
||||
|
||||
const ACTION_META: Record<
|
||||
string,
|
||||
{ label: string; icon: LucideIcon; filter: AuditFilterId }
|
||||
> = {
|
||||
'auth.login': { label: 'Вход', icon: LogInIcon, filter: 'auth' },
|
||||
'auth.login_failed': {
|
||||
label: 'Неудачный вход',
|
||||
icon: ShieldAlertIcon,
|
||||
filter: 'auth',
|
||||
},
|
||||
'auth.logout': { label: 'Выход', icon: LogOutIcon, filter: 'auth' },
|
||||
'auth.sso_handoff': {
|
||||
label: 'SSO',
|
||||
icon: KeyRoundIcon,
|
||||
filter: 'auth',
|
||||
},
|
||||
'user.create': { label: 'Создание пользователя', icon: UserPlusIcon, filter: 'users' },
|
||||
'user.update': { label: 'Изменение пользователя', icon: UserCogIcon, filter: 'users' },
|
||||
'user.delete': { label: 'Удаление пользователя', icon: UserXIcon, filter: 'users' },
|
||||
'user.access_update': {
|
||||
label: 'Обновление прав',
|
||||
icon: KeyRoundIcon,
|
||||
filter: 'users',
|
||||
},
|
||||
'app_switcher.update': {
|
||||
label: 'App Switcher',
|
||||
icon: SettingsIcon,
|
||||
filter: 'settings',
|
||||
},
|
||||
'audit.settings_update': {
|
||||
label: 'Срок хранения журнала',
|
||||
icon: SettingsIcon,
|
||||
filter: 'settings',
|
||||
},
|
||||
'audit.purge': { label: 'Очистка журнала', icon: Trash2Icon, filter: 'settings' },
|
||||
}
|
||||
|
||||
export function actionMeta(action: string) {
|
||||
return (
|
||||
ACTION_META[action] ?? {
|
||||
label: action,
|
||||
icon: SettingsIcon,
|
||||
filter: 'settings' as AuditFilterId,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function matchesFilter(entry: AuditLogEntry, filter: AuditFilterId) {
|
||||
if (filter === 'all') return true
|
||||
return actionMeta(entry.action).filter === filter
|
||||
}
|
||||
|
||||
export function matchesSourceApp(
|
||||
entry: AuditLogEntry,
|
||||
source: AuditSourceApp | 'all',
|
||||
) {
|
||||
if (source === 'all') return true
|
||||
return entry.source_app === source
|
||||
}
|
||||
|
||||
export function sourceAppLabel(app: AuditSourceApp | string): string {
|
||||
return (
|
||||
SOURCE_APP_OPTIONS.find((o) => o.value === app)?.label ?? String(app)
|
||||
)
|
||||
}
|
||||
|
||||
export function detailString(
|
||||
details: Record<string, unknown> | null | undefined,
|
||||
key: string,
|
||||
): string | null {
|
||||
if (!details) return null
|
||||
const v = details[key]
|
||||
if (typeof v === 'string' && v.trim()) return v
|
||||
return null
|
||||
}
|
||||
|
||||
export function matchesRange(entry: AuditLogEntry, range: AuditRange) {
|
||||
if (range === 'all') return true
|
||||
const ms =
|
||||
range === '24h'
|
||||
? 24 * 60 * 60 * 1000
|
||||
: range === '7d'
|
||||
? 7 * 24 * 60 * 60 * 1000
|
||||
: range === '30d'
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: 90 * 24 * 60 * 60 * 1000
|
||||
return Date.now() - new Date(entry.created_at).getTime() <= ms
|
||||
}
|
||||
|
||||
export function initials(name: string | null, email: string | null) {
|
||||
const source = (name ?? email ?? '?').trim()
|
||||
const parts = source.split(/\s+/).filter(Boolean)
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase()
|
||||
}
|
||||
return source.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
export type AuditDayGroup = {
|
||||
key: string
|
||||
label: string
|
||||
events: AuditLogEntry[]
|
||||
}
|
||||
|
||||
export function groupByDay(entries: AuditLogEntry[]): AuditDayGroup[] {
|
||||
const map = new Map<string, AuditLogEntry[]>()
|
||||
for (const entry of entries) {
|
||||
const d = new Date(entry.created_at)
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
const list = map.get(key) ?? []
|
||||
list.push(entry)
|
||||
map.set(key, list)
|
||||
}
|
||||
return [...map.entries()].map(([key, events]) => ({
|
||||
key,
|
||||
label: new Date(events[0]!.created_at).toLocaleDateString('ru-RU', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}),
|
||||
events,
|
||||
}))
|
||||
}
|
||||
|
||||
export function formatEventTime(iso: string) {
|
||||
return new Date(iso).toLocaleTimeString('ru-RU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function exportAuditCsv(entries: AuditLogEntry[]) {
|
||||
const header = [
|
||||
'created_at',
|
||||
'action',
|
||||
'severity',
|
||||
'actor_email',
|
||||
'actor_name',
|
||||
'summary',
|
||||
'target_type',
|
||||
'target_id',
|
||||
'ip',
|
||||
]
|
||||
const rows = entries.map((e) =>
|
||||
[
|
||||
e.created_at,
|
||||
e.action,
|
||||
e.severity,
|
||||
e.actor_email ?? '',
|
||||
e.actor_name ?? '',
|
||||
e.summary,
|
||||
e.target_type ?? '',
|
||||
e.target_id ?? '',
|
||||
e.ip ?? '',
|
||||
]
|
||||
.map((v) => `"${String(v).replaceAll('"', '""')}"`)
|
||||
.join(','),
|
||||
)
|
||||
const blob = new Blob([[header.join(','), ...rows].join('\n')], {
|
||||
type: 'text/csv;charset=utf-8',
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `audit-log-${new Date().toISOString().slice(0, 10)}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* Audit log timeline — adapted from @reui/solution-users-6.
|
||||
* Preview: https://reui.io/preview/base/solution-users-6
|
||||
* Docs: https://reui.io/blocks · Timeline: https://reui.io/docs/components/base/timeline
|
||||
*/
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { AuditLogEntry, AuditSourceApp } from '@authportal/shared'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
CalendarIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
FilterIcon,
|
||||
} from 'lucide-react'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Frame,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
} from '@/components/reui/frame'
|
||||
import {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
TimelineTitle,
|
||||
} from '@/components/reui/timeline'
|
||||
import { cn } from '@authportal/ui/lib/utils'
|
||||
import { Avatar, AvatarFallback } from '@authportal/ui/components/avatar'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@authportal/ui/components/collapsible'
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@authportal/ui/components/empty'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@authportal/ui/components/select'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
||||
import {
|
||||
AUDIT_FILTER_OPTIONS,
|
||||
RANGE_OPTIONS,
|
||||
SOURCE_APP_OPTIONS,
|
||||
actionMeta,
|
||||
detailString,
|
||||
exportAuditCsv,
|
||||
formatEventTime,
|
||||
groupByDay,
|
||||
initials,
|
||||
matchesFilter,
|
||||
matchesRange,
|
||||
matchesSourceApp,
|
||||
severityDotClass,
|
||||
severityLabel,
|
||||
severityVariant,
|
||||
sourceAppLabel,
|
||||
type AuditFilterId,
|
||||
type AuditRange,
|
||||
} from './audit-log-helpers'
|
||||
|
||||
function copyValue(value: string) {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
||||
void navigator.clipboard.writeText(value).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
function DetailRow({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<dt className="text-muted-foreground text-xs">{label}</dt>
|
||||
<dd className="flex min-w-0 items-center text-sm">{children}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EventRow({
|
||||
event,
|
||||
isLast,
|
||||
step,
|
||||
defaultOpen,
|
||||
}: {
|
||||
event: AuditLogEntry
|
||||
isLast: boolean
|
||||
step: number
|
||||
defaultOpen: boolean
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
const meta = actionMeta(event.action)
|
||||
const Icon = meta.icon
|
||||
const actorName = event.actor_name ?? event.actor_email ?? 'Система'
|
||||
const userAgent = detailString(event.details, 'user_agent')
|
||||
const targetApp =
|
||||
detailString(event.details, 'target_app') ?? event.source_app
|
||||
const reason = detailString(event.details, 'reason')
|
||||
|
||||
return (
|
||||
<TimelineItem step={step} className={cn('ms-10 min-w-0', isLast ? 'pb-0' : 'pb-6')}>
|
||||
<TimelineHeader className="flex min-w-0 items-center justify-between gap-2.5">
|
||||
<TimelineSeparator className="bg-border! group-data-[orientation=vertical]/timeline:-left-7 group-data-[orientation=vertical]/timeline:h-[calc(100%-1.5rem-0.5rem)] group-data-[orientation=vertical]/timeline:translate-y-7" />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<TimelineTitle className="text-sm font-semibold">
|
||||
{meta.label}
|
||||
</TimelineTitle>
|
||||
<Badge variant={severityVariant[event.severity]} className="gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'size-1.5 shrink-0 rounded-full',
|
||||
severityDotClass[event.severity],
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{severityLabel[event.severity]}
|
||||
</Badge>
|
||||
<Badge variant="outline" size="sm">
|
||||
{sourceAppLabel(event.source_app)}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatEventTime(event.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<TimelineIndicator className="border-border bg-background text-muted-foreground flex size-6 items-center justify-center border shadow-xs group-data-[orientation=vertical]/timeline:-left-7 [&_svg]:size-3.5">
|
||||
<Icon aria-hidden="true" />
|
||||
</TimelineIndicator>
|
||||
</TimelineHeader>
|
||||
|
||||
<TimelineContent className="mt-2 min-w-0">
|
||||
<Frame stacked dense spacing="sm" className="min-w-0">
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
className="group/collapsible"
|
||||
>
|
||||
<CollapsibleTrigger
|
||||
type="button"
|
||||
className="flex w-full min-w-0"
|
||||
aria-label={`Подробности: ${meta.label}`}
|
||||
>
|
||||
<FrameHeader className="flex min-w-0 grow flex-row items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
<Avatar className="size-5">
|
||||
<AvatarFallback className="text-[10px]">
|
||||
{initials(event.actor_name, event.actor_email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-foreground min-w-0 truncate text-sm font-medium">
|
||||
{event.summary || actorName}
|
||||
</span>
|
||||
{event.ip ? (
|
||||
<span className="text-muted-foreground font-mono text-xs tabular-nums">
|
||||
{event.ip}
|
||||
</span>
|
||||
) : null}
|
||||
{targetApp && targetApp !== 'portal' ? (
|
||||
<Badge variant="info-outline" size="sm">
|
||||
{sourceAppLabel(targetApp)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<ChevronRightIcon
|
||||
className="text-muted-foreground size-4 shrink-0 transition-transform duration-200 group-data-open/collapsible:rotate-90"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</FrameHeader>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<FramePanel className="flex flex-col gap-3">
|
||||
<dl className="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
|
||||
<DetailRow label="Логин">
|
||||
<span className="text-foreground truncate font-medium">
|
||||
{event.actor_email ?? '—'}
|
||||
</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="IP">
|
||||
<span className="text-foreground truncate font-medium tabular-nums font-mono text-xs">
|
||||
{event.ip ?? '—'}
|
||||
</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="User-Agent">
|
||||
<span
|
||||
className="text-foreground line-clamp-2 text-xs"
|
||||
title={userAgent ?? undefined}
|
||||
>
|
||||
{userAgent ?? '—'}
|
||||
</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Приложение">
|
||||
<span className="text-foreground truncate font-medium">
|
||||
{sourceAppLabel(targetApp)}
|
||||
</span>
|
||||
</DetailRow>
|
||||
{reason ? (
|
||||
<DetailRow label="Причина">
|
||||
<span className="text-foreground truncate font-medium">
|
||||
{reason}
|
||||
</span>
|
||||
</DetailRow>
|
||||
) : null}
|
||||
<DetailRow label="Действие">
|
||||
<span className="text-foreground truncate font-mono text-xs">
|
||||
{event.action}
|
||||
</span>
|
||||
</DetailRow>
|
||||
</dl>
|
||||
|
||||
{event.details ? (
|
||||
<pre className="bg-muted/50 text-muted-foreground max-h-40 overflow-auto rounded-md p-2 text-xs">
|
||||
{JSON.stringify(event.details, null, 2)}
|
||||
</pre>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2.5 border-t pt-2.5">
|
||||
<Badge variant="outline" className="gap-1.5 font-mono">
|
||||
{event.id.slice(0, 8)}
|
||||
</Badge>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{userAgent ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
copyValue(userAgent)
|
||||
toast.success('User-Agent скопирован')
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="opacity-60" aria-hidden="true" />
|
||||
UA
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
copyValue(event.id)
|
||||
toast.success('ID скопирован')
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="opacity-60" aria-hidden="true" />
|
||||
ID
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Frame>
|
||||
</TimelineContent>
|
||||
</TimelineItem>
|
||||
)
|
||||
}
|
||||
|
||||
export function AuditLogTimeline({
|
||||
entries,
|
||||
totalCount,
|
||||
compact = false,
|
||||
lockedUserId,
|
||||
sourceAppFilter,
|
||||
onSourceAppFilterChange,
|
||||
}: {
|
||||
entries: AuditLogEntry[]
|
||||
totalCount: number
|
||||
compact?: boolean
|
||||
lockedUserId?: string
|
||||
sourceAppFilter?: AuditSourceApp | 'all'
|
||||
onSourceAppFilterChange?: (v: AuditSourceApp | 'all') => void
|
||||
}) {
|
||||
const [filter, setFilter] = useState<AuditFilterId>('all')
|
||||
const [range, setRange] = useState<AuditRange>(compact ? 'all' : '7d')
|
||||
const [localSource, setLocalSource] = useState<AuditSourceApp | 'all'>(
|
||||
sourceAppFilter ?? 'all',
|
||||
)
|
||||
const source = sourceAppFilter ?? localSource
|
||||
const setSource = onSourceAppFilterChange ?? setLocalSource
|
||||
|
||||
const visible = useMemo(
|
||||
() =>
|
||||
entries.filter(
|
||||
(e) =>
|
||||
matchesFilter(e, filter) &&
|
||||
matchesRange(e, range) &&
|
||||
matchesSourceApp(e, source),
|
||||
),
|
||||
[entries, filter, range, source],
|
||||
)
|
||||
|
||||
const days = useMemo(() => groupByDay(visible), [visible])
|
||||
|
||||
return (
|
||||
<section className="w-full" aria-labelledby="audit-log-title">
|
||||
<div className={cn('flex flex-col gap-4', compact ? 'mb-4' : 'mb-6')}>
|
||||
{!compact ? (
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<h1
|
||||
id="audit-log-title"
|
||||
className="text-xl font-semibold tracking-tight"
|
||||
>
|
||||
Журнал изменений
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm leading-5">
|
||||
{totalCount} событий · показано {visible.length}
|
||||
{lockedUserId ? ' · пользователь' : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={source}
|
||||
onValueChange={(value) => {
|
||||
if (value) setSource(value as AuditSourceApp | 'all')
|
||||
}}
|
||||
items={[...SOURCE_APP_OPTIONS]}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end" alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{SOURCE_APP_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={range}
|
||||
onValueChange={(value) => {
|
||||
if (value) setRange(value as AuditRange)
|
||||
}}
|
||||
items={[...RANGE_OPTIONS]}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-44">
|
||||
<CalendarIcon
|
||||
className="text-muted-foreground size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end" alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{RANGE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
exportAuditCsv(visible)
|
||||
toast.success('CSV экспортирован', {
|
||||
description: `${visible.length} событий`,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
<span className="hidden sm:inline">Export CSV</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p
|
||||
id="audit-log-title"
|
||||
className="text-muted-foreground text-sm leading-5"
|
||||
>
|
||||
{visible.length} из {totalCount} событий
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
value={filter}
|
||||
onValueChange={(value) => {
|
||||
if (value) setFilter(value as AuditFilterId)
|
||||
}}
|
||||
>
|
||||
<TabsList
|
||||
variant="line"
|
||||
aria-label="Фильтр по типу"
|
||||
className="h-10! w-full justify-start gap-6 overflow-x-auto border-b"
|
||||
>
|
||||
{AUDIT_FILTER_OPTIONS.map((option) => (
|
||||
<TabsTrigger
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className="px-1 text-sm after:-bottom-px!"
|
||||
>
|
||||
{option.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{days.length === 0 ? (
|
||||
<Empty className="min-h-[280px] border-0 bg-transparent">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<FilterIcon aria-hidden="true" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Нет событий</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Нет записей по текущему фильтру. Сбросьте фильтр или расширьте
|
||||
период.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setFilter('all')
|
||||
setRange('all')
|
||||
}}
|
||||
>
|
||||
Сбросить
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="flex flex-col gap-8">
|
||||
{days.map((day, dayIndex) => (
|
||||
<div key={day.key} className="flex flex-col gap-4">
|
||||
<h2 className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
|
||||
{day.label}
|
||||
</h2>
|
||||
<Timeline>
|
||||
{day.events.map((event, index) => (
|
||||
<EventRow
|
||||
key={event.id}
|
||||
event={event}
|
||||
step={index + 1}
|
||||
isLast={index === day.events.length - 1}
|
||||
defaultOpen={dayIndex === 0 && index < 2}
|
||||
/>
|
||||
))}
|
||||
</Timeline>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Retention settings row — DNA settings-2.
|
||||
* Preview: https://reui.io/preview/base/settings-2
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { HistoryIcon } from 'lucide-react'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { Input } from '@authportal/ui/components/input'
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from '@authportal/ui/components/item'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@authportal/ui/components/alert-dialog'
|
||||
|
||||
export function AuditRetentionPanel({
|
||||
retentionDays,
|
||||
saving,
|
||||
purging,
|
||||
onSave,
|
||||
onPurge,
|
||||
}: {
|
||||
retentionDays: number
|
||||
saving: boolean
|
||||
purging: boolean
|
||||
onSave: (days: number) => void
|
||||
onPurge: () => void
|
||||
}) {
|
||||
const [days, setDays] = useState(String(retentionDays))
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setDays(String(retentionDays))
|
||||
}, [retentionDays])
|
||||
|
||||
const parsed = Number.parseInt(days, 10)
|
||||
const valid = Number.isFinite(parsed) && parsed >= 7 && parsed <= 3650
|
||||
|
||||
return (
|
||||
<>
|
||||
<Frame className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Ротация журнала</FrameTitle>
|
||||
<FrameDescription>
|
||||
Записи старше N дней удаляются автоматически каждый час
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex flex-col gap-4">
|
||||
<Item variant="outline" className="items-start">
|
||||
<ItemMedia variant="icon">
|
||||
<HistoryIcon className="size-4" aria-hidden="true" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Срок хранения</ItemTitle>
|
||||
<ItemDescription>
|
||||
От 7 до 3650 дней. Сейчас: {retentionDays} дн.
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={7}
|
||||
max={3650}
|
||||
value={days}
|
||||
onChange={(e) => setDays(e.target.value)}
|
||||
className="w-24 tabular-nums"
|
||||
aria-label="Дней хранения"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!valid || saving || parsed === retentionDays}
|
||||
onClick={() => onSave(parsed)}
|
||||
>
|
||||
{saving ? 'Сохранение…' : 'Сохранить'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={purging}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
>
|
||||
Очистить сейчас
|
||||
</Button>
|
||||
</div>
|
||||
</Item>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Очистить старые записи?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Будут удалены события старше {retentionDays} дней. Это действие
|
||||
необратимо.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setConfirmOpen(false)
|
||||
onPurge()
|
||||
}}
|
||||
>
|
||||
Удалить
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Create OIDC client Sheet — auth-portal admin.
|
||||
* Preview: https://reui.io/preview/base/sheet-8 · https://reui.io/preview/base/solution-users-1
|
||||
* Docs: https://reui.io/blocks · https://ui.shadcn.com/docs/components/base/sheet
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { XIcon } from 'lucide-react'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { Field, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
|
||||
import { Input } from '@authportal/ui/components/input'
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@authportal/ui/components/sheet'
|
||||
import { Switch } from '@authportal/ui/components/switch'
|
||||
import { Textarea } from '@authportal/ui/components/textarea'
|
||||
|
||||
const mutedIconButtonClassName = 'text-muted-foreground hover:text-foreground'
|
||||
|
||||
export type CreateOidcClientValues = {
|
||||
name: string
|
||||
redirect_uris: string[]
|
||||
scopes: Array<'openid' | 'profile' | 'email' | 'groups'>
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export function CreateOidcClientSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
pending,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
pending: boolean
|
||||
onSubmit: (values: CreateOidcClientValues) => void
|
||||
}) {
|
||||
const [name, setName] = useState('Technitium DNS')
|
||||
const [redirectUris, setRedirectUris] = useState(
|
||||
'https://dns.shnt.top/sso/callback',
|
||||
)
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setName('Technitium DNS')
|
||||
setRedirectUris('https://dns.shnt.top/sso/callback')
|
||||
setEnabled(true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
showCloseButton={false}
|
||||
className="inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(24rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none sm:max-w-none"
|
||||
>
|
||||
<SheetHeader className="shrink-0 gap-0 p-0">
|
||||
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
|
||||
<SheetTitle className="min-w-0 truncate text-base font-semibold">
|
||||
Новый OIDC-клиент
|
||||
</SheetTitle>
|
||||
<SheetClose
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Закрыть"
|
||||
className={mutedIconButtonClassName}
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<SheetDescription className="text-muted-foreground border-b px-4 py-2 text-sm">
|
||||
Redirect URI для Technitium:{' '}
|
||||
<code className="text-xs">https://<host>/sso/callback</code>
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<form
|
||||
id="create-oidc-client-form"
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
const uris = redirectUris
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
onSubmit({
|
||||
name: name.trim(),
|
||||
redirect_uris: uris,
|
||||
scopes: ['openid', 'profile', 'email', 'groups'],
|
||||
enabled,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5">
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="oidc-name">Название</FieldLabel>
|
||||
<Input
|
||||
id="oidc-name"
|
||||
name="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
autoComplete="off"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="oidc-redirects">
|
||||
Redirect URIs
|
||||
</FieldLabel>
|
||||
<Textarea
|
||||
id="oidc-redirects"
|
||||
name="redirect_uris"
|
||||
className="min-h-24"
|
||||
value={redirectUris}
|
||||
onChange={(e) => setRedirectUris(e.target.value)}
|
||||
placeholder="https://dns.example.com/sso/callback"
|
||||
required
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
По одному URI на строку
|
||||
</p>
|
||||
</Field>
|
||||
<Field orientation="horizontal" className="items-center justify-between">
|
||||
<FieldLabel htmlFor="oidc-enabled" className="font-normal">
|
||||
Включён
|
||||
</FieldLabel>
|
||||
<Switch
|
||||
id="oidc-enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={(v: boolean) => setEnabled(v === true)}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="bg-background shrink-0 border-t">
|
||||
<div className="flex w-full gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-w-0 flex-1"
|
||||
disabled={pending}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-oidc-client-form"
|
||||
className="min-w-0 flex-1"
|
||||
disabled={pending || !name.trim() || !redirectUris.trim()}
|
||||
>
|
||||
{pending ? 'Создание…' : 'Создать'}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Create user Sheet — auth-portal admin.
|
||||
* Preview: https://reui.io/preview/base/solution-users-1
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { CreateUserRequest } from '@authportal/shared'
|
||||
import { XIcon } from 'lucide-react'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { Checkbox } from '@authportal/ui/components/checkbox'
|
||||
import { Field, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
|
||||
import { Input } from '@authportal/ui/components/input'
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@authportal/ui/components/sheet'
|
||||
|
||||
const mutedIconButtonClassName = 'text-muted-foreground hover:text-foreground'
|
||||
|
||||
export function CreateUserSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
pending,
|
||||
error,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
pending: boolean
|
||||
error: string | null
|
||||
onSubmit: (values: CreateUserRequest) => void
|
||||
}) {
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setIsAdmin(false)
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
showCloseButton={false}
|
||||
className="inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(24rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none sm:max-w-none"
|
||||
>
|
||||
<SheetHeader className="shrink-0 gap-0 p-0">
|
||||
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
|
||||
<SheetTitle className="min-w-0 truncate text-base font-semibold">
|
||||
Новый пользователь
|
||||
</SheetTitle>
|
||||
<SheetClose
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Закрыть"
|
||||
className={mutedIconButtonClassName}
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<SheetDescription className="sr-only">
|
||||
Создание учётной записи портала
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<form
|
||||
id="create-user-form"
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
const fd = new FormData(e.currentTarget)
|
||||
onSubmit({
|
||||
email: String(fd.get('email') ?? ''),
|
||||
name: String(fd.get('name') ?? ''),
|
||||
password: String(fd.get('password') ?? ''),
|
||||
is_admin: isAdmin,
|
||||
apps: [],
|
||||
permissions: [],
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5">
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-name">Имя</FieldLabel>
|
||||
<Input
|
||||
id="create-name"
|
||||
name="name"
|
||||
autoComplete="name"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-email">Email</FieldLabel>
|
||||
<Input
|
||||
id="create-email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-password">Пароль</FieldLabel>
|
||||
<Input
|
||||
id="create-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={6}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id="create-is-admin"
|
||||
checked={isAdmin}
|
||||
onCheckedChange={(v) => setIsAdmin(v === true)}
|
||||
/>
|
||||
<FieldLabel htmlFor="create-is-admin" className="font-normal">
|
||||
Администратор портала
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="destructive" className="mt-5">
|
||||
<AlertTitle>Ошибка</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<SheetFooter className="bg-background shrink-0 border-t">
|
||||
<div className="flex w-full gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-w-0 flex-1"
|
||||
disabled={pending}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-user-form"
|
||||
className="min-w-0 flex-1"
|
||||
disabled={pending}
|
||||
>
|
||||
{pending ? 'Создание…' : 'Создать'}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export {
|
||||
KpiStatGrid,
|
||||
KpiStatCard,
|
||||
KpiStatCardTile,
|
||||
kpiStatItemKey,
|
||||
type KpiStatItem,
|
||||
type KpiStatCardData,
|
||||
type KpiStatCard as KpiStatCardType,
|
||||
type KpiStatVariant,
|
||||
type OpsKpiCard,
|
||||
} from './kpi-stat-grid'
|
||||
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
|
||||
export { AppSwitcherAdminEditor } from './app-switcher-admin-editor'
|
||||
export { UserAccessSheet } from './user-access-sheet'
|
||||
export { UserAuditSheet } from './user-audit-sheet'
|
||||
export { CreateUserSheet } from './create-user-sheet'
|
||||
export { CreateOidcClientSheet } from './create-oidc-client-sheet'
|
||||
export { AdminUsersGrid, type AdminUsersGridProps } from './admin-users-grid'
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Shared grid column classes for hybrid KPI / Quick Actions tiles. */
|
||||
export function kpiCols(count: number): string {
|
||||
if (count <= 1) return 'grid-cols-1'
|
||||
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
|
||||
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
|
||||
if (count === 4) return 'grid-cols-1 @xl:grid-cols-2 @6xl:grid-cols-4'
|
||||
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
|
||||
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
|
||||
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import type { KeyboardEvent, ReactNode } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { cn } from '@authportal/ui/lib/utils'
|
||||
import { kpiCols } from './kpi-cols'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { Skeleton } from '@authportal/ui/components/skeleton'
|
||||
|
||||
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
|
||||
|
||||
/**
|
||||
* KPI tile data — horizontal compact hybrid (icon left + label/Badge + value).
|
||||
* @see https://reui.io/preview/base/stats-12
|
||||
*/
|
||||
export type KpiStatItem = {
|
||||
id?: string
|
||||
label: ReactNode
|
||||
value: ReactNode
|
||||
hint?: ReactNode
|
||||
to?: string
|
||||
search?: Record<string, unknown>
|
||||
onSelect?: () => void
|
||||
onClick?: () => void
|
||||
selected?: boolean
|
||||
active?: boolean
|
||||
icon?: ReactNode
|
||||
iconClassName?: string
|
||||
variant?: KpiStatVariant
|
||||
footer?: ReactNode
|
||||
}
|
||||
|
||||
/** CFDM-compatible card shape (id required). */
|
||||
export type KpiStatCardData = KpiStatItem & { id: string }
|
||||
|
||||
/** @deprecated Use KpiStatCardData */
|
||||
export type OpsKpiCard = KpiStatCardData
|
||||
|
||||
/** @deprecated Use KpiStatCardData — type alias for CFDM kit parity */
|
||||
export type KpiStatCard = KpiStatCardData
|
||||
|
||||
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
||||
|
||||
const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
|
||||
default: 'text-foreground',
|
||||
warning: 'text-warning',
|
||||
destructive: 'text-destructive',
|
||||
}
|
||||
|
||||
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
onActivate()
|
||||
}
|
||||
}
|
||||
|
||||
function resolveActivate(item: KpiStatItem): (() => void) | undefined {
|
||||
return item.onClick ?? item.onSelect
|
||||
}
|
||||
|
||||
function isSelected(item: KpiStatItem): boolean {
|
||||
return Boolean(item.selected ?? item.active)
|
||||
}
|
||||
|
||||
function resolveFooter(item: KpiStatItem): ReactNode {
|
||||
if (item.footer) return item.footer
|
||||
if (typeof item.hint === 'string') {
|
||||
return (
|
||||
<Badge variant="outline" size="sm">
|
||||
{item.hint}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (item.hint) return item.hint
|
||||
return null
|
||||
}
|
||||
|
||||
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
||||
const footer = resolveFooter(item)
|
||||
const valueVariant = item.variant ?? 'default'
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex h-full items-start gap-3">
|
||||
{item.icon ? (
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
aria-hidden="true"
|
||||
className={cn('size-10.5', item.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||
>
|
||||
{item.icon}
|
||||
</IconTile>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
|
||||
{footer ? <div className="shrink-0">{footer}</div> : null}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'text-2xl leading-none font-bold tabular-nums',
|
||||
VALUE_VARIANT_CLASS[valueVariant],
|
||||
)}
|
||||
>
|
||||
{item.value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function panelClassName(item: KpiStatItem, className?: string) {
|
||||
const onActivate = resolveActivate(item)
|
||||
const clickable = Boolean(item.to || onActivate)
|
||||
const selected = isSelected(item)
|
||||
|
||||
return cn(
|
||||
'relative isolate flex h-full flex-col',
|
||||
clickable &&
|
||||
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
|
||||
selected && 'ring-primary/30 bg-muted/30 ring-1',
|
||||
className,
|
||||
)
|
||||
}
|
||||
|
||||
/** Single KPI tile — used for embedded / standalone contexts. */
|
||||
export function KpiStatCardTile({
|
||||
item,
|
||||
embedded = false,
|
||||
className,
|
||||
}: {
|
||||
item: KpiStatItem
|
||||
embedded?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const onActivate = resolveActivate(item)
|
||||
const panelClass = panelClassName(item, className)
|
||||
|
||||
let panel: ReactNode
|
||||
|
||||
if (item.to) {
|
||||
panel = (
|
||||
<FramePanel className={panelClass}>
|
||||
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
|
||||
<KpiStatCardBody item={item} />
|
||||
</Link>
|
||||
</FramePanel>
|
||||
)
|
||||
} else if (onActivate) {
|
||||
panel = (
|
||||
<FramePanel
|
||||
className={panelClass}
|
||||
onClick={onActivate}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
||||
>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
)
|
||||
} else {
|
||||
panel = (
|
||||
<FramePanel className={panelClass}>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
if (embedded) {
|
||||
return <Frame className="h-full ring-1 ring-foreground/10">{panel}</Frame>
|
||||
}
|
||||
|
||||
return <Frame className="h-full">{panel}</Frame>
|
||||
}
|
||||
|
||||
/** @deprecated Prefer KpiStatCardTile — kept for existing imports */
|
||||
export function KpiStatCard({
|
||||
item,
|
||||
embedded = false,
|
||||
className,
|
||||
}: {
|
||||
item: KpiStatItem
|
||||
embedded?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
return <KpiStatCardTile item={item} embedded={embedded} className={className} />
|
||||
}
|
||||
|
||||
function KpiStatGridSkeleton({ count }: { count: number }) {
|
||||
return (
|
||||
<Frame className="@container w-full">
|
||||
<div className={cn('grid gap-2', kpiCols(count))}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<FramePanel key={index} className="flex items-start gap-3">
|
||||
<Skeleton className="size-10.5 shrink-0 rounded-lg" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4.5 w-14 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="h-7 w-16" />
|
||||
</div>
|
||||
</FramePanel>
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
interface KpiStatGridProps {
|
||||
/** EvoBGP primary API */
|
||||
items?: KpiStatItem[]
|
||||
/** CFDM-compatible API */
|
||||
cards?: KpiStatCardData[]
|
||||
isLoading?: boolean
|
||||
emptyMessage?: ReactNode
|
||||
emptyIcon?: ReactNode
|
||||
className?: string
|
||||
skeletonCount?: number
|
||||
/** Wrap each tile in its own Frame (analytics panels). */
|
||||
embedded?: boolean
|
||||
'aria-label'?: string
|
||||
}
|
||||
|
||||
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
|
||||
const onActivate = resolveActivate(item)
|
||||
const panelClass = panelClassName(item)
|
||||
|
||||
if (item.to) {
|
||||
return (
|
||||
<FramePanel className={panelClass}>
|
||||
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
|
||||
<KpiStatCardBody item={item} />
|
||||
</Link>
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
if (onActivate) {
|
||||
return (
|
||||
<FramePanel
|
||||
className={panelClass}
|
||||
onClick={onActivate}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
||||
>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FramePanel className={panelClass}>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hybrid KPI — EvoBGP visual + horizontal compact layout (icon left).
|
||||
* Preview: https://reui.io/preview/base/stats-12
|
||||
*/
|
||||
export function KpiStatGrid({
|
||||
items,
|
||||
cards,
|
||||
isLoading = false,
|
||||
emptyMessage,
|
||||
emptyIcon,
|
||||
className,
|
||||
skeletonCount = 4,
|
||||
embedded = false,
|
||||
'aria-label': ariaLabel,
|
||||
}: KpiStatGridProps) {
|
||||
if (isLoading) {
|
||||
return <KpiStatGridSkeleton count={skeletonCount} />
|
||||
}
|
||||
|
||||
const list = items ?? cards ?? []
|
||||
|
||||
if (list.length === 0 && (emptyMessage || emptyIcon)) {
|
||||
return (
|
||||
<Frame dense spacing="sm" className={cn('w-full', className)}>
|
||||
<FramePanel className="flex items-center gap-3 p-4">
|
||||
{emptyIcon}
|
||||
{emptyMessage ? (
|
||||
<p className="text-muted-foreground text-sm">{emptyMessage}</p>
|
||||
) : null}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<section aria-label={ariaLabel} className={cn('@container w-full', className)}>
|
||||
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
||||
{list.map((item, index) => (
|
||||
<KpiStatCardTile key={kpiStatItemKey(item, index)} item={item} embedded />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
|
||||
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
||||
{list.map((item, index) => (
|
||||
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
export function kpiStatItemKey(item: KpiStatItem, index: number): string {
|
||||
if (item.id) return item.id
|
||||
if (typeof item.label === 'string') return item.label
|
||||
return `kpi-${index}`
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { cn } from '@authportal/ui/lib/utils'
|
||||
import { kpiCols } from './kpi-cols'
|
||||
|
||||
export interface QuickActionItem {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
to?: string
|
||||
search?: Record<string, unknown>
|
||||
onSelect?: () => void
|
||||
badgeLabel?: string
|
||||
/** Вариант бейджа действия (по умолчанию outline). */
|
||||
badgeVariant?: 'outline' | 'info-light' | 'warning-light'
|
||||
/** Моноширинная подпись под описанием: адрес · способ входа. */
|
||||
meta?: string
|
||||
icon?: ReactNode
|
||||
iconClassName?: string
|
||||
}
|
||||
|
||||
interface QuickActionGridProps {
|
||||
actions: QuickActionItem[]
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
/** Плитки в Frame с отступами и сеткой auto-fit от 17rem (как KPI-полоса): без вылета за границы. */
|
||||
autoFit?: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
||||
|
||||
function QuickActionBody({ action }: { action: QuickActionItem }) {
|
||||
return (
|
||||
<div className="relative z-10 flex h-full items-start gap-3">
|
||||
{action.icon ? (
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
aria-hidden="true"
|
||||
className={cn('size-10.5', action.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||
>
|
||||
{action.icon}
|
||||
</IconTile>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-foreground text-sm font-medium">{action.title}</span>
|
||||
<Badge variant={action.badgeVariant ?? 'outline'} size="sm" className="shrink-0">
|
||||
{action.badgeLabel ?? 'Перейти'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
||||
{action.description}
|
||||
</p>
|
||||
{action.meta ? (
|
||||
<p className="text-muted-foreground truncate font-mono text-[11px]">{action.meta}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function QuickActionHit({ action }: { action: QuickActionItem }) {
|
||||
const label = `${action.title}: ${action.description}`
|
||||
const body = <QuickActionBody action={action} />
|
||||
|
||||
if (action.onSelect) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left focus-visible:outline-none"
|
||||
aria-label={label}
|
||||
onClick={action.onSelect}
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (!action.to) return body
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={action.to}
|
||||
search={action.search}
|
||||
className="focus-visible:outline-none"
|
||||
aria-label={label}
|
||||
>
|
||||
{body}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* KPI-like quick actions strip (horizontal Frame tiles).
|
||||
* Preview: https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/card-12
|
||||
*/
|
||||
export function QuickActionGrid({
|
||||
actions,
|
||||
title = 'Быстрые действия',
|
||||
description,
|
||||
className,
|
||||
autoFit = false,
|
||||
}: QuickActionGridProps) {
|
||||
if (actions.length === 0) return null
|
||||
|
||||
return (
|
||||
<Frame dense={!autoFit} spacing="sm" className={cn('@container w-full', className)}>
|
||||
{(title || description) && (
|
||||
<FrameHeader>
|
||||
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
||||
{description ? <FrameDescription>{description}</FrameDescription> : null}
|
||||
</FrameHeader>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-2',
|
||||
autoFit ? 'grid-cols-[repeat(auto-fit,minmax(min(100%,17rem),1fr))]' : kpiCols(actions.length),
|
||||
)}
|
||||
>
|
||||
{actions.map((action) => (
|
||||
<FramePanel
|
||||
key={action.id}
|
||||
className="relative isolate flex h-full flex-col hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2"
|
||||
>
|
||||
<QuickActionHit action={action} />
|
||||
</FramePanel>
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
Loaded 100 of 311 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user