diff --git a/internal/webapp/authlocal.go b/internal/webapp/authlocal.go index e23a2d9..1d6ec06 100644 --- a/internal/webapp/authlocal.go +++ b/internal/webapp/authlocal.go @@ -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 `

You've been invited to a team. Sign in (or sign up) to accept.

` +} + // 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 = `

` + html.EscapeString(a.Brand) + `

` } - authPage(w, "Sign in", brand+fmt.Sprintf(`
%s%s%s
+ authPage(w, "Sign in", brand+inviteBanner(next)+fmt.Sprintf(`
%s%s%s
%s

Forgot password?

`, 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 = `

` + html.EscapeString(err.Error()) + `

` } - authPage(w, "Create account", fmt.Sprintf(`
%s%s%s%s
+ // 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 = `

Only ` + html.EscapeString(a.domainList()) + ` email addresses can sign up here.

` + } + brand := "" + if a.Brand != "" { + brand = `

` + html.EscapeString(a.Brand) + `

` + } + authPage(w, "Create account", brand+inviteBanner(next)+fmt.Sprintf(`
%s%s%s%s%s

Have an account? Sign in

`, 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))) } diff --git a/internal/webapp/lifecycle_test.go b/internal/webapp/lifecycle_test.go index fd85751..3c48590 100644 --- a/internal/webapp/lifecycle_test.go +++ b/internal/webapp/lifecycle_test.go @@ -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") + } +} diff --git a/internal/webapp/orgs.go b/internal/webapp/orgs.go index 046fc9d..222ba67 100644 --- a/internal/webapp/orgs.go +++ b/internal/webapp/orgs.go @@ -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}}) } diff --git a/internal/webapp/server.go b/internal/webapp/server.go index d5dbe41..0a61322 100644 --- a/internal/webapp/server.go +++ b/internal/webapp/server.go @@ -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) { diff --git a/internal/webapp/shares.go b/internal/webapp/shares.go index e519167..3a1242c 100644 --- a/internal/webapp/shares.go +++ b/internal/webapp/shares.go @@ -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}} diff --git a/internal/webapp/static/app.js b/internal/webapp/static/app.js index 4c46285..b695e9f 100644 --- a/internal/webapp/static/app.js +++ b/internal/webapp/static/app.js @@ -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 = `
Select a file from the sidebar
`; + $("content").innerHTML = `
Select a file to read it.
On a phone, tap ☰ to browse.
`; 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 = ` + `; + 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); } }; } diff --git a/internal/webapp/static/index.html b/internal/webapp/static/index.html index 376432d..fcc5541 100644 --- a/internal/webapp/static/index.html +++ b/internal/webapp/static/index.html @@ -14,7 +14,8 @@ …
- + +
@@ -37,12 +38,12 @@
-
Select a file from the sidebar
+
Select a file to read it.