Merge pull request #794 from Wikid82/feature/beta-release
Restructure User Management
This commit is contained in:
@@ -126,7 +126,7 @@ graph TB
|
||||
| **HTTP Framework** | Gin | Latest | Routing, middleware, HTTP handling |
|
||||
| **Database** | SQLite | 3.x | Embedded database |
|
||||
| **ORM** | GORM | Latest | Database abstraction layer |
|
||||
| **Reverse Proxy** | Caddy Server | 2.11.0-beta.2 | Embedded HTTP/HTTPS proxy |
|
||||
| **Reverse Proxy** | Caddy Server | 2.11.1 | Embedded HTTP/HTTPS proxy |
|
||||
| **WebSocket** | gorilla/websocket | Latest | Real-time log streaming |
|
||||
| **Crypto** | golang.org/x/crypto | Latest | Password hashing, encryption |
|
||||
| **Metrics** | Prometheus Client | Latest | Application metrics |
|
||||
|
||||
13
.github/renovate.json
vendored
13
.github/renovate.json
vendored
@@ -36,6 +36,19 @@
|
||||
"platformAutomerge": true,
|
||||
|
||||
"customManagers": [
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Track caddy-security plugin version in Dockerfile",
|
||||
"managerFilePatterns": [
|
||||
"/^Dockerfile$/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"ARG CADDY_SECURITY_VERSION=(?<currentValue>[^\\s]+)"
|
||||
],
|
||||
"depNameTemplate": "github.com/greenpau/caddy-security",
|
||||
"datasourceTemplate": "go",
|
||||
"versioningTemplate": "semver"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Track Go dependencies patched in Dockerfile for Caddy CVE fixes",
|
||||
|
||||
2
.github/workflows/codecov-upload.yml
vendored
2
.github/workflows/codecov-upload.yml
vendored
@@ -154,7 +154,7 @@ jobs:
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
|
||||
7
.github/workflows/codeql.yml
vendored
7
.github/workflows/codeql.yml
vendored
@@ -39,7 +39,12 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
# Use github.ref (full ref path) instead of github.ref_name:
|
||||
# - push/schedule: resolves to refs/heads/<branch>, checking out latest HEAD
|
||||
# - pull_request: resolves to refs/pull/<n>/merge, the correct PR merge ref
|
||||
# github.ref_name fails for PRs because it yields "<n>/merge" which checkout
|
||||
# interprets as a branch name (refs/heads/<n>/merge) that does not exist.
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Verify CodeQL parity guard
|
||||
if: matrix.language == 'go'
|
||||
|
||||
8
.github/workflows/docker-build.yml
vendored
8
.github/workflows/docker-build.yml
vendored
@@ -115,7 +115,7 @@ jobs:
|
||||
|
||||
- name: Set up QEMU
|
||||
if: steps.skip.outputs.skip_build != 'true'
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.skip.outputs.skip_build != 'true'
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
@@ -129,7 +129,7 @@ jobs:
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: steps.skip.outputs.skip_build != 'true'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -137,7 +137,7 @@ jobs:
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: steps.skip.outputs.skip_build != 'true' && env.HAS_DOCKERHUB_TOKEN == 'true'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -657,7 +657,7 @@ jobs:
|
||||
echo "image_ref=${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:${PR_TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
|
||||
2
.github/workflows/docs-to-issues.yml
vendored
2
.github/workflows/docs-to-issues.yml
vendored
@@ -44,7 +44,7 @@ jobs:
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
|
||||
2
.github/workflows/docs.yml
vendored
2
.github/workflows/docs.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
||||
|
||||
# Step 2: Set up Node.js (for building any JS-based doc tools)
|
||||
- name: 🔧 Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
|
||||
26
.github/workflows/e2e-tests-split.yml
vendored
26
.github/workflows/e2e-tests-split.yml
vendored
@@ -150,7 +150,7 @@ jobs:
|
||||
|
||||
- name: Set up Node.js
|
||||
if: steps.resolve-image.outputs.image_source == 'build'
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
@@ -224,7 +224,7 @@ jobs:
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
@@ -232,7 +232,7 @@ jobs:
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: needs.build.outputs.image_source == 'registry'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ${{ env.DOCKERHUB_REGISTRY }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -426,7 +426,7 @@ jobs:
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
@@ -434,7 +434,7 @@ jobs:
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: needs.build.outputs.image_source == 'registry'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ${{ env.DOCKERHUB_REGISTRY }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -636,7 +636,7 @@ jobs:
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
@@ -644,7 +644,7 @@ jobs:
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: needs.build.outputs.image_source == 'registry'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ${{ env.DOCKERHUB_REGISTRY }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -858,7 +858,7 @@ jobs:
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
@@ -898,7 +898,7 @@ jobs:
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: needs.build.outputs.image_source == 'registry'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ${{ env.DOCKERHUB_REGISTRY }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -1095,7 +1095,7 @@ jobs:
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
@@ -1135,7 +1135,7 @@ jobs:
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: needs.build.outputs.image_source == 'registry'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ${{ env.DOCKERHUB_REGISTRY }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -1340,7 +1340,7 @@ jobs:
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
@@ -1380,7 +1380,7 @@ jobs:
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: needs.build.outputs.image_source == 'registry'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ${{ env.DOCKERHUB_REGISTRY }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
|
||||
8
.github/workflows/nightly-build.yml
vendored
8
.github/workflows/nightly-build.yml
vendored
@@ -162,13 +162,13 @@ jobs:
|
||||
run: echo "IMAGE_NAME_LC=${IMAGE_NAME,,}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -176,7 +176,7 @@ jobs:
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: env.HAS_DOCKERHUB_TOKEN == 'true'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -330,7 +330,7 @@ jobs:
|
||||
run: echo "IMAGE_NAME_LC=${IMAGE_NAME,,}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
|
||||
2
.github/workflows/propagate-changes.yml
vendored
2
.github/workflows/propagate-changes.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
(github.event.workflow_run.head_branch == 'main' || github.event.workflow_run.head_branch == 'development')
|
||||
steps:
|
||||
- name: Set up Node (for github-script)
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
|
||||
2
.github/workflows/quality-checks.yml
vendored
2
.github/workflows/quality-checks.yml
vendored
@@ -249,7 +249,7 @@ jobs:
|
||||
bash "scripts/repo_health_check.sh"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
|
||||
2
.github/workflows/release-goreleaser.yml
vendored
2
.github/workflows/release-goreleaser.yml
vendored
@@ -51,7 +51,7 @@ jobs:
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
|
||||
6
.github/workflows/security-pr.yml
vendored
6
.github/workflows/security-pr.yml
vendored
@@ -362,7 +362,7 @@ jobs:
|
||||
- name: Run Trivy filesystem scan (SARIF output)
|
||||
if: steps.check-artifact.outputs.artifact_exists == 'true' || github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
# aquasecurity/trivy-action v0.33.1
|
||||
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478
|
||||
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1
|
||||
with:
|
||||
scan-type: 'fs'
|
||||
scan-ref: ${{ steps.extract.outputs.binary_path }}
|
||||
@@ -385,7 +385,7 @@ jobs:
|
||||
- name: Upload Trivy SARIF to GitHub Security
|
||||
if: always() && steps.trivy-sarif-check.outputs.exists == 'true'
|
||||
# github/codeql-action v4
|
||||
uses: github/codeql-action/upload-sarif@b895512248b1b5b0089ac3c33ecf123c2cd6f373
|
||||
uses: github/codeql-action/upload-sarif@a5b959e10d29aec4f277040b4d27d0f6bea2322a
|
||||
with:
|
||||
sarif_file: 'trivy-binary-results.sarif'
|
||||
category: ${{ steps.pr-info.outputs.is_push == 'true' && format('security-scan-{0}', github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref_name) || format('security-scan-pr-{0}', steps.pr-info.outputs.pr_number) }}
|
||||
@@ -394,7 +394,7 @@ jobs:
|
||||
- name: Run Trivy filesystem scan (fail on CRITICAL/HIGH)
|
||||
if: steps.check-artifact.outputs.artifact_exists == 'true' || github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
# aquasecurity/trivy-action v0.33.1
|
||||
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478
|
||||
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1
|
||||
with:
|
||||
scan-type: 'fs'
|
||||
scan-ref: ${{ steps.extract.outputs.binary_path }}
|
||||
|
||||
@@ -36,13 +36,18 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
# Explicitly fetch the current HEAD of the ref at run time, not the
|
||||
# SHA that was frozen when this scheduled job was queued. Without this,
|
||||
# a queued job can run days later with stale code.
|
||||
ref: ${{ github.ref_name }}
|
||||
|
||||
- name: Normalize image name
|
||||
run: |
|
||||
echo "IMAGE_NAME=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
@@ -56,7 +61,7 @@ jobs:
|
||||
echo "Base image digest: $DIGEST"
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -19,6 +19,8 @@ ARG CADDY_VERSION=2.11.1
|
||||
ARG CADDY_CANDIDATE_VERSION=2.11.1
|
||||
ARG CADDY_USE_CANDIDATE=0
|
||||
ARG CADDY_PATCH_SCENARIO=B
|
||||
# renovate: datasource=go depName=github.com/greenpau/caddy-security
|
||||
ARG CADDY_SECURITY_VERSION=1.1.36
|
||||
## When an official caddy image tag isn't available on the host, use a
|
||||
## plain Alpine base image and overwrite its caddy binary with our
|
||||
## xcaddy-built binary in the later COPY step. This avoids relying on
|
||||
@@ -134,7 +136,7 @@ RUN set -eux; \
|
||||
# Note: xx-go install puts binaries in /go/bin/TARGETOS_TARGETARCH/dlv if cross-compiling.
|
||||
# We find it and move it to /go/bin/dlv so it's in a consistent location for the next stage.
|
||||
# renovate: datasource=go depName=github.com/go-delve/delve
|
||||
ARG DLV_VERSION=1.26.0
|
||||
ARG DLV_VERSION=1.26.1
|
||||
# hadolint ignore=DL3059,DL4006
|
||||
RUN CGO_ENABLED=0 xx-go install github.com/go-delve/delve/cmd/dlv@v${DLV_VERSION} && \
|
||||
DLV_PATH=$(find /go/bin -name dlv -type f | head -n 1) && \
|
||||
@@ -202,6 +204,7 @@ ARG CADDY_VERSION
|
||||
ARG CADDY_CANDIDATE_VERSION
|
||||
ARG CADDY_USE_CANDIDATE
|
||||
ARG CADDY_PATCH_SCENARIO
|
||||
ARG CADDY_SECURITY_VERSION
|
||||
# renovate: datasource=go depName=github.com/caddyserver/xcaddy
|
||||
ARG XCADDY_VERSION=0.4.5
|
||||
|
||||
@@ -229,7 +232,8 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
echo "Stage 1: Generate go.mod with xcaddy..."; \
|
||||
# Run xcaddy to generate the build directory and go.mod
|
||||
GOOS=$TARGETOS GOARCH=$TARGETARCH xcaddy build v${CADDY_TARGET_VERSION} \
|
||||
--with github.com/greenpau/caddy-security \
|
||||
--with github.com/caddyserver/caddy/v2@v${CADDY_TARGET_VERSION} \
|
||||
--with github.com/greenpau/caddy-security@v${CADDY_SECURITY_VERSION} \
|
||||
--with github.com/corazawaf/coraza-caddy/v2 \
|
||||
--with github.com/hslatman/caddy-crowdsec-bouncer@v0.10.0 \
|
||||
--with github.com/zhangjiayin/caddy-geoip2 \
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestResetPasswordCommand_Succeeds(t *testing.T) {
|
||||
}
|
||||
|
||||
email := "user@example.com"
|
||||
user := models.User{UUID: "u-1", Email: email, Name: "User", Role: "admin", Enabled: true}
|
||||
user := models.User{UUID: "u-1", Email: email, Name: "User", Role: models.RoleAdmin, Enabled: true}
|
||||
user.PasswordHash = "$2a$10$example_hashed_password"
|
||||
if err = db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
@@ -257,7 +257,7 @@ func TestMain_ResetPasswordCommand_InProcess(t *testing.T) {
|
||||
}
|
||||
|
||||
email := "user@example.com"
|
||||
user := models.User{UUID: "u-1", Email: email, Name: "User", Role: "admin", Enabled: true}
|
||||
user := models.User{UUID: "u-1", Email: email, Name: "User", Role: models.RoleAdmin, Enabled: true}
|
||||
user.PasswordHash = "$2a$10$example_hashed_password"
|
||||
user.FailedLoginAttempts = 3
|
||||
if err = db.Create(&user).Error; err != nil {
|
||||
|
||||
@@ -72,7 +72,7 @@ func TestSeedMain_ForceAdminUpdatesExistingUserPassword(t *testing.T) {
|
||||
UUID: "existing-user",
|
||||
Email: "admin@localhost",
|
||||
Name: "Old Name",
|
||||
Role: "viewer",
|
||||
Role: models.RolePassthrough,
|
||||
Enabled: false,
|
||||
PasswordHash: "$2a$10$example_hashed_password",
|
||||
}
|
||||
@@ -134,7 +134,7 @@ func TestSeedMain_ForceAdminWithoutPasswordUpdatesMetadata(t *testing.T) {
|
||||
UUID: "existing-user-no-pass",
|
||||
Email: "admin@localhost",
|
||||
Name: "Old Name",
|
||||
Role: "viewer",
|
||||
Role: models.RolePassthrough,
|
||||
Enabled: false,
|
||||
PasswordHash: "$2a$10$example_hashed_password",
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ require (
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 // indirect
|
||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
||||
|
||||
@@ -176,8 +176,8 @@ go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 h1:PnV4kVnw0zOmwwFkAzCN5O07fw1YOIQor120zrh0AVo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0/go.mod h1:ofAwF4uinaf8SXdVzzbL4OsxJ3VfeEg3f/F6CeF49/Y=
|
||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
|
||||
@@ -186,10 +186,10 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4=
|
||||
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
||||
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
||||
go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8=
|
||||
go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y=
|
||||
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||
go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
|
||||
|
||||
@@ -381,7 +381,7 @@ func (h *AuthHandler) Verify(c *gin.Context) {
|
||||
|
||||
// Set headers for downstream services
|
||||
c.Header("X-Forwarded-User", user.Email)
|
||||
c.Header("X-Forwarded-Groups", user.Role)
|
||||
c.Header("X-Forwarded-Groups", string(user.Role))
|
||||
c.Header("X-Forwarded-Name", user.Name)
|
||||
|
||||
// Return 200 OK - access granted
|
||||
|
||||
@@ -430,7 +430,7 @@ func TestAuthHandler_Me(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "me@example.com",
|
||||
Name: "Me User",
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
}
|
||||
db.Create(user)
|
||||
|
||||
@@ -630,7 +630,7 @@ func TestAuthHandler_Verify_ValidToken(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "test@example.com",
|
||||
Name: "Test User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
}
|
||||
_ = user.SetPassword("password123")
|
||||
@@ -661,7 +661,7 @@ func TestAuthHandler_Verify_BearerToken(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "bearer@example.com",
|
||||
Name: "Bearer User",
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
Enabled: true,
|
||||
}
|
||||
_ = user.SetPassword("password123")
|
||||
@@ -690,7 +690,7 @@ func TestAuthHandler_Verify_DisabledUser(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "disabled@example.com",
|
||||
Name: "Disabled User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
}
|
||||
_ = user.SetPassword("password123")
|
||||
db.Create(user)
|
||||
@@ -730,7 +730,7 @@ func TestAuthHandler_Verify_ForwardAuthDenied(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "denied@example.com",
|
||||
Name: "Denied User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
PermissionMode: models.PermissionModeDenyAll,
|
||||
}
|
||||
@@ -795,7 +795,7 @@ func TestAuthHandler_VerifyStatus_Authenticated(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "status@example.com",
|
||||
Name: "Status User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
}
|
||||
_ = user.SetPassword("password123")
|
||||
@@ -828,7 +828,7 @@ func TestAuthHandler_VerifyStatus_DisabledUser(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "disabled2@example.com",
|
||||
Name: "Disabled User 2",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
}
|
||||
_ = user.SetPassword("password123")
|
||||
db.Create(user)
|
||||
@@ -880,7 +880,7 @@ func TestAuthHandler_GetAccessibleHosts_AllowAll(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "allowall@example.com",
|
||||
Name: "Allow All User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
PermissionMode: models.PermissionModeAllowAll,
|
||||
}
|
||||
@@ -917,7 +917,7 @@ func TestAuthHandler_GetAccessibleHosts_DenyAll(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "denyall@example.com",
|
||||
Name: "Deny All User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
PermissionMode: models.PermissionModeDenyAll,
|
||||
}
|
||||
@@ -956,7 +956,7 @@ func TestAuthHandler_GetAccessibleHosts_PermittedHosts(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "permitted@example.com",
|
||||
Name: "Permitted User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
PermissionMode: models.PermissionModeDenyAll,
|
||||
PermittedHosts: []models.ProxyHost{*host1}, // Only host1
|
||||
@@ -1111,7 +1111,7 @@ func TestAuthHandler_Logout_InvalidatesBearerSession(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "logout-session@example.com",
|
||||
Name: "Logout Session",
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
Enabled: true,
|
||||
}
|
||||
_ = user.SetPassword("password123")
|
||||
@@ -1242,7 +1242,7 @@ func TestAuthHandler_Refresh(t *testing.T) {
|
||||
|
||||
handler, db := setupAuthHandler(t)
|
||||
|
||||
user := &models.User{UUID: uuid.NewString(), Email: "refresh@example.com", Name: "Refresh User", Role: "user", Enabled: true}
|
||||
user := &models.User{UUID: uuid.NewString(), Email: "refresh@example.com", Name: "Refresh User", Role: models.RoleUser, Enabled: true}
|
||||
require.NoError(t, user.SetPassword("password123"))
|
||||
require.NoError(t, db.Create(user).Error)
|
||||
|
||||
@@ -1332,7 +1332,7 @@ func TestAuthHandler_Verify_UsesOriginalHostFallback(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "originalhost@example.com",
|
||||
Name: "Original Host User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
PermissionMode: models.PermissionModeAllowAll,
|
||||
}
|
||||
|
||||
@@ -384,10 +384,7 @@ func (h *EmergencyHandler) syncSecurityState(ctx context.Context) {
|
||||
// POST /api/v1/emergency/token/generate
|
||||
// Requires admin authentication
|
||||
func (h *EmergencyHandler) GenerateToken(c *gin.Context) {
|
||||
// Check admin role
|
||||
role, exists := c.Get("role")
|
||||
if !exists || role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -437,10 +434,7 @@ func (h *EmergencyHandler) GenerateToken(c *gin.Context) {
|
||||
// GET /api/v1/emergency/token/status
|
||||
// Requires admin authentication
|
||||
func (h *EmergencyHandler) GetTokenStatus(c *gin.Context) {
|
||||
// Check admin role
|
||||
role, exists := c.Get("role")
|
||||
if !exists || role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -458,10 +452,7 @@ func (h *EmergencyHandler) GetTokenStatus(c *gin.Context) {
|
||||
// DELETE /api/v1/emergency/token
|
||||
// Requires admin authentication
|
||||
func (h *EmergencyHandler) RevokeToken(c *gin.Context) {
|
||||
// Check admin role
|
||||
role, exists := c.Get("role")
|
||||
if !exists || role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -485,10 +476,7 @@ func (h *EmergencyHandler) RevokeToken(c *gin.Context) {
|
||||
// PATCH /api/v1/emergency/token/expiration
|
||||
// Requires admin authentication
|
||||
func (h *EmergencyHandler) UpdateTokenExpiration(c *gin.Context) {
|
||||
// Check admin role
|
||||
role, exists := c.Get("role")
|
||||
if !exists || role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -36,9 +36,7 @@ func requireAuthenticatedAdmin(c *gin.Context) bool {
|
||||
}
|
||||
|
||||
func isAdmin(c *gin.Context) bool {
|
||||
role, _ := c.Get("role")
|
||||
roleStr, _ := role.(string)
|
||||
return roleStr == "admin"
|
||||
return c.GetString("role") == string(models.RoleAdmin)
|
||||
}
|
||||
|
||||
func respondPermissionError(c *gin.Context, securityService *services.SecurityService, action string, err error, path string) bool {
|
||||
|
||||
@@ -307,7 +307,7 @@ func TestSecurityEventIntakeR6Intact(t *testing.T) {
|
||||
Email: "admin@example.com",
|
||||
Name: "Admin User",
|
||||
PasswordHash: "$2a$10$abcdefghijklmnopqrstuvwxyz", // Dummy bcrypt hash
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
Enabled: true,
|
||||
}
|
||||
require.NoError(t, db.Create(adminUser).Error)
|
||||
|
||||
@@ -1075,10 +1075,7 @@ func (h *SecurityHandler) PatchRateLimit(c *gin.Context) {
|
||||
// toggleSecurityModule is a helper function that handles enabling/disabling security modules
|
||||
// It updates the setting, invalidates cache, and triggers Caddy config reload
|
||||
func (h *SecurityHandler) toggleSecurityModule(c *gin.Context, settingKey string, enabled bool) {
|
||||
// Check admin role
|
||||
role, exists := c.Get("role")
|
||||
if !exists || role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -22,13 +22,15 @@ import (
|
||||
|
||||
type UserHandler struct {
|
||||
DB *gorm.DB
|
||||
AuthService *services.AuthService
|
||||
MailService *services.MailService
|
||||
securitySvc *services.SecurityService
|
||||
}
|
||||
|
||||
func NewUserHandler(db *gorm.DB) *UserHandler {
|
||||
func NewUserHandler(db *gorm.DB, authService *services.AuthService) *UserHandler {
|
||||
return &UserHandler{
|
||||
DB: db,
|
||||
AuthService: authService,
|
||||
MailService: services.NewMailService(db),
|
||||
securitySvc: services.NewSecurityService(db),
|
||||
}
|
||||
@@ -141,7 +143,7 @@ func (h *UserHandler) Setup(c *gin.Context) {
|
||||
UUID: uuid.New().String(),
|
||||
Name: req.Name,
|
||||
Email: strings.ToLower(req.Email),
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
Enabled: true,
|
||||
APIKey: uuid.New().String(),
|
||||
}
|
||||
@@ -197,8 +199,21 @@ func (h *UserHandler) Setup(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// rejectPassthrough aborts with 403 if the caller is a passthrough user.
|
||||
// Returns true if the request was rejected (caller should return).
|
||||
func rejectPassthrough(c *gin.Context, action string) bool {
|
||||
if c.GetString("role") == string(models.RolePassthrough) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Passthrough users cannot " + action})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RegenerateAPIKey generates a new API key for the authenticated user.
|
||||
func (h *UserHandler) RegenerateAPIKey(c *gin.Context) {
|
||||
if rejectPassthrough(c, "manage API keys") {
|
||||
return
|
||||
}
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
@@ -222,6 +237,9 @@ func (h *UserHandler) RegenerateAPIKey(c *gin.Context) {
|
||||
|
||||
// GetProfile returns the current user's profile including API key.
|
||||
func (h *UserHandler) GetProfile(c *gin.Context) {
|
||||
if rejectPassthrough(c, "access profile") {
|
||||
return
|
||||
}
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
@@ -252,6 +270,9 @@ type UpdateProfileRequest struct {
|
||||
|
||||
// UpdateProfile updates the authenticated user's profile.
|
||||
func (h *UserHandler) UpdateProfile(c *gin.Context) {
|
||||
if rejectPassthrough(c, "update profile") {
|
||||
return
|
||||
}
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
@@ -309,9 +330,7 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) {
|
||||
|
||||
// ListUsers returns all users (admin only).
|
||||
func (h *UserHandler) ListUsers(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -355,9 +374,7 @@ type CreateUserRequest struct {
|
||||
|
||||
// CreateUser creates a new user with a password (admin only).
|
||||
func (h *UserHandler) CreateUser(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -369,7 +386,12 @@ func (h *UserHandler) CreateUser(c *gin.Context) {
|
||||
|
||||
// Default role to "user"
|
||||
if req.Role == "" {
|
||||
req.Role = "user"
|
||||
req.Role = string(models.RoleUser)
|
||||
}
|
||||
|
||||
if !models.UserRole(req.Role).IsValid() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid role"})
|
||||
return
|
||||
}
|
||||
|
||||
// Default permission mode to "allow_all"
|
||||
@@ -392,7 +414,7 @@ func (h *UserHandler) CreateUser(c *gin.Context) {
|
||||
UUID: uuid.New().String(),
|
||||
Email: strings.ToLower(req.Email),
|
||||
Name: req.Name,
|
||||
Role: req.Role,
|
||||
Role: models.UserRole(req.Role),
|
||||
Enabled: true,
|
||||
APIKey: uuid.New().String(),
|
||||
PermissionMode: models.PermissionMode(req.PermissionMode),
|
||||
@@ -460,9 +482,7 @@ func generateSecureToken(length int) (string, error) {
|
||||
|
||||
// InviteUser creates a new user with an invite token and sends an email (admin only).
|
||||
func (h *UserHandler) InviteUser(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -476,7 +496,12 @@ func (h *UserHandler) InviteUser(c *gin.Context) {
|
||||
|
||||
// Default role to "user"
|
||||
if req.Role == "" {
|
||||
req.Role = "user"
|
||||
req.Role = string(models.RoleUser)
|
||||
}
|
||||
|
||||
if !models.UserRole(req.Role).IsValid() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid role"})
|
||||
return
|
||||
}
|
||||
|
||||
// Default permission mode to "allow_all"
|
||||
@@ -506,7 +531,7 @@ func (h *UserHandler) InviteUser(c *gin.Context) {
|
||||
user := models.User{
|
||||
UUID: uuid.New().String(),
|
||||
Email: strings.ToLower(req.Email),
|
||||
Role: req.Role,
|
||||
Role: models.UserRole(req.Role),
|
||||
Enabled: false, // Disabled until invite is accepted
|
||||
APIKey: uuid.New().String(),
|
||||
PermissionMode: models.PermissionMode(req.PermissionMode),
|
||||
@@ -595,9 +620,7 @@ type PreviewInviteURLRequest struct {
|
||||
|
||||
// PreviewInviteURL returns what the invite URL would look like with current settings.
|
||||
func (h *UserHandler) PreviewInviteURL(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -641,9 +664,7 @@ func getAppName(db *gorm.DB) string {
|
||||
|
||||
// GetUser returns a single user by ID (admin only).
|
||||
func (h *UserHandler) GetUser(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -692,11 +713,17 @@ type UpdateUserRequest struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// UpdateUser updates an existing user (admin only).
|
||||
// UpdateUser updates an existing user (admin only for management fields, self-service for name/password).
|
||||
func (h *UserHandler) UpdateUser(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
currentRole := c.GetString("role")
|
||||
currentUserIDRaw, exists := c.Get("userID")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||
return
|
||||
}
|
||||
currentUserID, ok := currentUserIDRaw.(uint)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid session"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -714,11 +741,31 @@ func (h *UserHandler) UpdateUser(c *gin.Context) {
|
||||
}
|
||||
|
||||
var req UpdateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
if bindErr := c.ShouldBindJSON(&req); bindErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": bindErr.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
isSelf := uint(id) == currentUserID
|
||||
isCallerAdmin := currentRole == string(models.RoleAdmin)
|
||||
|
||||
// Non-admin users can only update their own name and password via this endpoint.
|
||||
// Email changes require password verification and must go through PUT /user/profile.
|
||||
if !isCallerAdmin {
|
||||
if !isSelf {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
return
|
||||
}
|
||||
if req.Email != "" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Email changes must be made via your profile settings"})
|
||||
return
|
||||
}
|
||||
if req.Role != "" || req.Enabled != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cannot modify role or enabled status"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
updates := make(map[string]any)
|
||||
|
||||
if req.Name != "" {
|
||||
@@ -727,21 +774,37 @@ func (h *UserHandler) UpdateUser(c *gin.Context) {
|
||||
|
||||
if req.Email != "" {
|
||||
email := strings.ToLower(req.Email)
|
||||
// Check if email is taken by another user
|
||||
var count int64
|
||||
if err := h.DB.Model(&models.User{}).Where("email = ? AND id != ?", email, id).Count(&count).Error; err == nil && count > 0 {
|
||||
if dbErr := h.DB.Model(&models.User{}).Where("email = ? AND id != ?", email, id).Count(&count).Error; dbErr == nil && count > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Email already in use"})
|
||||
return
|
||||
}
|
||||
updates["email"] = email
|
||||
}
|
||||
|
||||
needsSessionInvalidation := false
|
||||
|
||||
if req.Role != "" {
|
||||
updates["role"] = req.Role
|
||||
newRole := models.UserRole(req.Role)
|
||||
if !newRole.IsValid() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid role"})
|
||||
return
|
||||
}
|
||||
|
||||
if newRole != user.Role {
|
||||
// Self-demotion prevention
|
||||
if isSelf {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cannot change your own role"})
|
||||
return
|
||||
}
|
||||
|
||||
updates["role"] = string(newRole)
|
||||
needsSessionInvalidation = true
|
||||
}
|
||||
}
|
||||
|
||||
if req.Password != nil {
|
||||
if err := user.SetPassword(*req.Password); err != nil {
|
||||
if hashErr := user.SetPassword(*req.Password); hashErr != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
|
||||
return
|
||||
}
|
||||
@@ -750,14 +813,82 @@ func (h *UserHandler) UpdateUser(c *gin.Context) {
|
||||
updates["locked_until"] = nil
|
||||
}
|
||||
|
||||
if req.Enabled != nil {
|
||||
if req.Enabled != nil && *req.Enabled != user.Enabled {
|
||||
// Prevent self-disable
|
||||
if isSelf && !*req.Enabled {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cannot disable your own account"})
|
||||
return
|
||||
}
|
||||
|
||||
updates["enabled"] = *req.Enabled
|
||||
if !*req.Enabled {
|
||||
needsSessionInvalidation = true
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap the last-admin checks and the actual update in a transaction to prevent
|
||||
// race conditions: two concurrent requests could both read adminCount==2
|
||||
// and both proceed, leaving zero admins.
|
||||
err = h.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// Re-fetch user inside transaction for consistent state
|
||||
if txErr := tx.First(&user, id).Error; txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
|
||||
// Last-admin protection for role demotion
|
||||
if newRoleStr, ok := updates["role"]; ok {
|
||||
newRole := models.UserRole(newRoleStr.(string))
|
||||
if user.Role == models.RoleAdmin && newRole != models.RoleAdmin {
|
||||
var adminCount int64
|
||||
// Policy: count only enabled admins. This is stricter than "WHERE role = ?"
|
||||
// because a disabled admin cannot act; treating them as non-existent
|
||||
// prevents leaving the system with only disabled admins.
|
||||
tx.Model(&models.User{}).Where("role = ? AND enabled = ?", models.RoleAdmin, true).Count(&adminCount)
|
||||
if adminCount <= 1 {
|
||||
return fmt.Errorf("cannot demote the last admin")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last-admin protection for disabling
|
||||
if enabledVal, ok := updates["enabled"]; ok {
|
||||
if enabled, isBool := enabledVal.(bool); isBool && !enabled {
|
||||
if user.Role == models.RoleAdmin {
|
||||
var adminCount int64
|
||||
// Policy: count only enabled admins (same rationale as above).
|
||||
tx.Model(&models.User{}).Where("role = ? AND enabled = ?", models.RoleAdmin, true).Count(&adminCount)
|
||||
if adminCount <= 1 {
|
||||
return fmt.Errorf("cannot disable the last admin")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
if txErr := tx.Model(&user).Updates(updates).Error; txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
if errMsg == "cannot demote the last admin" || errMsg == "cannot disable the last admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cannot" + errMsg[len("cannot"):]})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
if err := h.DB.Model(&user).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"})
|
||||
return
|
||||
if needsSessionInvalidation && h.AuthService != nil {
|
||||
if invErr := h.AuthService.InvalidateSessions(user.ID); invErr != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to invalidate sessions"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
h.logUserAudit(c, "user_update", &user, map[string]any{
|
||||
@@ -780,13 +911,12 @@ func mapsKeys(values map[string]any) []string {
|
||||
|
||||
// DeleteUser deletes a user (admin only).
|
||||
func (h *UserHandler) DeleteUser(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
currentUserID, _ := c.Get("userID")
|
||||
currentUserIDRaw, _ := c.Get("userID")
|
||||
currentUserID, _ := currentUserIDRaw.(uint)
|
||||
|
||||
idParam := c.Param("id")
|
||||
id, err := strconv.ParseUint(idParam, 10, 32)
|
||||
@@ -796,7 +926,7 @@ func (h *UserHandler) DeleteUser(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Prevent self-deletion
|
||||
if uint(id) == currentUserID.(uint) {
|
||||
if uint(id) == currentUserID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cannot delete your own account"})
|
||||
return
|
||||
}
|
||||
@@ -834,9 +964,7 @@ type UpdateUserPermissionsRequest struct {
|
||||
|
||||
// ResendInvite regenerates and resends an invitation to a pending user (admin only).
|
||||
func (h *UserHandler) ResendInvite(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -919,9 +1047,7 @@ func redactInviteURL(inviteURL string) string {
|
||||
|
||||
// UpdateUserPermissions updates a user's permission mode and host exceptions (admin only).
|
||||
func (h *UserHandler) UpdateUserPermissions(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ func setupUserCoverageDB(t *testing.T) *gorm.DB {
|
||||
func TestUserHandler_GetSetupStatus_Error(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
// Drop table to cause error
|
||||
_ = db.Migrator().DropTable(&models.User{})
|
||||
@@ -40,7 +40,7 @@ func TestUserHandler_GetSetupStatus_Error(t *testing.T) {
|
||||
func TestUserHandler_Setup_CheckStatusError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
// Drop table to cause error
|
||||
_ = db.Migrator().DropTable(&models.User{})
|
||||
@@ -57,10 +57,10 @@ func TestUserHandler_Setup_CheckStatusError(t *testing.T) {
|
||||
func TestUserHandler_Setup_AlreadyCompleted(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
// Create a user to mark setup as complete
|
||||
user := &models.User{UUID: "uuid-a", Name: "Admin", Email: "admin@test.com", Role: "admin"}
|
||||
user := &models.User{UUID: "uuid-a", Name: "Admin", Email: "admin@test.com", Role: models.RoleAdmin}
|
||||
_ = user.SetPassword("password123")
|
||||
db.Create(user)
|
||||
|
||||
@@ -76,7 +76,7 @@ func TestUserHandler_Setup_AlreadyCompleted(t *testing.T) {
|
||||
func TestUserHandler_Setup_InvalidJSON(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -91,7 +91,7 @@ func TestUserHandler_Setup_InvalidJSON(t *testing.T) {
|
||||
func TestUserHandler_RegenerateAPIKey_Unauthorized(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -105,7 +105,7 @@ func TestUserHandler_RegenerateAPIKey_Unauthorized(t *testing.T) {
|
||||
func TestUserHandler_RegenerateAPIKey_DBError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
// Drop table to cause error
|
||||
_ = db.Migrator().DropTable(&models.User{})
|
||||
@@ -123,7 +123,7 @@ func TestUserHandler_RegenerateAPIKey_DBError(t *testing.T) {
|
||||
func TestUserHandler_GetProfile_Unauthorized(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -137,7 +137,7 @@ func TestUserHandler_GetProfile_Unauthorized(t *testing.T) {
|
||||
func TestUserHandler_GetProfile_NotFound(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -152,7 +152,7 @@ func TestUserHandler_GetProfile_NotFound(t *testing.T) {
|
||||
func TestUserHandler_UpdateProfile_Unauthorized(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -166,7 +166,7 @@ func TestUserHandler_UpdateProfile_Unauthorized(t *testing.T) {
|
||||
func TestUserHandler_UpdateProfile_InvalidJSON(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -182,7 +182,7 @@ func TestUserHandler_UpdateProfile_InvalidJSON(t *testing.T) {
|
||||
func TestUserHandler_UpdateProfile_UserNotFound(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"name": "Updated",
|
||||
@@ -203,14 +203,14 @@ func TestUserHandler_UpdateProfile_UserNotFound(t *testing.T) {
|
||||
func TestUserHandler_UpdateProfile_EmailConflict(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
// Create two users
|
||||
user1 := &models.User{UUID: "uuid-1", Name: "User1", Email: "user1@test.com", Role: "admin", APIKey: "key1"}
|
||||
user1 := &models.User{UUID: "uuid-1", Name: "User1", Email: "user1@test.com", Role: models.RoleAdmin, APIKey: "key1"}
|
||||
_ = user1.SetPassword("password123")
|
||||
db.Create(user1)
|
||||
|
||||
user2 := &models.User{UUID: "uuid-2", Name: "User2", Email: "user2@test.com", Role: "admin", APIKey: "key2"}
|
||||
user2 := &models.User{UUID: "uuid-2", Name: "User2", Email: "user2@test.com", Role: models.RoleAdmin, APIKey: "key2"}
|
||||
_ = user2.SetPassword("password123")
|
||||
db.Create(user2)
|
||||
|
||||
@@ -236,9 +236,9 @@ func TestUserHandler_UpdateProfile_EmailConflict(t *testing.T) {
|
||||
func TestUserHandler_UpdateProfile_EmailChangeNoPassword(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
user := &models.User{UUID: "uuid-u", Name: "User", Email: "user@test.com", Role: "admin"}
|
||||
user := &models.User{UUID: "uuid-u", Name: "User", Email: "user@test.com", Role: models.RoleAdmin}
|
||||
_ = user.SetPassword("password123")
|
||||
db.Create(user)
|
||||
|
||||
@@ -263,9 +263,9 @@ func TestUserHandler_UpdateProfile_EmailChangeNoPassword(t *testing.T) {
|
||||
func TestUserHandler_UpdateProfile_WrongPassword(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
user := &models.User{UUID: "uuid-u", Name: "User", Email: "user@test.com", Role: "admin"}
|
||||
user := &models.User{UUID: "uuid-u", Name: "User", Email: "user@test.com", Role: models.RoleAdmin}
|
||||
_ = user.SetPassword("password123")
|
||||
db.Create(user)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -23,7 +25,7 @@ import (
|
||||
func setupUserHandler(t *testing.T) (*UserHandler, *gorm.DB) {
|
||||
db := OpenTestDB(t)
|
||||
_ = db.AutoMigrate(&models.User{}, &models.Setting{}, &models.SecurityAudit{})
|
||||
return NewUserHandler(db), db
|
||||
return NewUserHandler(db, nil), db
|
||||
}
|
||||
|
||||
func TestMapsKeys(t *testing.T) {
|
||||
@@ -312,7 +314,7 @@ func TestUserHandler_ListUsers_SecretEchoContract(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "user@example.com",
|
||||
Name: "User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
APIKey: "raw-api-key",
|
||||
InviteToken: "raw-invite-token",
|
||||
PasswordHash: "raw-password-hash",
|
||||
@@ -661,7 +663,7 @@ func TestUserHandler_UpdateProfile_Errors(t *testing.T) {
|
||||
func setupUserHandlerWithProxyHosts(t *testing.T) (*UserHandler, *gorm.DB) {
|
||||
db := OpenTestDB(t)
|
||||
_ = db.AutoMigrate(&models.User{}, &models.Setting{}, &models.ProxyHost{}, &models.SecurityAudit{})
|
||||
return NewUserHandler(db), db
|
||||
return NewUserHandler(db, nil), db
|
||||
}
|
||||
|
||||
func TestUserHandler_ListUsers_NonAdmin(t *testing.T) {
|
||||
@@ -912,18 +914,24 @@ func TestUserHandler_GetUser_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUser_NonAdmin(t *testing.T) {
|
||||
handler, _ := setupUserHandlerWithProxyHosts(t)
|
||||
handler, db := setupUserHandlerWithProxyHosts(t)
|
||||
|
||||
// Create a target user so it exists in the DB
|
||||
target := &models.User{UUID: uuid.NewString(), Email: "target@example.com", Name: "Target", APIKey: uuid.NewString(), Role: models.RoleUser}
|
||||
db.Create(target)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "user")
|
||||
c.Set("userID", uint(999))
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/users/:id", handler.UpdateUser)
|
||||
|
||||
body := map[string]any{"name": "Updated"}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest("PUT", "/users/1", bytes.NewBuffer(jsonBody))
|
||||
req := httptest.NewRequest("PUT", fmt.Sprintf("/users/%d", target.ID), bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
@@ -937,6 +945,7 @@ func TestUserHandler_UpdateUser_InvalidID(t *testing.T) {
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Set("userID", uint(11))
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/users/:id", handler.UpdateUser)
|
||||
@@ -962,6 +971,7 @@ func TestUserHandler_UpdateUser_InvalidJSON(t *testing.T) {
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Set("userID", uint(11))
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/users/:id", handler.UpdateUser)
|
||||
@@ -980,6 +990,7 @@ func TestUserHandler_UpdateUser_NotFound(t *testing.T) {
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Set("userID", uint(11))
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/users/:id", handler.UpdateUser)
|
||||
@@ -997,7 +1008,7 @@ func TestUserHandler_UpdateUser_NotFound(t *testing.T) {
|
||||
func TestUserHandler_UpdateUser_Success(t *testing.T) {
|
||||
handler, db := setupUserHandlerWithProxyHosts(t)
|
||||
|
||||
user := &models.User{UUID: uuid.NewString(), Email: "update@example.com", Name: "Original", Role: "user"}
|
||||
user := &models.User{UUID: uuid.NewString(), Email: "update@example.com", Name: "Original", Role: models.RoleUser}
|
||||
db.Create(user)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
@@ -1030,7 +1041,7 @@ func TestUserHandler_UpdateUser_Success(t *testing.T) {
|
||||
func TestUserHandler_UpdateUser_PasswordReset(t *testing.T) {
|
||||
handler, db := setupUserHandlerWithProxyHosts(t)
|
||||
|
||||
user := &models.User{UUID: uuid.NewString(), Email: "reset@example.com", Name: "Reset User", Role: "user"}
|
||||
user := &models.User{UUID: uuid.NewString(), Email: "reset@example.com", Name: "Reset User", Role: models.RoleUser}
|
||||
require.NoError(t, user.SetPassword("oldpassword123"))
|
||||
lockUntil := time.Now().Add(10 * time.Minute)
|
||||
user.FailedLoginAttempts = 4
|
||||
@@ -1041,6 +1052,7 @@ func TestUserHandler_UpdateUser_PasswordReset(t *testing.T) {
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Set("userID", uint(11))
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/users/:id", handler.UpdateUser)
|
||||
@@ -1214,7 +1226,7 @@ func TestUserHandler_UpdateUserPermissions_InvalidJSON(t *testing.T) {
|
||||
APIKey: uuid.NewString(),
|
||||
Email: "perms-invalid@example.com",
|
||||
Name: "Perms Invalid Test",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
}
|
||||
db.Create(user)
|
||||
@@ -1562,7 +1574,7 @@ func TestUserHandler_InviteUser_Success(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
APIKey: uuid.NewString(),
|
||||
Email: "admin@example.com",
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
}
|
||||
db.Create(admin)
|
||||
|
||||
@@ -1615,7 +1627,7 @@ func TestUserHandler_InviteUser_WithPermittedHosts(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
APIKey: uuid.NewString(),
|
||||
Email: "admin-perm@example.com",
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
}
|
||||
db.Create(admin)
|
||||
|
||||
@@ -1664,7 +1676,7 @@ func TestUserHandler_InviteUser_WithSMTPConfigured(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
APIKey: uuid.NewString(),
|
||||
Email: "admin-smtp@example.com",
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
}
|
||||
db.Create(admin)
|
||||
|
||||
@@ -1727,7 +1739,7 @@ func TestUserHandler_InviteUser_WithSMTPAndConfiguredPublicURL_IncludesInviteURL
|
||||
UUID: uuid.NewString(),
|
||||
APIKey: uuid.NewString(),
|
||||
Email: "admin-publicurl@example.com",
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
}
|
||||
db.Create(admin)
|
||||
|
||||
@@ -1780,7 +1792,7 @@ func TestUserHandler_InviteUser_WithSMTPAndMalformedPublicURL_DoesNotExposeInvit
|
||||
UUID: uuid.NewString(),
|
||||
APIKey: uuid.NewString(),
|
||||
Email: "admin-malformed-publicurl@example.com",
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
}
|
||||
db.Create(admin)
|
||||
|
||||
@@ -1834,7 +1846,7 @@ func TestUserHandler_InviteUser_WithSMTPConfigured_DefaultAppName(t *testing.T)
|
||||
UUID: uuid.NewString(),
|
||||
APIKey: uuid.NewString(),
|
||||
Email: "admin-smtp-default@example.com",
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
}
|
||||
db.Create(admin)
|
||||
|
||||
@@ -1976,7 +1988,7 @@ func TestUserHandler_PreviewInviteURL_NonAdmin(t *testing.T) {
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Admin access required")
|
||||
assert.Contains(t, w.Body.String(), "admin privileges required")
|
||||
}
|
||||
|
||||
func TestUserHandler_PreviewInviteURL_InvalidJSON(t *testing.T) {
|
||||
@@ -2137,6 +2149,7 @@ func TestUserHandler_UpdateUser_EmailConflict(t *testing.T) {
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Set("userID", uint(11))
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/users/:id", handler.UpdateUser)
|
||||
@@ -2195,7 +2208,7 @@ func TestUserHandler_InviteUser_EmailNormalization(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
APIKey: uuid.NewString(),
|
||||
Email: "admin@example.com",
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
}
|
||||
db.Create(admin)
|
||||
|
||||
@@ -2264,7 +2277,7 @@ func TestUserHandler_InviteUser_DefaultPermissionMode(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
APIKey: uuid.NewString(),
|
||||
Email: "admin@example.com",
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
}
|
||||
db.Create(admin)
|
||||
|
||||
@@ -2322,7 +2335,7 @@ func TestUserHandler_CreateUser_DefaultRole(t *testing.T) {
|
||||
// Verify role defaults to "user"
|
||||
var user models.User
|
||||
db.Where("email = ?", "defaultrole@example.com").First(&user)
|
||||
assert.Equal(t, "user", user.Role)
|
||||
assert.Equal(t, models.RoleUser, user.Role)
|
||||
}
|
||||
|
||||
func TestUserHandler_InviteUser_DefaultRole(t *testing.T) {
|
||||
@@ -2333,7 +2346,7 @@ func TestUserHandler_InviteUser_DefaultRole(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
APIKey: uuid.NewString(),
|
||||
Email: "admin@example.com",
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
}
|
||||
db.Create(admin)
|
||||
|
||||
@@ -2361,7 +2374,7 @@ func TestUserHandler_InviteUser_DefaultRole(t *testing.T) {
|
||||
// Verify role defaults to "user"
|
||||
var user models.User
|
||||
db.Where("email = ?", "defaultroleinvite@example.com").First(&user)
|
||||
assert.Equal(t, "user", user.Role)
|
||||
assert.Equal(t, models.RoleUser, user.Role)
|
||||
}
|
||||
|
||||
// TestUserHandler_PreviewInviteURL_Unconfigured_DoesNotUseRequestHost verifies that
|
||||
@@ -2484,7 +2497,7 @@ func TestResendInvite_NonAdmin(t *testing.T) {
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Admin access required")
|
||||
assert.Contains(t, w.Body.String(), "admin privileges required")
|
||||
}
|
||||
|
||||
func TestResendInvite_InvalidID(t *testing.T) {
|
||||
@@ -2705,3 +2718,394 @@ func TestRedactInviteURL(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Passthrough rejection tests ---
|
||||
|
||||
func setupPassthroughRouter(handler *UserHandler) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", string(models.RolePassthrough))
|
||||
c.Next()
|
||||
})
|
||||
r.POST("/api-key", handler.RegenerateAPIKey)
|
||||
r.GET("/profile", handler.GetProfile)
|
||||
r.PUT("/profile", handler.UpdateProfile)
|
||||
return r
|
||||
}
|
||||
|
||||
func TestUserHandler_RegenerateAPIKey_PassthroughRejected(t *testing.T) {
|
||||
handler, _ := setupUserHandler(t)
|
||||
r := setupPassthroughRouter(handler)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api-key", http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Passthrough users cannot manage API keys")
|
||||
}
|
||||
|
||||
func TestUserHandler_GetProfile_PassthroughRejected(t *testing.T) {
|
||||
handler, _ := setupUserHandler(t)
|
||||
r := setupPassthroughRouter(handler)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/profile", http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Passthrough users cannot access profile")
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateProfile_PassthroughRejected(t *testing.T) {
|
||||
handler, _ := setupUserHandler(t)
|
||||
r := setupPassthroughRouter(handler)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"name": "Test"})
|
||||
req := httptest.NewRequest(http.MethodPut, "/profile", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Passthrough users cannot update profile")
|
||||
}
|
||||
|
||||
// --- CreateUser / InviteUser invalid role ---
|
||||
|
||||
func TestUserHandler_CreateUser_InvalidRole(t *testing.T) {
|
||||
handler, _ := setupUserHandler(t)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
r.POST("/users", handler.CreateUser)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"name": "Test User",
|
||||
"email": "new@example.com",
|
||||
"role": "superadmin",
|
||||
"password": "password123",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Invalid role")
|
||||
}
|
||||
|
||||
func TestUserHandler_InviteUser_InvalidRole(t *testing.T) {
|
||||
handler, _ := setupUserHandler(t)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Set("userID", uint(1))
|
||||
c.Next()
|
||||
})
|
||||
r.POST("/invite", handler.InviteUser)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"email": "invite@example.com",
|
||||
"role": "superadmin",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/invite", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Invalid role")
|
||||
}
|
||||
|
||||
// --- UpdateUser authentication/session edge cases ---
|
||||
|
||||
func TestUserHandler_UpdateUser_MissingUserID(t *testing.T) {
|
||||
handler, db := setupUserHandler(t)
|
||||
|
||||
user := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "target@example.com", Role: models.RoleUser, Enabled: true}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
// No userID set in context
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/users/:id", handler.UpdateUser)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"name": "New Name"})
|
||||
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", user.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Authentication required")
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUser_InvalidSessionType(t *testing.T) {
|
||||
handler, db := setupUserHandler(t)
|
||||
|
||||
user := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "target2@example.com", Role: models.RoleUser, Enabled: true}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Set("userID", "not-a-uint") // wrong type
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/users/:id", handler.UpdateUser)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"name": "New Name"})
|
||||
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", user.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Invalid session")
|
||||
}
|
||||
|
||||
// --- UpdateUser role/enabled restriction for non-admin self ---
|
||||
|
||||
func TestUserHandler_UpdateUser_NonAdminSelfRoleChange(t *testing.T) {
|
||||
handler, db := setupUserHandler(t)
|
||||
|
||||
user := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "self@example.com", Role: models.RoleUser, Enabled: true}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "user") // non-admin
|
||||
c.Set("userID", user.ID) // isSelf = true
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/users/:id", handler.UpdateUser)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"role": "admin"})
|
||||
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", user.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Cannot modify role or enabled status")
|
||||
}
|
||||
|
||||
// --- UpdateUser invalid role string ---
|
||||
|
||||
func TestUserHandler_UpdateUser_InvalidRole(t *testing.T) {
|
||||
handler, db := setupUserHandler(t)
|
||||
|
||||
target := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "target3@example.com", Role: models.RoleUser, Enabled: true}
|
||||
require.NoError(t, db.Create(&target).Error)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Set("userID", uint(9999)) // not the target
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/users/:id", handler.UpdateUser)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"role": "superadmin"})
|
||||
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", target.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Invalid role")
|
||||
}
|
||||
|
||||
// --- UpdateUser self-demotion and self-disable ---
|
||||
|
||||
func TestUserHandler_UpdateUser_SelfDemotion(t *testing.T) {
|
||||
handler, db := setupUserHandler(t)
|
||||
|
||||
admin := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "admin@self.example.com", Role: models.RoleAdmin, Enabled: true}
|
||||
require.NoError(t, db.Create(&admin).Error)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Set("userID", admin.ID) // isSelf = true
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/users/:id", handler.UpdateUser)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"role": "user"})
|
||||
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", admin.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Cannot change your own role")
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUser_SelfDisable(t *testing.T) {
|
||||
handler, db := setupUserHandler(t)
|
||||
|
||||
admin := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "admin@disable.example.com", Role: models.RoleAdmin, Enabled: true}
|
||||
require.NoError(t, db.Create(&admin).Error)
|
||||
|
||||
disabled := false
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Set("userID", admin.ID) // isSelf = true
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/users/:id", handler.UpdateUser)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"enabled": disabled})
|
||||
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", admin.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Cannot disable your own account")
|
||||
}
|
||||
|
||||
// --- UpdateUser last-admin protection ---
|
||||
|
||||
func TestUserHandler_UpdateUser_LastAdminDemotion(t *testing.T) {
|
||||
handler, db := setupUserHandler(t)
|
||||
|
||||
// Only one admin in the DB (the target)
|
||||
target := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "last-admin@example.com", Role: models.RoleAdmin, Enabled: true}
|
||||
require.NoError(t, db.Create(&target).Error)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Set("userID", uint(9999)) // different from target; not in DB but role injected via context
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/users/:id", handler.UpdateUser)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"role": "user"})
|
||||
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", target.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Cannot demote the last admin")
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUser_LastAdminDisable(t *testing.T) {
|
||||
handler, db := setupUserHandler(t)
|
||||
|
||||
target := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "last-admin-disable@example.com", Role: models.RoleAdmin, Enabled: true}
|
||||
require.NoError(t, db.Create(&target).Error)
|
||||
|
||||
disabled := false
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Set("userID", uint(9999))
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/users/:id", handler.UpdateUser)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"enabled": disabled})
|
||||
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", target.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Cannot disable the last admin")
|
||||
}
|
||||
|
||||
// --- UpdateUser session invalidation ---
|
||||
|
||||
func TestUserHandler_UpdateUser_WithSessionInvalidation(t *testing.T) {
|
||||
db := OpenTestDB(t)
|
||||
_ = db.AutoMigrate(&models.User{}, &models.Setting{}, &models.SecurityAudit{})
|
||||
|
||||
authSvc := services.NewAuthService(db, config.Config{JWTSecret: "test-secret"})
|
||||
|
||||
caller := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "caller-si@example.com", Role: models.RoleAdmin, Enabled: true}
|
||||
target := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "target-si@example.com", Role: models.RoleUser, Enabled: true}
|
||||
require.NoError(t, db.Create(&caller).Error)
|
||||
require.NoError(t, db.Create(&target).Error)
|
||||
|
||||
handler := NewUserHandler(db, authSvc)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Set("userID", caller.ID)
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/users/:id", handler.UpdateUser)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"role": "passthrough"})
|
||||
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", target.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "User updated successfully")
|
||||
|
||||
var updated models.User
|
||||
require.NoError(t, db.First(&updated, target.ID).Error)
|
||||
assert.Greater(t, updated.SessionVersion, uint(0))
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUser_SessionInvalidationError(t *testing.T) {
|
||||
mainDB := OpenTestDB(t)
|
||||
_ = mainDB.AutoMigrate(&models.User{}, &models.Setting{}, &models.SecurityAudit{})
|
||||
|
||||
// Use a separate empty DB so InvalidateSessions cannot find the user
|
||||
authDB := OpenTestDB(t)
|
||||
authSvc := services.NewAuthService(authDB, config.Config{JWTSecret: "test-secret"})
|
||||
|
||||
target := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "target-sie@example.com", Role: models.RoleUser, Enabled: true}
|
||||
require.NoError(t, mainDB.Create(&target).Error)
|
||||
|
||||
handler := NewUserHandler(mainDB, authSvc)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Set("userID", uint(9999))
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/users/:id", handler.UpdateUser)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"role": "passthrough"})
|
||||
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", target.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Failed to invalidate sessions")
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestUserLoginAfterEmailChange(t *testing.T) {
|
||||
cfg := config.Config{}
|
||||
authService := services.NewAuthService(db, cfg)
|
||||
authHandler := NewAuthHandler(authService)
|
||||
userHandler := NewUserHandler(db)
|
||||
userHandler := NewUserHandler(db, nil)
|
||||
|
||||
// Setup Router
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -37,7 +38,7 @@ func AuthMiddleware(authService *services.AuthService) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
c.Set("userID", user.ID)
|
||||
c.Set("role", user.Role)
|
||||
c.Set("role", string(user.Role))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -95,15 +96,15 @@ func extractAuthCookieToken(c *gin.Context) string {
|
||||
return token
|
||||
}
|
||||
|
||||
func RequireRole(role string) gin.HandlerFunc {
|
||||
func RequireRole(role models.UserRole) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userRole, exists := c.Get("role")
|
||||
if !exists {
|
||||
userRole := c.GetString("role")
|
||||
if userRole == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
if userRole.(string) != role && userRole.(string) != "admin" {
|
||||
if userRole != string(role) && userRole != string(models.RoleAdmin) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Forbidden"})
|
||||
return
|
||||
}
|
||||
@@ -111,3 +112,14 @@ func RequireRole(role string) gin.HandlerFunc {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func RequireManagementAccess() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role := c.GetString("role")
|
||||
if role == string(models.RolePassthrough) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Pass-through users cannot access management features"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,3 +427,61 @@ func TestExtractAuthCookieToken_IgnoresNonAuthCookies(t *testing.T) {
|
||||
token := extractAuthCookieToken(ctx)
|
||||
assert.Equal(t, "", token)
|
||||
}
|
||||
|
||||
func TestRequireManagementAccess_PassthroughBlocked(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", string(models.RolePassthrough))
|
||||
c.Next()
|
||||
})
|
||||
r.Use(RequireManagementAccess())
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Pass-through users cannot access management features")
|
||||
}
|
||||
|
||||
func TestRequireManagementAccess_UserAllowed(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", string(models.RoleUser))
|
||||
c.Next()
|
||||
})
|
||||
r.Use(RequireManagementAccess())
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func TestRequireManagementAccess_AdminAllowed(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", string(models.RoleAdmin))
|
||||
c.Next()
|
||||
})
|
||||
r.Use(RequireManagementAccess())
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func OptionalAuth(authService *services.AuthService) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
c.Set("userID", user.ID)
|
||||
c.Set("role", user.Role)
|
||||
c.Set("role", string(user.Role))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ func TestOptionalAuth_ValidTokenSetsContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
authService, db := setupAuthServiceWithDB(t)
|
||||
user := &models.User{Email: "optional-auth@example.com", Name: "Optional Auth", Role: "admin", Enabled: true}
|
||||
user := &models.User{Email: "optional-auth@example.com", Name: "Optional Auth", Role: models.RoleAdmin, Enabled: true}
|
||||
require.NoError(t, user.SetPassword("password123"))
|
||||
require.NoError(t, db.Create(user).Error)
|
||||
|
||||
|
||||
@@ -52,6 +52,14 @@ func runInitialUptimeBootstrap(enabled bool, uptimeService uptimeBootstrapServic
|
||||
uptimeService.CheckAll()
|
||||
}
|
||||
|
||||
// migrateViewerToPassthrough renames any legacy "viewer" roles to "passthrough".
|
||||
func migrateViewerToPassthrough(db *gorm.DB) {
|
||||
result := db.Model(&models.User{}).Where("role = ?", "viewer").Update("role", string(models.RolePassthrough))
|
||||
if result.RowsAffected > 0 {
|
||||
logger.Log().WithField("count", result.RowsAffected).Info("Migrated viewer roles to passthrough")
|
||||
}
|
||||
}
|
||||
|
||||
// Register wires up API routes and performs automatic migrations.
|
||||
func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
|
||||
// Caddy Manager - created early so it can be used by settings handlers for config reload
|
||||
@@ -118,7 +126,7 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
return fmt.Errorf("auto migrate: %w", err)
|
||||
}
|
||||
|
||||
// Clean up invalid Let's Encrypt certificate associations
|
||||
migrateViewerToPassthrough(db)
|
||||
// Let's Encrypt certs are auto-managed by Caddy and should not be assigned via certificate_id
|
||||
logger.Log().Info("Cleaning up invalid Let's Encrypt certificate associations...")
|
||||
var hostsWithInvalidCerts []models.ProxyHost
|
||||
@@ -239,7 +247,7 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
api.POST("/security/events", securityNotificationHandler.HandleSecurityEvent)
|
||||
|
||||
// User handler (public endpoints)
|
||||
userHandler := handlers.NewUserHandler(db)
|
||||
userHandler := handlers.NewUserHandler(db, authService)
|
||||
api.GET("/setup", userHandler.GetSetupStatus)
|
||||
api.POST("/setup", userHandler.Setup)
|
||||
api.GET("/invite/validate", userHandler.ValidateInvite)
|
||||
@@ -251,109 +259,110 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
protected := api.Group("/")
|
||||
protected.Use(authMiddleware)
|
||||
{
|
||||
// Self-service routes — accessible to all authenticated users including passthrough
|
||||
protected.POST("/auth/logout", authHandler.Logout)
|
||||
protected.POST("/auth/refresh", authHandler.Refresh)
|
||||
protected.GET("/auth/me", authHandler.Me)
|
||||
protected.POST("/auth/change-password", authHandler.ChangePassword)
|
||||
|
||||
// Backups
|
||||
protected.GET("/backups", backupHandler.List)
|
||||
protected.POST("/backups", backupHandler.Create)
|
||||
protected.DELETE("/backups/:filename", backupHandler.Delete)
|
||||
protected.GET("/backups/:filename/download", backupHandler.Download)
|
||||
protected.POST("/backups/:filename/restore", backupHandler.Restore)
|
||||
|
||||
// Logs
|
||||
// WebSocket endpoints
|
||||
logsWSHandler := handlers.NewLogsWSHandler(wsTracker)
|
||||
protected.GET("/logs/live", logsWSHandler.HandleWebSocket)
|
||||
protected.GET("/logs", logsHandler.List)
|
||||
protected.GET("/logs/:filename", logsHandler.Read)
|
||||
protected.GET("/logs/:filename/download", logsHandler.Download)
|
||||
|
||||
// WebSocket status monitoring
|
||||
protected.GET("/websocket/connections", wsStatusHandler.GetConnections)
|
||||
protected.GET("/websocket/stats", wsStatusHandler.GetStats)
|
||||
|
||||
// Security Notification Settings - Use handler created earlier for event intake
|
||||
protected.GET("/security/notifications/settings", securityNotificationHandler.DeprecatedGetSettings)
|
||||
protected.PUT("/security/notifications/settings", securityNotificationHandler.DeprecatedUpdateSettings)
|
||||
protected.GET("/notifications/settings/security", securityNotificationHandler.GetSettings)
|
||||
protected.PUT("/notifications/settings/security", securityNotificationHandler.UpdateSettings)
|
||||
|
||||
// System permissions diagnostics and repair
|
||||
systemPermissionsHandler := handlers.NewSystemPermissionsHandler(cfg, securityService, nil)
|
||||
protected.GET("/system/permissions", systemPermissionsHandler.GetPermissions)
|
||||
protected.POST("/system/permissions/repair", systemPermissionsHandler.RepairPermissions)
|
||||
|
||||
// Audit Logs
|
||||
auditLogHandler := handlers.NewAuditLogHandler(securityService)
|
||||
protected.GET("/audit-logs", auditLogHandler.List)
|
||||
protected.GET("/audit-logs/:uuid", auditLogHandler.Get)
|
||||
|
||||
// Settings - with CaddyManager and Cerberus for security settings reload
|
||||
settingsHandler := handlers.NewSettingsHandlerWithDeps(db, caddyManager, cerb, securityService, dataRoot)
|
||||
|
||||
protected.GET("/settings", settingsHandler.GetSettings)
|
||||
protected.POST("/settings", settingsHandler.UpdateSetting)
|
||||
protected.PATCH("/settings", settingsHandler.UpdateSetting) // E2E tests use PATCH
|
||||
protected.PATCH("/config", settingsHandler.PatchConfig) // Bulk configuration update
|
||||
|
||||
// SMTP Configuration
|
||||
protected.GET("/settings/smtp", middleware.RequireRole("admin"), settingsHandler.GetSMTPConfig)
|
||||
protected.POST("/settings/smtp", settingsHandler.UpdateSMTPConfig)
|
||||
protected.POST("/settings/smtp/test", settingsHandler.TestSMTPConfig)
|
||||
protected.POST("/settings/smtp/test-email", settingsHandler.SendTestEmail)
|
||||
|
||||
// URL Validation
|
||||
protected.POST("/settings/validate-url", settingsHandler.ValidatePublicURL)
|
||||
protected.POST("/settings/test-url", settingsHandler.TestPublicURL)
|
||||
|
||||
// Auth related protected routes
|
||||
protected.GET("/auth/accessible-hosts", authHandler.GetAccessibleHosts)
|
||||
protected.GET("/auth/check-host/:hostId", authHandler.CheckHostAccess)
|
||||
|
||||
// Feature flags (DB-backed with env fallback)
|
||||
featureFlagsHandler := handlers.NewFeatureFlagsHandler(db)
|
||||
protected.GET("/feature-flags", featureFlagsHandler.GetFlags)
|
||||
protected.PUT("/feature-flags", featureFlagsHandler.UpdateFlags)
|
||||
|
||||
// User Profile & API Key
|
||||
protected.GET("/user/profile", userHandler.GetProfile)
|
||||
protected.POST("/user/profile", userHandler.UpdateProfile)
|
||||
protected.POST("/user/api-key", userHandler.RegenerateAPIKey)
|
||||
|
||||
// Management routes — blocked for passthrough users
|
||||
management := protected.Group("/")
|
||||
management.Use(middleware.RequireManagementAccess())
|
||||
|
||||
// Backups
|
||||
management.GET("/backups", backupHandler.List)
|
||||
management.POST("/backups", backupHandler.Create)
|
||||
management.DELETE("/backups/:filename", backupHandler.Delete)
|
||||
management.GET("/backups/:filename/download", backupHandler.Download)
|
||||
management.POST("/backups/:filename/restore", backupHandler.Restore)
|
||||
|
||||
// Logs
|
||||
// WebSocket endpoints
|
||||
logsWSHandler := handlers.NewLogsWSHandler(wsTracker)
|
||||
management.GET("/logs/live", logsWSHandler.HandleWebSocket)
|
||||
management.GET("/logs", logsHandler.List)
|
||||
management.GET("/logs/:filename", logsHandler.Read)
|
||||
management.GET("/logs/:filename/download", logsHandler.Download)
|
||||
|
||||
// WebSocket status monitoring
|
||||
management.GET("/websocket/connections", wsStatusHandler.GetConnections)
|
||||
management.GET("/websocket/stats", wsStatusHandler.GetStats)
|
||||
|
||||
// Security Notification Settings - Use handler created earlier for event intake
|
||||
management.GET("/security/notifications/settings", securityNotificationHandler.DeprecatedGetSettings)
|
||||
management.PUT("/security/notifications/settings", securityNotificationHandler.DeprecatedUpdateSettings)
|
||||
management.GET("/notifications/settings/security", securityNotificationHandler.GetSettings)
|
||||
management.PUT("/notifications/settings/security", securityNotificationHandler.UpdateSettings)
|
||||
|
||||
// System permissions diagnostics and repair
|
||||
systemPermissionsHandler := handlers.NewSystemPermissionsHandler(cfg, securityService, nil)
|
||||
management.GET("/system/permissions", systemPermissionsHandler.GetPermissions)
|
||||
management.POST("/system/permissions/repair", systemPermissionsHandler.RepairPermissions)
|
||||
|
||||
// Audit Logs
|
||||
auditLogHandler := handlers.NewAuditLogHandler(securityService)
|
||||
management.GET("/audit-logs", auditLogHandler.List)
|
||||
management.GET("/audit-logs/:uuid", auditLogHandler.Get)
|
||||
|
||||
// Settings - with CaddyManager and Cerberus for security settings reload
|
||||
settingsHandler := handlers.NewSettingsHandlerWithDeps(db, caddyManager, cerb, securityService, dataRoot)
|
||||
|
||||
management.GET("/settings", settingsHandler.GetSettings)
|
||||
management.POST("/settings", settingsHandler.UpdateSetting)
|
||||
management.PATCH("/settings", settingsHandler.UpdateSetting) // E2E tests use PATCH
|
||||
management.PATCH("/config", settingsHandler.PatchConfig) // Bulk configuration update
|
||||
|
||||
// SMTP Configuration
|
||||
management.GET("/settings/smtp", middleware.RequireRole(models.RoleAdmin), settingsHandler.GetSMTPConfig)
|
||||
management.POST("/settings/smtp", settingsHandler.UpdateSMTPConfig)
|
||||
management.POST("/settings/smtp/test", settingsHandler.TestSMTPConfig)
|
||||
management.POST("/settings/smtp/test-email", settingsHandler.SendTestEmail)
|
||||
|
||||
// URL Validation
|
||||
management.POST("/settings/validate-url", settingsHandler.ValidatePublicURL)
|
||||
management.POST("/settings/test-url", settingsHandler.TestPublicURL)
|
||||
|
||||
// Feature flags (DB-backed with env fallback)
|
||||
featureFlagsHandler := handlers.NewFeatureFlagsHandler(db)
|
||||
management.GET("/feature-flags", featureFlagsHandler.GetFlags)
|
||||
management.PUT("/feature-flags", featureFlagsHandler.UpdateFlags)
|
||||
|
||||
// User Management (admin only routes are in RegisterRoutes)
|
||||
protected.GET("/users", userHandler.ListUsers)
|
||||
protected.POST("/users", userHandler.CreateUser)
|
||||
protected.POST("/users/invite", userHandler.InviteUser)
|
||||
protected.POST("/users/preview-invite-url", userHandler.PreviewInviteURL)
|
||||
protected.GET("/users/:id", userHandler.GetUser)
|
||||
protected.PUT("/users/:id", userHandler.UpdateUser)
|
||||
protected.DELETE("/users/:id", userHandler.DeleteUser)
|
||||
protected.PUT("/users/:id/permissions", userHandler.UpdateUserPermissions)
|
||||
protected.POST("/users/:id/resend-invite", userHandler.ResendInvite)
|
||||
management.GET("/users", userHandler.ListUsers)
|
||||
management.POST("/users", userHandler.CreateUser)
|
||||
management.POST("/users/invite", userHandler.InviteUser)
|
||||
management.POST("/users/preview-invite-url", userHandler.PreviewInviteURL)
|
||||
management.GET("/users/:id", userHandler.GetUser)
|
||||
management.PUT("/users/:id", userHandler.UpdateUser)
|
||||
management.DELETE("/users/:id", userHandler.DeleteUser)
|
||||
management.PUT("/users/:id/permissions", userHandler.UpdateUserPermissions)
|
||||
management.POST("/users/:id/resend-invite", userHandler.ResendInvite)
|
||||
|
||||
// Updates
|
||||
updateService := services.NewUpdateService()
|
||||
updateHandler := handlers.NewUpdateHandler(updateService)
|
||||
protected.GET("/system/updates", updateHandler.Check)
|
||||
management.GET("/system/updates", updateHandler.Check)
|
||||
|
||||
// System info
|
||||
systemHandler := handlers.NewSystemHandler()
|
||||
protected.GET("/system/my-ip", systemHandler.GetMyIP)
|
||||
management.GET("/system/my-ip", systemHandler.GetMyIP)
|
||||
|
||||
// Notifications
|
||||
notificationHandler := handlers.NewNotificationHandler(notificationService)
|
||||
protected.GET("/notifications", notificationHandler.List)
|
||||
protected.POST("/notifications/:id/read", notificationHandler.MarkAsRead)
|
||||
protected.POST("/notifications/read-all", notificationHandler.MarkAllAsRead)
|
||||
management.GET("/notifications", notificationHandler.List)
|
||||
management.POST("/notifications/:id/read", notificationHandler.MarkAsRead)
|
||||
management.POST("/notifications/read-all", notificationHandler.MarkAllAsRead)
|
||||
|
||||
// Domains
|
||||
domainHandler := handlers.NewDomainHandler(db, notificationService)
|
||||
protected.GET("/domains", domainHandler.List)
|
||||
protected.POST("/domains", domainHandler.Create)
|
||||
protected.DELETE("/domains/:id", domainHandler.Delete)
|
||||
management.GET("/domains", domainHandler.List)
|
||||
management.POST("/domains", domainHandler.Create)
|
||||
management.DELETE("/domains/:id", domainHandler.Delete)
|
||||
|
||||
// DNS Providers - only available if encryption key is configured
|
||||
if cfg.EncryptionKey != "" {
|
||||
@@ -363,33 +372,33 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
} else {
|
||||
dnsProviderService := services.NewDNSProviderService(db, encryptionService)
|
||||
dnsProviderHandler := handlers.NewDNSProviderHandler(dnsProviderService)
|
||||
protected.GET("/dns-providers", dnsProviderHandler.List)
|
||||
protected.POST("/dns-providers", dnsProviderHandler.Create)
|
||||
protected.GET("/dns-providers/types", dnsProviderHandler.GetTypes)
|
||||
protected.GET("/dns-providers/:id", dnsProviderHandler.Get)
|
||||
protected.PUT("/dns-providers/:id", dnsProviderHandler.Update)
|
||||
protected.DELETE("/dns-providers/:id", dnsProviderHandler.Delete)
|
||||
protected.POST("/dns-providers/:id/test", dnsProviderHandler.Test)
|
||||
protected.POST("/dns-providers/test", dnsProviderHandler.TestCredentials)
|
||||
management.GET("/dns-providers", dnsProviderHandler.List)
|
||||
management.POST("/dns-providers", dnsProviderHandler.Create)
|
||||
management.GET("/dns-providers/types", dnsProviderHandler.GetTypes)
|
||||
management.GET("/dns-providers/:id", dnsProviderHandler.Get)
|
||||
management.PUT("/dns-providers/:id", dnsProviderHandler.Update)
|
||||
management.DELETE("/dns-providers/:id", dnsProviderHandler.Delete)
|
||||
management.POST("/dns-providers/:id/test", dnsProviderHandler.Test)
|
||||
management.POST("/dns-providers/test", dnsProviderHandler.TestCredentials)
|
||||
// Audit logs for DNS providers
|
||||
protected.GET("/dns-providers/:id/audit-logs", auditLogHandler.ListByProvider)
|
||||
management.GET("/dns-providers/:id/audit-logs", auditLogHandler.ListByProvider)
|
||||
|
||||
// DNS Provider Auto-Detection (Phase 4)
|
||||
dnsDetectionService := services.NewDNSDetectionService(db)
|
||||
dnsDetectionHandler := handlers.NewDNSDetectionHandler(dnsDetectionService)
|
||||
protected.POST("/dns-providers/detect", dnsDetectionHandler.Detect)
|
||||
protected.GET("/dns-providers/detection-patterns", dnsDetectionHandler.GetPatterns)
|
||||
management.POST("/dns-providers/detect", dnsDetectionHandler.Detect)
|
||||
management.GET("/dns-providers/detection-patterns", dnsDetectionHandler.GetPatterns)
|
||||
|
||||
// Multi-Credential Management (Phase 3)
|
||||
credentialService := services.NewCredentialService(db, encryptionService)
|
||||
credentialHandler := handlers.NewCredentialHandler(credentialService)
|
||||
protected.GET("/dns-providers/:id/credentials", credentialHandler.List)
|
||||
protected.POST("/dns-providers/:id/credentials", credentialHandler.Create)
|
||||
protected.GET("/dns-providers/:id/credentials/:cred_id", credentialHandler.Get)
|
||||
protected.PUT("/dns-providers/:id/credentials/:cred_id", credentialHandler.Update)
|
||||
protected.DELETE("/dns-providers/:id/credentials/:cred_id", credentialHandler.Delete)
|
||||
protected.POST("/dns-providers/:id/credentials/:cred_id/test", credentialHandler.Test)
|
||||
protected.POST("/dns-providers/:id/enable-multi-credentials", credentialHandler.EnableMultiCredentials)
|
||||
management.GET("/dns-providers/:id/credentials", credentialHandler.List)
|
||||
management.POST("/dns-providers/:id/credentials", credentialHandler.Create)
|
||||
management.GET("/dns-providers/:id/credentials/:cred_id", credentialHandler.Get)
|
||||
management.PUT("/dns-providers/:id/credentials/:cred_id", credentialHandler.Update)
|
||||
management.DELETE("/dns-providers/:id/credentials/:cred_id", credentialHandler.Delete)
|
||||
management.POST("/dns-providers/:id/credentials/:cred_id/test", credentialHandler.Test)
|
||||
management.POST("/dns-providers/:id/enable-multi-credentials", credentialHandler.EnableMultiCredentials)
|
||||
|
||||
// Encryption Management - Admin only endpoints
|
||||
rotationService, rotErr := crypto.NewRotationService(db)
|
||||
@@ -397,7 +406,7 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
logger.Log().WithError(rotErr).Warn("Failed to initialize rotation service - key rotation features will be unavailable")
|
||||
} else {
|
||||
encryptionHandler := handlers.NewEncryptionHandler(rotationService, securityService)
|
||||
adminEncryption := protected.Group("/admin/encryption")
|
||||
adminEncryption := management.Group("/admin/encryption")
|
||||
adminEncryption.GET("/status", encryptionHandler.GetStatus)
|
||||
adminEncryption.POST("/rotate", encryptionHandler.Rotate)
|
||||
adminEncryption.GET("/history", encryptionHandler.GetHistory)
|
||||
@@ -411,7 +420,7 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
}
|
||||
pluginLoader := services.NewPluginLoaderService(db, pluginDir, nil)
|
||||
pluginHandler := handlers.NewPluginHandler(db, pluginLoader)
|
||||
adminPlugins := protected.Group("/admin/plugins")
|
||||
adminPlugins := management.Group("/admin/plugins")
|
||||
adminPlugins.GET("", pluginHandler.ListPlugins)
|
||||
adminPlugins.GET("/:id", pluginHandler.GetPlugin)
|
||||
adminPlugins.POST("/:id/enable", pluginHandler.EnablePlugin)
|
||||
@@ -421,7 +430,7 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
// Manual DNS Challenges (Phase 1) - For users without automated DNS API access
|
||||
manualChallengeService := services.NewManualChallengeService(db)
|
||||
manualChallengeHandler := handlers.NewManualChallengeHandler(manualChallengeService, dnsProviderService)
|
||||
manualChallengeHandler.RegisterRoutes(protected)
|
||||
manualChallengeHandler.RegisterRoutes(management)
|
||||
}
|
||||
} else {
|
||||
logger.Log().Warn("CHARON_ENCRYPTION_KEY not set - DNS provider and plugin features will be unavailable")
|
||||
@@ -431,37 +440,37 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
// The service will return proper error messages when Docker is not accessible
|
||||
dockerService := services.NewDockerService()
|
||||
dockerHandler := handlers.NewDockerHandler(dockerService, remoteServerService)
|
||||
dockerHandler.RegisterRoutes(protected)
|
||||
dockerHandler.RegisterRoutes(management)
|
||||
|
||||
// Uptime Service — reuse the single uptimeService instance (defined above)
|
||||
// to share in-memory state (mutexes, notification batching) between
|
||||
// background checker, ProxyHostHandler, and API handlers.
|
||||
uptimeHandler := handlers.NewUptimeHandler(uptimeService)
|
||||
protected.GET("/uptime/monitors", uptimeHandler.List)
|
||||
protected.POST("/uptime/monitors", uptimeHandler.Create)
|
||||
protected.GET("/uptime/monitors/:id/history", uptimeHandler.GetHistory)
|
||||
protected.PUT("/uptime/monitors/:id", uptimeHandler.Update)
|
||||
protected.DELETE("/uptime/monitors/:id", uptimeHandler.Delete)
|
||||
protected.POST("/uptime/monitors/:id/check", uptimeHandler.CheckMonitor)
|
||||
protected.POST("/uptime/sync", uptimeHandler.Sync)
|
||||
management.GET("/uptime/monitors", uptimeHandler.List)
|
||||
management.POST("/uptime/monitors", uptimeHandler.Create)
|
||||
management.GET("/uptime/monitors/:id/history", uptimeHandler.GetHistory)
|
||||
management.PUT("/uptime/monitors/:id", uptimeHandler.Update)
|
||||
management.DELETE("/uptime/monitors/:id", uptimeHandler.Delete)
|
||||
management.POST("/uptime/monitors/:id/check", uptimeHandler.CheckMonitor)
|
||||
management.POST("/uptime/sync", uptimeHandler.Sync)
|
||||
|
||||
// Notification Providers
|
||||
notificationProviderHandler := handlers.NewNotificationProviderHandlerWithDeps(notificationService, securityService, dataRoot)
|
||||
protected.GET("/notifications/providers", notificationProviderHandler.List)
|
||||
protected.POST("/notifications/providers", notificationProviderHandler.Create)
|
||||
protected.PUT("/notifications/providers/:id", notificationProviderHandler.Update)
|
||||
protected.DELETE("/notifications/providers/:id", notificationProviderHandler.Delete)
|
||||
protected.POST("/notifications/providers/test", notificationProviderHandler.Test)
|
||||
protected.POST("/notifications/providers/preview", notificationProviderHandler.Preview)
|
||||
protected.GET("/notifications/templates", notificationProviderHandler.Templates)
|
||||
management.GET("/notifications/providers", notificationProviderHandler.List)
|
||||
management.POST("/notifications/providers", notificationProviderHandler.Create)
|
||||
management.PUT("/notifications/providers/:id", notificationProviderHandler.Update)
|
||||
management.DELETE("/notifications/providers/:id", notificationProviderHandler.Delete)
|
||||
management.POST("/notifications/providers/test", notificationProviderHandler.Test)
|
||||
management.POST("/notifications/providers/preview", notificationProviderHandler.Preview)
|
||||
management.GET("/notifications/templates", notificationProviderHandler.Templates)
|
||||
|
||||
// External notification templates (saved templates for providers)
|
||||
notificationTemplateHandler := handlers.NewNotificationTemplateHandlerWithDeps(notificationService, securityService, dataRoot)
|
||||
protected.GET("/notifications/external-templates", notificationTemplateHandler.List)
|
||||
protected.POST("/notifications/external-templates", notificationTemplateHandler.Create)
|
||||
protected.PUT("/notifications/external-templates/:id", notificationTemplateHandler.Update)
|
||||
protected.DELETE("/notifications/external-templates/:id", notificationTemplateHandler.Delete)
|
||||
protected.POST("/notifications/external-templates/preview", notificationTemplateHandler.Preview)
|
||||
management.GET("/notifications/external-templates", notificationTemplateHandler.List)
|
||||
management.POST("/notifications/external-templates", notificationTemplateHandler.Create)
|
||||
management.PUT("/notifications/external-templates/:id", notificationTemplateHandler.Update)
|
||||
management.DELETE("/notifications/external-templates/:id", notificationTemplateHandler.Delete)
|
||||
management.POST("/notifications/external-templates/preview", notificationTemplateHandler.Preview)
|
||||
|
||||
// Ensure uptime feature flag exists to avoid record-not-found logs
|
||||
defaultUptime := models.Setting{Key: "feature.uptime.enabled", Value: "true", Type: "bool", Category: "feature"}
|
||||
@@ -510,7 +519,7 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
}
|
||||
}()
|
||||
|
||||
protected.POST("/system/uptime/check", func(c *gin.Context) {
|
||||
management.POST("/system/uptime/check", func(c *gin.Context) {
|
||||
go uptimeService.CheckAll()
|
||||
c.JSON(200, gin.H{"message": "Uptime check started"})
|
||||
})
|
||||
@@ -542,19 +551,19 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
securityHandler.SetGeoIPService(geoipSvc)
|
||||
}
|
||||
|
||||
protected.GET("/security/status", securityHandler.GetStatus)
|
||||
management.GET("/security/status", securityHandler.GetStatus)
|
||||
// Security Config management
|
||||
protected.GET("/security/config", securityHandler.GetConfig)
|
||||
protected.GET("/security/decisions", securityHandler.ListDecisions)
|
||||
protected.GET("/security/rulesets", securityHandler.ListRuleSets)
|
||||
protected.GET("/security/rate-limit/presets", securityHandler.GetRateLimitPresets)
|
||||
management.GET("/security/config", securityHandler.GetConfig)
|
||||
management.GET("/security/decisions", securityHandler.ListDecisions)
|
||||
management.GET("/security/rulesets", securityHandler.ListRuleSets)
|
||||
management.GET("/security/rate-limit/presets", securityHandler.GetRateLimitPresets)
|
||||
// GeoIP endpoints
|
||||
protected.GET("/security/geoip/status", securityHandler.GetGeoIPStatus)
|
||||
management.GET("/security/geoip/status", securityHandler.GetGeoIPStatus)
|
||||
// WAF exclusion endpoints
|
||||
protected.GET("/security/waf/exclusions", securityHandler.GetWAFExclusions)
|
||||
management.GET("/security/waf/exclusions", securityHandler.GetWAFExclusions)
|
||||
|
||||
securityAdmin := protected.Group("/security")
|
||||
securityAdmin.Use(middleware.RequireRole("admin"))
|
||||
securityAdmin := management.Group("/security")
|
||||
securityAdmin.Use(middleware.RequireRole(models.RoleAdmin))
|
||||
securityAdmin.POST("/config", securityHandler.UpdateConfig)
|
||||
securityAdmin.POST("/enable", securityHandler.Enable)
|
||||
securityAdmin.POST("/disable", securityHandler.Disable)
|
||||
@@ -595,7 +604,7 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
|
||||
crowdsecExec := handlers.NewDefaultCrowdsecExecutor()
|
||||
crowdsecHandler := handlers.NewCrowdsecHandler(db, crowdsecExec, crowdsecBinPath, crowdsecDataDir)
|
||||
crowdsecHandler.RegisterRoutes(protected)
|
||||
crowdsecHandler.RegisterRoutes(management)
|
||||
|
||||
// NOTE: CrowdSec reconciliation now happens in main.go BEFORE HTTP server starts
|
||||
// This ensures proper initialization order and prevents race conditions
|
||||
@@ -626,24 +635,24 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
logger.Log().WithError(err).Error("Failed to start security log watcher")
|
||||
}
|
||||
cerberusLogsHandler := handlers.NewCerberusLogsHandler(logWatcher, wsTracker)
|
||||
protected.GET("/cerberus/logs/ws", cerberusLogsHandler.LiveLogs)
|
||||
management.GET("/cerberus/logs/ws", cerberusLogsHandler.LiveLogs)
|
||||
|
||||
// Access Lists
|
||||
accessListHandler := handlers.NewAccessListHandler(db)
|
||||
if geoipSvc != nil {
|
||||
accessListHandler.SetGeoIPService(geoipSvc)
|
||||
}
|
||||
protected.GET("/access-lists/templates", accessListHandler.GetTemplates)
|
||||
protected.GET("/access-lists", accessListHandler.List)
|
||||
protected.POST("/access-lists", accessListHandler.Create)
|
||||
protected.GET("/access-lists/:id", accessListHandler.Get)
|
||||
protected.PUT("/access-lists/:id", accessListHandler.Update)
|
||||
protected.DELETE("/access-lists/:id", accessListHandler.Delete)
|
||||
protected.POST("/access-lists/:id/test", accessListHandler.TestIP)
|
||||
management.GET("/access-lists/templates", accessListHandler.GetTemplates)
|
||||
management.GET("/access-lists", accessListHandler.List)
|
||||
management.POST("/access-lists", accessListHandler.Create)
|
||||
management.GET("/access-lists/:id", accessListHandler.Get)
|
||||
management.PUT("/access-lists/:id", accessListHandler.Update)
|
||||
management.DELETE("/access-lists/:id", accessListHandler.Delete)
|
||||
management.POST("/access-lists/:id/test", accessListHandler.TestIP)
|
||||
|
||||
// Security Headers
|
||||
securityHeadersHandler := handlers.NewSecurityHeadersHandler(db, caddyManager)
|
||||
securityHeadersHandler.RegisterRoutes(protected)
|
||||
securityHeadersHandler.RegisterRoutes(management)
|
||||
|
||||
// Certificate routes
|
||||
// Use cfg.CaddyConfigDir + "/data" for cert service so we scan the actual Caddy storage
|
||||
@@ -652,19 +661,20 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
logger.Log().WithField("caddy_data_dir", caddyDataDir).Info("Using Caddy data directory for certificates scan")
|
||||
certService := services.NewCertificateService(caddyDataDir, db)
|
||||
certHandler := handlers.NewCertificateHandler(certService, backupService, notificationService)
|
||||
protected.GET("/certificates", certHandler.List)
|
||||
protected.POST("/certificates", certHandler.Upload)
|
||||
protected.DELETE("/certificates/:id", certHandler.Delete)
|
||||
management.GET("/certificates", certHandler.List)
|
||||
management.POST("/certificates", certHandler.Upload)
|
||||
management.DELETE("/certificates/:id", certHandler.Delete)
|
||||
|
||||
// Proxy Hosts & Remote Servers
|
||||
proxyHostHandler := handlers.NewProxyHostHandler(db, caddyManager, notificationService, uptimeService)
|
||||
proxyHostHandler.RegisterRoutes(management)
|
||||
|
||||
remoteServerHandler := handlers.NewRemoteServerHandler(remoteServerService, notificationService)
|
||||
remoteServerHandler.RegisterRoutes(management)
|
||||
}
|
||||
|
||||
// Caddy Manager already created above
|
||||
|
||||
proxyHostHandler := handlers.NewProxyHostHandler(db, caddyManager, notificationService, uptimeService)
|
||||
proxyHostHandler.RegisterRoutes(protected)
|
||||
|
||||
remoteServerHandler := handlers.NewRemoteServerHandler(remoteServerService, notificationService)
|
||||
remoteServerHandler.RegisterRoutes(protected)
|
||||
|
||||
// Initial Caddy Config Sync
|
||||
go func() {
|
||||
// Wait for Caddy to be ready (max 30 seconds)
|
||||
@@ -708,7 +718,7 @@ func RegisterImportHandler(router *gin.Engine, db *gorm.DB, cfg config.Config, c
|
||||
api := router.Group("/api/v1")
|
||||
authService := services.NewAuthService(db, cfg)
|
||||
authenticatedAdmin := api.Group("/")
|
||||
authenticatedAdmin.Use(middleware.AuthMiddleware(authService), middleware.RequireRole("admin"))
|
||||
authenticatedAdmin.Use(middleware.AuthMiddleware(authService), middleware.RequireRole(models.RoleAdmin))
|
||||
importHandler.RegisterRoutes(authenticatedAdmin)
|
||||
|
||||
// NPM Import Handler - supports Nginx Proxy Manager export format
|
||||
|
||||
@@ -73,7 +73,7 @@ func TestRegisterImportHandler_AuthzGuards(t *testing.T) {
|
||||
router.ServeHTTP(unauthW, unauthReq)
|
||||
assert.Equal(t, http.StatusUnauthorized, unauthW.Code)
|
||||
|
||||
nonAdmin := &models.User{Email: "user@example.com", Role: "user", Enabled: true}
|
||||
nonAdmin := &models.User{Email: "user@example.com", Role: models.RoleUser, Enabled: true}
|
||||
require.NoError(t, db.Create(nonAdmin).Error)
|
||||
authSvc := services.NewAuthService(db, cfg)
|
||||
token, err := authSvc.GenerateToken(nonAdmin)
|
||||
|
||||
@@ -10,7 +10,9 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
@@ -1298,3 +1300,25 @@ func TestRegister_CreatesAccessLogFileForLogWatcher(t *testing.T) {
|
||||
_, statErr := os.Stat(logFilePath)
|
||||
assert.NoError(t, statErr)
|
||||
}
|
||||
|
||||
func TestMigrateViewerToPassthrough(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.User{}))
|
||||
|
||||
// Seed a user with the legacy "viewer" role
|
||||
viewer := models.User{
|
||||
UUID: uuid.NewString(),
|
||||
APIKey: uuid.NewString(),
|
||||
Email: "viewer@example.com",
|
||||
Role: models.UserRole("viewer"),
|
||||
Enabled: true,
|
||||
}
|
||||
require.NoError(t, db.Create(&viewer).Error)
|
||||
|
||||
migrateViewerToPassthrough(db)
|
||||
|
||||
var updated models.User
|
||||
require.NoError(t, db.First(&updated, viewer.ID).Error)
|
||||
assert.Equal(t, models.RolePassthrough, updated.Role)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package tests
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -45,7 +46,7 @@ func createTestAdminUser(t *testing.T, db *gorm.DB) uint {
|
||||
UUID: "admin-uuid-1234",
|
||||
Email: "admin@test.com",
|
||||
Name: "Test Admin",
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
Enabled: true,
|
||||
APIKey: "test-api-key",
|
||||
}
|
||||
@@ -66,7 +67,7 @@ func setupRouterWithAuth(db *gorm.DB, userID uint, role string) *gin.Engine {
|
||||
c.Next()
|
||||
})
|
||||
|
||||
userHandler := handlers.NewUserHandler(db)
|
||||
userHandler := handlers.NewUserHandler(db, nil)
|
||||
settingsHandler := handlers.NewSettingsHandler(db)
|
||||
|
||||
api := r.Group("/api")
|
||||
@@ -124,7 +125,7 @@ func TestInviteToken_ExpiredCannotBeUsed(t *testing.T) {
|
||||
user := models.User{
|
||||
UUID: "invite-uuid-1234",
|
||||
Email: "expired@test.com",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: false,
|
||||
InviteToken: "expired-token-12345678901234567890123456789012",
|
||||
InviteExpires: &expiredTime,
|
||||
@@ -153,7 +154,7 @@ func TestInviteToken_CannotBeReused(t *testing.T) {
|
||||
UUID: "accepted-uuid-1234",
|
||||
Email: "accepted@test.com",
|
||||
Name: "Accepted User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
InviteToken: "accepted-token-1234567890123456789012345678901",
|
||||
InvitedAt: &invitedAt,
|
||||
@@ -217,7 +218,7 @@ func TestAcceptInvite_PasswordValidation(t *testing.T) {
|
||||
user := models.User{
|
||||
UUID: "pending-uuid-1234",
|
||||
Email: "pending@test.com",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: false,
|
||||
InviteToken: "valid-token-12345678901234567890123456789012345",
|
||||
InviteExpires: &expires,
|
||||
@@ -269,15 +270,29 @@ func TestUserEndpoints_RequireAdmin(t *testing.T) {
|
||||
UUID: "user-uuid-1234",
|
||||
Email: "user@test.com",
|
||||
Name: "Regular User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
APIKey: "user-api-key-unique",
|
||||
}
|
||||
require.NoError(t, user.SetPassword("userpassword123"))
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
|
||||
// Create a second user to test admin-only operations against a non-self target
|
||||
otherUser := models.User{
|
||||
UUID: "other-uuid-5678",
|
||||
Email: "other@test.com",
|
||||
Name: "Other User",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
APIKey: "other-api-key-unique",
|
||||
}
|
||||
require.NoError(t, otherUser.SetPassword("otherpassword123"))
|
||||
require.NoError(t, db.Create(&otherUser).Error)
|
||||
|
||||
// Router with regular user role
|
||||
r := setupRouterWithAuth(db, user.ID, "user")
|
||||
|
||||
otherID := fmt.Sprintf("%d", otherUser.ID)
|
||||
endpoints := []struct {
|
||||
method string
|
||||
path string
|
||||
@@ -286,10 +301,10 @@ func TestUserEndpoints_RequireAdmin(t *testing.T) {
|
||||
{"GET", "/api/users", ""},
|
||||
{"POST", "/api/users", `{"email":"new@test.com","name":"New","password":"password123"}`},
|
||||
{"POST", "/api/users/invite", `{"email":"invite@test.com"}`},
|
||||
{"GET", "/api/users/1", ""},
|
||||
{"PUT", "/api/users/1", `{"name":"Updated"}`},
|
||||
{"DELETE", "/api/users/1", ""},
|
||||
{"PUT", "/api/users/1/permissions", `{"permission_mode":"deny_all"}`},
|
||||
{"GET", "/api/users/" + otherID, ""},
|
||||
{"PUT", "/api/users/" + otherID, `{"name":"Updated"}`},
|
||||
{"DELETE", "/api/users/" + otherID, ""},
|
||||
{"PUT", "/api/users/" + otherID + "/permissions", `{"permission_mode":"deny_all"}`},
|
||||
}
|
||||
|
||||
for _, ep := range endpoints {
|
||||
@@ -316,7 +331,7 @@ func TestSMTPEndpoints_RequireAdmin(t *testing.T) {
|
||||
UUID: "user-uuid-5678",
|
||||
Email: "user2@test.com",
|
||||
Name: "Regular User 2",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
}
|
||||
require.NoError(t, user.SetPassword("userpassword123"))
|
||||
@@ -462,7 +477,7 @@ func TestInviteUser_DuplicateEmailBlocked(t *testing.T) {
|
||||
UUID: "existing-uuid-1234",
|
||||
Email: "existing@test.com",
|
||||
Name: "Existing User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
}
|
||||
require.NoError(t, db.Create(&existing).Error)
|
||||
@@ -488,7 +503,7 @@ func TestInviteUser_EmailCaseInsensitive(t *testing.T) {
|
||||
UUID: "existing-uuid-5678",
|
||||
Email: "test@example.com",
|
||||
Name: "Existing User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
}
|
||||
require.NoError(t, db.Create(&existing).Error)
|
||||
@@ -532,7 +547,7 @@ func TestUpdatePermissions_ValidModes(t *testing.T) {
|
||||
UUID: "perms-user-1234",
|
||||
Email: "permsuser@test.com",
|
||||
Name: "Perms User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
@@ -574,7 +589,7 @@ func TestPublicEndpoints_NoAuthRequired(t *testing.T) {
|
||||
// Router WITHOUT auth middleware
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
userHandler := handlers.NewUserHandler(db)
|
||||
userHandler := handlers.NewUserHandler(db, nil)
|
||||
api := r.Group("/api")
|
||||
userHandler.RegisterRoutes(api)
|
||||
|
||||
@@ -584,7 +599,7 @@ func TestPublicEndpoints_NoAuthRequired(t *testing.T) {
|
||||
user := models.User{
|
||||
UUID: "public-test-uuid",
|
||||
Email: "public@test.com",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: false,
|
||||
InviteToken: "public-test-token-123456789012345678901234567",
|
||||
InviteExpires: &expires,
|
||||
|
||||
@@ -272,7 +272,7 @@ func (c *Cerberus) isAuthenticatedAdmin(ctx *gin.Context) bool {
|
||||
return false
|
||||
}
|
||||
roleStr, ok := role.(string)
|
||||
if !ok || roleStr != "admin" {
|
||||
if !ok || roleStr != string(models.RoleAdmin) {
|
||||
return false
|
||||
}
|
||||
userID, exists := ctx.Get("userID")
|
||||
|
||||
@@ -7,6 +7,27 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// UserRole represents an authenticated user's privilege tier.
|
||||
type UserRole string
|
||||
|
||||
const (
|
||||
// RoleAdmin has full access to all Charon features and management.
|
||||
RoleAdmin UserRole = "admin"
|
||||
// RoleUser can access the Charon management UI with restricted permissions.
|
||||
RoleUser UserRole = "user"
|
||||
// RolePassthrough can only authenticate for forward-auth proxy access.
|
||||
RolePassthrough UserRole = "passthrough"
|
||||
)
|
||||
|
||||
// IsValid returns true when the role is one of the recognised privilege tiers.
|
||||
func (r UserRole) IsValid() bool {
|
||||
switch r {
|
||||
case RoleAdmin, RoleUser, RolePassthrough:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PermissionMode determines how user access to proxy hosts is evaluated.
|
||||
type PermissionMode string
|
||||
|
||||
@@ -26,7 +47,7 @@ type User struct {
|
||||
APIKey string `json:"-" gorm:"uniqueIndex"` // For external API access, never exposed in JSON
|
||||
PasswordHash string `json:"-"` // Never serialize password hash
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role" gorm:"default:'user'"` // "admin", "user", "viewer"
|
||||
Role UserRole `json:"role" gorm:"default:'user'"`
|
||||
Enabled bool `json:"enabled" gorm:"default:true"`
|
||||
FailedLoginAttempts int `json:"-" gorm:"default:0"`
|
||||
LockedUntil *time.Time `json:"-"`
|
||||
@@ -77,7 +98,7 @@ func (u *User) HasPendingInvite() bool {
|
||||
// - deny_all mode: User can ONLY access hosts in PermittedHosts (whitelist)
|
||||
func (u *User) CanAccessHost(hostID uint) bool {
|
||||
// Admins always have access
|
||||
if u.Role == "admin" {
|
||||
if u.Role == RoleAdmin {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ func TestUser_HasPendingInvite(t *testing.T) {
|
||||
func TestUser_CanAccessHost_AllowAll(t *testing.T) {
|
||||
// User with allow_all mode (blacklist) - can access everything except listed hosts
|
||||
user := User{
|
||||
Role: "user",
|
||||
Role: RoleUser,
|
||||
PermissionMode: PermissionModeAllowAll,
|
||||
PermittedHosts: []ProxyHost{
|
||||
{ID: 1}, // Blocked host
|
||||
@@ -107,7 +107,7 @@ func TestUser_CanAccessHost_AllowAll(t *testing.T) {
|
||||
func TestUser_CanAccessHost_DenyAll(t *testing.T) {
|
||||
// User with deny_all mode (whitelist) - can only access listed hosts
|
||||
user := User{
|
||||
Role: "user",
|
||||
Role: RoleUser,
|
||||
PermissionMode: PermissionModeDenyAll,
|
||||
PermittedHosts: []ProxyHost{
|
||||
{ID: 5}, // Allowed host
|
||||
@@ -127,7 +127,7 @@ func TestUser_CanAccessHost_DenyAll(t *testing.T) {
|
||||
func TestUser_CanAccessHost_AdminBypass(t *testing.T) {
|
||||
// Admin users should always have access regardless of permission mode
|
||||
adminUser := User{
|
||||
Role: "admin",
|
||||
Role: RoleAdmin,
|
||||
PermissionMode: PermissionModeDenyAll,
|
||||
PermittedHosts: []ProxyHost{}, // No hosts in whitelist
|
||||
}
|
||||
@@ -140,7 +140,7 @@ func TestUser_CanAccessHost_AdminBypass(t *testing.T) {
|
||||
func TestUser_CanAccessHost_DefaultBehavior(t *testing.T) {
|
||||
// User with empty/default permission mode should behave like allow_all
|
||||
user := User{
|
||||
Role: "user",
|
||||
Role: RoleUser,
|
||||
PermissionMode: "", // Empty = default
|
||||
PermittedHosts: []ProxyHost{
|
||||
{ID: 1}, // Should be blocked
|
||||
@@ -175,7 +175,7 @@ func TestUser_CanAccessHost_EmptyPermittedHosts(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
user := User{
|
||||
Role: "user",
|
||||
Role: RoleUser,
|
||||
PermissionMode: tt.permissionMode,
|
||||
PermittedHosts: []ProxyHost{},
|
||||
}
|
||||
@@ -190,6 +190,31 @@ func TestPermissionMode_Constants(t *testing.T) {
|
||||
assert.Equal(t, PermissionMode("deny_all"), PermissionModeDenyAll)
|
||||
}
|
||||
|
||||
func TestUserRole_Constants(t *testing.T) {
|
||||
assert.Equal(t, UserRole("admin"), RoleAdmin)
|
||||
assert.Equal(t, UserRole("user"), RoleUser)
|
||||
assert.Equal(t, UserRole("passthrough"), RolePassthrough)
|
||||
}
|
||||
|
||||
func TestUserRole_IsValid(t *testing.T) {
|
||||
tests := []struct {
|
||||
role UserRole
|
||||
expected bool
|
||||
}{
|
||||
{RoleAdmin, true},
|
||||
{RoleUser, true},
|
||||
{RolePassthrough, true},
|
||||
{UserRole("viewer"), false},
|
||||
{UserRole("superadmin"), false},
|
||||
{UserRole(""), false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.role), func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, tt.role.IsValid())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to create time pointers
|
||||
func timePtr(t time.Time) *time.Time {
|
||||
return &t
|
||||
|
||||
@@ -33,9 +33,9 @@ func (s *AuthService) Register(email, password, name string) (*models.User, erro
|
||||
var count int64
|
||||
s.db.Model(&models.User{}).Count(&count)
|
||||
|
||||
role := "user"
|
||||
role := models.RoleUser
|
||||
if count == 0 {
|
||||
role = "admin" // First user is admin
|
||||
role = models.RoleAdmin
|
||||
}
|
||||
|
||||
user := &models.User{
|
||||
@@ -98,7 +98,7 @@ func (s *AuthService) GenerateToken(user *models.User) (string, error) {
|
||||
expirationTime := time.Now().Add(24 * time.Hour)
|
||||
claims := &Claims{
|
||||
UserID: user.ID,
|
||||
Role: user.Role,
|
||||
Role: string(user.Role),
|
||||
SessionVersion: user.SessionVersion,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
||||
|
||||
@@ -30,14 +30,14 @@ func TestAuthService_Register(t *testing.T) {
|
||||
// Test 1: First user should be admin
|
||||
admin, err := service.Register("admin@example.com", "password123", "Admin User")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "admin", admin.Role)
|
||||
assert.Equal(t, models.RoleAdmin, admin.Role)
|
||||
assert.NotEmpty(t, admin.PasswordHash)
|
||||
assert.NotEqual(t, "password123", admin.PasswordHash)
|
||||
|
||||
// Test 2: Second user should be regular user
|
||||
user, err := service.Register("user@example.com", "password123", "Regular User")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "user", user.Role)
|
||||
assert.Equal(t, models.RoleUser, user.Role)
|
||||
}
|
||||
|
||||
func TestAuthService_Login(t *testing.T) {
|
||||
@@ -300,7 +300,7 @@ func TestAuthService_AuthenticateToken_InvalidUserIDInClaims(t *testing.T) {
|
||||
|
||||
claims := Claims{
|
||||
UserID: user.ID + 9999,
|
||||
Role: "user",
|
||||
Role: string(models.RoleUser),
|
||||
SessionVersion: user.SessionVersion,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
||||
|
||||
@@ -45,7 +45,7 @@ func TestBackupService_RehydrateLiveDatabase(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "restore-user@example.com",
|
||||
Name: "Restore User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
APIKey: uuid.NewString(),
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func TestBackupService_RehydrateLiveDatabase_FromBackupWithWAL(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "restore-from-wal@example.com",
|
||||
Name: "Restore From WAL",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
APIKey: uuid.NewString(),
|
||||
}
|
||||
|
||||
362
docs/plans/archive/uptime_regression_spec.md
Normal file
362
docs/plans/archive/uptime_regression_spec.md
Normal file
@@ -0,0 +1,362 @@
|
||||
# Uptime Monitoring Regression Investigation (Scheduled vs Manual)
|
||||
|
||||
Date: 2026-03-01
|
||||
Owner: Planning Agent
|
||||
Status: Investigation Complete, Fix Plan Proposed
|
||||
Severity: High (false DOWN states on automated monitoring)
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
Two services (Wizarr and Charon) can flip to `DOWN` during scheduled cycles while manual checks immediately return `UP` because scheduled checks use a host-level TCP gate that can short-circuit monitor-level HTTP checks.
|
||||
|
||||
The scheduled path is:
|
||||
- `ticker -> CheckAll -> checkAllHosts -> (host status down) -> markHostMonitorsDown`
|
||||
|
||||
The manual path is:
|
||||
- `POST /api/v1/uptime/monitors/:id/check -> CheckMonitor -> checkMonitor`
|
||||
|
||||
Only the scheduled path runs host precheck gating. If host precheck fails (TCP to upstream host/port), `CheckAll` skips HTTP checks and forcibly writes monitor status to `down` with heartbeat message `Host unreachable`.
|
||||
|
||||
This is a backend state mutation problem (not only UI rendering).
|
||||
|
||||
## 1.1 Monitoring Policy (Authoritative Behavior)
|
||||
|
||||
Charon uptime monitoring SHALL follow URL-truth semantics for HTTP/HTTPS monitors,
|
||||
matching third-party external monitor behavior (Uptime Kuma style) without requiring
|
||||
any additional service.
|
||||
|
||||
Policy:
|
||||
- HTTP/HTTPS monitors are URL-truth based. The monitor result is authoritative based
|
||||
on the configured URL check outcome (status code/timeout/TLS/connectivity from URL
|
||||
perspective).
|
||||
- Internal TCP reachability precheck (`ForwardHost:ForwardPort`) is
|
||||
non-authoritative for HTTP/HTTPS monitor status.
|
||||
- TCP monitors remain endpoint-socket checks and may rely on direct socket
|
||||
reachability semantics.
|
||||
- Host precheck may still be used for optimization, grouping telemetry, and operator
|
||||
diagnostics, but SHALL NOT force HTTP/HTTPS monitors to DOWN.
|
||||
|
||||
## 2. Research Findings
|
||||
|
||||
### 2.1 Execution Path Comparison (Required)
|
||||
|
||||
### Scheduled path behavior
|
||||
- Entry: `backend/internal/api/routes/routes.go` (background ticker, calls `uptimeService.CheckAll()`)
|
||||
- `CheckAll()` calls `checkAllHosts()` first.
|
||||
- File: `backend/internal/services/uptime_service.go:354`
|
||||
- `checkAllHosts()` updates each `UptimeHost.Status` via TCP checks in `checkHost()`.
|
||||
- File: `backend/internal/services/uptime_service.go:395`
|
||||
- `checkHost()` dials `UptimeHost.Host` + monitor port (prefer `ProxyHost.ForwardPort`, fallback to URL port).
|
||||
- File: `backend/internal/services/uptime_service.go:437`
|
||||
- Back in `CheckAll()`, monitors are grouped by `UptimeHostID`.
|
||||
- File: `backend/internal/services/uptime_service.go:367`
|
||||
- If `UptimeHost.Status == "down"`, `markHostMonitorsDown()` is called and individual monitor checks are skipped.
|
||||
- File: `backend/internal/services/uptime_service.go:381`
|
||||
- File: `backend/internal/services/uptime_service.go:593`
|
||||
|
||||
### Manual path behavior
|
||||
- Entry: `POST /api/v1/uptime/monitors/:id/check`.
|
||||
- Handler: `backend/internal/api/handlers/uptime_handler.go:107`
|
||||
- Calls `service.CheckMonitor(*monitor)` asynchronously.
|
||||
- File: `backend/internal/services/uptime_service.go:707`
|
||||
- `checkMonitor()` performs direct HTTP/TCP monitor check and updates monitor + heartbeat.
|
||||
- File: `backend/internal/services/uptime_service.go:711`
|
||||
|
||||
### Key divergence
|
||||
- Scheduled: host-gated (precheck can override monitor)
|
||||
- Manual: direct monitor check (no host gate)
|
||||
|
||||
## 3. Root Cause With Evidence
|
||||
|
||||
## 3.1 Primary Root Cause: Host Precheck Overrides HTTP Success in Scheduled Cycles
|
||||
|
||||
When `UptimeHost` is marked `down`, scheduled checks do not run `checkMonitor()` for that host's monitors. Instead they call `markHostMonitorsDown()` which:
|
||||
- sets each monitor `Status = "down"`
|
||||
- writes `UptimeHeartbeat{Status: "down", Message: "Host unreachable"}`
|
||||
- maxes failure count (`FailureCount = MaxRetries`)
|
||||
|
||||
Evidence:
|
||||
- Short-circuit: `backend/internal/services/uptime_service.go:381`
|
||||
- Forced down write: `backend/internal/services/uptime_service.go:610`
|
||||
- Forced heartbeat message: `backend/internal/services/uptime_service.go:624`
|
||||
|
||||
This exactly matches symptom pattern:
|
||||
1. Manual refresh sets monitor `UP` via direct HTTP check.
|
||||
2. Next scheduler cycle can force it back to `DOWN` from host precheck path.
|
||||
|
||||
## 3.2 Hypothesis Check: TCP precheck can fail while public URL HTTP check succeeds
|
||||
|
||||
Confirmed as plausible by design:
|
||||
- `checkHost()` tests upstream reachability (`ForwardHost:ForwardPort`) from Charon runtime.
|
||||
- `checkMonitor()` tests monitor URL (public domain URL, often via Caddy/public routing).
|
||||
|
||||
A service can be publicly reachable by monitor URL while upstream TCP precheck fails due to network namespace/routing/DNS/hairpin differences.
|
||||
|
||||
This is especially likely for:
|
||||
- self-referential routes (Charon monitoring Charon via public hostname)
|
||||
- host/container networking asymmetry
|
||||
- services reachable through proxy path but not directly on upstream socket from current runtime context
|
||||
|
||||
## 3.3 Recent Change Correlation (Required)
|
||||
|
||||
### `SyncAndCheckForHost` (regression amplifier)
|
||||
- Introduced in commit `2cd19d89` and called from proxy host create path.
|
||||
- Files:
|
||||
- `backend/internal/services/uptime_service.go:1195`
|
||||
- `backend/internal/api/handlers/proxy_host_handler.go:418`
|
||||
- Behavior: creates/syncs monitor and immediately runs `checkMonitor()`.
|
||||
|
||||
Impact: makes monitors quickly show `UP` after create/manual, then scheduler can flip to `DOWN` if host precheck fails. This increased visibility of scheduled/manual inconsistency.
|
||||
|
||||
### `CleanupStaleFailureCounts`
|
||||
- Introduced in `2cd19d89`, refined in `7a12ab79`.
|
||||
- File: `backend/internal/services/uptime_service.go:1277`
|
||||
- It runs at startup and resets stale monitor states only; not per-cycle override logic.
|
||||
- Not root cause of recurring per-cycle flip.
|
||||
|
||||
### Frontend effective status changes
|
||||
- Latest commit `0241de69` refactors `effectiveStatus` handling.
|
||||
- File: `frontend/src/pages/Uptime.tsx`.
|
||||
- Backend evidence proves this is not visual-only: scheduler writes `down` heartbeats/messages directly in DB.
|
||||
|
||||
## 3.4 Grouping Logic Analysis (`UptimeHost`/`UpstreamHost`)
|
||||
|
||||
Monitors are grouped by `UptimeHostID` in `CheckAll()`. `UptimeHost` is derived from `ProxyHost.ForwardHost` in sync flows.
|
||||
|
||||
Relevant code:
|
||||
- group map by `UptimeHostID`: `backend/internal/services/uptime_service.go:367`
|
||||
- host linkage in sync: `backend/internal/services/uptime_service.go:189`, `backend/internal/services/uptime_service.go:226`
|
||||
- sync single-host update path: `backend/internal/services/uptime_service.go:1023`
|
||||
|
||||
Risk: one host precheck failure can mark all grouped monitors down without URL-level validation.
|
||||
|
||||
## 4. Technical Specification (Fix Plan)
|
||||
|
||||
## 4.1 Minimal Proper Fix (First)
|
||||
|
||||
Goal: eliminate false DOWN while preserving existing behavior as much as possible.
|
||||
|
||||
Change `CheckAll()` host-down branch to avoid hard override for HTTP/HTTPS monitors.
|
||||
|
||||
Mandatory hotfix rule:
|
||||
- WHEN a host precheck is `down`, THE SYSTEM SHALL partition host monitors by type inside `CheckAll()`.
|
||||
- `markHostMonitorsDown` MUST be invoked only for `tcp` monitors.
|
||||
- `http`/`https` monitors MUST still run through `checkMonitor()` and MUST NOT be force-written `down` by the host precheck path.
|
||||
- Host precheck outcomes MAY be recorded for optimization/telemetry/grouping, but MUST NOT be treated as final status for `http`/`https` monitors.
|
||||
|
||||
Proposed rule:
|
||||
1. If host is down:
|
||||
- For `http`/`https` monitors: still run `checkMonitor()` (do not force down).
|
||||
- For `tcp` monitors: keep current host-down fast-path (`markHostMonitorsDown`) or direct tcp check.
|
||||
2. If host is not down:
|
||||
- Keep existing behavior (run `checkMonitor()` for all monitors).
|
||||
|
||||
Rationale:
|
||||
- Aligns scheduled behavior with manual for URL-based monitors.
|
||||
- Preserves reverse proxy product semantics where public URL availability is the source of truth.
|
||||
- Minimal code delta in `CheckAll()` decision branch.
|
||||
- Preserves optimization for true TCP-only monitors.
|
||||
|
||||
### Exact file/function targets
|
||||
- `backend/internal/services/uptime_service.go`
|
||||
- `CheckAll()`
|
||||
- add small helper (optional): `partitionMonitorsByType(...)`
|
||||
|
||||
## 4.2 Long-Term Robust Fix (Deferred)
|
||||
|
||||
Introduce host precheck as advisory signal, not authoritative override.
|
||||
|
||||
Design:
|
||||
1. Add `HostReachability` result to run context (not persisted as forced monitor status).
|
||||
2. Always execute per-monitor checks, but use host precheck to:
|
||||
- tune retries/backoff
|
||||
- annotate failure reason
|
||||
- optimize notification batching
|
||||
3. Optionally add feature flag:
|
||||
- `feature.uptime.strict_host_precheck` (default `false`)
|
||||
- allows legacy strict gating in environments that want it.
|
||||
|
||||
Benefits:
|
||||
- Removes false DOWN caused by precheck mismatch.
|
||||
- Keeps performance and batching controls.
|
||||
- More explicit semantics for operators.
|
||||
|
||||
## 5. API/Schema Impact
|
||||
|
||||
No API contract change required for minimal fix.
|
||||
No database migration required for minimal fix.
|
||||
|
||||
Long-term fix may add one feature flag setting only.
|
||||
|
||||
## 6. EARS Requirements
|
||||
|
||||
### Ubiquitous
|
||||
- THE SYSTEM SHALL evaluate HTTP/HTTPS monitor availability using URL-level checks as the authoritative signal.
|
||||
|
||||
### Event-driven
|
||||
- WHEN the scheduled uptime cycle runs, THE SYSTEM SHALL execute HTTP/HTTPS monitor checks regardless of internal host precheck state.
|
||||
- WHEN the scheduled uptime cycle runs and host precheck is down, THE SYSTEM SHALL apply host-level forced-down logic only to TCP monitors.
|
||||
|
||||
### State-driven
|
||||
- WHILE a monitor type is `http` or `https`, THE SYSTEM SHALL NOT force monitor status to `down` solely from internal host precheck failure.
|
||||
- WHILE a monitor type is `tcp`, THE SYSTEM SHALL evaluate status using endpoint socket reachability semantics.
|
||||
|
||||
### Unwanted behavior
|
||||
- IF internal host precheck is unreachable AND URL-level HTTP/HTTPS check returns success, THEN THE SYSTEM SHALL set monitor status to `up`.
|
||||
- IF internal host precheck is reachable AND URL-level HTTP/HTTPS check fails, THEN THE SYSTEM SHALL set monitor status to `down`.
|
||||
|
||||
### Optional
|
||||
- WHERE host precheck telemetry is enabled, THE SYSTEM SHALL record host-level reachability for diagnostics and grouping without overriding HTTP/HTTPS monitor final state.
|
||||
|
||||
## 7. Implementation Plan
|
||||
|
||||
### Phase 1: Reproduction Lock-In (Tests First)
|
||||
- Add backend service test proving current regression:
|
||||
- host precheck fails
|
||||
- monitor URL check would succeed
|
||||
- scheduled `CheckAll()` currently writes down (existing behavior)
|
||||
- File: `backend/internal/services/uptime_service_test.go` (new test block)
|
||||
|
||||
### Phase 2: Minimal Backend Fix
|
||||
- Update `CheckAll()` branch logic to run HTTP/HTTPS monitors even when host is down.
|
||||
- Make monitor partitioning explicit and mandatory in `CheckAll()` host-down branch.
|
||||
- Add an implementation guard before partitioning: normalize monitor type using
|
||||
`strings.TrimSpace` + `strings.ToLower` to prevent `HTTP`/`HTTPS` case
|
||||
regressions and whitespace-related misclassification.
|
||||
- Ensure `markHostMonitorsDown` is called only for TCP monitor partitions.
|
||||
- File: `backend/internal/services/uptime_service.go`
|
||||
|
||||
### Phase 3: Backend Validation
|
||||
- Add/adjust tests:
|
||||
- scheduled path no longer forces down when HTTP succeeds
|
||||
- manual and scheduled reach same final state for HTTP monitors
|
||||
- internal host unreachable + public URL HTTP 200 => monitor is `UP`
|
||||
- internal host reachable + public URL failure => monitor is `DOWN`
|
||||
- TCP monitor behavior unchanged under host-down conditions
|
||||
- Files:
|
||||
- `backend/internal/services/uptime_service_test.go`
|
||||
- `backend/internal/services/uptime_service_race_test.go` (if needed for concurrency side-effects)
|
||||
|
||||
### Phase 4: Integration/E2E Coverage
|
||||
- Add targeted API-level integration test for scheduler vs manual parity.
|
||||
- Add Playwright scenario for:
|
||||
- monitor set UP by manual check
|
||||
- remains UP after scheduled cycle when URL is reachable
|
||||
- Add parity scenario for:
|
||||
- internal TCP precheck unreachable + URL returns 200 => `UP`
|
||||
- internal TCP precheck reachable + URL failure => `DOWN`
|
||||
- Files:
|
||||
- `backend/internal/api/routes/routes_test.go` (or uptime handler integration suite)
|
||||
- `tests/monitoring/uptime-monitoring.spec.ts` (or equivalent uptime spec file)
|
||||
|
||||
Scope note:
|
||||
- This hotfix plan is intentionally limited to backend behavior correction and
|
||||
regression tests (unit/integration/E2E).
|
||||
- Dedicated documentation-phase work is deferred and out of scope for this
|
||||
hotfix PR.
|
||||
|
||||
## 8. Test Plan (Unit / Integration / E2E)
|
||||
|
||||
Duplicate notification definition (hotfix acceptance/testing):
|
||||
- A duplicate notification means the same `(monitor_id, status,
|
||||
scheduler_tick_id)` is emitted more than once within a single scheduler run.
|
||||
|
||||
## Unit Tests
|
||||
1. `CheckAll_HostDown_DoesNotForceDown_HTTPMonitor_WhenHTTPCheckSucceeds`
|
||||
2. `CheckAll_HostDown_StillHandles_TCPMonitor_Conservatively`
|
||||
3. `CheckAll_ManualAndScheduledParity_HTTPMonitor`
|
||||
4. `CheckAll_InternalHostUnreachable_PublicURL200_HTTPMonitorEndsUp` (blocking)
|
||||
5. `CheckAll_InternalHostReachable_PublicURLFail_HTTPMonitorEndsDown` (blocking)
|
||||
|
||||
## Integration Tests
|
||||
1. Scheduler endpoint (`/api/v1/system/uptime/check`) parity with monitor check endpoint.
|
||||
2. Verify DB heartbeat message is real HTTP result (not `Host unreachable`) for HTTP monitors where URL is reachable.
|
||||
3. Verify when host precheck is down, HTTP monitor heartbeat/notification output is derived from `checkMonitor()` (not synthetic host-path `Host unreachable`).
|
||||
4. Verify no duplicate notifications are emitted from host+monitor paths for the same scheduler run, where duplicate is defined as repeated `(monitor_id, status, scheduler_tick_id)`.
|
||||
5. Verify internal host precheck unreachable + public URL 200 still resolves monitor `UP`.
|
||||
6. Verify internal host precheck reachable + public URL failure resolves monitor `DOWN`.
|
||||
|
||||
## E2E Tests
|
||||
1. Create/sync monitor scenario where manual refresh returns `UP`.
|
||||
2. Wait one scheduler interval.
|
||||
3. Assert monitor remains `UP` and latest heartbeat is not forced `Host unreachable` for reachable URL.
|
||||
4. Assert scenario: internal host precheck unreachable + public URL 200 => monitor remains `UP`.
|
||||
5. Assert scenario: internal host precheck reachable + public URL failure => monitor is `DOWN`.
|
||||
|
||||
## Regression Guardrails
|
||||
- Add a test explicitly asserting that host precheck must not unconditionally override HTTP monitor checks.
|
||||
- Add explicit assertions that HTTP monitors under host-down precheck emit
|
||||
check-derived heartbeat messages and do not produce duplicate notifications
|
||||
under the `(monitor_id, status, scheduler_tick_id)` rule within a single
|
||||
scheduler run.
|
||||
|
||||
## 9. Risks and Rollback
|
||||
|
||||
## Risks
|
||||
1. More HTTP checks under true host outage may increase check volume.
|
||||
2. Notification patterns may shift from single host-level event to monitor-level batched events.
|
||||
3. Edge cases for mixed-type monitor groups (HTTP + TCP) need deterministic behavior.
|
||||
|
||||
## Mitigations
|
||||
1. Preserve batching (`queueDownNotification`) and existing retry thresholds.
|
||||
2. Keep TCP strict path unchanged in minimal fix.
|
||||
3. Add explicit log fields and targeted tests for mixed groups.
|
||||
|
||||
## Rollback Plan
|
||||
1. Revert the `CheckAll()` branch change only (single-file rollback).
|
||||
2. Keep added tests; mark expected behavior as legacy if temporary rollback needed.
|
||||
3. If necessary, introduce temporary feature toggle to switch between strict and tolerant host gating.
|
||||
|
||||
## 10. PR Slicing Strategy
|
||||
|
||||
Decision: Single focused PR (hotfix + tests)
|
||||
|
||||
Trigger reasons:
|
||||
- High-severity runtime behavior fix requiring minimal blast radius
|
||||
- Fast review/rollback with behavior-only delta plus regression coverage
|
||||
- Avoid scope creep into optional hardening/feature-flag work
|
||||
|
||||
### PR-1 (Hotfix + Tests)
|
||||
Scope:
|
||||
- `CheckAll()` host-down branch adjustment for HTTP/HTTPS
|
||||
- Unit/integration/E2E regression tests for URL-truth semantics
|
||||
|
||||
Files:
|
||||
- `backend/internal/services/uptime_service.go`
|
||||
- `backend/internal/services/uptime_service_test.go`
|
||||
- `backend/internal/api/routes/routes_test.go` (or equivalent)
|
||||
- `tests/monitoring/uptime-monitoring.spec.ts` (or equivalent)
|
||||
|
||||
Validation gates:
|
||||
- backend unit tests pass
|
||||
- targeted uptime integration tests pass
|
||||
- targeted uptime E2E tests pass
|
||||
- no behavior regression in existing `CheckAll` tests
|
||||
|
||||
Rollback:
|
||||
- single revert of PR-1 commit
|
||||
|
||||
## 11. Acceptance Criteria (DoD)
|
||||
|
||||
1. Scheduled and manual checks produce consistent status for HTTP/HTTPS monitors.
|
||||
2. A reachable monitor URL is not forced to `DOWN` solely by host precheck failure.
|
||||
3. New regression tests fail before fix and pass after fix.
|
||||
4. No break in TCP monitor behavior expectations.
|
||||
5. No new critical/high security findings in touched paths.
|
||||
6. Blocking parity case passes: internal host precheck unreachable + public URL 200 => scheduled result is `UP`.
|
||||
7. Blocking parity case passes: internal host precheck reachable + public URL failure => scheduled result is `DOWN`.
|
||||
8. Under host-down precheck, HTTP monitors produce check-derived heartbeat messages (not synthetic `Host unreachable` from host path).
|
||||
9. No duplicate notifications are produced by host+monitor paths within a
|
||||
single scheduler run, where duplicate is defined as repeated
|
||||
`(monitor_id, status, scheduler_tick_id)`.
|
||||
|
||||
## 12. Implementation Risks
|
||||
|
||||
1. Increased scheduler workload during host-precheck failures because HTTP/HTTPS checks continue to run.
|
||||
2. Notification cadence may change due to check-derived monitor outcomes replacing host-forced synthetic downs.
|
||||
3. Mixed monitor groups (TCP + HTTP/HTTPS) require strict ordering/partitioning to avoid regression.
|
||||
|
||||
Mitigations:
|
||||
- Keep change localized to `CheckAll()` host-down branch decisioning.
|
||||
- Add explicit regression tests for both parity directions and mixed monitor types.
|
||||
- Keep rollback path as single-commit revert.
|
||||
File diff suppressed because it is too large
Load Diff
80
frontend/package-lock.json
generated
80
frontend/package-lock.json
generated
@@ -19,9 +19,9 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"i18next": "^25.8.13",
|
||||
"i18next": "^25.8.14",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^0.576.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.71.2",
|
||||
@@ -56,7 +56,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"jsdom": "28.1.0",
|
||||
"knip": "^5.85.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
@@ -1391,9 +1391,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@exodus/bytes": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz",
|
||||
"integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==",
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz",
|
||||
"integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1409,31 +1409,31 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz",
|
||||
"integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==",
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz",
|
||||
"integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==",
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.4",
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
"@floating-ui/core": "^1.7.5",
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react-dom": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz",
|
||||
"integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==",
|
||||
"version": "2.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
|
||||
"integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.5"
|
||||
"@floating-ui/dom": "^1.7.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
@@ -1441,9 +1441,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
|
||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
|
||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
@@ -4350,9 +4350,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001775",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001775.tgz",
|
||||
"integrity": "sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==",
|
||||
"version": "1.0.30001776",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001776.tgz",
|
||||
"integrity": "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -4716,9 +4716,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.302",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
|
||||
"integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==",
|
||||
"version": "1.5.307",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz",
|
||||
"integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -5651,9 +5651,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "25.8.13",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.13.tgz",
|
||||
"integrity": "sha512-E0vzjBY1yM+nsFrtgkjLhST2NBkirkvOVoQa0MSldhsuZ3jUge7ZNpuwG0Cfc74zwo5ZwRzg3uOgT+McBn32iA==",
|
||||
"version": "25.8.14",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.14.tgz",
|
||||
"integrity": "sha512-paMUYkfWJMsWPeE/Hejcw+XLhHrQPehem+4wMo+uELnvIwvCG019L9sAIljwjCmEMtFQQO3YeitJY8Kctei3iA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -6343,9 +6343,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.576.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.576.0.tgz",
|
||||
"integrity": "sha512-koNxU14BXrxUfZQ9cUaP0ES1uyPZKYDjk31FQZB6dQ/x+tXk979sVAn9ppZ/pVeJJyOxVM8j1E+8QEuSc02Vug==",
|
||||
"version": "0.577.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz",
|
||||
"integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
@@ -7418,9 +7418,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
|
||||
"version": "2.0.36",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
|
||||
"integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -7623,9 +7623,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
||||
@@ -38,9 +38,9 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"i18next": "^25.8.13",
|
||||
"i18next": "^25.8.14",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^0.576.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.71.2",
|
||||
@@ -75,7 +75,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"jsdom": "28.1.0",
|
||||
"knip": "^5.85.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ToastContainer } from './components/Toast'
|
||||
import { SetupGuard } from './components/SetupGuard'
|
||||
import { LoadingOverlay } from './components/LoadingStates'
|
||||
import RequireAuth from './components/RequireAuth'
|
||||
import RequireRole from './components/RequireRole'
|
||||
import { AuthProvider } from './context/AuthContext'
|
||||
|
||||
// Lazy load pages for code splitting
|
||||
@@ -23,7 +24,6 @@ const DNSProviders = lazy(() => import('./pages/DNSProviders'))
|
||||
const SystemSettings = lazy(() => import('./pages/SystemSettings'))
|
||||
const SMTPSettings = lazy(() => import('./pages/SMTPSettings'))
|
||||
const CrowdSecConfig = lazy(() => import('./pages/CrowdSecConfig'))
|
||||
const Account = lazy(() => import('./pages/Account'))
|
||||
const Settings = lazy(() => import('./pages/Settings'))
|
||||
const Backups = lazy(() => import('./pages/Backups'))
|
||||
const Tasks = lazy(() => import('./pages/Tasks'))
|
||||
@@ -43,6 +43,7 @@ const Plugins = lazy(() => import('./pages/Plugins'))
|
||||
const Login = lazy(() => import('./pages/Login'))
|
||||
const Setup = lazy(() => import('./pages/Setup'))
|
||||
const AcceptInvite = lazy(() => import('./pages/AcceptInvite'))
|
||||
const PassthroughLanding = lazy(() => import('./pages/PassthroughLanding'))
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -53,6 +54,11 @@ export default function App() {
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/setup" element={<Setup />} />
|
||||
<Route path="/accept-invite" element={<AcceptInvite />} />
|
||||
<Route path="/passthrough" element={
|
||||
<RequireAuth>
|
||||
<PassthroughLanding />
|
||||
</RequireAuth>
|
||||
} />
|
||||
<Route path="/" element={
|
||||
<SetupGuard>
|
||||
<RequireAuth>
|
||||
@@ -88,19 +94,23 @@ export default function App() {
|
||||
<Route path="security/encryption" element={<EncryptionManagement />} />
|
||||
<Route path="access-lists" element={<AccessLists />} />
|
||||
<Route path="uptime" element={<Uptime />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
|
||||
{/* Legacy redirects for old user management paths */}
|
||||
<Route path="users" element={<Navigate to="/settings/users" replace />} />
|
||||
<Route path="admin/plugins" element={<Navigate to="/dns/plugins" replace />} />
|
||||
<Route path="import" element={<Navigate to="/tasks/import/caddyfile" replace />} />
|
||||
|
||||
{/* Settings Routes */}
|
||||
<Route path="settings" element={<Settings />}>
|
||||
<Route path="settings" element={<RequireRole allowed={['admin', 'user']}><Settings /></RequireRole>}>
|
||||
<Route index element={<SystemSettings />} />
|
||||
<Route path="system" element={<SystemSettings />} />
|
||||
<Route path="notifications" element={<Notifications />} />
|
||||
<Route path="smtp" element={<SMTPSettings />} />
|
||||
<Route path="crowdsec" element={<Navigate to="/security/crowdsec" replace />} />
|
||||
<Route path="account" element={<Account />} />
|
||||
<Route path="account-management" element={<UsersPage />} />
|
||||
<Route path="users" element={<RequireRole allowed={['admin']}><UsersPage /></RequireRole>} />
|
||||
{/* Legacy redirects */}
|
||||
<Route path="account" element={<Navigate to="/settings/users" replace />} />
|
||||
<Route path="account-management" element={<Navigate to="/settings/users" replace />} />
|
||||
</Route>
|
||||
|
||||
{/* Tasks Routes */}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import client from '../client'
|
||||
import { getProfile, regenerateApiKey, updateProfile } from '../user'
|
||||
import { getProfile, regenerateApiKey, updateProfile } from '../users'
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import client from './client'
|
||||
|
||||
/** Current user profile information. */
|
||||
export interface UserProfile {
|
||||
id: number
|
||||
email: string
|
||||
name: string
|
||||
role: string
|
||||
has_api_key: boolean
|
||||
api_key_masked: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the current user's profile.
|
||||
* @returns Promise resolving to UserProfile
|
||||
* @throws {AxiosError} If the request fails or not authenticated
|
||||
*/
|
||||
export const getProfile = async (): Promise<UserProfile> => {
|
||||
const response = await client.get('/user/profile')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerates the current user's API key.
|
||||
* @returns Promise resolving to object containing the new API key
|
||||
* @throws {AxiosError} If regeneration fails
|
||||
*/
|
||||
export interface RegenerateApiKeyResponse {
|
||||
message: string
|
||||
has_api_key: boolean
|
||||
api_key_masked: string
|
||||
api_key_updated: string
|
||||
}
|
||||
|
||||
export const regenerateApiKey = async (): Promise<RegenerateApiKeyResponse> => {
|
||||
const response = await client.post<RegenerateApiKeyResponse>('/user/api-key')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current user's profile.
|
||||
* @param data - Object with name, email, and optional current_password for verification
|
||||
* @returns Promise resolving to success message
|
||||
* @throws {AxiosError} If update fails or password verification fails
|
||||
*/
|
||||
export const updateProfile = async (data: { name: string; email: string; current_password?: string }): Promise<{ message: string }> => {
|
||||
const response = await client.post('/user/profile', data)
|
||||
return response.data
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export interface User {
|
||||
uuid: string
|
||||
email: string
|
||||
name: string
|
||||
role: 'admin' | 'user' | 'viewer'
|
||||
role: 'admin' | 'user' | 'passthrough'
|
||||
enabled: boolean
|
||||
last_login?: string
|
||||
invite_status?: 'pending' | 'accepted' | 'expired'
|
||||
@@ -212,3 +212,51 @@ export const resendInvite = async (id: number): Promise<InviteUserResponse> => {
|
||||
const response = await client.post<InviteUserResponse>(`/users/${id}/resend-invite`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// --- Self-service profile endpoints (merged from api/user.ts) ---
|
||||
|
||||
/** Current user profile information. */
|
||||
export interface UserProfile {
|
||||
id: number
|
||||
email: string
|
||||
name: string
|
||||
role: 'admin' | 'user' | 'passthrough'
|
||||
has_api_key: boolean
|
||||
api_key_masked: string
|
||||
}
|
||||
|
||||
/** Response from API key regeneration. */
|
||||
export interface RegenerateApiKeyResponse {
|
||||
message: string
|
||||
has_api_key: boolean
|
||||
api_key_masked: string
|
||||
api_key_updated: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the current user's profile.
|
||||
* @returns Promise resolving to UserProfile
|
||||
*/
|
||||
export const getProfile = async (): Promise<UserProfile> => {
|
||||
const response = await client.get<UserProfile>('/user/profile')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current user's profile.
|
||||
* @param data - Object with name, email, and optional current_password for verification
|
||||
* @returns Promise resolving to success message
|
||||
*/
|
||||
export const updateProfile = async (data: { name: string; email: string; current_password?: string }): Promise<{ message: string }> => {
|
||||
const response = await client.post<{ message: string }>('/user/profile', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerates the current user's API key.
|
||||
* @returns Promise resolving to object containing the new API key
|
||||
*/
|
||||
export const regenerateApiKey = async (): Promise<RegenerateApiKeyResponse> => {
|
||||
const response = await client.post<RegenerateApiKeyResponse>('/user/api-key')
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -85,8 +85,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
{ name: t('navigation.system'), path: '/settings/system', icon: '⚙️' },
|
||||
{ name: t('navigation.notifications'), path: '/settings/notifications', icon: '🔔' },
|
||||
{ name: t('navigation.email'), path: '/settings/smtp', icon: '📧' },
|
||||
{ name: t('navigation.adminAccount'), path: '/settings/account', icon: '🛡️' },
|
||||
{ name: t('navigation.accountManagement'), path: '/settings/account-management', icon: '👥' },
|
||||
...(user?.role === 'admin' ? [{ name: t('navigation.users'), path: '/settings/users', icon: '👥' }] : []),
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -109,6 +108,8 @@ export default function Layout({ children }: LayoutProps) {
|
||||
]
|
||||
},
|
||||
].filter(item => {
|
||||
// Passthrough users see no navigation — they're redirected to /passthrough
|
||||
if (user?.role === 'passthrough') return false
|
||||
// Optional Features Logic
|
||||
// Default to visible (true) if flags are loading or undefined
|
||||
if (item.name === t('navigation.uptime')) return featureFlags?.['feature.uptime.enabled'] !== false
|
||||
@@ -362,7 +363,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
</div>
|
||||
<div className="w-1/3 flex justify-end items-center gap-4">
|
||||
{user && (
|
||||
<Link to="/settings/account" className="text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
|
||||
<Link to="/settings/users" className="text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
|
||||
{user.name}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
25
frontend/src/components/RequireRole.tsx
Normal file
25
frontend/src/components/RequireRole.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
|
||||
interface RequireRoleProps {
|
||||
allowed: Array<'admin' | 'user' | 'passthrough'>
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const RequireRole: React.FC<RequireRoleProps> = ({ allowed, children }) => {
|
||||
const { user } = useAuth()
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
if (!allowed.includes(user.role)) {
|
||||
const redirectTarget = user.role === 'passthrough' ? '/passthrough' : '/'
|
||||
return <Navigate to={redirectTarget} replace />
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
export default RequireRole
|
||||
@@ -2,7 +2,7 @@ import { createContext } from 'react';
|
||||
|
||||
export interface User {
|
||||
user_id: number;
|
||||
role: string;
|
||||
role: 'admin' | 'user' | 'passthrough';
|
||||
name?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('useAuth hook', () => {
|
||||
})
|
||||
|
||||
it('returns context inside provider', () => {
|
||||
const fakeCtx = { user: { user_id: 1, role: 'admin', name: 'Test', email: 't@example.com' }, login: async () => {}, logout: () => {}, changePassword: async () => {}, isAuthenticated: true, isLoading: false }
|
||||
const fakeCtx = { user: { user_id: 1, role: 'admin' as const, name: 'Test', email: 't@example.com' }, login: async () => {}, logout: () => {}, changePassword: async () => {}, isAuthenticated: true, isLoading: false }
|
||||
render(
|
||||
<AuthContext.Provider value={fakeCtx}>
|
||||
<TestComponent />
|
||||
|
||||
47
frontend/src/hooks/useFocusTrap.ts
Normal file
47
frontend/src/hooks/useFocusTrap.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useEffect, type RefObject } from 'react'
|
||||
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
|
||||
export function useFocusTrap(
|
||||
dialogRef: RefObject<HTMLElement | null>,
|
||||
isOpen: boolean,
|
||||
onEscape?: () => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && onEscape) {
|
||||
onEscape()
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Tab' && dialogRef.current) {
|
||||
const focusable =
|
||||
dialogRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
|
||||
if (focusable.length === 0) return
|
||||
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const first = dialogRef.current?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR)
|
||||
first?.focus()
|
||||
})
|
||||
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isOpen, onEscape, dialogRef])
|
||||
}
|
||||
@@ -66,8 +66,6 @@
|
||||
"settings": "Einstellungen",
|
||||
"system": "System",
|
||||
"email": "E-Mail (SMTP)",
|
||||
"adminAccount": "Admin-Konto",
|
||||
"accountManagement": "Kontoverwaltung",
|
||||
"import": "Importieren",
|
||||
"caddyfile": "Caddyfile",
|
||||
"backups": "Sicherungen",
|
||||
@@ -538,6 +536,10 @@
|
||||
"role": "Rolle",
|
||||
"roleUser": "Benutzer",
|
||||
"roleAdmin": "Administrator",
|
||||
"rolePassthrough": "Passthrough",
|
||||
"roleUserDescription": "Kann nur auf erlaubte Proxy-Hosts zugreifen.",
|
||||
"roleAdminDescription": "Vollzugriff auf alle Funktionen und Einstellungen.",
|
||||
"rolePassthroughDescription": "Nur Proxy-Zugriff — keine Verwaltungsoberfläche.",
|
||||
"permissionMode": "Berechtigungsmodus",
|
||||
"allowAllBlacklist": "Alles erlauben (Blacklist)",
|
||||
"denyAllWhitelist": "Alles verweigern (Whitelist)",
|
||||
@@ -571,7 +573,23 @@
|
||||
"resendInvite": "Einladung erneut senden",
|
||||
"inviteResent": "Einladung erfolgreich erneut gesendet",
|
||||
"inviteCreatedNoEmail": "Neue Einladung erstellt. E-Mail konnte nicht gesendet werden.",
|
||||
"resendFailed": "Einladung konnte nicht erneut gesendet werden"
|
||||
"resendFailed": "Einladung konnte nicht erneut gesendet werden",
|
||||
"myProfile": "Mein Profil",
|
||||
"editUser": "Benutzer bearbeiten",
|
||||
"changePassword": "Passwort ändern",
|
||||
"currentPassword": "Aktuelles Passwort",
|
||||
"newPassword": "Neues Passwort",
|
||||
"confirmPassword": "Passwort bestätigen",
|
||||
"passwordChanged": "Passwort erfolgreich geändert",
|
||||
"passwordChangeFailed": "Passwort konnte nicht geändert werden",
|
||||
"passwordMismatch": "Passwörter stimmen nicht überein",
|
||||
"apiKey": "API-Schlüssel",
|
||||
"regenerateApiKey": "API-Schlüssel neu generieren",
|
||||
"apiKeyRegenerated": "API-Schlüssel neu generiert",
|
||||
"apiKeyRegenerateFailed": "API-Schlüssel konnte nicht neu generiert werden",
|
||||
"apiKeyConfirm": "Sind Sie sicher? Der aktuelle API-Schlüssel wird ungültig.",
|
||||
"profileUpdated": "Profil erfolgreich aktualisiert",
|
||||
"profileUpdateFailed": "Profil konnte nicht aktualisiert werden"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -1018,5 +1036,10 @@
|
||||
"dns": {
|
||||
"title": "DNS-Verwaltung",
|
||||
"description": "DNS-Anbieter und Plugins für die Zertifikatsautomatisierung verwalten"
|
||||
},
|
||||
"passthrough": {
|
||||
"title": "Willkommen",
|
||||
"description": "Ihr Konto hat Passthrough-Zugriff. Sie können Ihre zugewiesenen Dienste direkt erreichen — keine Verwaltungsoberfläche verfügbar.",
|
||||
"noAccessToManagement": "Sie haben keinen Zugriff auf die Verwaltungsoberfläche."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,8 +70,6 @@
|
||||
"settings": "Settings",
|
||||
"system": "System",
|
||||
"email": "Email (SMTP)",
|
||||
"adminAccount": "Admin Account",
|
||||
"accountManagement": "Account Management",
|
||||
"import": "Import",
|
||||
"caddyfile": "Caddyfile",
|
||||
"importNPM": "Import NPM",
|
||||
@@ -618,6 +616,10 @@
|
||||
"role": "Role",
|
||||
"roleUser": "User",
|
||||
"roleAdmin": "Admin",
|
||||
"rolePassthrough": "Passthrough",
|
||||
"roleUserDescription": "Can access permitted proxy hosts only.",
|
||||
"roleAdminDescription": "Full access to all features and settings.",
|
||||
"rolePassthroughDescription": "Proxy access only — no management interface.",
|
||||
"permissionMode": "Permission Mode",
|
||||
"allowAllBlacklist": "Allow All (Blacklist)",
|
||||
"denyAllWhitelist": "Deny All (Whitelist)",
|
||||
@@ -651,7 +653,23 @@
|
||||
"resendInvite": "Resend Invite",
|
||||
"inviteResent": "Invitation resent successfully",
|
||||
"inviteCreatedNoEmail": "New invite created. Email could not be sent.",
|
||||
"resendFailed": "Failed to resend invitation"
|
||||
"resendFailed": "Failed to resend invitation",
|
||||
"myProfile": "My Profile",
|
||||
"editUser": "Edit User",
|
||||
"changePassword": "Change Password",
|
||||
"currentPassword": "Current Password",
|
||||
"newPassword": "New Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"passwordChanged": "Password changed successfully",
|
||||
"passwordChangeFailed": "Failed to change password",
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"apiKey": "API Key",
|
||||
"regenerateApiKey": "Regenerate API Key",
|
||||
"apiKeyRegenerated": "API key regenerated",
|
||||
"apiKeyRegenerateFailed": "Failed to regenerate API key",
|
||||
"apiKeyConfirm": "Are you sure? The current API key will be invalidated.",
|
||||
"profileUpdated": "Profile updated successfully",
|
||||
"profileUpdateFailed": "Failed to update profile"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -1360,5 +1378,10 @@
|
||||
"validationError": "Key configuration validation failed. Check errors below.",
|
||||
"validationFailed": "Validation request failed: {{error}}",
|
||||
"failedToLoadStatus": "Failed to load encryption status. Please refresh the page."
|
||||
},
|
||||
"passthrough": {
|
||||
"title": "Welcome",
|
||||
"description": "Your account has passthrough access. You can reach your assigned services directly — no management interface is available.",
|
||||
"noAccessToManagement": "You do not have access to the management interface."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,8 +66,6 @@
|
||||
"settings": "Configuración",
|
||||
"system": "Sistema",
|
||||
"email": "Correo Electrónico (SMTP)",
|
||||
"adminAccount": "Cuenta de Administrador",
|
||||
"accountManagement": "Gestión de Cuentas",
|
||||
"import": "Importar",
|
||||
"caddyfile": "Caddyfile",
|
||||
"backups": "Copias de Seguridad",
|
||||
@@ -538,6 +536,10 @@
|
||||
"role": "Rol",
|
||||
"roleUser": "Usuario",
|
||||
"roleAdmin": "Administrador",
|
||||
"rolePassthrough": "Passthrough",
|
||||
"roleUserDescription": "Solo puede acceder a los hosts proxy permitidos.",
|
||||
"roleAdminDescription": "Acceso completo a todas las funciones y configuraciones.",
|
||||
"rolePassthroughDescription": "Solo acceso proxy — sin interfaz de gestión.",
|
||||
"permissionMode": "Modo de Permisos",
|
||||
"allowAllBlacklist": "Permitir Todo (Lista Negra)",
|
||||
"denyAllWhitelist": "Denegar Todo (Lista Blanca)",
|
||||
@@ -571,7 +573,23 @@
|
||||
"resendInvite": "Reenviar invitación",
|
||||
"inviteResent": "Invitación reenviada exitosamente",
|
||||
"inviteCreatedNoEmail": "Nueva invitación creada. No se pudo enviar el correo electrónico.",
|
||||
"resendFailed": "Error al reenviar la invitación"
|
||||
"resendFailed": "Error al reenviar la invitación",
|
||||
"myProfile": "Mi Perfil",
|
||||
"editUser": "Editar Usuario",
|
||||
"changePassword": "Cambiar Contraseña",
|
||||
"currentPassword": "Contraseña Actual",
|
||||
"newPassword": "Nueva Contraseña",
|
||||
"confirmPassword": "Confirmar Contraseña",
|
||||
"passwordChanged": "Contraseña cambiada exitosamente",
|
||||
"passwordChangeFailed": "Error al cambiar la contraseña",
|
||||
"passwordMismatch": "Las contraseñas no coinciden",
|
||||
"apiKey": "Clave API",
|
||||
"regenerateApiKey": "Regenerar Clave API",
|
||||
"apiKeyRegenerated": "Clave API regenerada",
|
||||
"apiKeyRegenerateFailed": "Error al regenerar la clave API",
|
||||
"apiKeyConfirm": "¿Está seguro? La clave API actual será invalidada.",
|
||||
"profileUpdated": "Perfil actualizado exitosamente",
|
||||
"profileUpdateFailed": "Error al actualizar el perfil"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Panel de Control",
|
||||
@@ -1018,5 +1036,10 @@
|
||||
"dns": {
|
||||
"title": "Gestión DNS",
|
||||
"description": "Administrar proveedores DNS y plugins para la automatización de certificados"
|
||||
},
|
||||
"passthrough": {
|
||||
"title": "Bienvenido",
|
||||
"description": "Su cuenta tiene acceso passthrough. Puede acceder a sus servicios asignados directamente — no hay interfaz de gestión disponible.",
|
||||
"noAccessToManagement": "No tiene acceso a la interfaz de gestión."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,8 +66,6 @@
|
||||
"settings": "Paramètres",
|
||||
"system": "Système",
|
||||
"email": "Email (SMTP)",
|
||||
"adminAccount": "Compte Administrateur",
|
||||
"accountManagement": "Gestion des Comptes",
|
||||
"import": "Importer",
|
||||
"caddyfile": "Caddyfile",
|
||||
"backups": "Sauvegardes",
|
||||
@@ -538,6 +536,10 @@
|
||||
"role": "Rôle",
|
||||
"roleUser": "Utilisateur",
|
||||
"roleAdmin": "Administrateur",
|
||||
"rolePassthrough": "Passthrough",
|
||||
"roleUserDescription": "Peut accéder uniquement aux hôtes proxy autorisés.",
|
||||
"roleAdminDescription": "Accès complet à toutes les fonctionnalités et paramètres.",
|
||||
"rolePassthroughDescription": "Accès proxy uniquement — aucune interface de gestion.",
|
||||
"permissionMode": "Mode de Permission",
|
||||
"allowAllBlacklist": "Tout Autoriser (Liste Noire)",
|
||||
"denyAllWhitelist": "Tout Refuser (Liste Blanche)",
|
||||
@@ -571,7 +573,23 @@
|
||||
"resendInvite": "Renvoyer l'invitation",
|
||||
"inviteResent": "Invitation renvoyée avec succès",
|
||||
"inviteCreatedNoEmail": "Nouvelle invitation créée. L'e-mail n'a pas pu être envoyé.",
|
||||
"resendFailed": "Échec du renvoi de l'invitation"
|
||||
"resendFailed": "Échec du renvoi de l'invitation",
|
||||
"myProfile": "Mon Profil",
|
||||
"editUser": "Modifier l'utilisateur",
|
||||
"changePassword": "Changer le mot de passe",
|
||||
"currentPassword": "Mot de passe actuel",
|
||||
"newPassword": "Nouveau mot de passe",
|
||||
"confirmPassword": "Confirmer le mot de passe",
|
||||
"passwordChanged": "Mot de passe changé avec succès",
|
||||
"passwordChangeFailed": "Échec du changement de mot de passe",
|
||||
"passwordMismatch": "Les mots de passe ne correspondent pas",
|
||||
"apiKey": "Clé API",
|
||||
"regenerateApiKey": "Régénérer la clé API",
|
||||
"apiKeyRegenerated": "Clé API régénérée",
|
||||
"apiKeyRegenerateFailed": "Échec de la régénération de la clé API",
|
||||
"apiKeyConfirm": "Êtes-vous sûr ? La clé API actuelle sera invalidée.",
|
||||
"profileUpdated": "Profil mis à jour avec succès",
|
||||
"profileUpdateFailed": "Échec de la mise à jour du profil"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
@@ -1018,5 +1036,10 @@
|
||||
"dns": {
|
||||
"title": "Gestion DNS",
|
||||
"description": "Gérer les fournisseurs DNS et les plugins pour l'automatisation des certificats"
|
||||
},
|
||||
"passthrough": {
|
||||
"title": "Bienvenue",
|
||||
"description": "Votre compte a un accès passthrough. Vous pouvez accéder directement à vos services assignés — aucune interface de gestion n'est disponible.",
|
||||
"noAccessToManagement": "Vous n'avez pas accès à l'interface de gestion."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,8 +66,6 @@
|
||||
"settings": "设置",
|
||||
"system": "系统",
|
||||
"email": "电子邮件 (SMTP)",
|
||||
"adminAccount": "管理员账户",
|
||||
"accountManagement": "账户管理",
|
||||
"import": "导入",
|
||||
"caddyfile": "Caddyfile",
|
||||
"backups": "备份",
|
||||
@@ -538,6 +536,10 @@
|
||||
"role": "角色",
|
||||
"roleUser": "用户",
|
||||
"roleAdmin": "管理员",
|
||||
"rolePassthrough": "Passthrough",
|
||||
"roleUserDescription": "只能访问允许的代理主机。",
|
||||
"roleAdminDescription": "完全访问所有功能和设置。",
|
||||
"rolePassthroughDescription": "仅代理访问 — 无管理界面。",
|
||||
"permissionMode": "权限模式",
|
||||
"allowAllBlacklist": "允许所有(黑名单)",
|
||||
"denyAllWhitelist": "拒绝所有(白名单)",
|
||||
@@ -571,7 +573,23 @@
|
||||
"resendInvite": "重新发送邀请",
|
||||
"inviteResent": "邀请重新发送成功",
|
||||
"inviteCreatedNoEmail": "新邀请已创建。无法发送电子邮件。",
|
||||
"resendFailed": "重新发送邀请失败"
|
||||
"resendFailed": "重新发送邀请失败",
|
||||
"myProfile": "我的资料",
|
||||
"editUser": "编辑用户",
|
||||
"changePassword": "修改密码",
|
||||
"currentPassword": "当前密码",
|
||||
"newPassword": "新密码",
|
||||
"confirmPassword": "确认密码",
|
||||
"passwordChanged": "密码修改成功",
|
||||
"passwordChangeFailed": "密码修改失败",
|
||||
"passwordMismatch": "密码不匹配",
|
||||
"apiKey": "API密钥",
|
||||
"regenerateApiKey": "重新生成API密钥",
|
||||
"apiKeyRegenerated": "API密钥已重新生成",
|
||||
"apiKeyRegenerateFailed": "重新生成API密钥失败",
|
||||
"apiKeyConfirm": "确定吗?当前的API密钥将失效。",
|
||||
"profileUpdated": "资料更新成功",
|
||||
"profileUpdateFailed": "资料更新失败"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "仪表板",
|
||||
@@ -1020,5 +1038,10 @@
|
||||
"dns": {
|
||||
"title": "DNS 管理",
|
||||
"description": "管理 DNS 提供商和插件以实现证书自动化"
|
||||
},
|
||||
"passthrough": {
|
||||
"title": "欢迎",
|
||||
"description": "您的账户拥有 Passthrough 访问权限。您可以直接访问分配给您的服务 — 无管理界面可用。",
|
||||
"noAccessToManagement": "您无权访问管理界面。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,540 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '../components/ui/Card'
|
||||
import { Input } from '../components/ui/Input'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { Label } from '../components/ui/Label'
|
||||
import { Alert } from '../components/ui/Alert'
|
||||
import { Checkbox } from '../components/ui/Checkbox'
|
||||
import { Skeleton } from '../components/ui/Skeleton'
|
||||
import { toast } from '../utils/toast'
|
||||
import { getProfile, regenerateApiKey, updateProfile } from '../api/user'
|
||||
import { getSettings, updateSetting } from '../api/settings'
|
||||
import { RefreshCw, Shield, Mail, User, AlertTriangle, Key } from 'lucide-react'
|
||||
import { PasswordStrengthMeter } from '../components/PasswordStrengthMeter'
|
||||
import { isValidEmail } from '../utils/validation'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
|
||||
export default function Account() {
|
||||
const { t } = useTranslation()
|
||||
const [oldPassword, setOldPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// Profile State
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [emailValid, setEmailValid] = useState<boolean | null>(null)
|
||||
const [confirmPasswordForUpdate, setConfirmPasswordForUpdate] = useState('')
|
||||
const [showPasswordPrompt, setShowPasswordPrompt] = useState(false)
|
||||
const [pendingProfileUpdate, setPendingProfileUpdate] = useState<{name: string, email: string} | null>(null)
|
||||
const [previousEmail, setPreviousEmail] = useState('')
|
||||
const [showEmailConfirmModal, setShowEmailConfirmModal] = useState(false)
|
||||
|
||||
// Certificate Email State
|
||||
const [certEmail, setCertEmail] = useState('')
|
||||
const [certEmailValid, setCertEmailValid] = useState<boolean | null>(null)
|
||||
const [useUserEmail, setUseUserEmail] = useState(true)
|
||||
const [certEmailInitialized, setCertEmailInitialized] = useState(false)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const { changePassword } = useAuth()
|
||||
|
||||
const { data: profile, isLoading: isLoadingProfile } = useQuery({
|
||||
queryKey: ['profile'],
|
||||
queryFn: getProfile,
|
||||
})
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: getSettings,
|
||||
})
|
||||
|
||||
// Initialize profile state
|
||||
useEffect(() => {
|
||||
if (profile) {
|
||||
setName(profile.name)
|
||||
setEmail(profile.email)
|
||||
}
|
||||
}, [profile])
|
||||
|
||||
// Validate profile email
|
||||
useEffect(() => {
|
||||
if (email) {
|
||||
setEmailValid(isValidEmail(email))
|
||||
} else {
|
||||
setEmailValid(null)
|
||||
}
|
||||
}, [email])
|
||||
|
||||
// Initialize cert email state only once, when both settings and profile are loaded
|
||||
useEffect(() => {
|
||||
if (!certEmailInitialized && settings && profile) {
|
||||
const savedEmail = settings['caddy.email']
|
||||
if (savedEmail && savedEmail !== profile.email) {
|
||||
setCertEmail(savedEmail)
|
||||
setUseUserEmail(false)
|
||||
} else {
|
||||
setCertEmail(profile.email)
|
||||
setUseUserEmail(true)
|
||||
}
|
||||
setCertEmailInitialized(true)
|
||||
}
|
||||
}, [settings, profile, certEmailInitialized])
|
||||
|
||||
// Validate cert email
|
||||
useEffect(() => {
|
||||
if (certEmail && !useUserEmail) {
|
||||
setCertEmailValid(isValidEmail(certEmail))
|
||||
} else {
|
||||
setCertEmailValid(null)
|
||||
}
|
||||
}, [certEmail, useUserEmail])
|
||||
|
||||
const updateProfileMutation = useMutation({
|
||||
mutationFn: updateProfile,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['profile'] })
|
||||
toast.success(t('account.profileUpdated'))
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(t('account.profileUpdateFailed', { error: error.message }))
|
||||
},
|
||||
})
|
||||
|
||||
const updateSettingMutation = useMutation({
|
||||
mutationFn: (variables: { key: string; value: string; category: string }) =>
|
||||
updateSetting(variables.key, variables.value, variables.category),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] })
|
||||
toast.success(t('account.certEmailUpdated'))
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(t('account.certEmailUpdateFailed', { error: error.message }))
|
||||
},
|
||||
})
|
||||
|
||||
const regenerateMutation = useMutation({
|
||||
mutationFn: regenerateApiKey,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['profile'] })
|
||||
toast.success(t('account.apiKeyRegenerated'))
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(t('account.apiKeyRegenerateFailed', { error: error.message }))
|
||||
},
|
||||
})
|
||||
|
||||
const handleUpdateProfile = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!emailValid) return
|
||||
|
||||
// Check if email changed
|
||||
if (email !== profile?.email) {
|
||||
setPreviousEmail(profile?.email || '')
|
||||
setPendingProfileUpdate({ name, email })
|
||||
setShowPasswordPrompt(true)
|
||||
return
|
||||
}
|
||||
|
||||
updateProfileMutation.mutate({ name, email })
|
||||
}
|
||||
|
||||
const handlePasswordPromptSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!pendingProfileUpdate) return
|
||||
|
||||
setShowPasswordPrompt(false)
|
||||
|
||||
// If email changed, we might need to ask about cert email too
|
||||
// But first, let's update the profile with the password
|
||||
updateProfileMutation.mutate({
|
||||
name: pendingProfileUpdate.name,
|
||||
email: pendingProfileUpdate.email,
|
||||
current_password: confirmPasswordForUpdate
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setConfirmPasswordForUpdate('')
|
||||
// Check if we need to prompt for cert email
|
||||
// We do this AFTER success to ensure profile is updated
|
||||
// But wait, if we do it after success, the profile email is already new.
|
||||
// The user wanted to be asked.
|
||||
// Let's ask about cert email first? No, user said "Updateing email test the popup worked as expected"
|
||||
// But "I chose to keep my certificate email as the old email and it changed anyway"
|
||||
// This implies the logic below is flawed or the backend/frontend sync is weird.
|
||||
|
||||
// Let's show the cert email modal if the update was successful AND it was an email change
|
||||
setShowEmailConfirmModal(true)
|
||||
},
|
||||
onError: () => {
|
||||
setConfirmPasswordForUpdate('')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const confirmEmailUpdate = (updateCertEmail: boolean) => {
|
||||
setShowEmailConfirmModal(false)
|
||||
|
||||
if (updateCertEmail) {
|
||||
updateSettingMutation.mutate({
|
||||
key: 'caddy.email',
|
||||
value: email,
|
||||
category: 'caddy'
|
||||
})
|
||||
setCertEmail(email)
|
||||
setUseUserEmail(true)
|
||||
} else {
|
||||
// If user chose NO, we must ensure the cert email stays as the OLD email.
|
||||
// If settings['caddy.email'] is empty, it defaults to profile email (which is now NEW).
|
||||
// So we must explicitly save the OLD email.
|
||||
const savedEmail = settings?.['caddy.email']
|
||||
if (!savedEmail && previousEmail) {
|
||||
updateSettingMutation.mutate({
|
||||
key: 'caddy.email',
|
||||
value: previousEmail,
|
||||
category: 'caddy'
|
||||
})
|
||||
// Update local state immediately
|
||||
setCertEmail(previousEmail)
|
||||
setUseUserEmail(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateCertEmail = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!useUserEmail && !certEmailValid) return
|
||||
|
||||
const emailToSave = useUserEmail ? profile?.email : certEmail
|
||||
if (!emailToSave) return
|
||||
|
||||
updateSettingMutation.mutate({
|
||||
key: 'caddy.email',
|
||||
value: emailToSave,
|
||||
category: 'caddy'
|
||||
})
|
||||
}
|
||||
|
||||
// Compute disabled state for certificate email button
|
||||
// Button should be disabled when using custom email and it's invalid/empty const isCertEmailButtonDisabled = useUserEmail ? false : (certEmailValid !== true)
|
||||
|
||||
const handlePasswordChange = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast.error(t('account.passwordsDoNotMatch'))
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
await changePassword(oldPassword, newPassword)
|
||||
toast.success(t('account.passwordUpdated'))
|
||||
setOldPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
} catch (err) {
|
||||
const error = err as Error
|
||||
toast.error(error.message || t('account.passwordUpdateFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoadingProfile) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-brand-500/10 rounded-lg">
|
||||
<User className="h-6 w-6 text-brand-500" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-content-primary">{t('account.title')}</h1>
|
||||
</div>
|
||||
|
||||
{/* Profile Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-5 w-5 text-brand-500" />
|
||||
<CardTitle>{t('account.profile')}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>{t('account.profileDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleUpdateProfile}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-name" required>{t('common.name')}</Label>
|
||||
<Input
|
||||
id="profile-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-email" required>{t('auth.email')}</Label>
|
||||
<Input
|
||||
id="profile-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
error={emailValid === false ? t('errors.invalidEmail') : undefined}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end">
|
||||
<Button type="submit" isLoading={updateProfileMutation.isPending} disabled={emailValid === false}>
|
||||
{t('account.saveProfile')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* Certificate Email Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5 text-info" />
|
||||
<CardTitle>{t('account.certificateEmail')}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{t('account.certificateEmailDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleUpdateCertEmail}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id="useUserEmail"
|
||||
checked={useUserEmail}
|
||||
onCheckedChange={(checked) => {
|
||||
setUseUserEmail(checked === true)
|
||||
if (checked && profile) {
|
||||
setCertEmail(profile.email)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="useUserEmail" className="cursor-pointer">
|
||||
{t('account.useAccountEmail', { email: profile?.email })}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{!useUserEmail && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cert-email" required>{t('account.customEmail')}</Label>
|
||||
<Input
|
||||
id="cert-email"
|
||||
type="email"
|
||||
value={certEmail}
|
||||
onChange={(e) => setCertEmail(e.target.value)}
|
||||
required={!useUserEmail}
|
||||
error={certEmailValid === false ? t('errors.invalidEmail') : undefined}
|
||||
errorTestId="cert-email-error"
|
||||
aria-invalid={certEmailValid === false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={updateSettingMutation.isPending}
|
||||
disabled={useUserEmail ? false : certEmailValid !== true}
|
||||
data-use-user-email={useUserEmail}
|
||||
data-cert-email-valid={String(certEmailValid)}
|
||||
data-compute-disabled={String(useUserEmail ? false : certEmailValid !== true)}
|
||||
>
|
||||
{t('account.saveCertificateEmail')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* Password Change */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-success" />
|
||||
<CardTitle>{t('account.changePassword')}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>{t('account.changePasswordDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handlePasswordChange}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="current-password" required>{t('account.currentPassword')}</Label>
|
||||
<Input
|
||||
id="current-password"
|
||||
type="password"
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-password" required>{t('account.newPassword')}</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<PasswordStrengthMeter password={newPassword} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password" required>{t('account.confirmNewPassword')}</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
error={confirmPassword && newPassword !== confirmPassword ? t('account.passwordsDoNotMatch') : undefined}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end">
|
||||
<Button type="submit" isLoading={loading}>
|
||||
{t('account.updatePassword')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* API Key */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5 text-warning" />
|
||||
<CardTitle>{t('account.apiKey')}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{t('account.apiKeyDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={profile?.api_key_masked || ''}
|
||||
readOnly
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => regenerateMutation.mutate()}
|
||||
isLoading={regenerateMutation.isPending}
|
||||
title={t('account.regenerateApiKey')}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Alert variant="warning" title={t('account.securityNotice')}>
|
||||
{t('account.securityNoticeMessage')}
|
||||
</Alert>
|
||||
|
||||
{/* Password Prompt Modal */}
|
||||
{showPasswordPrompt && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<Card className="max-w-md w-full">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3 text-brand-500">
|
||||
<Shield className="h-6 w-6" />
|
||||
<CardTitle>{t('account.confirmPassword')}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{t('account.confirmPasswordDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handlePasswordPromptSubmit}>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-current-password" required>{t('account.currentPassword')}</Label>
|
||||
<Input
|
||||
id="confirm-current-password"
|
||||
type="password"
|
||||
placeholder={t('account.enterPassword')}
|
||||
value={confirmPasswordForUpdate}
|
||||
onChange={(e) => setConfirmPasswordForUpdate(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex-col gap-3">
|
||||
<Button type="submit" className="w-full" isLoading={updateProfileMutation.isPending}>
|
||||
{t('account.confirmAndUpdate')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowPasswordPrompt(false)
|
||||
setConfirmPasswordForUpdate('')
|
||||
setPendingProfileUpdate(null)
|
||||
}}
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Update Confirmation Modal */}
|
||||
{showEmailConfirmModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<Card className="max-w-md w-full">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3 text-warning">
|
||||
<AlertTriangle className="h-6 w-6" />
|
||||
<CardTitle>{t('account.updateCertEmailTitle')}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{t('account.updateCertEmailDescription', { email })}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col gap-3">
|
||||
<Button onClick={() => confirmEmailUpdate(true)} className="w-full">
|
||||
{t('account.yesUpdateCertEmail')}
|
||||
</Button>
|
||||
<Button onClick={() => confirmEmailUpdate(false)} variant="secondary" className="w-full">
|
||||
{t('account.noKeepEmail', { email: previousEmail || certEmail })}
|
||||
</Button>
|
||||
<Button onClick={() => setShowEmailConfirmModal(false)} variant="ghost" className="w-full">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
63
frontend/src/pages/PassthroughLanding.tsx
Normal file
63
frontend/src/pages/PassthroughLanding.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { Card } from '../components/ui/Card'
|
||||
import { Shield, LogOut } from 'lucide-react'
|
||||
|
||||
export default function PassthroughLanding() {
|
||||
const { t } = useTranslation()
|
||||
const { user, logout } = useAuth()
|
||||
const headingRef = useRef<HTMLHeadingElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
headingRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-light-bg dark:bg-dark-bg flex items-center justify-center p-4">
|
||||
<main className="w-full max-w-md" aria-labelledby="passthrough-heading">
|
||||
<Card className="p-8 text-center space-y-6">
|
||||
<div className="flex justify-center">
|
||||
<div className="p-3 bg-blue-900/20 rounded-full">
|
||||
<Shield className="h-8 w-8 text-blue-400" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h1
|
||||
id="passthrough-heading"
|
||||
ref={headingRef}
|
||||
tabIndex={-1}
|
||||
className="text-2xl font-bold text-gray-900 dark:text-white outline-none"
|
||||
>
|
||||
{t('passthrough.title')}
|
||||
</h1>
|
||||
{user?.name && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{user.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-content-secondary">
|
||||
{t('passthrough.description')}
|
||||
</p>
|
||||
|
||||
<p className="text-sm text-content-muted">
|
||||
{t('passthrough.noAccessToManagement')}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
onClick={logout}
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" aria-hidden="true" />
|
||||
{t('auth.logout')}
|
||||
</Button>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,11 +2,13 @@ import { Link, Outlet, useLocation } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PageShell } from '../components/layout/PageShell'
|
||||
import { cn } from '../utils/cn'
|
||||
import { Settings as SettingsIcon, Server, Mail, User, Bell } from 'lucide-react'
|
||||
import { Settings as SettingsIcon, Server, Mail, Bell, Users } from 'lucide-react'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
|
||||
export default function Settings() {
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
const { user } = useAuth()
|
||||
|
||||
const isActive = (path: string) => location.pathname === path
|
||||
|
||||
@@ -14,7 +16,7 @@ export default function Settings() {
|
||||
{ path: '/settings/system', label: t('settings.system'), icon: Server },
|
||||
{ path: '/settings/notifications', label: t('navigation.notifications'), icon: Bell },
|
||||
{ path: '/settings/smtp', label: t('settings.smtp'), icon: Mail },
|
||||
{ path: '/settings/account', label: t('settings.account'), icon: User },
|
||||
...(user?.role === 'admin' ? [{ path: '/settings/users', label: t('navigation.users'), icon: Users }] : []),
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useFocusTrap } from '../hooks/useFocusTrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
@@ -17,10 +18,14 @@ import {
|
||||
updateUser,
|
||||
updateUserPermissions,
|
||||
resendInvite,
|
||||
getProfile,
|
||||
updateProfile,
|
||||
regenerateApiKey,
|
||||
} from '../api/users'
|
||||
import type { User, InviteUserRequest, PermissionMode, UpdateUserPermissionsRequest } from '../api/users'
|
||||
import { getProxyHosts } from '../api/proxyHosts'
|
||||
import type { ProxyHost } from '../api/proxyHosts'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import {
|
||||
Users,
|
||||
UserPlus,
|
||||
@@ -36,6 +41,10 @@ import {
|
||||
Loader2,
|
||||
ExternalLink,
|
||||
AlertTriangle,
|
||||
Pencil,
|
||||
Key,
|
||||
Lock,
|
||||
UserCircle,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface InviteModalProps {
|
||||
@@ -47,9 +56,10 @@ interface InviteModalProps {
|
||||
function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const [email, setEmail] = useState('')
|
||||
const [emailError, setEmailError] = useState<string | null>(null)
|
||||
const [role, setRole] = useState<'user' | 'admin'>('user')
|
||||
const [role, setRole] = useState<'user' | 'admin' | 'passthrough'>('user')
|
||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>('allow_all')
|
||||
const [selectedHosts, setSelectedHosts] = useState<number[]>([])
|
||||
const [inviteResult, setInviteResult] = useState<{
|
||||
@@ -84,19 +94,7 @@ function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Keyboard navigation - close on Escape
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
useFocusTrap(dialogRef, isOpen, onClose)
|
||||
|
||||
// Fetch preview when email changes
|
||||
useEffect(() => {
|
||||
@@ -170,7 +168,7 @@ function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) {
|
||||
const handleClose = () => {
|
||||
setEmail('')
|
||||
setEmailError(null)
|
||||
setRole('user')
|
||||
setRole('user' as 'user' | 'admin' | 'passthrough')
|
||||
setPermissionMode('allow_all')
|
||||
setSelectedHosts([])
|
||||
setInviteResult(null)
|
||||
@@ -196,6 +194,7 @@ function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) {
|
||||
|
||||
{/* Layer 3: Form content (pointer-events-auto) */}
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="bg-dark-card border border-gray-800 rounded-lg w-full max-w-lg max-h-[90vh] overflow-y-auto pointer-events-auto"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@@ -287,15 +286,21 @@ function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) {
|
||||
<select
|
||||
id="invite-user-role"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as 'user' | 'admin')}
|
||||
onChange={(e) => setRole(e.target.value as 'user' | 'admin' | 'passthrough')}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="user">{t('users.roleUser')}</option>
|
||||
<option value="admin">{t('users.roleAdmin')}</option>
|
||||
<option value="passthrough">{t('users.rolePassthrough')}</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{role === 'admin' && t('users.roleAdminDescription')}
|
||||
{role === 'user' && t('users.roleUserDescription')}
|
||||
{role === 'passthrough' && t('users.rolePassthroughDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{role === 'user' && (
|
||||
{(role === 'user' || role === 'passthrough') && (
|
||||
<>
|
||||
<div className="w-full">
|
||||
<label htmlFor="invite-permission-mode" className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
@@ -411,6 +416,7 @@ interface PermissionsModalProps {
|
||||
function PermissionsModal({ isOpen, onClose, user, proxyHosts }: PermissionsModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>('allow_all')
|
||||
const [selectedHosts, setSelectedHosts] = useState<number[]>([])
|
||||
|
||||
@@ -422,23 +428,11 @@ function PermissionsModal({ isOpen, onClose, user, proxyHosts }: PermissionsModa
|
||||
}
|
||||
}, [user])
|
||||
|
||||
// Keyboard navigation - close on Escape
|
||||
const handleClose = useCallback(() => {
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [isOpen, handleClose])
|
||||
useFocusTrap(dialogRef, isOpen, handleClose)
|
||||
|
||||
const updatePermissionsMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
@@ -478,6 +472,7 @@ function PermissionsModal({ isOpen, onClose, user, proxyHosts }: PermissionsModa
|
||||
|
||||
{/* Layer 3: Form content (pointer-events-auto) */}
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="bg-dark-card border border-gray-800 rounded-lg w-full max-w-lg max-h-[90vh] overflow-y-auto pointer-events-auto"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@@ -566,12 +561,244 @@ function PermissionsModal({ isOpen, onClose, user, proxyHosts }: PermissionsModa
|
||||
)
|
||||
}
|
||||
|
||||
interface UserDetailModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
user: User | null
|
||||
isSelf: boolean
|
||||
}
|
||||
|
||||
function UserDetailModal({ isOpen, onClose, user, isSelf }: UserDetailModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const { changePassword } = useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [showPasswordSection, setShowPasswordSection] = useState(false)
|
||||
const [apiKeyMasked, setApiKeyMasked] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setName(user.name || '')
|
||||
setEmail(user.email || '')
|
||||
setShowPasswordSection(false)
|
||||
setCurrentPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
}
|
||||
}, [user])
|
||||
|
||||
// Fetch profile for API key info when editing self
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ['profile'],
|
||||
queryFn: getProfile,
|
||||
enabled: isOpen && isSelf,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (profile) {
|
||||
setApiKeyMasked(profile.api_key_masked || '')
|
||||
}
|
||||
}, [profile])
|
||||
|
||||
useFocusTrap(dialogRef, isOpen, onClose)
|
||||
|
||||
const profileMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (isSelf) {
|
||||
return updateProfile({ name, email })
|
||||
}
|
||||
return updateUser(user!.id, { name, email })
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] })
|
||||
if (isSelf) queryClient.invalidateQueries({ queryKey: ['profile'] })
|
||||
toast.success(t('users.profileUpdated'))
|
||||
onClose()
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const err = error as { response?: { data?: { error?: string } } }
|
||||
toast.error(err.response?.data?.error || t('users.profileUpdateFailed'))
|
||||
},
|
||||
})
|
||||
|
||||
const passwordMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (newPassword !== confirmPassword) {
|
||||
throw new Error(t('users.passwordMismatch'))
|
||||
}
|
||||
return changePassword(currentPassword, newPassword)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('users.passwordChanged'))
|
||||
setShowPasswordSection(false)
|
||||
setCurrentPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const err = error as { message?: string }
|
||||
toast.error(err.message || t('users.passwordChangeFailed'))
|
||||
},
|
||||
})
|
||||
|
||||
const regenApiKeyMutation = useMutation({
|
||||
mutationFn: regenerateApiKey,
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['profile'] })
|
||||
setApiKeyMasked(data.api_key_masked)
|
||||
toast.success(t('users.apiKeyRegenerated'))
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const err = error as { response?: { data?: { error?: string } } }
|
||||
toast.error(err.response?.data?.error || t('users.apiKeyRegenerateFailed'))
|
||||
},
|
||||
})
|
||||
|
||||
if (!isOpen || !user) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 bg-black/50 z-40" onClick={onClose} />
|
||||
<div className="fixed inset-0 flex items-center justify-center pointer-events-none z-50">
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="bg-dark-card border border-gray-800 rounded-lg w-full max-w-lg max-h-[90vh] overflow-y-auto pointer-events-auto"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="user-detail-modal-title"
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-800">
|
||||
<h3 id="user-detail-modal-title" className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Pencil className="h-5 w-5" />
|
||||
{isSelf ? t('users.myProfile') : t('users.editUser')}
|
||||
</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-white" aria-label={t('common.close')}>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Name & Email */}
|
||||
<div>
|
||||
<Input
|
||||
label={t('common.name')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
label={t('users.emailAddress')}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
onClick={() => profileMutation.mutate()}
|
||||
isLoading={profileMutation.isPending}
|
||||
className="flex-1"
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Password Section — self only */}
|
||||
{isSelf && (
|
||||
<div className="border-t border-gray-700 pt-4">
|
||||
<button
|
||||
onClick={() => setShowPasswordSection(!showPasswordSection)}
|
||||
className="flex items-center gap-2 text-sm font-medium text-gray-300 hover:text-white"
|
||||
>
|
||||
<Lock className="h-4 w-4" />
|
||||
{t('users.changePassword')}
|
||||
</button>
|
||||
{showPasswordSection && (
|
||||
<div className="mt-3 space-y-3">
|
||||
<Input
|
||||
id="current-password"
|
||||
label={t('users.currentPassword')}
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
id="new-password"
|
||||
label={t('users.newPassword')}
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
label={t('users.confirmPassword')}
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
{newPassword && confirmPassword && newPassword !== confirmPassword && (
|
||||
<p className="text-xs text-red-400" role="alert">{t('users.passwordMismatch')}</p>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => passwordMutation.mutate()}
|
||||
isLoading={passwordMutation.isPending}
|
||||
disabled={!currentPassword || !newPassword || newPassword !== confirmPassword}
|
||||
variant="secondary"
|
||||
>
|
||||
{t('users.changePassword')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key Section — self only */}
|
||||
{isSelf && (
|
||||
<div className="border-t border-gray-700 pt-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Key className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm font-medium text-gray-300">{t('users.apiKey')}</span>
|
||||
</div>
|
||||
{apiKeyMasked && (
|
||||
<p className="text-sm font-mono text-gray-500 mb-2">{apiKeyMasked}</p>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (confirm(t('users.apiKeyConfirm'))) {
|
||||
regenApiKeyMutation.mutate()
|
||||
}
|
||||
}}
|
||||
isLoading={regenApiKeyMutation.isPending}
|
||||
>
|
||||
{t('users.regenerateApiKey')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const { t } = useTranslation()
|
||||
const { user: authUser } = useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
const [inviteModalOpen, setInviteModalOpen] = useState(false)
|
||||
const [permissionsModalOpen, setPermissionsModalOpen] = useState(false)
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null)
|
||||
const [detailModalOpen, setDetailModalOpen] = useState(false)
|
||||
const [detailUser, setDetailUser] = useState<User | null>(null)
|
||||
const [isSelfEdit, setIsSelfEdit] = useState(false)
|
||||
|
||||
const { data: users, isLoading } = useQuery({
|
||||
queryKey: ['users'],
|
||||
@@ -630,6 +857,14 @@ export default function UsersPage() {
|
||||
setPermissionsModalOpen(true)
|
||||
}
|
||||
|
||||
const openDetail = (user: User, self: boolean) => {
|
||||
setDetailUser(user)
|
||||
setIsSelfEdit(self)
|
||||
setDetailModalOpen(true)
|
||||
}
|
||||
|
||||
const currentUser = users?.find((u) => u.id === authUser?.user_id)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
@@ -651,6 +886,26 @@ export default function UsersPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* My Profile Card */}
|
||||
{currentUser && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<UserCircle className="h-10 w-10 text-blue-500" />
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-white">{t('users.myProfile')}</h2>
|
||||
<p className="text-sm text-white">{currentUser.name || t('users.noName')}</p>
|
||||
<p className="text-xs text-gray-500">{currentUser.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={() => openDetail(currentUser, true)}>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
{t('users.editUser')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
@@ -678,10 +933,14 @@ export default function UsersPage() {
|
||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
user.role === 'admin'
|
||||
? 'bg-purple-900/30 text-purple-400'
|
||||
: 'bg-blue-900/30 text-blue-400'
|
||||
: user.role === 'passthrough'
|
||||
? 'bg-gray-900/30 text-gray-400'
|
||||
: 'bg-blue-900/30 text-blue-400'
|
||||
}`}
|
||||
>
|
||||
{user.role}
|
||||
{user.role === 'admin' && t('users.roleAdmin')}
|
||||
{user.role === 'user' && t('users.roleUser')}
|
||||
{user.role === 'passthrough' && t('users.rolePassthrough')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
@@ -721,6 +980,14 @@ export default function UsersPage() {
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => openDetail(user, user.id === authUser?.user_id)}
|
||||
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-800 rounded"
|
||||
title={t('users.editUser')}
|
||||
aria-label={t('users.editUser')}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
{user.invite_status === 'pending' && (
|
||||
<button
|
||||
onClick={() => resendInviteMutation.mutate(user.id)}
|
||||
@@ -779,6 +1046,16 @@ export default function UsersPage() {
|
||||
user={selectedUser}
|
||||
proxyHosts={proxyHosts}
|
||||
/>
|
||||
|
||||
<UserDetailModal
|
||||
isOpen={detailModalOpen}
|
||||
onClose={() => {
|
||||
setDetailModalOpen(false)
|
||||
setDetailUser(null)
|
||||
}}
|
||||
user={detailUser}
|
||||
isSelf={isSelfEdit}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ const translations: Record<string, string> = {
|
||||
'settings.system': 'System',
|
||||
'navigation.notifications': 'Notifications',
|
||||
'settings.smtp': 'Email (SMTP)',
|
||||
'settings.account': 'Account',
|
||||
'navigation.users': 'Users',
|
||||
}
|
||||
|
||||
const t = (key: string) => translations[key] ?? key
|
||||
@@ -19,6 +19,10 @@ vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t }),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useAuth', () => ({
|
||||
useAuth: () => ({ user: { user_id: 1, role: 'admin', name: 'Admin' } }),
|
||||
}))
|
||||
|
||||
const renderWithRoute = (route: string) =>
|
||||
render(
|
||||
<MemoryRouter initialEntries={[route]}>
|
||||
@@ -27,7 +31,7 @@ const renderWithRoute = (route: string) =>
|
||||
<Route path="system" element={<div>System Page</div>} />
|
||||
<Route path="notifications" element={<div>Notifications Page</div>} />
|
||||
<Route path="smtp" element={<div>SMTP Page</div>} />
|
||||
<Route path="account" element={<div>Account Page</div>} />
|
||||
<Route path="users" element={<div>Users Page</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
@@ -46,12 +50,12 @@ describe('Settings page', () => {
|
||||
expect(screen.getByText('System Page')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps navigation order consistent', () => {
|
||||
it('keeps navigation order consistent for admin', () => {
|
||||
renderWithRoute('/settings/notifications')
|
||||
|
||||
const links = screen.getAllByRole('link')
|
||||
const labels = links.map(link => link.textContent)
|
||||
|
||||
expect(labels).toEqual(['System', 'Notifications', 'Email (SMTP)', 'Account'])
|
||||
expect(labels).toEqual(['System', 'Notifications', 'Email (SMTP)', 'Users'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as proxyHostsApi from '../../api/proxyHosts'
|
||||
import client from '../../api/client'
|
||||
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
|
||||
import { toast } from '../../utils/toast'
|
||||
import { useAuth } from '../../hooks/useAuth'
|
||||
|
||||
// Mock APIs
|
||||
vi.mock('../../api/users', () => ({
|
||||
@@ -22,6 +23,20 @@ vi.mock('../../api/users', () => ({
|
||||
acceptInvite: vi.fn(),
|
||||
previewInviteURL: vi.fn(),
|
||||
resendInvite: vi.fn(),
|
||||
getProfile: vi.fn(),
|
||||
updateProfile: vi.fn(),
|
||||
regenerateApiKey: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useAuth', () => ({
|
||||
useAuth: vi.fn().mockReturnValue({
|
||||
user: { user_id: 1, role: 'admin', name: 'Admin User', email: 'admin@example.com' },
|
||||
changePassword: vi.fn().mockResolvedValue(undefined),
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/proxyHosts', () => ({
|
||||
@@ -78,6 +93,18 @@ const mockUsers = [
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
uuid: '999-000',
|
||||
email: 'passthrough@example.com',
|
||||
name: 'Passthrough User',
|
||||
role: 'passthrough' as const,
|
||||
enabled: true,
|
||||
invite_status: 'accepted' as const,
|
||||
permission_mode: 'allow_all' as const,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
const mockProxyHosts = [
|
||||
@@ -127,8 +154,8 @@ describe('UsersPage', () => {
|
||||
expect(screen.getByText('User Management')).toBeTruthy()
|
||||
})
|
||||
|
||||
expect(screen.getByText('Admin User')).toBeTruthy()
|
||||
expect(screen.getByText('admin@example.com')).toBeTruthy()
|
||||
expect(screen.getAllByText('Admin User').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('admin@example.com').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('Regular User')).toBeTruthy()
|
||||
expect(screen.getByText('user@example.com')).toBeTruthy()
|
||||
})
|
||||
@@ -346,6 +373,58 @@ describe('UsersPage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('renders passthrough role badge', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Passthrough')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders My Profile card for current user', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('My Profile')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows passthrough option in invite role select', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('Invite User')).toBeTruthy())
|
||||
await user.click(screen.getByRole('button', { name: /Invite User/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
const roleSelect = screen.getByLabelText('Role') as HTMLSelectElement
|
||||
const options = Array.from(roleSelect.options).map(o => o.value)
|
||||
expect(options).toContain('passthrough')
|
||||
})
|
||||
})
|
||||
|
||||
it('opens detail modal when edit button is clicked', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Regular User')).toBeTruthy())
|
||||
|
||||
const user = userEvent.setup()
|
||||
const editButtons = screen.getAllByTitle('Edit User')
|
||||
await user.click(editButtons[1])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog', { name: /Edit User/i })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL Preview in InviteModal', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
@@ -525,4 +604,264 @@ describe('UsersPage', () => {
|
||||
expect(previewQuery).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('InviteModal role reset on close', () => {
|
||||
it('resets role to user when modal is closed', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('Invite User')).toBeInTheDocument())
|
||||
|
||||
// Open invite modal
|
||||
await user.click(screen.getByRole('button', { name: /Invite User/i }))
|
||||
await waitFor(() => expect(screen.getByLabelText(/Role/i)).toBeInTheDocument())
|
||||
|
||||
// Change role to passthrough
|
||||
await user.selectOptions(screen.getByLabelText(/Role/i), 'passthrough')
|
||||
expect((screen.getByLabelText(/Role/i) as HTMLSelectElement).value).toBe('passthrough')
|
||||
|
||||
// Close via Cancel button (calls handleClose which resets role)
|
||||
await user.click(screen.getByRole('button', { name: /^Cancel$/i }))
|
||||
|
||||
// Reopen modal — role should be reset to 'user'
|
||||
await user.click(screen.getByRole('button', { name: /Invite User/i }))
|
||||
await waitFor(() => expect(screen.getByLabelText(/Role/i)).toBeInTheDocument())
|
||||
expect((screen.getByLabelText(/Role/i) as HTMLSelectElement).value).toBe('user')
|
||||
})
|
||||
})
|
||||
|
||||
describe('UserDetailModal', () => {
|
||||
it('shows profile update error via toast', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(usersApi.updateUser).mockRejectedValue({
|
||||
response: { data: { error: 'Email already in use' } },
|
||||
})
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('Regular User')).toBeInTheDocument())
|
||||
|
||||
// Click Edit User for Regular User (second "Edit User" button in the table)
|
||||
const editButtons = screen.getAllByTitle('Edit User')
|
||||
await user.click(editButtons[1]) // index 1 = Regular User row
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||
|
||||
// Click Save
|
||||
await user.click(screen.getByRole('button', { name: /^Save$/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('toggles the password change section', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(usersApi.getProfile).mockResolvedValue({ api_key_masked: 'abc-****' } as never)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('My Profile')).toBeInTheDocument())
|
||||
|
||||
// Click Edit User in My Profile card (opens with isSelf=true) — card button is first
|
||||
await user.click(screen.getAllByRole('button', { name: /Edit User/i })[0])
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||
|
||||
// Password fields should not be visible until toggled
|
||||
expect(screen.queryByLabelText(/Current Password/i)).toBeNull()
|
||||
|
||||
// Click the Change Password toggle
|
||||
await user.click(screen.getAllByRole('button', { name: /Change Password/i })[0])
|
||||
|
||||
// Password fields should now be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Current Password/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('submits password change successfully', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(usersApi.getProfile).mockResolvedValue({ api_key_masked: 'abc-****' } as never)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('My Profile')).toBeInTheDocument())
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: /Edit User/i })[0])
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||
|
||||
// Expand password section
|
||||
await user.click(screen.getAllByRole('button', { name: /Change Password/i })[0])
|
||||
await waitFor(() => expect(screen.getByLabelText(/Current Password/i)).toBeInTheDocument())
|
||||
|
||||
// Fill matching passwords
|
||||
await user.type(screen.getByLabelText(/Current Password/i), 'oldpass123')
|
||||
await user.type(screen.getByLabelText(/^New Password/i), 'newpass456')
|
||||
await user.type(screen.getByLabelText(/Confirm Password/i), 'newpass456')
|
||||
|
||||
// Submit button (second "Change Password" button — the submit one)
|
||||
const changePasswordButtons = screen.getAllByRole('button', { name: /Change Password/i })
|
||||
const submitButton = changePasswordButtons[changePasswordButtons.length - 1]
|
||||
await user.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error toast on password change failure', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(usersApi.getProfile).mockResolvedValue({ api_key_masked: 'abc-****' } as never)
|
||||
vi.mocked(useAuth).mockReturnValue({
|
||||
user: { user_id: 1, role: 'admin', name: 'Admin User', email: 'admin@example.com' },
|
||||
changePassword: vi.fn().mockRejectedValue(new Error('Invalid current password')),
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
})
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('My Profile')).toBeInTheDocument())
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: /Edit User/i })[0])
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: /Change Password/i })[0])
|
||||
await waitFor(() => expect(screen.getByLabelText(/Current Password/i)).toBeInTheDocument())
|
||||
|
||||
await user.type(screen.getByLabelText(/Current Password/i), 'wrongpass')
|
||||
await user.type(screen.getByLabelText(/^New Password/i), 'newpass456')
|
||||
await user.type(screen.getByLabelText(/Confirm Password/i), 'newpass456')
|
||||
|
||||
const changePasswordButtons = screen.getAllByRole('button', { name: /Change Password/i })
|
||||
const submitButton = changePasswordButtons[changePasswordButtons.length - 1]
|
||||
await user.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('Invalid current password')
|
||||
})
|
||||
})
|
||||
|
||||
it('regenerates API key when user confirms', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(usersApi.getProfile).mockResolvedValue({ api_key_masked: 'old-****' } as never)
|
||||
vi.mocked(usersApi.regenerateApiKey).mockResolvedValue({ api_key_masked: 'new-****' } as never)
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('My Profile')).toBeInTheDocument())
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: /Edit User/i })[0])
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Regenerate API Key/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Regenerate API Key/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(usersApi.regenerateApiKey).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('updates self profile and shows profile updated toast', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(usersApi.updateProfile).mockResolvedValue({ message: 'ok' } as never)
|
||||
vi.mocked(usersApi.getProfile).mockResolvedValue({ api_key_masked: 'abc-****' } as never)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('My Profile')).toBeInTheDocument())
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: /Edit User/i })[0])
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
await user.click(within(dialog).getByRole('button', { name: /^Save$/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(usersApi.updateProfile).toHaveBeenCalled()
|
||||
expect(toast.success).toHaveBeenCalledWith('Profile updated successfully')
|
||||
})
|
||||
})
|
||||
|
||||
it('updates non-self user profile and shows success toast', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(usersApi.updateUser).mockResolvedValue({ message: 'ok' } as never)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('Regular User')).toBeInTheDocument())
|
||||
|
||||
const editButtons = screen.getAllByTitle('Edit User')
|
||||
await user.click(editButtons[1])
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
await user.click(within(dialog).getByRole('button', { name: /^Save$/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(usersApi.updateUser).toHaveBeenCalledWith(2, expect.objectContaining({
|
||||
email: 'user@example.com',
|
||||
}))
|
||||
expect(toast.success).toHaveBeenCalledWith('Profile updated successfully')
|
||||
})
|
||||
})
|
||||
|
||||
it('displays masked API key text when profile query resolves', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(usersApi.getProfile).mockResolvedValue({ api_key_masked: 'SK-****-masktest' } as never)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('My Profile')).toBeInTheDocument())
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: /Edit User/i })[0])
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('SK-****-masktest')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows password mismatch alert when new and confirm passwords differ', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(usersApi.getProfile).mockResolvedValue({ api_key_masked: '' } as never)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('My Profile')).toBeInTheDocument())
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: /Edit User/i })[0])
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: /Change Password/i })[0])
|
||||
await waitFor(() => expect(screen.getByLabelText(/Current Password/i)).toBeInTheDocument())
|
||||
|
||||
await user.type(screen.getByLabelText(/Current Password/i), 'current123')
|
||||
await user.type(screen.getByLabelText(/^New Password/i), 'newpass1')
|
||||
await user.type(screen.getByLabelText(/Confirm Password/i), 'different2')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument()
|
||||
expect(screen.getByText('Passwords do not match')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -2556,9 +2556,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
||||
@@ -162,8 +162,9 @@ test.describe('Admin Onboarding & Setup', () => {
|
||||
});
|
||||
|
||||
await test.step('Verify user info displayed', async () => {
|
||||
// Admin name or email should be visible in header/profile area
|
||||
const accountLink = page.locator('a[href*="settings/account"]');
|
||||
// Admin name or email should be visible in header/profile area.
|
||||
// The header profile link points to /settings/users after user management consolidation.
|
||||
const accountLink = page.locator('a[href*="settings/users"]');
|
||||
await expect(accountLink).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
});
|
||||
|
||||
9
tests/fixtures/auth-fixtures.ts
vendored
9
tests/fixtures/auth-fixtures.ts
vendored
@@ -403,19 +403,20 @@ export const test = base.extend<AuthFixtures>({
|
||||
},
|
||||
|
||||
/**
|
||||
* Guest user (read-only) fixture
|
||||
* Use for testing read-only access
|
||||
* Guest user (restricted access) fixture — using 'passthrough' role
|
||||
* (the 'guest' role was removed in PR-3; 'passthrough' is the equivalent
|
||||
* lowest-privilege role in the Admin / User / Passthrough model)
|
||||
*/
|
||||
guestUser: async ({ testData }, use) => {
|
||||
const user = await testData.createUser({
|
||||
name: `Test Guest ${Date.now()}`,
|
||||
email: `guest-${Date.now()}@test.local`,
|
||||
password: TEST_PASSWORD,
|
||||
role: 'guest',
|
||||
role: 'passthrough',
|
||||
});
|
||||
await use({
|
||||
...user,
|
||||
role: 'guest',
|
||||
role: 'passthrough',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -29,7 +29,126 @@ test.describe('Account Settings', () => {
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
test.describe('Profile Management', () => {
|
||||
/**
|
||||
* PR-3: Account Route Redirect (F8)
|
||||
*
|
||||
* Verifies that legacy account settings routes redirect to the
|
||||
* consolidated Users page at /settings/users.
|
||||
*/
|
||||
test.describe('PR-3: Account Route Redirect (F8)', () => {
|
||||
// Outer beforeEach already handles login. These tests re-navigate to the legacy
|
||||
// routes to assert the React Router <Navigate> redirects them to /settings/users.
|
||||
test('should redirect /settings/account to /settings/users', async ({ page }) => {
|
||||
await page.goto('/settings/account');
|
||||
await page.waitForURL(/\/settings\/users/, { timeout: 15000 });
|
||||
await expect(page).toHaveURL(/\/settings\/users/);
|
||||
});
|
||||
|
||||
test('should redirect /settings/account-management to /settings/users', async ({ page }) => {
|
||||
await page.goto('/settings/account-management');
|
||||
await page.waitForURL(/\/settings\/users/, { timeout: 15000 });
|
||||
await expect(page).toHaveURL(/\/settings\/users/);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* PR-3: Self-Service Profile via Users Page (F10)
|
||||
*
|
||||
* Verifies that an admin can manage their own profile (name, email,
|
||||
* password, API key) through the UserDetailModal on /settings/users.
|
||||
* This replaces the deleted Account.tsx page.
|
||||
*/
|
||||
test.describe('PR-3: Self-Service Profile via Users Page (F10)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Outer beforeEach already handles login. Navigate to the users page
|
||||
// and wait for the user data to fully render before each test.
|
||||
await page.goto('/settings/users');
|
||||
await waitForLoadingComplete(page);
|
||||
// Wait for user data to load — the My Profile card's Edit User button
|
||||
// only appears after the API returns the current user's profile.
|
||||
await page.getByRole('button', { name: 'Edit User' }).first().waitFor({
|
||||
state: 'visible',
|
||||
timeout: 15000,
|
||||
});
|
||||
});
|
||||
|
||||
test('should open My Profile modal from the My Profile card', async ({ page }) => {
|
||||
await test.step('Click Edit User in the My Profile card', async () => {
|
||||
// The My Profile card button is the first "Edit User" button in the DOM
|
||||
await page.getByRole('button', { name: 'Edit User' }).first().click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify dialog is labelled "My Profile"', async () => {
|
||||
await expect(
|
||||
page.getByRole('dialog').getByRole('heading', { name: 'My Profile' })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify name and email fields are editable', async () => {
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog.locator('input').first()).toBeVisible();
|
||||
await expect(dialog.locator('input[type="email"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test('should display Change Password toggle in My Profile modal (self-only)', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Edit User' }).first().click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog.getByRole('button', { name: 'Change Password' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should reveal password fields after clicking Change Password toggle', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Edit User' }).first().click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
|
||||
await test.step('Password fields are hidden before toggling', async () => {
|
||||
await expect(dialog.locator('#current-password')).not.toBeVisible();
|
||||
await expect(dialog.locator('#new-password')).not.toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Click Change Password to expand the section', async () => {
|
||||
await dialog.getByRole('button', { name: 'Change Password' }).click();
|
||||
});
|
||||
|
||||
await test.step('Password fields are now visible', async () => {
|
||||
await expect(dialog.locator('#current-password')).toBeVisible();
|
||||
await expect(dialog.locator('#new-password')).toBeVisible();
|
||||
await expect(dialog.locator('#confirm-password')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test('should display API Key section in My Profile modal (self-only)', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Edit User' }).first().click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog.getByText('API Key', { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByRole('button', { name: 'Regenerate API Key' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should have accessible structure in My Profile modal', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Edit User' }).first().click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
await test.step('Dialog has accessible heading', async () => {
|
||||
await expect(dialog.getByRole('heading', { name: 'My Profile' })).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Close button has accessible label', async () => {
|
||||
await expect(dialog.getByRole('button', { name: /close/i })).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// These tests reference the deleted Account.tsx page (removed in PR-2b). Equivalent
|
||||
// functionality is covered by the "PR-3: Self-Service Profile via Users Page (F10)" suite above.
|
||||
test.describe.skip('Profile Management', () => {
|
||||
/**
|
||||
* Test: Profile displays correctly
|
||||
* Verifies that user profile information is displayed on load.
|
||||
@@ -200,7 +319,9 @@ test.describe('Account Settings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Certificate Email', () => {
|
||||
// These tests reference Certificate Email UI elements (#useUserEmail, #cert-email) from the deleted
|
||||
// Account.tsx page (removed in PR-2b). Certificate email settings are not present in UsersPage.tsx.
|
||||
test.describe.skip('Certificate Email', () => {
|
||||
/**
|
||||
* Test: Toggle use account email checkbox
|
||||
* Verifies the checkbox toggles custom email field visibility.
|
||||
@@ -386,7 +507,11 @@ test.describe('Account Settings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Password Change', () => {
|
||||
// These tests reference password fields (#current-password, #new-password, #confirm-password)
|
||||
// from the deleted Account.tsx page. In UsersPage.tsx these fields are inside the UserDetailModal
|
||||
// (only visible after clicking Edit User → Change Password). Equivalent coverage is provided by
|
||||
// the 'PR-3: Self-Service Profile via Users Page (F10)' suite above.
|
||||
test.describe.skip('Password Change', () => {
|
||||
/**
|
||||
* Test: Change password with valid inputs
|
||||
* Verifies password can be changed successfully.
|
||||
@@ -565,7 +690,10 @@ test.describe('Account Settings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('API Key Management', () => {
|
||||
// These tests reference API key elements from the deleted Account.tsx page. In UsersPage.tsx
|
||||
// the API key section is inside the UserDetailModal (only visible after clicking Edit User).
|
||||
// Equivalent coverage is provided by the 'PR-3: Self-Service Profile via Users Page (F10)' suite above.
|
||||
test.describe.skip('API Key Management', () => {
|
||||
/**
|
||||
* Test: API key is displayed
|
||||
* Verifies API key section shows the key value.
|
||||
@@ -691,7 +819,10 @@ test.describe('Account Settings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Accessibility', () => {
|
||||
// These tests reference form labels and IDs (#profile-name, #profile-email, #useUserEmail)
|
||||
// from the deleted Account.tsx page (removed in PR-2b). Accessibility of the replacement UI
|
||||
// is covered by the 'PR-3: Self-Service Profile via Users Page (F10)' suite above.
|
||||
test.describe.skip('Accessibility', () => {
|
||||
/**
|
||||
* Test: Keyboard navigation through account settings
|
||||
* Uses increased loop counts and waitForTimeout for CI reliability
|
||||
|
||||
@@ -329,10 +329,13 @@ test.describe('Admin-User E2E Workflow', () => {
|
||||
expect(duration).toBeLessThan(5000);
|
||||
});
|
||||
|
||||
await test.step('STEP 2: Assign User role', async () => {
|
||||
await test.step('STEP 2: Update user record (triggers user_update audit event)', async () => {
|
||||
// Sending { role: 'user' } would be a no-op (user was already created with role:'user')
|
||||
// and the backend only writes the audit log when at least one field actually changes.
|
||||
// Update the name instead to guarantee a real write and a user_update audit entry.
|
||||
const token = await getAuthToken(page);
|
||||
const updateRoleResponse = await page.request.put(`/api/v1/users/${createdUserId}`, {
|
||||
data: { role: 'user' },
|
||||
data: { name: `${testUser.name} (updated)` },
|
||||
headers: buildAuthHeaders(token),
|
||||
});
|
||||
|
||||
@@ -428,21 +431,19 @@ test.describe('Admin-User E2E Workflow', () => {
|
||||
await loginWithCredentials(page, adminEmail, TEST_PASSWORD);
|
||||
|
||||
const token = await getAuthToken(page);
|
||||
// STEP 1 logs user_create; STEP 2 (PUT /users/:id with role:'user') logs user_update.
|
||||
// Both events must be present.
|
||||
await expect.poll(async () => {
|
||||
const createEntries = await getAuditLogEntries(page, token, {
|
||||
const auditEntries = await getAuditLogEntries(page, token, {
|
||||
limit: 100,
|
||||
maxPages: 8,
|
||||
});
|
||||
const updateEntries = await getAuditLogEntries(page, token, {
|
||||
limit: 100,
|
||||
maxPages: 8,
|
||||
});
|
||||
const createEntry = findLifecycleEntry(createEntries, testUser.email, 'user_create');
|
||||
const updateEntry = findLifecycleEntry(updateEntries, testUser.email, 'user_update');
|
||||
const createEntry = findLifecycleEntry(auditEntries, testUser.email, 'user_create');
|
||||
const updateEntry = findLifecycleEntry(auditEntries, testUser.email, 'user_update');
|
||||
return Number(Boolean(createEntry)) + Number(Boolean(updateEntry));
|
||||
}, {
|
||||
timeout: 30000,
|
||||
message: `Expected user lifecycle audit entries for ${testUser.email}`,
|
||||
message: `Expected both user_create and user_update audit entries for ${testUser.email}`,
|
||||
}).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -708,3 +709,167 @@ test.describe('Admin-User E2E Workflow', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* PR-3: Passthrough User — Access Restriction (F4)
|
||||
*
|
||||
* Verifies that a passthrough-role user is redirected to the
|
||||
* PassthroughLanding page when they attempt to access management routes,
|
||||
* and that they cannot reach the admin Users page.
|
||||
*/
|
||||
test.describe('PR-3: Passthrough User Access Restriction (F4)', () => {
|
||||
let adminEmail = '';
|
||||
|
||||
test.beforeEach(async ({ page, adminUser }) => {
|
||||
await resetSecurityState(page);
|
||||
adminEmail = adminUser.email;
|
||||
await loginUser(page, adminUser);
|
||||
await waitForLoadingComplete(page, { timeout: 15000 });
|
||||
});
|
||||
|
||||
test('passthrough user is redirected to PassthroughLanding when accessing management routes', async ({ page }) => {
|
||||
const suffix = uniqueSuffix();
|
||||
const ptUser = {
|
||||
email: `passthrough-${suffix}@test.local`,
|
||||
name: `Passthrough User ${suffix}`,
|
||||
password: 'PassthroughPass123!',
|
||||
role: 'passthrough' as 'admin' | 'user' | 'passthrough',
|
||||
};
|
||||
let ptUserId: string | number | undefined;
|
||||
|
||||
await test.step('Admin creates a passthrough-role user directly', async () => {
|
||||
const token = await page.evaluate(() => localStorage.getItem('charon_auth_token') || '');
|
||||
const resp = await page.request.post('/api/v1/users', {
|
||||
data: ptUser,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
expect(resp.ok()).toBe(true);
|
||||
const body = await resp.json();
|
||||
ptUserId = body.id;
|
||||
});
|
||||
|
||||
await test.step('Admin logs out', async () => {
|
||||
await logoutUser(page);
|
||||
});
|
||||
|
||||
await test.step('Passthrough user logs in', async () => {
|
||||
await navigateToLogin(page);
|
||||
await loginWithCredentials(page, ptUser.email, ptUser.password);
|
||||
// Wait for the initial post-login navigation to settle before probing routes
|
||||
await page.waitForURL(/^\/?((?!login).)*$/, { timeout: 10000 }).catch(() => {});
|
||||
});
|
||||
|
||||
await test.step('Passthrough user navigating to management route is redirected to /passthrough', async () => {
|
||||
await page.goto('/settings/users', { waitUntil: 'domcontentloaded' }).catch(() => {});
|
||||
await page.waitForURL(/\/passthrough/, { timeout: 15000 });
|
||||
await expect(page).toHaveURL(/\/passthrough/);
|
||||
});
|
||||
|
||||
await test.step('PassthroughLanding displays welcome heading and no-access message', async () => {
|
||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(/do not have access to the management interface/i)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('PassthroughLanding shows a logout button', async () => {
|
||||
await expect(page.getByRole('button', { name: /logout/i })).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Cleanup: admin logs back in and deletes passthrough user', async () => {
|
||||
// Logout passthrough user
|
||||
await page.getByRole('button', { name: /logout/i }).click();
|
||||
await page.waitForURL(/login/, { timeout: 10000 });
|
||||
|
||||
// Login as admin
|
||||
await loginWithCredentials(page, adminEmail, TEST_PASSWORD);
|
||||
const token = await page.evaluate(() => localStorage.getItem('charon_auth_token') || '');
|
||||
if (ptUserId !== undefined) {
|
||||
await page.request.delete(`/api/v1/users/${ptUserId}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* PR-3: Regular User — No Admin-Only Nav Items (F9)
|
||||
*
|
||||
* Verifies that a regular (non-admin) user does not see the "Users"
|
||||
* navigation item, which is restricted to admins only.
|
||||
*/
|
||||
test.describe('PR-3: Regular User Has No Admin Navigation Items (F9)', () => {
|
||||
let adminEmail = '';
|
||||
|
||||
test.beforeEach(async ({ page, adminUser }) => {
|
||||
await resetSecurityState(page);
|
||||
adminEmail = adminUser.email;
|
||||
await loginUser(page, adminUser);
|
||||
await waitForLoadingComplete(page, { timeout: 15000 });
|
||||
});
|
||||
|
||||
test('regular user does not see the Users navigation item', async ({ page }) => {
|
||||
const suffix = uniqueSuffix();
|
||||
const regularUserData = {
|
||||
email: `navtest-user-${suffix}@test.local`,
|
||||
name: `Nav Test User ${suffix}`,
|
||||
password: 'NavTestPass123!',
|
||||
role: 'user' as 'admin' | 'user' | 'passthrough',
|
||||
};
|
||||
let regularUserId: string | number | undefined;
|
||||
|
||||
await test.step('Admin creates a regular user', async () => {
|
||||
const token = await page.evaluate(() => localStorage.getItem('charon_auth_token') || '');
|
||||
const resp = await page.request.post('/api/v1/users', {
|
||||
data: regularUserData,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
expect(resp.ok()).toBe(true);
|
||||
const body = await resp.json();
|
||||
regularUserId = body.id;
|
||||
});
|
||||
|
||||
await test.step('Admin logs out', async () => {
|
||||
await logoutUser(page);
|
||||
});
|
||||
|
||||
await test.step('Regular user logs in', async () => {
|
||||
await navigateToLogin(page);
|
||||
await loginWithCredentials(page, regularUserData.email, regularUserData.password);
|
||||
await waitForLoadingComplete(page, { timeout: 15000 });
|
||||
});
|
||||
|
||||
await test.step('Verify "Users" nav item is NOT visible for regular user', async () => {
|
||||
const nav = page.getByRole('navigation').first();
|
||||
await expect(nav.getByRole('link', { name: 'Users' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify other nav items ARE visible (navigation renders for regular users)', async () => {
|
||||
const nav = page.getByRole('navigation').first();
|
||||
await expect(nav.getByRole('link', { name: /dashboard/i })).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Cleanup: admin logs back in and deletes regular user', async () => {
|
||||
await logoutUser(page);
|
||||
await navigateToLogin(page);
|
||||
await loginWithCredentials(page, adminEmail, TEST_PASSWORD);
|
||||
const token = await page.evaluate(() => localStorage.getItem('charon_auth_token') || '');
|
||||
if (regularUserId !== undefined) {
|
||||
await page.request.delete(`/api/v1/users/${regularUserId}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('admin user sees the Users navigation item', async ({ page }) => {
|
||||
await test.step('Navigate to settings to reveal Settings sub-navigation', async () => {
|
||||
await page.goto('/settings/users');
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
await test.step('Verify "Users" nav item is visible for admin in Settings nav', async () => {
|
||||
await expect(page.getByRole('link', { name: 'Users', exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,8 +42,9 @@ test.describe('User Management', () => {
|
||||
|
||||
await test.step('Verify page URL and heading', async () => {
|
||||
await expect(page).toHaveURL(/\/users/);
|
||||
// Wait for page to fully load - heading may take time to render
|
||||
const heading = page.getByRole('heading', { level: 1 });
|
||||
// Use name-scoped locator to avoid strict mode violation — the settings
|
||||
// layout renders a second h1 ("Settings") alongside the content heading.
|
||||
const heading = page.getByRole('heading', { name: 'User Management' });
|
||||
await expect(heading).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
@@ -1301,4 +1302,154 @@ test.describe('User Management', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* PR-3: Passthrough Role in Invite Modal (F3)
|
||||
*
|
||||
* Verifies that the invite modal exposes all three role options:
|
||||
* Admin, User, and Passthrough — and that selecting Passthrough
|
||||
* surfaces the appropriate role description.
|
||||
*/
|
||||
test.describe('PR-3: Passthrough Role in Invite (F3)', () => {
|
||||
test('should offer passthrough as a role option in the invite modal', async ({ page }) => {
|
||||
await test.step('Open the Invite User modal', async () => {
|
||||
const inviteButton = page.getByRole('button', { name: /invite.*user/i });
|
||||
await expect(inviteButton).toBeVisible();
|
||||
await inviteButton.click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify three role options are present in the role select', async () => {
|
||||
const roleSelect = page.locator('#invite-user-role');
|
||||
await expect(roleSelect).toBeVisible();
|
||||
await expect(roleSelect.locator('option[value="user"]')).toHaveCount(1);
|
||||
await expect(roleSelect.locator('option[value="admin"]')).toHaveCount(1);
|
||||
await expect(roleSelect.locator('option[value="passthrough"]')).toHaveCount(1);
|
||||
});
|
||||
|
||||
await test.step('Select passthrough and verify description is shown', async () => {
|
||||
await page.locator('#invite-user-role').selectOption('passthrough');
|
||||
await expect(
|
||||
page.getByText(/proxy access only|no management interface/i)
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test('should show permission mode selector when passthrough role is selected', async ({ page }) => {
|
||||
await test.step('Open invite modal and select passthrough role', async () => {
|
||||
await page.getByRole('button', { name: /invite.*user/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await page.locator('#invite-user-role').selectOption('passthrough');
|
||||
});
|
||||
|
||||
await test.step('Verify permission mode select is visible for passthrough', async () => {
|
||||
const permSelect = page.locator('#invite-permission-mode');
|
||||
await expect(permSelect).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* PR-3: User Detail Modal — Self vs Other (F2)
|
||||
*
|
||||
* Verifies that UserDetailModal shows different sections depending
|
||||
* on whether the admin is editing their own profile (isSelf=true)
|
||||
* versus another user's profile (isSelf=false).
|
||||
*/
|
||||
test.describe('PR-3: User Detail Modal (F2)', () => {
|
||||
test('should open My Profile modal with password and API key sections when editing self', async ({ page }) => {
|
||||
await test.step('Click the Edit User button in the My Profile card (first button in DOM)', async () => {
|
||||
// The My Profile card renders its button before the table rows in the DOM
|
||||
await page.getByRole('button', { name: 'Edit User' }).first().click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify dialog title is "My Profile" (isSelf=true)', async () => {
|
||||
await expect(
|
||||
page.getByRole('dialog').getByRole('heading', { name: 'My Profile' })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify name and email input fields are present', async () => {
|
||||
const dialog = page.getByRole('dialog');
|
||||
// First input in the dialog is the name field (no type attribute)
|
||||
await expect(dialog.locator('input').first()).toBeVisible();
|
||||
// Email field has type="email"
|
||||
await expect(dialog.locator('input[type="email"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify Change Password toggle is present (self-only section)', async () => {
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog.getByRole('button', { name: 'Change Password' })).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify API Key section is present (self-only section)', async () => {
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog.getByText('API Key', { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByRole('button', { name: 'Regenerate API Key' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test('should open Edit User modal without password/API key sections for another user', async ({ page }) => {
|
||||
const suffix = Date.now();
|
||||
const otherUser = {
|
||||
email: `modal-other-${suffix}@test.local`,
|
||||
name: `Modal Other User ${suffix}`,
|
||||
password: 'TestPass123!',
|
||||
role: 'user',
|
||||
};
|
||||
let otherUserId: number | string | undefined;
|
||||
|
||||
await test.step('Create a second user so there is an "other" row in the table', async () => {
|
||||
const token = await page.evaluate(() => localStorage.getItem('charon_auth_token') || '');
|
||||
const resp = await page.request.post('/api/v1/users', {
|
||||
data: otherUser,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
expect(resp.ok()).toBe(true);
|
||||
const body = await resp.json();
|
||||
otherUserId = body.id;
|
||||
await page.reload();
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
await test.step('Click the Edit User button in the row for the other user', async () => {
|
||||
const row = page.getByRole('row').filter({ hasText: otherUser.email });
|
||||
await row.getByRole('button', { name: 'Edit User' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify dialog title is "Edit User" (isSelf=false)', async () => {
|
||||
await expect(
|
||||
page.getByRole('dialog').getByRole('heading', { name: 'Edit User' })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify name and email fields are present', async () => {
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog.locator('input').first()).toBeVisible();
|
||||
await expect(dialog.locator('input[type="email"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify Change Password button is NOT visible (other-user edit)', async () => {
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog.getByRole('button', { name: 'Change Password' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify API Key section is NOT visible (other-user edit)', async () => {
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog.getByText('Regenerate API Key')).not.toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Cleanup: close modal and delete the test user', async () => {
|
||||
await page.keyboard.press('Escape');
|
||||
if (otherUserId !== undefined) {
|
||||
const token = await page.evaluate(() => localStorage.getItem('charon_auth_token') || '');
|
||||
await page.request.delete(`/api/v1/users/${otherUserId}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user