polish(hub): round-3 — mobile actions reachable, admin gating settings UI

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
This commit is contained in:
Snow Lee
2026-07-08 22:55:14 -07:00
co-authored by Claude Fable 5
parent e022998c9f
commit 40fcda7cf7
7 changed files with 237 additions and 19 deletions
+11 -4
View File
@@ -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))
+42
View File
@@ -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 {
+28
View File
@@ -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
+40
View File
@@ -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)
+2
View File
@@ -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)
+101 -9
View File
@@ -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 = `<div class="admin"><h1>Signup &amp; access</h1>
<p class="admin-sub">Who can create an account on this hub, and how new accounts are vetted.</p></div>`;
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 (iesy, 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;
}
+13 -6
View File
@@ -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%; }