From 40fcda7cf7c773e2af9e09253321138d633808c5 Mon Sep 17 00:00:00 2001 From: Snow Lee Date: Wed, 8 Jul 2026 22:55:14 -0700 Subject: [PATCH] =?UTF-8?q?polish(hub):=20round-3=20=E2=80=94=20mobile=20a?= =?UTF-8?q?ctions=20reachable,=20admin=20gating=20settings=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the two round-2 majors. User: on mobile the per-file actions (Share/History/Upload/Download) are now icon buttons in the header — reachable again (round 2 wrongly hid them). Search tolerates simple English plurals (ideas→idea), the no-matches state states what search covers, the sidebar shows the hub brand instead of the raw device name, and history-row downloads carry a download attribute. Logged-out loads redirect to sign-in from /api/config instead of firing 401-ing API calls. Admin: a hub-admin "Signup & access" settings screen (⚙ Admin in the sidebar) toggles email verification and admin approval live — persisted to auth.json and surviving restart — while the domain allowlist and admin list are shown read-only (deliberately server-config-owned so a browser session can't widen access). Pending approvals live on the same screen. Config toggles are now *bool so an explicit config value pins the setting each boot, else the UI-saved policy stands. Invite revoke confirms; role change re-renders the panel. Tests: policy persistence + reload, policy API admin-only. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs --- cmd/bdrive/web.go | 15 +++-- internal/webapp/admin.go | 42 ++++++++++++ internal/webapp/authlocal.go | 28 ++++++++ internal/webapp/gating_test.go | 40 +++++++++++ internal/webapp/server.go | 2 + internal/webapp/static/app.js | 110 ++++++++++++++++++++++++++++--- internal/webapp/static/style.css | 19 ++++-- 7 files changed, 237 insertions(+), 19 deletions(-) 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 = `

Signup & access

+

Who can create an account on this hub, and how new accounts are vetted.

`; + const panel = box.querySelector(".admin"); + + el(panel, "h3", null, "New-account vetting"); + const toggles = el(panel, "div", "admin-list"); + const mkToggle = (label, desc, key, on) => { + const row = el(toggles, "label", "admin-item toggle"); + const left = el(row, "span", "ai-main"); + el(left, "div", "tg-label", label); + el(left, "div", "tg-desc", desc); + const cb = document.createElement("input"); + cb.type = "checkbox"; cb.checked = !!on; cb.dataset.key = key; + row.appendChild(cb); + return cb; + }; + const ver = mkToggle("Require email verification", + pol.mailer ? "New accounts must click an emailed link before they can sign in." : + "New accounts must verify via a link (no mailer configured — the link is written to the server log).", + "require_verification", pol.require_verification); + const app = mkToggle("Require admin approval", + "New accounts wait for a hub admin to approve them before they gain access.", + "require_approval", pol.require_approval); + const save = el(panel, "button", "pbtn", "Save policy"); + save.style.marginTop = "14px"; + save.onclick = async () => { + try { + await postJSON("api/admin/policy", { require_verification: ver.checked, require_approval: app.checked }); + toast("Signup policy saved."); + } catch (e) { toast(e.message, true); } + }; + + el(panel, "h3", null, "Who can sign up"); + const info = el(panel, "div", "admin-list"); + const dom = el(info, "div", "admin-item"); + el(dom, "span", "ai-main", "Allowed email domains"); + el(dom, "span", "ai-tag", (pol.allowed_domains && pol.allowed_domains.length) ? pol.allowed_domains.map((d) => "@" + d).join(", ") : "any (open signup)"); + const sg = el(info, "div", "admin-item"); + el(sg, "span", "ai-main", "Self-signup"); + el(sg, "span", "ai-tag", pol.allow_signup ? "open" : "closed"); + const ad = el(info, "div", "admin-item"); + el(ad, "span", "ai-main", "Hub admins"); + el(ad, "span", "ai-tag", (pol.admins && pol.admins.length) ? pol.admins.join(", ") : "none"); + el(panel, "p", "admin-sub", "Domains and admins are set in the server config file (they can't be widened from the browser)."); + + // Pending approvals live here too, so this is the single admin home. + el(panel, "h3", null, "Pending signups"); + const plist = el(panel, "div", "admin-list"); + let pending = []; + try { pending = (await getJSON("api/admin/pending")).pending || []; } catch { } + if (!pending.length) el(plist, "div", "admin-empty", "No one is waiting for approval."); + for (const u of pending) { + const row = el(plist, "div", "admin-item"); + el(row, "span", "ai-main", (u.name ? u.name + " · " : "") + u.email); + const ok = el(row, "button", "pbtn", "Approve"); + ok.onclick = async () => { try { await postJSON("api/admin/pending/" + u.id + "/approve"); toast("Approved " + u.email); updateAdminBar(); showHubSettings(); } catch (e) { toast(e.message, true); } }; + const no = el(row, "button", "ai-del", "Deny"); + no.onclick = async () => { try { await postJSON("api/admin/pending/" + u.id + "/deny"); toast("Denied " + u.email); updateAdminBar(); showHubSettings(); } catch (e) { toast(e.message, true); } }; + } } async function showPending() { @@ -749,6 +826,7 @@ async function showHistory(q) { const dl = document.createElement("a"); dl.textContent = "download"; dl.href = view.href + "&download=1"; + dl.setAttribute("download", e.path.split("/").pop()); row.querySelector(".hact").append(view, " ", dl); } const p = e.path; @@ -963,10 +1041,24 @@ function paletteCandidates() { return items; } +/* Match a query against a label, tolerating a simple English plural so + "ideas" still finds idea.md. Tries the raw query first, then a lightly + de-pluralized form (…ies→…y, …es→…, …s→…). */ +function fuzzyStemmed(query, label) { + const m = fuzzy(query, label); + if (m) return m; + const q = query.toLowerCase(); + let stem = null; + if (q.length > 3 && q.endsWith("ies")) stem = q.slice(0, -3) + "y"; + else if (q.length > 3 && q.endsWith("es")) stem = q.slice(0, -2); + else if (q.length > 2 && q.endsWith("s")) stem = q.slice(0, -1); + return stem ? fuzzy(stem, label) : null; +} + function buildPaletteItems(query) { const scored = []; for (const c of paletteCandidates()) { - const m = fuzzy(query, c.label); + const m = fuzzyStemmed(query, c.label); if (m) scored.push({ ...c, score: m.score, hits: m.hits }); } scored.sort((a, b) => b.score - a.score); @@ -981,7 +1073,7 @@ function renderPalette() { if (paletteItems.length === 0) { const li = document.createElement("li"); li.className = "pempty"; - li.textContent = "No matches"; + li.textContent = "No matches — search covers file names, projects, and actions"; ul.appendChild(li); return; } diff --git a/internal/webapp/static/style.css b/internal/webapp/static/style.css index 2ff063b..35bc35c 100644 --- a/internal/webapp/static/style.css +++ b/internal/webapp/static/style.css @@ -269,6 +269,11 @@ body { .ai-del { color: #ff9b91; } .ai-del:hover { background: #4a2420; color: #ff7b72; } .admin-empty { padding: 14px 12px; color: var(--text-faint); font-size: 13px; } +.admin-sub { color: var(--text-dim); font-size: 13.5px; margin: -8px 0 4px; } +.admin-item.toggle { cursor: pointer; } +.admin-item.toggle input { width: 16px; height: 16px; accent-color: var(--accent); } +.tg-label { font-size: 13.5px; color: var(--text); } +.tg-desc { font-size: 12px; color: var(--text-faint); margin-top: 2px; } /* ---- modal ---- */ .modal-back { position: fixed; inset: 0; background: rgba(0,0,0,.5); display: flex; align-items: center; justify-content: center; z-index: 150; padding: 20px; } @@ -296,12 +301,14 @@ body { #topbar { padding: 8px 14px; gap: 8px; } #crumb { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #search-btn kbd { display: none; } - #search-btn { font-size: 0; gap: 0; padding: 4px 9px; } - #search-btn::before { content: "🔍"; font-size: 14px; } - /* The per-file action buttons overflow a phone header; on mobile they - live in the ⌘K palette instead (which offers all of them), so the bar - stays to Menu · crumb · Search. */ - #share-btn, #history-btn, #upload-btn, #download { display: none !important; } + /* On a phone the header shrinks every control to an icon so all the + per-file actions stay reachable (the R2 mistake was hiding them). */ + #topbar .btn { font-size: 0; gap: 0; padding: 5px 8px; } + #search-btn::before { content: "🔍"; font-size: 15px; } + #share-btn::before { content: "↗"; font-size: 15px; } + #history-btn::before { content: "🕘"; font-size: 14px; } + #upload-btn::before { content: "⬆"; font-size: 14px; } + #download::before { content: "⬇"; font-size: 14px; } #meta { display: none; } .markdown, .admin, .onboard, .history { max-width: 100%; } .markdown table, pre.plain { display: block; overflow-x: auto; max-width: 100%; }