From 136dd7ef6243d568ade14da6cae89ada24bed8f5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 22:31:09 +0000 Subject: [PATCH 01/23] chore(deps): update dependency postcss to ^8.5.7 --- frontend/package-lock.json | 8 ++++---- frontend/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3a16b08e..d8d96c3e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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.7", "tailwindcss": "^4.2.1", "typescript": "^5.9.3", "typescript-eslint": "^8.56.1", @@ -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": [ { diff --git a/frontend/package.json b/frontend/package.json index af28c1bf..b403792f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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.7", "tailwindcss": "^4.2.1", "typescript": "^5.9.3", "typescript-eslint": "^8.56.1", From 6cfe8ca9f221bcc5102c02a44445734d3933d409 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 00:22:16 +0000 Subject: [PATCH 02/23] chore(deps): update dependency postcss to ^8.5.8 --- frontend/package-lock.json | 2 +- frontend/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d8d96c3e..01331e77 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -56,7 +56,7 @@ "eslint-plugin-react-refresh": "^0.5.2", "jsdom": "28.1.0", "knip": "^5.85.0", - "postcss": "^8.5.7", + "postcss": "^8.5.8", "tailwindcss": "^4.2.1", "typescript": "^5.9.3", "typescript-eslint": "^8.56.1", diff --git a/frontend/package.json b/frontend/package.json index b403792f..065cc8a9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -75,7 +75,7 @@ "eslint-plugin-react-refresh": "^0.5.2", "jsdom": "28.1.0", "knip": "^5.85.0", - "postcss": "^8.5.7", + "postcss": "^8.5.8", "tailwindcss": "^4.2.1", "typescript": "^5.9.3", "typescript-eslint": "^8.56.1", From a1a9ab2eceef3d8f6c17169bd91864cd823f2ead Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 2 Mar 2026 22:44:41 +0000 Subject: [PATCH 03/23] chore(docs): archive uptime monitoring regression investigation plan to address false DOWN states --- .../archived_docker-socket-group-spec.md | 0 docs/plans/archive/uptime_regression_spec.md | 362 ++++++++++++++++++ 2 files changed, 362 insertions(+) rename docs/plans/{ => archive}/archived_docker-socket-group-spec.md (100%) create mode 100644 docs/plans/archive/uptime_regression_spec.md diff --git a/docs/plans/archived_docker-socket-group-spec.md b/docs/plans/archive/archived_docker-socket-group-spec.md similarity index 100% rename from docs/plans/archived_docker-socket-group-spec.md rename to docs/plans/archive/archived_docker-socket-group-spec.md diff --git a/docs/plans/archive/uptime_regression_spec.md b/docs/plans/archive/uptime_regression_spec.md new file mode 100644 index 00000000..a69a91c1 --- /dev/null +++ b/docs/plans/archive/uptime_regression_spec.md @@ -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. From 3632d0d88c54784785617922924667aeac9c9587 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Tue, 3 Mar 2026 00:19:23 +0000 Subject: [PATCH 04/23] fix: user roles to use UserRole type and update related tests - Changed user role representation from string to UserRole type in User model. - Updated role assignments in various services and handlers to use the new UserRole constants. - Modified middleware to handle UserRole type for role checks. - Refactored tests to align with the new UserRole type. - Added migration function to convert legacy "viewer" roles to "passthrough". - Ensured all role checks and assignments are consistent across the application. --- backend/cmd/api/main_test.go | 4 +- backend/cmd/seed/seed_smoke_test.go | 4 +- backend/internal/api/handlers/auth_handler.go | 2 +- .../api/handlers/auth_handler_test.go | 26 +- .../api/handlers/emergency_handler.go | 20 +- .../api/handlers/permission_helpers.go | 4 +- .../handlers/security_event_intake_test.go | 2 +- .../internal/api/handlers/security_handler.go | 5 +- backend/internal/api/handlers/user_handler.go | 160 ++++++++--- .../handlers/user_handler_coverage_test.go | 38 +-- .../api/handlers/user_handler_test.go | 54 ++-- .../api/handlers/user_integration_test.go | 2 +- backend/internal/api/middleware/auth.go | 22 +- .../internal/api/middleware/optional_auth.go | 2 +- .../api/middleware/optional_auth_test.go | 2 +- backend/internal/api/routes/routes.go | 270 +++++++++--------- .../internal/api/routes/routes_import_test.go | 2 +- .../api/tests/user_smtp_audit_test.go | 47 +-- backend/internal/cerberus/cerberus.go | 2 +- backend/internal/models/user.go | 25 +- backend/internal/models/user_test.go | 10 +- backend/internal/services/auth_service.go | 6 +- .../internal/services/auth_service_test.go | 6 +- .../services/backup_service_rehydrate_test.go | 4 +- 24 files changed, 427 insertions(+), 292 deletions(-) diff --git a/backend/cmd/api/main_test.go b/backend/cmd/api/main_test.go index d260b552..78d3978c 100644 --- a/backend/cmd/api/main_test.go +++ b/backend/cmd/api/main_test.go @@ -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 { diff --git a/backend/cmd/seed/seed_smoke_test.go b/backend/cmd/seed/seed_smoke_test.go index c47f5a9a..54e12953 100644 --- a/backend/cmd/seed/seed_smoke_test.go +++ b/backend/cmd/seed/seed_smoke_test.go @@ -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", } diff --git a/backend/internal/api/handlers/auth_handler.go b/backend/internal/api/handlers/auth_handler.go index 8d6c86e0..72decb75 100644 --- a/backend/internal/api/handlers/auth_handler.go +++ b/backend/internal/api/handlers/auth_handler.go @@ -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 diff --git a/backend/internal/api/handlers/auth_handler_test.go b/backend/internal/api/handlers/auth_handler_test.go index 72f73c88..101df321 100644 --- a/backend/internal/api/handlers/auth_handler_test.go +++ b/backend/internal/api/handlers/auth_handler_test.go @@ -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, } diff --git a/backend/internal/api/handlers/emergency_handler.go b/backend/internal/api/handlers/emergency_handler.go index 55ea772e..e2110bcd 100644 --- a/backend/internal/api/handlers/emergency_handler.go +++ b/backend/internal/api/handlers/emergency_handler.go @@ -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 } diff --git a/backend/internal/api/handlers/permission_helpers.go b/backend/internal/api/handlers/permission_helpers.go index e2a06716..2d20f370 100644 --- a/backend/internal/api/handlers/permission_helpers.go +++ b/backend/internal/api/handlers/permission_helpers.go @@ -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 { diff --git a/backend/internal/api/handlers/security_event_intake_test.go b/backend/internal/api/handlers/security_event_intake_test.go index 37afce16..febf286c 100644 --- a/backend/internal/api/handlers/security_event_intake_test.go +++ b/backend/internal/api/handlers/security_event_intake_test.go @@ -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) diff --git a/backend/internal/api/handlers/security_handler.go b/backend/internal/api/handlers/security_handler.go index 4468d4b2..20dafef9 100644 --- a/backend/internal/api/handlers/security_handler.go +++ b/backend/internal/api/handlers/security_handler.go @@ -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 } diff --git a/backend/internal/api/handlers/user_handler.go b/backend/internal/api/handlers/user_handler.go index 6b1d884a..ef838361 100644 --- a/backend/internal/api/handlers/user_handler.go +++ b/backend/internal/api/handlers/user_handler.go @@ -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(), } @@ -199,6 +201,10 @@ func (h *UserHandler) Setup(c *gin.Context) { // RegenerateAPIKey generates a new API key for the authenticated user. func (h *UserHandler) RegenerateAPIKey(c *gin.Context) { + if c.GetString("role") == string(models.RolePassthrough) { + c.JSON(http.StatusForbidden, gin.H{"error": "Passthrough users cannot manage API keys"}) + return + } userID, exists := c.Get("userID") if !exists { c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) @@ -222,6 +228,10 @@ 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 c.GetString("role") == string(models.RolePassthrough) { + c.JSON(http.StatusForbidden, gin.H{"error": "Passthrough users cannot access profile"}) + return + } userID, exists := c.Get("userID") if !exists { c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) @@ -252,6 +262,10 @@ type UpdateProfileRequest struct { // UpdateProfile updates the authenticated user's profile. func (h *UserHandler) UpdateProfile(c *gin.Context) { + if c.GetString("role") == string(models.RolePassthrough) { + c.JSON(http.StatusForbidden, gin.H{"error": "Passthrough users cannot update profile"}) + return + } userID, exists := c.Get("userID") if !exists { c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) @@ -309,9 +323,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 +367,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 +379,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 +407,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 +475,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 +489,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 +524,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 +613,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 +657,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 +706,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 } @@ -719,6 +739,21 @@ func (h *UserHandler) UpdateUser(c *gin.Context) { return } + isSelf := uint(id) == currentUserID + isCallerAdmin := currentRole == string(models.RoleAdmin) + + // Non-admin users can only update their own name and password + if !isCallerAdmin { + if !isSelf { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"}) + 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,7 +762,6 @@ 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 { c.JSON(http.StatusConflict, gin.H{"error": "Email already in use"}) @@ -736,8 +770,35 @@ func (h *UserHandler) UpdateUser(c *gin.Context) { 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 + } + + // Last-admin protection + if user.Role == models.RoleAdmin && newRole != models.RoleAdmin { + var adminCount int64 + h.DB.Model(&models.User{}).Where("role = ? AND enabled = ?", models.RoleAdmin, true).Count(&adminCount) + if adminCount <= 1 { + c.JSON(http.StatusForbidden, gin.H{"error": "Cannot demote the last admin"}) + return + } + } + + updates["role"] = string(newRole) + needsSessionInvalidation = true + } } if req.Password != nil { @@ -750,8 +811,27 @@ 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 + } + + // Last-admin protection for disabling + if user.Role == models.RoleAdmin && !*req.Enabled { + var adminCount int64 + h.DB.Model(&models.User{}).Where("role = ? AND enabled = ?", models.RoleAdmin, true).Count(&adminCount) + if adminCount <= 1 { + c.JSON(http.StatusForbidden, gin.H{"error": "Cannot disable the last admin"}) + return + } + } + updates["enabled"] = *req.Enabled + if !*req.Enabled { + needsSessionInvalidation = true + } } if len(updates) > 0 { @@ -760,6 +840,13 @@ func (h *UserHandler) UpdateUser(c *gin.Context) { 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{ "target_email": user.Email, "target_role": user.Role, @@ -780,13 +867,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 +882,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 +920,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 +1003,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 } diff --git a/backend/internal/api/handlers/user_handler_coverage_test.go b/backend/internal/api/handlers/user_handler_coverage_test.go index d1fc16af..db0133a8 100644 --- a/backend/internal/api/handlers/user_handler_coverage_test.go +++ b/backend/internal/api/handlers/user_handler_coverage_test.go @@ -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) diff --git a/backend/internal/api/handlers/user_handler_test.go b/backend/internal/api/handlers/user_handler_test.go index bdcb24b7..9d2df2eb 100644 --- a/backend/internal/api/handlers/user_handler_test.go +++ b/backend/internal/api/handlers/user_handler_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "strconv" @@ -23,7 +24,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 +313,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 +662,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 +913,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 +944,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 +970,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 +989,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 +1007,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 +1040,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 +1051,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 +1225,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 +1573,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 +1626,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 +1675,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 +1738,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 +1791,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 +1845,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 +1987,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 +2148,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 +2207,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 +2276,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 +2334,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 +2345,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 +2373,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 +2496,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) { diff --git a/backend/internal/api/handlers/user_integration_test.go b/backend/internal/api/handlers/user_integration_test.go index 7b5e6eae..7eed110f 100644 --- a/backend/internal/api/handlers/user_integration_test.go +++ b/backend/internal/api/handlers/user_integration_test.go @@ -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) diff --git a/backend/internal/api/middleware/auth.go b/backend/internal/api/middleware/auth.go index 6164e25e..726eb65a 100644 --- a/backend/internal/api/middleware/auth.go +++ b/backend/internal/api/middleware/auth.go @@ -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": "Forbidden"}) + return + } + c.Next() + } +} diff --git a/backend/internal/api/middleware/optional_auth.go b/backend/internal/api/middleware/optional_auth.go index 95123ae6..9edbe86f 100644 --- a/backend/internal/api/middleware/optional_auth.go +++ b/backend/internal/api/middleware/optional_auth.go @@ -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() } } diff --git a/backend/internal/api/middleware/optional_auth_test.go b/backend/internal/api/middleware/optional_auth_test.go index e8e5f944..f45522d4 100644 --- a/backend/internal/api/middleware/optional_auth_test.go +++ b/backend/internal/api/middleware/optional_auth_test.go @@ -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) diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 2533036d..3ef436ca 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -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) + protected.GET("/auth/accessible-hosts", authHandler.GetAccessibleHosts) + protected.GET("/auth/check-host/:hostId", authHandler.CheckHostAccess) + 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 - 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) + 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) - protected.GET("/logs/live", logsWSHandler.HandleWebSocket) - protected.GET("/logs", logsHandler.List) - protected.GET("/logs/:filename", logsHandler.Read) - protected.GET("/logs/:filename/download", logsHandler.Download) + 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 - protected.GET("/websocket/connections", wsStatusHandler.GetConnections) - protected.GET("/websocket/stats", wsStatusHandler.GetStats) + management.GET("/websocket/connections", wsStatusHandler.GetConnections) + management.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) + 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) - protected.GET("/system/permissions", systemPermissionsHandler.GetPermissions) - protected.POST("/system/permissions/repair", systemPermissionsHandler.RepairPermissions) + management.GET("/system/permissions", systemPermissionsHandler.GetPermissions) + management.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) + 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) - 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 + 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 - 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) + 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 - 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) + 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) - 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.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,18 +661,19 @@ 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) - // Caddy Manager already created above + // Proxy Hosts & Remote Servers + proxyHostHandler := handlers.NewProxyHostHandler(db, caddyManager, notificationService, uptimeService) + proxyHostHandler.RegisterRoutes(management) - proxyHostHandler := handlers.NewProxyHostHandler(db, caddyManager, notificationService, uptimeService) - proxyHostHandler.RegisterRoutes(protected) + remoteServerHandler := handlers.NewRemoteServerHandler(remoteServerService, notificationService) + remoteServerHandler.RegisterRoutes(management) + } - remoteServerHandler := handlers.NewRemoteServerHandler(remoteServerService, notificationService) - remoteServerHandler.RegisterRoutes(protected) + // Caddy Manager already created above // Initial Caddy Config Sync go func() { @@ -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 diff --git a/backend/internal/api/routes/routes_import_test.go b/backend/internal/api/routes/routes_import_test.go index 84a0010f..ff07f54a 100644 --- a/backend/internal/api/routes/routes_import_test.go +++ b/backend/internal/api/routes/routes_import_test.go @@ -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) diff --git a/backend/internal/api/tests/user_smtp_audit_test.go b/backend/internal/api/tests/user_smtp_audit_test.go index f27b74a9..571bac09 100644 --- a/backend/internal/api/tests/user_smtp_audit_test.go +++ b/backend/internal/api/tests/user_smtp_audit_test.go @@ -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, diff --git a/backend/internal/cerberus/cerberus.go b/backend/internal/cerberus/cerberus.go index e28d37b9..66bae81c 100644 --- a/backend/internal/cerberus/cerberus.go +++ b/backend/internal/cerberus/cerberus.go @@ -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") diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go index 4cb9b3c6..f0455aa6 100644 --- a/backend/internal/models/user.go +++ b/backend/internal/models/user.go @@ -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 } diff --git a/backend/internal/models/user_test.go b/backend/internal/models/user_test.go index 86b40d86..48296d5f 100644 --- a/backend/internal/models/user_test.go +++ b/backend/internal/models/user_test.go @@ -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{}, } diff --git a/backend/internal/services/auth_service.go b/backend/internal/services/auth_service.go index d5202e38..997cff4e 100644 --- a/backend/internal/services/auth_service.go +++ b/backend/internal/services/auth_service.go @@ -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), diff --git a/backend/internal/services/auth_service_test.go b/backend/internal/services/auth_service_test.go index fedc4001..2cca4daf 100644 --- a/backend/internal/services/auth_service_test.go +++ b/backend/internal/services/auth_service_test.go @@ -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)), diff --git a/backend/internal/services/backup_service_rehydrate_test.go b/backend/internal/services/backup_service_rehydrate_test.go index 0034d940..1308e129 100644 --- a/backend/internal/services/backup_service_rehydrate_test.go +++ b/backend/internal/services/backup_service_rehydrate_test.go @@ -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(), } From a681d6aa30f7d740e25bdb554a7755ebf22d9fbc Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Tue, 3 Mar 2026 02:17:17 +0000 Subject: [PATCH 05/23] feat: remove Account page and add PassthroughLanding page - Deleted the Account page and its associated logic. - Introduced a new PassthroughLanding page for users without management access. - Updated Settings page to conditionally display the Users link for admin users. - Enhanced UsersPage to support passthrough user role, including invite functionality and user detail modal. - Updated tests to reflect changes in user roles and navigation. --- frontend/src/App.tsx | 18 +- frontend/src/api/__tests__/user.test.ts | 2 +- frontend/src/api/user.ts | 49 -- frontend/src/api/users.ts | 50 +- frontend/src/components/Layout.tsx | 7 +- frontend/src/components/RequireRole.tsx | 25 + frontend/src/context/AuthContextValue.ts | 2 +- frontend/src/hooks/__tests__/useAuth.test.tsx | 2 +- frontend/src/locales/de/translation.json | 29 +- frontend/src/locales/en/translation.json | 29 +- frontend/src/locales/es/translation.json | 29 +- frontend/src/locales/fr/translation.json | 29 +- frontend/src/locales/zh/translation.json | 29 +- frontend/src/pages/Account.tsx | 540 ------------------ frontend/src/pages/PassthroughLanding.tsx | 54 ++ frontend/src/pages/Settings.tsx | 6 +- frontend/src/pages/UsersPage.tsx | 344 ++++++++++- .../src/pages/__tests__/Settings.test.tsx | 12 +- .../src/pages/__tests__/UsersPage.test.tsx | 82 ++- 19 files changed, 708 insertions(+), 630 deletions(-) delete mode 100644 frontend/src/api/user.ts create mode 100644 frontend/src/components/RequireRole.tsx delete mode 100644 frontend/src/pages/Account.tsx create mode 100644 frontend/src/pages/PassthroughLanding.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ddcc4dbd..343225da 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + + + + } /> @@ -88,7 +94,9 @@ export default function App() { } /> } /> } /> - } /> + + {/* Legacy redirects for old user management paths */} + } /> } /> } /> @@ -99,8 +107,10 @@ export default function App() { } /> } /> } /> - } /> - } /> + } /> + {/* Legacy redirects */} + } /> + } /> {/* Tasks Routes */} diff --git a/frontend/src/api/__tests__/user.test.ts b/frontend/src/api/__tests__/user.test.ts index ee43f501..167f523b 100644 --- a/frontend/src/api/__tests__/user.test.ts +++ b/frontend/src/api/__tests__/user.test.ts @@ -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: { diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts deleted file mode 100644 index 0477d6c5..00000000 --- a/frontend/src/api/user.ts +++ /dev/null @@ -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 => { - 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 => { - const response = await client.post('/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 -} diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index e9aebc27..6fc67a80 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -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 => { const response = await client.post(`/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: string + 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 => { + const response = await client.get('/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('/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 => { + const response = await client.post('/user/api-key') + return response.data +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 1e83b1f5..b19de38b 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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) {
{user && ( - + {user.name} )} diff --git a/frontend/src/components/RequireRole.tsx b/frontend/src/components/RequireRole.tsx new file mode 100644 index 00000000..0ab14b85 --- /dev/null +++ b/frontend/src/components/RequireRole.tsx @@ -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 = ({ allowed, children }) => { + const { user } = useAuth() + + if (!user) { + return + } + + if (!allowed.includes(user.role)) { + const redirectTarget = user.role === 'passthrough' ? '/passthrough' : '/' + return + } + + return children +} + +export default RequireRole diff --git a/frontend/src/context/AuthContextValue.ts b/frontend/src/context/AuthContextValue.ts index ebd5c09e..4644a810 100644 --- a/frontend/src/context/AuthContextValue.ts +++ b/frontend/src/context/AuthContextValue.ts @@ -2,7 +2,7 @@ import { createContext } from 'react'; export interface User { user_id: number; - role: string; + role: 'admin' | 'user' | 'passthrough'; name?: string; email?: string; } diff --git a/frontend/src/hooks/__tests__/useAuth.test.tsx b/frontend/src/hooks/__tests__/useAuth.test.tsx index 00808d90..e3eb5dd8 100644 --- a/frontend/src/hooks/__tests__/useAuth.test.tsx +++ b/frontend/src/hooks/__tests__/useAuth.test.tsx @@ -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( diff --git a/frontend/src/locales/de/translation.json b/frontend/src/locales/de/translation.json index e40b3da1..55259dd7 100644 --- a/frontend/src/locales/de/translation.json +++ b/frontend/src/locales/de/translation.json @@ -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." } } diff --git a/frontend/src/locales/en/translation.json b/frontend/src/locales/en/translation.json index 04eca004..71d9d742 100644 --- a/frontend/src/locales/en/translation.json +++ b/frontend/src/locales/en/translation.json @@ -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." } } diff --git a/frontend/src/locales/es/translation.json b/frontend/src/locales/es/translation.json index a9067bbe..0271f797 100644 --- a/frontend/src/locales/es/translation.json +++ b/frontend/src/locales/es/translation.json @@ -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." } } diff --git a/frontend/src/locales/fr/translation.json b/frontend/src/locales/fr/translation.json index 525cec3f..73512630 100644 --- a/frontend/src/locales/fr/translation.json +++ b/frontend/src/locales/fr/translation.json @@ -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." } } diff --git a/frontend/src/locales/zh/translation.json b/frontend/src/locales/zh/translation.json index 885d64b9..e2c1bf77 100644 --- a/frontend/src/locales/zh/translation.json +++ b/frontend/src/locales/zh/translation.json @@ -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": "您无权访问管理界面。" } } diff --git a/frontend/src/pages/Account.tsx b/frontend/src/pages/Account.tsx deleted file mode 100644 index 571dde00..00000000 --- a/frontend/src/pages/Account.tsx +++ /dev/null @@ -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(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(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 ( -
- - {[1, 2, 3, 4].map((i) => ( - - - - - - - - ))} -
- ) - } - - return ( -
-
-
- -
-

{t('account.title')}

-
- - {/* Profile Settings */} - - -
- - {t('account.profile')} -
- {t('account.profileDescription')} -
-
- -
- - setName(e.target.value)} - required - /> -
-
- - setEmail(e.target.value)} - required - error={emailValid === false ? t('errors.invalidEmail') : undefined} - /> -
-
- - - -
-
- - {/* Certificate Email Settings */} - - -
- - {t('account.certificateEmail')} -
- - {t('account.certificateEmailDescription')} - -
-
- -
- { - setUseUserEmail(checked === true) - if (checked && profile) { - setCertEmail(profile.email) - } - }} - /> - -
- - {!useUserEmail && ( -
- - setCertEmail(e.target.value)} - required={!useUserEmail} - error={certEmailValid === false ? t('errors.invalidEmail') : undefined} - errorTestId="cert-email-error" - aria-invalid={certEmailValid === false} - /> -
- )} -
- - - -
-
- - {/* Password Change */} - - -
- - {t('account.changePassword')} -
- {t('account.changePasswordDescription')} -
-
- -
- - setOldPassword(e.target.value)} - required - autoComplete="current-password" - /> -
-
- - setNewPassword(e.target.value)} - required - autoComplete="new-password" - /> - -
-
- - setConfirmPassword(e.target.value)} - required - error={confirmPassword && newPassword !== confirmPassword ? t('account.passwordsDoNotMatch') : undefined} - autoComplete="new-password" - /> -
-
- - - -
-
- - {/* API Key */} - - -
- - {t('account.apiKey')} -
- - {t('account.apiKeyDescription')} - -
- -
- - -
-
-
- - - {t('account.securityNoticeMessage')} - - - {/* Password Prompt Modal */} - {showPasswordPrompt && ( -
- - -
- - {t('account.confirmPassword')} -
- - {t('account.confirmPasswordDescription')} - -
-
- -
- - setConfirmPasswordForUpdate(e.target.value)} - required - autoFocus - /> -
-
- - - - -
-
-
- )} - - {/* Email Update Confirmation Modal */} - {showEmailConfirmModal && ( -
- - -
- - {t('account.updateCertEmailTitle')} -
- - {t('account.updateCertEmailDescription', { email })} - -
- - - - - -
-
- )} -
- ) -} diff --git a/frontend/src/pages/PassthroughLanding.tsx b/frontend/src/pages/PassthroughLanding.tsx new file mode 100644 index 00000000..33a619c3 --- /dev/null +++ b/frontend/src/pages/PassthroughLanding.tsx @@ -0,0 +1,54 @@ +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() + + return ( +
+
+ +
+
+
+
+ +
+

+ {t('passthrough.title')} +

+ {user?.name && ( +

+ {user.name} +

+ )} +
+ +

+ {t('passthrough.description')} +

+ +

+ {t('passthrough.noAccessToManagement')} +

+ + +
+
+
+ ) +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 973438ab..2b591b4e 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -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 ( diff --git a/frontend/src/pages/UsersPage.tsx b/frontend/src/pages/UsersPage.tsx index adfdc4fc..714f07f9 100644 --- a/frontend/src/pages/UsersPage.tsx +++ b/frontend/src/pages/UsersPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { Link } from 'react-router-dom' @@ -17,10 +17,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 +40,10 @@ import { Loader2, ExternalLink, AlertTriangle, + Pencil, + Key, + Lock, + UserCircle, } from 'lucide-react' interface InviteModalProps { @@ -49,7 +57,7 @@ function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) { const queryClient = useQueryClient() const [email, setEmail] = useState('') const [emailError, setEmailError] = useState(null) - const [role, setRole] = useState<'user' | 'admin'>('user') + const [role, setRole] = useState<'user' | 'admin' | 'passthrough'>('user') const [permissionMode, setPermissionMode] = useState('allow_all') const [selectedHosts, setSelectedHosts] = useState([]) const [inviteResult, setInviteResult] = useState<{ @@ -170,7 +178,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) @@ -287,15 +295,21 @@ function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) { +

+ {role === 'admin' && t('users.roleAdminDescription')} + {role === 'user' && t('users.roleUserDescription')} + {role === 'passthrough' && t('users.rolePassthroughDescription')} +

- {role === 'user' && ( + {(role === 'user' || role === 'passthrough') && ( <>