Merge pull request #815 from Wikid82/nightly

Weekly: Promote nightly to main (2026-03-09)
This commit is contained in:
Jeremy
2026-03-09 08:36:28 -04:00
committed by GitHub
112 changed files with 5656 additions and 1516 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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 |
+46 -1
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",
@@ -117,13 +130,45 @@
{
"customType": "regex",
"description": "Track GO_VERSION in Actions workflows",
"fileMatch": ["^\\.github/workflows/.*\\.yml$"],
"managerFilePatterns": ["/^\\.github/workflows/.*\\.yml$/"],
"matchStrings": [
"GO_VERSION: ['\"]?(?<currentValue>[\\d\\.]+)['\"]?"
],
"depNameTemplate": "golang/go",
"datasourceTemplate": "golang-version",
"versioningTemplate": "semver"
},
{
"customType": "regex",
"description": "Track Syft version in workflows and scripts",
"managerFilePatterns": [
"/^\\.github/workflows/nightly-build\\.yml$/",
"/^\\.github/skills/security-scan-docker-image-scripts/run\\.sh$/"
],
"matchStrings": [
"SYFT_VERSION=\\\"v(?<currentValue>[^\\\"\\s]+)\\\"",
"set_default_env \\\"SYFT_VERSION\\\" \\\"v(?<currentValue>[^\\\"]+)\\\""
],
"depNameTemplate": "anchore/syft",
"datasourceTemplate": "github-releases",
"versioningTemplate": "semver",
"extractVersionTemplate": "^v(?<version>.*)$"
},
{
"customType": "regex",
"description": "Track Grype version in workflows and scripts",
"managerFilePatterns": [
"/^\\.github/workflows/supply-chain-pr\\.yml$/",
"/^\\.github/skills/security-scan-docker-image-scripts/run\\.sh$/"
],
"matchStrings": [
"anchore/grype/main/install\\.sh \\| sh -s -- -b /usr/local/bin v(?<currentValue>[0-9]+\\.[0-9]+\\.[0-9]+)",
"set_default_env \\\"GRYPE_VERSION\\\" \\\"v(?<currentValue>[^\\\"]+)\\\""
],
"depNameTemplate": "anchore/grype",
"datasourceTemplate": "github-releases",
"versioningTemplate": "semver",
"extractVersionTemplate": "^v(?<version>.*)$"
}
],
+1 -1
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'
+9 -4
View File
@@ -39,14 +39,19 @@ 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'
run: bash scripts/ci/check-codeql-parity.sh
- name: Initialize CodeQL
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4
uses: github/codeql-action/init@c793b717bc78562f491db7b0e93a3a178b099162 # v4
with:
languages: ${{ matrix.language }}
queries: security-and-quality
@@ -86,10 +91,10 @@ jobs:
run: mkdir -p sarif-results
- name: Autobuild
uses: github/codeql-action/autobuild@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4
uses: github/codeql-action/autobuild@c793b717bc78562f491db7b0e93a3a178b099162 # v4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4
uses: github/codeql-action/analyze@c793b717bc78562f491db7b0e93a3a178b099162 # v4
with:
category: "/language:${{ matrix.language }}"
output: sarif-results/${{ matrix.language }}
+13 -13
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 }}
@@ -531,7 +531,7 @@ jobs:
- name: Run Trivy scan (table output)
if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
with:
image-ref: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
format: 'table'
@@ -542,7 +542,7 @@ jobs:
- name: Run Trivy vulnerability scanner (SARIF)
if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
id: trivy
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
with:
image-ref: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
format: 'sarif'
@@ -562,7 +562,7 @@ jobs:
- name: Upload Trivy results
if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.trivy-check.outputs.exists == 'true'
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
with:
sarif_file: 'trivy-results.sarif'
category: '.github/workflows/docker-build.yml:build-and-push'
@@ -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 }}
@@ -689,7 +689,7 @@ jobs:
echo "✅ Image freshness validated"
- name: Run Trivy scan on PR image (table output)
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
with:
image-ref: ${{ steps.pr-image.outputs.image_ref }}
format: 'table'
@@ -698,7 +698,7 @@ jobs:
- name: Run Trivy scan on PR image (SARIF - blocking)
id: trivy-scan
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
with:
image-ref: ${{ steps.pr-image.outputs.image_ref }}
format: 'sarif'
@@ -719,14 +719,14 @@ jobs:
- name: Upload Trivy scan results
if: always() && steps.trivy-pr-check.outputs.exists == 'true'
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
with:
sarif_file: 'trivy-pr-results.sarif'
category: 'docker-pr-image'
- name: Upload Trivy compatibility results (docker-build category)
if: always() && steps.trivy-pr-check.outputs.exists == 'true'
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
with:
sarif_file: 'trivy-pr-results.sarif'
category: '.github/workflows/docker-build.yml:build-and-push'
@@ -734,7 +734,7 @@ jobs:
- name: Upload Trivy compatibility results (docker-publish alias)
if: always() && steps.trivy-pr-check.outputs.exists == 'true'
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
with:
sarif_file: 'trivy-pr-results.sarif'
category: '.github/workflows/docker-publish.yml:build-and-push'
@@ -742,7 +742,7 @@ jobs:
- name: Upload Trivy compatibility results (nightly alias)
if: always() && steps.trivy-pr-check.outputs.exists == 'true'
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
with:
sarif_file: 'trivy-pr-results.sarif'
category: 'trivy-nightly'
+1 -1
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 }}
+1 -1
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 }}
+13 -13
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 }}
+6 -6
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 }}
@@ -396,14 +396,14 @@ jobs:
severity-cutoff: high
- name: Scan with Trivy
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
with:
image-ref: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build-and-push-nightly.outputs.digest }}
format: 'sarif'
output: 'trivy-nightly.sarif'
- name: Upload Trivy results
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
with:
sarif_file: 'trivy-nightly.sarif'
category: 'trivy-nightly'
+1 -1
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 }}
+2 -1
View File
@@ -4,6 +4,7 @@ on:
pull_request:
push:
branches:
- nightly
- main
concurrency:
@@ -248,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'
+1 -1
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 }}
+3 -3
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@4c61e6329bab9be735ca35291551614bc663dff3
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@0ec47d036c68ae0cf94c629009b1029407111281
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@4c61e6329bab9be735ca35291551614bc663dff3
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1
with:
scan-type: 'fs'
scan-ref: ${{ steps.extract.outputs.binary_path }}
+11 -6
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 }}
@@ -88,7 +93,7 @@ jobs:
BASE_IMAGE=${{ steps.base-image.outputs.digest }}
- name: Run Trivy vulnerability scanner (CRITICAL+HIGH)
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
format: 'table'
@@ -98,7 +103,7 @@ jobs:
- name: Run Trivy vulnerability scanner (SARIF)
id: trivy-sarif
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
format: 'sarif'
@@ -106,12 +111,12 @@ jobs:
severity: 'CRITICAL,HIGH,MEDIUM'
- name: Upload Trivy results to GitHub Security
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
with:
sarif_file: 'trivy-weekly-results.sarif'
- name: Run Trivy vulnerability scanner (JSON for artifact)
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
format: 'json'
+1 -1
View File
@@ -362,7 +362,7 @@ jobs:
- name: Upload SARIF to GitHub Security
if: steps.check-artifact.outputs.artifact_found == 'true'
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4
continue-on-error: true
with:
sarif_file: grype-results.sarif
+1 -1
View File
@@ -1 +1 @@
v0.19.1
v0.21.0
+6 -2
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 \
+2 -2
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 {
+2 -2
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",
}
+4 -4
View File
@@ -84,11 +84,11 @@ 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/otel v1.40.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.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.opentelemetry.io/otel/metric v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/arch v0.24.0 // indirect
golang.org/x/sys v0.41.0 // indirect
+12 -12
View File
@@ -176,22 +176,22 @@ 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/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
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=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
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/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
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.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=
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -381,7 +381,7 @@ func (h *AuthHandler) Verify(c *gin.Context) {
// Set headers for downstream services
c.Header("X-Forwarded-User", user.Email)
c.Header("X-Forwarded-Groups", user.Role)
c.Header("X-Forwarded-Groups", string(user.Role))
c.Header("X-Forwarded-Name", user.Name)
// Return 200 OK - access granted
@@ -430,7 +430,7 @@ func TestAuthHandler_Me(t *testing.T) {
UUID: uuid.NewString(),
Email: "me@example.com",
Name: "Me User",
Role: "admin",
Role: models.RoleAdmin,
}
db.Create(user)
@@ -630,7 +630,7 @@ func TestAuthHandler_Verify_ValidToken(t *testing.T) {
UUID: uuid.NewString(),
Email: "test@example.com",
Name: "Test User",
Role: "user",
Role: models.RoleUser,
Enabled: true,
}
_ = user.SetPassword("password123")
@@ -661,7 +661,7 @@ func TestAuthHandler_Verify_BearerToken(t *testing.T) {
UUID: uuid.NewString(),
Email: "bearer@example.com",
Name: "Bearer User",
Role: "admin",
Role: models.RoleAdmin,
Enabled: true,
}
_ = user.SetPassword("password123")
@@ -690,7 +690,7 @@ func TestAuthHandler_Verify_DisabledUser(t *testing.T) {
UUID: uuid.NewString(),
Email: "disabled@example.com",
Name: "Disabled User",
Role: "user",
Role: models.RoleUser,
}
_ = user.SetPassword("password123")
db.Create(user)
@@ -730,7 +730,7 @@ func TestAuthHandler_Verify_ForwardAuthDenied(t *testing.T) {
UUID: uuid.NewString(),
Email: "denied@example.com",
Name: "Denied User",
Role: "user",
Role: models.RoleUser,
Enabled: true,
PermissionMode: models.PermissionModeDenyAll,
}
@@ -795,7 +795,7 @@ func TestAuthHandler_VerifyStatus_Authenticated(t *testing.T) {
UUID: uuid.NewString(),
Email: "status@example.com",
Name: "Status User",
Role: "user",
Role: models.RoleUser,
Enabled: true,
}
_ = user.SetPassword("password123")
@@ -828,7 +828,7 @@ func TestAuthHandler_VerifyStatus_DisabledUser(t *testing.T) {
UUID: uuid.NewString(),
Email: "disabled2@example.com",
Name: "Disabled User 2",
Role: "user",
Role: models.RoleUser,
}
_ = user.SetPassword("password123")
db.Create(user)
@@ -880,7 +880,7 @@ func TestAuthHandler_GetAccessibleHosts_AllowAll(t *testing.T) {
UUID: uuid.NewString(),
Email: "allowall@example.com",
Name: "Allow All User",
Role: "user",
Role: models.RoleUser,
Enabled: true,
PermissionMode: models.PermissionModeAllowAll,
}
@@ -917,7 +917,7 @@ func TestAuthHandler_GetAccessibleHosts_DenyAll(t *testing.T) {
UUID: uuid.NewString(),
Email: "denyall@example.com",
Name: "Deny All User",
Role: "user",
Role: models.RoleUser,
Enabled: true,
PermissionMode: models.PermissionModeDenyAll,
}
@@ -956,7 +956,7 @@ func TestAuthHandler_GetAccessibleHosts_PermittedHosts(t *testing.T) {
UUID: uuid.NewString(),
Email: "permitted@example.com",
Name: "Permitted User",
Role: "user",
Role: models.RoleUser,
Enabled: true,
PermissionMode: models.PermissionModeDenyAll,
PermittedHosts: []models.ProxyHost{*host1}, // Only host1
@@ -1111,7 +1111,7 @@ func TestAuthHandler_Logout_InvalidatesBearerSession(t *testing.T) {
UUID: uuid.NewString(),
Email: "logout-session@example.com",
Name: "Logout Session",
Role: "admin",
Role: models.RoleAdmin,
Enabled: true,
}
_ = user.SetPassword("password123")
@@ -1242,7 +1242,7 @@ func TestAuthHandler_Refresh(t *testing.T) {
handler, db := setupAuthHandler(t)
user := &models.User{UUID: uuid.NewString(), Email: "refresh@example.com", Name: "Refresh User", Role: "user", Enabled: true}
user := &models.User{UUID: uuid.NewString(), Email: "refresh@example.com", Name: "Refresh User", Role: models.RoleUser, Enabled: true}
require.NoError(t, user.SetPassword("password123"))
require.NoError(t, db.Create(user).Error)
@@ -1332,7 +1332,7 @@ func TestAuthHandler_Verify_UsesOriginalHostFallback(t *testing.T) {
UUID: uuid.NewString(),
Email: "originalhost@example.com",
Name: "Original Host User",
Role: "user",
Role: models.RoleUser,
Enabled: true,
PermissionMode: models.PermissionModeAllowAll,
}
@@ -384,10 +384,7 @@ func (h *EmergencyHandler) syncSecurityState(ctx context.Context) {
// POST /api/v1/emergency/token/generate
// Requires admin authentication
func (h *EmergencyHandler) GenerateToken(c *gin.Context) {
// Check admin role
role, exists := c.Get("role")
if !exists || role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
if !requireAdmin(c) {
return
}
@@ -437,10 +434,7 @@ func (h *EmergencyHandler) GenerateToken(c *gin.Context) {
// GET /api/v1/emergency/token/status
// Requires admin authentication
func (h *EmergencyHandler) GetTokenStatus(c *gin.Context) {
// Check admin role
role, exists := c.Get("role")
if !exists || role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
if !requireAdmin(c) {
return
}
@@ -458,10 +452,7 @@ func (h *EmergencyHandler) GetTokenStatus(c *gin.Context) {
// DELETE /api/v1/emergency/token
// Requires admin authentication
func (h *EmergencyHandler) RevokeToken(c *gin.Context) {
// Check admin role
role, exists := c.Get("role")
if !exists || role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
if !requireAdmin(c) {
return
}
@@ -485,10 +476,7 @@ func (h *EmergencyHandler) RevokeToken(c *gin.Context) {
// PATCH /api/v1/emergency/token/expiration
// Requires admin authentication
func (h *EmergencyHandler) UpdateTokenExpiration(c *gin.Context) {
// Check admin role
role, exists := c.Get("role")
if !exists || role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
if !requireAdmin(c) {
return
}
@@ -340,6 +340,11 @@ func TestCommitAndCancel_InvalidSessionUUID(t *testing.T) {
r.ServeHTTP(wCommit, reqCommit)
assert.Equal(t, http.StatusBadRequest, wCommit.Code)
wCancelMissing := httptest.NewRecorder()
reqCancelMissing, _ := http.NewRequest(http.MethodDelete, "/api/v1/import/cancel", http.NoBody)
r.ServeHTTP(wCancelMissing, reqCancelMissing)
assert.Equal(t, http.StatusBadRequest, wCancelMissing.Code)
wCancel := httptest.NewRecorder()
reqCancel, _ := http.NewRequest(http.MethodDelete, "/api/v1/import/cancel?session_uuid=.", http.NoBody)
r.ServeHTTP(wCancel, reqCancel)
@@ -310,6 +310,11 @@ func (h *JSONImportHandler) Cancel(c *gin.Context) {
return
}
if strings.TrimSpace(req.SessionUUID) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "session_uuid required"})
return
}
// Clean up session if it exists
jsonImportSessionsMu.Lock()
delete(jsonImportSessions, req.SessionUUID)
@@ -497,6 +497,62 @@ func TestJSONImportHandler_ConflictDetection(t *testing.T) {
assert.Contains(t, conflictDetails, "conflict.com")
}
func TestJSONImportHandler_Cancel_RequiresValidJSONBody(t *testing.T) {
db := setupJSONTestDB(t)
handler := NewJSONImportHandler(db)
gin.SetMode(gin.TestMode)
router := gin.New()
api := router.Group("/api/v1")
handler.RegisterRoutes(api)
t.Run("missing body", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/json/cancel", http.NoBody)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
})
t.Run("invalid json", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/json/cancel", bytes.NewBufferString("{"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
})
t.Run("empty object payload", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/json/cancel", bytes.NewBufferString("{}"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
var resp map[string]string
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Equal(t, "session_uuid required", resp["error"])
})
t.Run("missing session_uuid payload", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/json/cancel", bytes.NewBufferString(`{"foo":"bar"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
var resp map[string]string
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Equal(t, "session_uuid required", resp["error"])
})
}
func TestJSONImportHandler_IsCharonFormat(t *testing.T) {
db := setupJSONTestDB(t)
handler := NewJSONImportHandler(db)
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"github.com/gin-gonic/gin"
@@ -293,6 +294,11 @@ func (h *NPMImportHandler) Cancel(c *gin.Context) {
return
}
if strings.TrimSpace(req.SessionUUID) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "session_uuid required"})
return
}
// Clean up session if it exists
npmImportSessionsMu.Lock()
delete(npmImportSessions, req.SessionUUID)
@@ -453,6 +453,62 @@ func TestNPMImportHandler_Cancel(t *testing.T) {
assert.Equal(t, http.StatusNotFound, commitW.Code)
}
func TestNPMImportHandler_Cancel_RequiresValidJSONBody(t *testing.T) {
db := setupNPMTestDB(t)
handler := NewNPMImportHandler(db)
gin.SetMode(gin.TestMode)
router := gin.New()
api := router.Group("/api/v1")
handler.RegisterRoutes(api)
t.Run("missing body", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/npm/cancel", http.NoBody)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
})
t.Run("invalid json", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/npm/cancel", bytes.NewBufferString("{"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
})
t.Run("empty object payload", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/npm/cancel", bytes.NewBufferString("{}"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
var resp map[string]string
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Equal(t, "session_uuid required", resp["error"])
})
t.Run("missing session_uuid payload", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/npm/cancel", bytes.NewBufferString(`{"foo":"bar"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
var resp map[string]string
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Equal(t, "session_uuid required", resp["error"])
})
}
func TestNPMImportHandler_ConvertNPMToImportResult(t *testing.T) {
db := setupNPMTestDB(t)
handler := NewNPMImportHandler(db)
@@ -36,9 +36,7 @@ func requireAuthenticatedAdmin(c *gin.Context) bool {
}
func isAdmin(c *gin.Context) bool {
role, _ := c.Get("role")
roleStr, _ := role.(string)
return roleStr == "admin"
return c.GetString("role") == string(models.RoleAdmin)
}
func respondPermissionError(c *gin.Context, securityService *services.SecurityService, action string, err error, path string) bool {
@@ -307,7 +307,7 @@ func TestSecurityEventIntakeR6Intact(t *testing.T) {
Email: "admin@example.com",
Name: "Admin User",
PasswordHash: "$2a$10$abcdefghijklmnopqrstuvwxyz", // Dummy bcrypt hash
Role: "admin",
Role: models.RoleAdmin,
Enabled: true,
}
require.NoError(t, db.Create(adminUser).Error)
@@ -1075,10 +1075,7 @@ func (h *SecurityHandler) PatchRateLimit(c *gin.Context) {
// toggleSecurityModule is a helper function that handles enabling/disabling security modules
// It updates the setting, invalidates cache, and triggers Caddy config reload
func (h *SecurityHandler) toggleSecurityModule(c *gin.Context, settingKey string, enabled bool) {
// Check admin role
role, exists := c.Get("role")
if !exists || role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
if !requireAdmin(c) {
return
}
+172 -46
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
}
@@ -23,7 +23,7 @@ func setupUserCoverageDB(t *testing.T) *gorm.DB {
func TestUserHandler_GetSetupStatus_Error(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupUserCoverageDB(t)
h := NewUserHandler(db)
h := NewUserHandler(db, nil)
// Drop table to cause error
_ = db.Migrator().DropTable(&models.User{})
@@ -40,7 +40,7 @@ func TestUserHandler_GetSetupStatus_Error(t *testing.T) {
func TestUserHandler_Setup_CheckStatusError(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupUserCoverageDB(t)
h := NewUserHandler(db)
h := NewUserHandler(db, nil)
// Drop table to cause error
_ = db.Migrator().DropTable(&models.User{})
@@ -57,10 +57,10 @@ func TestUserHandler_Setup_CheckStatusError(t *testing.T) {
func TestUserHandler_Setup_AlreadyCompleted(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupUserCoverageDB(t)
h := NewUserHandler(db)
h := NewUserHandler(db, nil)
// Create a user to mark setup as complete
user := &models.User{UUID: "uuid-a", Name: "Admin", Email: "admin@test.com", Role: "admin"}
user := &models.User{UUID: "uuid-a", Name: "Admin", Email: "admin@test.com", Role: models.RoleAdmin}
_ = user.SetPassword("password123")
db.Create(user)
@@ -76,7 +76,7 @@ func TestUserHandler_Setup_AlreadyCompleted(t *testing.T) {
func TestUserHandler_Setup_InvalidJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupUserCoverageDB(t)
h := NewUserHandler(db)
h := NewUserHandler(db, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -91,7 +91,7 @@ func TestUserHandler_Setup_InvalidJSON(t *testing.T) {
func TestUserHandler_RegenerateAPIKey_Unauthorized(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupUserCoverageDB(t)
h := NewUserHandler(db)
h := NewUserHandler(db, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -105,7 +105,7 @@ func TestUserHandler_RegenerateAPIKey_Unauthorized(t *testing.T) {
func TestUserHandler_RegenerateAPIKey_DBError(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupUserCoverageDB(t)
h := NewUserHandler(db)
h := NewUserHandler(db, nil)
// Drop table to cause error
_ = db.Migrator().DropTable(&models.User{})
@@ -123,7 +123,7 @@ func TestUserHandler_RegenerateAPIKey_DBError(t *testing.T) {
func TestUserHandler_GetProfile_Unauthorized(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupUserCoverageDB(t)
h := NewUserHandler(db)
h := NewUserHandler(db, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -137,7 +137,7 @@ func TestUserHandler_GetProfile_Unauthorized(t *testing.T) {
func TestUserHandler_GetProfile_NotFound(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupUserCoverageDB(t)
h := NewUserHandler(db)
h := NewUserHandler(db, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -152,7 +152,7 @@ func TestUserHandler_GetProfile_NotFound(t *testing.T) {
func TestUserHandler_UpdateProfile_Unauthorized(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupUserCoverageDB(t)
h := NewUserHandler(db)
h := NewUserHandler(db, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -166,7 +166,7 @@ func TestUserHandler_UpdateProfile_Unauthorized(t *testing.T) {
func TestUserHandler_UpdateProfile_InvalidJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupUserCoverageDB(t)
h := NewUserHandler(db)
h := NewUserHandler(db, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -182,7 +182,7 @@ func TestUserHandler_UpdateProfile_InvalidJSON(t *testing.T) {
func TestUserHandler_UpdateProfile_UserNotFound(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupUserCoverageDB(t)
h := NewUserHandler(db)
h := NewUserHandler(db, nil)
body, _ := json.Marshal(map[string]string{
"name": "Updated",
@@ -203,14 +203,14 @@ func TestUserHandler_UpdateProfile_UserNotFound(t *testing.T) {
func TestUserHandler_UpdateProfile_EmailConflict(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupUserCoverageDB(t)
h := NewUserHandler(db)
h := NewUserHandler(db, nil)
// Create two users
user1 := &models.User{UUID: "uuid-1", Name: "User1", Email: "user1@test.com", Role: "admin", APIKey: "key1"}
user1 := &models.User{UUID: "uuid-1", Name: "User1", Email: "user1@test.com", Role: models.RoleAdmin, APIKey: "key1"}
_ = user1.SetPassword("password123")
db.Create(user1)
user2 := &models.User{UUID: "uuid-2", Name: "User2", Email: "user2@test.com", Role: "admin", APIKey: "key2"}
user2 := &models.User{UUID: "uuid-2", Name: "User2", Email: "user2@test.com", Role: models.RoleAdmin, APIKey: "key2"}
_ = user2.SetPassword("password123")
db.Create(user2)
@@ -236,9 +236,9 @@ func TestUserHandler_UpdateProfile_EmailConflict(t *testing.T) {
func TestUserHandler_UpdateProfile_EmailChangeNoPassword(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupUserCoverageDB(t)
h := NewUserHandler(db)
h := NewUserHandler(db, nil)
user := &models.User{UUID: "uuid-u", Name: "User", Email: "user@test.com", Role: "admin"}
user := &models.User{UUID: "uuid-u", Name: "User", Email: "user@test.com", Role: models.RoleAdmin}
_ = user.SetPassword("password123")
db.Create(user)
@@ -263,9 +263,9 @@ func TestUserHandler_UpdateProfile_EmailChangeNoPassword(t *testing.T) {
func TestUserHandler_UpdateProfile_WrongPassword(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupUserCoverageDB(t)
h := NewUserHandler(db)
h := NewUserHandler(db, nil)
user := &models.User{UUID: "uuid-u", Name: "User", Email: "user@test.com", Role: "admin"}
user := &models.User{UUID: "uuid-u", Name: "User", Email: "user@test.com", Role: models.RoleAdmin}
_ = user.SetPassword("password123")
db.Create(user)
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
@@ -11,6 +12,7 @@ import (
"testing"
"time"
"github.com/Wikid82/charon/backend/internal/config"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
@@ -23,7 +25,7 @@ import (
func setupUserHandler(t *testing.T) (*UserHandler, *gorm.DB) {
db := OpenTestDB(t)
_ = db.AutoMigrate(&models.User{}, &models.Setting{}, &models.SecurityAudit{})
return NewUserHandler(db), db
return NewUserHandler(db, nil), db
}
func TestMapsKeys(t *testing.T) {
@@ -312,7 +314,7 @@ func TestUserHandler_ListUsers_SecretEchoContract(t *testing.T) {
UUID: uuid.NewString(),
Email: "user@example.com",
Name: "User",
Role: "user",
Role: models.RoleUser,
APIKey: "raw-api-key",
InviteToken: "raw-invite-token",
PasswordHash: "raw-password-hash",
@@ -661,7 +663,7 @@ func TestUserHandler_UpdateProfile_Errors(t *testing.T) {
func setupUserHandlerWithProxyHosts(t *testing.T) (*UserHandler, *gorm.DB) {
db := OpenTestDB(t)
_ = db.AutoMigrate(&models.User{}, &models.Setting{}, &models.ProxyHost{}, &models.SecurityAudit{})
return NewUserHandler(db), db
return NewUserHandler(db, nil), db
}
func TestUserHandler_ListUsers_NonAdmin(t *testing.T) {
@@ -912,18 +914,24 @@ func TestUserHandler_GetUser_Success(t *testing.T) {
}
func TestUserHandler_UpdateUser_NonAdmin(t *testing.T) {
handler, _ := setupUserHandlerWithProxyHosts(t)
handler, db := setupUserHandlerWithProxyHosts(t)
// Create a target user so it exists in the DB
target := &models.User{UUID: uuid.NewString(), Email: "target@example.com", Name: "Target", APIKey: uuid.NewString(), Role: models.RoleUser}
db.Create(target)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", "user")
c.Set("userID", uint(999))
c.Next()
})
r.PUT("/users/:id", handler.UpdateUser)
body := map[string]any{"name": "Updated"}
jsonBody, _ := json.Marshal(body)
req := httptest.NewRequest("PUT", "/users/1", bytes.NewBuffer(jsonBody))
req := httptest.NewRequest("PUT", fmt.Sprintf("/users/%d", target.ID), bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -937,6 +945,7 @@ func TestUserHandler_UpdateUser_InvalidID(t *testing.T) {
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Set("userID", uint(11))
c.Next()
})
r.PUT("/users/:id", handler.UpdateUser)
@@ -962,6 +971,7 @@ func TestUserHandler_UpdateUser_InvalidJSON(t *testing.T) {
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Set("userID", uint(11))
c.Next()
})
r.PUT("/users/:id", handler.UpdateUser)
@@ -980,6 +990,7 @@ func TestUserHandler_UpdateUser_NotFound(t *testing.T) {
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Set("userID", uint(11))
c.Next()
})
r.PUT("/users/:id", handler.UpdateUser)
@@ -997,7 +1008,7 @@ func TestUserHandler_UpdateUser_NotFound(t *testing.T) {
func TestUserHandler_UpdateUser_Success(t *testing.T) {
handler, db := setupUserHandlerWithProxyHosts(t)
user := &models.User{UUID: uuid.NewString(), Email: "update@example.com", Name: "Original", Role: "user"}
user := &models.User{UUID: uuid.NewString(), Email: "update@example.com", Name: "Original", Role: models.RoleUser}
db.Create(user)
gin.SetMode(gin.TestMode)
@@ -1030,7 +1041,7 @@ func TestUserHandler_UpdateUser_Success(t *testing.T) {
func TestUserHandler_UpdateUser_PasswordReset(t *testing.T) {
handler, db := setupUserHandlerWithProxyHosts(t)
user := &models.User{UUID: uuid.NewString(), Email: "reset@example.com", Name: "Reset User", Role: "user"}
user := &models.User{UUID: uuid.NewString(), Email: "reset@example.com", Name: "Reset User", Role: models.RoleUser}
require.NoError(t, user.SetPassword("oldpassword123"))
lockUntil := time.Now().Add(10 * time.Minute)
user.FailedLoginAttempts = 4
@@ -1041,6 +1052,7 @@ func TestUserHandler_UpdateUser_PasswordReset(t *testing.T) {
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Set("userID", uint(11))
c.Next()
})
r.PUT("/users/:id", handler.UpdateUser)
@@ -1214,7 +1226,7 @@ func TestUserHandler_UpdateUserPermissions_InvalidJSON(t *testing.T) {
APIKey: uuid.NewString(),
Email: "perms-invalid@example.com",
Name: "Perms Invalid Test",
Role: "user",
Role: models.RoleUser,
Enabled: true,
}
db.Create(user)
@@ -1562,7 +1574,7 @@ func TestUserHandler_InviteUser_Success(t *testing.T) {
UUID: uuid.NewString(),
APIKey: uuid.NewString(),
Email: "admin@example.com",
Role: "admin",
Role: models.RoleAdmin,
}
db.Create(admin)
@@ -1615,7 +1627,7 @@ func TestUserHandler_InviteUser_WithPermittedHosts(t *testing.T) {
UUID: uuid.NewString(),
APIKey: uuid.NewString(),
Email: "admin-perm@example.com",
Role: "admin",
Role: models.RoleAdmin,
}
db.Create(admin)
@@ -1664,7 +1676,7 @@ func TestUserHandler_InviteUser_WithSMTPConfigured(t *testing.T) {
UUID: uuid.NewString(),
APIKey: uuid.NewString(),
Email: "admin-smtp@example.com",
Role: "admin",
Role: models.RoleAdmin,
}
db.Create(admin)
@@ -1727,7 +1739,7 @@ func TestUserHandler_InviteUser_WithSMTPAndConfiguredPublicURL_IncludesInviteURL
UUID: uuid.NewString(),
APIKey: uuid.NewString(),
Email: "admin-publicurl@example.com",
Role: "admin",
Role: models.RoleAdmin,
}
db.Create(admin)
@@ -1780,7 +1792,7 @@ func TestUserHandler_InviteUser_WithSMTPAndMalformedPublicURL_DoesNotExposeInvit
UUID: uuid.NewString(),
APIKey: uuid.NewString(),
Email: "admin-malformed-publicurl@example.com",
Role: "admin",
Role: models.RoleAdmin,
}
db.Create(admin)
@@ -1834,7 +1846,7 @@ func TestUserHandler_InviteUser_WithSMTPConfigured_DefaultAppName(t *testing.T)
UUID: uuid.NewString(),
APIKey: uuid.NewString(),
Email: "admin-smtp-default@example.com",
Role: "admin",
Role: models.RoleAdmin,
}
db.Create(admin)
@@ -1976,7 +1988,7 @@ func TestUserHandler_PreviewInviteURL_NonAdmin(t *testing.T) {
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
assert.Contains(t, w.Body.String(), "Admin access required")
assert.Contains(t, w.Body.String(), "admin privileges required")
}
func TestUserHandler_PreviewInviteURL_InvalidJSON(t *testing.T) {
@@ -2137,6 +2149,7 @@ func TestUserHandler_UpdateUser_EmailConflict(t *testing.T) {
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Set("userID", uint(11))
c.Next()
})
r.PUT("/users/:id", handler.UpdateUser)
@@ -2195,7 +2208,7 @@ func TestUserHandler_InviteUser_EmailNormalization(t *testing.T) {
UUID: uuid.NewString(),
APIKey: uuid.NewString(),
Email: "admin@example.com",
Role: "admin",
Role: models.RoleAdmin,
}
db.Create(admin)
@@ -2264,7 +2277,7 @@ func TestUserHandler_InviteUser_DefaultPermissionMode(t *testing.T) {
UUID: uuid.NewString(),
APIKey: uuid.NewString(),
Email: "admin@example.com",
Role: "admin",
Role: models.RoleAdmin,
}
db.Create(admin)
@@ -2322,7 +2335,7 @@ func TestUserHandler_CreateUser_DefaultRole(t *testing.T) {
// Verify role defaults to "user"
var user models.User
db.Where("email = ?", "defaultrole@example.com").First(&user)
assert.Equal(t, "user", user.Role)
assert.Equal(t, models.RoleUser, user.Role)
}
func TestUserHandler_InviteUser_DefaultRole(t *testing.T) {
@@ -2333,7 +2346,7 @@ func TestUserHandler_InviteUser_DefaultRole(t *testing.T) {
UUID: uuid.NewString(),
APIKey: uuid.NewString(),
Email: "admin@example.com",
Role: "admin",
Role: models.RoleAdmin,
}
db.Create(admin)
@@ -2361,7 +2374,7 @@ func TestUserHandler_InviteUser_DefaultRole(t *testing.T) {
// Verify role defaults to "user"
var user models.User
db.Where("email = ?", "defaultroleinvite@example.com").First(&user)
assert.Equal(t, "user", user.Role)
assert.Equal(t, models.RoleUser, user.Role)
}
// TestUserHandler_PreviewInviteURL_Unconfigured_DoesNotUseRequestHost verifies that
@@ -2484,7 +2497,7 @@ func TestResendInvite_NonAdmin(t *testing.T) {
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
assert.Contains(t, w.Body.String(), "Admin access required")
assert.Contains(t, w.Body.String(), "admin privileges required")
}
func TestResendInvite_InvalidID(t *testing.T) {
@@ -2705,3 +2718,394 @@ func TestRedactInviteURL(t *testing.T) {
})
}
}
// --- Passthrough rejection tests ---
func setupPassthroughRouter(handler *UserHandler) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", string(models.RolePassthrough))
c.Next()
})
r.POST("/api-key", handler.RegenerateAPIKey)
r.GET("/profile", handler.GetProfile)
r.PUT("/profile", handler.UpdateProfile)
return r
}
func TestUserHandler_RegenerateAPIKey_PassthroughRejected(t *testing.T) {
handler, _ := setupUserHandler(t)
r := setupPassthroughRouter(handler)
req := httptest.NewRequest(http.MethodPost, "/api-key", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
assert.Contains(t, w.Body.String(), "Passthrough users cannot manage API keys")
}
func TestUserHandler_GetProfile_PassthroughRejected(t *testing.T) {
handler, _ := setupUserHandler(t)
r := setupPassthroughRouter(handler)
req := httptest.NewRequest(http.MethodGet, "/profile", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
assert.Contains(t, w.Body.String(), "Passthrough users cannot access profile")
}
func TestUserHandler_UpdateProfile_PassthroughRejected(t *testing.T) {
handler, _ := setupUserHandler(t)
r := setupPassthroughRouter(handler)
body, _ := json.Marshal(map[string]string{"name": "Test"})
req := httptest.NewRequest(http.MethodPut, "/profile", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
assert.Contains(t, w.Body.String(), "Passthrough users cannot update profile")
}
// --- CreateUser / InviteUser invalid role ---
func TestUserHandler_CreateUser_InvalidRole(t *testing.T) {
handler, _ := setupUserHandler(t)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
r.POST("/users", handler.CreateUser)
body, _ := json.Marshal(map[string]string{
"name": "Test User",
"email": "new@example.com",
"role": "superadmin",
"password": "password123",
})
req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "Invalid role")
}
func TestUserHandler_InviteUser_InvalidRole(t *testing.T) {
handler, _ := setupUserHandler(t)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Set("userID", uint(1))
c.Next()
})
r.POST("/invite", handler.InviteUser)
body, _ := json.Marshal(map[string]string{
"email": "invite@example.com",
"role": "superadmin",
})
req := httptest.NewRequest(http.MethodPost, "/invite", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "Invalid role")
}
// --- UpdateUser authentication/session edge cases ---
func TestUserHandler_UpdateUser_MissingUserID(t *testing.T) {
handler, db := setupUserHandler(t)
user := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "target@example.com", Role: models.RoleUser, Enabled: true}
require.NoError(t, db.Create(&user).Error)
gin.SetMode(gin.TestMode)
r := gin.New()
// No userID set in context
r.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
r.PUT("/users/:id", handler.UpdateUser)
body, _ := json.Marshal(map[string]string{"name": "New Name"})
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", user.ID), bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
assert.Contains(t, w.Body.String(), "Authentication required")
}
func TestUserHandler_UpdateUser_InvalidSessionType(t *testing.T) {
handler, db := setupUserHandler(t)
user := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "target2@example.com", Role: models.RoleUser, Enabled: true}
require.NoError(t, db.Create(&user).Error)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Set("userID", "not-a-uint") // wrong type
c.Next()
})
r.PUT("/users/:id", handler.UpdateUser)
body, _ := json.Marshal(map[string]string{"name": "New Name"})
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", user.ID), bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, w.Body.String(), "Invalid session")
}
// --- UpdateUser role/enabled restriction for non-admin self ---
func TestUserHandler_UpdateUser_NonAdminSelfRoleChange(t *testing.T) {
handler, db := setupUserHandler(t)
user := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "self@example.com", Role: models.RoleUser, Enabled: true}
require.NoError(t, db.Create(&user).Error)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", "user") // non-admin
c.Set("userID", user.ID) // isSelf = true
c.Next()
})
r.PUT("/users/:id", handler.UpdateUser)
body, _ := json.Marshal(map[string]string{"role": "admin"})
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", user.ID), bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
assert.Contains(t, w.Body.String(), "Cannot modify role or enabled status")
}
// --- UpdateUser invalid role string ---
func TestUserHandler_UpdateUser_InvalidRole(t *testing.T) {
handler, db := setupUserHandler(t)
target := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "target3@example.com", Role: models.RoleUser, Enabled: true}
require.NoError(t, db.Create(&target).Error)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Set("userID", uint(9999)) // not the target
c.Next()
})
r.PUT("/users/:id", handler.UpdateUser)
body, _ := json.Marshal(map[string]string{"role": "superadmin"})
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", target.ID), bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "Invalid role")
}
// --- UpdateUser self-demotion and self-disable ---
func TestUserHandler_UpdateUser_SelfDemotion(t *testing.T) {
handler, db := setupUserHandler(t)
admin := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "admin@self.example.com", Role: models.RoleAdmin, Enabled: true}
require.NoError(t, db.Create(&admin).Error)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Set("userID", admin.ID) // isSelf = true
c.Next()
})
r.PUT("/users/:id", handler.UpdateUser)
body, _ := json.Marshal(map[string]string{"role": "user"})
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", admin.ID), bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
assert.Contains(t, w.Body.String(), "Cannot change your own role")
}
func TestUserHandler_UpdateUser_SelfDisable(t *testing.T) {
handler, db := setupUserHandler(t)
admin := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "admin@disable.example.com", Role: models.RoleAdmin, Enabled: true}
require.NoError(t, db.Create(&admin).Error)
disabled := false
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Set("userID", admin.ID) // isSelf = true
c.Next()
})
r.PUT("/users/:id", handler.UpdateUser)
body, _ := json.Marshal(map[string]interface{}{"enabled": disabled})
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", admin.ID), bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
assert.Contains(t, w.Body.String(), "Cannot disable your own account")
}
// --- UpdateUser last-admin protection ---
func TestUserHandler_UpdateUser_LastAdminDemotion(t *testing.T) {
handler, db := setupUserHandler(t)
// Only one admin in the DB (the target)
target := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "last-admin@example.com", Role: models.RoleAdmin, Enabled: true}
require.NoError(t, db.Create(&target).Error)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Set("userID", uint(9999)) // different from target; not in DB but role injected via context
c.Next()
})
r.PUT("/users/:id", handler.UpdateUser)
body, _ := json.Marshal(map[string]string{"role": "user"})
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", target.ID), bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
assert.Contains(t, w.Body.String(), "Cannot demote the last admin")
}
func TestUserHandler_UpdateUser_LastAdminDisable(t *testing.T) {
handler, db := setupUserHandler(t)
target := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "last-admin-disable@example.com", Role: models.RoleAdmin, Enabled: true}
require.NoError(t, db.Create(&target).Error)
disabled := false
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Set("userID", uint(9999))
c.Next()
})
r.PUT("/users/:id", handler.UpdateUser)
body, _ := json.Marshal(map[string]interface{}{"enabled": disabled})
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", target.ID), bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
assert.Contains(t, w.Body.String(), "Cannot disable the last admin")
}
// --- UpdateUser session invalidation ---
func TestUserHandler_UpdateUser_WithSessionInvalidation(t *testing.T) {
db := OpenTestDB(t)
_ = db.AutoMigrate(&models.User{}, &models.Setting{}, &models.SecurityAudit{})
authSvc := services.NewAuthService(db, config.Config{JWTSecret: "test-secret"})
caller := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "caller-si@example.com", Role: models.RoleAdmin, Enabled: true}
target := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "target-si@example.com", Role: models.RoleUser, Enabled: true}
require.NoError(t, db.Create(&caller).Error)
require.NoError(t, db.Create(&target).Error)
handler := NewUserHandler(db, authSvc)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Set("userID", caller.ID)
c.Next()
})
r.PUT("/users/:id", handler.UpdateUser)
body, _ := json.Marshal(map[string]string{"role": "passthrough"})
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", target.ID), bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "User updated successfully")
var updated models.User
require.NoError(t, db.First(&updated, target.ID).Error)
assert.Greater(t, updated.SessionVersion, uint(0))
}
func TestUserHandler_UpdateUser_SessionInvalidationError(t *testing.T) {
mainDB := OpenTestDB(t)
_ = mainDB.AutoMigrate(&models.User{}, &models.Setting{}, &models.SecurityAudit{})
// Use a separate empty DB so InvalidateSessions cannot find the user
authDB := OpenTestDB(t)
authSvc := services.NewAuthService(authDB, config.Config{JWTSecret: "test-secret"})
target := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "target-sie@example.com", Role: models.RoleUser, Enabled: true}
require.NoError(t, mainDB.Create(&target).Error)
handler := NewUserHandler(mainDB, authSvc)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Set("userID", uint(9999))
c.Next()
})
r.PUT("/users/:id", handler.UpdateUser)
body, _ := json.Marshal(map[string]string{"role": "passthrough"})
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", target.ID), bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, w.Body.String(), "Failed to invalidate sessions")
}
@@ -28,7 +28,7 @@ func TestUserLoginAfterEmailChange(t *testing.T) {
cfg := config.Config{}
authService := services.NewAuthService(db, cfg)
authHandler := NewAuthHandler(authService)
userHandler := NewUserHandler(db)
userHandler := NewUserHandler(db, nil)
// Setup Router
gin.SetMode(gin.TestMode)
+17 -5
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()
}
}
@@ -427,3 +427,61 @@ func TestExtractAuthCookieToken_IgnoresNonAuthCookies(t *testing.T) {
token := extractAuthCookieToken(ctx)
assert.Equal(t, "", token)
}
func TestRequireManagementAccess_PassthroughBlocked(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", string(models.RolePassthrough))
c.Next()
})
r.Use(RequireManagementAccess())
r.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
assert.Contains(t, w.Body.String(), "Pass-through users cannot access management features")
}
func TestRequireManagementAccess_UserAllowed(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", string(models.RoleUser))
c.Next()
})
r.Use(RequireManagementAccess())
r.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestRequireManagementAccess_AdminAllowed(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", string(models.RoleAdmin))
c.Next()
})
r.Use(RequireManagementAccess())
r.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
@@ -38,7 +38,7 @@ func OptionalAuth(authService *services.AuthService) gin.HandlerFunc {
}
c.Set("userID", user.ID)
c.Set("role", user.Role)
c.Set("role", string(user.Role))
c.Next()
}
}
@@ -138,7 +138,7 @@ func TestOptionalAuth_ValidTokenSetsContext(t *testing.T) {
t.Parallel()
authService, db := setupAuthServiceWithDB(t)
user := &models.User{Email: "optional-auth@example.com", Name: "Optional Auth", Role: "admin", Enabled: true}
user := &models.User{Email: "optional-auth@example.com", Name: "Optional Auth", Role: models.RoleAdmin, Enabled: true}
require.NoError(t, user.SetPassword("password123"))
require.NoError(t, db.Create(user).Error)
@@ -0,0 +1,209 @@
package routes_test
import (
"fmt"
"sort"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
type endpointInventoryEntry struct {
Name string
Method string
Path string
Source string
}
func backendImportRouteMatrix() []endpointInventoryEntry {
return []endpointInventoryEntry{
{Name: "Import status", Method: "GET", Path: "/api/v1/import/status", Source: "backend/internal/api/handlers/import_handler.go"},
{Name: "Import preview", Method: "GET", Path: "/api/v1/import/preview", Source: "backend/internal/api/handlers/import_handler.go"},
{Name: "Import upload", Method: "POST", Path: "/api/v1/import/upload", Source: "backend/internal/api/handlers/import_handler.go"},
{Name: "Import upload multi", Method: "POST", Path: "/api/v1/import/upload-multi", Source: "backend/internal/api/handlers/import_handler.go"},
{Name: "Import detect imports", Method: "POST", Path: "/api/v1/import/detect-imports", Source: "backend/internal/api/handlers/import_handler.go"},
{Name: "Import commit", Method: "POST", Path: "/api/v1/import/commit", Source: "backend/internal/api/handlers/import_handler.go"},
{Name: "Import cancel", Method: "DELETE", Path: "/api/v1/import/cancel", Source: "backend/internal/api/handlers/import_handler.go"},
{Name: "NPM import upload", Method: "POST", Path: "/api/v1/import/npm/upload", Source: "backend/internal/api/handlers/npm_import_handler.go"},
{Name: "NPM import commit", Method: "POST", Path: "/api/v1/import/npm/commit", Source: "backend/internal/api/handlers/npm_import_handler.go"},
{Name: "NPM import cancel", Method: "POST", Path: "/api/v1/import/npm/cancel", Source: "backend/internal/api/handlers/npm_import_handler.go"},
{Name: "JSON import upload", Method: "POST", Path: "/api/v1/import/json/upload", Source: "backend/internal/api/handlers/json_import_handler.go"},
{Name: "JSON import commit", Method: "POST", Path: "/api/v1/import/json/commit", Source: "backend/internal/api/handlers/json_import_handler.go"},
{Name: "JSON import cancel", Method: "POST", Path: "/api/v1/import/json/cancel", Source: "backend/internal/api/handlers/json_import_handler.go"},
}
}
func frontendImportRouteMatrix() []endpointInventoryEntry {
return []endpointInventoryEntry{
{Name: "Import status", Method: "GET", Path: "/api/v1/import/status", Source: "frontend/src/api/import.ts"},
{Name: "Import preview", Method: "GET", Path: "/api/v1/import/preview", Source: "frontend/src/api/import.ts"},
{Name: "Import upload", Method: "POST", Path: "/api/v1/import/upload", Source: "frontend/src/api/import.ts"},
{Name: "Import upload multi", Method: "POST", Path: "/api/v1/import/upload-multi", Source: "frontend/src/api/import.ts"},
{Name: "Import commit", Method: "POST", Path: "/api/v1/import/commit", Source: "frontend/src/api/import.ts"},
{Name: "Import cancel", Method: "DELETE", Path: "/api/v1/import/cancel", Source: "frontend/src/api/import.ts"},
{Name: "NPM import upload", Method: "POST", Path: "/api/v1/import/npm/upload", Source: "frontend/src/api/npmImport.ts"},
{Name: "NPM import commit", Method: "POST", Path: "/api/v1/import/npm/commit", Source: "frontend/src/api/npmImport.ts"},
{Name: "NPM import cancel", Method: "POST", Path: "/api/v1/import/npm/cancel", Source: "frontend/src/api/npmImport.ts"},
{Name: "JSON import upload", Method: "POST", Path: "/api/v1/import/json/upload", Source: "frontend/src/api/jsonImport.ts"},
{Name: "JSON import commit", Method: "POST", Path: "/api/v1/import/json/commit", Source: "frontend/src/api/jsonImport.ts"},
{Name: "JSON import cancel", Method: "POST", Path: "/api/v1/import/json/cancel", Source: "frontend/src/api/jsonImport.ts"},
}
}
func saveRouteMatrixForImportWorkflows() []endpointInventoryEntry {
return []endpointInventoryEntry{
{Name: "Backup list", Method: "GET", Path: "/api/v1/backups", Source: "frontend/src/api/backups.ts"},
{Name: "Backup create", Method: "POST", Path: "/api/v1/backups", Source: "frontend/src/api/backups.ts"},
{Name: "Settings list", Method: "GET", Path: "/api/v1/settings", Source: "frontend/src/api/settings.ts"},
{Name: "Settings save", Method: "POST", Path: "/api/v1/settings", Source: "frontend/src/api/settings.ts"},
{Name: "Settings save patch", Method: "PATCH", Path: "/api/v1/settings", Source: "frontend/src/api/settings.ts"},
{Name: "Settings validate URL", Method: "POST", Path: "/api/v1/settings/validate-url", Source: "frontend/src/api/settings.ts"},
{Name: "Settings test URL", Method: "POST", Path: "/api/v1/settings/test-url", Source: "frontend/src/api/settings.ts"},
{Name: "SMTP get", Method: "GET", Path: "/api/v1/settings/smtp", Source: "frontend/src/api/smtp.ts"},
{Name: "SMTP save", Method: "POST", Path: "/api/v1/settings/smtp", Source: "frontend/src/api/smtp.ts"},
{Name: "Proxy host list", Method: "GET", Path: "/api/v1/proxy-hosts", Source: "frontend/src/api/proxyHosts.ts"},
{Name: "Proxy host create", Method: "POST", Path: "/api/v1/proxy-hosts", Source: "frontend/src/api/proxyHosts.ts"},
{Name: "Proxy host get", Method: "GET", Path: "/api/v1/proxy-hosts/:uuid", Source: "frontend/src/api/proxyHosts.ts"},
{Name: "Proxy host update", Method: "PUT", Path: "/api/v1/proxy-hosts/:uuid", Source: "frontend/src/api/proxyHosts.ts"},
{Name: "Proxy host delete", Method: "DELETE", Path: "/api/v1/proxy-hosts/:uuid", Source: "frontend/src/api/proxyHosts.ts"},
}
}
func backendImportSaveInventoryCanonical() []endpointInventoryEntry {
entries := append([]endpointInventoryEntry{}, backendImportRouteMatrix()...)
entries = append(entries, saveRouteMatrixForImportWorkflows()...)
return entries
}
func frontendObservedImportSaveInventory() []endpointInventoryEntry {
entries := append([]endpointInventoryEntry{}, frontendImportRouteMatrix()...)
entries = append(entries, saveRouteMatrixForImportWorkflows()...)
return entries
}
func routeKey(method, path string) string {
return method + " " + path
}
func buildRouteLookup(routes []gin.RouteInfo) (map[string]gin.RouteInfo, map[string]map[string]struct{}) {
byMethodAndPath := make(map[string]gin.RouteInfo, len(routes))
methodsByPath := make(map[string]map[string]struct{})
for _, route := range routes {
key := routeKey(route.Method, route.Path)
byMethodAndPath[key] = route
if _, exists := methodsByPath[route.Path]; !exists {
methodsByPath[route.Path] = map[string]struct{}{}
}
methodsByPath[route.Path][route.Method] = struct{}{}
}
return byMethodAndPath, methodsByPath
}
func methodList(methodSet map[string]struct{}) []string {
methods := make([]string, 0, len(methodSet))
for method := range methodSet {
methods = append(methods, method)
}
sort.Strings(methods)
return methods
}
func assertStrictMethodPathMatrix(t *testing.T, routes []gin.RouteInfo, expected []endpointInventoryEntry, matrixName string) {
t.Helper()
byMethodAndPath, methodsByPath := buildRouteLookup(routes)
seen := map[string]string{}
expectedMethodsByPath := map[string]map[string]struct{}{}
var failures []string
for _, endpoint := range expected {
key := routeKey(endpoint.Method, endpoint.Path)
if previous, duplicated := seen[key]; duplicated {
failures = append(failures, fmt.Sprintf("duplicate expected entry %q (%s and %s)", key, previous, endpoint.Name))
continue
}
seen[key] = endpoint.Name
if _, exists := expectedMethodsByPath[endpoint.Path]; !exists {
expectedMethodsByPath[endpoint.Path] = map[string]struct{}{}
}
expectedMethodsByPath[endpoint.Path][endpoint.Method] = struct{}{}
if _, exists := byMethodAndPath[key]; exists {
continue
}
if methodSet, pathExists := methodsByPath[endpoint.Path]; pathExists {
failures = append(
failures,
fmt.Sprintf("method drift for %s (%s): expected %s, registered methods=[%s]", endpoint.Name, endpoint.Path, endpoint.Method, strings.Join(methodList(methodSet), ", ")),
)
continue
}
failures = append(
failures,
fmt.Sprintf("missing route for %s: expected %s (source=%s)", endpoint.Name, key, endpoint.Source),
)
}
for path, expectedMethodSet := range expectedMethodsByPath {
actualMethodSet, exists := methodsByPath[path]
if !exists {
continue
}
extraMethods := make([]string, 0)
for method := range actualMethodSet {
if _, expectedMethod := expectedMethodSet[method]; !expectedMethod {
extraMethods = append(extraMethods, method)
}
}
if len(extraMethods) > 0 {
sort.Strings(extraMethods)
failures = append(
failures,
fmt.Sprintf(
"unexpected methods for %s: extra=[%s], expected=[%s], registered=[%s]",
path,
strings.Join(extraMethods, ", "),
strings.Join(methodList(expectedMethodSet), ", "),
strings.Join(methodList(actualMethodSet), ", "),
),
)
}
}
if len(failures) > 0 {
t.Fatalf("%s route matrix assertion failed:\n- %s", matrixName, strings.Join(failures, "\n- "))
}
}
func collectRouteMatrixDrift(routes []gin.RouteInfo, expected []endpointInventoryEntry) []string {
byMethodAndPath, methodsByPath := buildRouteLookup(routes)
failures := make([]string, 0)
for _, endpoint := range expected {
key := routeKey(endpoint.Method, endpoint.Path)
if _, exists := byMethodAndPath[key]; exists {
continue
}
if methodSet, pathExists := methodsByPath[endpoint.Path]; pathExists {
failures = append(
failures,
fmt.Sprintf("method drift for %s (%s): expected %s, registered methods=[%s]", endpoint.Name, endpoint.Path, endpoint.Method, strings.Join(methodList(methodSet), ", ")),
)
continue
}
failures = append(
failures,
fmt.Sprintf("missing route for %s: expected %s (source=%s)", endpoint.Name, key, endpoint.Source),
)
}
return failures
}
@@ -0,0 +1,67 @@
package routes_test
import (
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/api/routes"
"github.com/Wikid82/charon/backend/internal/config"
)
func TestEndpointInventory_FrontendCanonicalSaveImportContractsExistInBackend(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_endpoint_inventory"), &gorm.Config{})
require.NoError(t, err)
router := gin.New()
require.NoError(t, routes.Register(router, db, config.Config{JWTSecret: "test-secret"}))
routes.RegisterImportHandler(router, db, config.Config{JWTSecret: "test-secret"}, "echo", "/tmp", "/import/Caddyfile")
assertStrictMethodPathMatrix(t, router.Routes(), backendImportSaveInventoryCanonical(), "backend canonical save/import inventory")
}
func TestEndpointInventory_FrontendParityMatchesCurrentContract(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_endpoint_inventory_frontend_parity"), &gorm.Config{})
require.NoError(t, err)
router := gin.New()
require.NoError(t, routes.Register(router, db, config.Config{JWTSecret: "test-secret"}))
routes.RegisterImportHandler(router, db, config.Config{JWTSecret: "test-secret"}, "echo", "/tmp", "/import/Caddyfile")
assertStrictMethodPathMatrix(t, router.Routes(), frontendObservedImportSaveInventory(), "frontend observed save/import inventory")
}
func TestEndpointInventory_FrontendParityDetectsActualMismatch(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_endpoint_inventory_frontend_parity_mismatch"), &gorm.Config{})
require.NoError(t, err)
router := gin.New()
require.NoError(t, routes.Register(router, db, config.Config{JWTSecret: "test-secret"}))
routes.RegisterImportHandler(router, db, config.Config{JWTSecret: "test-secret"}, "echo", "/tmp", "/import/Caddyfile")
contractWithMismatch := append([]endpointInventoryEntry{}, frontendObservedImportSaveInventory()...)
for i := range contractWithMismatch {
if contractWithMismatch[i].Path == "/api/v1/import/cancel" {
contractWithMismatch[i].Method = "POST"
break
}
}
drift := collectRouteMatrixDrift(router.Routes(), contractWithMismatch)
assert.Contains(
t,
drift,
"method drift for Import cancel (/api/v1/import/cancel): expected POST, registered methods=[DELETE]",
)
}
+162 -152
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
@@ -0,0 +1,23 @@
package routes_test
import (
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/Wikid82/charon/backend/internal/api/routes"
"github.com/Wikid82/charon/backend/internal/config"
)
func TestRegisterImportHandler_StrictRouteMatrix(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestImportDB(t)
tempDir := t.TempDir()
importCaddyfilePath := filepath.Join(tempDir, "import", "Caddyfile")
router := gin.New()
routes.RegisterImportHandler(router, db, config.Config{JWTSecret: "test-secret"}, "echo", tempDir, importCaddyfilePath)
assertStrictMethodPathMatrix(t, router.Routes(), backendImportRouteMatrix(), "import")
}
@@ -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)
@@ -0,0 +1,25 @@
package routes_test
import (
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/api/routes"
"github.com/Wikid82/charon/backend/internal/config"
)
func TestRegister_StrictSaveRouteMatrixUsedByImportWorkflows(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_save_contract_matrix"), &gorm.Config{})
require.NoError(t, err)
router := gin.New()
require.NoError(t, routes.Register(router, db, config.Config{JWTSecret: "test-secret"}))
assertStrictMethodPathMatrix(t, router.Routes(), saveRouteMatrixForImportWorkflows(), "save")
}
@@ -10,7 +10,9 @@ import (
"testing"
"github.com/Wikid82/charon/backend/internal/config"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
@@ -1298,3 +1300,25 @@ func TestRegister_CreatesAccessLogFileForLogWatcher(t *testing.T) {
_, statErr := os.Stat(logFilePath)
assert.NoError(t, statErr)
}
func TestMigrateViewerToPassthrough(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.User{}))
// Seed a user with the legacy "viewer" role
viewer := models.User{
UUID: uuid.NewString(),
APIKey: uuid.NewString(),
Email: "viewer@example.com",
Role: models.UserRole("viewer"),
Enabled: true,
}
require.NoError(t, db.Create(&viewer).Error)
migrateViewerToPassthrough(db)
var updated models.User
require.NoError(t, db.First(&updated, viewer.ID).Error)
assert.Equal(t, models.RolePassthrough, updated.Role)
}
@@ -3,6 +3,7 @@ package tests
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
@@ -45,7 +46,7 @@ func createTestAdminUser(t *testing.T, db *gorm.DB) uint {
UUID: "admin-uuid-1234",
Email: "admin@test.com",
Name: "Test Admin",
Role: "admin",
Role: models.RoleAdmin,
Enabled: true,
APIKey: "test-api-key",
}
@@ -66,7 +67,7 @@ func setupRouterWithAuth(db *gorm.DB, userID uint, role string) *gin.Engine {
c.Next()
})
userHandler := handlers.NewUserHandler(db)
userHandler := handlers.NewUserHandler(db, nil)
settingsHandler := handlers.NewSettingsHandler(db)
api := r.Group("/api")
@@ -124,7 +125,7 @@ func TestInviteToken_ExpiredCannotBeUsed(t *testing.T) {
user := models.User{
UUID: "invite-uuid-1234",
Email: "expired@test.com",
Role: "user",
Role: models.RoleUser,
Enabled: false,
InviteToken: "expired-token-12345678901234567890123456789012",
InviteExpires: &expiredTime,
@@ -153,7 +154,7 @@ func TestInviteToken_CannotBeReused(t *testing.T) {
UUID: "accepted-uuid-1234",
Email: "accepted@test.com",
Name: "Accepted User",
Role: "user",
Role: models.RoleUser,
Enabled: true,
InviteToken: "accepted-token-1234567890123456789012345678901",
InvitedAt: &invitedAt,
@@ -217,7 +218,7 @@ func TestAcceptInvite_PasswordValidation(t *testing.T) {
user := models.User{
UUID: "pending-uuid-1234",
Email: "pending@test.com",
Role: "user",
Role: models.RoleUser,
Enabled: false,
InviteToken: "valid-token-12345678901234567890123456789012345",
InviteExpires: &expires,
@@ -269,15 +270,29 @@ func TestUserEndpoints_RequireAdmin(t *testing.T) {
UUID: "user-uuid-1234",
Email: "user@test.com",
Name: "Regular User",
Role: "user",
Role: models.RoleUser,
Enabled: true,
APIKey: "user-api-key-unique",
}
require.NoError(t, user.SetPassword("userpassword123"))
require.NoError(t, db.Create(&user).Error)
// Create a second user to test admin-only operations against a non-self target
otherUser := models.User{
UUID: "other-uuid-5678",
Email: "other@test.com",
Name: "Other User",
Role: models.RoleUser,
Enabled: true,
APIKey: "other-api-key-unique",
}
require.NoError(t, otherUser.SetPassword("otherpassword123"))
require.NoError(t, db.Create(&otherUser).Error)
// Router with regular user role
r := setupRouterWithAuth(db, user.ID, "user")
otherID := fmt.Sprintf("%d", otherUser.ID)
endpoints := []struct {
method string
path string
@@ -286,10 +301,10 @@ func TestUserEndpoints_RequireAdmin(t *testing.T) {
{"GET", "/api/users", ""},
{"POST", "/api/users", `{"email":"new@test.com","name":"New","password":"password123"}`},
{"POST", "/api/users/invite", `{"email":"invite@test.com"}`},
{"GET", "/api/users/1", ""},
{"PUT", "/api/users/1", `{"name":"Updated"}`},
{"DELETE", "/api/users/1", ""},
{"PUT", "/api/users/1/permissions", `{"permission_mode":"deny_all"}`},
{"GET", "/api/users/" + otherID, ""},
{"PUT", "/api/users/" + otherID, `{"name":"Updated"}`},
{"DELETE", "/api/users/" + otherID, ""},
{"PUT", "/api/users/" + otherID + "/permissions", `{"permission_mode":"deny_all"}`},
}
for _, ep := range endpoints {
@@ -316,7 +331,7 @@ func TestSMTPEndpoints_RequireAdmin(t *testing.T) {
UUID: "user-uuid-5678",
Email: "user2@test.com",
Name: "Regular User 2",
Role: "user",
Role: models.RoleUser,
Enabled: true,
}
require.NoError(t, user.SetPassword("userpassword123"))
@@ -462,7 +477,7 @@ func TestInviteUser_DuplicateEmailBlocked(t *testing.T) {
UUID: "existing-uuid-1234",
Email: "existing@test.com",
Name: "Existing User",
Role: "user",
Role: models.RoleUser,
Enabled: true,
}
require.NoError(t, db.Create(&existing).Error)
@@ -488,7 +503,7 @@ func TestInviteUser_EmailCaseInsensitive(t *testing.T) {
UUID: "existing-uuid-5678",
Email: "test@example.com",
Name: "Existing User",
Role: "user",
Role: models.RoleUser,
Enabled: true,
}
require.NoError(t, db.Create(&existing).Error)
@@ -532,7 +547,7 @@ func TestUpdatePermissions_ValidModes(t *testing.T) {
UUID: "perms-user-1234",
Email: "permsuser@test.com",
Name: "Perms User",
Role: "user",
Role: models.RoleUser,
Enabled: true,
}
require.NoError(t, db.Create(&user).Error)
@@ -574,7 +589,7 @@ func TestPublicEndpoints_NoAuthRequired(t *testing.T) {
// Router WITHOUT auth middleware
gin.SetMode(gin.TestMode)
r := gin.New()
userHandler := handlers.NewUserHandler(db)
userHandler := handlers.NewUserHandler(db, nil)
api := r.Group("/api")
userHandler.RegisterRoutes(api)
@@ -584,7 +599,7 @@ func TestPublicEndpoints_NoAuthRequired(t *testing.T) {
user := models.User{
UUID: "public-test-uuid",
Email: "public@test.com",
Role: "user",
Role: models.RoleUser,
Enabled: false,
InviteToken: "public-test-token-123456789012345678901234567",
InviteExpires: &expires,
+1 -1
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")
+23 -2
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
}
+30 -5
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
+3 -3
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),
@@ -30,14 +30,14 @@ func TestAuthService_Register(t *testing.T) {
// Test 1: First user should be admin
admin, err := service.Register("admin@example.com", "password123", "Admin User")
require.NoError(t, err)
assert.Equal(t, "admin", admin.Role)
assert.Equal(t, models.RoleAdmin, admin.Role)
assert.NotEmpty(t, admin.PasswordHash)
assert.NotEqual(t, "password123", admin.PasswordHash)
// Test 2: Second user should be regular user
user, err := service.Register("user@example.com", "password123", "Regular User")
require.NoError(t, err)
assert.Equal(t, "user", user.Role)
assert.Equal(t, models.RoleUser, user.Role)
}
func TestAuthService_Login(t *testing.T) {
@@ -300,7 +300,7 @@ func TestAuthService_AuthenticateToken_InvalidUserIDInClaims(t *testing.T) {
claims := Claims{
UserID: user.ID + 9999,
Role: "user",
Role: string(models.RoleUser),
SessionVersion: user.SessionVersion,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
@@ -45,7 +45,7 @@ func TestBackupService_RehydrateLiveDatabase(t *testing.T) {
UUID: uuid.NewString(),
Email: "restore-user@example.com",
Name: "Restore User",
Role: "user",
Role: models.RoleUser,
Enabled: true,
APIKey: uuid.NewString(),
}
@@ -87,7 +87,7 @@ func TestBackupService_RehydrateLiveDatabase_FromBackupWithWAL(t *testing.T) {
UUID: uuid.NewString(),
Email: "restore-from-wal@example.com",
Name: "Restore From WAL",
Role: "user",
Role: models.RoleUser,
Enabled: true,
APIKey: uuid.NewString(),
}
@@ -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.
+955 -336
View File
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -1,7 +1,9 @@
## QA Report - PR #779
## QA Report — Import/Save Route Regression Test Suite
- Date: 2026-03-01
- Scope: Post-remediation merge-readiness gates after Caddy Import E2E fix
- Date: 2026-03-02
- Branch: `feature/beta-release` (HEAD `2f90d936`)
- Scope: Regression test coverage for import and save function routes
- Full report: [docs/reports/qa_report_import_save_regression.md](qa_report_import_save_regression.md)
## E2E Status
@@ -0,0 +1,188 @@
## QA Report — Import/Save Route Regression Test Suite
- **Date**: 2026-03-02
- **Branch**: `feature/beta-release`
- **HEAD**: `2f90d936``fix(tests): simplify back/cancel button handling in cross-browser import tests`
- **Scope**: Regression test implementation for import and save function routes
---
## Summary
| DoD Gate | Result | Notes |
|---|---|---|
| Patch Coverage Preflight | ✅ PASS | 100% — 12/12 changed lines covered |
| Backend Unit Tests + Coverage | ✅ PASS | 87.9% statements (threshold: 87%) |
| Frontend Unit Tests + Coverage | ✅ PASS | 89.63% lines (threshold: 87%) |
| TypeScript Type Check | ✅ PASS | 0 type errors |
| Pre-commit Hooks | ✅ PASS | 17/17 hooks passed |
| GORM Security Scan | ⏭️ SKIP | No model files changed |
| Trivy FS Scan | ✅ PASS | 0 HIGH/CRITICAL in npm packages |
| Docker Image Scan | ✅ PASS | 0 HIGH/CRITICAL (13 LOW/MED total) |
| CodeQL Analysis | ✅ PASS | 1 pre-existing warning (not a regression) |
**Overall Verdict: PASS** — All gated checks passed. Two pre-existing items documented below.
---
## New Test Files
Eight test files were added as part of this feature:
| File | Type | Tests |
|---|---|---|
| `backend/internal/api/routes/routes_import_contract_test.go` | Backend unit | Route contract coverage |
| `backend/internal/api/routes/routes_save_contract_test.go` | Backend unit | Route contract coverage |
| `backend/internal/api/routes/endpoint_inventory_test.go` | Backend unit | Endpoint inventory/matrix |
| `frontend/src/api/__tests__/npmImport.test.ts` | Frontend unit | 6 tests |
| `frontend/src/api/__tests__/jsonImport.test.ts` | Frontend unit | 6 tests |
| `frontend/src/hooks/__tests__/useNPMImport.test.tsx` | Frontend unit | 5 tests |
| `frontend/src/hooks/__tests__/useJSONImport.test.tsx` | Frontend unit | 5 tests |
| `tests/integration/import-save-route-regression.spec.ts` | Integration | Route regression spec |
All 22 new frontend tests passed. Backend route package runs clean.
---
## Step 1 — Patch Coverage Preflight
- **Command**: `bash scripts/local-patch-report.sh`
- **Artifacts**: `test-results/local-patch-report.md`, `test-results/local-patch-report.json`
- **Result**: PASS
- **Metrics**:
- Overall patch coverage: 100% (12/12 changed lines)
- Backend changed lines: 8/8 covered (100%)
- Frontend changed lines: 4/4 covered (100%)
---
## Step 2 — Backend Unit Tests + Coverage
- **Command**: `bash scripts/go-test-coverage.sh`
- **Result**: PASS
- **Metrics**:
- Total statements: 87.9%
- `internal/api/routes` package: 87.8%
- Gate threshold: 87%
- **Package results**: 25/26 packages `ok`
- **Known exception**: `internal/api/handlers` — 1 test fails in full suite only
### Pre-existing Backend Failure
| Item | Detail |
|---|---|
| Test | `TestSecurityHandler_UpsertRuleSet_XSSInContent` |
| Package | `internal/api/handlers` |
| File | `security_handler_audit_test.go` |
| Behaviour | Fails in full suite (`FAIL: expected 200, got {"error":"failed to list rule sets"}`); passes in isolation |
| Cause | Parallel test state pollution — shared in-memory SQLite DB contaminated by another test in the same package |
| Introduced by this PR | No — file shows no git changes in this session |
| Regression | No |
---
## Step 3 — Frontend Unit Tests + Coverage
- **Command**: `bash scripts/frontend-test-coverage.sh`
- **Result**: PASS
- **Metrics**:
- Lines: 89.63% (threshold: 87%)
- Statements: 88.96%
- Functions: 86.06%
- Branches: 81.41%
- **Test counts**: 589 passed, 23 skipped, 0 failed, 24 test suites
### New Frontend Test Results
All four new test files passed explicitly:
```
✅ npmImport.test.ts 6 tests passed
✅ jsonImport.test.ts 6 tests passed
✅ useNPMImport.test.tsx 5 tests passed
✅ useJSONImport.test.tsx 5 tests passed
```
---
## Step 4 — TypeScript Type Check
- **Command**: `npm run type-check`
- **Result**: PASS — 0 errors, clean exit
---
## Step 5 — Pre-commit Hooks
- **Command**: `pre-commit run --all-files`
- **Result**: PASS — 17/17 hooks passed
Hooks verified include: `fix-end-of-files`, `trim-trailing-whitespace`, `check-yaml`, `shellcheck`, `actionlint`, `dockerfile-validation`, `go-vet`, `golangci-lint (Fast Linters - BLOCKING)`, `frontend-typecheck`, `frontend-lint`.
---
## Step 6 — GORM Security Scan
- **Result**: SKIPPED
- **Reason**: No files under `backend/internal/models/**` or GORM service/repository paths were modified in this session.
---
## Step 7 — Security Scans
### Trivy Filesystem Scan
- **Command**: `trivy fs . --severity HIGH,CRITICAL --exit-code 1 --skip-dirs .git,node_modules,...`
- **Result**: PASS — 0 HIGH/CRITICAL vulnerabilities
- **Scope**: `package-lock.json` (npm)
- **Report**: `trivy-report.json`
### Docker Image Scan
- **Command**: `.github/skills/scripts/skill-runner.sh security-scan-docker-image`
- **Result**: PASS — 0 HIGH/CRITICAL vulnerabilities
- **Total findings**: 13 (all LOW or MEDIUM severity)
- **Verdict**: Gate passed — no action required
### CodeQL Analysis
- **SARIF files**:
- `codeql-results-go.sarif` — generated 2026-03-02
- `codeql-results-javascript.sarif` — generated 2026-03-02
- **Go results**: 1 finding — `go/cookie-secure-not-set` (warning level)
- **JavaScript results**: 0 findings
- **Result**: PASS (no error-level findings)
#### Pre-existing CodeQL Finding
| Item | Detail |
|---|---|
| Rule | `go/cookie-secure-not-set` |
| File | `internal/api/handlers/auth_handler.go:151159` |
| Severity | Warning (non-blocking) |
| Description | Cookie does not set `Secure` attribute to `true` |
| Context | Intentional design: `secure` flag defaults to `true`; set to `false` **only** for local loopback requests without TLS. This allows the management UI to function over HTTP on `localhost` during development. The code comment explicitly documents this decision: _"Secure: true for HTTPS; false only for local non-HTTPS loopback flows"_ |
| Introduced by this PR | No — `auth_handler.go` was last modified in commits predating HEAD (`e348b5b2`, `00349689`) |
| Regression | No |
| Action | None — accepted as intentional design trade-off for local-dev UX |
---
## Pre-existing Issues Register
| ID | Location | Nature | Regression? | Action |
|---|---|---|---|---|
| PE-001 | `handlers.TestSecurityHandler_UpsertRuleSet_XSSInContent` | Test isolation failure — parallel SQLite state pollution | No | Track separately; fix with test DB isolation |
| PE-002 | `auth_handler.go:151``go/cookie-secure-not-set` | CodeQL warning; intentional local-dev design | No | Accepted; document as acknowledged finding |
---
## Related Commits
| Hash | Message |
|---|---|
| `63e79664` | `test(routes): add strict route matrix tests for import and save workflows` |
| `077e3c1d` | `chore: add integration tests for import/save route regression coverage` |
| `f60a99d0` | `fix(tests): update route validation functions to ensure canonical success responses in import/save regression tests` |
| `b5fd5d57` | `fix(tests): update import handler test to use temporary directory for Caddyfile path` |
| `2f90d936` | `fix(tests): simplify back/cancel button handling in cross-browser import tests` |
+58 -58
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.575.0",
"lucide-react": "^0.577.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.71.2",
@@ -29,7 +29,7 @@
"react-i18next": "^16.5.4",
"react-router-dom": "^7.13.1",
"tailwind-merge": "^3.5.0",
"tldts": "^7.0.23"
"tldts": "^7.0.24"
},
"devDependencies": {
"@eslint/css": "^0.14.1",
@@ -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",
@@ -579,9 +579,9 @@
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.0.28",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.28.tgz",
"integrity": "sha512-1NRf1CUBjnr3K7hu8BLxjQrKCxEe8FP/xmPTenAxCRZWVLbmGotkFvG9mfNpjA6k7Bw1bw4BilZq9cu19RA5pg==",
"version": "1.0.29",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.29.tgz",
"integrity": "sha512-jx9GjkkP5YHuTmko2eWAvpPnb0mB4mGRr2U7XwVNwevm8nlpobZEVk+GNmiYMk2VuA75v+plfXWyroWKmICZXg==",
"dev": true,
"funding": [
{
@@ -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": [
{
@@ -4543,13 +4543,13 @@
"license": "MIT"
},
"node_modules/cssstyle": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.1.0.tgz",
"integrity": "sha512-Ml4fP2UT2K3CUBQnVlbdV/8aFDdlY69E+YnwJM+3VUWl08S3J8c8aRuJqCkD9Py8DHZ7zNNvsfKl8psocHZEFg==",
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz",
"integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.0.0",
"@asamuzakjp/css-color": "^5.0.1",
"@csstools/css-syntax-patches-for-csstree": "^1.0.28",
"css-tree": "^3.1.0",
"lru-cache": "^11.2.6"
@@ -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"
},
@@ -5305,9 +5305,9 @@
}
},
"node_modules/flatted": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz",
"integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==",
"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.575.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.575.0.tgz",
"integrity": "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==",
"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": [
{
@@ -8297,21 +8297,21 @@
}
},
"node_modules/tldts": {
"version": "7.0.23",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz",
"integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==",
"version": "7.0.24",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.24.tgz",
"integrity": "sha512-1r6vQTTt1rUiJkI5vX7KG8PR342Ru/5Oh13kEQP2SMbRSZpOey9SrBe27IDxkoWulx8ShWu4K6C0BkctP8Z1bQ==",
"license": "MIT",
"dependencies": {
"tldts-core": "^7.0.23"
"tldts-core": "^7.0.24"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.0.23",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz",
"integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==",
"version": "7.0.24",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.24.tgz",
"integrity": "sha512-pj7yygNMoMRqG7ML2SDQ0xNIOfN3IBDUcPVM2Sg6hP96oFNN2nqnzHreT3z9xLq85IWJyNTvD38O002DdOrPMw==",
"license": "MIT"
},
"node_modules/to-regex-range": {
+4 -4
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.575.0",
"lucide-react": "^0.577.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.71.2",
@@ -48,7 +48,7 @@
"react-i18next": "^16.5.4",
"react-router-dom": "^7.13.1",
"tailwind-merge": "^3.5.0",
"tldts": "^7.0.23"
"tldts": "^7.0.24"
},
"devDependencies": {
"@eslint/css": "^0.14.1",
@@ -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",
+15 -5
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 */}
+22 -6
View File
@@ -6,12 +6,14 @@ vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}));
describe('import API', () => {
const mockedGet = vi.mocked(client.get);
const mockedPost = vi.mocked(client.post);
const mockedDelete = vi.mocked(client.delete);
beforeEach(() => {
vi.clearAllMocks();
@@ -71,11 +73,25 @@ describe('import API', () => {
expect(result).toEqual(mockResponse);
});
it('cancelImport posts cancel', async () => {
mockedPost.mockResolvedValue({});
it('cancelImport deletes cancel with required session_uuid query', async () => {
const sessionUUID = 'uuid-cancel-123';
mockedDelete.mockResolvedValue({});
await cancelImport();
expect(client.post).toHaveBeenCalledWith('/import/cancel');
await cancelImport(sessionUUID);
expect(client.delete).toHaveBeenCalledTimes(1);
expect(client.delete).toHaveBeenCalledWith('/import/cancel', {
params: {
session_uuid: sessionUUID,
},
});
const [, requestConfig] = mockedDelete.mock.calls[0];
expect(requestConfig).toEqual({
params: {
session_uuid: sessionUUID,
},
});
});
it('forwards commitImport errors', async () => {
@@ -87,9 +103,9 @@ describe('import API', () => {
it('forwards cancelImport errors', async () => {
const error = new Error('cancel failed');
mockedPost.mockRejectedValue(error);
mockedDelete.mockRejectedValue(error);
await expect(cancelImport()).rejects.toBe(error);
await expect(cancelImport('uuid-cancel-123')).rejects.toBe(error);
});
it('getImportStatus gets status', async () => {
@@ -0,0 +1,96 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { uploadJSONExport, commitJSONImport, cancelJSONImport } from '../jsonImport';
import client from '../client';
vi.mock('../client', () => ({
default: {
post: vi.fn(),
},
}));
describe('jsonImport API', () => {
const mockedPost = vi.mocked(client.post);
beforeEach(() => {
vi.clearAllMocks();
});
it('cancelJSONImport posts cancel endpoint with required session_uuid body', async () => {
const sessionUUID = 'json-session-123';
mockedPost.mockResolvedValue({});
await cancelJSONImport(sessionUUID);
expect(client.post).toHaveBeenCalledWith('/import/json/cancel', {
session_uuid: sessionUUID,
});
});
it('uploadJSONExport posts upload endpoint with content payload', async () => {
const content = '{"proxy_hosts":[]}';
const mockResponse = {
session: {
id: 'json-session-456',
state: 'reviewing',
source: 'json',
},
preview: {
hosts: [],
conflicts: [],
errors: [],
},
conflict_details: {},
};
mockedPost.mockResolvedValue({ data: mockResponse });
const result = await uploadJSONExport(content);
expect(client.post).toHaveBeenCalledWith('/import/json/upload', { content });
expect(result).toEqual(mockResponse);
});
it('commitJSONImport posts commit endpoint with session_uuid, resolutions, and names body', async () => {
const sessionUUID = 'json-session-789';
const resolutions = { 'json.example.com': 'replace' };
const names = { 'json.example.com': 'JSON Example' };
const mockResponse = {
created: 1,
updated: 1,
skipped: 0,
errors: [],
};
mockedPost.mockResolvedValue({ data: mockResponse });
const result = await commitJSONImport(sessionUUID, resolutions, names);
expect(client.post).toHaveBeenCalledWith('/import/json/commit', {
session_uuid: sessionUUID,
resolutions,
names,
});
expect(result).toEqual(mockResponse);
});
it('forwards uploadJSONExport errors', async () => {
const error = new Error('upload failed');
mockedPost.mockRejectedValue(error);
await expect(uploadJSONExport('{"proxy_hosts":[]}')).rejects.toBe(error);
});
it('forwards commitJSONImport errors', async () => {
const error = new Error('commit failed');
mockedPost.mockRejectedValue(error);
await expect(commitJSONImport('json-session-123', {}, {})).rejects.toBe(error);
});
it('forwards cancelJSONImport errors', async () => {
const error = new Error('cancel failed');
mockedPost.mockRejectedValue(error);
await expect(cancelJSONImport('json-session-123')).rejects.toBe(error);
});
});
@@ -0,0 +1,96 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { uploadNPMExport, commitNPMImport, cancelNPMImport } from '../npmImport';
import client from '../client';
vi.mock('../client', () => ({
default: {
post: vi.fn(),
},
}));
describe('npmImport API', () => {
const mockedPost = vi.mocked(client.post);
beforeEach(() => {
vi.clearAllMocks();
});
it('cancelNPMImport posts cancel endpoint with required session_uuid body', async () => {
const sessionUUID = 'npm-session-123';
mockedPost.mockResolvedValue({});
await cancelNPMImport(sessionUUID);
expect(client.post).toHaveBeenCalledWith('/import/npm/cancel', {
session_uuid: sessionUUID,
});
});
it('uploadNPMExport posts upload endpoint with content payload', async () => {
const content = '{"proxy_hosts":[]}';
const mockResponse = {
session: {
id: 'npm-session-456',
state: 'reviewing',
source: 'npm',
},
preview: {
hosts: [],
conflicts: [],
errors: [],
},
conflict_details: {},
};
mockedPost.mockResolvedValue({ data: mockResponse });
const result = await uploadNPMExport(content);
expect(client.post).toHaveBeenCalledWith('/import/npm/upload', { content });
expect(result).toEqual(mockResponse);
});
it('commitNPMImport posts commit endpoint with session_uuid, resolutions, and names body', async () => {
const sessionUUID = 'npm-session-789';
const resolutions = { 'npm.example.com': 'replace' };
const names = { 'npm.example.com': 'NPM Example' };
const mockResponse = {
created: 1,
updated: 1,
skipped: 0,
errors: [],
};
mockedPost.mockResolvedValue({ data: mockResponse });
const result = await commitNPMImport(sessionUUID, resolutions, names);
expect(client.post).toHaveBeenCalledWith('/import/npm/commit', {
session_uuid: sessionUUID,
resolutions,
names,
});
expect(result).toEqual(mockResponse);
});
it('forwards uploadNPMExport errors', async () => {
const error = new Error('upload failed');
mockedPost.mockRejectedValue(error);
await expect(uploadNPMExport('{"proxy_hosts":[]}')).rejects.toBe(error);
});
it('forwards commitNPMImport errors', async () => {
const error = new Error('commit failed');
mockedPost.mockRejectedValue(error);
await expect(commitNPMImport('npm-session-123', {}, {})).rejects.toBe(error);
});
it('forwards cancelNPMImport errors', async () => {
const error = new Error('cancel failed');
mockedPost.mockRejectedValue(error);
await expect(cancelNPMImport('npm-session-123')).rejects.toBe(error);
});
});
+1 -1
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: {
+7 -2
View File
@@ -110,10 +110,15 @@ export const commitImport = async (
/**
* Cancels the current import session.
* @param sessionUUID - The import session UUID
* @throws {AxiosError} If cancellation fails
*/
export const cancelImport = async (): Promise<void> => {
await client.post('/import/cancel');
export const cancelImport = async (sessionUUID: string): Promise<void> => {
await client.delete('/import/cancel', {
params: {
session_uuid: sessionUUID,
},
});
};
/**
+5 -2
View File
@@ -83,8 +83,11 @@ export const commitJSONImport = async (
/**
* Cancels the current JSON import session.
* @param sessionUuid - The import session UUID
* @throws {AxiosError} If cancellation fails
*/
export const cancelJSONImport = async (): Promise<void> => {
await client.post('/import/json/cancel');
export const cancelJSONImport = async (sessionUuid: string): Promise<void> => {
await client.post('/import/json/cancel', {
session_uuid: sessionUuid,
});
};
+5 -2
View File
@@ -83,8 +83,11 @@ export const commitNPMImport = async (
/**
* Cancels the current NPM import session.
* @param sessionUuid - The import session UUID
* @throws {AxiosError} If cancellation fails
*/
export const cancelNPMImport = async (): Promise<void> => {
await client.post('/import/npm/cancel');
export const cancelNPMImport = async (sessionUuid: string): Promise<void> => {
await client.post('/import/npm/cancel', {
session_uuid: sessionUuid,
});
};
-49
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
}
+49 -1
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
}
+4 -3
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>
)}
+25
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
+1 -1
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;
}
@@ -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 />
@@ -208,7 +208,7 @@ describe('useImport', () => {
await result.current.cancel()
})
expect(api.cancelImport).toHaveBeenCalled()
expect(api.cancelImport).toHaveBeenCalledWith('session-3')
await waitFor(() => {
expect(result.current.session).toBeNull()
})
@@ -0,0 +1,190 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import React from 'react'
import { useJSONImport } from '../useJSONImport'
import * as api from '../../api/jsonImport'
vi.mock('../../api/jsonImport', () => ({
uploadJSONExport: vi.fn(),
commitJSONImport: vi.fn(),
cancelJSONImport: vi.fn(),
}))
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}
describe('useJSONImport', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('sets preview and sessionId after successful upload', async () => {
const uploadResponse = {
session: {
id: 'json-session-upload',
state: 'reviewing',
source: 'json',
},
preview: {
hosts: [],
conflicts: [],
errors: [],
},
conflict_details: {},
}
vi.mocked(api.uploadJSONExport).mockResolvedValue(uploadResponse)
const { result } = renderHook(() => useJSONImport(), { wrapper: createWrapper() })
await act(async () => {
await result.current.upload('{"proxy_hosts":[]}')
})
await waitFor(() => {
expect(result.current.sessionId).toBe('json-session-upload')
expect(result.current.preview).toEqual(uploadResponse)
})
})
it('commits active session and clears preview/session state', async () => {
const uploadResponse = {
session: {
id: 'json-session-commit',
state: 'reviewing',
source: 'json',
},
preview: {
hosts: [],
conflicts: [],
errors: [],
},
conflict_details: {},
}
const commitResponse = {
created: 1,
updated: 0,
skipped: 0,
errors: [],
}
vi.mocked(api.uploadJSONExport).mockResolvedValue(uploadResponse)
vi.mocked(api.commitJSONImport).mockResolvedValue(commitResponse)
const { result } = renderHook(() => useJSONImport(), { wrapper: createWrapper() })
await act(async () => {
await result.current.upload('{"proxy_hosts":[]}')
})
await waitFor(() => {
expect(result.current.sessionId).toBe('json-session-commit')
})
await act(async () => {
await result.current.commit({ 'json.example.com': 'replace' }, { 'json.example.com': 'JSON Example' })
})
expect(api.commitJSONImport).toHaveBeenCalledWith(
'json-session-commit',
{ 'json.example.com': 'replace' },
{ 'json.example.com': 'JSON Example' }
)
await waitFor(() => {
expect(result.current.sessionId).toBeNull()
expect(result.current.preview).toBeNull()
expect(result.current.commitResult).toEqual(commitResponse)
})
})
it('passes active session UUID to cancelJSONImport', async () => {
const sessionId = 'json-session-123'
vi.mocked(api.uploadJSONExport).mockResolvedValue({
session: {
id: sessionId,
state: 'reviewing',
source: 'json',
},
preview: {
hosts: [],
conflicts: [],
errors: [],
},
conflict_details: {},
})
vi.mocked(api.cancelJSONImport).mockResolvedValue(undefined)
const { result } = renderHook(() => useJSONImport(), { wrapper: createWrapper() })
await act(async () => {
await result.current.upload('{}')
})
await waitFor(() => {
expect(result.current.sessionId).toBe(sessionId)
})
await act(async () => {
await result.current.cancel()
})
expect(api.cancelJSONImport).toHaveBeenCalledWith(sessionId)
await waitFor(() => {
expect(result.current.sessionId).toBeNull()
})
})
it('returns No active session and skips cancel API call when session is missing', async () => {
const { result } = renderHook(() => useJSONImport(), { wrapper: createWrapper() })
await expect(result.current.cancel()).rejects.toThrow('No active session')
expect(api.cancelJSONImport).not.toHaveBeenCalled()
})
it('exposes commit error and preserves session on commit failure', async () => {
const uploadResponse = {
session: {
id: 'json-session-error',
state: 'reviewing',
source: 'json',
},
preview: {
hosts: [],
conflicts: [],
errors: [],
},
conflict_details: {},
}
const commitError = new Error('404 Not Found')
vi.mocked(api.uploadJSONExport).mockResolvedValue(uploadResponse)
vi.mocked(api.commitJSONImport).mockRejectedValue(commitError)
const { result } = renderHook(() => useJSONImport(), { wrapper: createWrapper() })
await act(async () => {
await result.current.upload('{"proxy_hosts":[]}')
})
await expect(result.current.commit({}, {})).rejects.toBe(commitError)
await waitFor(() => {
expect(result.current.commitError).toBe(commitError)
expect(result.current.sessionId).toBe('json-session-error')
expect(result.current.preview).not.toBeNull()
})
})
})
@@ -0,0 +1,190 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import React from 'react'
import { useNPMImport } from '../useNPMImport'
import * as api from '../../api/npmImport'
vi.mock('../../api/npmImport', () => ({
uploadNPMExport: vi.fn(),
commitNPMImport: vi.fn(),
cancelNPMImport: vi.fn(),
}))
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}
describe('useNPMImport', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('sets preview and sessionId after successful upload', async () => {
const uploadResponse = {
session: {
id: 'npm-session-upload',
state: 'reviewing',
source: 'npm',
},
preview: {
hosts: [],
conflicts: [],
errors: [],
},
conflict_details: {},
}
vi.mocked(api.uploadNPMExport).mockResolvedValue(uploadResponse)
const { result } = renderHook(() => useNPMImport(), { wrapper: createWrapper() })
await act(async () => {
await result.current.upload('{"proxy_hosts":[]}')
})
await waitFor(() => {
expect(result.current.sessionId).toBe('npm-session-upload')
expect(result.current.preview).toEqual(uploadResponse)
})
})
it('commits active session and clears preview/session state', async () => {
const uploadResponse = {
session: {
id: 'npm-session-commit',
state: 'reviewing',
source: 'npm',
},
preview: {
hosts: [],
conflicts: [],
errors: [],
},
conflict_details: {},
}
const commitResponse = {
created: 1,
updated: 0,
skipped: 0,
errors: [],
}
vi.mocked(api.uploadNPMExport).mockResolvedValue(uploadResponse)
vi.mocked(api.commitNPMImport).mockResolvedValue(commitResponse)
const { result } = renderHook(() => useNPMImport(), { wrapper: createWrapper() })
await act(async () => {
await result.current.upload('{"proxy_hosts":[]}')
})
await waitFor(() => {
expect(result.current.sessionId).toBe('npm-session-commit')
})
await act(async () => {
await result.current.commit({ 'npm.example.com': 'replace' }, { 'npm.example.com': 'NPM Example' })
})
expect(api.commitNPMImport).toHaveBeenCalledWith(
'npm-session-commit',
{ 'npm.example.com': 'replace' },
{ 'npm.example.com': 'NPM Example' }
)
await waitFor(() => {
expect(result.current.sessionId).toBeNull()
expect(result.current.preview).toBeNull()
expect(result.current.commitResult).toEqual(commitResponse)
})
})
it('passes active session UUID to cancelNPMImport', async () => {
const sessionId = 'npm-session-123'
vi.mocked(api.uploadNPMExport).mockResolvedValue({
session: {
id: sessionId,
state: 'reviewing',
source: 'npm',
},
preview: {
hosts: [],
conflicts: [],
errors: [],
},
conflict_details: {},
})
vi.mocked(api.cancelNPMImport).mockResolvedValue(undefined)
const { result } = renderHook(() => useNPMImport(), { wrapper: createWrapper() })
await act(async () => {
await result.current.upload('{}')
})
await waitFor(() => {
expect(result.current.sessionId).toBe(sessionId)
})
await act(async () => {
await result.current.cancel()
})
expect(api.cancelNPMImport).toHaveBeenCalledWith(sessionId)
await waitFor(() => {
expect(result.current.sessionId).toBeNull()
})
})
it('returns No active session and skips cancel API call when session is missing', async () => {
const { result } = renderHook(() => useNPMImport(), { wrapper: createWrapper() })
await expect(result.current.cancel()).rejects.toThrow('No active session')
expect(api.cancelNPMImport).not.toHaveBeenCalled()
})
it('exposes commit error and preserves session on commit failure', async () => {
const uploadResponse = {
session: {
id: 'npm-session-error',
state: 'reviewing',
source: 'npm',
},
preview: {
hosts: [],
conflicts: [],
errors: [],
},
conflict_details: {},
}
const commitError = new Error('404 Not Found')
vi.mocked(api.uploadNPMExport).mockResolvedValue(uploadResponse)
vi.mocked(api.commitNPMImport).mockRejectedValue(commitError)
const { result } = renderHook(() => useNPMImport(), { wrapper: createWrapper() })
await act(async () => {
await result.current.upload('{"proxy_hosts":[]}')
})
await expect(result.current.commit({}, {})).rejects.toBe(commitError)
await waitFor(() => {
expect(result.current.commitError).toBe(commitError)
expect(result.current.sessionId).toBe('npm-session-error')
expect(result.current.preview).not.toBeNull()
})
})
})
+47
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])
}
+5 -1
View File
@@ -77,7 +77,11 @@ export function useImport() {
});
const cancelMutation = useMutation({
mutationFn: () => cancelImport(),
mutationFn: () => {
const sessionId = uploadPreview?.session?.id || statusQuery.data?.session?.id;
if (!sessionId) throw new Error('No active session');
return cancelImport(sessionId);
},
onSuccess: () => {
// Clear upload preview and remove query cache
setUploadPreview(null);
+4 -1
View File
@@ -46,7 +46,10 @@ export function useJSONImport() {
});
const cancelMutation = useMutation({
mutationFn: cancelJSONImport,
mutationFn: () => {
if (!sessionId) throw new Error('No active session');
return cancelJSONImport(sessionId);
},
onSuccess: () => {
setPreview(null);
setSessionId(null);
+4 -1
View File
@@ -46,7 +46,10 @@ export function useNPMImport() {
});
const cancelMutation = useMutation({
mutationFn: cancelNPMImport,
mutationFn: () => {
if (!sessionId) throw new Error('No active session');
return cancelNPMImport(sessionId);
},
onSuccess: () => {
setPreview(null);
setSessionId(null);
+26 -3
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."
}
}
+26 -3
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."
}
}
+26 -3
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."
}
}
+26 -3
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."
}
}
+26 -3
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": "您无权访问管理界面。"
}
}
-540
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>
)
}
+63
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>
)
}
+4 -2
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 (
+310 -33
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>
)
}

Some files were not shown because too many files have changed in this diff Show More