polish(hub): round-2 UX — close the gap to the usability bar

Admin: top-level ⚙ settings entry (owners/admins), signup form states the
domain restriction up front, invite list shows creator + join count,
org-wide share audit shows creator/date and confirms before revoke,
self-row role/remove controls disabled to avoid footguns.

User: mobile header no longer overflows (per-file actions move to the ⌘K
palette on narrow viewports; tables/pre scroll in their own container),
empty-state copy works on mobile, share confirmation is now an explicit
"anyone with this link can view" dialog with copy/open/revoke, invite
links carry a "you've been invited" banner through login, joining opens
the joined project, brand shown as the title, logout labeled, palette
placeholder clarified to "file names".

Tests: invite use-counter + creator in the owner list.

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:39:07 -07:00
co-authored by Claude Fable 5
parent baad9e6dc2
commit e022998c9f
8 changed files with 169 additions and 27 deletions
+22 -2
View File
@@ -491,6 +491,15 @@ func (a *BuiltinAuth) startSession(w http.ResponseWriter, userID string) error {
return nil
}
// inviteBanner shows an invitation cue when the post-login destination is a
// join link, so a visitor who clicked an invite knows why they're here.
func inviteBanner(next string) string {
if !strings.Contains(next, "join/") && !strings.Contains(next, "join%2F") {
return ""
}
return `<p class="msg" style="margin:0 0 14px">You've been invited to a team. Sign in (or sign up) to accept.</p>`
}
// safeNext keeps post-login redirects on this site.
func safeNext(next string) string {
if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
@@ -565,7 +574,7 @@ func (a *BuiltinAuth) pageLogin(w http.ResponseWriter, r *http.Request) {
if a.Brand != "" {
brand = `<p class="alt" style="margin:0 0 14px;color:#aaa">` + html.EscapeString(a.Brand) + `</p>`
}
authPage(w, "Sign in", brand+fmt.Sprintf(`<form method="post" action="/auth/login?next=%s">%s%s%s<button>Sign in</button></form>
authPage(w, "Sign in", brand+inviteBanner(next)+fmt.Sprintf(`<form method="post" action="/auth/login?next=%s">%s%s%s<button>Sign in</button></form>
%s<p class="alt"><a href="/auth/reset">Forgot password?</a></p>`,
url.QueryEscape(next),
field("Email", "email", "email", r.FormValue("email")),
@@ -604,11 +613,22 @@ func (a *BuiltinAuth) pageSignup(w http.ResponseWriter, r *http.Request) {
}
errMsg = `<p class="err">` + html.EscapeString(err.Error()) + `</p>`
}
authPage(w, "Create account", fmt.Sprintf(`<form method="post" action="/auth/signup?next=%s">%s%s%s%s<button>Sign up</button></form>
// State the domain restriction up front, where the stranger types their
// email — not only after a rejected submit.
domainNote := ""
if len(a.AllowedDomains) > 0 {
domainNote = `<p class="alt" style="margin:2px 0 0">Only ` + html.EscapeString(a.domainList()) + ` email addresses can sign up here.</p>`
}
brand := ""
if a.Brand != "" {
brand = `<p class="alt" style="margin:0 0 14px;color:#aaa">` + html.EscapeString(a.Brand) + `</p>`
}
authPage(w, "Create account", brand+inviteBanner(next)+fmt.Sprintf(`<form method="post" action="/auth/signup?next=%s">%s%s%s%s%s<button>Sign up</button></form>
<p class="alt">Have an account? <a href="/auth/login?next=%s">Sign in</a></p>`,
url.QueryEscape(next),
field("Name", "name", "text", r.FormValue("name")),
field("Email", "email", "email", r.FormValue("email")),
domainNote,
field("Password (min 8 chars)", "password", "password", ""),
errMsg, url.QueryEscape(next)))
}
+25
View File
@@ -132,3 +132,28 @@ func TestMemberManagementHTTP(t *testing.T) {
}
}
}
// A joined invite bumps its use counter, visible in the owner's invite list.
func TestInviteUseCounter(t *testing.T) {
h, _, alice, bob, pa := orgHubSrv(t)
rec := doAs(t, h, "POST", "/api/orgs/"+pa.Org+"/invites", nil, alice)
var inv struct{ Token string }
mustJSON(t, rec, &inv)
doAs(t, h, "POST", "/api/invites/"+inv.Token, nil, bob)
rec = doAs(t, h, "GET", "/api/orgs/"+pa.Org+"/invites", nil, alice)
var out struct {
Invites []struct {
Token string `json:"token"`
Uses int `json:"uses"`
Creator string `json:"creator"`
} `json:"invites"`
}
mustJSON(t, rec, &out)
if len(out.Invites) != 1 || out.Invites[0].Uses != 1 {
t.Fatalf("invite uses = %+v, want 1 join recorded", out.Invites)
}
if out.Invites[0].Creator == "" {
t.Fatal("invite list should carry the creator")
}
}
+17 -1
View File
@@ -43,6 +43,18 @@ type OrgInvite struct {
Creator string `json:"creator,omitempty"` // account email
Created time.Time `json:"created"`
Expires time.Time `json:"expires"`
Uses int `json:"uses"` // how many accounts have joined via this link
}
// RecordInviteUse bumps the join counter for an invite (best effort).
func (db *OrgDB) RecordInviteUse(token string) {
db.mu.Lock()
defer db.mu.Unlock()
if inv, ok := db.invites[token]; ok {
inv.Uses++
db.invites[token] = inv
db.save()
}
}
func (i OrgInvite) expired() bool { return time.Now().After(i.Expires) }
@@ -500,7 +512,7 @@ func (s *Server) handleInviteList(w http.ResponseWriter, r *http.Request) {
for _, inv := range invs {
out = append(out, map[string]any{
"token": inv.Token, "url": requestBaseURL(r) + "/#join/" + inv.Token,
"creator": inv.Creator, "created": inv.Created, "expires": inv.Expires,
"creator": inv.Creator, "created": inv.Created, "expires": inv.Expires, "uses": inv.Uses,
})
}
writeJSON(w, map[string]any{"invites": out})
@@ -581,9 +593,13 @@ func (s *Server) handleInviteAccept(w http.ResponseWriter, r *http.Request) {
return
}
}
newMember := org.Members[normEmail(me.Email)] == ""
if err := s.Orgs.AddMember(inv.Org, me.Email, RoleMember); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if newMember {
s.Orgs.RecordInviteUse(r.PathValue("token"))
}
writeJSON(w, map[string]any{"ok": true, "org": map[string]string{"id": org.ID, "name": org.Name}})
}
+14 -3
View File
@@ -380,18 +380,29 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
// Tell the frontend whether self-signup is offered and whether the
// signed-in user is a hub admin, so it can hide the "Sign up" link and
// show the admin surfaces. Never leak more than these booleans.
me := s.requestUser(r)
brand := ""
if a := s.builtinAuth(); a != nil {
auth["allow_signup"] = a.AllowSignup
auth["admin"] = s.requestUser(r).Admin
auth["admin"] = me.Admin
brand = a.Brand
}
writeJSON(w, map[string]any{
if brand == "" {
brand = s.Volume
}
out := map[string]any{
"mode": mode,
"volume": s.Volume,
"brand": brand,
"upload": map[string]any{
"enabled": s.Upload.Enabled,
},
"auth": auth,
})
}
if me.Email != "" {
out["me"] = map[string]string{"email": me.Email, "name": me.Name}
}
writeJSON(w, out)
}
func (s *Server) handleProjectList(w http.ResponseWriter, r *http.Request) {
+2 -1
View File
@@ -339,7 +339,8 @@ code{background:#f6f8fa;padding:2px 5px;border-radius:4px;font-size:.9em}
pre code{padding:0;background:none}
img{max-width:100%%}
blockquote{margin:0;padding-left:16px;border-left:3px solid #d0d7de;color:#57606a}
table{border-collapse:collapse}td,th{border:1px solid #d0d7de;padding:5px 10px}
table{border-collapse:collapse;display:block;overflow-x:auto;max-width:100%%}td,th{border:1px solid #d0d7de;padding:5px 10px}
pre{max-width:100%%}
footer.bdrive{margin-top:64px;padding-top:14px;border-top:1px solid #d0d7de;font-size:12.5px;color:#57606a}
footer.bdrive a{color:inherit}
@media (prefers-color-scheme: dark){footer.bdrive{border-color:#3a3a44;color:#888}}
+68 -17
View File
@@ -18,6 +18,7 @@ let projects = [];
let currentProject = null; // hub mode: the selected project
let apiBase = "api/"; // volume-scoped endpoint prefix
let orgs = []; // hub mode: the orgs this account belongs to
let joinedOrgId = null; // org just joined via an invite this page-load
const fileURL = (p) => apiBase + "file?path=" + encodeURIComponent(p);
@@ -50,7 +51,7 @@ async function boot() {
try {
serverConfig = await getJSON("api/config");
} catch { /* non-fatal */ }
document.title = (serverConfig.volume || "beardrive") + " — BearDrive";
document.title = serverConfig.brand || serverConfig.volume || "BearDrive";
if (serverConfig.auth && serverConfig.auth.enabled) $("signout").hidden = false;
if (serverConfig.mode === "hub") {
await acceptInviteFromHash();
@@ -58,9 +59,13 @@ async function boot() {
await loadProjects();
updateAdminBar();
const { project, path } = parseHash();
const proj = projects.find((x) => x.id === project) || projects[0];
// After accepting an invite, open a project in the org you just joined
// rather than whatever happened to be first.
const proj = projects.find((x) => x.id === project)
|| (joinedOrgId && projects.find((x) => x.org === joinedOrgId))
|| projects[0];
if (proj) selectProject(proj, path);
else { $("vault-name").textContent = serverConfig.volume || "BearDrive"; showEmptyState(); }
else { $("vault-name").textContent = serverConfig.volume || "BearDrive"; updateOrgBar(); showEmptyState(); }
setInterval(loadProjects, 30000); // pick up new projects
} else {
$("vault-name").textContent = serverConfig.volume || "BearDrive";
@@ -117,7 +122,7 @@ function selectProject(p, path) {
$("crumb").textContent = "";
$("meta").textContent = "";
$("download").hidden = true;
$("content").innerHTML = `<div class="empty">Select a file from the sidebar</div>`;
$("content").innerHTML = `<div class="empty">Select a file to read it.<br><span class="empty-hint">On a phone, tap ☰ to browse.</span></div>`;
loadProjects(); // refresh active highlight
updateOrgBar();
initUpload();
@@ -139,6 +144,7 @@ async function acceptInviteFromHash() {
try {
const out = await postJSON("api/invites/" + m[1]); // may redirect to login (401)
location.hash = "";
joinedOrgId = out.org && out.org.id;
toast("Welcome — you joined “" + out.org.name + "”.");
} catch (e) {
if (String(e.message).includes("signing in")) throw e; // redirecting; stop boot
@@ -214,6 +220,16 @@ function currentOrg() {
and owners get an Invite button that mints a join link. */
function updateOrgBar() {
const bar = $("orgbar"), org = currentOrg();
// The top-of-sidebar gear is the always-visible admin entry point: any
// account that owns an org (or is a hub admin) gets it, whatever project
// is open.
const owned = orgs.find((o) => o.role === "owner");
const gear = $("settings-btn");
if (gear) {
const target = (org && org.role === "owner") ? org : owned;
gear.hidden = !target;
gear.onclick = () => showOrgAdmin(target);
}
if (!org) { bar.hidden = true; return; }
bar.hidden = false;
const nm = $("org-name");
@@ -255,10 +271,12 @@ async function showOrgAdmin(org) {
// Members
el(panel, "h3", null, "Members");
const mlist = el(panel, "div", "admin-list");
const myEmail = (serverConfig.me && serverConfig.me.email) || "";
for (const m of org.members) {
const row = el(mlist, "div", "admin-item");
el(row, "span", "ai-main", m.email);
if (owner) {
const isSelf = myEmail && m.email.toLowerCase() === myEmail.toLowerCase();
el(row, "span", "ai-main", m.email + (isSelf ? " (you)" : ""));
if (owner && !isSelf) {
const sel = document.createElement("select");
for (const r of ["owner", "member"]) {
const o = document.createElement("option"); o.value = r; o.textContent = r;
@@ -333,7 +351,10 @@ async function showOrgAdmin(org) {
main.style.cursor = "pointer";
main.title = "Copy";
main.onclick = () => { navigator.clipboard.writeText(inv.url).then(() => toast("Copied.")); };
el(row, "span", "ai-tag", "expires " + new Date(inv.expires).toLocaleDateString());
const meta = (inv.creator ? "by " + inv.creator + " · " : "") +
(inv.uses ? inv.uses + " joined · " : "unused · ") +
"expires " + new Date(inv.expires).toLocaleDateString();
el(row, "span", "ai-tag", meta);
const rv = el(row, "button", "ai-del", "Revoke");
rv.onclick = async () => {
try { await api("DELETE", "api/orgs/" + org.id + "/invites/" + inv.token); toast("Revoked."); showOrgAdmin(currentOrg()); }
@@ -353,9 +374,13 @@ async function showOrgAdmin(org) {
const main = el(row, "span", "ai-main mono", sh.path);
main.title = sh.url; main.style.cursor = "pointer";
main.onclick = () => window.open(sh.url, "_blank");
el(row, "span", "ai-tag", sh.project_name || "");
const meta = (sh.project_name || "") +
(sh.creator ? " · by " + sh.creator : "") +
(sh.created ? " · " + new Date(sh.created).toLocaleDateString() : "");
el(row, "span", "ai-tag", meta);
const rv = el(row, "button", "ai-del", "Revoke");
rv.onclick = async () => {
if (!confirm("Revoke the public link to “" + sh.path + "”? Anyone with the URL will lose access.")) return;
try { await api("DELETE", "api/shares/" + sh.token); toast("Share revoked."); showOrgAdmin(currentOrg()); }
catch (e) { toast(e.message, true); }
};
@@ -597,6 +622,37 @@ function openWikilink(target) {
if (hit) openFile(hit.path);
}
/* A clear, explicitly-public share confirmation: warns that anyone with the
link can view, and offers copy / open / revoke. */
function showShareDialog(url, copied) {
const back = document.createElement("div");
back.className = "modal-back";
back.innerHTML = `
<div class="modal">
<h3>🔗 Public link created</h3>
<p><b>Anyone with this link can view this file</b> — no account needed. It always shows the latest version until you revoke it.</p>
<div class="modal-url"></div>
<div class="modal-actions">
<button class="pbtn" data-a="copy">${copied ? "Copied ✓" : "Copy link"}</button>
<button class="ai-btn" data-a="open">Open</button>
<button class="ai-del" data-a="revoke">Revoke</button>
<button class="ai-btn" data-a="close">Done</button>
</div>
</div>`;
back.querySelector(".modal-url").textContent = url;
const close = () => back.remove();
back.onclick = (e) => { if (e.target === back) close(); };
const token = url.split("/s/")[1];
back.querySelector('[data-a="copy"]').onclick = () => navigator.clipboard.writeText(url).then(() => toast("Copied."));
back.querySelector('[data-a="open"]').onclick = () => window.open(url, "_blank");
back.querySelector('[data-a="close"]').onclick = close;
back.querySelector('[data-a="revoke"]').onclick = async () => {
try { await api("DELETE", "api/shares/" + token); toast("Link revoked — it no longer works."); close(); }
catch (e) { toast(e.message, true); }
};
document.body.appendChild(back);
}
function join(dir, rel) {
const parts = (dir ? dir.split("/") : []).concat(rel.split("/"));
const out = [];
@@ -623,18 +679,13 @@ function updateShareButton() {
});
if (!r.ok) throw new Error(await r.text());
const share = await r.json();
let copied = "";
let copied = false;
if (navigator.clipboard) {
try { await navigator.clipboard.writeText(share.url); copied = " (copied)"; } catch { /* http origin */ }
try { await navigator.clipboard.writeText(share.url); copied = true; } catch { /* http origin */ }
}
$("meta").innerHTML = "";
const a = document.createElement("a");
a.href = share.url;
a.target = "_blank";
a.textContent = share.url;
$("meta").append("public link: ", a, copied);
showShareDialog(share.url, copied);
} catch (err) {
$("meta").textContent = "Share failed: " + err.message;
toast("Share failed: " + err.message, true);
}
};
}
+4 -3
View File
@@ -14,7 +14,8 @@
<span id="vault-name">…</span>
<div class="vault-actions">
<button id="adminbar" class="adminbar" hidden></button>
<a id="signout" href="/auth/logout" hidden title="Sign out">⏻</a>
<button id="settings-btn" class="icon-btn2" hidden title="Manage organization">⚙</button>
<a id="signout" href="/auth/logout" hidden title="Sign out" aria-label="Sign out">⏻</a>
</div>
</header>
<nav id="projects" aria-label="Projects" hidden></nav>
@@ -37,12 +38,12 @@
<a id="download" class="btn" hidden download>Download</a>
</header>
<article id="content" class="markdown">
<div class="empty">Select a file from the sidebar</div>
<div class="empty">Select a file to read it.</div>
</article>
</main>
<div id="palette-overlay" hidden>
<div id="palette" role="dialog" aria-label="Search and quick actions">
<input id="palette-input" type="text" placeholder="Search files, projects, actions…"
<input id="palette-input" type="text" placeholder="Search file names, projects, actions…"
autocomplete="off" spellcheck="false">
<ul id="palette-results"></ul>
<footer id="palette-hint">↑↓ navigate · ⏎ select · esc close</footer>
+17
View File
@@ -152,6 +152,7 @@ body {
.hact { margin-left: auto; }
.hact a { color: var(--accent); text-decoration: none; margin-left: 8px; }
.empty { color: var(--text-faint); text-align: center; margin-top: 20vh; }
.empty-hint { font-size: 12px; color: var(--text-faint); }
/* ---- command palette (⌘K) ---- */
#palette-overlay[hidden] { display: none; }
@@ -224,6 +225,8 @@ body {
.vault-actions { display: flex; align-items: center; gap: 8px; }
.adminbar { border: none; cursor: pointer; font: inherit; font-size: 11.5px; padding: 2px 8px; border-radius: 5px; background: #4a3a12; color: #f5c451; }
.adminbar:hover { background: #5c4816; }
.icon-btn2 { border: none; background: transparent; color: var(--text-faint); font-size: 15px; cursor: pointer; padding: 0 2px; line-height: 1; }
.icon-btn2:hover { color: var(--text); }
/* ---- topbar controls ---- */
.icon-btn { display: none; border: none; background: transparent; color: var(--text-dim); font-size: 18px; cursor: pointer; padding: 2px 6px; border-radius: 5px; }
@@ -267,6 +270,14 @@ body {
.ai-del:hover { background: #4a2420; color: #ff7b72; }
.admin-empty { padding: 14px 12px; color: var(--text-faint); font-size: 13px; }
/* ---- 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; }
.modal { background: var(--bg-side); border: 1px solid var(--border); border-radius: 12px; padding: 24px; width: min(460px, 100%); box-shadow: 0 20px 60px rgba(0,0,0,.5); }
.modal h3 { margin: 0 0 10px; font-size: 17px; }
.modal p { margin: 0 0 16px; font-size: 13.5px; color: var(--text-dim); }
.modal-url { font: 12px var(--mono, ui-monospace, Menlo, monospace); background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 9px 11px; color: var(--text-dim); word-break: break-all; margin-bottom: 16px; }
.modal-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
/* ---- toast ---- */
#toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(20px); background: var(--bg-active); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 10px 18px; font-size: 13.5px; box-shadow: 0 8px 30px rgba(0,0,0,.5); opacity: 0; pointer-events: none; transition: opacity .2s, transform .2s; z-index: 200; }
#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
@@ -287,7 +298,13 @@ body {
#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; }
#meta { display: none; }
.markdown, .admin, .onboard, .history { max-width: 100%; }
.markdown table, pre.plain { display: block; overflow-x: auto; max-width: 100%; }
.ob-row { flex-direction: column; }
}