commit ef7bb79d4ea4ba6b9a15d2d3edd1734756f939fa Author: Denozordec Date: Tue Nov 3 03:05:24 2026 +0700 fix(mcp): подготовить доступ и отзыв разрешений для приёмки diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ae3b9a7 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.gemini/settings.json b/.gemini/settings.json new file mode 100644 index 0000000..a8006f9 --- /dev/null +++ b/.gemini/settings.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "reui": { + "httpUrl": "https://mcp.reui.io/api/mcp", + "headers": { + "Authorization": "Bearer ${REUI_LICENSE_KEY}" + } + } + } +} diff --git a/.gitea/README.md b/.gitea/README.md new file mode 100644 index 0000000..7bb8d0a --- /dev/null +++ b/.gitea/README.md @@ -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//authportal:latest +git.shx.one//authportal:v1.2.3 +git.shx.one//authportal:1.2.3 +git.shx.one//authportal: +git.shx.one//authportal: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://:8088/"`. Иначе restore — timeout/ECONNREFUSED и пакеты качаются снова. + +Не делайте `docker system prune -a` по cron: сотрётся и Docker-кэш FROM, и пользы от `cleanup: false` у buildx не будет. diff --git a/.gitea/actions/setup-pnpm/action.yaml b/.gitea/actions/setup-pnpm/action.yaml new file mode 100644 index 0000000..0a91f3b --- /dev/null +++ b/.gitea/actions/setup-pnpm/action.yaml @@ -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 }}- diff --git a/.gitea/workflows/cd.yaml b/.gitea/workflows/cd.yaml new file mode 100644 index 0000000..b6fae19 --- /dev/null +++ b/.gitea/workflows/cd.yaml @@ -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 diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..ba99831 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -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 }} diff --git a/.gitea/workflows/mcp-acceptance-preview.yml b/.gitea/workflows/mcp-acceptance-preview.yml new file mode 100644 index 0000000..b2f0261 --- /dev/null +++ b/.gitea/workflows/mcp-acceptance-preview.yml @@ -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 diff --git a/.gitea/workflows/nightly-perf.yaml b/.gitea/workflows/nightly-perf.yaml new file mode 100644 index 0000000..08c2f16 --- /dev/null +++ b/.gitea/workflows/nightly-perf.yaml @@ -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 diff --git a/.gitea/workflows/quality.yaml b/.gitea/workflows/quality.yaml new file mode 100644 index 0000000..7f1b9c6 --- /dev/null +++ b/.gitea/workflows/quality.yaml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2c3206a --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.releaserc.json b/.releaserc.json new file mode 100644 index 0000000..47cd6ea --- /dev/null +++ b/.releaserc.json @@ -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" + } + ] + } + ] + ] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a87c47d --- /dev/null +++ b/AGENTS.md @@ -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). + + +## 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: `` 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. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e79feeb --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/apps/api/eslint.config.js b/apps/api/eslint.config.js new file mode 100644 index 0000000..7defe6d --- /dev/null +++ b/apps/api/eslint.config.js @@ -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: '^_' }, + ], + }, + }, +]) diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..66771f1 --- /dev/null +++ b/apps/api/package.json @@ -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:" + } +} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts new file mode 100644 index 0000000..4aca885 --- /dev/null +++ b/apps/api/src/app.ts @@ -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 { + 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 { + 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 +} diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts new file mode 100644 index 0000000..b5fc9d9 --- /dev/null +++ b/apps/api/src/config.ts @@ -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 + +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() + 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(/\/$/, '') +} diff --git a/apps/api/src/lib/audit.ts b/apps/api/src/lib/audit.ts new file mode 100644 index 0000000..c680ff7 --- /dev/null +++ b/apps/api/src/lib/audit.ts @@ -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, + } +} diff --git a/apps/api/src/lib/cache.ts b/apps/api/src/lib/cache.ts new file mode 100644 index 0000000..6878896 --- /dev/null +++ b/apps/api/src/lib/cache.ts @@ -0,0 +1,51 @@ +/** Мини-кэш с TTL и вытеснением по LRU (Map сохраняет порядок вставки). */ +export class TtlCache { + private readonly map = new Map() + + 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 + } +} diff --git a/apps/api/src/lib/client-secret.ts b/apps/api/src/lib/client-secret.ts new file mode 100644 index 0000000..fc6f1e1 --- /dev/null +++ b/apps/api/src/lib/client-secret.ts @@ -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 { + 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 } + } +} diff --git a/apps/api/src/lib/complete-login.ts b/apps/api/src/lib/complete-login.ts new file mode 100644 index 0000000..e96f97f --- /dev/null +++ b/apps/api/src/lib/complete-login.ts @@ -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 +} diff --git a/apps/api/src/lib/cookies.ts b/apps/api/src/lib/cookies.ts new file mode 100644 index 0000000..8150a92 --- /dev/null +++ b/apps/api/src/lib/cookies.ts @@ -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 +} diff --git a/apps/api/src/lib/cors.ts b/apps/api/src/lib/cors.ts new file mode 100644 index 0000000..0bd5855 --- /dev/null +++ b/apps/api/src/lib/cors.ts @@ -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() + 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 + } +} diff --git a/apps/api/src/lib/issue-access-token.ts b/apps/api/src/lib/issue-access-token.ts new file mode 100644 index 0000000..3e45194 --- /dev/null +++ b/apps/api/src/lib/issue-access-token.ts @@ -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, + } +} diff --git a/apps/api/src/lib/oidc/keys.ts b/apps/api/src/lib/oidc/keys.ts new file mode 100644 index 0000000..6c6a1f4 --- /dev/null +++ b/apps/api/src/lib/oidc/keys.ts @@ -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() + +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 { + // 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 { + 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() + +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, + expiresInSeconds: number, +): Promise { + 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> { + 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 +} diff --git a/apps/api/src/lib/password.ts b/apps/api/src/lib/password.ts new file mode 100644 index 0000000..bf09d53 --- /dev/null +++ b/apps/api/src/lib/password.ts @@ -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(fn: () => Promise): Promise { + if (active >= limit) { + await new Promise((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 { + return withSlot(() => hash(password)) +} + +export function verifyPassword( + passwordHash: string, + password: string, +): Promise { + return withSlot(() => verify(passwordHash, password)) +} + +let dummyHash: Promise | null = null + +/** + * Для неизвестного/отключённого пользователя выполняем настоящую проверку + * по фиктивному хешу: время ответа не выдаёт существование учётки. + */ +export async function verifyDummyPassword(password: string): Promise { + dummyHash ??= hash('dummy-password-for-timing') + try { + await withSlot(async () => verify(await dummyHash!, password)) + } catch { + /* результат не важен */ + } +} diff --git a/apps/api/src/lib/shutdown.ts b/apps/api/src/lib/shutdown.ts new file mode 100644 index 0000000..2454d65 --- /dev/null +++ b/apps/api/src/lib/shutdown.ts @@ -0,0 +1,67 @@ +import type { FastifyInstance } from 'fastify' + +export interface ShutdownOptions { + timeoutMs: number + exit?: (code: number) => void + signals?: NodeJS.Signals[] + proc?: Pick +} + +/** + * 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 | null = null + + const shutdown = (reason: string, code = 0): Promise => { + 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) + }, + } +} diff --git a/apps/api/src/lib/target-app.ts b/apps/api/src/lib/target-app.ts new file mode 100644 index 0000000..5bcddc1 --- /dev/null +++ b/apps/api/src/lib/target-app.ts @@ -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 | null { + const ua = headers['user-agent'] + if (typeof ua === 'string' && ua.trim()) return ua.trim().slice(0, 512) + return null +} diff --git a/apps/api/src/lib/user-profile.ts b/apps/api/src/lib/user-profile.ts new file mode 100644 index 0000000..f29514c --- /dev/null +++ b/apps/api/src/lib/user-profile.ts @@ -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>() + +function cacheFor(db: AppDb): TtlCache { + 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 +} diff --git a/apps/api/src/lib/webauthn.ts b/apps/api/src/lib/webauthn.ts new file mode 100644 index 0000000..9d1eb18 --- /dev/null +++ b/apps/api/src/lib/webauthn.ts @@ -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 { + // Копия через конструктор даёт Uint8Array, которого требует SimpleWebAuthn v13. + return new Uint8Array(isoUint8Array.fromUTF8String(userId)) +} + +export function encodePublicKey(publicKey: Uint8Array): string { + return isoBase64URL.fromBuffer(publicKey) +} + +export function decodePublicKey(stored: string): Uint8Array { + return new Uint8Array(isoBase64URL.toBuffer(stored)) +} + +export function asTransports( + values: string[], +): AuthenticatorTransportFuture[] | undefined { + if (values.length === 0) return undefined + return values as AuthenticatorTransportFuture[] +} diff --git a/apps/api/src/plugins/auth-guards.ts b/apps/api/src/plugins/auth-guards.ts new file mode 100644 index 0000000..a9b411c --- /dev/null +++ b/apps/api/src/plugins/auth-guards.ts @@ -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 + bgp_tenant_id?: string + is_admin?: boolean + iss: string + } + user: { + sub: string + email: string + name: string + apps: string[] + permissions: string[] + tenants?: Record + 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 { + 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 { + await requireAuth(request, reply) + if (reply.sent) return + if (!request.authUser?.isAdmin) { + return reply.status(403).send({ + error: { code: 'FORBIDDEN', message: 'Только для администраторов' }, + }) + } +} diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts new file mode 100644 index 0000000..f74be7e --- /dev/null +++ b/apps/api/src/routes/admin.ts @@ -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>, + 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>, +): 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>, +): 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 { + 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 } + }, + ) +} diff --git a/apps/api/src/routes/audit.ts b/apps/api/src/routes/audit.ts new file mode 100644 index 0000000..d393c4f --- /dev/null +++ b/apps/api/src/routes/audit.ts @@ -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 { + 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 } + }) +} diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts new file mode 100644 index 0000000..330a342 --- /dev/null +++ b/apps/api/src/routes/auth.ts @@ -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 { + /** 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, + } + }) +} diff --git a/apps/api/src/routes/ingest-audit.ts b/apps/api/src/routes/ingest-audit.ts new file mode 100644 index 0000000..1d6483c --- /dev/null +++ b/apps/api/src/routes/ingest-audit.ts @@ -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 { + 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 } + }, + ) +} diff --git a/apps/api/src/routes/oidc.ts b/apps/api/src/routes/oidc.ts new file mode 100644 index 0000000..397738f --- /dev/null +++ b/apps/api/src/routes/oidc.ts @@ -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 { + 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 = { + 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 { + 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 + 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 = + typeof raw === 'string' + ? Object.fromEntries(new URLSearchParams(raw)) + : raw && typeof raw === 'object' + ? (raw as Record) + : {} + 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 = { + ...info, + aud: client.clientId, + } + if (authCode.nonce) idClaims.nonce = authCode.nonce + + const accessClaims: Record = { + 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 + 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) + }) +} diff --git a/apps/api/src/routes/webauthn.ts b/apps/api/src/routes/webauthn.ts new file mode 100644 index 0000000..a057679 --- /dev/null +++ b/apps/api/src/routes/webauthn.ts @@ -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 { + 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, + }) + }, + ) +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts new file mode 100644 index 0000000..ea7604c --- /dev/null +++ b/apps/api/src/server.ts @@ -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) +} diff --git a/apps/api/src/services/audit-retention.ts b/apps/api/src/services/audit-retention.ts new file mode 100644 index 0000000..db781ba --- /dev/null +++ b/apps/api/src/services/audit-retention.ts @@ -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) + } +} diff --git a/apps/api/src/services/backup.ts b/apps/api/src/services/backup.ts new file mode 100644 index 0000000..a79ae9b --- /dev/null +++ b/apps/api/src/services/backup.ts @@ -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) +} diff --git a/apps/api/src/services/housekeeping.ts b/apps/api/src/services/housekeeping.ts new file mode 100644 index 0000000..2387198 --- /dev/null +++ b/apps/api/src/services/housekeeping.ts @@ -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) + } +} diff --git a/apps/api/src/services/portal-revocations.ts b/apps/api/src/services/portal-revocations.ts new file mode 100644 index 0000000..d4fbdea --- /dev/null +++ b/apps/api/src/services/portal-revocations.ts @@ -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 { + 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 | 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 | 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 }) +} diff --git a/apps/api/test/api-coverage.test.ts b/apps/api/test/api-coverage.test.ts new file mode 100644 index 0000000..fbc19cf --- /dev/null +++ b/apps/api/test/api-coverage.test.ts @@ -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> +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 = {}, + ): Promise { + 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) => + 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) + }) +}) diff --git a/apps/api/test/app-switcher.test.ts b/apps/api/test/app-switcher.test.ts new file mode 100644 index 0000000..f85178d --- /dev/null +++ b/apps/api/test/app-switcher.test.ts @@ -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() + }) +}) diff --git a/apps/api/test/audit.test.ts b/apps/api/test/audit.test.ts new file mode 100644 index 0000000..06d80b0 --- /dev/null +++ b/apps/api/test/audit.test.ts @@ -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 | 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() + }) +}) diff --git a/apps/api/test/backup.test.ts b/apps/api/test/backup.test.ts new file mode 100644 index 0000000..7f6207c --- /dev/null +++ b/apps/api/test/backup.test.ts @@ -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() + }) +}) diff --git a/apps/api/test/config.test.ts b/apps/api/test/config.test.ts new file mode 100644 index 0000000..b679c16 --- /dev/null +++ b/apps/api/test/config.test.ts @@ -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 | 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') + }) +}) diff --git a/apps/api/test/data-layer.test.ts b/apps/api/test/data-layer.test.ts new file mode 100644 index 0000000..11d1a5f --- /dev/null +++ b/apps/api/test/data-layer.test.ts @@ -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> | 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> + 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) + }) +}) diff --git a/apps/api/test/deploy-config.test.ts b/apps/api/test/deploy-config.test.ts new file mode 100644 index 0000000..e76406c --- /dev/null +++ b/apps/api/test/deploy-config.test.ts @@ -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', + ) + }) +}) diff --git a/apps/api/test/error-handler.test.ts b/apps/api/test/error-handler.test.ts new file mode 100644 index 0000000..3a03615 --- /dev/null +++ b/apps/api/test/error-handler.test.ts @@ -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> | 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'), 'spa') + 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') + } + }) +}) diff --git a/apps/api/test/helpers.ts b/apps/api/test/helpers.ts new file mode 100644 index 0000000..4512795 --- /dev/null +++ b/apps/api/test/helpers.ts @@ -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>, +): Promise { + 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 +} diff --git a/apps/api/test/hot-path.test.ts b/apps/api/test/hot-path.test.ts new file mode 100644 index 0000000..feb3067 --- /dev/null +++ b/apps/api/test/hot-path.test.ts @@ -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> | undefined +afterEach(async () => { + vi.restoreAllMocks() + await app?.close() + app = undefined +}) + +describe('TtlCache', () => { + it('истекает по TTL', () => { + let t = 0 + const c = new TtlCache({ 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({ 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, + 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') + }) +}) diff --git a/apps/api/test/http-layer.test.ts b/apps/api/test/http-layer.test.ts new file mode 100644 index 0000000..3ac9de5 --- /dev/null +++ b/apps/api/test/http-layer.test.ts @@ -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> | 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'), 'spa') + 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('') + }) + + 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:') + }) +}) diff --git a/apps/api/test/oidc.test.ts b/apps/api/test/oidc.test.ts new file mode 100644 index 0000000..3db9817 --- /dev/null +++ b/apps/api/test/oidc.test.ts @@ -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 | 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() + }) +}) diff --git a/apps/api/test/shutdown.test.ts b/apps/api/test/shutdown.test.ts new file mode 100644 index 0000000..9bf5672 --- /dev/null +++ b/apps/api/test/shutdown.test.ts @@ -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) { + return { + close, + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + } as unknown as Parameters[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((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(() => {})), + { 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) + }) +}) diff --git a/apps/api/test/target-app.test.ts b/apps/api/test/target-app.test.ts new file mode 100644 index 0000000..1bc6a9b --- /dev/null +++ b/apps/api/test/target-app.test.ts @@ -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', + ) + }) +}) diff --git a/apps/api/test/webauthn.test.ts b/apps/api/test/webauthn.test.ts new file mode 100644 index 0000000..964b7ee --- /dev/null +++ b/apps/api/test/webauthn.test.ts @@ -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() + }) +}) diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..6761c01 --- /dev/null +++ b/apps/api/tsconfig.json @@ -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"] +} diff --git a/apps/api/tsup.config.ts b/apps/api/tsup.config.ts new file mode 100644 index 0000000..5409bd1 --- /dev/null +++ b/apps/api/tsup.config.ts @@ -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, +}) diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts new file mode 100644 index 0000000..d93d217 --- /dev/null +++ b/apps/api/vitest.config.ts @@ -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 }, + }, + }, +}) diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 0000000..59382cf --- /dev/null +++ b/apps/web/components.json @@ -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" +} diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js new file mode 100644 index 0000000..888021f --- /dev/null +++ b/apps/web/eslint.config.js @@ -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', + }, + }, +]) diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..21e664c --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,13 @@ + + + + + + + Auth Portal + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..efc88ec --- /dev/null +++ b/apps/web/package.json @@ -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:" + } +} diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx new file mode 100644 index 0000000..bc9616b --- /dev/null +++ b/apps/web/src/components/app-sidebar.tsx @@ -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 ( + + + + + + + Портал + + + + } + > + + Приложения + + + + } + > + + Безопасность + + + + + + + {me?.is_admin ? ( + + Админ + + + + } + > + + Пользователи + + + + } + > + + Входы + + + + } + > + + Изменения + + + + } + > + + Ссылки приложений + + + + } + > + + OIDC-клиенты + + + + + + ) : null} + + + + + + ) +} diff --git a/apps/web/src/components/app-switcher.tsx b/apps/web/src/components/app-switcher.tsx new file mode 100644 index 0000000..021acd8 --- /dev/null +++ b/apps/web/src/components/app-switcher.tsx @@ -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 ( + + + + + } + > +
+ +
+
+ {PORTAL.name} + + {PORTAL.subtitle} + +
+ +
+ +
+ {menuLabel} +
+ + + Auth Portal + + + {apps.map((app) => { + const Icon = ICON_MAP[app.icon] ?? ServerIcon + return ( + { + void ssoOpenApp(app.url, app.authMode ?? 'jwt').catch(() => { + window.location.href = app.url.replace(/\/$/, '') + }) + }} + > + + {app.name} + + ) + })} +
+
+
+
+ ) +} diff --git a/apps/web/src/components/auth-logo.tsx b/apps/web/src/components/auth-logo.tsx new file mode 100644 index 0000000..2e0a510 --- /dev/null +++ b/apps/web/src/components/auth-logo.tsx @@ -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 ( + + ) +} diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx new file mode 100644 index 0000000..8affc4c --- /dev/null +++ b/apps/web/src/components/layout/app-shell.tsx @@ -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 ( + + + + К содержимому + + + + +
+ {children} +
+
+
+
+ ) +} diff --git a/apps/web/src/components/layout/apps-menu.tsx b/apps/web/src/components/layout/apps-menu.tsx new file mode 100644 index 0000000..97c1c3e --- /dev/null +++ b/apps/web/src/components/layout/apps-menu.tsx @@ -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 ( + + + } + > + + + + + Приложения +
+ + + + + Auth Portal + + {APPS.map((app) => { + const Icon = APP_ICONS[app.id] + return ( + } + className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5" + > + + + + {app.title} + + ) + })} +
+
+
+
+ ) +} diff --git a/apps/web/src/components/layout/portal-shell.tsx b/apps/web/src/components/layout/portal-shell.tsx new file mode 100644 index 0000000..2ee0530 --- /dev/null +++ b/apps/web/src/components/layout/portal-shell.tsx @@ -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 = { + '/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(() => { + 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((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((a) => ({ id: `app-${a.id}`, group: 'Приложения', label: a.title, hint: a.url.replace(/^https?:\/\//, ''), dot: 'mu', to: '/apps' })), + ...(isAdmin + ? clients.map((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: , + 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 ( +
+ + +
+ ) + } + + return ( + + + + + + К содержимому + + + + +
{children}
+
+ +
+
+
+
+ ) +} diff --git a/apps/web/src/components/layout/site-header.tsx b/apps/web/src/components/layout/site-header.tsx new file mode 100644 index 0000000..a467e8e --- /dev/null +++ b/apps/web/src/components/layout/site-header.tsx @@ -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 ( +
+ + + + + {crumbs.map((crumb, i) => { + const last = i === crumbs.length - 1 + return ( + + {i > 0 ? : null} + + {last ? ( + {crumb.label} + ) : ( + }> + {crumb.label} + + )} + + + ) + })} + + + +
+ + +
+
+ ) +} diff --git a/apps/web/src/components/layout/system-monitor-popover.tsx b/apps/web/src/components/layout/system-monitor-popover.tsx new file mode 100644 index 0000000..ea708d1 --- /dev/null +++ b/apps/web/src/components/layout/system-monitor-popover.tsx @@ -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 ( +
+
+
+ ) +} + +function MetricCell({ metric }: { metric: MonitorMetric }) { + const color = toneColor(metric.tone) + return ( +
+
+
+ + + {metric.icon} + + + {metric.label} +
+ + {metric.value} + {metric.unit} + +
+ +
+ ) +} + +/** 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( + () => [ + { + id: 'ready', + label: 'API', + value: apiOk ? 'OK' : '—', + unit: '', + percent: apiOk ? 100 : 15, + icon: , + tone: apiOk ? 'success' : 'destructive', + alert: !apiOk, + }, + { + id: 'apps', + label: 'Приложения', + value: String(appsCount), + unit: 'шт.', + percent: Math.min(100, appsCount * 20), + icon: , + 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: , + 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: , + 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 ( + + + } + > + + + {spiking ? ( + + ) : null} + + Система + + {spiking ? 'Внимание' : 'Норма'} + + + + +
+ Монитор Auth Portal + + {new Date().toLocaleTimeString('ru-RU')} + +
+
+ {metrics.map((metric, i) => ( +
= 2 && 'border-border border-t', + )} + > + +
+ ))} +
+
+ API:{' '} + {apiOk ? 'OK' : '—'} + {isAdmin ? ( + <> + {' · '} + Отключено:{' '} + {disabledUsers} + + ) : ( + <> + {' · '} + Метрики пользователей — для админа + + )} +
+
+
+ ) +} diff --git a/apps/web/src/components/nav-user.tsx b/apps/web/src/components/nav-user.tsx new file mode 100644 index 0000000..ce865ea --- /dev/null +++ b/apps/web/src/components/nav-user.tsx @@ -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: , + }, + { + value: 'dark', + label: 'Тёмная', + icon: , + }, + { + value: 'system', + label: 'Системная', + icon: , + }, +] as const + +function ThemeSegmentedToggle() { + const { theme, setTheme } = useTheme() + const [mounted, setMounted] = useState(false) + + useEffect(() => { + setMounted(true) + }, []) + + const currentTheme = mounted ? (theme ?? 'system') : 'system' + + return ( +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > + {THEMES.map(({ value, label, icon }) => { + const isActive = currentTheme === value + return ( + + ) + })} +
+ ) +} + +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 ( + + + + + } + > + + + {fallback} + + +
+ {name} + + {email || '—'} + +
+ +
+ + + + + + {fallback} + + +
+ {name} + + {email || '—'} + +
+
+
+ + + + + void navigate({ to: '/account' })} + > + + Безопасность + + + + Тема +
+ +
+
+
+ + + + + void handleSignOut()}> + + Выйти + + +
+
+
+
+ ) +} diff --git a/apps/web/src/components/page-shell.tsx b/apps/web/src/components/page-shell.tsx new file mode 100644 index 0000000..af3a3a0 --- /dev/null +++ b/apps/web/src/components/page-shell.tsx @@ -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 ( +
+ {children} +
+ ) +} diff --git a/apps/web/src/components/portal-login-form.tsx b/apps/web/src/components/portal-login-form.tsx new file mode 100644 index 0000000..9fc9257 --- /dev/null +++ b/apps/web/src/components/portal-login-form.tsx @@ -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(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) { + 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 ( +
+
+
+
+ +
+ Auth Portal + auth.shnt.top +
+
+

+ Один вход для всех сервисов shnt.top +

+

+ Войдите один раз — и открывайте CFDM, VPS Tracker, EvoBGP, EvoFirewall, CDN Manager, MikrotikManager и + Technitium DNS без повторного ввода пароля. +

+ + + {PERKS.map((perk) => ( +
+ + + +
+ {perk.title} + {perk.text} +
+
+ ))} +
+ +
+ + + + Вход в портал + Введите email и пароль или используйте passkey + + + {returnHost ? ( +
+ + SSO + + + После входа вы вернётесь в {returnHost} + +
+ ) : null} + + {error ? ( + + Ошибка входа + {error} + + ) : null} + +
+ + + Email + + + + + Пароль + + + + setShowPassword((v) => !v)} + > + {showPassword ? : } + + + + + + + +
+ + {passkeySupported ? ( + <> +
+ + или + +
+ + + Пароль остаётся запасным входом. Passkey добавляется в разделе «Безопасность». + + + ) : null} +
+ +
+
+ ) +} diff --git a/apps/web/src/components/portal/bits.tsx b/apps/web/src/components/portal/bits.tsx new file mode 100644 index 0000000..0aafd38 --- /dev/null +++ b/apps/web/src/components/portal/bits.tsx @@ -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 = { ok: '●', wn: '▲', er: '✕', mu: '○', in: '' } + +/** Плашка статуса: слово + глиф (● ▲ ✕ ○), чтобы цвет не был единственным признаком. */ +export function StatusBadge({ tone, children }: { tone: Tone; children: ReactNode }) { + const glyph = TONE_GLYPH[tone] + return ( + + {glyph ? {glyph} : null} + {children} + + ) +} + +/** Заголовок страницы: название, описание, метка свежести и действия. */ +export function PageHead({ + title, + sub, + actions, +}: { + title: string + sub: string + actions?: ReactNode +}) { + return ( +
+
+

{title}

+

{sub}

+
+ {actions ?
{actions}
: null} +
+ ) +} + +const ALERT_ICON = { + er: , + wn: , + in: , +} 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_ICON[tone]} + {title} + {children} + {action ? {action} : null} + + ) +} + +/** Боковое окно (как 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 ( + <> + + + +
+ {title} + + } + > + +
+ + {description} + +
+
setTouched(true)} + onClick={(e) => { + if ((e.target as HTMLElement).closest('[role=switch]')) setTouched(true) + }} + > + {children} +
+ {footer ? ( + { + // «Закрыть» и «Отмена» при изменённой форме тоже спрашивают подтверждение. + const label = (e.target as HTMLElement).closest('button')?.textContent?.trim() + if (touched && (label === 'Закрыть' || label === 'Отмена')) { + e.stopPropagation() + setGuard(true) + } + }} + >{footer} + ) : null} +
+
+ + + + Закрыть без сохранения? + Изменения в форме будут потеряны. + + + Остаться + + + + + + ) +} + +/** Подтверждение опасного действия вводом слова (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 ( + { + if (!next) setValue('') + onOpenChange(next) + }} + > + + + {title} + {description} + +
+ + setValue(e.target.value)} + aria-invalid={value.length > 0 && !matches} + /> +
+ + Отмена + + +
+
+ ) +} + +export function KvFrame({ + title, + description, + items, +}: { + title: string + description: string + items: Array<{ label: string; value: ReactNode }> +}) { + return ( + + + {title} + {description} + + +
+ {items.map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+
+ + ) +} + +/** Список «значение + подпись + копировать» (параметры issuer). */ +export function CopyList({ + title, + description, + items, +}: { + title: string + description: string + items: Array<{ label: string; value: string }> +}) { + return ( + + + {title} + {description} + + +
    + {items.map((item) => ( +
  • + {item.value} + {item.label} + +
  • + ))} +
+
+ + ) +} + +/** Компактный список «ключ — значение» для боковых окон. */ +export function KvList({ items }: { items: Array<[string, string]> }) { + return ( +
+ {items.map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+ ) +} + +export function CodeBlock({ label, children }: { label: string; children: string }) { + return ( +
+ {label} +
{children}
+
+ ) +} + +/** Сегментированный переключатель на Tabs (вид таблицы, период). */ +export function Segmented({ + label, + options, + value, + onChange, +}: { + label: string + options: string[] + value: number + onChange: (index: number) => void +}) { + return ( + onChange(Number(v))} aria-label={label} className="max-w-full"> + + {options.map((o, i) => ( + + {o} + + ))} + + + ) +} + +/** Плашка строки с накопленной правкой: «Не сохранено» или «Будет удалено». */ +export function PendingChip({ mark }: { mark: 'new' | 'dirty' | 'deleted' }) { + return ( + + {mark === 'deleted' ? 'Будет удалено' : 'Не сохранено'} + + ) +} + +/** Вкладки страницы как на досках: линия-подчёркивание, число — мелким индексом. */ +export function PageTabsList({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +export function PageTab({ value, label, count }: { value: string; label: string; count?: number | string }) { + return ( + + {label} + {count !== undefined ? {count} : null} + + ) +} + +/** Строка настройки как на досках: слева название и описание, справа элемент управления. */ +export function SettingRow({ title, description, children }: { title: string; description: string; children: ReactNode }) { + return ( +
+
+ {title} + {description} +
+
{children}
+
+ ) +} + +/** Frame с заголовком, итоговой плашкой справа и строками настроек (блок «form» досок). */ +export function FormFrame({ + title, + description, + badge, + tone = 'ok', + children, +}: { + title: string + description: string + badge: string + tone?: Tone + children: ReactNode +}) { + return ( + + +
+ {title} + {description} +
+ {badge} +
+ {children} + + ) +} diff --git a/apps/web/src/components/portal/canvas-blocks.tsx b/apps/web/src/components/portal/canvas-blocks.tsx new file mode 100644 index 0000000..9933f1c --- /dev/null +++ b/apps/web/src/components/portal/canvas-blocks.tsx @@ -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 ( + + +
+ {title} + {description} +
+ {action} +
+ +
    + {items.map((c) => { + const body = ( + <> +
    + + + {c.icon} + + {c.title} + + + {c.badge} + +
    +

    {c.desc}

    +

    {c.meta}

    + + ) + const cls = 'bg-muted/25 w-full rounded-lg border p-3.5 text-left' + return ( +
  • + {c.onSelect ? ( + + ) : ( +
    {body}
    + )} +
  • + ) + })} +
+
+ + ) +} + +const DOT: Record = { + 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 ( + + +
+ {title} + {description} +
+ {action} +
+ +
    + {items.map((e) => ( +
  1. + {e.t} + + + + + + + {e.label} + + {e.title} + {e.text} + +
  2. + ))} +
+
+ + ) +} diff --git a/apps/web/src/components/portal/chrome-context.tsx b/apps/web/src/components/portal/chrome-context.tsx new file mode 100644 index 0000000..6d8aeb7 --- /dev/null +++ b/apps/web/src/components/portal/chrome-context.tsx @@ -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 {children} +} + +function readStored(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(() => readStored(`${storageKey}:density`, 'comfortable')) + const [contrast, setContrastState] = useState(() => readStored(`${storageKey}:contrast`, 'standard')) + const [pins, setPins] = useState(() => 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( + () => ({ paletteOpen, setPaletteOpen, pins, togglePin, density, setDensity, contrast, setContrast }), + [paletteOpen, pins, togglePin, density, setDensity, contrast, setContrast], + ) + return {children} +} diff --git a/apps/web/src/components/portal/chrome-store.ts b/apps/web/src/components/portal/chrome-store.ts new file mode 100644 index 0000000..7763f0d --- /dev/null +++ b/apps/web/src/components/portal/chrome-store.ts @@ -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(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(null) + +export function useUiStore(): UiStore { + const ctx = useContext(UiStoreContext) + if (!ctx) throw new Error('PortalUiProvider отсутствует') + return ctx +} diff --git a/apps/web/src/components/portal/chrome.tsx b/apps/web/src/components/portal/chrome.tsx new file mode 100644 index 0000000..d04331f --- /dev/null +++ b/apps/web/src/components/portal/chrome.tsx @@ -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 ( + + + + + } + > +
+ {user.initials} +
+
+ {user.name} + {user.email} +
+ +
+ +
+
+ {user.initials} +
+
+ {user.name} + {user.email} +
+
+ + +
+ Тема + setTheme(i === 1 ? 'light' : 'dark')} + /> + Плотность + setDensity(i === 1 ? 'compact' : 'comfortable')} + /> + Контраст + setContrast(i === 1 ? 'high' : 'standard')} + /> +
+ + +
+
+
+
+ ) +} + +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 ( + + } + > + + {item.label} + {item.problem ? ( + + ) : null} + + + + + + ) +} + +export function PortalSidebar() { + const pathname = useRouterState({ select: (s) => s.location.pathname }) + const { pins, togglePin } = useUiStore() + const { navItems, navGroups, brand } = useChromeConfig() + const [closed, setClosed] = useState>({}) + const pinned = navItems.filter((i) => pins.includes(i.id)) + return ( + + + {brand} + + + {pinned.length > 0 ? ( + + Закреплённые + + + {pinned.map((item) => ( + togglePin(item.id)} /> + ))} + + + + ) : null} + {navGroups.map((group, gi) => ( +
+ {gi > 0 ? : null} + + setClosed((c) => ({ ...c, [group]: !c[group] }))} + /> + } + className="h-7 cursor-pointer justify-between text-[11px] font-semibold tracking-wide uppercase" + > + {group} + + + + + {navItems.filter((i) => i.group === group).map((item) => ( + togglePin(item.id)} + /> + ))} + + + +
+ ))} +
+ + + +
+ ) +} + +/** Единый центр состояния: внимание + сводка + список проблем. Адаптивен по ширине окна, как на досках. */ +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 ( + + + } + > + + Внимание + + + {errors} + + {warnings} + + + {bad.length} + + + + +
+
+ Состояние системы + {monitor.updated} +
+ 0 ? "destructive-light" : warnings > 0 ? "warning-light" : "success-light"} size="sm">{errors > 0 ? "Сбой" : warnings > 0 ? "Внимание" : "Норма"} +
+
+ {[ + { n: errors, l: 'сбой', c: 'text-destructive' }, + { n: warnings, l: 'предупреждения', c: 'text-warning' }, + { n: ok, l: 'в норме', c: 'text-success' }, + ].map((x) => ( + + {x.n} + {x.l} + + ))} +
+
    + {MONITOR_ITEMS.filter((m) => m.tone !== 'ok').map((m) => ( +
  • + + + + {m.title} + {m.text} + + {m.age} + +
  • + ))} +
