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(` + authPage(w, "Sign in", brand+inviteBanner(next)+fmt.Sprintf(` %s`, 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(` + // 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(`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 = `Anyone with this link can view this file — no account needed. It always shows the latest version until you revoke it.
+ +