feat(web): invite-only signup by default; harden signup postures

A hub on a public URL could be signed up to with any fake email. Make the
safe posture the default and turn the dangerous ones into startup errors.

Three supported postures:
- invite-only (NEW default): allow_signup defaults false. A valid org
  invite link bootstraps an account even when self-signup is closed — the
  only way in. pageLogin/pageSignup detect a /join/<token> target via
  BuiltinAuth.InviteValid (wired to OrgDB.ValidInvite) and offer account
  creation; signupInvited skips the domain/approval/verification gates and
  activates immediately (the owner's invite is the vetting).
- approval-gated: allow_signup + require_approval (no SMTP needed).
- domain-restricted + verified: allow_signup + allowed_domains +
  require_verification (needs smtp).

Hardening:
- BuiltinAuth.ValidateSignupPolicy (run at hub startup in web.go) refuses to
  boot an open hub with no gate, or require_verification without a mailer
  (the link would otherwise only reach the server log).
- handleAdminPolicy rejects enabling verification without SMTP; the UI
  toggle is disabled with clearer copy in that case.

Tests: TestValidateSignupPolicy, TestInviteBootstrapsAccountWhenSignupClosed,
TestPolicyVerificationNeedsMailer. Verified end to end in the running hub
(outside-domain invitee onboards via a link; direct signup stays closed).
Docs updated (README, CLAUDE.md, SKILL.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
This commit is contained in:
Snow Lee
2026-07-09 16:57:49 -07:00
co-authored by Claude Opus 4.8
parent fd29352dd5
commit bd7ea16200
10 changed files with 247 additions and 22 deletions
+1 -1
View File
@@ -43,7 +43,7 @@ Package roles (`internal/`):
`cmd/bdrive/` is a thin cobra CLI over these packages (`login`, `init`, `stop`, `sync`, `status`, `log`, `remote`, `web`, `whoami`, `daemon`, `version` — `mnt`/`umnt` are gone; `init` is the front door and `stop` pauses). `bdrive login` signs the device in (bare form uses the remembered server or `config.DefaultServer` = beardrive.ai; loopback-callback browser flow in `login.go`, `--device` for headless) and stores server+token+account in `settings.json`. `bdrive init` is interactive on a TTY (survey menus: create-new vs connect-existing with a project list; whole-folder vs `--shared <dir>`, which becomes the include list) with full flag bypass (`--name/--project/--shared/--yes`) and never prompts without a TTY; it runs the login flow first when there is no session, writes `.bdrive/config.json`, seeds `.bdriveignore`, and starts sync via `startSync`; re-running it resumes — including after a folder move. `bdrive web -c config.json` configures the server from a file, explicit flags winning.
Authentication (`webapp/auth.go`, `authlocal.go`, `mail.go`) is **mandatory in hub mode** — the config's `auth` block only tunes `users_db`/`allow_signup`/`smtp`; the plain-folder viewer stays auth-free — and sits behind the `AuthProvider` interface — the OSS server ships only `BuiltinAuth` (email+password accounts and device tokens in a file-backed `auth.json`; bcrypt for passwords, SHA-256 digests for tokens, plaintext never stored; server-owned `/auth/*` pages; one-time codes for the CLI callback and device flows; SMTP reset mail with a log-link fallback). A managed deployment can swap in a different provider (e.g. PropelAuth) without touching the CLI or API — keep provider-specific code out of this repo. The sync client picks up its token from `BDRIVE_TOKEN` or `settings.json` and sends `X-Bdrive-Device{,-Name,-Os}` headers (`remote/http.go`); the hub's file-backed device registry (`webapp/devices.go`) records per-device name/OS/account/server-observed IP. Journal ops carry the signed-in account (`Op.User`/`UserName` from `Session.Account`; `Author` remains the git/OS fallback). History (`webapp/history.go`): `GET /api/p/<id>/history?path=|prefix=` (newest first, device-registry join) and `GET /api/p/<id>/blob?sha=` stream any exact version — blobs are retained forever, so the future revert phase is just re-putting an old blob as a new op. Share links (`webapp/shares.go`, file-backed `shares.json`): any signed-in member mints `/s/<token>` public URLs (`bdrive share`, or the UI's Share button) serving the file's LATEST content until revoked (optional expiry); `/s/*` responses are sandboxed (CSP `sandbox allow-scripts`, no auth cookies) so shared HTML can't attack hub sessions — keep that header on any change; `/s/*` also sits behind a per-IP token bucket (`ratelimit.go`, `share_rpm` config), and markdown share pages get a "Shared with BearDrive" footer (raw HTML is never injected into).
Authentication (`webapp/auth.go`, `authlocal.go`, `mail.go`) is **mandatory in hub mode** — the config's `auth` block tunes `users_db`/`allow_signup`/`allowed_domains`/`require_verification`/`require_approval`/`admins`/`smtp`; the plain-folder viewer stays auth-free — and sits behind the `AuthProvider` interface — the OSS server ships only `BuiltinAuth` (email+password accounts and device tokens in a file-backed `auth.json`; bcrypt for passwords, SHA-256 digests for tokens, plaintext never stored; server-owned `/auth/*` pages; one-time codes for the CLI callback and device flows; SMTP reset mail with a log-link fallback). **Signup is invite-only by default** (`allow_signup` defaults false): a valid org invite bootstraps an account even when self-signup is closed — `BuiltinAuth.InviteValid` (wired to `OrgDB.ValidInvite`) lets `pageSignup`/`pageLogin` offer account creation for a `/join/<token>` target, and `signupInvited` skips the domain/verification/approval gates and activates immediately (the invite is the vetting). `BuiltinAuth.ValidateSignupPolicy` (called at hub startup, `web.go`) refuses an ungated open hub and email-verification-without-SMTP rather than silently leaving the door open. The three postures: invite-only (default), approval-gated (`require_approval`), and domain-restricted+verified (`allowed_domains`+`require_verification`+`smtp`); `allow_signup`/`allowed_domains`/`admins` stay server-config-owned so a browser session can't widen access. A managed deployment can swap in a different provider (e.g. PropelAuth) without touching the CLI or API — keep provider-specific code out of this repo. The sync client picks up its token from `BDRIVE_TOKEN` or `settings.json` and sends `X-Bdrive-Device{,-Name,-Os}` headers (`remote/http.go`); the hub's file-backed device registry (`webapp/devices.go`) records per-device name/OS/account/server-observed IP. Journal ops carry the signed-in account (`Op.User`/`UserName` from `Session.Account`; `Author` remains the git/OS fallback). History (`webapp/history.go`): `GET /api/p/<id>/history?path=|prefix=` (newest first, device-registry join) and `GET /api/p/<id>/blob?sha=` stream any exact version — blobs are retained forever, so the future revert phase is just re-putting an old blob as a new op. Share links (`webapp/shares.go`, file-backed `shares.json`): any signed-in member mints `/s/<token>` public URLs (`bdrive share`, or the UI's Share button) serving the file's LATEST content until revoked (optional expiry); `/s/*` responses are sandboxed (CSP `sandbox allow-scripts`, no auth cookies) so shared HTML can't attack hub sessions — keep that header on any change; `/s/*` also sits behind a per-IP token bucket (`ratelimit.go`, `share_rpm` config), and markdown share pages get a "Shared with BearDrive" footer (raw HTML is never injected into).
## Invariants — do not break these
+21 -2
View File
@@ -187,8 +187,13 @@ the positional argument), `--upload` (allow client writes, off by default),
"projects_db": "/var/lib/bdrive/projects.json",
"share_rpm": 120, // per-IP rate limit on public /s/* links
"auth": { // optional knobs; hub auth is always on
// Signup is invite-only by default. To allow self-service signup,
// open it WITH a gate (an ungated open hub is refused at startup):
"allow_signup": true,
"allowed_domains": ["example.com"], // only these domains may sign up
"require_approval": true, // …and an admin must approve each one
"users_db": "/var/lib/bdrive/auth.json",
"admins": ["admin@example.com"],
"smtp": { "host": "smtp.example.com", "port": 587,
"user": "drive@example.com", "pass": "…", "from": "drive@example.com" }
}
@@ -306,8 +311,22 @@ session; only `/api/config` and the auth pages stay open (the plain-folder
viewer, `bdrive web ./folder`, remains auth-free). Accounts are
email + password + name, kept in a file-backed registry (`auth.json`:
bcrypt password hashes and SHA-256 token digests, atomically rewritten —
no plaintext credentials ever touch disk). Sign-up is open by default;
`"allow_signup": false` closes it once the team is onboarded.
no plaintext credentials ever touch disk).
**Signup is invite-only by default** — the safe posture for a hub on a
public URL. New people get in only through an expiring invite link an owner
mints; the link lets them create an account (bypassing the gates below) and
join, in one step. To allow self-service signup instead, set
`"allow_signup": true` **with a gate** — the server refuses to start an open
hub that has none, so a fake email can never just walk in. Three postures:
- **Invite-only** (default): `allow_signup` unset/false. Only invite links create accounts.
- **Approval-gated**: `allow_signup: true` + `require_approval: true` — anyone can sign up, but a hub admin approves each new account before it works (no SMTP needed).
- **Domain-restricted + verified**: `allow_signup: true` + `allowed_domains: ["you.com"]` + `require_verification: true` (needs `smtp`) — only your company's addresses may sign up, each confirming an emailed link. Verification without SMTP is refused (the link would otherwise only reach the server log).
Admins tune verification/approval live from the web UI (**Admin → Signup &
access**); `allowed_domains`, the admin list, and `allow_signup` are
server-config-owned so a browser session can never widen who gets in.
`bdrive login <url>` on a client device opens the server's sign-in page in
a browser (sign up right there if needed); when the user signs in, the
+12 -1
View File
@@ -211,7 +211,10 @@ credentials); otherwise it is relayed through this server.`,
// stays auth-free.
if srv.Root != nil {
usersDB := ""
allowSignup := true
// Invite-only by default: a fresh hub doesn't accept self-signup
// until an admin opts in (with a gate). Owners onboard people
// with /join/<token> invite links.
allowSignup := false
var mail *webapp.Mailer
if cfg.Auth != nil {
usersDB = cfg.Auth.UsersDB
@@ -256,12 +259,20 @@ credentials); otherwise it is relayed through this server.`,
}
}
}
// Refuse to boot an incoherent signup posture (open with no
// gate, or verification without a mailer) rather than silently
// leaving the door open.
if err := auth.ValidateSignupPolicy(); err != nil {
return err
}
srv.Auth = auth
orgs, err := webapp.OpenOrgDB(filepath.Join(filepath.Dir(projectsDB), "orgs.json"))
if err != nil {
return fmt.Errorf("open org registry: %w", err)
}
srv.Orgs = orgs
// Invite links can bootstrap an account on an invite-only hub.
auth.InviteValid = orgs.ValidInvite
// A hub that predates organizations: sweep its projects into
// a default org so it keeps working with zero manual steps.
if err := webapp.MigrateOrgs(srv.Projects, orgs, auth.Accounts()); err != nil {
+6
View File
@@ -147,6 +147,12 @@ func (s *Server) handleAdminPolicy(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
return
}
// Email verification is only a real gate with a mailer; refuse to turn
// it on without SMTP rather than silently logging links.
if req.RequireVerification && a.Mail == nil {
http.Error(w, "email verification needs SMTP configured on the server", http.StatusBadRequest)
return
}
if err := a.SetPolicy(req.RequireVerification, req.RequireApproval); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
+1 -1
View File
@@ -134,7 +134,7 @@ func TestSignupDisabled(t *testing.T) {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "does not allow self-signup") {
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "invite-only") {
t.Fatalf("signup while disabled: %d %s", rec.Code, rec.Body)
}
if len(auth.users) != 0 {
+103 -10
View File
@@ -37,6 +37,12 @@ type BuiltinAuth struct {
Admins map[string]bool // hub admins (lowercase emails): approve users, govern shares
Brand string // optional name shown on the sign-in page
// InviteValid, when set, reports whether a token is a live org invite.
// It lets an invite link bootstrap an account on an invite-only hub
// (AllowSignup false) — the one path in without self-signup. Wired to
// OrgDB.ValidInvite by the server. Nil → no invite-based signup.
InviteValid func(token string) bool
path string
mu sync.Mutex
@@ -200,13 +206,27 @@ func (a *BuiltinAuth) findByEmail(email string) *authUser {
return nil
}
// signup creates a self-service account, subject to the domain allowlist and
// starting in the state the gating policy dictates.
func (a *BuiltinAuth) signup(email, name, password string) (*authUser, error) {
return a.createAccount(email, name, password, false)
}
// signupInvited creates an account from a valid invite link. An invite is an
// explicit grant by an owner, so it is the vetting: the domain allowlist and
// the approval/verification gates are bypassed and the account is active. The
// caller must have already checked the invite token is live.
func (a *BuiltinAuth) signupInvited(email, name, password string) (*authUser, error) {
return a.createAccount(email, name, password, true)
}
func (a *BuiltinAuth) createAccount(email, name, password string, viaInvite bool) (*authUser, error) {
email = strings.TrimSpace(strings.ToLower(email))
name = strings.TrimSpace(name)
if email == "" || !strings.Contains(email, "@") {
return nil, fmt.Errorf("a valid email is required")
}
if !a.domainAllowed(email) {
if !viaInvite && !a.domainAllowed(email) {
return nil, fmt.Errorf("this server only accepts %s email addresses", a.domainList())
}
if name == "" {
@@ -219,6 +239,10 @@ func (a *BuiltinAuth) signup(email, name, password string) (*authUser, error) {
if err != nil {
return nil, err
}
status := a.initialStatus()
if viaInvite {
status = statusActive
}
a.mu.Lock()
defer a.mu.Unlock()
if a.findByEmail(email) != nil {
@@ -226,7 +250,7 @@ func (a *BuiltinAuth) signup(email, name, password string) (*authUser, error) {
}
u := &authUser{
ID: "u-" + randHex(4), Email: email, Name: name,
Pass: string(hash), Status: a.initialStatus(), Created: time.Now().UTC(),
Pass: string(hash), Status: status, Created: time.Now().UTC(),
}
a.users[u.ID] = u
if err := a.save(); err != nil {
@@ -249,6 +273,26 @@ func (a *BuiltinAuth) initialStatus() string {
}
}
// ValidateSignupPolicy rejects incoherent signup configurations at startup so
// a hub is never accidentally left open to fake-email signups. The three
// supported postures are: invite-only (AllowSignup false — the default),
// approval-gated, and domain-restricted with email verification.
//
// - Open self-signup must carry at least one gate (allowed domains, admin
// approval, or email verification). Without one, anyone can register any
// address — the exact hole this guards.
// - Email verification needs a mailer: without SMTP the link only reaches
// the server log, so it can't actually gate real users.
func (a *BuiltinAuth) ValidateSignupPolicy() error {
if a.RequireVerification && a.Mail == nil {
return fmt.Errorf("auth: require_verification needs an smtp mailer — without one the verification link only reaches the server log; configure auth.smtp or turn verification off")
}
if a.AllowSignup && len(a.AllowedDomains) == 0 && !a.RequireApproval && !a.RequireVerification {
return fmt.Errorf("auth: open self-signup has no gate, so anyone could register any email — set allow_signup:false (invite-only, the default), or add allowed_domains, require_approval, or require_verification")
}
return nil
}
// afterVerify is the state a just-verified account moves to.
func (a *BuiltinAuth) afterVerify() string {
if a.RequireApproval {
@@ -536,6 +580,40 @@ func safeNext(next string) string {
return next
}
// inviteTokenFromNext pulls an org-invite token out of a post-login target
// like "/join/<token>". Tokens are lowercase hex.
func inviteTokenFromNext(next string) string {
const marker = "/join/"
i := strings.Index(next, marker)
if i < 0 {
return ""
}
tok := next[i+len(marker):]
if j := strings.IndexAny(tok, "/?&#"); j >= 0 {
tok = tok[:j]
}
if tok == "" {
return ""
}
for _, c := range tok {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
return ""
}
}
return tok
}
// invitedVia returns the invite token in next when it points at a live invite,
// so the login/signup pages can let an invitee create an account even on an
// invite-only hub. Empty string when there's no valid invite.
func (a *BuiltinAuth) invitedVia(next string) string {
tok := inviteTokenFromNext(next)
if tok != "" && a.InviteValid != nil && a.InviteValid(tok) {
return tok
}
return ""
}
// ---- pages ----
func authPage(w http.ResponseWriter, title, body string) {
@@ -600,13 +678,20 @@ func (a *BuiltinAuth) pageLogin(w http.ResponseWriter, r *http.Request) {
errMsg = `<p class="err">Wrong email or password.</p>`
}
}
// Offer account creation when public signup is open, or when the visitor
// arrived through a valid invite (the way into an invite-only hub).
signup := ""
if a.AllowSignup {
invited := a.invitedVia(next) != ""
if a.AllowSignup || invited {
note := ""
if len(a.AllowedDomains) > 0 {
if a.AllowSignup && len(a.AllowedDomains) > 0 {
note = ` <span style="color:#868b93">(` + html.EscapeString(a.domainList()) + ` only)</span>`
}
signup = fmt.Sprintf(`<p class="alt">No account? <a href="/auth/signup?next=%s">Sign up</a>%s</p>`, url.QueryEscape(next), note)
label := "No account?"
if invited && !a.AllowSignup {
label = "New here?"
}
signup = fmt.Sprintf(`<p class="alt">%s <a href="/auth/signup?next=%s">Sign up</a>%s</p>`, label, url.QueryEscape(next), note)
}
brand := ""
if a.Brand != "" {
@@ -622,14 +707,21 @@ func (a *BuiltinAuth) pageLogin(w http.ResponseWriter, r *http.Request) {
func (a *BuiltinAuth) pageSignup(w http.ResponseWriter, r *http.Request) {
next := safeNext(r.FormValue("next"))
if !a.AllowSignup {
authPage(w, "Sign up disabled", `<p>This server does not allow self-signup. Ask the server admin for an account.</p>
// An invite link authorizes account creation even when public self-signup
// is closed — it's the only way into an invite-only hub.
inviteTok := a.invitedVia(next)
if !a.AllowSignup && inviteTok == "" {
authPage(w, "Sign up disabled", `<p>This server is invite-only. Ask a team owner for an invite link, or sign in if you already have an account.</p>
<p class="alt"><a href="/auth/login">Back to sign in</a></p>`)
return
}
var errMsg string
if r.Method == http.MethodPost {
u, err := a.signup(r.FormValue("email"), r.FormValue("name"), r.FormValue("password"))
signup := a.signup
if inviteTok != "" {
signup = a.signupInvited // invite is the vetting: skip gates, activate
}
u, err := signup(r.FormValue("email"), r.FormValue("name"), r.FormValue("password"))
if err == nil {
switch u.Status {
case statusUnverified:
@@ -652,9 +744,10 @@ func (a *BuiltinAuth) pageSignup(w http.ResponseWriter, r *http.Request) {
errMsg = `<p class="err">` + html.EscapeString(err.Error()) + `</p>`
}
// State the domain restriction up front, where the stranger types their
// email — not only after a rejected submit.
// email — not only after a rejected submit. An invite bypasses the domain
// allowlist, so don't show it when arriving through one.
domainNote := ""
if len(a.AllowedDomains) > 0 {
if inviteTok == "" && 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 := ""
+85
View File
@@ -147,6 +147,91 @@ func TestPolicyAPIAdminOnly(t *testing.T) {
}
}
// The three supported postures validate; an ungated open hub and
// verification-without-a-mailer are refused.
func TestValidateSignupPolicy(t *testing.T) {
ok := func(name string, tune func(*BuiltinAuth)) {
t.Helper()
a := gatedAuth(t, tune)
if err := a.ValidateSignupPolicy(); err != nil {
t.Fatalf("%s: unexpected error: %v", name, err)
}
}
bad := func(name string, tune func(*BuiltinAuth)) {
t.Helper()
a := gatedAuth(t, tune)
if err := a.ValidateSignupPolicy(); err == nil {
t.Fatalf("%s: expected an error", name)
}
}
ok("invite-only", func(a *BuiltinAuth) { a.AllowSignup = false })
ok("open+domain", func(a *BuiltinAuth) { a.AllowedDomains = []string{"x.io"} })
ok("open+approval", func(a *BuiltinAuth) { a.RequireApproval = true })
ok("open+verify+mailer", func(a *BuiltinAuth) { a.RequireVerification = true; a.Mail = &Mailer{Host: "smtp"} })
bad("open+no-gate", func(a *BuiltinAuth) { a.AllowSignup = true })
bad("verify-without-mailer", func(a *BuiltinAuth) { a.RequireVerification = true })
}
// On an invite-only hub, a valid invite link lets a brand-new person create an
// account (bypassing the closed signup and the domain allowlist); an invalid
// or absent invite still hits the disabled page.
func TestInviteBootstrapsAccountWhenSignupClosed(t *testing.T) {
srv, auth, _ := authHub(t, false) // AllowSignup=false (invite-only)
auth.AllowedDomains = []string{"runbear.io"}
auth.InviteValid = func(tok string) bool { return tok == "abc123" }
h := srv.Handler()
signupVia := func(next, email string) *httptest.ResponseRecorder {
form := url.Values{"email": {email}, "name": {"New"}, "password": {"password1"}}
u := "/auth/signup"
if next != "" {
u += "?next=" + url.QueryEscape(next)
}
req := httptest.NewRequest("POST", u, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// Valid invite → account created and active, even for an outside domain.
rec := signupVia("/join/abc123", "contractor@elsewhere.com")
if rec.Code != http.StatusSeeOther {
t.Fatalf("invited signup: %d %s", rec.Code, rec.Body)
}
u := auth.findByEmail("contractor@elsewhere.com")
if u == nil || !u.active() {
t.Fatalf("invited account should exist and be active: %+v", u)
}
// No invite → disabled, no account.
if rec := signupVia("", "nobody@runbear.io"); !strings.Contains(rec.Body.String(), "invite-only") {
t.Fatalf("uninvited signup should be disabled: %s", rec.Body)
}
// Invalid/expired invite token → disabled too.
if rec := signupVia("/join/deadbeef", "nobody@runbear.io"); !strings.Contains(rec.Body.String(), "invite-only") {
t.Fatalf("bad-invite signup should be disabled: %s", rec.Body)
}
if auth.findByEmail("nobody@runbear.io") != nil {
t.Fatal("account created without a valid invite")
}
}
// Turning on email verification without a mailer is refused over the policy API.
func TestPolicyVerificationNeedsMailer(t *testing.T) {
srv, auth, _ := authHub(t, true)
auth.Admins = map[string]bool{"boss@x.io": true}
h := srv.Handler()
boss := signupAndSession(t, h, "boss@x.io", "Boss", "password1")
rec := doAs(t, h, "POST", "/api/admin/policy", map[string]bool{"require_verification": true}, boss)
if rec.Code != http.StatusBadRequest {
t.Fatalf("verification without mailer should be refused: %d %s", rec.Code, rec.Body)
}
if auth.RequireVerification {
t.Fatal("verification was enabled despite having no mailer")
}
}
func TestAuthRateLimit(t *testing.T) {
srv, _, _ := newHub(t, true, nil)
srv.Auth = gatedAuth(t, nil)
+10
View File
@@ -348,6 +348,16 @@ func (db *OrgDB) Redeem(token string) (OrgInvite, bool) {
return inv, true
}
// ValidInvite reports whether a token is a live invite, without consuming it.
// It lets the signup page permit account creation from an invite link even
// when public self-signup is closed (invite-only hubs).
func (db *OrgDB) ValidInvite(token string) bool {
db.mu.Lock()
defer db.mu.Unlock()
inv, ok := db.invites[token]
return ok && !inv.expired()
}
// ---- migration ----
// MigrateOrgs assigns every org-less project to a default org so a hub that
+7 -6
View File
@@ -550,20 +550,21 @@ async function showHubSettings() {
el(panel, "h3", null, "New-account vetting");
const toggles = el(panel, "div", "admin-list");
const mkToggle = (label, desc, key, on) => {
const mkToggle = (label, desc, key, on, disabled) => {
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;
if (disabled) { cb.disabled = true; row.style.opacity = ".55"; }
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);
pol.mailer ? "New accounts must click an emailed link before they can sign in — proves they control the address."
: "Configure SMTP on the server (auth.smtp) to enable email verification.",
"require_verification", pol.require_verification && pol.mailer, !pol.mailer);
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);
@@ -580,10 +581,10 @@ async function showHubSettings() {
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)");
el(dom, "span", "ai-tag", (pol.allowed_domains && pol.allowed_domains.length) ? pol.allowed_domains.map((d) => "@" + d).join(", ") : "any");
const sg = el(info, "div", "admin-item");
el(sg, "span", "ai-main", "Self-signup");
el(sg, "span", "ai-tag", pol.allow_signup ? "open" : "closed");
el(sg, "span", "ai-tag", pol.allow_signup ? "open" : "invite-only");
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");
+1 -1
View File
@@ -93,7 +93,7 @@ cd ~/agent-workspace && bdrive init --name agent-workspace
Devices connecting the same project (by name or id) converge through the hub. Direct-to-bucket setups (no hub) remain possible via `bdrive remote set <folder> s3://…` after an offline init.
Hub projects belong to an **organization**: only members of the project's org can see or sync it (project names are scoped per org too). Your first `bdrive init` creates your org automatically. To give a teammate access, an org **owner** opens the web UI and clicks **Invite** in the sidebar footer — it mints an expiring join link (`…/join/<token>`); the teammate opens it, signs in (or up), and is in. If a teammate's `bdrive init --project <id>` gets 403/404 or the project list looks empty, the missing invite is the reason. Public share links (`bdrive share`) intentionally bypass the org wall.
Hub projects belong to an **organization**: only members of the project's org can see or sync it (project names are scoped per org too). Your first `bdrive init` creates your org automatically. **Hubs are invite-only by default** — the safe posture for a public URL. To give a teammate access, an org **owner** opens the web UI and clicks **Invite** in the sidebar footer — it mints an expiring join link (`…/join/<token>`); the teammate opens it and creates an account through the link (invites bootstrap signup even when public self-signup is closed), and is in. An admin can instead open self-service signup with a gate (admin approval, or allowed-domains + email verification) under **Admin → Signup & access** / the config's `auth` block. If a teammate's `bdrive init --project <id>` gets 403/404 or the project list looks empty, the missing invite is the reason. Public share links (`bdrive share`) intentionally bypass the org wall.
### Renames and moves