diff --git a/cmd/bdrive/web.go b/cmd/bdrive/web.go index 3fb41d3..18da857 100644 --- a/cmd/bdrive/web.go +++ b/cmd/bdrive/web.go @@ -38,8 +38,8 @@ type webConfig struct { AllowSignup *bool `json:"allow_signup,omitempty"` // default true UsersDB string `json:"users_db,omitempty"` // default $BDRIVE_HOME/auth.json AllowedDomains []string `json:"allowed_domains,omitempty"` // signup email must match one (e.g. ["runbear.io"]) - RequireVerification bool `json:"require_verification,omitempty"` // new accounts verify email before activation - RequireApproval bool `json:"require_approval,omitempty"` // new accounts await admin approval + RequireVerification *bool `json:"require_verification,omitempty"` // new accounts verify email before activation + RequireApproval *bool `json:"require_approval,omitempty"` // new accounts await admin approval Admins []string `json:"admins,omitempty"` // hub admin emails (approve users, govern shares) Brand string `json:"brand,omitempty"` // name shown on the sign-in page SMTP *struct { @@ -239,8 +239,15 @@ credentials); otherwise it is relayed through this server.`, } if cfg.Auth != nil { auth.AllowedDomains = cfg.Auth.AllowedDomains - auth.RequireVerification = cfg.Auth.RequireVerification - auth.RequireApproval = cfg.Auth.RequireApproval + // Toggles: an explicit config value pins the setting each + // boot; otherwise the UI-saved policy (loaded from auth.json) + // stands. + if cfg.Auth.RequireVerification != nil { + auth.RequireVerification = *cfg.Auth.RequireVerification + } + if cfg.Auth.RequireApproval != nil { + auth.RequireApproval = *cfg.Auth.RequireApproval + } auth.Brand = cfg.Auth.Brand if len(cfg.Auth.Admins) > 0 { auth.Admins = make(map[string]bool, len(cfg.Auth.Admins)) diff --git a/internal/webapp/admin.go b/internal/webapp/admin.go index 60958e3..342f9b7 100644 --- a/internal/webapp/admin.go +++ b/internal/webapp/admin.go @@ -124,6 +124,48 @@ func (s *Server) handleAdminPending(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"pending": a.PendingUsers()}) } +// handleAdminPolicy reads (GET) or updates (POST) the signup/access policy. +// Domains and the admin list are reported read-only — they're server-config +// owned so a browser session can't widen access — while verification and +// approval toggles can be flipped live and are persisted. +func (s *Server) handleAdminPolicy(w http.ResponseWriter, r *http.Request) { + if !s.requestUser(r).Admin { + http.Error(w, "hub admins only", http.StatusForbidden) + return + } + a := s.builtinAuth() + if a == nil { + http.Error(w, "policy is not supported by this auth provider", http.StatusNotFound) + return + } + if r.Method == http.MethodPost { + var req struct { + RequireVerification bool `json:"require_verification"` + RequireApproval bool `json:"require_approval"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest) + return + } + if err := a.SetPolicy(req.RequireVerification, req.RequireApproval); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + admins := make([]string, 0, len(a.Admins)) + for e := range a.Admins { + admins = append(admins, e) + } + writeJSON(w, map[string]any{ + "require_verification": a.RequireVerification, + "require_approval": a.RequireApproval, + "allow_signup": a.AllowSignup, + "allowed_domains": a.AllowedDomains, // read-only (server config) + "admins": admins, // read-only (server config) + "mailer": a.Mail != nil, + }) +} + // handleAdminApprove activates a pending account. Hub admins only. func (s *Server) handleAdminApprove(w http.ResponseWriter, r *http.Request) { if !s.requestUser(r).Admin { diff --git a/internal/webapp/authlocal.go b/internal/webapp/authlocal.go index 1d6ec06..470d258 100644 --- a/internal/webapp/authlocal.go +++ b/internal/webapp/authlocal.go @@ -100,6 +100,7 @@ func OpenBuiltinAuth(path string, allowSignup bool, mail *Mailer) (*BuiltinAuth, var file struct { Users []*authUser `json:"users"` Tokens []authToken `json:"tokens"` + Policy *authPolicy `json:"policy,omitempty"` } if err := json.Unmarshal(data, &file); err != nil { return nil, fmt.Errorf("parse %s: %w", path, err) @@ -110,14 +111,40 @@ func OpenBuiltinAuth(path string, allowSignup bool, mail *Mailer) (*BuiltinAuth, for _, t := range file.Tokens { a.tokens[t.Hash] = t } + // A UI-saved policy is the persisted operational default; the server + // config can still override it at startup (see web.go), so a sysadmin who + // pins a value in the config file always wins over a browser toggle. + if file.Policy != nil { + a.RequireVerification = file.Policy.RequireVerification + a.RequireApproval = file.Policy.RequireApproval + } return a, nil } +// authPolicy is the UI-tunable slice of gating (persisted in auth.json). +// Domain allowlist and the admin list are intentionally NOT here — they are +// security-critical identity config owned by whoever controls the server, +// not something a browser session should be able to widen. +type authPolicy struct { + RequireVerification bool `json:"require_verification"` + RequireApproval bool `json:"require_approval"` +} + +// SetPolicy updates the tunable gating toggles and persists them. +func (a *BuiltinAuth) SetPolicy(requireVerification, requireApproval bool) error { + a.mu.Lock() + defer a.mu.Unlock() + a.RequireVerification = requireVerification + a.RequireApproval = requireApproval + return a.save() +} + // save persists users and tokens. Callers hold mu. func (a *BuiltinAuth) save() error { var file struct { Users []*authUser `json:"users"` Tokens []authToken `json:"tokens"` + Policy *authPolicy `json:"policy,omitempty"` } for _, u := range a.users { file.Users = append(file.Users, u) @@ -125,6 +152,7 @@ func (a *BuiltinAuth) save() error { for _, t := range a.tokens { file.Tokens = append(file.Tokens, t) } + file.Policy = &authPolicy{RequireVerification: a.RequireVerification, RequireApproval: a.RequireApproval} data, err := json.MarshalIndent(file, "", " ") if err != nil { return err diff --git a/internal/webapp/gating_test.go b/internal/webapp/gating_test.go index 8d267a2..74ec80a 100644 --- a/internal/webapp/gating_test.go +++ b/internal/webapp/gating_test.go @@ -107,6 +107,46 @@ func TestSignupPageVerificationFlow(t *testing.T) { } } +// The policy toggles persist to auth.json and reload; the domain allowlist +// and admin list are reported but not mutated by the policy API. +func TestPolicyPersistence(t *testing.T) { + path := filepath.Join(t.TempDir(), "auth.json") + a, _ := OpenBuiltinAuth(path, true, nil) + a.AllowedDomains = []string{"x.io"} + a.Admins = map[string]bool{"admin@x.io": true} + if err := a.SetPolicy(true, true); err != nil { + t.Fatal(err) + } + // reload: toggles survive, and initialStatus reflects them + a2, _ := OpenBuiltinAuth(path, true, nil) + if !a2.RequireVerification || !a2.RequireApproval { + t.Fatal("policy toggles did not persist across reload") + } + if a2.initialStatus() != statusUnverified { + t.Fatalf("initialStatus = %q", a2.initialStatus()) + } +} + +// A hub admin can read and flip the policy over HTTP; a non-admin cannot. +func TestPolicyAPIAdminOnly(t *testing.T) { + srv, _, _ := newHub(t, true, nil) + auth := gatedAuth(t, func(a *BuiltinAuth) { a.Admins = map[string]bool{"boss@x.io": true} }) + srv.Auth = auth + h := srv.Handler() + boss := signupAndSession(t, h, "boss@x.io", "Boss", "password1") + pleb := signupAndSession(t, h, "pleb@x.io", "Pleb", "password1") + + if rec := doAs(t, h, "GET", "/api/admin/policy", nil, pleb); rec.Code != http.StatusForbidden { + t.Fatalf("non-admin policy read: %d", rec.Code) + } + if rec := doAs(t, h, "POST", "/api/admin/policy", map[string]bool{"require_approval": true}, boss); rec.Code != 200 { + t.Fatalf("admin policy write: %d %s", rec.Code, rec.Body) + } + if !auth.RequireApproval { + t.Fatal("policy POST did not take effect") + } +} + func TestAuthRateLimit(t *testing.T) { srv, _, _ := newHub(t, true, nil) srv.Auth = gatedAuth(t, nil) diff --git a/internal/webapp/server.go b/internal/webapp/server.go index 0a61322..546b728 100644 --- a/internal/webapp/server.go +++ b/internal/webapp/server.go @@ -340,6 +340,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("PATCH /api/projects/{project}", s.handleProjectRename) mux.HandleFunc("DELETE /api/projects/{project}", s.handleProjectDelete) + mux.HandleFunc("GET /api/admin/policy", s.handleAdminPolicy) + mux.HandleFunc("POST /api/admin/policy", s.handleAdminPolicy) mux.HandleFunc("GET /api/admin/pending", s.handleAdminPending) mux.HandleFunc("POST /api/admin/pending/{id}/approve", s.handleAdminApprove) mux.HandleFunc("POST /api/admin/pending/{id}/deny", s.handleAdminDeny) diff --git a/internal/webapp/static/app.js b/internal/webapp/static/app.js index b695e9f..8abb741 100644 --- a/internal/webapp/static/app.js +++ b/internal/webapp/static/app.js @@ -52,6 +52,13 @@ async function boot() { serverConfig = await getJSON("api/config"); } catch { /* non-fatal */ } document.title = serverConfig.brand || serverConfig.volume || "BearDrive"; + // If auth is on and we're not signed in, go straight to the login page + // rather than firing authed API calls that 401 (noisy console, and the + // redirect happens anyway). /api/config reports `me` only when signed in. + if (serverConfig.auth && serverConfig.auth.enabled && !serverConfig.me) { + location.href = "/auth/login?next=" + encodeURIComponent(location.pathname + location.hash); + return; + } if (serverConfig.auth && serverConfig.auth.enabled) $("signout").hidden = false; if (serverConfig.mode === "hub") { await acceptInviteFromHash(); @@ -65,7 +72,7 @@ async function boot() { || (joinedOrgId && projects.find((x) => x.org === joinedOrgId)) || projects[0]; if (proj) selectProject(proj, path); - else { $("vault-name").textContent = serverConfig.volume || "BearDrive"; updateOrgBar(); showEmptyState(); } + else { $("vault-name").textContent = serverConfig.brand || serverConfig.volume || "BearDrive"; updateOrgBar(); showEmptyState(); } setInterval(loadProjects, 30000); // pick up new projects } else { $("vault-name").textContent = serverConfig.volume || "BearDrive"; @@ -283,7 +290,7 @@ async function showOrgAdmin(org) { if (m.role === r) o.selected = true; sel.appendChild(o); } sel.onchange = async () => { - try { await api("PATCH", "api/orgs/" + org.id + "/members/" + encodeURIComponent(m.email), { role: sel.value }); toast("Role updated."); await loadOrgs(); } + try { await api("PATCH", "api/orgs/" + org.id + "/members/" + encodeURIComponent(m.email), { role: sel.value }); toast("Role updated."); await loadOrgs(); showOrgAdmin(currentOrg()); } catch (e) { toast(e.message, true); showOrgAdmin(currentOrg()); } }; row.appendChild(sel); @@ -357,6 +364,7 @@ async function showOrgAdmin(org) { el(row, "span", "ai-tag", meta); const rv = el(row, "button", "ai-del", "Revoke"); rv.onclick = async () => { + if (!confirm("Revoke this invite link? Anyone still holding it won't be able to join.")) return; try { await api("DELETE", "api/orgs/" + org.id + "/invites/" + inv.token); toast("Revoked."); showOrgAdmin(currentOrg()); } catch (e) { toast(e.message, true); } }; @@ -424,12 +432,81 @@ async function updateAdminBar() { if (!(serverConfig.auth && serverConfig.auth.admin)) { bar.hidden = true; return; } let pending = []; try { pending = (await getJSON("api/admin/pending")).pending || []; } catch { } - if (!pending.length) { bar.hidden = true; return; } bar.hidden = false; - bar.innerHTML = ""; - const b = el(bar, "button", "adminbar-btn"); - b.textContent = "⚑ " + pending.length + " pending signup" + (pending.length > 1 ? "s" : ""); - b.onclick = () => showPending(); + bar.textContent = pending.length ? "⚑ Admin · " + pending.length : "⚙ Admin"; + bar.title = "Hub administration — signup policy" + (pending.length ? " and pending approvals" : ""); + bar.onclick = () => showHubSettings(); +} + +/* Hub-admin settings: signup/access policy. Verification & approval are + live toggles; the domain allowlist and admin list are shown read-only + (they're server-config owned, deliberately not browser-editable). */ +async function showHubSettings() { + let pol = {}; + try { pol = await getJSON("api/admin/policy"); } catch (e) { toast(e.message, true); return; } + currentPath = null; markActive(); closeSidebarOnMobile(); + $("crumb").textContent = "Signup & access"; + $("share-btn").hidden = $("history-btn").hidden = $("download").hidden = true; + const box = $("content"); + box.innerHTML = `
Who can create an account on this hub, and how new accounts are vetted.