+
+ {MONITOR_METRICS.map((m) => ( +
+ {m.l} + {m.v} +
+ +
+
+ ))} +
+ + {monitor.footerLabel} + + +
+
+ ) +} + +function FreshnessPopover() { + const pathname = useRouterState({ select: (st) => st.location.pathname }) + const { fresh: freshFor } = useChromeConfig() + const fresh = freshFor(pathname) + return ( + + + } + > + + + + {fresh.badge} +

{fresh.text}

+
+
+ ) +} + +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 ( +
+ + + + + + {crumbFor ? crumbFor(pathname, current?.label) : (current?.label ?? 'Auth Portal')} + + + +
+ + + +
+
+ ) +} + +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>((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>() + 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 ( + (o ? setPaletteOpen(true) : close())}> + + Командная палитра + Разделы, пользователи, приложения и действия +
+ + { + 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() + } + }} + /> + Esc +
+
+ {groups.length === 0 ? ( +

Ничего не найдено

+ ) : ( + groups.map(([group, list]) => ( +
+
{group}
+ {list.map((e) => { + const Icon = e.icon + return ( + + ) + })} +
+ )) + )} +
+
+ ↑↓ выбор + ↵ открыть + Esc закрыть + {palette.example} +
+
+
+ ) +} diff --git a/apps/web/src/components/portal/data-page-layout.ts b/apps/web/src/components/portal/data-page-layout.ts new file mode 100644 index 0000000..b56e5e9 --- /dev/null +++ b/apps/web/src/components/portal/data-page-layout.ts @@ -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['tableLayout']> = { + rowBorder: true, + headerBackground: true, + headerBorder: true, + columnsResizable: false, +} + +export const DATA_GRID_CLASS_NAMES: NonNullable['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 +} diff --git a/apps/web/src/components/portal/data-page.tsx b/apps/web/src/components/portal/data-page.tsx new file mode 100644 index 0000000..6966b49 --- /dev/null +++ b/apps/web/src/components/portal/data-page.tsx @@ -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 ( + + {children} + + ) +} + +export function SegmentedControl({ + value, + onChange, + options, + label, +}: { + value: T + onChange: (value: T) => void + options: { value: T; label: string; count?: number }[] + label?: string +}) { + return ( +
+ {options.map((o) => ( + + ))} +
+ ) +} + +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 ( +
+ {leading} + {segmented ? : null} + {filterControl} + {onSearchChange ? ( + + + + + onSearchChange(e.target.value)} /> + + ) : null} + {countLabel ? {countLabel} : null} + {actions} +
+ ) +} + +export function DataGridSortHeader({ + column, + title, + className, +}: { + column: Column + title: string + className?: string +}) { + const sorted = column.getIsSorted() + if (!column.getCanSort()) { + return {title} + } + return ( + + ) +} diff --git a/apps/web/src/components/portal/explain-banner.tsx b/apps/web/src/components/portal/explain-banner.tsx new file mode 100644 index 0000000..8f62162 --- /dev/null +++ b/apps/web/src/components/portal/explain-banner.tsx @@ -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 ( + <> +
+
+ {title} {children} +
+ +
+ + + + + } + > + + {title} {text} + +
+ Вероятные причины +
+ Подбор пароля + Устаревший passkey + Смена IP +
+ Отсортированы по вероятности по журналу входов за 24 часа. +
+ + {'GET /api/v1/admin/audit?kind=logins&action=auth.login_failed&range=24h\nGET /api/v1/admin/sessions?user=ops@shnt.top'} + + + Блокировка и сброс passkeys выполняются вручную с подтверждением и попадают в журнал «Изменения». + +
+ + ) +} diff --git a/apps/web/src/components/portal/format.ts b/apps/web/src/components/portal/format.ts new file mode 100644 index 0000000..d825508 --- /dev/null +++ b/apps/web/src/components/portal/format.ts @@ -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)} дн назад` +} diff --git a/apps/web/src/components/portal/kpi-strip.tsx b/apps/web/src/components/portal/kpi-strip.tsx new file mode 100644 index 0000000..c75b4db --- /dev/null +++ b/apps/web/src/components/portal/kpi-strip.tsx @@ -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 = { + 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 = { + 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 = { + neutral: 'text-muted-foreground', + ok: 'text-success', + in: 'text-info', + wn: 'text-warning', + er: 'text-destructive', +} + +const GLYPH: Record = { 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 ( + + +
+ {items.map((k) => { + const Icon = k.icon + return ( +
+ + + +
+
{k.label}
+
+ {k.value} + + {GLYPH[k.tone] ? {GLYPH[k.tone]} : null} + {k.hint} + +
+
+
+ ) + })} +
+
+ + ) +} diff --git a/apps/web/src/components/portal/kpi.tsx b/apps/web/src/components/portal/kpi.tsx new file mode 100644 index 0000000..d9b4abb --- /dev/null +++ b/apps/web/src/components/portal/kpi.tsx @@ -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 } +} diff --git a/apps/web/src/components/portal/section-grid.tsx b/apps/web/src/components/portal/section-grid.tsx new file mode 100644 index 0000000..f03057c --- /dev/null +++ b/apps/web/src/components/portal/section-grid.tsx @@ -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 = { + id: string + title: string + /** Ширина колонки; сумма задаёт минимальную ширину таблицы. */ + size: number + cell: (row: T) => ReactNode + /** Ключ сортировки; без него колонка не сортируется. */ + sortValue?: (row: T) => string | number +} + +export type GridView = { 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({ + 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[] + getRowId: (row: T) => string + views?: GridView[] + 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([]) + const [rowSelection, setRowSelection] = useState({}) + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize }) + const [hidden, setHidden] = useState>({}) + 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[]>(() => { + const list: ColumnDef[] = [] + if (onReorder) { + list.push({ + id: 'drag', + header: () => Порядок, + cell: () => , + enableSorting: false, + enableResizing: false, + size: DRAG_COLUMN_SIZE, + }) + } + if (selectable) { + list.push({ + id: 'select', + header: () => , + cell: ({ row }) => ( +
e.stopPropagation()}> + +
+ ), + 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 }) => , + 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: () => Действия, + cell: ({ row }) => { + const r = row.original + const on = rowSwitch?.value(r) + const menu: RowMenuConfig = rowMenu?.(r) ?? {} + const soon = (what: string) => () => toast(`${what} (макет)`) + return ( +
e.stopPropagation()}> + {on !== undefined && rowSwitch ? ( + rowSwitch.onChange(r, v)} aria-label="Включено" /> + ) : null} + + }> + + + + onRowClick?.(r))}>Открыть + + {menu.toggleLabel ?? (on === undefined ? 'Повторить проверку' : on ? 'Выключить' : 'Включить')} + + Дублировать + + Удалить… + + + +
+ ) + }, + 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 ( + + +

{title}

+ { + 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 ? ( + setCards(v === 'cards')} aria-label="Вид списка"> + + + Таблица + + + Карточки + + + + ) : 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 ? ( + + } + > + + + + {columns.slice(1).map((c) => ( + setHidden((h) => ({ ...h, [c.id]: !v }))}> + {c.title} + + ))} + + + ) : null} + + } + /> + {selectable && selectedIds.length > 0 ? ( +
+ Выбрано: {selectedIds.length} + + + + + +
+ ) : null} + {showCards && card ? ( +
    + {data.map((r) => { + const c = card(r) + const id = getRowId(r) + const marked = rowStatus?.(r) + return ( +
  • +
    + + setRowSelection((s) => { + const next = { ...s } + if (e.target.checked) next[id] = true + else delete next[id] + return next + }) + } + className="accent-primary size-4" + /> + {c.status} +
    + +
    + {c.cells.map((x) => ( +
    +
    {x.l}
    +
    {x.v}
    +
    + ))} +
    +
  • + ) + })} +
+ ) : ( + +
+ {onReorder ? : } +
+
+ )} + {paged && !showCards ? : null} +
+
+ ) +} + +/** Двухстрочная ячейка: заголовок + подпись. */ +export function TwoLineCell({ title, sub, dot }: { title: ReactNode; sub?: ReactNode; dot?: Tone }) { + return ( +
+ {dot ? : null} +
+ {title} + {sub ? {sub} : null} +
+
+ ) +} diff --git a/apps/web/src/components/portal/tone.ts b/apps/web/src/components/portal/tone.ts new file mode 100644 index 0000000..b45a9d4 --- /dev/null +++ b/apps/web/src/components/portal/tone.ts @@ -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 = { + ok: 'success-light', + wn: 'warning-light', + er: 'destructive-light', + in: 'info-light', + mu: 'outline', +} + +export const TONE_DOT: Record = { + ok: 'bg-success', + wn: 'bg-warning', + er: 'bg-destructive', + in: 'bg-success', + mu: 'bg-muted-foreground/50', +} diff --git a/apps/web/src/components/reui-kit/admin-users-grid.tsx b/apps/web/src/components/reui-kit/admin-users-grid.tsx new file mode 100644 index 0000000..babeab0 --- /dev/null +++ b/apps/web/src/components/reui-kit/admin-users-grid.tsx @@ -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 ? ( + Админ + ) : ( + Пользователь + ) +} + +function StatusBadge({ disabled }: { disabled: boolean }) { + return disabled ? ( + + + Отключён + + ) : ( + + + Активен + + ) +} + +const AppTags = memo(function AppTags({ apps }: { apps: AppId[] }) { + if (apps.length === 0) { + return — + } + return ( +
+ {apps.map((appId) => ( + + {appId.toUpperCase()} + + ))} +
+ ) +}) + +const MemberCell = memo(function MemberCell({ row }: { row: Row }) { + const user = row.original + return ( +
+ + + {userInitials(user.name, user.email)} + + +
+
+ {user.name} +
+
+ {user.email} +
+
+
+ ) +}) + +function AdminUsersBulkActionBar({ + selectedCount, + roleValue, + onRoleChange, + onChangeRole, + onDeactivate, + onClear, +}: { + selectedCount: number + roleValue: BulkRole + onRoleChange: (value: BulkRole) => void + onChangeRole: () => void + onDeactivate: () => void + onClear: () => void +}) { + return ( +
+
+ + Выбрано: {selectedCount} + + + Массовое изменение роли или отключение доступа. + +
+
+ + + + +
+
+ ) +} + +function ActionsCell({ + row, + onOpenAudit, + onOpenAccess, + onDeactivate, + onDelete, + onResetPasskeys, +}: { + row: Row + 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 ( +
e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}> + + + } + > + + + + onOpenAudit(user)}> + + onOpenAccess(user)}> + + {!user.disabled ? ( + onDeactivate(user)}> + + ) : null} + {(user.passkey_count ?? 0) > 0 ? ( + setResetOpen(true)}> + + ) : null} + + setDeleteOpen(true)} + > + + + + + + + + + Сбросить passkeys? + + Все ключи пользователя{' '} + {user.email}{' '} + будут удалены. Вход останется по паролю. + + + + Отмена + { + setResetOpen(false) + onResetPasskeys(user) + }} + > + Сбросить + + + + + + + + + Удалить пользователя? + + Учётная запись{' '} + {user.email}{' '} + будет удалена без возможности восстановления. + + + + Отмена + { + setDeleteOpen(false) + onDelete(user) + }} + > + Удалить + + + + +
+ ) +} + +function createAdminUserColumns(handlers: { + onOpenAudit: (user: AdminUser) => void + onOpenAccess: (user: AdminUser) => void + onDeactivate: (user: AdminUser) => void + onDelete: (user: AdminUser) => void + onResetPasskeys: (user: AdminUser) => void +}): ColumnDef[] { + return [ + { + id: 'select', + header: () => ( +
e.stopPropagation()}> + +
+ ), + cell: ({ row }) => ( +
e.stopPropagation()}> + +
+ ), + enableSorting: false, + size: 40, + enableResizing: false, + enableHiding: false, + meta: { + skeleton: , + 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 }) => ( + + ), + cell: ({ row }) => , + enableSorting: true, + enableHiding: false, + enableResizing: true, + minSize: 220, + meta: { + headerTitle: 'Участник', + autoSize: true, + skeleton: ( +
+ +
+ + +
+
+ ), + }, + }, + { + id: 'role', + accessorFn: (row) => (row.is_admin ? 'admin' : 'user'), + header: ({ column }) => ( + + ), + cell: ({ row }) => , + size: 130, + enableSorting: true, + enableHiding: true, + enableResizing: true, + meta: { + headerTitle: 'Роль', + skeleton: , + }, + }, + { + id: 'apps', + accessorFn: (row) => row.apps.length, + header: ({ column }) => ( + + ), + cell: ({ row }) => , + size: 160, + enableSorting: false, + enableHiding: true, + enableResizing: true, + meta: { + headerTitle: 'Apps', + skeleton: ( +
+ + +
+ ), + }, + }, + { + id: 'status', + accessorFn: (row) => (row.disabled ? 'disabled' : 'active'), + header: ({ column }) => ( + + ), + cell: ({ row }) => , + size: 130, + enableSorting: true, + enableHiding: true, + enableResizing: true, + meta: { + headerTitle: 'Статус', + skeleton: , + }, + }, + { + id: 'auth', + header: ({ column }) => ( + + ), + cell: () => ( + + + ), + size: 120, + enableSorting: false, + enableHiding: true, + enableResizing: true, + meta: { + headerTitle: 'Auth', + skeleton: , + }, + }, + { + id: 'twoFactor', + accessorFn: (row) => (row.passkey_count ?? 0) > 0, + header: ({ column }) => ( + + ), + cell: ({ row }) => + (row.original.passkey_count ?? 0) > 0 ? ( + + + ) : ( + + + ), + size: 100, + enableSorting: false, + enableHiding: true, + enableResizing: true, + meta: { + headerTitle: '2FA', + skeleton: , + }, + }, + { + accessorKey: 'last_login_at', + id: 'lastActive', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {formatLastActive(row.original.last_login_at)} + + ), + size: 150, + enableSorting: true, + enableHiding: true, + enableResizing: true, + meta: { + headerTitle: 'Last Active', + skeleton: , + }, + }, + { + accessorKey: 'last_login_ip', + id: 'lastIp', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.last_login_ip ?? '—'} + + ), + size: 140, + enableSorting: true, + enableHiding: true, + enableResizing: true, + meta: { + headerTitle: 'Последний IP', + skeleton: , + }, + }, + { + id: 'actions', + header: '', + cell: ({ row }) => ( + + ), + size: 60, + enableSorting: false, + enableHiding: false, + enableResizing: false, + meta: { + skeleton: , + 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({ + pageIndex: 0, + pageSize: 10, + }) + const [sorting, setSorting] = useState([ + { id: 'name', desc: false }, + ]) + const [columnVisibility, setColumnVisibility] = useState({}) + const [rowSelection, setRowSelection] = useState({}) + const [query, setQuery] = useState(() => createFilterQuery()) + const [bulkRole, setBulkRole] = useState('user') + const lastAppliedKey = useRef(serializeConditionsKey([])) + + const filterFields: FilterField[] = useMemo( + () => [ + { + id: 'name', + label: 'Участник', + icon: , + type: 'text', + placeholder: 'Поиск…', + }, + { + id: 'email', + label: 'Email', + icon: , + type: 'text', + placeholder: 'Поиск…', + }, + { + id: 'role', + label: 'Роль', + icon: , + type: 'select', + searchable: false, + options: BULK_ROLE_OPTIONS.map((o) => ({ value: o.value, label: o.label })), + }, + { + id: 'status', + label: 'Статус', + icon: , + 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( + () => 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 ( + + + + + + Фильтры + + } + /> + } + countLabel={`${filteredData.length} пользователей`} + actions={ + <> + {showClearButton ? ( + + ) : null} + + + + + ) +} diff --git a/apps/web/src/components/reui-kit/app-switcher-admin-editor.tsx b/apps/web/src/components/reui-kit/app-switcher-admin-editor.tsx new file mode 100644 index 0000000..461b789 --- /dev/null +++ b/apps/web/src/components/reui-kit/app-switcher-admin-editor.tsx @@ -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 ( +
+ void form.handleSubmit((values) => onSave(values))(e) + } + > + + + Заголовок меню + + + + {APP_IDS.map((appId, index) => { + const apps = form.watch('apps') + const appIndex = apps.findIndex((a) => a.id === appId) + if (appIndex < 0) return null + return ( +
+ {index > 0 ? : null} +

+ {appId.toUpperCase()} +

+
+ + Название + + + + URL + + + {appId === 'bgp' ? ( + + + EvoBGP tenant ID + + +

+ Попадает в JWT (`bgp_tenant_id` / `tenants.bgp`). Публичный + switcher его не отдаёт. +

+
+ ) : null} + + + Описание + + + + + Иконка + + + + + form.setValue(`apps.${appIndex}.enabled`, v, { + shouldDirty: true, + }) + } + /> + Включено в switcher + +
+ +
+ ) + })} +
+ + +
+ ) +} diff --git a/apps/web/src/components/reui-kit/audit-log-helpers.ts b/apps/web/src/components/reui-kit/audit-log-helpers.ts new file mode 100644 index 0000000..c0f4f05 --- /dev/null +++ b/apps/web/src/components/reui-kit/audit-log-helpers.ts @@ -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 = { + info: 'success-outline', + warning: 'warning-outline', + critical: 'destructive-outline', +} + +export const severityLabel: Record = { + info: 'Info', + warning: 'Warning', + critical: 'Critical', +} + +export const severityDotClass: Record = { + 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 | 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() + 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) +} diff --git a/apps/web/src/components/reui-kit/audit-log-timeline.tsx b/apps/web/src/components/reui-kit/audit-log-timeline.tsx new file mode 100644 index 0000000..8f2ca90 --- /dev/null +++ b/apps/web/src/components/reui-kit/audit-log-timeline.tsx @@ -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 ( +
+
{label}
+
{children}
+
+ ) +} + +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 ( + + + +
+ + {meta.label} + + + + + {sourceAppLabel(event.source_app)} + + + {formatEventTime(event.created_at)} + +
+ + +
+ + + + + + +
+ + + {initials(event.actor_name, event.actor_email)} + + + + {event.summary || actorName} + + {event.ip ? ( + + {event.ip} + + ) : null} + {targetApp && targetApp !== 'portal' ? ( + + {sourceAppLabel(targetApp)} + + ) : null} +
+
+
+ + + +
+ + + {event.actor_email ?? '—'} + + + + + {event.ip ?? '—'} + + + + + {userAgent ?? '—'} + + + + + {sourceAppLabel(targetApp)} + + + {reason ? ( + + + {reason} + + + ) : null} + + + {event.action} + + +
+ + {event.details ? ( +
+                    {JSON.stringify(event.details, null, 2)}
+                  
+ ) : null} + +
+ + {event.id.slice(0, 8)} + +
+ {userAgent ? ( + + ) : null} + +
+
+
+
+
+ +
+
+ ) +} + +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('all') + const [range, setRange] = useState(compact ? 'all' : '7d') + const [localSource, setLocalSource] = useState( + 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 ( +
+
+ {!compact ? ( +
+
+

+ Журнал изменений +

+

+ {totalCount} событий · показано {visible.length} + {lockedUserId ? ' · пользователь' : ''} +

+
+ +
+ + + + + +
+
+ ) : ( +

+ {visible.length} из {totalCount} событий +

+ )} + + { + if (value) setFilter(value as AuditFilterId) + }} + > + + {AUDIT_FILTER_OPTIONS.map((option) => ( + + {option.label} + + ))} + + +
+ + {days.length === 0 ? ( + + + + + Нет событий + + Нет записей по текущему фильтру. Сбросьте фильтр или расширьте + период. + + + + + + + ) : ( +
+ {days.map((day, dayIndex) => ( +
+

+ {day.label} +

+ + {day.events.map((event, index) => ( + + ))} + +
+ ))} +
+ )} +
+ ) +} diff --git a/apps/web/src/components/reui-kit/audit-retention-panel.tsx b/apps/web/src/components/reui-kit/audit-retention-panel.tsx new file mode 100644 index 0000000..5bc4c85 --- /dev/null +++ b/apps/web/src/components/reui-kit/audit-retention-panel.tsx @@ -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 ( + <> + + + Ротация журнала + + Записи старше N дней удаляются автоматически каждый час + + + + + + + + Срок хранения + + От 7 до 3650 дней. Сейчас: {retentionDays} дн. + + +
+ setDays(e.target.value)} + className="w-24 tabular-nums" + aria-label="Дней хранения" + /> + + +
+
+
+ + + + + + Очистить старые записи? + + Будут удалены события старше {retentionDays} дней. Это действие + необратимо. + + + + Отмена + { + setConfirmOpen(false) + onPurge() + }} + > + Удалить + + + + + + ) +} diff --git a/apps/web/src/components/reui-kit/create-oidc-client-sheet.tsx b/apps/web/src/components/reui-kit/create-oidc-client-sheet.tsx new file mode 100644 index 0000000..23b2c44 --- /dev/null +++ b/apps/web/src/components/reui-kit/create-oidc-client-sheet.tsx @@ -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 ( + + + +
+ + Новый OIDC-клиент + + +
+ + Redirect URI для Technitium:{' '} + https://<host>/sso/callback + +
+ +
{ + 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, + }) + }} + > +
+ + + Название + setName(e.target.value)} + required + autoComplete="off" + /> + + + + Redirect URIs + +