Merge pull request #794 from Wikid82/feature/beta-release

Restructure User Management
This commit is contained in:
Jeremy
2026-03-04 13:31:05 -05:00
committed by GitHub
74 changed files with 3903 additions and 1426 deletions

View File

@@ -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
View File

@@ -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",

View File

@@ -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'

View File

@@ -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'

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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'

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 \

View File

@@ -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 {

View File

@@ -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",
}

View File

@@ -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

View File

@@ -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=

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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")
}

View File

@@ -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)

View File

@@ -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()
}
}

View File

@@ -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)
}

View File

@@ -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()
}
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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")

View File

@@ -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
}

View File

@@ -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

View File

@@ -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),

View File

@@ -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)),

View File

@@ -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(),
}

View 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

View File

@@ -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": [
{

View File

@@ -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",

View File

@@ -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 */}

View File

@@ -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: {

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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>
)}

View 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

View File

@@ -2,7 +2,7 @@ import { createContext } from 'react';
export interface User {
user_id: number;
role: string;
role: 'admin' | 'user' | 'passthrough';
name?: string;
email?: string;
}

View File

@@ -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 />

View 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])
}

View File

@@ -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."
}
}

View File

@@ -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."
}
}

View File

@@ -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."
}
}

View File

@@ -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."
}
}

View File

@@ -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": "您无权访问管理界面。"
}
}

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -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 (

View File

@@ -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>
)
}

View File

@@ -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'])
})
})

View File

@@ -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
View File

@@ -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",

View File

@@ -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 });
});
});

View File

@@ -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',
});
},
});

View File

@@ -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

View File

@@ -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();
});
});
});

View File

@@ -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}` } : {},
});
}
});
});
});
});