Cohort invites: an invite carries an access level (#657)
* server+web: cohort invites — an invite carries an access level
key_invites.tier_id (admin-minted only — level assignment stays an admin
power); assignment at first-device claim ('invite' source, best-effort like
default groups, deleted-level fails safe); the invite lookup names the level
for the welcome card (link-holders only, branding-class); setPubkey prods
level-key holders the moment the joiner is wrappable-to; console invite form
gains the cohort picker (hidden until levels exist, dead levels excluded).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* admin: access levels manage in place — no repaint flash; placeholder shows example names
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* admin: restore the add-level handler dropped by the in-place rework
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web+server: cohort picker in the in-chat invite flow (admins), Enter creates a level, member rows never render nameless
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* cohort invites: anyone in a level can invite into it (flat-group rule); admins any level; picker fetches fresh so a just-created level shows
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: show a person's access levels as chips on their profile card
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: show your own access levels in Edit profile (self-facing mirror of the profile chips)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* server+web: access-level visibility — admin policy (shown/hidden/user) + per-member choice
Each level's roster is served full to its members and admins (they share the
key — insiders inherently know each other) but filtered for non-members by
the admin policy: shown → all, hidden → none, user → each member's own choice
(setUserTierVisible). Admin app gets the 3-way policy select per level; Edit
profile gets a per-level 'show on my profile' toggle under the 'user' policy,
a members-only note under 'hidden'. Endpoints: POST /api/tiers/{id}/policy
(admin), /api/tiers/{id}/show (member). TestTierVisibility covers all three.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* admin: note that gating a group to a level needs level membership; drop the stale 'Soon'
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: a parked thread's refresh must not paint into the shared pane
The welcome/tour emits steps asynchronously; each appendNotice fires the
thread's refreshFeed closure, which re-renders via renderApp(threadId,'chat',
#app-pane) — the shared pane. mountedCtx/refreshFeed persist for a PARKED
thread, so a step firing after you navigated away rendered the welcome (Eleven)
into whatever chat now occupies #app-pane (the reported 'Eleven in my inviter
DM'). render() already guards hotkeys/native against a parked re-render but not
the feed. Stamp the mount root with its threadId and skip the refresh when the
pane belongs to another thread — the notice is already stored and paints on the
thread's next open. (Groups were fixed earlier via the not-loaded path; this is
the DM/loaded-thread path.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: 'New members join automatically' at group-creation time too (was Edit-group only)
Admin-only, shown when Open is ticked and no access-level gate (a gated group
can't be default — server refuses). createThread marks it default after the
open group is created, mirroring the Edit-group toggle.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: open groups open with a start-of-conversation note (name + live member count + Say hi)
Reuses the chatIntro persistent-first-row primitive; intro vrev now tracks its
text so the member count stays current as people join.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
11 files changed,
+1023
−144
changelog/2026-07-11-cohort-invites.md+6 −0db.go+127 −11handlers_test.go+288 −2main.go+130 −1web/pages/admin.html+2 −0web/static/admin.js+33 −2web/static/apps/admin.js+195 −122web/static/apps/chat.js+3 −1web/static/join.js+14 −0web/static/shell.js+217 −5web/static/style.css+8 −0
diff --git a/changelog/2026-07-11-cohort-invites.md b/changelog/2026-07-11-cohort-invites.md| new file mode 100644 |
| index 0000000..cf995be |
| --- /dev/null |
| +++ b/changelog/2026-07-11-cohort-invites.md |
| @@ -0,0 +1,6 @@ |
| +🤖 **Cohort invites.** An invite can now carry an access level: pick "Add |
| +them to January 2027" when you create the invite, and everyone who joins |
| +through that link lands in the level automatically — their key follows from |
| +a member the moment they arrive. The invitation itself says which group |
| +they're joining. Pair it with a level-limited group and each intake gets |
| +its own room from day one. |
diff --git a/db.go b/db.go| index 45f8435..004225f 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -582,6 +582,20 @@ CREATE TABLE IF NOT EXISTS member_bots ( |
| // never force-joined). Admin-set, meaningful only while visibility is |
| // 'open' — joining still needs no key material beyond open_key. |
| "ALTER TABLE threads ADD COLUMN default_join INTEGER NOT NULL DEFAULT 0", |
| + // Cohort invites (docs/community-and-tiers.md): an invite may carry an |
| + // access level — its claimer is assigned the level on first-device |
| + // claim ('invite' source). Admin-set only. |
| + "ALTER TABLE key_invites ADD COLUMN tier_id TEXT", |
| + // Level visibility policy (admin-set, per level): 'shown' (on everyone's |
| + // profile — the default, = today's behaviour), 'hidden' (only the |
| + // level's own members see who's in it — non-members never do), or |
| + // 'user' (each member chooses via user_tiers.visible). NB: "hidden" |
| + // can only mean hidden-from-NON-members — a level's members share its |
| + // key, so they inherently know each other (E2E, not a choice). |
| + "ALTER TABLE tiers ADD COLUMN visibility TEXT NOT NULL DEFAULT 'shown'", |
| + // Per-member show/hide, consulted only when the level's policy is |
| + // 'user'. Default 1 = shown. |
| + "ALTER TABLE user_tiers ADD COLUMN visible INTEGER NOT NULL DEFAULT 1", |
| // One-way mint marker: set (transactionally, with the first grants) |
| // when a level's key is first created. "Zero key rows" must NOT mean |
| // "never minted" — holders can all be removed later, and silently |
| @@ -1014,6 +1028,7 @@ type KeyInvite struct { |
| ThreadID string // the pending DM this invite drops the joiner into ("" if none) |
| ClaimHash string // hashToken of the claim secret; "" for pre-migration invites |
| GrantsAdmin bool // a setup invite: claiming makes the user the (first) admin |
| + TierID string // cohort invite: the access level its claimer joins ("" = none) |
| Used bool |
| Expired bool |
| } |
| @@ -1024,8 +1039,8 @@ func (db *DB) getKeyInvite(id string) (KeyInvite, bool, error) { |
| var threadID, claimHash sql.NullString |
| var grantsAdmin int |
| err := db.sql.QueryRow( |
| - "SELECT id, user_id, wrapped, thread_id, claim_hash, COALESCE(grants_admin, 0), used_at, expires_at FROM key_invites WHERE id = ?", id, |
| - ).Scan(&ki.ID, &ki.UserID, &ki.Wrapped, &threadID, &claimHash, &grantsAdmin, &used, &exp) |
| + "SELECT id, user_id, wrapped, thread_id, claim_hash, COALESCE(grants_admin, 0), COALESCE(tier_id, ''), used_at, expires_at FROM key_invites WHERE id = ?", id, |
| + ).Scan(&ki.ID, &ki.UserID, &ki.Wrapped, &threadID, &claimHash, &grantsAdmin, &ki.TierID, &used, &exp) |
| if err == sql.ErrNoRows { |
| return KeyInvite{}, false, nil |
| } |
| @@ -1136,6 +1151,12 @@ func (db *DB) claimKeyInvite(id, deviceLabel, wrappedSelf, claimSecret string) ( |
| // would brick the invite — and a missed default join is recoverable |
| // (the group is one Discover tap away). |
| _ = db.joinDefaultThreads(ki.UserID) |
| + // A cohort invite also carries an access level — assign it now |
| + // ('invite' source), same best-effort rule. Their level key follows |
| + // once they publish a pubkey (setPubkey prods the holders). |
| + if ki.TierID != "" && db.tierExists(ki.TierID) { |
| + _ = db.assignTier(ki.TierID, ki.UserID, "invite") |
| + } |
| } |
| if ki.ThreadID != "" && wrappedSelf != "" { |
| // They're already a member of the pending DM (added at invite time); this |
| @@ -1689,6 +1710,11 @@ type Tier struct { |
| // KeyLost: the key was minted but every holder is gone — unrecoverable |
| // (grants are refused with 410); the level should be deleted and remade. |
| KeyLost bool `json:"key_lost,omitempty"` |
| + // Visibility policy: 'shown' | 'hidden' | 'user' (see the schema comment). |
| + Visibility string `json:"visibility,omitempty"` |
| + // Hidden: MY membership of this level is currently hidden from non-members |
| + // — meaningful only when I'm a member and Visibility=='user'. |
| + Hidden bool `json:"hidden,omitempty"` |
| } |
| func (db *DB) createTier(name string) (string, error) { |
| @@ -1820,6 +1846,45 @@ func (db *DB) tierKeyMinted(tierID string) bool { |
| return x == 1 |
| } |
| +// setInviteTier attaches an access level to an invite (a cohort invite): the |
| +// claimer is assigned the level on first-device claim (claimKeyInvite). |
| +func (db *DB) setInviteTier(inviteID, tierID string) error { |
| + _, err := db.sql.Exec("UPDATE key_invites SET tier_id = ? WHERE id = ?", tierID, inviteID) |
| + return err |
| +} |
| + |
| +// tierName returns a level's plaintext name ("" if unknown). Names are |
| +// branding-class by design — they render on claim screens where no key exists. |
| +func (db *DB) tierName(id string) string { |
| + var n string |
| + if db.sql.QueryRow("SELECT name FROM tiers WHERE id = ?", id).Scan(&n) != nil { |
| + return "" |
| + } |
| + return n |
| +} |
| + |
| +// userTiersNeedingKeys lists the levels a member belongs to but holds no key |
| +// copy for — the setPubkey prod targets (a cohort joiner becomes wrappable-to |
| +// the moment their pubkey lands). |
| +func (db *DB) userTiersNeedingKeys(userID string) []string { |
| + rows, err := db.sql.Query(` |
| + SELECT ut.tier_id FROM user_tiers ut |
| + LEFT JOIN tier_keys k ON k.tier_id = ut.tier_id AND k.user_id = ut.user_id |
| + WHERE ut.user_id = ? AND k.user_id IS NULL`, userID) |
| + if err != nil { |
| + return nil |
| + } |
| + defer rows.Close() |
| + out := []string{} |
| + for rows.Next() { |
| + var id string |
| + if rows.Scan(&id) == nil { |
| + out = append(out, id) |
| + } |
| + } |
| + return out |
| +} |
| + |
| // tierGatesThreads counts threads (any visibility) whose gate is this level — |
| // the deletion guard: those groups' open_keys are wrapped under this level's |
| // key forever, so deleting the level would seal them for self-serve joiners. |
| @@ -1860,12 +1925,22 @@ func (db *DB) idColumn(query, arg string) ([]string, error) { |
| // listTiers returns every tier (names are plaintext space policy, visible to |
| // all members like the roster), personalised with the caller's membership, |
| // their wrapped key copy, and who already holds one. |
| -func (db *DB) listTiers(userID string) ([]Tier, error) { |
| +// listTiers returns every level, personalised to userID. isAdmin callers (and |
| +// the level's own members) get the FULL member/holder lists — an admin manages |
| +// them, and a member must be able to wrap the key to every co-member. To an |
| +// OUTSIDER (not a member, not admin) each level's members are filtered by its |
| +// visibility policy: 'shown' → all, 'hidden' → none, 'user' → only members who |
| +// opted visible. So a hidden level never leaks its roster to non-members, while |
| +// the crypto's own truth — members know each other — is preserved. |
| +func (db *DB) listTiers(userID string, isAdmin bool) ([]Tier, error) { |
| rows, err := db.sql.Query(` |
| SELECT t.id, t.name, t.created_at, COALESCE(k.wrapped, ''), COALESCE(t.key_minted, 0), |
| - EXISTS(SELECT 1 FROM user_tiers m WHERE m.tier_id = t.id AND m.user_id = ?) |
| + COALESCE(t.visibility, 'shown'), |
| + CASE WHEN m.user_id IS NULL THEN 0 ELSE 1 END, |
| + COALESCE(m.visible, 1) |
| FROM tiers t |
| LEFT JOIN tier_keys k ON k.tier_id = t.id AND k.user_id = ? |
| + LEFT JOIN user_tiers m ON m.tier_id = t.id AND m.user_id = ? |
| ORDER BY t.created_at, t.id`, userID, userID) |
| if err != nil { |
| return nil, err |
| @@ -1873,30 +1948,71 @@ func (db *DB) listTiers(userID string) ([]Tier, error) { |
| defer rows.Close() |
| out := []Tier{} |
| minted := []bool{} |
| + myVisible := []bool{} |
| for rows.Next() { |
| var t Tier |
| - var m int |
| - if err := rows.Scan(&t.ID, &t.Name, &t.CreatedAt, &t.Wrapped, &m, &t.Mine); err != nil { |
| + var m, mine, vis int |
| + if err := rows.Scan(&t.ID, &t.Name, &t.CreatedAt, &t.Wrapped, &m, &t.Visibility, &mine, &vis); err != nil { |
| return nil, err |
| } |
| + t.Mine = mine == 1 |
| minted = append(minted, m == 1) |
| + myVisible = append(myVisible, vis == 1) |
| out = append(out, t) |
| } |
| if err := rows.Err(); err != nil { |
| return nil, err |
| } |
| for i := range out { |
| - if out[i].Members, err = db.tierMemberIDs(out[i].ID); err != nil { |
| - return nil, err |
| - } |
| - if out[i].Have, err = db.tierHolderIDs(out[i].ID); err != nil { |
| + insider := isAdmin || out[i].Mine |
| + if insider { |
| + if out[i].Members, err = db.tierMemberIDs(out[i].ID); err != nil { |
| + return nil, err |
| + } |
| + if out[i].Have, err = db.tierHolderIDs(out[i].ID); err != nil { |
| + return nil, err |
| + } |
| + out[i].KeyLost = minted[i] && len(out[i].Have) == 0 |
| + } else if out[i].Members, err = db.publicTierMemberIDs(out[i].ID, out[i].Visibility); err != nil { |
| return nil, err |
| } |
| - out[i].KeyLost = minted[i] && len(out[i].Have) == 0 |
| + // My own show/hide state — only a control when I'm a member of a |
| + // 'user'-policy level; otherwise the policy decides, no personal say. |
| + out[i].Hidden = out[i].Mine && out[i].Visibility == "user" && !myVisible[i] |
| } |
| return out, nil |
| } |
| +// publicTierMemberIDs is the member list an OUTSIDER may see: all for 'shown', |
| +// none for 'hidden', the opted-in subset for 'user'. |
| +func (db *DB) publicTierMemberIDs(tierID, visibility string) ([]string, error) { |
| + switch visibility { |
| + case "hidden": |
| + return []string{}, nil |
| + case "user": |
| + return db.idColumn("SELECT user_id FROM user_tiers WHERE tier_id = ? AND COALESCE(visible, 1) = 1", tierID) |
| + default: // shown |
| + return db.tierMemberIDs(tierID) |
| + } |
| +} |
| + |
| +// setTierVisibility sets a level's admin policy ('shown'|'hidden'|'user'). |
| +func (db *DB) setTierVisibility(tierID, visibility string) error { |
| + _, err := db.sql.Exec("UPDATE tiers SET visibility = ? WHERE id = ?", visibility, tierID) |
| + return err |
| +} |
| + |
| +// setUserTierVisible sets one member's show/hide for a level (their own choice, |
| +// honoured only while the level's policy is 'user'). |
| +func (db *DB) setUserTierVisible(tierID, userID string, visible bool) error { |
| + v := 0 |
| + if visible { |
| + v = 1 |
| + } |
| + _, err := db.sql.Exec("UPDATE user_tiers SET visible = ? WHERE tier_id = ? AND user_id = ?", v, tierID, userID) |
| + return err |
| +} |
| + |
| type Thread struct { |
| ID string `json:"id"` |
| Kind string `json:"kind"` // self | dm | group |
diff --git a/handlers_test.go b/handlers_test.go| index ba184a2..e156cfb 100644 |
| --- a/handlers_test.go |
| +++ b/handlers_test.go |
| @@ -2291,7 +2291,9 @@ func TestTiers(t *testing.T) { |
| t.Fatalf("remove: code=%d", code) |
| } |
| got = tiersFor(tokC) |
| - if got[0].Mine || got[0].Wrapped != "" || len(got[0].Members) != 1 || len(got[0].Have) != 1 { |
| + // Cal is now an outsider: on this default 'shown' level they still see the |
| + // member list (Bea), but the holder list is insiders/admins only. |
| + if got[0].Mine || got[0].Wrapped != "" || len(got[0].Members) != 1 || len(got[0].Have) != 0 { |
| t.Fatalf("after remove: %+v", got[0]) |
| } |
| @@ -2534,7 +2536,7 @@ func TestTierKeyLifecycle(t *testing.T) { |
| if code := do("POST", "/api/tiers/"+tid+"/keys", `{"token":"`+tokAdmin+`","grants":[{"user_id":"`+uidB+`","wrapped":"w2"}]}`); code != 410 { |
| t.Fatalf("grant on lost key: code=%d, want 410 (never a silent re-mint)", code) |
| } |
| - tiers, err := srv.db.listTiers(uidAdmin) |
| + tiers, err := srv.db.listTiers(uidAdmin, true) |
| if err != nil || len(tiers) != 1 || !tiers[0].KeyLost { |
| t.Fatalf("key_lost not reported: %+v err=%v", tiers, err) |
| } |
| @@ -2566,3 +2568,287 @@ func TestTierKeyLifecycle(t *testing.T) { |
| t.Fatalf("ghost key holder after removeUser: %v", hs) |
| } |
| } |
| + |
| +// TestCohortInvites: an admin-minted invite carries an access level; its |
| +// claimer lands in the level on FIRST-device claim (source 'invite'), the |
| +// welcome card learns the level's name, and stale/invalid gates fail safely. |
| +func TestCohortInvites(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + |
| + tid, err := srv.db.createTier("January 2027") |
| + if err != nil { |
| + t.Fatalf("createTier: %v", err) |
| + } |
| + |
| + mint := func(inviteID, tierID string) int { |
| + body := `{"id":"` + inviteID + `","wrapped":"","claim_hash":"` + testClaimHash + `","tier_id":"` + tierID + `"}` |
| + req, _ := http.NewRequest("POST", ts.URL+"/api/admin/key-invites", strings.NewReader(body)) |
| + req.Header.Set("Authorization", "Bearer admintok") |
| + req.Header.Set("Content-Type", "application/json") |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatalf("mint: %v", err) |
| + } |
| + resp.Body.Close() |
| + return resp.StatusCode |
| + } |
| + |
| + if code := mint("coh-bad", "nope"); code != 400 { |
| + t.Fatalf("mint with unknown level: code=%d, want 400", code) |
| + } |
| + if code := mint("coh-1", tid); code != 200 { |
| + t.Fatalf("mint cohort invite: code=%d", code) |
| + } |
| + |
| + // The invite lookup names the level (link-holders only, like description). |
| + resp, err := http.Get(ts.URL + "/api/key-invite/coh-1") |
| + if err != nil { |
| + t.Fatalf("lookup: %v", err) |
| + } |
| + var info struct { |
| + OK bool `json:"ok"` |
| + TierName string `json:"tier_name"` |
| + } |
| + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { |
| + t.Fatalf("decode: %v", err) |
| + } |
| + resp.Body.Close() |
| + if !info.OK || info.TierName != "January 2027" { |
| + t.Fatalf("lookup: %+v", info) |
| + } |
| + |
| + // Claim → the joiner is in the level, attributed to the invite. |
| + ki, _, _ := srv.db.getKeyInvite("coh-1") |
| + if _, ok, err := srv.db.claimKeyInvite("coh-1", "phone", "", testClaimSecret); !ok || err != nil { |
| + t.Fatalf("claim: ok=%v err=%v", ok, err) |
| + } |
| + if !srv.db.tierMember(tid, ki.UserID) { |
| + t.Fatalf("claimer not in the cohort level") |
| + } |
| + var src string |
| + srv.db.sql.QueryRow("SELECT source FROM user_tiers WHERE tier_id = ? AND user_id = ?", tid, ki.UserID).Scan(&src) |
| + if src != "invite" { |
| + t.Fatalf("assignment source = %q, want invite", src) |
| + } |
| + |
| + // An add-device claim later never re-assigns: leave the level, add a |
| + // device, still out. |
| + if err := srv.db.unassignTier(tid, ki.UserID); err != nil { |
| + t.Fatalf("unassign: %v", err) |
| + } |
| + if err := srv.db.createKeyInvite("coh-dev", ki.UserID, "wrap", testClaimHash, 0); err != nil { |
| + t.Fatalf("device invite: %v", err) |
| + } |
| + if err := srv.db.setInviteTier("coh-dev", tid); err != nil { |
| + t.Fatalf("setInviteTier: %v", err) |
| + } |
| + if _, ok, err := srv.db.claimKeyInvite("coh-dev", "laptop", "", testClaimSecret); !ok || err != nil { |
| + t.Fatalf("device claim: ok=%v err=%v", ok, err) |
| + } |
| + if srv.db.tierMember(tid, ki.UserID) { |
| + t.Fatalf("add-device claim re-assigned a left level") |
| + } |
| + |
| + // A level deleted between mint and claim fails safe: claim succeeds, no |
| + // assignment (tierExists guard in claimKeyInvite). |
| + if code := mint("coh-2", tid); code != 200 { |
| + t.Fatalf("mint 2: code=%d", code) |
| + } |
| + if err := srv.db.deleteTier(tid); err != nil { |
| + t.Fatalf("deleteTier: %v", err) |
| + } |
| + ki2, _, _ := srv.db.getKeyInvite("coh-2") |
| + if _, ok, err := srv.db.claimKeyInvite("coh-2", "", "", testClaimSecret); !ok || err != nil { |
| + t.Fatalf("claim after delete: ok=%v err=%v", ok, err) |
| + } |
| + if srv.db.tierMember(tid, ki2.UserID) { |
| + t.Fatalf("assignment to a deleted level") |
| + } |
| +} |
| + |
| +// TestCohortInviteFromChat: the in-chat invite path accepts a level from |
| +// role-admins only — a member invite can't smuggle anyone into a level. |
| +func TestCohortInviteFromChat(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + |
| + mkUser := func(label string) (tok, uid string) { |
| + u, err := srv.db.createUser(label) |
| + if err != nil { |
| + t.Fatalf("createUser: %v", err) |
| + } |
| + if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil { |
| + t.Fatalf("createKeyInvite: %v", err) |
| + } |
| + tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret) |
| + if err != nil || !ok { |
| + t.Fatalf("claim: ok=%v err=%v", ok, err) |
| + } |
| + if err := srv.db.setUserPublicKey(u, "pub-"+label); err != nil { |
| + t.Fatalf("setUserPublicKey: %v", err) |
| + } |
| + return tk, u |
| + } |
| + tokAdmin, uidAdmin := mkUser("Root") |
| + if err := srv.db.setUserRole(uidAdmin, "admin"); err != nil { |
| + t.Fatalf("setUserRole: %v", err) |
| + } |
| + tokB, _ := mkUser("Bea") |
| + if err := srv.db.setUsersCanInvite(true); err != nil { |
| + t.Fatalf("setUsersCanInvite: %v", err) |
| + } |
| + tid, err := srv.db.createTier("VIP") |
| + if err != nil { |
| + t.Fatalf("createTier: %v", err) |
| + } |
| + |
| + mint := func(tok, inviteID, tierID string) int { |
| + body := `{"token":"` + tok + `","label_blob":"lb","id":"` + inviteID + `","wrapped":"w","self_wrapped":"sw","claim_hash":"` + testClaimHash + `","tier_id":"` + tierID + `"}` |
| + resp, err := http.Post(ts.URL+"/api/invites", "application/json", strings.NewReader(body)) |
| + if err != nil { |
| + t.Fatalf("post: %v", err) |
| + } |
| + resp.Body.Close() |
| + return resp.StatusCode |
| + } |
| + |
| + // A member NOT in the level can't mint access to it (they don't hold the |
| + // key — they couldn't wrap it anyway). |
| + if code := mint(tokB, "chat-coh-1", tid); code != 403 { |
| + t.Fatalf("outsider cohort invite: code=%d, want 403", code) |
| + } |
| + if code := mint(tokAdmin, "chat-coh-2", "nope"); code != 400 { |
| + t.Fatalf("unknown level: code=%d, want 400", code) |
| + } |
| + // Admins mint for any level; a member IN the level can too (the flat-group |
| + // rule — membership is the power to extend membership). |
| + if code := mint(tokAdmin, "chat-coh-3", tid); code != 200 { |
| + t.Fatalf("admin cohort invite: code=%d", code) |
| + } |
| + if err := srv.db.assignTier(tid, mustUID(t, srv, tokB), "admin"); err != nil { |
| + t.Fatalf("assignTier: %v", err) |
| + } |
| + if code := mint(tokB, "chat-coh-4", tid); code != 200 { |
| + t.Fatalf("in-level member cohort invite: code=%d, want 200", code) |
| + } |
| + ki, found, _ := srv.db.getKeyInvite("chat-coh-3") |
| + if !found || ki.TierID != tid { |
| + t.Fatalf("tier not stored on chat invite: %+v", ki) |
| + } |
| + if _, ok, err := srv.db.claimKeyInvite("chat-coh-3", "", "sw2", testClaimSecret); !ok || err != nil { |
| + t.Fatalf("claim: ok=%v err=%v", ok, err) |
| + } |
| + if !srv.db.tierMember(tid, ki.UserID) { |
| + t.Fatalf("chat-invite claimer not in the level") |
| + } |
| +} |
| + |
| +// TestTierVisibility: admin policy (shown/hidden/user) and the per-member |
| +// choice govern what an OUTSIDER sees in a level's member list; insiders and |
| +// admins always see the full roster (they hold the shared key). |
| +func TestTierVisibility(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + |
| + mkUser := func(label string) (tok, uid string) { |
| + u, err := srv.db.createUser(label) |
| + if err != nil { |
| + t.Fatalf("createUser: %v", err) |
| + } |
| + if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil { |
| + t.Fatalf("createKeyInvite: %v", err) |
| + } |
| + tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret) |
| + if err != nil || !ok { |
| + t.Fatalf("claim: ok=%v err=%v", ok, err) |
| + } |
| + if err := srv.db.setUserPublicKey(u, "pub-"+label); err != nil { |
| + t.Fatalf("setUserPublicKey: %v", err) |
| + } |
| + return tk, u |
| + } |
| + do := func(method, path, body string) int { |
| + req, _ := http.NewRequest(method, ts.URL+path, strings.NewReader(body)) |
| + req.Header.Set("Content-Type", "application/json") |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatalf("%s %s: %v", method, path, err) |
| + } |
| + resp.Body.Close() |
| + return resp.StatusCode |
| + } |
| + tierRow := func(tok, id string) Tier { |
| + resp, err := http.Get(ts.URL + "/api/tiers?token=" + tok) |
| + if err != nil { |
| + t.Fatalf("get tiers: %v", err) |
| + } |
| + defer resp.Body.Close() |
| + var out struct { |
| + Tiers []Tier `json:"tiers"` |
| + } |
| + json.NewDecoder(resp.Body).Decode(&out) |
| + for _, tr := range out.Tiers { |
| + if tr.ID == id { |
| + return tr |
| + } |
| + } |
| + t.Fatalf("tier %s not in %s's view", id, tok) |
| + return Tier{} |
| + } |
| + |
| + tokAdmin, uidAdmin := mkUser("Root") |
| + if err := srv.db.setUserRole(uidAdmin, "admin"); err != nil { |
| + t.Fatalf("setUserRole: %v", err) |
| + } |
| + _, uidB := mkUser("Bea") |
| + tokOut, _ := mkUser("Outsider") |
| + |
| + tid, _ := srv.db.createTier("VIP") |
| + if err := srv.db.assignTier(tid, uidB, "admin"); err != nil { |
| + t.Fatalf("assign: %v", err) |
| + } |
| + |
| + // Default 'shown': the outsider sees Bea. |
| + if m := tierRow(tokOut, tid).Members; len(m) != 1 || m[0] != uidB { |
| + t.Fatalf("shown → outsider sees members: %v", m) |
| + } |
| + |
| + // Only admins set policy. |
| + if code := do("POST", "/api/tiers/"+tid+"/policy", `{"token":"`+tokOut+`","policy":"hidden"}`); code != 403 { |
| + t.Fatalf("non-admin policy: code=%d, want 403", code) |
| + } |
| + if code := do("POST", "/api/tiers/"+tid+"/policy", `{"token":"`+tokAdmin+`","policy":"nope"}`); code != 400 { |
| + t.Fatalf("bad policy value: code=%d, want 400", code) |
| + } |
| + |
| + // 'hidden': the outsider sees an empty roster; the admin still sees Bea. |
| + if code := do("POST", "/api/tiers/"+tid+"/policy", `{"token":"`+tokAdmin+`","policy":"hidden"}`); code != 200 { |
| + t.Fatalf("set hidden: code=%d", code) |
| + } |
| + if m := tierRow(tokOut, tid).Members; len(m) != 0 { |
| + t.Fatalf("hidden → outsider sees nobody: %v", m) |
| + } |
| + if m := tierRow(tokAdmin, tid).Members; len(m) != 1 { |
| + t.Fatalf("hidden → admin still sees members: %v", m) |
| + } |
| + |
| + // 'user': Bea shows by default; when she hides, the outsider loses her. |
| + if code := do("POST", "/api/tiers/"+tid+"/policy", `{"token":"`+tokAdmin+`","policy":"user"}`); code != 200 { |
| + t.Fatalf("set user: code=%d", code) |
| + } |
| + if m := tierRow(tokOut, tid).Members; len(m) != 1 { |
| + t.Fatalf("user default → outsider sees Bea: %v", m) |
| + } |
| + // Only a member sets their own show/hide — an outsider can't touch it. |
| + if code := do("POST", "/api/tiers/"+tid+"/show", `{"token":"`+tokOut+`","show":false}`); code != 403 { |
| + t.Fatalf("outsider sets show: code=%d, want 403", code) |
| + } |
| + // Bea hides herself → the outsider loses her, the admin still sees her. |
| + if err := srv.db.setUserTierVisible(tid, uidB, false); err != nil { |
| + t.Fatalf("hide: %v", err) |
| + } |
| + if m := tierRow(tokOut, tid).Members; len(m) != 0 { |
| + t.Fatalf("user + Bea hidden → outsider sees nobody: %v", m) |
| + } |
| + if m := tierRow(tokAdmin, tid).Members; len(m) != 1 { |
| + t.Fatalf("user + Bea hidden → admin still sees Bea: %v", m) |
| + } |
| +} |
diff --git a/main.go b/main.go| index 98b66ab..d95fc1f 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -378,6 +378,8 @@ func (s *Server) handler() http.Handler { |
| mux.HandleFunc("POST /api/tiers/{id}/remove", s.postTierRemove) |
| mux.HandleFunc("POST /api/tiers/{id}/keys", s.postTierKeys) |
| mux.HandleFunc("POST /api/tiers/{id}/nudge", s.nudgeTierKey) |
| + mux.HandleFunc("POST /api/tiers/{id}/policy", s.postTierPolicy) |
| + mux.HandleFunc("POST /api/tiers/{id}/show", s.postTierShow) |
| mux.HandleFunc("POST /api/label", s.postLabel) |
| mux.HandleFunc("POST /api/presence", s.postPresence) |
| mux.HandleFunc("POST /api/avatar", s.postAvatar) |
| @@ -439,6 +441,7 @@ func (s *Server) handler() http.Handler { |
| mux.HandleFunc("POST /api/key-invite/{id}/claim", s.claimKeyInvite) |
| mux.HandleFunc("POST /api/key-invite/{id}/adopt", s.adoptKeyInvite) |
| mux.HandleFunc("POST /api/admin/key-invites", s.createKeyInvite) |
| + mux.HandleFunc("GET /api/admin/tiers", s.adminTiers) |
| mux.HandleFunc("GET /api/admin/users", s.listUsers) |
| mux.HandleFunc("POST /api/admin/users/{id}/remove", s.removeUser) |
| mux.HandleFunc("POST /api/admin/users/{id}/label", s.renameUser) |
| @@ -878,6 +881,12 @@ func (s *Server) setPubkey(w http.ResponseWriter, r *http.Request) { |
| if firstKey { |
| out, _ := json.Marshal(map[string]any{"type": "joined", "user_id": uid}) |
| s.hub.broadcast <- outbound{data: out} |
| + // A cohort joiner is only NOW wrappable-to: prod each of their levels' |
| + // key holders (targeted, keyless). The holder backfill in loadTiers |
| + // self-heals later regardless; this makes the common case immediate. |
| + for _, tid := range s.db.userTiersNeedingKeys(uid) { |
| + s.prodTierHolders(tid, uid) |
| + } |
| } |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| } |
| @@ -1461,6 +1470,21 @@ func (s *Server) nudgeInstanceKey(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| } |
| +// adminTiers lists the access levels for the admin console's cohort picker |
| +// (bearer-token surface — the console holds no member token). Same rows the |
| +// member endpoint serves, unpersonalised. |
| +func (s *Server) adminTiers(w http.ResponseWriter, r *http.Request) { |
| + if !s.requireAdmin(w, r) { |
| + return |
| + } |
| + ts, err := s.db.listTiers("", true) |
| + if err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"tiers": ts}) |
| +} |
| + |
| // ---- tiers ---------------------------------------------------------------- |
| // Admin-curated member sets that carry key material (docs/community-and-tiers.md). |
| // The key machinery is the instance key's, per tier: clients mint and wrap the |
| @@ -1496,7 +1520,7 @@ func (s *Server) getTiers(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, map[string]any{"tiers": []Tier{}}) |
| return |
| } |
| - ts, err := s.db.listTiers(uid) |
| + ts, err := s.db.listTiers(uid, s.db.userRole(uid) == "admin") |
| if err != nil { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| @@ -1713,6 +1737,67 @@ func (s *Server) nudgeTierKey(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| } |
| +// postTierPolicy sets a level's visibility policy — admin-gated (it's space |
| +// policy, like management). 'shown' | 'hidden' | 'user'. |
| +func (s *Server) postTierPolicy(w http.ResponseWriter, r *http.Request) { |
| + var body struct { |
| + Token string `json:"token"` |
| + Policy string `json:"policy"` |
| + } |
| + if json.NewDecoder(r.Body).Decode(&body) != nil { |
| + httpError(w, http.StatusBadRequest, "bad request") |
| + return |
| + } |
| + if _, ok := s.tierAdmin(w, body.Token); !ok { |
| + return |
| + } |
| + if body.Policy != "shown" && body.Policy != "hidden" && body.Policy != "user" { |
| + httpError(w, http.StatusBadRequest, "bad policy") |
| + return |
| + } |
| + if !s.db.tierExists(r.PathValue("id")) { |
| + httpError(w, http.StatusBadRequest, "no such level") |
| + return |
| + } |
| + if err := s.db.setTierVisibility(r.PathValue("id"), body.Policy); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| +} |
| + |
| +// postTierShow is a member's own show/hide choice for a level they're in — |
| +// honoured only while the level's policy is 'user' (else the admin policy |
| +// decides). Members choose whether THEIR membership shows to non-members. |
| +func (s *Server) postTierShow(w http.ResponseWriter, r *http.Request) { |
| + var body struct { |
| + Token string `json:"token"` |
| + Show bool `json:"show"` |
| + } |
| + if json.NewDecoder(r.Body).Decode(&body) != nil { |
| + httpError(w, http.StatusBadRequest, "bad request") |
| + return |
| + } |
| + if !s.tokenOK(body.Token) { |
| + httpError(w, http.StatusForbidden, "not a member") |
| + return |
| + } |
| + uid, ok := s.db.deviceTokenUser(body.Token) |
| + if !ok { |
| + httpError(w, http.StatusForbidden, "no user for token") |
| + return |
| + } |
| + if !s.db.tierMember(r.PathValue("id"), uid) { |
| + httpError(w, http.StatusForbidden, "not in this level") |
| + return |
| + } |
| + if err := s.db.setUserTierVisible(r.PathValue("id"), uid, body.Show); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| +} |
| + |
| // prodTierHolders tells current key holders that userID needs the tier key |
| // wrapped to them — carrying the target's pubkey, since a holder's roster |
| // view may not include a fresh joiner (the instance-key nudge's reasoning). |
| @@ -2392,6 +2477,10 @@ func (s *Server) createKeyInvite(w http.ResponseWriter, r *http.Request) { |
| // add-instance for a fresh instance's first user (and available to admins |
| // for promoting someone at invite time). See docs/conversational-onboarding.md. |
| GrantsAdmin bool `json:"grants_admin"` |
| + // TierID makes this a COHORT invite: the claimer is assigned that |
| + // access level on first-device claim ('invite' source). Admin-minted |
| + // only, which is what keeps level assignment an admin power. |
| + TierID string `json:"tier_id"` |
| } |
| if json.NewDecoder(r.Body).Decode(&body) != nil { |
| httpError(w, http.StatusBadRequest, "bad request") |
| @@ -2438,6 +2527,10 @@ func (s *Server) createKeyInvite(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| } |
| + if body.TierID != "" && !s.db.tierExists(body.TierID) { |
| + httpError(w, http.StatusBadRequest, "bad access level") |
| + return |
| + } |
| if err := s.db.createKeyInvite(body.ID, userID, body.Wrapped, body.ClaimHash, body.Expires); err != nil { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| @@ -2448,6 +2541,12 @@ func (s *Server) createKeyInvite(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| } |
| + if body.TierID != "" { |
| + if err := s.db.setInviteTier(body.ID, body.TierID); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + } |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true, "user_id": userID}) |
| } |
| @@ -2476,6 +2575,14 @@ func (s *Server) getKeyInvite(w http.ResponseWriter, r *http.Request) { |
| if d := s.db.getGroupDescription(); d != "" { |
| resp["description"] = d |
| } |
| + // A cohort invite says which access level the joiner lands in — the |
| + // level NAME is branding-class by design (plaintext space policy), and |
| + // like the description it's served to link-holders only. |
| + if ki.TierID != "" { |
| + if n := s.db.tierName(ki.TierID); n != "" { |
| + resp["tier_name"] = n |
| + } |
| + } |
| // The suggested pre-fill name is NOT returned here — it rides in the invite |
| // link's #fragment (never sent to the server), so the server never sees it. |
| } |
| @@ -2701,6 +2808,10 @@ func (s *Server) postUserInvite(w http.ResponseWriter, r *http.Request) { |
| SelfWrapped string `json:"self_wrapped"` |
| ClaimHash string `json:"claim_hash"` |
| ThreadID string `json:"thread_id"` |
| + // TierID makes this a cohort invite (claimer lands in the level on |
| + // first-device claim). Role-admins only — level assignment is an |
| + // admin power, so a member invite can't smuggle anyone in. |
| + TierID string `json:"tier_id"` |
| } |
| if json.NewDecoder(r.Body).Decode(&body) != nil { |
| httpError(w, http.StatusBadRequest, "bad request") |
| @@ -2760,11 +2871,29 @@ func (s *Server) postUserInvite(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusBadRequest, "bad request") |
| return |
| } |
| + // The flat-group rule applied to levels: membership is the power to |
| + // extend membership. Anyone may cohort-invite into a level THEY belong to |
| + // (they hold its key already — the server can't pretend otherwise); |
| + // admins may mint for any level. Nobody mints access they don't hold. |
| + if body.TierID != "" && !s.db.tierExists(body.TierID) { |
| + httpError(w, http.StatusBadRequest, "bad access level") |
| + return |
| + } |
| + if body.TierID != "" && s.db.userRole(uid) != "admin" && !s.db.tierMember(body.TierID, uid) { |
| + httpError(w, http.StatusForbidden, "not in that level") |
| + return |
| + } |
| threadID, inviteeID, err := s.db.createUserInvite(uid, body.ID, body.LabelBlob, body.Wrapped, body.SelfWrapped, body.ClaimHash, 0) |
| if err != nil { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| } |
| + if body.TierID != "" { |
| + if err := s.db.setInviteTier(body.ID, body.TierID); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + } |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true, "thread_id": threadID, "user_id": inviteeID}) |
| } |
diff --git a/web/pages/admin.html b/web/pages/admin.html| index 3f68fd3..a4821ab 100644 |
| --- a/web/pages/admin.html |
| +++ b/web/pages/admin.html |
| @@ -182,6 +182,8 @@ |
| <p class="sub" style="margin:0 0 10px">Invite the first person with a short, single-use link (and QR) — they pick a screen name and their device makes its own keys, so the server never sees any key. After someone joins, people are added from inside the chat.</p> |
| <form id="createForm" class="inline-form" autocomplete="off"> |
| <input id="label" placeholder="Their name (e.g. Sophie)" maxlength="60" /> |
| + <!-- Cohort invites: hidden until the space has access levels. --> |
| + <select id="inviteTier" class="hidden" aria-label="Access level for this invite"></select> |
| <button type="submit">Create invite</button> |
| </form> |
| </div> |
diff --git a/web/static/admin.js b/web/static/admin.js| index fb8fef2..2004121 100644 |
| --- a/web/static/admin.js |
| +++ b/web/static/admin.js |
| @@ -472,12 +472,42 @@ function wireCreate() { |
| document.getElementById("createForm").onsubmit = async (e) => { |
| e.preventDefault(); |
| const label = document.getElementById("label").value.trim(); |
| - const minted = await mintInvite(label, null); |
| + const tierSel = document.getElementById("inviteTier"); |
| + const minted = await mintInvite(label, null, false, null, false, (tierSel && tierSel.value) || ""); |
| if (!minted) return; |
| document.getElementById("label").value = ""; |
| showInviteModal(minted.link, label || "New invite"); |
| loadUsers(); |
| }; |
| + loadInviteTiers(); |
| +} |
| + |
| +// Cohort invites: when the space has access levels, the invite form offers |
| +// "add them to <level>" — the claimer lands in that level on join (their |
| +// level key follows from a holder once they publish a pubkey). |
| +async function loadInviteTiers() { |
| + const sel = document.getElementById("inviteTier"); |
| + if (!sel) return; |
| + let tiers = []; |
| + try { |
| + tiers = (await (await api("GET", "/api/admin/tiers")).json()).tiers || []; |
| + } catch { |
| + return; // older server or offline — the picker just stays hidden |
| + } |
| + if (!tiers.length) return; |
| + sel.textContent = ""; |
| + const none = document.createElement("option"); |
| + none.value = ""; |
| + none.textContent = "No access level"; |
| + sel.appendChild(none); |
| + for (const t of tiers) { |
| + if (t.key_lost) continue; // a dead level can't receive anyone |
| + const o = document.createElement("option"); |
| + o.value = t.id; |
| + o.textContent = `Add them to ${t.name}`; |
| + sel.appendChild(o); |
| + } |
| + sel.classList.remove("hidden"); |
| } |
| // Adding an agent is a deliberate, separate action from inviting a person: it |
| @@ -512,7 +542,7 @@ function wireCreateAgent() { |
| // Mint a single-use invite: generate the secret T client-side, wrap the group |
| // key under it, and store only {id, wrapped} server-side. Pass a userId to |
| // re-issue for an existing person (add/recover a device). Returns the link. |
| -async function mintInvite(label, userId, agent, config, grantsAdmin) { |
| +async function mintInvite(label, userId, agent, config, grantsAdmin, tierID) { |
| // Auth-only invite (threads model): no instance-wide key to wrap — the joiner |
| // generates their own user keypair on join. See docs/threads-and-dms.md. |
| const T = randomSecret(); |
| @@ -528,6 +558,7 @@ async function mintInvite(label, userId, agent, config, grantsAdmin) { |
| claim_hash: claimHash, |
| agent: !!agent, |
| grants_admin: !!grantsAdmin, |
| + tier_id: tierID || "", |
| ...(config || {}), // model, system_prompt, docs — agent only |
| }); |
| if (!res.ok) { |
diff --git a/web/static/apps/admin.js b/web/static/apps/admin.js| index 510ec0a..acf197b 100644 |
| --- a/web/static/apps/admin.js |
| +++ b/web/static/apps/admin.js |
| @@ -374,10 +374,19 @@ export const app = defineApp({ |
| // its members (the shell does all crypto — this section is pure UI). |
| const levelsSec = section("Access levels"); |
| const levelsDesc = document.createElement("p"); |
| - levelsDesc.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 12px"; |
| + levelsDesc.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 8px"; |
| levelsDesc.textContent = |
| - "Named sets of members — Coaches, Family, Founders. Soon a group can be limited to an access level: only its members can find and join it."; |
| + "Named sets of members — Coaches, Family, Founders. A group can be limited to an access level: only its members can find and join it."; |
| levelsSec.appendChild(levelsDesc); |
| + // The one constraint worth stating: gating a group means wrapping its key |
| + // under the level's key, which only that level's members hold — so you |
| + // must be in a level to make a group limited to it (an admin who curates |
| + // a level without joining it can't read what it gates, by design). |
| + const levelsNote = document.createElement("p"); |
| + levelsNote.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 12px"; |
| + levelsNote.textContent = |
| + "To make a group limited to a level, you need to be a member of that level yourself."; |
| + levelsSec.appendChild(levelsNote); |
| const levelsList = document.createElement("div"); |
| levelsSec.appendChild(levelsList); |
| const levelAddRow = document.createElement("div"); |
| @@ -385,143 +394,205 @@ export const app = defineApp({ |
| const levelName = document.createElement("input"); |
| levelName.type = "text"; |
| levelName.maxLength = 60; |
| - levelName.placeholder = "New level name (e.g. Coaches)"; |
| + levelName.placeholder = "New level name — e.g. Coach, VIP, MVP"; |
| levelName.setAttribute("aria-label", "New access level name"); |
| const levelAddBtn = document.createElement("button"); |
| levelAddBtn.textContent = "Add level"; |
| + levelName.addEventListener("keydown", (e) => { |
| + // The row isn't a <form> (the app builds plain DOM), so give the input |
| + // the form behavior people expect: Return creates the level. |
| + if (e.key === "Enter") { |
| + e.preventDefault(); |
| + levelAddBtn.click(); |
| + } |
| + }); |
| levelAddRow.append(levelName, levelAddBtn); |
| levelsSec.appendChild(levelAddRow); |
| const levelsStatus = statusLine(levelsSec); |
| - const paintLevels = async () => { |
| - const [levels, people] = await Promise.all([bridge.tiers(), Promise.resolve(bridge.levelMembers())]); |
| - levelsList.textContent = ""; |
| - if (!levels.length) { |
| - const p = document.createElement("p"); |
| - p.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 8px"; |
| - p.textContent = "No access levels yet."; |
| - levelsList.appendChild(p); |
| - return; |
| + // In-place list management — add appends, delete removes, saving members |
| + // swaps one box. A full repaint (initial load only) rebuilds everything; |
| + // doing that after every action flashed the whole section blank. |
| + let people = bridge.levelMembers(); |
| + const emptyNote = document.createElement("p"); |
| + emptyNote.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 8px"; |
| + emptyNote.textContent = "No access levels yet."; |
| + const syncEmptyNote = () => { |
| + if (!levelsList.querySelector("[data-level]")) levelsList.appendChild(emptyNote); |
| + else emptyNote.remove(); |
| + }; |
| + |
| + const buildLevelBox = (level) => { |
| + const box = document.createElement("div"); |
| + box.className = "admin-row"; |
| + box.dataset.level = level.id; |
| + box.style.cssText = "display:block;padding:10px 0"; |
| + const head = document.createElement("div"); |
| + head.style.cssText = "display:flex;align-items:center;gap:8px"; |
| + const name = document.createElement("strong"); |
| + name.textContent = level.name; |
| + const count = document.createElement("span"); |
| + count.style.cssText = "font-size:13px;color:var(--muted)"; |
| + const n = (level.members || []).length; |
| + count.textContent = n === 1 ? "1 member" : `${n} members`; |
| + const spacer = document.createElement("span"); |
| + spacer.style.flex = "1"; |
| + const editBtn = document.createElement("button"); |
| + editBtn.className = "linkish"; |
| + editBtn.textContent = "Members"; |
| + editBtn.setAttribute("aria-expanded", "false"); |
| + const delBtn = document.createElement("button"); |
| + delBtn.className = "linkish danger"; |
| + delBtn.textContent = "Delete"; |
| + head.append(name, count, spacer, editBtn, delBtn); |
| + box.appendChild(head); |
| + |
| + // Who can see who's in this level. "Members choose" hands each member |
| + // the toggle in their own profile. All three hide the roster only from |
| + // NON-members — a level's members share its key, so they always know |
| + // each other. |
| + const visRow = document.createElement("div"); |
| + visRow.style.cssText = "display:flex;align-items:center;gap:8px;margin-top:6px"; |
| + const visLabel = document.createElement("span"); |
| + visLabel.style.cssText = "font-size:12px;color:var(--muted)"; |
| + visLabel.textContent = "Shown on profiles:"; |
| + const visSel = document.createElement("select"); |
| + visSel.setAttribute("aria-label", `Who sees who's in ${level.name}`); |
| + for (const [val, text] of [ |
| + ["shown", "Everyone"], |
| + ["hidden", "Only its members"], |
| + ["user", "Members choose"], |
| + ]) { |
| + const o = document.createElement("option"); |
| + o.value = val; |
| + o.textContent = text; |
| + visSel.appendChild(o); |
| + } |
| + visSel.value = level.visibility || "shown"; |
| + visSel.onchange = async () => { |
| + const prev = level.visibility || "shown"; |
| + const want = visSel.value; |
| + visSel.disabled = true; |
| + const ok = await bridge.setTierPolicy(level.id, want); |
| + visSel.disabled = false; |
| + if (ok) { |
| + level.visibility = want; |
| + flash(levelsStatus, "Saved."); |
| + } else { |
| + visSel.value = prev; |
| + flash(levelsStatus, "Couldn't save — try again."); |
| + } |
| + }; |
| + visRow.append(visLabel, visSel); |
| + box.appendChild(visRow); |
| + |
| + if (level.key_lost) { |
| + // Minted once, every holder gone — unrecoverable by design (keys |
| + // never re-mint under groups wrapped to the old one). Say so. |
| + const lost = document.createElement("div"); |
| + lost.style.cssText = "font-size:12px;color:var(--danger);margin-top:4px"; |
| + lost.textContent = |
| + "This level's key was lost when its last member left. Delete it and create a new one."; |
| + box.appendChild(lost); |
| + } |
| + // Members whose key copy hasn't landed yet: honest inline status, the |
| + // same "on its way" register as photo syncing (never a toast). |
| + const missing = (level.members || []).filter((uid) => !(level.have || []).includes(uid)); |
| + if (!level.key_lost && missing.length) { |
| + const wait = document.createElement("div"); |
| + wait.style.cssText = "font-size:12px;color:var(--muted);margin-top:4px"; |
| + wait.textContent = |
| + missing.length === 1 |
| + ? "1 member's key is on its way — it arrives when a key holder is online." |
| + : `${missing.length} members' keys are on their way — they arrive when a key holder is online.`; |
| + box.appendChild(wait); |
| } |
| - const labelOf = (uid) => { |
| - const e = people.find((p) => p.user_id === uid); |
| - return e ? e.label : ""; |
| + |
| + // Refresh just this box from the server's rows (post-save): the box |
| + // swaps in place, the rest of the list never repaints. |
| + const refreshBox = async () => { |
| + const fresh = (await bridge.tiers()).find((t) => t.id === level.id); |
| + if (fresh) box.replaceWith(buildLevelBox(fresh)); |
| }; |
| - for (const level of levels) { |
| - const box = document.createElement("div"); |
| - box.className = "admin-row"; |
| - box.style.cssText = "display:block;padding:10px 0"; |
| - const head = document.createElement("div"); |
| - head.style.cssText = "display:flex;align-items:center;gap:8px"; |
| - const name = document.createElement("strong"); |
| - name.textContent = level.name; |
| - const count = document.createElement("span"); |
| - count.style.cssText = "font-size:13px;color:var(--muted)"; |
| - const n = (level.members || []).length; |
| - count.textContent = n === 1 ? "1 member" : `${n} members`; |
| - const spacer = document.createElement("span"); |
| - spacer.style.flex = "1"; |
| - const editBtn = document.createElement("button"); |
| - editBtn.className = "linkish"; |
| - editBtn.textContent = "Members"; |
| - editBtn.setAttribute("aria-expanded", "false"); |
| - const delBtn = document.createElement("button"); |
| - delBtn.className = "linkish danger"; |
| - delBtn.textContent = "Delete"; |
| - head.append(name, count, spacer, editBtn, delBtn); |
| - box.appendChild(head); |
| - |
| - if (level.key_lost) { |
| - // Minted once, every holder gone — unrecoverable by design (keys |
| - // never re-mint under groups wrapped to the old one). Say so. |
| - const lost = document.createElement("div"); |
| - lost.style.cssText = "font-size:12px;color:var(--danger);margin-top:4px"; |
| - lost.textContent = |
| - "This level's key was lost when its last member left. Delete it and create a new one."; |
| - box.appendChild(lost); |
| + |
| + let editor = null; |
| + editBtn.onclick = () => { |
| + if (editor) { |
| + editor.remove(); |
| + editor = null; |
| + editBtn.setAttribute("aria-expanded", "false"); |
| + return; |
| } |
| - // Members whose key copy hasn't landed yet: honest inline status, the |
| - // same "on its way" register as photo syncing (never a toast). |
| - const missing = (level.members || []).filter((uid) => !(level.have || []).includes(uid)); |
| - if (!level.key_lost && missing.length) { |
| - const wait = document.createElement("div"); |
| - wait.style.cssText = "font-size:12px;color:var(--muted);margin-top:4px"; |
| - wait.textContent = |
| - missing.length === 1 |
| - ? "1 member's key is on its way — it arrives when a key holder is online." |
| - : `${missing.length} members' keys are on their way — they arrive when a key holder is online.`; |
| - box.appendChild(wait); |
| + editBtn.setAttribute("aria-expanded", "true"); |
| + people = bridge.levelMembers(); // fresh roster snapshot for the picker |
| + editor = document.createElement("div"); |
| + editor.style.cssText = "margin:8px 0 4px;padding-left:4px"; |
| + const selected = new Set(level.members || []); |
| + for (const p of people) { |
| + const row = document.createElement("label"); |
| + row.style.cssText = "display:flex;align-items:center;gap:8px;padding:4px 0;cursor:pointer"; |
| + const cb = document.createElement("input"); |
| + cb.type = "checkbox"; |
| + cb.checked = selected.has(p.user_id); |
| + cb.onchange = () => (cb.checked ? selected.add(p.user_id) : selected.delete(p.user_id)); |
| + const l = document.createElement("span"); |
| + l.textContent = p.label; |
| + row.append(cb, l); |
| + editor.appendChild(row); |
| } |
| - |
| - let editor = null; |
| - editBtn.onclick = () => { |
| - if (editor) { |
| - editor.remove(); |
| - editor = null; |
| - editBtn.setAttribute("aria-expanded", "false"); |
| - return; |
| - } |
| - editBtn.setAttribute("aria-expanded", "true"); |
| - editor = document.createElement("div"); |
| - editor.style.cssText = "margin:8px 0 4px;padding-left:4px"; |
| - const selected = new Set(level.members || []); |
| - for (const p of people) { |
| - const row = document.createElement("label"); |
| - row.style.cssText = "display:flex;align-items:center;gap:8px;padding:4px 0;cursor:pointer"; |
| - const cb = document.createElement("input"); |
| - cb.type = "checkbox"; |
| - cb.checked = selected.has(p.user_id); |
| - cb.onchange = () => (cb.checked ? selected.add(p.user_id) : selected.delete(p.user_id)); |
| - const l = document.createElement("span"); |
| - l.textContent = p.label; |
| - row.append(cb, l); |
| - editor.appendChild(row); |
| - } |
| - const foot = document.createElement("div"); |
| - foot.style.cssText = "display:flex;gap:8px;margin-top:6px"; |
| - const save = document.createElement("button"); |
| - save.textContent = "Save members"; |
| - save.onclick = async () => { |
| - save.disabled = true; |
| - flash(levelsStatus, "Saving…"); |
| - const ok = await bridge.setTierMembers(level.id, [...selected]); |
| - flash(levelsStatus, ok ? "Saved." : "Couldn't save — try again."); |
| - await paintLevels(); |
| - }; |
| - const cancel = document.createElement("button"); |
| - cancel.className = "secondary"; |
| - cancel.textContent = "Cancel"; |
| - cancel.onclick = () => { |
| - editor.remove(); |
| - editor = null; |
| - editBtn.setAttribute("aria-expanded", "false"); |
| - }; |
| - foot.append(save, cancel); |
| - editor.appendChild(foot); |
| - box.appendChild(editor); |
| + const foot = document.createElement("div"); |
| + foot.style.cssText = "display:flex;gap:8px;margin-top:6px"; |
| + const save = document.createElement("button"); |
| + save.textContent = "Save members"; |
| + save.onclick = async () => { |
| + save.disabled = true; |
| + flash(levelsStatus, "Saving…"); |
| + const ok = await bridge.setTierMembers(level.id, [...selected]); |
| + flash(levelsStatus, ok ? "Saved." : "Couldn't save — try again."); |
| + await refreshBox(); |
| }; |
| - delBtn.onclick = async () => { |
| - if (!confirm(`Delete the "${level.name}" access level?`)) return; |
| - delBtn.disabled = true; |
| - const status = await bridge.deleteTier(level.id); |
| - flash( |
| - levelsStatus, |
| - status === 200 |
| - ? "Deleted." |
| - : status === 409 |
| - ? "Can't delete — groups are still limited to this level." |
| - : "Couldn't delete — try again.", |
| - ); |
| - await paintLevels(); |
| + const cancel = document.createElement("button"); |
| + cancel.className = "secondary"; |
| + cancel.textContent = "Cancel"; |
| + cancel.onclick = () => { |
| + editor.remove(); |
| + editor = null; |
| + editBtn.setAttribute("aria-expanded", "false"); |
| }; |
| - levelsList.appendChild(box); |
| - } |
| + foot.append(save, cancel); |
| + editor.appendChild(foot); |
| + box.appendChild(editor); |
| + }; |
| + delBtn.onclick = async () => { |
| + if (!confirm(`Delete the "${level.name}" access level?`)) return; |
| + delBtn.disabled = true; |
| + const status = await bridge.deleteTier(level.id); |
| + if (status === 200) { |
| + box.remove(); |
| + syncEmptyNote(); |
| + flash(levelsStatus, "Deleted."); |
| + return; |
| + } |
| + delBtn.disabled = false; |
| + flash( |
| + levelsStatus, |
| + status === 409 ? "Can't delete — groups are still limited to this level." : "Couldn't delete — try again.", |
| + ); |
| + }; |
| + return box; |
| + }; |
| + |
| + const paintLevels = async () => { |
| + const levels = await bridge.tiers(); |
| + people = bridge.levelMembers(); |
| + levelsList.textContent = ""; |
| + for (const level of levels) levelsList.appendChild(buildLevelBox(level)); |
| + syncEmptyNote(); |
| }; |
| levelAddBtn.onclick = async () => { |
| const name = levelName.value.trim(); |
| if (!name) return; |
| levelAddBtn.disabled = true; |
| - flash(levelsStatus, "Creating…"); |
| const id = await bridge.createTier(name); |
| levelAddBtn.disabled = false; |
| if (!id) { |
| @@ -530,7 +601,9 @@ export const app = defineApp({ |
| } |
| levelName.value = ""; |
| flash(levelsStatus, `"${name}" created — now add its members.`); |
| - await paintLevels(); |
| + // Appears in place — a full repaint here flashed the whole section. |
| + levelsList.appendChild(buildLevelBox({ id, name, members: [], have: [] })); |
| + syncEmptyNote(); |
| }; |
| paintLevels(); |
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js| index aa14410..940e029 100644 |
| --- a/web/static/apps/chat.js |
| +++ b/web/static/apps/chat.js |
| @@ -694,7 +694,9 @@ export const app = defineApp({ |
| // its action lines grow, marking the beginning, rather than a vanishing |
| // empty-state. host.chatIntro is null for a normal chat (no intro row). |
| const introText = host && host.chatIntro && host.chatIntro(ctx.threadId); |
| - if (introText) items.unshift({ id: "chatapp-intro", intro: true, text: introText, vrev: "intro" }); |
| + // vrev tracks the text so a dynamic intro (an open group's live "N people |
| + // here") re-renders when it changes; static intros keep a stable rev. |
| + if (introText) items.unshift({ id: "chatapp-intro", intro: true, text: introText, vrev: "intro|" + introText }); |
| keyedList(feed, items, { |
| key: (m) => m.id, |
| rev: (m) => m.vrev, |
diff --git a/web/static/join.js b/web/static/join.js| index 21ad2bb..d5aa5f9 100644 |
| --- a/web/static/join.js |
| +++ b/web/static/join.js |
| @@ -92,6 +92,9 @@ async function start() { |
| // only — it's never on the public read), so the welcome card can say what |
| // this place is before the joiner holds any key. |
| const spaceDesc = typeof info.description === "string" ? info.description : ""; |
| + // A cohort invite names the access level the joiner lands in (plaintext by |
| + // design, like the description — served to link-holders only). |
| + cohortName = typeof info.tier_name === "string" ? info.tier_name : ""; |
| // Note whether this instance has a branding photo (a public flag), so the |
| // onboarding cards below can show it instead of the placeholder emoji. |
| @@ -244,6 +247,8 @@ function markWelcomePending(suggested) { |
| // The welcome card: the invite is inspected (a non-consuming GET) but nothing is |
| // claimed until a button is pressed. In the native add-chat flow (__lchatEmbed) |
| // the person already chose explicitly in the app, so continue straight on. |
| +let cohortName = ""; // cohort invite: the level the joiner will be part of |
| + |
| // `spaceDesc` is the space's admin-set description (from the invite lookup). |
| function showWelcome(kind, onContinue, spaceDesc = "") { |
| if (window.__lchatEmbed) return onContinue(); |
| @@ -259,6 +264,15 @@ function showWelcome(kind, onContinue, spaceDesc = "") { |
| d.textContent = spaceDesc.slice(0, 600); |
| d.classList.remove("hidden"); |
| } |
| + if (cohortName && kind !== "device") { |
| + // The cohort line sits under the subtitle: arriving somewhere and being |
| + // told which circle you're joining is half the welcome. |
| + const c = document.createElement("p"); |
| + c.className = "sub"; |
| + c.textContent = `You'll be part of ${cohortName}.`.slice(0, 120); |
| + const sub = card.querySelector("#welcome-sub"); |
| + if (sub) sub.after(c); |
| + } |
| if (kind === "device") { |
| // document.title carries the substituted group name. |
| card.querySelector("#welcome-title").textContent = `Sign in to ${document.title} on this device`; |
diff --git a/web/static/shell.js b/web/static/shell.js| index ef5bee0..a5f27b2 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -1488,6 +1488,21 @@ window.addEventListener("focus", () => { |
| loadTiers(); |
| }); |
| +// A member's own show/hide for a level (honoured only under the "user" |
| +// policy). Used by the self-view in Edit profile; returns true on success. |
| +async function setTierShown(id, show) { |
| + try { |
| + const r = await fetch(`/api/tiers/${encodeURIComponent(id)}/show`, { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ token: session.token, show }), |
| + }); |
| + return r.ok; |
| + } catch { |
| + return false; |
| + } |
| +} |
| + |
| // Make sure every member of a level holds its key, as far as THIS client can: |
| // wrap with our copy if we hold one, or mint if nobody holds one yet (the mint |
| // moment — the server allows it for admins only). A minting admin who isn't a |
| @@ -3612,6 +3627,17 @@ setChatAppHost({ |
| const name = other && rosterLabel(other); |
| if (name) return `This is the start of your chat with ${name}.`; |
| } |
| + // An open community group opens with a start marker that invites the first |
| + // hello — a stranger arriving from Discover lands in a room that greets |
| + // them rather than a blank feed. Persists as the history's first row (it |
| + // scrolls up as the conversation grows); the count stays current as people |
| + // join (the intro re-renders when its text changes — see chat.js). |
| + if (t && t.kind === "group" && t.visibility === "open") { |
| + const name = threadName[threadId] || "this group"; |
| + const n = (t.members || []).length; |
| + const who = n === 1 ? "There's 1 person here." : `There are ${n} people here.`; |
| + return `This is the start of the conversation in ${name}. ${who} Say hi!`; |
| + } |
| return null; |
| }, |
| icons: { reply: ICON_REPLY, edit: ICON_EDIT, react: ICON_REACT, trash: ICON_TRASH, x: ICON_X, play: ICON_PLAY, copy: ICON_COPY }, |
| @@ -4137,7 +4163,18 @@ setSetupBridge({ |
| // bot to a level would widen what its sidecar can read — bot access stays a |
| // deliberate per-thread choice, docs/bots.md). |
| levelMembers: () => |
| - roster.filter((e) => e.public_key && !e.is_agent).map((e) => ({ user_id: e.user_id, label: e.label })), |
| + roster |
| + .filter((e) => e.public_key && !e.is_agent) |
| + .map((e) => ({ |
| + user_id: e.user_id, |
| + // Labels decrypt under the instance key and can be momentarily (or |
| + // legitimately) empty — a naked checkbox is not a member row. Name |
| + // yourself properly; strangers get an honest placeholder. |
| + label: |
| + e.user_id === myUserId |
| + ? `${e.label || session.name || "You"} (you)` |
| + : e.label || "Unnamed member", |
| + })), |
| createTier: async (name) => { |
| try { |
| const r = await fetch("/api/tiers", { |
| @@ -4168,6 +4205,32 @@ setSetupBridge({ |
| // Set a level's membership to exactly memberIds: assign the missing, remove |
| // the dropped, then get keys to whoever this client can reach (minting on |
| // first use — see ensureTierKeys for the no-copy-for-curators rule). |
| + // Admin sets a level's visibility policy: "shown" | "hidden" | "user". |
| + setTierPolicy: async (id, policy) => { |
| + try { |
| + const r = await fetch(`/api/tiers/${encodeURIComponent(id)}/policy`, { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ token: session.token, policy }), |
| + }); |
| + return r.ok; |
| + } catch { |
| + return false; |
| + } |
| + }, |
| + // A member's own show/hide for a level (honoured only under the "user" policy). |
| + setTierShown: async (id, show) => { |
| + try { |
| + const r = await fetch(`/api/tiers/${encodeURIComponent(id)}/show`, { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ token: session.token, show }), |
| + }); |
| + return r.ok; |
| + } catch { |
| + return false; |
| + } |
| + }, |
| setTierMembers: async (id, memberIds) => { |
| try { |
| await loadTiers(); |
| @@ -6704,6 +6767,25 @@ function viewProfile(uid) { |
| card.appendChild(p); |
| } |
| + // The person's access levels, as chips. Level names are plaintext space |
| + // policy already listed to every member by /api/tiers, so this surfaces |
| + // what the roster view exposes — no new disclosure. Populated from a fresh |
| + // fetch (the cache can lag a just-made level); hidden until there's one. |
| + if (!e.is_agent) { |
| + const chips = document.createElement("div"); |
| + chips.className = "level-chips"; |
| + card.appendChild(chips); |
| + loadTiers().then(() => { |
| + const mine = tiers.filter((tt) => (tt.members || []).includes(uid)); |
| + for (const tt of mine) { |
| + const chip = document.createElement("span"); |
| + chip.className = "level-chip"; |
| + chip.textContent = tt.name; |
| + chips.appendChild(chip); |
| + } |
| + }); |
| + } |
| + |
| // Message them — the card's action when it was reached from somewhere other |
| // than their DM (the member sheet, a mention). Inside their DM it's noise. |
| const dm = existingDM(uid); |
| @@ -7295,6 +7377,36 @@ const disc = document.createElement("button"); |
| who.maxLength = 60; |
| who.className = "sheet-input"; |
| card.appendChild(who); |
| + // Cohort invites, right where people actually invite. The select lives |
| + // in the DOM from the start (keeping its place between the name field |
| + // and the buttons) but populates from a FRESH fetch — the module `tiers` |
| + // cache can lag a level just created in the Admin app, or another |
| + // member's change this session. Hidden until it has an option. |
| + const inviteTierSel = document.createElement("select"); |
| + inviteTierSel.className = "sheet-input"; |
| + inviteTierSel.setAttribute("aria-label", "Access level for this invite"); |
| + inviteTierSel.style.display = "none"; |
| + card.appendChild(inviteTierSel); |
| + loadTiers().then(() => { |
| + const meEntry = roster.find((e) => e.user_id === myUserId); |
| + const isAdmin = !!(meEntry && meEntry.role === "admin"); |
| + // The flat-group rule applied to levels: you can bring someone into a |
| + // level you're IN (you hold its key already); admins mint for any |
| + // level. The server enforces the same line. |
| + const usableLevels = tiers.filter((t) => !t.key_lost && (isAdmin || t.mine)); |
| + if (!usableLevels.length) return; |
| + const none = document.createElement("option"); |
| + none.value = ""; |
| + none.textContent = "No access level"; |
| + inviteTierSel.appendChild(none); |
| + for (const t of usableLevels) { |
| + const o = document.createElement("option"); |
| + o.value = t.id; |
| + o.textContent = `Add them to ${t.name}`; |
| + inviteTierSel.appendChild(o); |
| + } |
| + inviteTierSel.style.display = ""; |
| + }); |
| const foot = document.createElement("div"); |
| foot.className = "sheet-foot"; |
| const cancel = document.createElement("button"); |
| @@ -7305,7 +7417,8 @@ const disc = document.createElement("button"); |
| create.textContent = "Create invite"; |
| create.disabled = true; |
| who.addEventListener("input", () => (create.disabled = !who.value.trim())); |
| - create.onclick = () => go(inviteSomeone(who.value.trim()), create); |
| + create.onclick = () => |
| + go(inviteSomeone(who.value.trim(), (inviteTierSel && inviteTierSel.value) || ""), create); |
| submitOnEnter(who, () => create.click()); |
| foot.appendChild(cancel); |
| foot.appendChild(create); |
| @@ -7339,7 +7452,7 @@ const disc = document.createElement("button"); |
| return; |
| } |
| - let nameInput, openCb, tierSel; |
| + let nameInput, openCb, tierSel, defaultCb; |
| if (mode === "group") { |
| nameInput = document.createElement("input"); |
| nameInput.placeholder = "Group name (optional)"; |
| @@ -7388,6 +7501,35 @@ const disc = document.createElement("button"); |
| }); |
| card.appendChild(tierSel); |
| } |
| + // Admins can make an open group one that new members auto-join |
| + // (a #general). Admin-only, and only while open + ungated (a gated |
| + // group can't be default — the server refuses; a newcomer holds no |
| + // level yet). Mirrors the Edit-group toggle. |
| + const meEntry = roster.find((e) => e.user_id === myUserId); |
| + if (meEntry && meEntry.role === "admin") { |
| + const drow = document.createElement("label"); |
| + drow.className = "opt-row"; |
| + defaultCb = document.createElement("input"); |
| + defaultCb.type = "checkbox"; |
| + const dtxt = document.createElement("span"); |
| + dtxt.textContent = "New members join automatically"; |
| + const ddesc = document.createElement("span"); |
| + ddesc.className = "opt-desc"; |
| + ddesc.textContent = "Everyone who joins the space starts in this group."; |
| + dtxt.appendChild(ddesc); |
| + drow.appendChild(defaultCb); |
| + drow.appendChild(dtxt); |
| + card.appendChild(drow); |
| + const syncDefault = () => { |
| + const gated = tierSel && tierSel.value; |
| + const on = openCb.checked && !gated; |
| + drow.style.display = on ? "" : "none"; |
| + if (!on) defaultCb.checked = false; |
| + }; |
| + syncDefault(); |
| + openCb.addEventListener("change", syncDefault); |
| + if (tierSel) tierSel.addEventListener("change", syncDefault); |
| + } |
| } |
| } |
| @@ -7492,6 +7634,7 @@ const disc = document.createElement("button"); |
| name: nameInput.value.trim(), |
| open: !!(openCb && openCb.checked), |
| openTier: (openCb && openCb.checked && tierSel && tierSel.value) || "", |
| + default: !!(defaultCb && defaultCb.checked), |
| }), |
| create, |
| ); |
| @@ -7764,6 +7907,16 @@ async function createThread(otherIds, opts = {}) { |
| threadKeys[r.thread_id] = key; |
| threadKeyB64[r.thread_id] = b64; |
| } |
| + // An open, ungated group can be marked default (new members auto-join) at |
| + // creation — a separate admin-gated call after the thread exists. Ungated |
| + // only: the server refuses default on a level-gated group. |
| + if (opts.default && opts.open && !opts.openTier && !r.existing) { |
| + await fetch(`/api/threads/${encodeURIComponent(r.thread_id)}/default`, { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ token: session.token, default: true }), |
| + }).catch(() => {}); |
| + } |
| return r.thread_id; |
| } |
| @@ -7773,7 +7926,7 @@ async function createThread(otherIds, opts = {}) { |
| // to join. The browser generates the DM key, wraps it to us (so we hold it) and |
| // encrypts it to the invite secret (so the joiner recovers it) — the server only |
| // stores blobs. Returns the pending thread id; the link lands in inviteLinks. |
| -async function inviteSomeone(name) { |
| +async function inviteSomeone(name, tierID = "") { |
| const T = randomSecret(); |
| const { id, wrapKey, claimHash } = await deriveInvite(T); |
| const { key, b64 } = await generateKey(); |
| @@ -7789,7 +7942,7 @@ async function inviteSomeone(name) { |
| await fetch("/api/invites", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| - body: JSON.stringify({ token: session.token, label_blob: labelBlob, id, wrapped, self_wrapped: selfWrapped, claim_hash: claimHash }), |
| + body: JSON.stringify({ token: session.token, label_blob: labelBlob, id, wrapped, self_wrapped: selfWrapped, claim_hash: claimHash, tier_id: tierID }), |
| }) |
| ).json(); |
| if (!r.thread_id) throw new Error("invite failed"); |
| @@ -8179,6 +8332,65 @@ function editProfile() { |
| card.appendChild(aboutLabel); |
| card.appendChild(about); |
| + // Your access levels — read-only here (an admin, or another member of the |
| + // level, grants them; docs/community-and-tiers.md). The self-facing mirror |
| + // of the chips on someone else's profile card; fetched fresh, hidden when |
| + // you're in none so members of spaces without levels see nothing. |
| + const levelLabel = document.createElement("label"); |
| + levelLabel.className = "field-label"; |
| + levelLabel.style.marginTop = "14px"; |
| + levelLabel.textContent = "Your access levels"; |
| + levelLabel.style.display = "none"; |
| + const levelChips = document.createElement("div"); |
| + levelChips.className = "level-chips"; |
| + levelChips.style.justifyContent = "flex-start"; // left-aligned under the label |
| + card.appendChild(levelLabel); |
| + card.appendChild(levelChips); |
| + const levelNotes = document.createElement("div"); |
| + card.appendChild(levelNotes); |
| + loadTiers().then(() => { |
| + const mine = tiers.filter((tt) => (tt.members || []).includes(myUserId)); |
| + if (!mine.length) return; |
| + levelLabel.style.display = ""; |
| + for (const tt of mine) { |
| + const chip = document.createElement("span"); |
| + chip.className = "level-chip"; |
| + chip.textContent = tt.name; |
| + levelChips.appendChild(chip); |
| + } |
| + // Per-level visibility in the member's own words: |
| + // - "user" → a live toggle they control. |
| + // - "hidden" → a muted note (members-only; no control). |
| + // - "shown" → nothing (the default; a note per level would be clutter). |
| + for (const tt of mine) { |
| + if (tt.visibility === "user") { |
| + const row = document.createElement("label"); |
| + row.style.cssText = |
| + "display:flex;align-items:center;gap:8px;font-size:13px;color:var(--muted);margin-top:8px;cursor:pointer"; |
| + const cb = document.createElement("input"); |
| + cb.type = "checkbox"; |
| + cb.checked = !tt.hidden; // hidden=true → currently NOT shown to others |
| + cb.onchange = async () => { |
| + cb.disabled = true; |
| + const ok = await setTierShown(tt.id, cb.checked); |
| + cb.disabled = false; |
| + if (ok) tt.hidden = !cb.checked; |
| + else cb.checked = !cb.checked; // revert on failure |
| + }; |
| + const txt = document.createElement("span"); |
| + txt.textContent = `Show “${tt.name}” on my profile`; |
| + row.append(cb, txt); |
| + levelNotes.appendChild(row); |
| + } else if (tt.visibility === "hidden") { |
| + const note = document.createElement("p"); |
| + note.className = "sub"; |
| + note.style.margin = "6px 0 0"; |
| + note.textContent = `“${tt.name}” shows only to its members.`; |
| + levelNotes.appendChild(note); |
| + } |
| + } |
| + }); |
| + |
| // Away: pins your presence dot to away, so activity doesn't flip it back to |
| // online. Applies immediately (server-stored, follows you across devices) — |
| // Save is only for the name. |
diff --git a/web/static/style.css b/web/static/style.css| index 6dc1918..b903e2b 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -2010,6 +2010,14 @@ input.filter-check:focus-visible { outline: 2px solid var(--accent); outline-off |
| .p-dot.offline { background: var(--offline); } |
| /* Member sheet: the muted status note beside a member's name. */ |
| .member-presence { margin-left: 8px; font-size: 12px; color: var(--muted); flex: none; } |
| +/* Access-level chips on the profile card — the person's levels ("Coach", |
| + "VIP"), a quiet accent-tinted badge (names are plaintext space policy). */ |
| +.level-chips { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin: 8px 0 4px; } |
| +.level-chip { |
| + font-size: 12px; font-weight: 600; line-height: 1; |
| + color: var(--accent-ink); background: var(--accent); |
| + border-radius: 999px; padding: 5px 10px; |
| +} |
| .thread-row-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; } |
| .thread-row-name { font-size: 16px; font-weight: 600; display: flex; align-items: center; min-width: 0; } |
| .thread-row-name-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |