diff --git a/docs/plans/2026-03-08-access-control-design.md b/docs/plans/2026-03-08-access-control-design.md
new file mode 100644
index 00000000..f22dd2a8
--- /dev/null
+++ b/docs/plans/2026-03-08-access-control-design.md
@@ -0,0 +1,121 @@
+# Access Control Design
+
+## Overview
+
+Site-wide access control for OCAP2-Web instances. Allows community operators to restrict who can view recordings via a single config mode. Builds on the existing role-based auth foundation (PR #311).
+
+## Mode System
+
+Single `auth.mode` config value. Default `public` (current behavior).
+
+| Mode | Description |
+|------|-------------|
+| `public` | No restrictions. Current behavior. |
+| `password` | Shared viewer password. |
+| `steam` | Any Steam account can view. |
+| `steamAllowlist` | Steam login + admin-managed allowlist of Steam IDs. |
+
+All non-public modes issue a JWT with `viewer` role on successful authentication.
+
+## Gate Behavior
+
+### Protected Endpoints
+- `/api/v1/operations*` — recording list, metadata, marker blacklist
+- `/api/v1/worlds` — installed world metadata
+- `/data/*` — recording data files
+
+### Always Public
+- Static assets (`/static/*`)
+- Map tiles (`/images/maps/*`)
+- `/api/healthcheck`
+- `/api/version`
+- `/api/v1/customize`
+- `/api/v1/auth/*` — login/callback/me endpoints
+- `/api/v1/operations/add` — upload endpoint (has own `secret` auth)
+
+### Unauthenticated Flow
+1. User hits protected endpoint → 401
+2. Frontend intercepts 401, saves current path to `sessionStorage` (`ocap_return_to`)
+3. Redirect to login page
+4. User authenticates via mode-appropriate method
+5. JWT issued, redirect back to saved path
+
+## Per-Mode Auth Flow
+
+### `public`
+No gate. Optional Steam login for admin access.
+
+### `password`
+1. User enters shared password on login page
+2. Backend validates password against `auth.password` config (timing-safe comparison)
+3. JWT issued with `viewer` role, subject `password`
+
+### `steam`
+1. User clicks Steam login button
+2. Standard Steam OpenID flow
+3. JWT issued with `viewer` role (or `admin` if in `adminSteamIds`)
+
+### `steamAllowlist`
+1. User clicks Steam login button
+2. Steam OpenID flow completes, Steam ID obtained
+3. Backend checks if Steam ID is in `steam_allowlist` SQLite table
+4. **Admins bypass** — users in `adminSteamIds` always get `admin` role regardless of allowlist
+5. **On allowlist** → JWT issued with `viewer` role
+6. **Not on allowlist** → no token issued, redirect with `auth_error=not_allowed`
+
+Admins manage the allowlist via API:
+- `GET /api/v1/auth/allowlist` — list all allowed Steam IDs
+- `PUT /api/v1/auth/allowlist/{steamId}` — add (idempotent)
+- `DELETE /api/v1/auth/allowlist/{steamId}` — remove
+
+## Admin Bypass
+
+Users whose Steam ID is in `adminSteamIds` always pass the gate regardless of mode. This prevents admin lockout.
+
+## Login UI
+
+| Mode | Primary Action | Secondary Action |
+|------|---------------|-----------------|
+| `public` | — | Steam button (admin) |
+| `password` | Password field + submit | Steam button (admin) |
+| `steam` | Steam button | — |
+| `steamAllowlist` | Steam button | — |
+
+## Configuration
+
+```json
+"auth": {
+ "mode": "public",
+ "sessionTTL": "24h",
+ "adminSteamIds": ["76561198000074241"],
+ "steamApiKey": "",
+ "password": ""
+}
+```
+
+Fields only relevant to the active mode are ignored.
+
+## Startup Validation
+
+Server validates on start that required config values for the active mode are present.
+
+| Mode | Required |
+|------|----------|
+| `public` | — |
+| `password` | `password` |
+| `steam` | — |
+| `steamAllowlist` | — |
+
+## Storage
+
+The `steam_allowlist` table (migration v11) stores allowed Steam IDs:
+
+```sql
+CREATE TABLE steam_allowlist (
+ steam_id TEXT NOT NULL PRIMARY KEY
+);
+```
+
+## Future Compatibility
+
+Per-recording visibility (public/restricted/private per recording) is a separate layer that can be added later. Site-wide gate is middleware-level; per-recording is endpoint-level logic. No conflicts.
diff --git a/docs/plans/2026-03-08-access-control-impl.md b/docs/plans/2026-03-08-access-control-impl.md
new file mode 100644
index 00000000..32799c6e
--- /dev/null
+++ b/docs/plans/2026-03-08-access-control-impl.md
@@ -0,0 +1,1113 @@
+# Access Control Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Add site-wide access control with five modes (public, password, steam, steamGroup, squadXml) to restrict who can view recordings.
+
+**Architecture:** New `requireViewer` middleware gates recording endpoints (`/api/v1/operations*`, `/data/*`). A new `auth.mode` config field controls which authentication method is required. Password mode adds a new backend endpoint; steamGroup/squadXml add membership checks in the Steam callback. The frontend `/api/v1/customize` response is extended with `authMode` so the UI can show the appropriate login controls.
+
+**Tech Stack:** Go (backend, fuego framework), SolidJS + TypeScript (frontend), Vitest (frontend tests), Go testing (backend tests)
+
+**Design doc:** `docs/plans/2026-03-08-access-control-design.md`
+
+---
+
+### Task 1: Config — Add auth mode fields to Setting struct
+
+**Files:**
+- Modify: `internal/server/setting.go:50-54` (Auth struct)
+- Modify: `internal/server/setting.go:96-99` (viper defaults)
+- Modify: `internal/server/setting.go:103` (env var bindings)
+- Modify: `setting.json.example:35-39` (example config)
+
+**Step 1: Add fields to Auth struct**
+
+In `setting.go`, extend the `Auth` struct:
+
+```go
+type Auth struct {
+ Mode string `json:"mode" yaml:"mode"`
+ SessionTTL time.Duration `json:"sessionTTL" yaml:"sessionTTL"`
+ AdminSteamIDs []string `json:"adminSteamIds" yaml:"adminSteamIds"`
+ SteamAPIKey string `json:"steamApiKey" yaml:"steamApiKey"`
+ Password string `json:"password" yaml:"password"`
+ SteamGroupID string `json:"steamGroupId" yaml:"steamGroupId"`
+ SquadXmlURL string `json:"squadXmlUrl" yaml:"squadXmlUrl"`
+ SquadXmlCacheTTL time.Duration `json:"squadXmlCacheTTL" yaml:"squadXmlCacheTTL"`
+}
+```
+
+**Step 2: Add viper defaults**
+
+After line 99 (`viper.SetDefault("auth.steamApiKey", "")`), add:
+
+```go
+viper.SetDefault("auth.mode", "public")
+viper.SetDefault("auth.password", "")
+viper.SetDefault("auth.steamGroupId", "")
+viper.SetDefault("auth.squadXmlUrl", "")
+viper.SetDefault("auth.squadXmlCacheTTL", "5m")
+```
+
+**Step 3: Add env var bindings**
+
+Add to the `envKeys` slice in line 103:
+
+```
+"auth.mode", "auth.password", "auth.steamGroupId", "auth.squadXmlUrl", "auth.squadXmlCacheTTL"
+```
+
+**Step 4: Add startup validation**
+
+Add a `validateAuthConfig` function and call it from `NewSetting()` after unmarshal (after line 124):
+
+```go
+func validateAuthConfig(auth Auth) error {
+ validModes := []string{"public", "password", "steam", "steamGroup", "squadXml"}
+ if !slices.Contains(validModes, auth.Mode) {
+ return fmt.Errorf("auth.mode %q is not valid, must be one of: %s", auth.Mode, strings.Join(validModes, ", "))
+ }
+ switch auth.Mode {
+ case "password":
+ if auth.Password == "" {
+ return fmt.Errorf("auth.mode %q requires auth.password to be set", auth.Mode)
+ }
+ case "steamGroup":
+ if auth.SteamAPIKey == "" {
+ return fmt.Errorf("auth.mode %q requires auth.steamApiKey to be set", auth.Mode)
+ }
+ if auth.SteamGroupID == "" {
+ return fmt.Errorf("auth.mode %q requires auth.steamGroupId to be set", auth.Mode)
+ }
+ case "squadXml":
+ if auth.SteamAPIKey == "" {
+ return fmt.Errorf("auth.mode %q requires auth.steamApiKey to be set", auth.Mode)
+ }
+ if auth.SquadXmlURL == "" {
+ return fmt.Errorf("auth.mode %q requires auth.squadXmlUrl to be set", auth.Mode)
+ }
+ if auth.SquadXmlCacheTTL == 0 {
+ log.Printf("WARN: auth.squadXmlCacheTTL is 0, squad XML will be fetched on every login")
+ }
+ }
+ return nil
+}
+```
+
+Call it in `NewSetting()`:
+```go
+if err = validateAuthConfig(setting.Auth); err != nil {
+ return
+}
+```
+
+**Step 5: Update setting.json.example**
+
+```json
+"auth": {
+ "mode": "public",
+ "sessionTTL": "24h",
+ "adminSteamIds": [],
+ "steamApiKey": "",
+ "password": "",
+ "steamGroupId": "",
+ "squadXmlUrl": "",
+ "squadXmlCacheTTL": "5m"
+}
+```
+
+**Step 6: Run tests**
+
+Run: `go test ./internal/server/ -run TestNew -v`
+
+**Step 7: Commit**
+
+```
+feat(auth): add access control mode config fields
+
+Adds mode, password, steamGroupId, squadXmlUrl, squadXmlCacheTTL
+to auth config with startup validation.
+```
+
+---
+
+### Task 2: Backend — requireViewer middleware
+
+**Files:**
+- Modify: `internal/server/handler_auth.go` (add `requireViewer` middleware)
+- Modify: `internal/server/handler.go:144-155` (apply middleware to recording/data routes)
+
+**Step 1: Write test for requireViewer**
+
+Add to `handler_auth_test.go`:
+
+```go
+func TestRequireViewer(t *testing.T) {
+ okHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ })
+
+ t.Run("public mode allows unauthenticated", func(t *testing.T) {
+ hdlr := Handler{
+ setting: Setting{Auth: Auth{Mode: "public"}},
+ }
+ rr := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/api/v1/operations", nil)
+ hdlr.requireViewer(okHandler).ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusOK, rr.Code)
+ })
+
+ t.Run("non-public mode rejects unauthenticated", func(t *testing.T) {
+ hdlr := Handler{
+ setting: Setting{Auth: Auth{Mode: "steam"}, Secret: "test-secret"},
+ jwt: NewJWTManager("test-secret", time.Hour),
+ }
+ rr := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/api/v1/operations", nil)
+ hdlr.requireViewer(okHandler).ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusUnauthorized, rr.Code)
+ })
+
+ t.Run("non-public mode allows viewer", func(t *testing.T) {
+ jwtMgr := NewJWTManager("test-secret", time.Hour)
+ token, _ := jwtMgr.Create("steam123", WithRole("viewer"))
+ hdlr := Handler{
+ setting: Setting{Auth: Auth{Mode: "steam"}, Secret: "test-secret"},
+ jwt: jwtMgr,
+ }
+ rr := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/api/v1/operations", nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ hdlr.requireViewer(okHandler).ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusOK, rr.Code)
+ })
+
+ t.Run("non-public mode allows admin", func(t *testing.T) {
+ jwtMgr := NewJWTManager("test-secret", time.Hour)
+ token, _ := jwtMgr.Create("steam123", WithRole("admin"))
+ hdlr := Handler{
+ setting: Setting{Auth: Auth{Mode: "steam"}, Secret: "test-secret"},
+ jwt: jwtMgr,
+ }
+ rr := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/api/v1/operations", nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ hdlr.requireViewer(okHandler).ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusOK, rr.Code)
+ })
+}
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/server/ -run TestRequireViewer -v`
+Expected: FAIL — `requireViewer` not defined
+
+**Step 3: Implement requireViewer**
+
+Add to `handler_auth.go` after the `requireAdmin` middleware:
+
+```go
+// requireViewer is middleware that enforces site-wide access control.
+// In "public" mode it passes all requests through. In all other modes
+// it requires a valid JWT with any role (viewer or admin).
+func (h *Handler) requireViewer(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if h.setting.Auth.Mode == "public" {
+ next.ServeHTTP(w, r)
+ return
+ }
+ token := bearerToken(r)
+ if token == "" || h.jwt.Validate(token) != nil {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+```
+
+**Step 4: Run test to verify it passes**
+
+Run: `go test ./internal/server/ -run TestRequireViewer -v`
+Expected: PASS
+
+**Step 5: Apply middleware to routes**
+
+In `handler.go`, create a viewer-gated group for recording and data endpoints. Replace the current route registrations (lines 144-160) with:
+
+```go
+// Viewer-gated routes (public in "public" mode, auth required in other modes)
+viewer := fuego.Group(g, "")
+fuego.Use(viewer, hdlr.requireViewer)
+
+// Recordings (viewer-gated)
+fuego.Get(viewer, "/api/v1/operations", hdlr.GetOperations, fuego.OptionTags("Recordings"))
+fuego.Get(viewer, "/api/v1/operations/{id}", hdlr.GetOperation, fuego.OptionTags("Recordings"))
+fuego.Get(viewer, "/api/v1/operations/{id}/marker-blacklist", hdlr.GetMarkerBlacklist, fuego.OptionTags("Recordings"))
+fuego.Get(viewer, "/api/v1/worlds", hdlr.GetWorlds, fuego.OptionTags("Recordings"))
+
+// Upload — stays on prefix group (has its own secret/JWT auth)
+fuego.PostStd(g, "/api/v1/operations/add", hdlr.StoreOperation, fuego.OptionTags("Recordings"))
+
+// Customize — stays on prefix group (public, frontend needs it before auth)
+fuego.Get(g, "/api/v1/customize", hdlr.GetCustomize, fuego.OptionTags("Recordings"))
+
+// Stream — stays on prefix group (has its own secret auth)
+fuego.GetStd(g, "/api/v1/stream", hdlr.HandleStream, fuego.OptionTags("Recordings"))
+
+// Assets (viewer-gated for data, public for everything else)
+cacheMiddleware := hdlr.cacheControl(CacheDuration)
+fuego.GetStd(viewer, "/data/{path...}", hdlr.GetData, fuego.OptionTags("Assets"), fuego.OptionMiddleware(cacheMiddleware))
+fuego.GetStd(g, "/images/markers/{name}/{color}", hdlr.GetMarker, fuego.OptionTags("Assets"), fuego.OptionMiddleware(cacheMiddleware))
+fuego.GetStd(g, "/images/markers/magicons/{name}", hdlr.GetAmmo, fuego.OptionTags("Assets"), fuego.OptionMiddleware(cacheMiddleware))
+fuego.GetStd(g, "/images/maps/fonts/{fontstack}/{range}", hdlr.GetFont, fuego.OptionTags("Assets"), fuego.OptionMiddleware(cacheMiddleware))
+fuego.GetStd(g, "/images/maps/sprites/{name}", hdlr.GetSprite, fuego.OptionTags("Assets"), fuego.OptionMiddleware(cacheMiddleware))
+fuego.GetStd(g, "/images/maps/{path...}", hdlr.GetMapTile, fuego.OptionTags("Assets"), fuego.OptionMiddleware(cacheMiddleware))
+```
+
+**Step 6: Run all tests**
+
+Run: `go test ./internal/server/ -v`
+Expected: PASS
+
+**Step 7: Commit**
+
+```
+feat(auth): add requireViewer middleware and gate recording/data endpoints
+
+In public mode all requests pass through. In other modes a valid JWT
+is required to access recording list, metadata, and data files.
+```
+
+---
+
+### Task 3: Backend — Password login endpoint
+
+**Files:**
+- Modify: `internal/server/handler_auth.go` (add `PasswordLogin` handler)
+- Modify: `internal/server/handler.go` (register route)
+
+**Step 1: Write test for password login**
+
+Add to `handler_auth_test.go`:
+
+```go
+func TestPasswordLogin(t *testing.T) {
+ t.Run("correct password issues viewer JWT", func(t *testing.T) {
+ jwtMgr := NewJWTManager("test-secret", time.Hour)
+ hdlr := Handler{
+ setting: Setting{
+ Auth: Auth{Mode: "password", Password: "secret123"},
+ Secret: "test-secret",
+ },
+ jwt: jwtMgr,
+ }
+
+ body := `{"password":"secret123"}`
+ req := httptest.NewRequest("POST", "/api/v1/auth/password", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ rr := httptest.NewRecorder()
+ hdlr.PasswordLogin(rr, req)
+
+ assert.Equal(t, http.StatusOK, rr.Code)
+
+ var resp map[string]string
+ json.NewDecoder(rr.Body).Decode(&resp)
+ assert.NotEmpty(t, resp["token"])
+
+ claims := jwtMgr.Claims(resp["token"])
+ assert.Equal(t, "viewer", claims.Role)
+ assert.Equal(t, "password", claims.Subject)
+ })
+
+ t.Run("wrong password returns 401", func(t *testing.T) {
+ hdlr := Handler{
+ setting: Setting{
+ Auth: Auth{Mode: "password", Password: "secret123"},
+ Secret: "test-secret",
+ },
+ jwt: NewJWTManager("test-secret", time.Hour),
+ }
+
+ body := `{"password":"wrong"}`
+ req := httptest.NewRequest("POST", "/api/v1/auth/password", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ rr := httptest.NewRecorder()
+ hdlr.PasswordLogin(rr, req)
+
+ assert.Equal(t, http.StatusUnauthorized, rr.Code)
+ })
+
+ t.Run("empty password returns 401", func(t *testing.T) {
+ hdlr := Handler{
+ setting: Setting{
+ Auth: Auth{Mode: "password", Password: "secret123"},
+ Secret: "test-secret",
+ },
+ jwt: NewJWTManager("test-secret", time.Hour),
+ }
+
+ body := `{"password":""}`
+ req := httptest.NewRequest("POST", "/api/v1/auth/password", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ rr := httptest.NewRecorder()
+ hdlr.PasswordLogin(rr, req)
+
+ assert.Equal(t, http.StatusUnauthorized, rr.Code)
+ })
+}
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/server/ -run TestPasswordLogin -v`
+
+**Step 3: Implement PasswordLogin**
+
+Add to `handler_auth.go`:
+
+```go
+// PasswordLogin validates a shared password and issues a viewer JWT.
+func (h *Handler) PasswordLogin(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Password string `json:"password"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "invalid request", http.StatusBadRequest)
+ return
+ }
+
+ if req.Password == "" || req.Password != h.setting.Auth.Password {
+ http.Error(w, "invalid password", http.StatusUnauthorized)
+ return
+ }
+
+ token, err := h.jwt.Create("password", WithRole("viewer"))
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"token": token})
+}
+```
+
+**Step 4: Register route**
+
+In `handler.go`, add in the Auth section (after line 166):
+
+```go
+fuego.PostStd(g, "/api/v1/auth/password", hdlr.PasswordLogin, fuego.OptionTags("Auth"))
+```
+
+**Step 5: Run tests**
+
+Run: `go test ./internal/server/ -run TestPasswordLogin -v`
+Expected: PASS
+
+**Step 6: Commit**
+
+```
+feat(auth): add password login endpoint
+
+POST /api/v1/auth/password accepts {"password":"..."} and issues a
+viewer JWT when the password matches auth.password config.
+```
+
+---
+
+### Task 4: Backend — Steam group membership check
+
+**Files:**
+- Modify: `internal/server/handler_auth.go` (add group check in SteamCallback)
+
+**Step 1: Write test for Steam group membership check**
+
+Add to `handler_auth_test.go`:
+
+```go
+func TestSteamGroupMembershipCheck(t *testing.T) {
+ t.Run("member gets viewer token", func(t *testing.T) {
+ // Mock Steam group members API
+ groupServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{
+ "response": map[string]any{
+ "success": 1,
+ "members": []map[string]string{
+ {"steamid": "76561198012345678"},
+ {"steamid": "76561198099999999"},
+ },
+ },
+ })
+ }))
+ defer groupServer.Close()
+
+ result, err := checkSteamGroupMembership(groupServer.URL, "76561198012345678", "test-api-key", "103582791460000000")
+ assert.NoError(t, err)
+ assert.True(t, result)
+ })
+
+ t.Run("non-member is rejected", func(t *testing.T) {
+ groupServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{
+ "response": map[string]any{
+ "success": 1,
+ "members": []map[string]string{
+ {"steamid": "76561198099999999"},
+ },
+ },
+ })
+ }))
+ defer groupServer.Close()
+
+ result, err := checkSteamGroupMembership(groupServer.URL, "76561198012345678", "test-api-key", "103582791460000000")
+ assert.NoError(t, err)
+ assert.False(t, result)
+ })
+
+ t.Run("API error returns error", func(t *testing.T) {
+ groupServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusForbidden)
+ }))
+ defer groupServer.Close()
+
+ _, err := checkSteamGroupMembership(groupServer.URL, "76561198012345678", "test-api-key", "103582791460000000")
+ assert.Error(t, err)
+ })
+}
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/server/ -run TestSteamGroupMembership -v`
+
+**Step 3: Implement checkSteamGroupMembership**
+
+Add to `handler_auth.go`:
+
+```go
+// steamGroupMembersResponse models the Steam Web API GetGroupMembers response.
+type steamGroupMembersResponse struct {
+ Response struct {
+ Success int `json:"success"`
+ Members []struct {
+ SteamID string `json:"steamid"`
+ } `json:"members"`
+ } `json:"response"`
+}
+
+// checkSteamGroupMembership checks if a Steam ID is a member of a Steam group
+// using the Steam Web API (ISteamUser/GetUserGroupList is per-user; we use
+// ISteamUser/GetGroupMembers which is the group-level API).
+func checkSteamGroupMembership(baseURL, steamID, apiKey, groupID string) (bool, error) {
+ u := baseURL + "?key=" + url.QueryEscape(apiKey) + "&groupid=" + url.QueryEscape(groupID)
+
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Get(u)
+ if err != nil {
+ return false, fmt.Errorf("steam group API request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return false, fmt.Errorf("steam group API error: status %d", resp.StatusCode)
+ }
+
+ var data steamGroupMembersResponse
+ if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
+ return false, fmt.Errorf("steam group API decode error: %w", err)
+ }
+
+ for _, m := range data.Response.Members {
+ if m.SteamID == steamID {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+```
+
+**Step 4: Integrate into SteamCallback**
+
+In `SteamCallback` (handler_auth.go), after the role determination (after line 117), add the membership check before issuing the token:
+
+```go
+ // In steamGroup mode, check group membership (admins bypass)
+ if h.setting.Auth.Mode == "steamGroup" && role != "admin" {
+ baseURL := steamGroupAPIBaseURL
+ if h.steamAPIBaseURL != "" {
+ baseURL = h.steamAPIBaseURL + "/GetGroupMembers"
+ }
+ isMember, err := checkSteamGroupMembership(baseURL, steamID, h.setting.Auth.SteamAPIKey, h.setting.Auth.SteamGroupID)
+ if err != nil {
+ log.Printf("WARN: steam group membership check failed for %s: %v", steamID, err)
+ h.authRedirect(w, r, "auth_error=membership_check_failed")
+ return
+ }
+ if !isMember {
+ h.authRedirect(w, r, "auth_error=not_a_member")
+ return
+ }
+ }
+```
+
+Add the constant:
+```go
+const steamGroupAPIBaseURL = "https://api.steampowered.com/ISteamUser/GetUserGroupList/v1/"
+```
+
+Note: The actual Steam Web API endpoint for checking group membership may need adjustment based on Steam's API. The `GetUserGroupList` endpoint returns groups a user belongs to (keyed by user), which may be more practical than fetching all group members. Verify the correct endpoint during implementation.
+
+**Step 5: Run tests**
+
+Run: `go test ./internal/server/ -run TestSteamGroup -v`
+Expected: PASS
+
+**Step 6: Commit**
+
+```
+feat(auth): add Steam group membership check
+
+In steamGroup mode, non-admin users must be a member of the configured
+Steam group. Admins bypass the check to prevent lockout.
+```
+
+---
+
+### Task 5: Backend — Squad XML membership check
+
+**Files:**
+- Modify: `internal/server/handler_auth.go` (add squad XML fetcher + cache + check in SteamCallback)
+
+**Step 1: Write test for squad XML parsing and membership check**
+
+Add to `handler_auth_test.go`:
+
+```go
+func TestSquadXmlMembershipCheck(t *testing.T) {
+ squadXml := `
+
+
+ Test Group
+
+
+`
+
+ t.Run("member found in squad XML", func(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/xml")
+ w.Write([]byte(squadXml))
+ }))
+ defer srv.Close()
+
+ checker := newSquadXmlChecker(srv.URL, 0)
+ result, err := checker.isMember("76561198012345678")
+ assert.NoError(t, err)
+ assert.True(t, result)
+ })
+
+ t.Run("non-member not found in squad XML", func(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/xml")
+ w.Write([]byte(squadXml))
+ }))
+ defer srv.Close()
+
+ checker := newSquadXmlChecker(srv.URL, 0)
+ result, err := checker.isMember("76561198000000000")
+ assert.NoError(t, err)
+ assert.False(t, result)
+ })
+
+ t.Run("caching avoids refetch", func(t *testing.T) {
+ fetchCount := 0
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ fetchCount++
+ w.Write([]byte(squadXml))
+ }))
+ defer srv.Close()
+
+ checker := newSquadXmlChecker(srv.URL, 5*time.Minute)
+ checker.isMember("76561198012345678")
+ checker.isMember("76561198012345678")
+ assert.Equal(t, 1, fetchCount)
+ })
+
+ t.Run("zero TTL refetches every time", func(t *testing.T) {
+ fetchCount := 0
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ fetchCount++
+ w.Write([]byte(squadXml))
+ }))
+ defer srv.Close()
+
+ checker := newSquadXmlChecker(srv.URL, 0)
+ checker.isMember("76561198012345678")
+ checker.isMember("76561198012345678")
+ assert.Equal(t, 2, fetchCount)
+ })
+
+ t.Run("HTTP error returns error", func(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ }))
+ defer srv.Close()
+
+ checker := newSquadXmlChecker(srv.URL, 0)
+ _, err := checker.isMember("76561198012345678")
+ assert.Error(t, err)
+ })
+}
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/server/ -run TestSquadXml -v`
+
+**Step 3: Implement squadXmlChecker**
+
+Add to `handler_auth.go`:
+
+```go
+// squadXmlChecker fetches and caches a remote Arma 3 squad XML,
+// then checks membership by Steam ID.
+type squadXmlChecker struct {
+ url string
+ cacheTTL time.Duration
+
+ mu sync.Mutex
+ members map[string]bool
+ fetchedAt time.Time
+}
+
+func newSquadXmlChecker(url string, cacheTTL time.Duration) *squadXmlChecker {
+ return &squadXmlChecker{
+ url: url,
+ cacheTTL: cacheTTL,
+ }
+}
+
+// squadXml models the Arma 3 squad.xml format.
+type squadXml struct {
+ Members []squadXmlMember `xml:"member"`
+}
+
+type squadXmlMember struct {
+ ID string `xml:"id,attr"`
+}
+
+func (c *squadXmlChecker) isMember(steamID string) (bool, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ if c.members != nil && c.cacheTTL > 0 && time.Since(c.fetchedAt) < c.cacheTTL {
+ return c.members[steamID], nil
+ }
+
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Get(c.url)
+ if err != nil {
+ return false, fmt.Errorf("squad XML fetch failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return false, fmt.Errorf("squad XML fetch error: status %d", resp.StatusCode)
+ }
+
+ var squad squadXml
+ if err := xml.NewDecoder(resp.Body).Decode(&squad); err != nil {
+ return false, fmt.Errorf("squad XML parse error: %w", err)
+ }
+
+ c.members = make(map[string]bool, len(squad.Members))
+ for _, m := range squad.Members {
+ c.members[m.ID] = true
+ }
+ c.fetchedAt = time.Now()
+
+ return c.members[steamID], nil
+}
+```
+
+Add `"encoding/xml"` and `"sync"` to the imports.
+
+**Step 4: Add squadXmlChecker to Handler and integrate into SteamCallback**
+
+Add field to Handler struct in `handler.go`:
+```go
+squadXml *squadXmlChecker
+```
+
+Initialize in `NewHandler` (after JWT setup, around line 133):
+```go
+if hdlr.setting.Auth.Mode == "squadXml" {
+ hdlr.squadXml = newSquadXmlChecker(hdlr.setting.Auth.SquadXmlURL, hdlr.setting.Auth.SquadXmlCacheTTL)
+}
+```
+
+Add check in `SteamCallback` (after the steamGroup check):
+```go
+ // In squadXml mode, check squad XML membership (admins bypass)
+ if h.setting.Auth.Mode == "squadXml" && role != "admin" {
+ isMember, err := h.squadXml.isMember(steamID)
+ if err != nil {
+ log.Printf("WARN: squad XML membership check failed for %s: %v", steamID, err)
+ h.authRedirect(w, r, "auth_error=membership_check_failed")
+ return
+ }
+ if !isMember {
+ h.authRedirect(w, r, "auth_error=not_a_member")
+ return
+ }
+ }
+```
+
+**Step 5: Run tests**
+
+Run: `go test ./internal/server/ -run TestSquadXml -v`
+Expected: PASS
+
+**Step 6: Commit**
+
+```
+feat(auth): add squad XML membership check with caching
+
+In squadXml mode, fetches the remote squad.xml and checks if the
+user's Steam ID is listed. Cache TTL is configurable; 0 disables.
+```
+
+---
+
+### Task 6: Backend — Expose auth mode via /api/v1/customize
+
+**Files:**
+- Modify: `internal/server/handler.go` (extend GetCustomize response)
+
+**Step 1: Write test**
+
+Add to `handler_test.go`:
+
+```go
+func TestGetCustomize_IncludesAuthMode(t *testing.T) {
+ hdlr := Handler{
+ setting: Setting{
+ Auth: Auth{Mode: "steamGroup"},
+ Customize: Customize{Enabled: true},
+ },
+ }
+ mockCtx := newMockContext("GET", "/api/v1/customize")
+ result, err := hdlr.GetCustomize(mockCtx)
+ assert.NoError(t, err)
+ assert.Equal(t, "steamGroup", result.AuthMode)
+}
+
+func TestGetCustomize_PublicModeDefault(t *testing.T) {
+ hdlr := Handler{
+ setting: Setting{
+ Auth: Auth{Mode: "public"},
+ Customize: Customize{Enabled: true},
+ },
+ }
+ mockCtx := newMockContext("GET", "/api/v1/customize")
+ result, err := hdlr.GetCustomize(mockCtx)
+ assert.NoError(t, err)
+ assert.Equal(t, "public", result.AuthMode)
+}
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/server/ -run TestGetCustomize_IncludesAuthMode -v`
+
+**Step 3: Implement**
+
+The current `GetCustomize` returns `*Customize` directly. We need a response wrapper that includes auth mode. Change the response type:
+
+```go
+type CustomizeResponse struct {
+ Customize
+ AuthMode string `json:"authMode"`
+}
+
+func (h *Handler) GetCustomize(c ContextNoBody) (*CustomizeResponse, error) {
+ resp := &CustomizeResponse{
+ AuthMode: h.setting.Auth.Mode,
+ }
+ if h.setting.Customize.Enabled {
+ resp.Customize = h.setting.Customize
+ } else {
+ c.SetStatus(http.StatusNoContent)
+ return nil, nil
+ }
+ return resp, nil
+}
+```
+
+Wait — the current behavior returns 204 No Content when customize is disabled. But we always need the auth mode. Rethink: always return a response, but only populate customize fields when enabled:
+
+```go
+type CustomizeResponse struct {
+ *Customize `json:"customize,omitempty"`
+ AuthMode string `json:"authMode"`
+}
+
+func (h *Handler) GetCustomize(c ContextNoBody) (CustomizeResponse, error) {
+ resp := CustomizeResponse{
+ AuthMode: h.setting.Auth.Mode,
+ }
+ if h.setting.Customize.Enabled {
+ resp.Customize = &h.setting.Customize
+ }
+ return resp, nil
+}
+```
+
+This is a **breaking change** — the endpoint previously returned the `Customize` struct directly (or 204). Now it wraps it. The frontend `useCustomize.tsx` and `apiClient.ts` will need updating in the frontend task. The fuego route type signature also needs updating.
+
+Note: Evaluate whether it's cleaner to add a separate `/api/v1/auth/config` endpoint that returns just `{"mode":"steamGroup"}` instead of modifying `/api/v1/customize`. This avoids the breaking change. **Decision to make during implementation** — either approach works, but a separate endpoint is lower risk.
+
+**Step 4: Run tests and fix any broken customize tests**
+
+Run: `go test ./internal/server/ -run TestGetCustomize -v`
+Fix any tests that relied on the old response shape.
+
+**Step 5: Commit**
+
+```
+feat(auth): expose auth mode to frontend
+
+Adds authMode field to the customize response (or a new /api/v1/auth/config
+endpoint) so the frontend knows which login controls to show.
+```
+
+---
+
+### Task 7: Frontend — Add auth mode to API client and useCustomize
+
+**Files:**
+- Modify: `ui/src/data/apiClient.ts` (add password login method, update customize types)
+- Modify: `ui/src/hooks/useCustomize.tsx` (expose auth mode)
+
+**Step 1: Update API client**
+
+In `apiClient.ts`, add the password login method:
+
+```typescript
+async passwordLogin(password: string): Promise<{ token: string }> {
+ const resp = await fetch(`${this.base}/api/v1/auth/password`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ password }),
+ });
+ if (!resp.ok) {
+ throw new Error(resp.status === 401 ? "Invalid password" : "Login failed");
+ }
+ return resp.json();
+}
+```
+
+Update the `CustomizeConfig` type to include `authMode`:
+
+```typescript
+export interface CustomizeConfig {
+ // ... existing fields ...
+ authMode?: string;
+}
+```
+
+Or if using a separate endpoint:
+```typescript
+export interface AuthConfig {
+ mode: string;
+}
+
+async getAuthConfig(): Promise {
+ const resp = await fetch(`${this.base}/api/v1/auth/config`);
+ return resp.json();
+}
+```
+
+**Step 2: Add auth error messages**
+
+In `useAuth.tsx`, add new error message mappings:
+
+```typescript
+const AUTH_ERROR_MESSAGES: Record = {
+ steam_error: "Steam login failed. Please try again.",
+ not_a_member: "You are not a member of this community. Contact an admin for access.",
+ membership_check_failed: "Could not verify membership. Please try again later.",
+};
+```
+
+**Step 3: Commit**
+
+```
+feat(auth): add password login and auth mode to frontend API client
+```
+
+---
+
+### Task 8: Frontend — Auth-gated UI with login page
+
+**Files:**
+- Modify: `ui/src/hooks/useAuth.tsx` (add password login, auth mode awareness)
+- Modify: `ui/src/components/AuthBadge.tsx` (show password field in password mode)
+- Modify: `ui/src/App.tsx` or `ui/src/main.tsx` (intercept 401 and show login)
+
+**Step 1: Add auth mode to AuthProvider**
+
+In `useAuth.tsx`, fetch auth mode on mount and expose it:
+
+```typescript
+const [authMode, setAuthMode] = createSignal("public");
+
+onMount(async () => {
+ // Fetch auth config first
+ try {
+ const config = await api.getAuthConfig();
+ setAuthMode(config.mode);
+ } catch {
+ // Default to public if endpoint fails
+ }
+ // ... existing token consumption logic ...
+});
+```
+
+Add `authMode` and `loginWithPassword` to the context:
+
+```typescript
+loginWithPassword: async (password: string) => {
+ try {
+ const resp = await api.passwordLogin(password);
+ setAuthToken(resp.token);
+ const me = await api.getMe();
+ if (me.authenticated) {
+ setAuthenticated(true);
+ setRole(me.role ?? null);
+ // ... set other fields ...
+ }
+ } catch (err) {
+ setAuthError(err instanceof Error ? err.message : "Login failed");
+ }
+},
+```
+
+**Step 2: Update AuthBadge for password mode**
+
+In `AuthBadge.tsx`, when not authenticated and `authMode() === "password"`:
+
+```tsx
+
+
+
+
+
+
+
+
+```
+
+In password mode: show password field as primary + Steam button as secondary.
+In steam/steamGroup/squadXml modes: show only Steam button.
+In public mode: show only Steam button (for admin access).
+
+**Step 3: Handle 401 redirect for direct links**
+
+In `apiClient.ts`, add a global 401 handler for recording endpoints. When a fetch to `/api/v1/operations` or `/data/` returns 401:
+
+```typescript
+if (resp.status === 401) {
+ sessionStorage.setItem("ocap_return_to", window.location.pathname);
+ window.location.href = basePath + "/";
+ throw new Error("Authentication required");
+}
+```
+
+This triggers the existing `ocap_return_to` → login → redirect-back flow.
+
+**Step 4: Write tests**
+
+Add tests to `AuthBadge.test.tsx`:
+- Password mode shows password field
+- Password mode shows Steam button as secondary
+- Steam mode shows only Steam button
+- Public mode shows only Steam button
+
+Add tests to `useAuth.test.tsx`:
+- `loginWithPassword` success flow
+- `loginWithPassword` wrong password shows error
+- Auth mode is exposed from provider
+
+**Step 5: Run tests**
+
+Run: `cd ui && npm test`
+Expected: PASS
+
+**Step 6: Commit**
+
+```
+feat(auth): add auth-gated login UI with password and Steam modes
+
+Shows appropriate login controls based on auth.mode config.
+Handles 401 redirect for direct recording links.
+```
+
+---
+
+### Task 9: Integration testing and cleanup
+
+**Step 1: Manual integration test matrix**
+
+Test each mode with the dev server:
+
+| Mode | Test |
+|------|------|
+| `public` | All recordings accessible without login |
+| `password` | Recordings blocked → enter password → access granted |
+| `steam` | Recordings blocked → Steam login → access granted |
+| `steamGroup` | Steam login → member gets access, non-member gets error |
+| `squadXml` | Steam login → member gets access, non-member gets error |
+
+For each mode also test:
+- Direct link redirect flow (copy recording URL, open in incognito, verify redirect to login then back)
+- Admin bypass (admin can access in all modes)
+- Upload endpoint still works with secret (not gated)
+
+**Step 2: Run full test suite**
+
+```bash
+go test ./...
+cd ui && npm test
+```
+
+**Step 3: Final commit if any cleanup needed**
+
+```
+chore: clean up access control implementation
+```
+
+---
+
+## Notes for implementer
+
+- **Steam Group API**: Verify which Steam Web API endpoint is correct for group membership. Options:
+ - `ISteamUser/GetUserGroupList/v1/?key=X&steamid=Y` — returns groups a user belongs to (simpler, no pagination)
+ - Custom group members endpoint — may require pagination for large groups
+ - The user-centric approach (check if user is in group) is likely better than fetching all group members
+- **Squad XML format**: Standard Arma 3 format with `` elements
+- **Error codes**: `auth_error=not_a_member` and `auth_error=membership_check_failed` are new values the frontend needs to handle
+- **Breaking change**: If `/api/v1/customize` response shape changes, existing frontend code needs updating. Consider a separate `/api/v1/auth/config` endpoint to avoid this.
+- **Thread safety**: `squadXmlChecker` uses a mutex for cache access since multiple requests may hit it concurrently.
diff --git a/internal/server/allowlist.go b/internal/server/allowlist.go
new file mode 100644
index 00000000..1a8da9c8
--- /dev/null
+++ b/internal/server/allowlist.go
@@ -0,0 +1,49 @@
+package server
+
+import "context"
+
+// GetAllowlist returns all Steam IDs in the allowlist.
+func (r *RepoOperation) GetAllowlist(ctx context.Context) ([]string, error) {
+ rows, err := r.db.QueryContext(ctx,
+ `SELECT steam_id FROM steam_allowlist ORDER BY steam_id`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ ids := []string{}
+ for rows.Next() {
+ var id string
+ if err := rows.Scan(&id); err != nil {
+ return nil, err
+ }
+ ids = append(ids, id)
+ }
+ return ids, rows.Err()
+}
+
+// AddToAllowlist adds a Steam ID to the allowlist.
+// The operation is idempotent — duplicate inserts are ignored.
+func (r *RepoOperation) AddToAllowlist(ctx context.Context, steamID string) error {
+ _, err := r.db.ExecContext(ctx,
+ `INSERT OR IGNORE INTO steam_allowlist (steam_id) VALUES (?)`,
+ steamID)
+ return err
+}
+
+// RemoveFromAllowlist removes a Steam ID from the allowlist.
+func (r *RepoOperation) RemoveFromAllowlist(ctx context.Context, steamID string) error {
+ _, err := r.db.ExecContext(ctx,
+ `DELETE FROM steam_allowlist WHERE steam_id = ?`,
+ steamID)
+ return err
+}
+
+// IsOnAllowlist checks whether a Steam ID is on the allowlist.
+func (r *RepoOperation) IsOnAllowlist(ctx context.Context, steamID string) (bool, error) {
+ var count int
+ err := r.db.QueryRowContext(ctx,
+ `SELECT COUNT(*) FROM steam_allowlist WHERE steam_id = ?`,
+ steamID).Scan(&count)
+ return count > 0, err
+}
diff --git a/internal/server/handler.go b/internal/server/handler.go
index 6c535012..e13e3478 100644
--- a/internal/server/handler.go
+++ b/internal/server/handler.go
@@ -141,18 +141,22 @@ func NewHandler(
fuego.Get(g, "/api/healthcheck", hdlr.GetHealthcheck, fuego.OptionTags("Health"))
fuego.Get(g, "/api/version", hdlr.GetVersion, fuego.OptionTags("Health"))
- // Recordings (public read)
- fuego.Get(g, "/api/v1/operations", hdlr.GetOperations, fuego.OptionTags("Recordings"))
- fuego.Get(g, "/api/v1/operations/{id}", hdlr.GetOperation, fuego.OptionTags("Recordings"))
- fuego.Get(g, "/api/v1/operations/{id}/marker-blacklist", hdlr.GetMarkerBlacklist, fuego.OptionTags("Recordings"))
+ // Public recording endpoints (own auth or needed before login)
fuego.PostStd(g, "/api/v1/operations/add", hdlr.StoreOperation, fuego.OptionTags("Recordings"))
- fuego.Get(g, "/api/v1/worlds", hdlr.GetWorlds, fuego.OptionTags("Recordings"))
fuego.Get(g, "/api/v1/customize", hdlr.GetCustomize, fuego.OptionTags("Recordings"))
fuego.GetStd(g, "/api/v1/stream", hdlr.HandleStream, fuego.OptionTags("Recordings"))
+ // Viewer-gated endpoints (require valid JWT in non-public modes)
+ viewer := fuego.Group(g, "")
+ fuego.Use(viewer, hdlr.requireViewer)
+ fuego.Get(viewer, "/api/v1/operations", hdlr.GetOperations, fuego.OptionTags("Recordings"))
+ fuego.Get(viewer, "/api/v1/operations/{id}", hdlr.GetOperation, fuego.OptionTags("Recordings"))
+ fuego.Get(viewer, "/api/v1/operations/{id}/marker-blacklist", hdlr.GetMarkerBlacklist, fuego.OptionTags("Recordings"))
+ fuego.Get(viewer, "/api/v1/worlds", hdlr.GetWorlds, fuego.OptionTags("Recordings"))
+
// Assets (static file serving)
cacheMiddleware := hdlr.cacheControl(CacheDuration)
- fuego.GetStd(g, "/data/{path...}", hdlr.GetData, fuego.OptionTags("Assets"), fuego.OptionMiddleware(cacheMiddleware))
+ fuego.GetStd(viewer, "/data/{path...}", hdlr.GetData, fuego.OptionTags("Assets"), fuego.OptionMiddleware(cacheMiddleware))
fuego.GetStd(g, "/images/markers/{name}/{color}", hdlr.GetMarker, fuego.OptionTags("Assets"), fuego.OptionMiddleware(cacheMiddleware))
fuego.GetStd(g, "/images/markers/magicons/{name}", hdlr.GetAmmo, fuego.OptionTags("Assets"), fuego.OptionMiddleware(cacheMiddleware))
fuego.GetStd(g, "/images/maps/fonts/{fontstack}/{range}", hdlr.GetFont, fuego.OptionTags("Assets"), fuego.OptionMiddleware(cacheMiddleware))
@@ -160,8 +164,10 @@ func NewHandler(
fuego.GetStd(g, "/images/maps/{path...}", hdlr.GetMapTile, fuego.OptionTags("Assets"), fuego.OptionMiddleware(cacheMiddleware))
// Auth
+ fuego.Get(g, "/api/v1/auth/config", hdlr.GetAuthConfig, fuego.OptionTags("Auth"))
fuego.GetStd(g, "/api/v1/auth/steam", hdlr.SteamLogin, fuego.OptionTags("Auth"))
fuego.GetStd(g, "/api/v1/auth/steam/callback", hdlr.SteamCallback, fuego.OptionTags("Auth"))
+ fuego.PostStd(g, "/api/v1/auth/password", hdlr.PasswordLogin, fuego.OptionTags("Auth"))
fuego.Get(g, "/api/v1/auth/me", hdlr.GetMe, fuego.OptionTags("Auth"))
fuego.Post(g, "/api/v1/auth/logout", hdlr.Logout, fuego.OptionTags("Auth"), fuego.OptionSecurity(bearerAuth))
@@ -173,6 +179,9 @@ func NewHandler(
fuego.Post(admin, "/api/v1/operations/{id}/retry", hdlr.RetryConversion, fuego.OptionTags("Admin"), fuego.OptionSecurity(bearerAuth))
fuego.Put(admin, "/api/v1/operations/{id}/marker-blacklist/{playerId}", hdlr.AddMarkerBlacklist, fuego.OptionTags("Admin"), fuego.OptionSecurity(bearerAuth))
fuego.Delete(admin, "/api/v1/operations/{id}/marker-blacklist/{playerId}", hdlr.RemoveMarkerBlacklist, fuego.OptionTags("Admin"), fuego.OptionSecurity(bearerAuth))
+ fuego.Get(admin, "/api/v1/auth/allowlist", hdlr.GetAllowlist, fuego.OptionTags("Admin"), fuego.OptionSecurity(bearerAuth))
+ fuego.Put(admin, "/api/v1/auth/allowlist/{steamId}", hdlr.AddToAllowlist, fuego.OptionTags("Admin"), fuego.OptionSecurity(bearerAuth))
+ fuego.Delete(admin, "/api/v1/auth/allowlist/{steamId}", hdlr.RemoveFromAllowlist, fuego.OptionTags("Admin"), fuego.OptionSecurity(bearerAuth))
// MapTool (require admin JWT; SSE endpoint handles its own auth via query param)
if hdlr.maptoolMgr != nil {
diff --git a/internal/server/handler_auth.go b/internal/server/handler_auth.go
index 93221b23..a34561a5 100644
--- a/internal/server/handler_auth.go
+++ b/internal/server/handler_auth.go
@@ -2,6 +2,7 @@ package server
import (
"crypto/rand"
+ "crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
@@ -12,6 +13,7 @@ import (
"strings"
"time"
+ "github.com/go-fuego/fuego"
"github.com/yohcop/openid-go"
)
@@ -116,6 +118,20 @@ func (h *Handler) SteamCallback(w http.ResponseWriter, r *http.Request) {
role = "admin"
}
+ // In steamAllowlist mode, check if the user is allowed (admins always bypass)
+ if h.setting.Auth.Mode == "steamAllowlist" && role != "admin" {
+ allowed, err := h.repoOperation.IsOnAllowlist(r.Context(), steamID)
+ if err != nil {
+ log.Printf("WARN: allowlist check failed for %s: %v", steamID, err)
+ h.authRedirect(w, r, "auth_error=steam_error")
+ return
+ }
+ if !allowed {
+ h.authRedirect(w, r, "auth_error=not_allowed")
+ return
+ }
+ }
+
// Fetch Steam profile data if API key is configured
claimOpts := []ClaimOption{WithRole(role)}
if h.setting.Auth.SteamAPIKey != "" {
@@ -154,6 +170,36 @@ func (h *Handler) authRedirect(w http.ResponseWriter, r *http.Request, query str
http.Redirect(w, r, prefix, http.StatusTemporaryRedirect)
}
+// PasswordLogin validates a shared password and issues a viewer JWT.
+func (h *Handler) PasswordLogin(w http.ResponseWriter, r *http.Request) {
+ if h.setting.Auth.Mode != "password" {
+ http.Error(w, "password login not enabled", http.StatusNotFound)
+ return
+ }
+
+ var req struct {
+ Password string `json:"password"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "invalid request", http.StatusBadRequest)
+ return
+ }
+
+ if req.Password == "" || subtle.ConstantTimeCompare([]byte(req.Password), []byte(h.setting.Auth.Password)) != 1 {
+ http.Error(w, "invalid password", http.StatusUnauthorized)
+ return
+ }
+
+ token, err := h.jwt.Create("password", WithRole("viewer"))
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"token": token})
+}
+
// MeResponse describes the authentication status returned by GetMe.
type MeResponse struct {
Authenticated bool `json:"authenticated"`
@@ -179,12 +225,41 @@ func (h *Handler) GetMe(c ContextNoBody) (MeResponse, error) {
return resp, nil
}
+// AuthConfigResponse describes the authentication configuration returned by GetAuthConfig.
+type AuthConfigResponse struct {
+ Mode string `json:"mode"`
+}
+
+// GetAuthConfig returns the current authentication mode so the frontend
+// can show the appropriate login controls.
+func (h *Handler) GetAuthConfig(c ContextNoBody) (AuthConfigResponse, error) {
+ return AuthConfigResponse{Mode: h.setting.Auth.Mode}, nil
+}
+
// Logout is a no-op for stateless JWT — the frontend discards the token.
func (h *Handler) Logout(c ContextNoBody) (any, error) {
c.SetStatus(http.StatusNoContent)
return nil, nil
}
+// requireViewer is middleware that enforces site-wide access control.
+// In "public" mode it passes all requests through. In all other modes
+// it requires a valid JWT with any role (viewer or admin).
+func (h *Handler) requireViewer(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if h.setting.Auth.Mode == "public" {
+ next.ServeHTTP(w, r)
+ return
+ }
+ token := bearerToken(r)
+ if token == "" || h.jwt.Validate(token) != nil {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
// requireAdmin is middleware that checks for a valid JWT Bearer token with admin role.
func (h *Handler) requireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -275,3 +350,43 @@ func randomHex(n int) (string, error) {
}
return hex.EncodeToString(b), nil
}
+
+// AllowlistResponse contains the Steam IDs on the allowlist.
+type AllowlistResponse struct {
+ SteamIDs []string `json:"steamIds"`
+}
+
+// GetAllowlist returns all Steam IDs on the allowlist.
+func (h *Handler) GetAllowlist(c ContextNoBody) (AllowlistResponse, error) {
+ ids, err := h.repoOperation.GetAllowlist(c.Context())
+ if err != nil {
+ return AllowlistResponse{}, err
+ }
+ return AllowlistResponse{SteamIDs: ids}, nil
+}
+
+// AddToAllowlist adds a Steam ID to the allowlist.
+func (h *Handler) AddToAllowlist(c ContextNoBody) (any, error) {
+ steamID := c.PathParam("steamId")
+ if steamID == "" {
+ return nil, fuego.BadRequestError{Detail: "steamId is required"}
+ }
+ if err := h.repoOperation.AddToAllowlist(c.Context(), steamID); err != nil {
+ return nil, err
+ }
+ c.SetStatus(http.StatusNoContent)
+ return nil, nil
+}
+
+// RemoveFromAllowlist removes a Steam ID from the allowlist.
+func (h *Handler) RemoveFromAllowlist(c ContextNoBody) (any, error) {
+ steamID := c.PathParam("steamId")
+ if steamID == "" {
+ return nil, fuego.BadRequestError{Detail: "steamId is required"}
+ }
+ if err := h.repoOperation.RemoveFromAllowlist(c.Context(), steamID); err != nil {
+ return nil, err
+ }
+ c.SetStatus(http.StatusNoContent)
+ return nil, nil
+}
diff --git a/internal/server/handler_auth_test.go b/internal/server/handler_auth_test.go
index 4a7eaef1..3dd0b498 100644
--- a/internal/server/handler_auth_test.go
+++ b/internal/server/handler_auth_test.go
@@ -1,12 +1,14 @@
package server
import (
+ "context"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
+ "path/filepath"
"strings"
"testing"
"time"
@@ -541,6 +543,72 @@ func TestRequireAdmin_AllowsAdminRole(t *testing.T) {
assert.True(t, called)
}
+func TestRequireViewer_PublicMode_AllowsUnauthenticated(t *testing.T) {
+ hdlr := newSteamAuthHandler(nil)
+ hdlr.setting.Auth.Mode = "public"
+
+ called := false
+ next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true })
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ rec := httptest.NewRecorder()
+
+ hdlr.requireViewer(next).ServeHTTP(rec, req)
+ assert.Equal(t, http.StatusOK, rec.Code)
+ assert.True(t, called)
+}
+
+func TestRequireViewer_NonPublic_RejectsUnauthenticated(t *testing.T) {
+ hdlr := newSteamAuthHandler(nil)
+ hdlr.setting.Auth.Mode = "steam"
+
+ called := false
+ next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true })
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ rec := httptest.NewRecorder()
+
+ hdlr.requireViewer(next).ServeHTTP(rec, req)
+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
+ assert.False(t, called)
+}
+
+func TestRequireViewer_NonPublic_AllowsViewerRole(t *testing.T) {
+ hdlr := newSteamAuthHandler(nil)
+ hdlr.setting.Auth.Mode = "steam"
+ token, err := hdlr.jwt.Create("76561198012345678", WithRole("viewer"))
+ require.NoError(t, err)
+
+ called := false
+ next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true })
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ rec := httptest.NewRecorder()
+
+ hdlr.requireViewer(next).ServeHTTP(rec, req)
+ assert.Equal(t, http.StatusOK, rec.Code)
+ assert.True(t, called)
+}
+
+func TestRequireViewer_NonPublic_AllowsAdminRole(t *testing.T) {
+ hdlr := newSteamAuthHandler(nil)
+ hdlr.setting.Auth.Mode = "steam"
+ token, err := hdlr.jwt.Create("76561198012345678", WithRole("admin"))
+ require.NoError(t, err)
+
+ called := false
+ next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true })
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ rec := httptest.NewRecorder()
+
+ hdlr.requireViewer(next).ServeHTTP(rec, req)
+ assert.Equal(t, http.StatusOK, rec.Code)
+ assert.True(t, called)
+}
+
func TestGetMe_ReturnsRole(t *testing.T) {
hdlr := newSteamAuthHandler(nil)
token, err := hdlr.jwt.Create("76561198012345678", WithRole("viewer"))
@@ -556,3 +624,331 @@ func TestGetMe_ReturnsRole(t *testing.T) {
assert.True(t, resp.Authenticated)
assert.Equal(t, "viewer", resp.Role)
}
+
+func newPasswordAuthHandler(password string) Handler {
+ return Handler{
+ setting: Setting{
+ Secret: "test-secret",
+ Auth: Auth{
+ Mode: "password",
+ SessionTTL: time.Hour,
+ Password: password,
+ },
+ },
+ jwt: NewJWTManager("test-secret", time.Hour),
+ }
+}
+
+func TestPasswordLogin_CorrectPassword(t *testing.T) {
+ hdlr := newPasswordAuthHandler("s3cret")
+
+ body := strings.NewReader(`{"password":"s3cret"}`)
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password", body)
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+
+ hdlr.PasswordLogin(rec, req)
+ assert.Equal(t, http.StatusOK, rec.Code)
+ assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
+
+ var resp map[string]string
+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
+ require.NotEmpty(t, resp["token"])
+
+ claims := hdlr.jwt.Claims(resp["token"])
+ require.NotNil(t, claims)
+ assert.Equal(t, "viewer", claims.Role)
+ assert.Equal(t, "password", claims.Subject)
+}
+
+func TestPasswordLogin_WrongPassword(t *testing.T) {
+ hdlr := newPasswordAuthHandler("s3cret")
+
+ body := strings.NewReader(`{"password":"wrong"}`)
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password", body)
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+
+ hdlr.PasswordLogin(rec, req)
+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
+}
+
+func TestPasswordLogin_EmptyPassword(t *testing.T) {
+ hdlr := newPasswordAuthHandler("s3cret")
+
+ body := strings.NewReader(`{"password":""}`)
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password", body)
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+
+ hdlr.PasswordLogin(rec, req)
+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
+}
+
+func TestPasswordLogin_InvalidJSON(t *testing.T) {
+ hdlr := newPasswordAuthHandler("s3cret")
+
+ body := strings.NewReader(`not json`)
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password", body)
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+
+ hdlr.PasswordLogin(rec, req)
+ assert.Equal(t, http.StatusBadRequest, rec.Code)
+}
+
+func TestPasswordLogin_MissingBody(t *testing.T) {
+ hdlr := newPasswordAuthHandler("s3cret")
+
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password", nil)
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+
+ hdlr.PasswordLogin(rec, req)
+ assert.Equal(t, http.StatusBadRequest, rec.Code)
+}
+
+func TestPasswordLogin_WrongMode(t *testing.T) {
+ hdlr := Handler{
+ setting: Setting{
+ Secret: "test-secret",
+ Auth: Auth{Mode: "steam", SessionTTL: time.Hour, Password: "s3cret"},
+ },
+ jwt: NewJWTManager("test-secret", time.Hour),
+ }
+ body := strings.NewReader(`{"password":"s3cret"}`)
+ req := httptest.NewRequest("POST", "/api/v1/auth/password", body)
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ hdlr.PasswordLogin(rec, req)
+ assert.Equal(t, http.StatusNotFound, rec.Code)
+}
+
+// --- GetAuthConfig tests ---
+
+func TestGetAuthConfig_ReturnsPublicMode(t *testing.T) {
+ hdlr := Handler{
+ setting: Setting{Auth: Auth{Mode: "public"}},
+ }
+
+ ctx := fuego.NewMockContextNoBody()
+ resp, err := hdlr.GetAuthConfig(ctx)
+ require.NoError(t, err)
+ assert.Equal(t, "public", resp.Mode)
+}
+
+func TestGetAuthConfig_ReturnsPasswordMode(t *testing.T) {
+ hdlr := Handler{
+ setting: Setting{Auth: Auth{Mode: "password"}},
+ }
+
+ ctx := fuego.NewMockContextNoBody()
+ resp, err := hdlr.GetAuthConfig(ctx)
+ require.NoError(t, err)
+ assert.Equal(t, "password", resp.Mode)
+}
+
+func TestGetAuthConfig_ReturnsSteamMode(t *testing.T) {
+ hdlr := Handler{
+ setting: Setting{Auth: Auth{Mode: "steam"}},
+ }
+
+ ctx := fuego.NewMockContextNoBody()
+ resp, err := hdlr.GetAuthConfig(ctx)
+ require.NoError(t, err)
+ assert.Equal(t, "steam", resp.Mode)
+}
+
+func TestGetAuthConfig_ReturnsEmptyWhenNotSet(t *testing.T) {
+ hdlr := Handler{
+ setting: Setting{},
+ }
+
+ ctx := fuego.NewMockContextNoBody()
+ resp, err := hdlr.GetAuthConfig(ctx)
+ require.NoError(t, err)
+ assert.Equal(t, "", resp.Mode)
+}
+
+// --- Allowlist CRUD tests ---
+
+func newAllowlistAuthHandler(t *testing.T, adminIDs []string) Handler {
+ t.Helper()
+ repo, err := NewRepoOperation(filepath.Join(t.TempDir(), "test.db"))
+ require.NoError(t, err)
+ t.Cleanup(func() { repo.db.Close() })
+ return Handler{
+ repoOperation: repo,
+ setting: Setting{
+ Secret: "test-secret",
+ Auth: Auth{
+ Mode: "steamAllowlist",
+ SessionTTL: time.Hour,
+ AdminSteamIDs: adminIDs,
+ },
+ },
+ jwt: NewJWTManager("test-secret", time.Hour),
+ openIDCache: openid.NewSimpleDiscoveryCache(),
+ openIDNonceStore: openid.NewSimpleNonceStore(),
+ openIDVerifier: mockVerifier{claimedID: "https://steamcommunity.com/openid/id/76561198012345678"},
+ }
+}
+
+func TestAllowlistCRUD(t *testing.T) {
+ hdlr := newAllowlistAuthHandler(t, nil)
+ ctx := context.Background()
+
+ // Empty allowlist
+ ids, err := hdlr.repoOperation.GetAllowlist(ctx)
+ require.NoError(t, err)
+ assert.Empty(t, ids)
+
+ // Add a Steam ID
+ err = hdlr.repoOperation.AddToAllowlist(ctx, "76561198012345678")
+ require.NoError(t, err)
+
+ // Verify it's there
+ ids, err = hdlr.repoOperation.GetAllowlist(ctx)
+ require.NoError(t, err)
+ assert.Equal(t, []string{"76561198012345678"}, ids)
+
+ // Add again (idempotent)
+ err = hdlr.repoOperation.AddToAllowlist(ctx, "76561198012345678")
+ require.NoError(t, err)
+
+ ids, err = hdlr.repoOperation.GetAllowlist(ctx)
+ require.NoError(t, err)
+ assert.Equal(t, []string{"76561198012345678"}, ids)
+
+ // IsOnAllowlist
+ on, err := hdlr.repoOperation.IsOnAllowlist(ctx, "76561198012345678")
+ require.NoError(t, err)
+ assert.True(t, on)
+
+ on, err = hdlr.repoOperation.IsOnAllowlist(ctx, "76561198099999999")
+ require.NoError(t, err)
+ assert.False(t, on)
+
+ // Remove
+ err = hdlr.repoOperation.RemoveFromAllowlist(ctx, "76561198012345678")
+ require.NoError(t, err)
+
+ ids, err = hdlr.repoOperation.GetAllowlist(ctx)
+ require.NoError(t, err)
+ assert.Empty(t, ids)
+}
+
+func TestGetAllowlist_Handler(t *testing.T) {
+ hdlr := newAllowlistAuthHandler(t, nil)
+ ctx := context.Background()
+
+ // Seed data
+ require.NoError(t, hdlr.repoOperation.AddToAllowlist(ctx, "76561198012345678"))
+ require.NoError(t, hdlr.repoOperation.AddToAllowlist(ctx, "76561198099999999"))
+
+ mockCtx := fuego.NewMockContextNoBody()
+ resp, err := hdlr.GetAllowlist(mockCtx)
+ require.NoError(t, err)
+ assert.Equal(t, []string{"76561198012345678", "76561198099999999"}, resp.SteamIDs)
+}
+
+func TestAddToAllowlist_Handler(t *testing.T) {
+ hdlr := newAllowlistAuthHandler(t, nil)
+
+ mockCtx := fuego.NewMockContextNoBody()
+ mockCtx.PathParams = map[string]string{"steamId": "76561198012345678"}
+
+ _, err := hdlr.AddToAllowlist(mockCtx)
+ require.NoError(t, err)
+
+ // Verify it was added
+ ids, err := hdlr.repoOperation.GetAllowlist(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, []string{"76561198012345678"}, ids)
+}
+
+func TestRemoveFromAllowlist_Handler(t *testing.T) {
+ hdlr := newAllowlistAuthHandler(t, nil)
+ ctx := context.Background()
+
+ // Seed data
+ require.NoError(t, hdlr.repoOperation.AddToAllowlist(ctx, "76561198012345678"))
+
+ mockCtx := fuego.NewMockContextNoBody()
+ mockCtx.PathParams = map[string]string{"steamId": "76561198012345678"}
+
+ _, err := hdlr.RemoveFromAllowlist(mockCtx)
+ require.NoError(t, err)
+
+ // Verify it was removed
+ ids, err := hdlr.repoOperation.GetAllowlist(ctx)
+ require.NoError(t, err)
+ assert.Empty(t, ids)
+}
+
+// --- SteamCallback allowlist mode tests ---
+
+func TestSteamCallback_AllowlistMode_AllowedUser(t *testing.T) {
+ hdlr := newAllowlistAuthHandler(t, []string{"76561198099999999"}) // different admin
+ hdlr.openIDVerifier = mockVerifier{claimedID: "https://steamcommunity.com/openid/id/76561198012345678"}
+
+ // Add the user to the allowlist
+ require.NoError(t, hdlr.repoOperation.AddToAllowlist(context.Background(), "76561198012345678"))
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/steam/callback?nonce=abc", nil)
+ req.AddCookie(&http.Cookie{Name: cookieNonce, Value: "abc"})
+ rec := httptest.NewRecorder()
+
+ hdlr.SteamCallback(rec, req)
+ assert.Equal(t, http.StatusTemporaryRedirect, rec.Code)
+
+ loc := rec.Header().Get("Location")
+ assert.Contains(t, loc, "auth_token=")
+ assert.NotContains(t, loc, "auth_error")
+
+ u, err := url.Parse(loc)
+ require.NoError(t, err)
+ tokenValue := u.Query().Get("auth_token")
+ claims := hdlr.jwt.Claims(tokenValue)
+ require.NotNil(t, claims)
+ assert.Equal(t, "viewer", claims.Role)
+}
+
+func TestSteamCallback_AllowlistMode_DeniedUser(t *testing.T) {
+ hdlr := newAllowlistAuthHandler(t, []string{"76561198099999999"}) // different admin
+ hdlr.openIDVerifier = mockVerifier{claimedID: "https://steamcommunity.com/openid/id/76561198012345678"}
+ // Do NOT add to allowlist
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/steam/callback?nonce=abc", nil)
+ req.AddCookie(&http.Cookie{Name: cookieNonce, Value: "abc"})
+ rec := httptest.NewRecorder()
+
+ hdlr.SteamCallback(rec, req)
+ assert.Equal(t, http.StatusTemporaryRedirect, rec.Code)
+ assert.Contains(t, rec.Header().Get("Location"), "auth_error=not_allowed")
+}
+
+func TestSteamCallback_AllowlistMode_AdminBypass(t *testing.T) {
+ hdlr := newAllowlistAuthHandler(t, []string{"76561198012345678"}) // same as the mock verifier
+ hdlr.openIDVerifier = mockVerifier{claimedID: "https://steamcommunity.com/openid/id/76561198012345678"}
+ // Do NOT add to allowlist — admin should bypass
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/steam/callback?nonce=abc", nil)
+ req.AddCookie(&http.Cookie{Name: cookieNonce, Value: "abc"})
+ rec := httptest.NewRecorder()
+
+ hdlr.SteamCallback(rec, req)
+ assert.Equal(t, http.StatusTemporaryRedirect, rec.Code)
+
+ loc := rec.Header().Get("Location")
+ assert.Contains(t, loc, "auth_token=")
+ assert.NotContains(t, loc, "auth_error")
+
+ u, err := url.Parse(loc)
+ require.NoError(t, err)
+ tokenValue := u.Query().Get("auth_token")
+ claims := hdlr.jwt.Claims(tokenValue)
+ require.NotNil(t, claims)
+ assert.Equal(t, "admin", claims.Role)
+}
+
diff --git a/internal/server/handler_test.go b/internal/server/handler_test.go
index e7e4a0a5..8f60e86e 100644
--- a/internal/server/handler_test.go
+++ b/internal/server/handler_test.go
@@ -831,6 +831,7 @@ func TestNewHandler(t *testing.T) {
Data: dataDir,
Markers: markerDir,
Ammo: ammoDir,
+ Auth: Auth{Mode: "public"},
}
s := fuego.NewServer(fuego.WithoutStartupMessages(), fuego.WithoutAutoGroupTags(), fuego.WithSecurity(OpenAPISecuritySchemes))
diff --git a/internal/server/operation.go b/internal/server/operation.go
index 01aa7a6c..b80814df 100644
--- a/internal/server/operation.go
+++ b/internal/server/operation.go
@@ -232,6 +232,16 @@ func (r *RepoOperation) migration() (err error) {
}
}
+ if version < 11 {
+ if err = r.runMigration(11,
+ `CREATE TABLE IF NOT EXISTS steam_allowlist (
+ steam_id TEXT NOT NULL PRIMARY KEY
+ )`,
+ ); err != nil {
+ return err
+ }
+ }
+
return nil
}
diff --git a/internal/server/operation_test.go b/internal/server/operation_test.go
index 7ef7c015..889147d0 100644
--- a/internal/server/operation_test.go
+++ b/internal/server/operation_test.go
@@ -438,7 +438,7 @@ func TestMigrationRerun(t *testing.T) {
var version int
err = repo2.db.QueryRow("SELECT db FROM version ORDER BY db DESC LIMIT 1").Scan(&version)
assert.NoError(t, err)
- assert.Equal(t, 10, version)
+ assert.Equal(t, 11, version)
}
func TestMigrationV10NormalizeWorldName(t *testing.T) {
@@ -461,7 +461,7 @@ func TestMigrationV10NormalizeWorldName(t *testing.T) {
// Reset version so migration 10 runs again
db, err := sql.Open("sqlite3", pathDB)
require.NoError(t, err)
- _, err = db.Exec(`DELETE FROM version WHERE db = 10`)
+ _, err = db.Exec(`DELETE FROM version WHERE db >= 10`)
require.NoError(t, err)
require.NoError(t, db.Close())
diff --git a/internal/server/setting.go b/internal/server/setting.go
index 6d682c5c..88f4f7ab 100644
--- a/internal/server/setting.go
+++ b/internal/server/setting.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"os"
+ "slices"
"strings"
"time"
@@ -48,9 +49,11 @@ type Customize struct {
}
type Auth struct {
+ Mode string `json:"mode" yaml:"mode"`
SessionTTL time.Duration `json:"sessionTTL" yaml:"sessionTTL"`
AdminSteamIDs []string `json:"adminSteamIds" yaml:"adminSteamIds"`
SteamAPIKey string `json:"steamApiKey" yaml:"steamApiKey"`
+ Password string `json:"password" yaml:"password"`
}
type Streaming struct {
@@ -97,10 +100,11 @@ func NewSetting() (setting Setting, err error) {
viper.SetDefault("auth.sessionTTL", "24h")
viper.SetDefault("auth.adminSteamIds", []string{})
viper.SetDefault("auth.steamApiKey", "")
-
+ viper.SetDefault("auth.mode", "public")
+ viper.SetDefault("auth.password", "")
// workaround for https://github.com/spf13/viper/issues/761
- envKeys := []string{"listen", "prefixURL", "secret", "db", "markers", "ammo", "fonts", "maps", "data", "static", "customize.enabled", "customize.websiteurl", "customize.websitelogo", "customize.websitelogosize", "customize.disableKillCount", "customize.headertitle", "customize.headersubtitle", "conversion.enabled", "conversion.interval", "conversion.batchSize", "conversion.chunkSize", "conversion.retryFailed", "streaming.enabled", "streaming.pingInterval", "streaming.pingTimeout", "auth.sessionTTL", "auth.adminSteamIds", "auth.steamApiKey"}
+ envKeys := []string{"listen", "prefixURL", "secret", "db", "markers", "ammo", "fonts", "maps", "data", "static", "customize.enabled", "customize.websiteurl", "customize.websitelogo", "customize.websitelogosize", "customize.disableKillCount", "customize.headertitle", "customize.headersubtitle", "conversion.enabled", "conversion.interval", "conversion.batchSize", "conversion.chunkSize", "conversion.retryFailed", "streaming.enabled", "streaming.pingInterval", "streaming.pingTimeout", "auth.sessionTTL", "auth.adminSteamIds", "auth.steamApiKey", "auth.mode", "auth.password"}
for _, key := range envKeys {
env := strings.ToUpper(strings.ReplaceAll(key, ".", "_"))
if err = viper.BindEnv(key, env); err != nil {
@@ -123,6 +127,10 @@ func NewSetting() (setting Setting, err error) {
// so a value like "id1,id2" ends up as ["id1,id2"]. Expand it.
setting.Auth.AdminSteamIDs = splitCSV(setting.Auth.AdminSteamIDs)
+ if err = validateAuthConfig(setting.Auth); err != nil {
+ return
+ }
+
// Viper can't unmarshal a JSON string env var into map[string]string,
// so parse OCAP_CUSTOMIZE_CSSOVERRIDES manually if set. Env var takes
// precedence over config file.
@@ -145,6 +153,20 @@ func NewSetting() (setting Setting, err error) {
return
}
+func validateAuthConfig(auth Auth) error {
+ validModes := []string{"public", "password", "steam", "steamAllowlist"}
+ if !slices.Contains(validModes, auth.Mode) {
+ return fmt.Errorf("auth.mode %q is not valid, must be one of: %s", auth.Mode, strings.Join(validModes, ", "))
+ }
+ switch auth.Mode {
+ case "password":
+ if auth.Password == "" {
+ return fmt.Errorf("auth.mode %q requires auth.password to be set", auth.Mode)
+ }
+ }
+ return nil
+}
+
// splitCSV expands a []string where one element may contain comma-separated
// values (from an env var) into individual trimmed entries.
func splitCSV(in []string) []string {
diff --git a/internal/server/setting_test.go b/internal/server/setting_test.go
index 1740dda7..95b2e6e7 100644
--- a/internal/server/setting_test.go
+++ b/internal/server/setting_test.go
@@ -490,3 +490,88 @@ func TestNewSetting_NoConfigFile(t *testing.T) {
_, err := NewSetting()
assert.Error(t, err)
}
+
+func TestValidateAuthConfig(t *testing.T) {
+ t.Run("valid modes accepted", func(t *testing.T) {
+ for _, mode := range []string{"public", "steam", "steamAllowlist"} {
+ err := validateAuthConfig(Auth{Mode: mode})
+ assert.NoError(t, err, "mode %q should be valid", mode)
+ }
+ err := validateAuthConfig(Auth{Mode: "password", Password: "secret"})
+ assert.NoError(t, err)
+ })
+
+ t.Run("invalid mode returns error", func(t *testing.T) {
+ err := validateAuthConfig(Auth{Mode: "bogus"})
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "bogus")
+ assert.Contains(t, err.Error(), "not valid")
+ })
+
+ t.Run("password mode without password", func(t *testing.T) {
+ err := validateAuthConfig(Auth{Mode: "password"})
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "auth.password")
+ })
+
+ t.Run("removed modes are rejected", func(t *testing.T) {
+ err := validateAuthConfig(Auth{Mode: "steamGroup"})
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not valid")
+
+ err = validateAuthConfig(Auth{Mode: "squadXml"})
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not valid")
+ })
+}
+
+func TestNewSetting_AuthModeDefault(t *testing.T) {
+ defer viper.Reset()
+
+ dir := t.TempDir()
+ err := os.WriteFile(filepath.Join(dir, "setting.json"), []byte(`{"secret": "test-secret-value"}`), 0644)
+ require.NoError(t, err)
+
+ viper.Reset()
+ viper.AddConfigPath(dir)
+ setting, err := NewSetting()
+ require.NoError(t, err)
+
+ assert.Equal(t, "public", setting.Auth.Mode)
+}
+
+func TestNewSetting_AuthModeInvalid(t *testing.T) {
+ defer viper.Reset()
+
+ dir := t.TempDir()
+ err := os.WriteFile(filepath.Join(dir, "setting.json"), []byte(`{
+ "secret": "test-secret-value",
+ "auth": {"mode": "invalid"}
+ }`), 0644)
+ require.NoError(t, err)
+
+ viper.Reset()
+ viper.AddConfigPath(dir)
+ _, err = NewSetting()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid")
+}
+
+func TestNewSetting_AuthPasswordMode(t *testing.T) {
+ defer viper.Reset()
+
+ dir := t.TempDir()
+ err := os.WriteFile(filepath.Join(dir, "setting.json"), []byte(`{
+ "secret": "test-secret-value",
+ "auth": {"mode": "password", "password": "hunter2"}
+ }`), 0644)
+ require.NoError(t, err)
+
+ viper.Reset()
+ viper.AddConfigPath(dir)
+ setting, err := NewSetting()
+ require.NoError(t, err)
+
+ assert.Equal(t, "password", setting.Auth.Mode)
+ assert.Equal(t, "hunter2", setting.Auth.Password)
+}
diff --git a/setting.json.example b/setting.json.example
index 708b74ba..9d02e204 100644
--- a/setting.json.example
+++ b/setting.json.example
@@ -33,8 +33,10 @@
"pingTimeout": "10s"
},
"auth": {
+ "mode": "public",
"sessionTTL": "24h",
"adminSteamIds": [],
- "steamApiKey": ""
+ "steamApiKey": "",
+ "password": ""
}
}
diff --git a/ui/src/components/AuthBadge.module.css b/ui/src/components/AuthBadge.module.css
index 0f3b318d..9001fd4b 100644
--- a/ui/src/components/AuthBadge.module.css
+++ b/ui/src/components/AuthBadge.module.css
@@ -87,3 +87,58 @@
filter: brightness(1.1);
border-color: rgba(255, 255, 255, 0.15);
}
+
+.authControls {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.passwordForm {
+ display: flex;
+ gap: 0.25rem;
+}
+
+.passwordInput {
+ padding: 0.25rem 0.5rem;
+ border: 1px solid var(--border-color, #444);
+ border-radius: 4px;
+ background: var(--input-bg, #1a1a2e);
+ color: var(--text-color, #e0e0e0);
+ font-size: 0.8rem;
+ width: 140px;
+}
+
+.passwordSubmit {
+ padding: 0.25rem 0.5rem;
+ border: 1px solid var(--border-color, #444);
+ border-radius: 4px;
+ background: var(--accent-color, #4a9eff);
+ color: white;
+ font-size: 0.8rem;
+ cursor: pointer;
+}
+
+.passwordSubmit:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.authError {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ color: var(--error-color, #ff4444);
+ font-size: 0.75rem;
+ max-width: 250px;
+}
+
+.dismissError {
+ background: none;
+ border: none;
+ color: inherit;
+ cursor: pointer;
+ font-size: 1rem;
+ padding: 0;
+ line-height: 1;
+}
diff --git a/ui/src/components/AuthBadge.tsx b/ui/src/components/AuthBadge.tsx
index 200c9e29..37c310cc 100644
--- a/ui/src/components/AuthBadge.tsx
+++ b/ui/src/components/AuthBadge.tsx
@@ -1,4 +1,4 @@
-import { Show } from "solid-js";
+import { Show, createSignal } from "solid-js";
import type { JSX } from "solid-js";
import { useAuth } from "../hooks/useAuth";
import { useI18n } from "../hooks/useLocale";
@@ -6,21 +6,59 @@ import { SteamIcon, ShieldIcon, LogOutIcon } from "./Icons";
import styles from "./AuthBadge.module.css";
/**
- * Shared auth badge — renders Steam sign-in when unauthenticated,
+ * Shared auth badge — renders login controls when unauthenticated,
* admin badge + sign-out when authenticated.
+ * Shows password form in password mode, Steam button in all other modes.
* Calls useAuth() internally; no props needed.
*/
export function AuthBadge(): JSX.Element {
- const { authenticated, isAdmin, steamName, steamId, steamAvatar, loginWithSteam, logout } = useAuth();
+ const { authenticated, isAdmin, steamName, steamId, steamAvatar, authMode, authError, dismissAuthError, loginWithSteam, loginWithPassword, logout } = useAuth();
const { t } = useI18n();
+ const [password, setPassword] = createSignal("");
+ const [loading, setLoading] = createSignal(false);
+
+ const handlePasswordSubmit = async (e: Event) => {
+ e.preventDefault();
+ if (!password()) return;
+ setLoading(true);
+ try {
+ await loginWithPassword(password());
+ } finally {
+ setLoading(false);
+ setPassword("");
+ }
+ };
return (
loginWithSteam()}>
- {t("sign_in")}
-
+
+
+
+
+
+
+
+ {authError()}
+
+
+
+
}
>
<>
diff --git a/ui/src/components/__tests__/AuthBadge.test.tsx b/ui/src/components/__tests__/AuthBadge.test.tsx
index 7a25691d..21737a9e 100644
--- a/ui/src/components/__tests__/AuthBadge.test.tsx
+++ b/ui/src/components/__tests__/AuthBadge.test.tsx
@@ -6,6 +6,7 @@ import { I18nProvider } from "../../hooks/useLocale";
// ─── Mock useAuth ───
const mockLoginWithSteam = vi.fn();
+const mockLoginWithPassword = vi.fn().mockResolvedValue(undefined);
const mockLogout = vi.fn();
const authState = {
@@ -15,9 +16,11 @@ const authState = {
steamName: vi.fn(() => null as string | null),
steamId: vi.fn(() => null as string | null),
steamAvatar: vi.fn(() => null as string | null),
- authError: vi.fn(() => null),
+ authError: vi.fn(() => null as string | null),
+ authMode: vi.fn(() => "public"),
dismissAuthError: vi.fn(),
loginWithSteam: mockLoginWithSteam,
+ loginWithPassword: mockLoginWithPassword,
logout: mockLogout,
};
@@ -37,6 +40,8 @@ describe("AuthBadge", () => {
authState.steamName.mockReturnValue(null);
authState.steamId.mockReturnValue(null);
authState.steamAvatar.mockReturnValue(null);
+ authState.authError.mockReturnValue(null);
+ authState.authMode.mockReturnValue("public");
});
it("shows sign-in button when not authenticated", () => {
@@ -111,4 +116,61 @@ describe("AuthBadge", () => {
fireEvent.click(getByTitle("Sign out"));
expect(mockLogout).toHaveBeenCalledOnce();
});
+
+ // ─── Password mode ───
+
+ it("shows password field and Steam button in password mode", () => {
+ authState.authMode.mockReturnValue("password");
+
+ const { getByPlaceholderText, getByText } = render(() => );
+ expect(getByPlaceholderText("Password")).toBeDefined();
+ expect(getByText("Unlock")).toBeDefined();
+ expect(getByText("Sign in")).toBeDefined();
+ });
+
+ it("shows only Steam button in steam mode", () => {
+ authState.authMode.mockReturnValue("steam");
+
+ const { getByText, queryByPlaceholderText } = render(() => );
+ expect(getByText("Sign in")).toBeDefined();
+ expect(queryByPlaceholderText("Password")).toBeNull();
+ });
+
+ it("shows only Steam button in steamAllowlist mode", () => {
+ authState.authMode.mockReturnValue("steamAllowlist");
+
+ const { getByText, queryByPlaceholderText } = render(() => );
+ expect(getByText("Sign in")).toBeDefined();
+ expect(queryByPlaceholderText("Password")).toBeNull();
+ });
+
+ it("shows only Steam button in public mode", () => {
+ authState.authMode.mockReturnValue("public");
+
+ const { getByText, queryByPlaceholderText } = render(() => );
+ expect(getByText("Sign in")).toBeDefined();
+ expect(queryByPlaceholderText("Password")).toBeNull();
+ });
+
+ it("calls loginWithPassword on form submit", async () => {
+ authState.authMode.mockReturnValue("password");
+
+ const { getByPlaceholderText, getByText } = render(() => );
+
+ const input = getByPlaceholderText("Password") as HTMLInputElement;
+ fireEvent.input(input, { target: { value: "secret123" } });
+ fireEvent.click(getByText("Unlock"));
+
+ expect(mockLoginWithPassword).toHaveBeenCalledWith("secret123");
+ });
+
+ it("displays auth error and dismiss button", () => {
+ authState.authError.mockReturnValue("Invalid password");
+
+ const { getByText } = render(() => );
+ expect(getByText("Invalid password")).toBeDefined();
+
+ fireEvent.click(getByText("x"));
+ expect(authState.dismissAuthError).toHaveBeenCalledOnce();
+ });
});
diff --git a/ui/src/data/__tests__/apiClient.test.ts b/ui/src/data/__tests__/apiClient.test.ts
index f44009cd..27774969 100644
--- a/ui/src/data/__tests__/apiClient.test.ts
+++ b/ui/src/data/__tests__/apiClient.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { ApiClient, ApiError, setAuthToken, getAuthToken } from "../apiClient";
-import type { CustomizeConfig, BuildInfo } from "../apiClient";
+import type { CustomizeConfig, BuildInfo, AuthConfig } from "../apiClient";
// ─── Helpers ───
@@ -161,7 +161,7 @@ describe("ApiClient", () => {
const client = new ApiClient("/aar/");
const result = await client.getRecordingData("my_mission");
- expect(fetch).toHaveBeenCalledWith("/aar/data/my_mission.json.gz");
+ expect(fetch).toHaveBeenCalledWith("/aar/data/my_mission.json.gz", expect.anything());
expect(new Uint8Array(result)).toEqual(new Uint8Array([1, 2, 3, 4]));
});
@@ -476,6 +476,7 @@ describe("ApiClient", () => {
expect(fetch).toHaveBeenCalledWith(
"/aar/data/op-123/manifest.pb",
+ expect.anything(),
);
expect(new Uint8Array(result)).toEqual(new Uint8Array([10, 20, 30]));
});
@@ -491,6 +492,7 @@ describe("ApiClient", () => {
expect(fetch).toHaveBeenCalledWith(
"/aar/data/op-123/chunks/0005.pb",
+ expect.anything(),
);
expect(new Uint8Array(result)).toEqual(new Uint8Array([0xaa, 0xbb]));
});
@@ -1107,4 +1109,156 @@ describe("ApiClient", () => {
await promise;
});
});
+
+ // ─── getAuthConfig ───
+
+ describe("getAuthConfig", () => {
+ it("returns auth mode from server", async () => {
+ mockFetchJson({ mode: "password" });
+
+ const client = new ApiClient("/aar/");
+ const result = await client.getAuthConfig();
+
+ expect(fetch).toHaveBeenCalledWith("/aar/api/v1/auth/config", {
+ cache: "no-cache",
+ });
+ expect(result).toEqual({ mode: "password" });
+ });
+
+ it("defaults to public mode on error", async () => {
+ mockFetchError(500, "Internal Server Error");
+
+ const client = new ApiClient("/aar/");
+ const result = await client.getAuthConfig();
+
+ expect(result).toEqual({ mode: "public" });
+ });
+ });
+
+ // ─── passwordLogin ───
+
+ describe("passwordLogin", () => {
+ it("stores token on success", async () => {
+ mockFetchJson({ token: "pw-jwt-token" });
+
+ const client = new ApiClient("/aar/");
+ const token = await client.passwordLogin("secret123");
+
+ expect(fetch).toHaveBeenCalledWith("/aar/api/v1/auth/password", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ password: "secret123" }),
+ });
+ expect(token).toBe("pw-jwt-token");
+ expect(getAuthToken()).toBe("pw-jwt-token");
+ });
+
+ it("throws 'Invalid password' on 401", async () => {
+ mockFetchError(401, "Unauthorized");
+
+ const client = new ApiClient("/aar/");
+ await expect(client.passwordLogin("wrong")).rejects.toThrow("Invalid password");
+ });
+
+ it("throws 'Login failed' on other errors", async () => {
+ mockFetchError(500, "Internal Server Error");
+
+ const client = new ApiClient("/aar/");
+ await expect(client.passwordLogin("test")).rejects.toThrow("Login failed");
+ });
+ });
+
+ // ─── Auth headers on viewer-gated endpoints ───
+
+ describe("auth headers on viewer-gated endpoints", () => {
+ it("includes auth header in fetchJson calls when token is set", async () => {
+ setAuthToken("viewer-jwt");
+ mockFetchJson([]);
+
+ const client = new ApiClient("/aar/");
+ await client.getRecordings();
+
+ expect(fetch).toHaveBeenCalledWith(
+ "/aar/api/v1/operations",
+ expect.objectContaining({
+ headers: { Authorization: "Bearer viewer-jwt" },
+ }),
+ );
+ });
+
+ it("includes auth header in fetchBuffer calls when token is set", async () => {
+ setAuthToken("viewer-jwt");
+ mockFetchBuffer(new ArrayBuffer(0));
+
+ const client = new ApiClient("/aar/");
+ await client.getRecordingData("test");
+
+ expect(fetch).toHaveBeenCalledWith(
+ "/aar/data/test.json.gz",
+ expect.objectContaining({
+ headers: { Authorization: "Bearer viewer-jwt" },
+ }),
+ );
+ });
+
+ it("sends empty headers when no token is stored", async () => {
+ mockFetchJson([]);
+
+ const client = new ApiClient("/aar/");
+ await client.getRecordings();
+
+ expect(fetch).toHaveBeenCalledWith(
+ "/aar/api/v1/operations",
+ expect.objectContaining({
+ headers: {},
+ }),
+ );
+ });
+
+ it("saves return path and redirects on 401 from fetchJson", async () => {
+ mockFetchError(401, "Unauthorized");
+
+ const hrefSetter = vi.fn();
+ Object.defineProperty(window, "location", {
+ value: {
+ ...window.location,
+ pathname: "/recording/42/test",
+ search: "",
+ get href() { return "http://localhost/recording/42/test"; },
+ set href(v: string) { hrefSetter(v); },
+ },
+ writable: true,
+ configurable: true,
+ });
+
+ const client = new ApiClient("/aar/");
+ await expect(client.getRecordings()).rejects.toThrow("Authentication required");
+
+ expect(sessionStorage.getItem("ocap_return_to")).toBe("/recording/42/test");
+ expect(hrefSetter).toHaveBeenCalledWith("/");
+ });
+
+ it("saves return path and redirects on 401 from fetchBuffer", async () => {
+ mockFetchError(401, "Unauthorized");
+
+ const hrefSetter = vi.fn();
+ Object.defineProperty(window, "location", {
+ value: {
+ ...window.location,
+ pathname: "/recording/7/mission",
+ search: "?t=100",
+ get href() { return "http://localhost/recording/7/mission?t=100"; },
+ set href(v: string) { hrefSetter(v); },
+ },
+ writable: true,
+ configurable: true,
+ });
+
+ const client = new ApiClient("/aar/");
+ await expect(client.getRecordingData("test")).rejects.toThrow("Authentication required");
+
+ expect(sessionStorage.getItem("ocap_return_to")).toBe("/recording/7/mission?t=100");
+ expect(hrefSetter).toHaveBeenCalledWith("/");
+ });
+ });
});
diff --git a/ui/src/data/apiClient.ts b/ui/src/data/apiClient.ts
index 95f70dc0..3e57fd57 100644
--- a/ui/src/data/apiClient.ts
+++ b/ui/src/data/apiClient.ts
@@ -3,6 +3,10 @@ import type { ToolSet, HealthCheck, MapInfo, JobInfo } from "../pages/map-manage
// ─── Response types for endpoints not covered in types.ts ───
+export interface AuthConfig {
+ mode: string;
+}
+
export interface CustomizeConfig {
websiteURL?: string;
websiteLogo?: string;
@@ -333,6 +337,34 @@ export class ApiClient {
return true;
}
+ async getAuthConfig(): Promise {
+ const response = await fetch(`${this.baseUrl}/api/v1/auth/config`, {
+ cache: "no-cache",
+ });
+ if (!response.ok) {
+ return { mode: "public" };
+ }
+ return response.json() as Promise;
+ }
+
+ async passwordLogin(password: string): Promise {
+ const response = await fetch(`${this.baseUrl}/api/v1/auth/password`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ password }),
+ });
+ if (!response.ok) {
+ throw new ApiError(
+ response.status === 401 ? "Invalid password" : "Login failed",
+ response.status,
+ response.statusText,
+ );
+ }
+ const data = (await response.json()) as { token: string };
+ setAuthToken(data.token);
+ return data.token;
+ }
+
async getMe(): Promise {
const response = await fetch(`${this.baseUrl}/api/v1/auth/me`, {
headers: authHeaders(),
@@ -470,6 +502,43 @@ export class ApiClient {
}
}
+ // ─── Allowlist methods (admin) ───
+
+ async getAllowlist(): Promise {
+ const data = await this.fetchJsonAuth<{ steamIds: string[] }>(
+ `${this.baseUrl}/api/v1/auth/allowlist`,
+ );
+ return data.steamIds;
+ }
+
+ async addToAllowlist(steamId: string): Promise {
+ const response = await fetch(
+ `${this.baseUrl}/api/v1/auth/allowlist/${encodeURIComponent(steamId)}`,
+ { method: "PUT", headers: authHeaders() },
+ );
+ if (!response.ok) {
+ throw new ApiError(
+ `Add to allowlist failed: ${response.status} ${response.statusText}`,
+ response.status,
+ response.statusText,
+ );
+ }
+ }
+
+ async removeFromAllowlist(steamId: string): Promise {
+ const response = await fetch(
+ `${this.baseUrl}/api/v1/auth/allowlist/${encodeURIComponent(steamId)}`,
+ { method: "DELETE", headers: authHeaders() },
+ );
+ if (!response.ok) {
+ throw new ApiError(
+ `Remove from allowlist failed: ${response.status} ${response.statusText}`,
+ response.status,
+ response.statusText,
+ );
+ }
+ }
+
// ─── MapTool methods ───
async getMapToolHealth(): Promise {
@@ -590,7 +659,16 @@ export class ApiClient {
}
private async fetchJson(url: string): Promise {
- const response = await fetch(url, { cache: "no-store" });
+ const response = await fetch(url, {
+ headers: authHeaders(),
+ cache: "no-store",
+ });
+ if (response.status === 401) {
+ sessionStorage.setItem("ocap_return_to", window.location.pathname + window.location.search);
+ const base = ((globalThis as Record).__BASE_PATH__ as string) ?? "";
+ window.location.href = base + "/";
+ throw new ApiError("Authentication required", 401, "Unauthorized");
+ }
if (!response.ok) {
throw new ApiError(
`GET ${url} failed: ${response.status} ${response.statusText}`,
@@ -602,7 +680,15 @@ export class ApiClient {
}
private async fetchBuffer(url: string): Promise {
- const response = await fetch(url);
+ const response = await fetch(url, {
+ headers: authHeaders(),
+ });
+ if (response.status === 401) {
+ sessionStorage.setItem("ocap_return_to", window.location.pathname + window.location.search);
+ const base = ((globalThis as Record).__BASE_PATH__ as string) ?? "";
+ window.location.href = base + "/";
+ throw new ApiError("Authentication required", 401, "Unauthorized");
+ }
if (!response.ok) {
throw new ApiError(
`GET ${url} failed: ${response.status} ${response.statusText}`,
diff --git a/ui/src/hooks/__tests__/useAuth.test.tsx b/ui/src/hooks/__tests__/useAuth.test.tsx
index 723283b3..5ec1d7ad 100644
--- a/ui/src/hooks/__tests__/useAuth.test.tsx
+++ b/ui/src/hooks/__tests__/useAuth.test.tsx
@@ -11,6 +11,8 @@ const mockLogout = vi.fn();
const mockGetSteamLoginUrl = vi.fn().mockReturnValue("/api/v1/auth/steam");
const mockConsumeAuthToken = vi.fn().mockReturnValue(false);
const mockPopReturnTo = vi.fn().mockReturnValue(null);
+const mockGetAuthConfig = vi.fn().mockResolvedValue({ mode: "public" });
+const mockPasswordLogin = vi.fn();
vi.mock("../../data/apiClient", async () => {
const actual = await vi.importActual("../../data/apiClient");
@@ -22,6 +24,8 @@ vi.mock("../../data/apiClient", async () => {
getSteamLoginUrl = mockGetSteamLoginUrl;
consumeAuthToken = mockConsumeAuthToken;
popReturnTo = mockPopReturnTo;
+ getAuthConfig = mockGetAuthConfig;
+ passwordLogin = mockPasswordLogin;
},
};
});
@@ -50,6 +54,8 @@ describe("useAuth", () => {
mockLogout.mockResolvedValue(undefined);
mockConsumeAuthToken.mockReturnValue(false);
mockPopReturnTo.mockReturnValue(null);
+ mockGetAuthConfig.mockResolvedValue({ mode: "public" });
+ mockPasswordLogin.mockResolvedValue("pw-token");
});
afterEach(() => {
@@ -284,4 +290,87 @@ describe("useAuth", () => {
expect(authRef.role()).toBeNull();
expect(authRef.isAdmin()).toBe(false);
});
+
+ it("authMode defaults to public", async () => {
+ let authRef!: Auth;
+ const { findByTestId } = renderAuth((a) => { authRef = a; });
+
+ await findByTestId("authenticated");
+ await vi.waitFor(() => {
+ expect(authRef.authMode()).toBe("public");
+ });
+ });
+
+ it("authMode reflects server config", async () => {
+ mockGetAuthConfig.mockResolvedValue({ mode: "password" });
+
+ let authRef!: Auth;
+ const { findByTestId } = renderAuth((a) => { authRef = a; });
+
+ await findByTestId("authenticated");
+ await vi.waitFor(() => {
+ expect(authRef.authMode()).toBe("password");
+ });
+ });
+
+ it("authMode defaults to public when getAuthConfig fails", async () => {
+ mockGetAuthConfig.mockRejectedValue(new Error("network error"));
+
+ let authRef!: Auth;
+ const { findByTestId } = renderAuth((a) => { authRef = a; });
+
+ await findByTestId("authenticated");
+ await vi.waitFor(() => {
+ expect(authRef.authMode()).toBe("public");
+ });
+ });
+
+ it("loginWithPassword success sets authenticated state", async () => {
+ mockPasswordLogin.mockResolvedValue("pw-token");
+ mockGetMe.mockResolvedValue({
+ authenticated: true,
+ role: "viewer",
+ steamId: null,
+ steamName: null,
+ steamAvatar: null,
+ });
+
+ let authRef!: Auth;
+ const { findByTestId } = renderAuth((a) => { authRef = a; });
+
+ await findByTestId("authenticated");
+ await authRef.loginWithPassword("secret123");
+
+ expect(mockPasswordLogin).toHaveBeenCalledWith("secret123");
+ expect(authRef.authenticated()).toBe(true);
+ expect(authRef.role()).toBe("viewer");
+ });
+
+ it("loginWithPassword failure sets authError", async () => {
+ mockPasswordLogin.mockRejectedValue(new Error("Invalid password"));
+
+ let authRef!: Auth;
+ const { findByTestId } = renderAuth((a) => { authRef = a; });
+
+ await findByTestId("authenticated");
+ await authRef.loginWithPassword("wrong");
+
+ expect(authRef.authError()).toBe("Invalid password");
+ expect(authRef.authenticated()).toBe(false);
+ });
+
+ it("maps not_allowed auth error", async () => {
+ Object.defineProperty(window, "location", {
+ value: { ...window.location, search: "?auth_error=not_allowed", href: window.location.origin + "/?auth_error=not_allowed", pathname: "/" },
+ writable: true,
+ configurable: true,
+ });
+
+ let authRef!: Auth;
+ renderAuth((a) => { authRef = a; });
+
+ await vi.waitFor(() => {
+ expect(authRef.authError()).toBe("You are not on the allowlist. Contact an admin for access.");
+ });
+ });
});
diff --git a/ui/src/hooks/useAuth.tsx b/ui/src/hooks/useAuth.tsx
index 15b0c1d7..1042a148 100644
--- a/ui/src/hooks/useAuth.tsx
+++ b/ui/src/hooks/useAuth.tsx
@@ -10,13 +10,16 @@ export interface Auth {
steamName: Accessor;
steamAvatar: Accessor;
authError: Accessor;
+ authMode: Accessor;
dismissAuthError: () => void;
loginWithSteam: () => void;
+ loginWithPassword: (password: string) => Promise;
logout: () => Promise;
}
const AUTH_ERROR_MESSAGES: Record = {
steam_error: "Steam login failed. Please try again.",
+ not_allowed: "You are not on the allowlist. Contact an admin for access.",
};
const AuthContext = createContext();
@@ -32,9 +35,18 @@ export function AuthProvider(props: { children: JSX.Element }): JSX.Element {
const [steamName, setSteamName] = createSignal(null);
const [steamAvatar, setSteamAvatar] = createSignal(null);
const [authError, setAuthError] = createSignal(null);
+ const [authMode, setAuthMode] = createSignal("public");
const api = new ApiClient();
onMount(async () => {
+ // Fetch auth config first
+ try {
+ const config = await api.getAuthConfig();
+ setAuthMode(config.mode);
+ } catch {
+ // Default to public if endpoint fails
+ }
+
// Read query params from Steam callback redirect
const params = new URLSearchParams(window.location.search);
@@ -78,6 +90,21 @@ export function AuthProvider(props: { children: JSX.Element }): JSX.Element {
const dismissAuthError = () => setAuthError(null);
+ const loginWithPassword = async (password: string): Promise => {
+ setAuthError(null);
+ try {
+ await api.passwordLogin(password);
+ const state = await api.getMe();
+ setAuthenticated(state.authenticated);
+ setRole(state.role ?? null);
+ setSteamId(state.steamId ?? null);
+ setSteamName(state.steamName ?? null);
+ setSteamAvatar(state.steamAvatar ?? null);
+ } catch (err) {
+ setAuthError(err instanceof Error ? err.message : "Login failed");
+ }
+ };
+
const loginWithSteam = () => {
setAuthError(null);
window.location.href = api.getSteamLoginUrl(
@@ -98,7 +125,7 @@ export function AuthProvider(props: { children: JSX.Element }): JSX.Element {
};
return (
-
+
{props.children}
);
diff --git a/ui/src/pages/recording-selector/__tests__/RecordingSelector.test.tsx b/ui/src/pages/recording-selector/__tests__/RecordingSelector.test.tsx
index b4b4e310..e824cfa9 100644
--- a/ui/src/pages/recording-selector/__tests__/RecordingSelector.test.tsx
+++ b/ui/src/pages/recording-selector/__tests__/RecordingSelector.test.tsx
@@ -632,8 +632,9 @@ describe("RecordingSelector", () => {
// Wait for toast to appear
await vi.waitFor(() => {
- expect(screen.getByTestId("auth-toast")).toBeDefined();
- expect(screen.getByText(/Steam login failed/)).toBeDefined();
+ const toast = screen.getByTestId("auth-toast");
+ expect(toast).toBeDefined();
+ expect(within(toast).getByText(/Steam login failed/)).toBeDefined();
});
// Advance past the 5s auto-dismiss timeout