Conversational onboarding: Eleven sets up new instances, plus the Admin app (#257)
* docs: conversational onboarding + admin mini-app design
Captures the 2026-07-06 design discussion: chat-first onboarding for both
server admins (Eleven-scripted setup replacing /admin, ending in a stern
instance-credential key ceremony) and joiners (welcome script: name, avatar,
save-profile, tour). Prerequisite user roles (admin/moderator/member),
the admin mini-app, honest scoping of the AI toggle, and a phased slicing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* server: user roles + admin-granting setup invites
users.role (admin|moderator|member), key_invites.grants_admin; claiming a
setup invite makes you admin; /api/admin/* accepts an admin-role member's
device token; /api/me exposes role + setup_complete; add-instance prints a
one-time /join/setup#T link (Go HKDF derivation cross-checked against
crypto.js) and a setup-invite subcommand re-mints one.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: Eleven's first-run setup conversation
Claiming a setup invite lands the new admin in a chat with Eleven (a
client-driven script like /tutorial — no AI, no bot needed) that walks
name, space name, contact email, server photo, member invites, and a
stern key-backup ceremony ending in the passkey enrol flow. The
conversation lives in a system-scope companion app thread; completed
steps persist as encrypted setup.step events (action lines + a progress
Interface), so the script resumes across reloads and the passkey
round-trip. Typed answers ride the composer via a new one-waiter-per-
thread answer hand-off in apps.js.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: session may be nameless until setup asks; pin setup feed to newest
A setup-invite claim signs in with an empty name (the conversation asks
for it), so loadSession accepts name:'' — and the setup thread scrolls
to each new question as it appends.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: conversational polish for the setup flow
Eleven now types before each reply (queued notices with a length-scaled
beat), never offers the compiled-in default as a keepable space name
(/api/group grows name_set), previews the uploaded server photo in the
reply with a change-it loop, and the passkey step offers a Continue path
when the ceremony happens outside this page (native/system browser) —
plus an explicit confirmation when it can verify the enrolment.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: one backup voice + conversational feel for setup
The sidebar 'Save securely' banner stands down while Eleven's setup
conversation runs; skipping the key ceremony starts an escalating
reminder schedule (24h → 72h → 1w, then the classic snooze/opt-out).
Notices ease in like live messages and pin the feed via pinNext (fixes
chips not scrolling), the photo preview reserves its box so the scroll
lands right, the typing beat is slower with natural jitter, and the
contact-email copy says what it is: a way to reach you outside the chat.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: input-hint chips — label what to type, not just what to tap
Questions in the setup conversation now carry a ghost 'Type your name…'
chip below the bubble (the inverse of an action chip; tapping focuses
the composer), so the expected reply is visible at a glance instead of
spelled out inside the prose. Email retries re-ask in one bubble.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: context/request message split; drop the input-hint chip; honest save errors
Questions now come as two bubbles — scene-setting first, then the ask
('Reply with your name') — instead of a faux-input hint chip. Failed
admin writes distinguish a rejected value from an auth/network error,
so a healthy name never gets blamed for a broken save (the canary loop
was old server code 401ing the role path).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: Admin app + Eleven presents as BOT; context/question bubble pairs
First cut of the in-app Admin panel (docs/conversational-onboarding.md
phase 2): system-scope, admin-only, opened via /admin or the setup
finish's 'Open admin' chip — space name, contact email, member-invite
toggle, server photo, one-time invite links, and the people list, all
through the host bridge. The setup thread badges BOT (senderKind
overrides the APP chip), each step is a multi-line context bubble plus
a single-line question, the key ceremony splits the same way, and
notices join the message grid so the typing indicator lines up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: notices stack in sender runs and read as a participant
Consecutive notices from the same speaker keep one header (name + BOT
badge) and tuck together like message runs; in a solo thread (setup —
no other members) the 'Only visible to you' tag is implicit and drops;
Eleven's opener types noticeably faster than mid-conversation replies.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: hold the loading cover until the setup conversation is open
First run revealed the empty home for a beat before Eleven's thread
opened. The role/setup fetch now starts alongside boot's loads and boot
awaits maybeStartSetup before dropping the cover — the first thing a
new admin sees is the conversation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: one loading state across join and first run
The join page adopts the app's full-screen loader (#app-loading): it
covers initial load until the welcome card is ready, and every in-flight
claim state ('Setting up…', 'Signing you in…') — the per-card Loading
heading and spinner-card variants are gone. With boot already holding
the cover until Eleven's thread opens, claim → chat is one continuous
loader.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: the server photo arrives through the chat
Eleven now asks you to SEND a photo (the + button / paste), like any
picture message: answer waiters carry the full sent payload, the host
decrypts the attachment and re-posts its bytes as the plaintext branding
photo, and the preview/keep/change loop rides on top. Skipping disarms
the photo waiter so later typed messages aren't mistaken for attempts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* server+web: photo alt text rides with the branding image; fix adopt-photo CSP failure
The photo step's decrypt path used fetch(blob:) which connect-src 'self'
blocks — adoptServerPhoto now decrypts straight to bytes (loadBlobBytes).
Text sent alongside the photo becomes its alt text: stored server-side
with the image (?alt= on upload, served by /api/group, reset with the
photo), and applied wherever it renders — join/welcome cards, the
in-chat preview, the admin panel. Eleven's prompt says how.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: clearer key-ceremony record lines
'backed up the key with a passkey' → 'Saved your credentials securely
with a passkey' (and matching skip/outside-browser variants).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: the member-invites setting is a switch
A proper on/off switch (self-contained in the admin app's styles) with
the state visible at a glance, reverting on a failed save — instead of a
button whose label encoded the state.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: trim the admin panel to admin-only concerns
The invites switch gets room to breathe (no more wrapped label); the
'Invite someone' and 'People' sections go — inviting is part of the main
chat experience and members already show up in New chat. The panel keeps
what only an admin can touch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: admin panel in three clean sections
This space (name + contact, one Save), Permissions (the sentence on the
left, the switch on the right — no column label), Photo (preview +
Change / Remove).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: the switch row escapes the label-column rule
The permissions switch label is a <label> inside .admin-row, so the
generic '.admin-row label { flex: 0 0 110px }' column rule was pinning
the sentence to a sliver — the actual cause of every squish so far. A
more specific selector lets the row be sentence-left, switch-right.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: the admin panel opens with the invitation it controls
Design pass: the one card on the page is now a live miniature of the
join card — photo, 'You're invited to <name>', the real sub-line —
updating as you type, captioned 'What people see when you invite them'
(these settings ARE the keyless branding surface, so show the artifact).
The three groups sit unboxed beneath small-caps eyebrows and hairlines;
inputs get a proper focus ring.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: the setup thread is chat-only — its progress card is retired
A third app-thread presentation, chatOnly: a companion that is ONLY its
chat view (chatMessage projections, no Interface pane, no view toggle).
The setup conversation adopts it; its 'Setting up this space' card was
superseded by the Admin panel.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* review fixes: close the ten confirmed findings
- Alt text consent: the preview echoes the exact text and says it's
public before it can be kept (it left an encrypted chat)
- Setup invites expire after 7 days (a stale admin-granting link in
scrollback must not mint an admin later) + test
- setup_complete backfills once on established instances, so promoting
an admin never triggers first-run onboarding on a live space + test
- The photo adopt runs inside the step guard (no branding installed
when Skip wins the race)
- /api/me is time-boxed (4s) so a stalled fetch can't hold the reveal
- Nameless setup claimers send as 'Admin', never a blank sender
- Notice continuations keep 'Only visible to you' outside solo threads
- Banner deferral re-anchors at each dismissal (rungs are gaps, not
absolute deadlines)
- The admin panel gains an alt-text field; photo changes carry it and
settings can update it without re-upload
- Alt truncation is rune-safe (no invalid UTF-8 from a byte slice)
Plus: changelog/doc status accuracy, parallel groupInfo fetches, the
32-char name cap applied at the source.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
21 files changed,
+2257
−84
changelog/2026-07-06-admin-app.md+5 −0changelog/2026-07-06-setup-conversation.md+5 −0control.go+25 −9db.go+86 −11docs/conversational-onboarding.md+264 −0handlers_test.go+38 −0main.go+90 −7roles_test.go+185 −0setup_invite.go+141 −0setup_invite_test.go+91 −0test/apps.test.mjs+55 −0test/backup.test.mjs+29 −1web/pages/join.html+7 −3web/static/apps.js+90 −9web/static/apps/admin.js+324 −0web/static/apps/chat.js+41 −4web/static/apps/setup.js+403 −0web/static/backup.js+56 −6web/static/join.js+43 −12web/static/shell.js+257 −21web/static/style.css+22 −1
diff --git a/changelog/2026-07-06-admin-app.md b/changelog/2026-07-06-admin-app.md| new file mode 100644 |
| index 0000000..1543a4b |
| --- /dev/null |
| +++ b/changelog/2026-07-06-admin-app.md |
| @@ -0,0 +1,5 @@ |
| +🤖 Admins get an Admin panel right inside the app: type `/admin` in any chat to |
| +open it. It opens with a live preview of the invitation people see, and lets |
| +you rename the space, set a contact email, change the photo (with alt text), |
| +and choose whether members can invite. Only visible to a space's admins — and |
| +Eleven now introduces it at the end of first-run setup. |
diff --git a/changelog/2026-07-06-setup-conversation.md b/changelog/2026-07-06-setup-conversation.md| new file mode 100644 |
| index 0000000..7b904eb |
| --- /dev/null |
| +++ b/changelog/2026-07-06-setup-conversation.md |
| @@ -0,0 +1,5 @@ |
| +🤖 Setting up a brand-new space is now a conversation. The person who stands one |
| +up lands straight in a chat with Eleven, who walks through the essentials — |
| +your name, the space's name, a contact email, a photo, whether members can |
| +invite people — and, most importantly, helps you back up your key before you |
| +do anything else. No admin console required. |
diff --git a/control.go b/control.go| index cad2289..20352c2 100644 |
| --- a/control.go |
| +++ b/control.go |
| @@ -189,7 +189,17 @@ func runAddInstance(args []string) { |
| die("add-instance: reload %s: %v (route added; run: systemctl reload %s)", *routerUnit, err, *routerUnit) |
| } |
| - fmt.Printf("added %s\n admin: https://%s/admin#token=%s\n (recover later: lchat admin-link %s)\n", host, host, token, host) |
| + fmt.Printf("added %s\n", host) |
| + // The first-user setup link: claiming it makes you the instance's admin and |
| + // lands you in the chat view, where the Eleven setup conversation takes over. |
| + // Minting can only fail against an unhealthy instance (we just health-checked), |
| + // but if it does the admin link below still bootstraps the old way. |
| + if link, err := mintSetupInvite(socketClient(socket), "http://lchat", "https://"+host, token); err != nil { |
| + fmt.Printf(" (setup link unavailable: %v — mint later: lchat setup-invite %s)\n", err, host) |
| + } else { |
| + fmt.Printf(" get started (one-time link, makes you the admin):\n %s\n", link) |
| + } |
| + fmt.Printf(" admin token link: https://%s/admin#token=%s\n (recover later: lchat admin-link %s / lchat setup-invite %s)\n", host, token, host, host) |
| } |
| // runAddService: `lchat add-service <host> --socket <path>` — front an |
| @@ -441,14 +451,10 @@ func rollbackLive(host, prev, unit, socket string) { |
| _ = waitHealthy(socket, 20*time.Second) |
| } |
| -// waitHealthy blocks until the instance answers an HTTP request on its socket, or |
| -// the timeout elapses. It retries, so it's robust to the restart window (socket |
| -// briefly absent, or a stale socket left by an abruptly-killed old process) — and |
| -// a broken new binary that never becomes healthy fails the deploy before `--all` |
| -// rolls it to the rest of the fleet. |
| -func waitHealthy(socket string, timeout time.Duration) error { |
| - deadline := time.Now().Add(timeout) |
| - client := &http.Client{ |
| +// socketClient returns an HTTP client that dials an instance's unix socket |
| +// (request URLs use the placeholder host http://lchat). |
| +func socketClient(socket string) *http.Client { |
| + return &http.Client{ |
| Timeout: 3 * time.Second, |
| Transport: &http.Transport{ |
| DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { |
| @@ -456,6 +462,16 @@ func waitHealthy(socket string, timeout time.Duration) error { |
| }, |
| }, |
| } |
| +} |
| + |
| +// waitHealthy blocks until the instance answers an HTTP request on its socket, or |
| +// the timeout elapses. It retries, so it's robust to the restart window (socket |
| +// briefly absent, or a stale socket left by an abruptly-killed old process) — and |
| +// a broken new binary that never becomes healthy fails the deploy before `--all` |
| +// rolls it to the rest of the fleet. |
| +func waitHealthy(socket string, timeout time.Duration) error { |
| + deadline := time.Now().Add(timeout) |
| + client := socketClient(socket) |
| var last error |
| for time.Now().Before(deadline) { |
| resp, err := client.Get("http://lchat/") |
diff --git a/db.go b/db.go| index 9f2e457..552e4e3 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -32,6 +32,14 @@ func (db *DB) setGroupName(name string) error { |
| return db.setConfig("group_name", name) |
| } |
| +// groupNameSet reports whether an admin explicitly named the instance (vs the |
| +// compiled-in default) — the setup conversation asks fresh instead of offering |
| +// a default to keep. |
| +func (db *DB) groupNameSet() bool { |
| + v, ok, _ := db.getConfig("group_name") |
| + return ok && strings.TrimSpace(v) != "" |
| +} |
| + |
| // getAdminName / getAdminEmail return the optional administrator identity an |
| // admin may publish to members (who runs this space + how to reach them). Shown |
| // by Eleven's guided tour and answers. Plaintext branding like the group name — |
| @@ -43,6 +51,14 @@ func (db *DB) getAdminEmail() string { v, _, _ := db.getConfig("admin_email"); r |
| func (db *DB) setAdminName(v string) error { return db.setConfig("admin_name", v) } |
| func (db *DB) setAdminEmail(v string) error { return db.setConfig("admin_email", v) } |
| +// setupComplete gates the first-run Eleven setup conversation: false until the |
| +// admin finishes (or dismisses) it. See docs/conversational-onboarding.md. |
| +func (db *DB) setupComplete() bool { |
| + v, _, _ := db.getConfig("setup_complete") |
| + return v == "1" |
| +} |
| +func (db *DB) setSetupComplete(on bool) error { return db.setConfig("setup_complete", boolStr(on)) } |
| + |
| // getInstanceAvatar returns the admin-set instance branding photo (bytes + MIME + |
| // an updated-at stamp for cache validation), or ok=false when none is set. Like the |
| // group name, this is plaintext branding the server serves in the clear. |
| @@ -369,11 +385,37 @@ CREATE TABLE IF NOT EXISTS connected_services ( |
| // Blobs gain a thread tag (for thread-scoped GC + quota accounting). Older |
| // blobs (branding, pre-disk photos) stay NULL and are never thread-GC'd. |
| "ALTER TABLE blobs ADD COLUMN thread_id TEXT", |
| + // Roles (docs/conversational-onboarding.md): 'admin' | 'moderator' | |
| + // 'member'. Authorisation only — a role grants no key material. The admin |
| + // bearer token remains as break-glass; role-admins reach the same admin API |
| + // with their own device token. Moderator is reserved (nothing enforces it |
| + // yet). Existing members default to 'member'. |
| + "ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'member'", |
| + // A setup invite (minted by add-instance for a fresh instance) makes its |
| + // claimer the first admin. Ordinary invites never carry this. |
| + "ALTER TABLE key_invites ADD COLUMN grants_admin INTEGER NOT NULL DEFAULT 0", |
| } { |
| if _, err := db.sql.Exec(c); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| return err |
| } |
| } |
| + // Established instances never ran the setup conversation — mark it complete |
| + // ONCE at upgrade time, so promoting someone to admin later doesn't drop them |
| + // into first-run onboarding on a live space. Guarded by its own marker: a |
| + // fresh instance (no devices yet) records the marker but leaves setup pending |
| + // for its first claimer, and a restart mid-setup doesn't re-run the check. |
| + if v, _, _ := db.getConfig("setup_backfilled"); v != "1" { |
| + var devices int |
| + db.sql.QueryRow("SELECT COUNT(*) FROM device_tokens").Scan(&devices) |
| + if devices > 0 { |
| + if err := db.setSetupComplete(true); err != nil { |
| + return err |
| + } |
| + } |
| + if err := db.setConfig("setup_backfilled", "1"); err != nil { |
| + return err |
| + } |
| + } |
| // Older push/APNs rows stored the session token in clear; replace it with its |
| // hash and scrub the plaintext so a stolen DB yields no live credentials. |
| return db.backfillPushTokenHashes() |
| @@ -566,6 +608,8 @@ type User struct { |
| IsAgent bool `json:"is_agent,omitempty"` |
| // Kind is "ai" or "bot" for agents (drives the admin AI/BOT badge); "" for people. |
| Kind string `json:"kind,omitempty"` |
| + // Role is 'admin' | 'moderator' | 'member' (authorisation only, no keys). |
| + Role string `json:"role"` |
| } |
| func (db *DB) createUser(label string) (string, error) { |
| @@ -580,7 +624,7 @@ func (db *DB) createUser(label string) (string, error) { |
| func (db *DB) listUsers() ([]User, error) { |
| now := time.Now().Unix() |
| rows, err := db.sql.Query(` |
| - SELECT u.id, u.label, u.created_at, COALESCE(u.is_agent, 0), |
| + SELECT u.id, u.label, u.created_at, COALESCE(u.is_agent, 0), COALESCE(u.role, 'member'), |
| (SELECT COUNT(*) FROM device_tokens d WHERE d.user_id = u.id), |
| (SELECT MIN(d.created_at) FROM device_tokens d WHERE d.user_id = u.id), |
| (SELECT COUNT(*) FROM key_invites k WHERE k.user_id = u.id |
| @@ -599,7 +643,7 @@ func (db *DB) listUsers() ([]User, error) { |
| var joinedAt sql.NullInt64 |
| var pending, pendingDM, isAgent int |
| var kind string |
| - if err := rows.Scan(&u.ID, &u.Label, &u.CreatedAt, &isAgent, &u.Devices, &joinedAt, &pending, &pendingDM, &kind); err != nil { |
| + if err := rows.Scan(&u.ID, &u.Label, &u.CreatedAt, &isAgent, &u.Role, &u.Devices, &joinedAt, &pending, &pendingDM, &kind); err != nil { |
| return nil, err |
| } |
| if joinedAt.Valid { |
| @@ -707,22 +751,24 @@ func (db *DB) createUserInvite(inviterID, inviteID, labelBlob, bootstrapWrapped, |
| } |
| type KeyInvite struct { |
| - ID string |
| - UserID string |
| - Wrapped string |
| - ThreadID string // the pending DM this invite drops the joiner into ("" if none) |
| - ClaimHash string // hashToken of the claim secret; "" for pre-migration invites |
| - Used bool |
| - Expired bool |
| + ID string |
| + UserID string |
| + Wrapped string |
| + 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 |
| + Used bool |
| + Expired bool |
| } |
| func (db *DB) getKeyInvite(id string) (KeyInvite, bool, error) { |
| var ki KeyInvite |
| var used, exp sql.NullInt64 |
| var threadID, claimHash sql.NullString |
| + var grantsAdmin int |
| err := db.sql.QueryRow( |
| - "SELECT id, user_id, wrapped, thread_id, claim_hash, used_at, expires_at FROM key_invites WHERE id = ?", id, |
| - ).Scan(&ki.ID, &ki.UserID, &ki.Wrapped, &threadID, &claimHash, &used, &exp) |
| + "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) |
| if err == sql.ErrNoRows { |
| return KeyInvite{}, false, nil |
| } |
| @@ -731,11 +777,19 @@ func (db *DB) getKeyInvite(id string) (KeyInvite, bool, error) { |
| } |
| ki.ThreadID = threadID.String |
| ki.ClaimHash = claimHash.String |
| + ki.GrantsAdmin = grantsAdmin != 0 |
| ki.Used = used.Valid |
| ki.Expired = exp.Valid && time.Now().Unix() > exp.Int64 |
| return ki, true, nil |
| } |
| +// setInviteGrantsAdmin marks an unclaimed invite as a setup invite: whoever |
| +// claims it becomes an admin. Only reachable via admin-authed invite creation. |
| +func (db *DB) setInviteGrantsAdmin(id string) error { |
| + _, err := db.sql.Exec("UPDATE key_invites SET grants_admin = 1 WHERE id = ? AND used_at IS NULL", id) |
| + return err |
| +} |
| + |
| // claimKeyInvite marks an unused, unexpired invite as used (single-use) and mints |
| // a device token for its user. Returns the plaintext token (shown once). ok=false |
| // (no error) if the invite is unknown, used, expired, or lost a claim race. |
| @@ -787,9 +841,30 @@ func (db *DB) claimKeyInvite(id, deviceLabel, wrappedSelf, claimSecret string) ( |
| return "", false, err |
| } |
| } |
| + if ki.GrantsAdmin { |
| + // A setup invite: its claimer is the (first) admin. Role is authorisation |
| + // only — no key material rides on it. |
| + if err := db.setUserRole(ki.UserID, "admin"); err != nil { |
| + return "", false, err |
| + } |
| + } |
| return tok, true, nil |
| } |
| +// userRole returns a user's role ('member' when unset or unknown). |
| +func (db *DB) userRole(id string) string { |
| + var r string |
| + if db.sql.QueryRow("SELECT COALESCE(role, 'member') FROM users WHERE id = ?", id).Scan(&r) != nil || r == "" { |
| + return "member" |
| + } |
| + return r |
| +} |
| + |
| +func (db *DB) setUserRole(id, role string) error { |
| + _, err := db.sql.Exec("UPDATE users SET role = ? WHERE id = ?", role, id) |
| + return err |
| +} |
| + |
| // deviceCount returns how many devices (tokens) a user currently has signed in — so |
| // the client can warn before signing out the LAST one (which loses their keys). |
| func (db *DB) deviceCount(userID string) int { |
diff --git a/docs/conversational-onboarding.md b/docs/conversational-onboarding.md| new file mode 100644 |
| index 0000000..2fad95d |
| --- /dev/null |
| +++ b/docs/conversational-onboarding.md |
| @@ -0,0 +1,264 @@ |
| +# 🤖 Conversational onboarding & the admin mini-app — design |
| + |
| +Status: **phases 1–3 implemented** (roles, the setup conversation, the Admin |
| +app v1 — landed with this doc); later phases still proposed. Captured from |
| +design discussion 2026-07-06. |
| +Builds on `onboarding-and-home-server.md` (welcome page, home-server vault, |
| +fragility banner — phases 1–3a shipped) and the `/tutorial` mini-app |
| +(`web/static/apps/tutorial.js`), whose client-driven, in-Eleven's-voice pattern |
| +this design generalises. |
| + |
| +## Goals |
| + |
| +Two onboarding moments, one shape for both: |
| + |
| +- **Server-admin onboarding.** Standing up a server should land the admin in |
| + the **chat view**, in a conversation with Eleven that walks setup — no |
| + `/admin` console, no forms-first overlay. `/admin` is eventually retired, |
| + replaced by an **admin mini-app** only admins can see. |
| +- **User onboarding.** Joining from an invite link should land the joiner in |
| + the **chat view**, where Eleven runs a welcome script — name, avatar, "save |
| + your profile", then an invitation to take the tour. |
| + |
| +The bar from CLAUDE.md holds: familiar before novel; a command/script invokes a |
| +response *in place*; delight rides on top. Onboarding is a conversation because |
| +that's the most familiar thing in a chat app — not because it's clever. |
| + |
| +## Non-negotiables |
| + |
| +- **The scripts are deterministic, always.** Client-driven, like `/tutorial` — |
| + never a bot round-trip, never AI. A fresh instance has no sidecar and no API |
| + key, and "enable AI?" is itself one of the setup questions, so AI cannot be a |
| + dependency of asking it. Eleven owns the *voice*; the logic is a script with |
| + tap-chips and captured composer answers. AI Eleven converses *after* setup, |
| + where present. |
| +- **E2E rules unchanged.** Everything the scripts collect lands on existing |
| + trust boundaries: display names encrypted under the instance key; group name, |
| + server photo, and contact email are admin-set **branding/settings** |
| + (plaintext by design, already carved out in CLAUDE.md); no new plaintext |
| + categories are introduced. |
| +- **One voice.** Where a bot sidecar exists, its welcome-DM behaviour stands |
| + down in favour of the client script (see *Joiner flow*): two Elevens must |
| + never greet the same person. |
| + |
| +--- |
| + |
| +## 1. Prerequisite: admin becomes a role, not just a token |
| + |
| +Today "admin" is only a bearer token (`requireAdmin` checks |
| +`checkAdmin(token)`); roster users carry no admin flag. Everything here — an |
| +admin-only mini-app, "first user is the admin", promoting others, moderators — |
| +hangs off one change: |
| + |
| +- **`users.role`**: `admin | moderator | member`. Moderator is in the enum |
| + from day one, unenforced at first, so it's designed-in rather than bolted on. |
| +- **Role-based admin auth.** `/api/admin/*` accepts *either* the admin token |
| + *or* a member device token whose user has `role=admin`. The admin mini-app |
| + then works with the session the user already has — no token pasting. |
| +- **First claimer is admin.** The first user to claim membership on a fresh |
| + instance gets `role=admin`. Admins can later grant/revoke `admin` (and |
| + `moderator`) on other users via the admin app. |
| + |
| +**The admin token survives as a break-glass API credential** — it's how |
| +`add-instance` provisions, how the runbook recovers access, and it's |
| +auth-not-decryption by design. What retires is the `/admin` *page*, once the |
| +mini-app reaches parity. Losing the token path entirely would leave "admin lost |
| +their only device" with no recovery. |
| + |
| +### Moderators (the aside, captured) |
| + |
| +Problem: on instances where members can invite (`user_invites`), an invited |
| +member currently has the same social surface as anyone else. Roster-affecting |
| +powers should be tiered: |
| + |
| +| power | member | moderator | admin | |
| +|---|---|---|---| |
| +| invite new people (when `user_invites` on) | ✓ | ✓ | ✓ | |
| +| remove a user from the instance | – | ✓ | ✓ | |
| +| rename another user | – | ✓ | ✓ | |
| +| grant/revoke moderator | – | – | ✓ | |
| +| grant/revoke admin | – | – | ✓ | |
| +| instance settings, branding, bots | – | – | ✓ | |
| + |
| +Enforcement is a later phase (see *Phasing*); the schema and the admin-app UI |
| +should assume it from the start. |
| + |
| +## 2. The admin mini-app |
| + |
| +A mini-app (per `web/static/apps/README.md`) that replaces the `/admin` |
| +console. Visible only to users with `role=admin` — the gate lives in the app |
| +framework (an app can declare `adminOnly`), not scattered through `shell.js`. |
| + |
| +Surface (parity with today's `admin.js`, reachable in-app instead of on a |
| +separate page): invites, roster (remove/rename, now also roles), group name, |
| +server photo, contact email, `user_invites` toggle, bot config, test push. |
| +Companion-view shape (Chat | Interface) fits naturally: the Interface is the |
| +console; the Chat view is where admin-flavoured bot responses land (e.g. |
| +`/invite` already exists as a command). |
| + |
| +Retirement is two-step: (1) ship the app, `/admin` stays; (2) once parity is |
| +proven in use, `/admin` becomes a redirect/pointer and the page + `admin.js` |
| +are deleted. |
| + |
| +## 3. Server-admin onboarding (first run) |
| + |
| +### Entry |
| + |
| +`lchat add-instance <host>` stops printing an `/admin#token=` link and instead |
| +prints a one-time **setup link** — a normal invite marked `grants_admin` |
| +(claiming it creates the membership *and* sets `role=admin`). The printed |
| +admin token / admin link remain available (`lchat admin-link <host>`) as the |
| +break-glass path. |
| + |
| +Landing on the setup link does what invites do today: claim, generate the |
| +keypair, and — already true — the first member bootstraps the **instance key** |
| +client-side (`shell.js`, `generateKey()` when no holders exist). The admin |
| +lands in the chat view with a conversation with Eleven already open. |
| + |
| +### The setup script |
| + |
| +Client-driven, keyed on: caller has `role=admin` **and** the server-side |
| +`setup_complete` config flag is unset. Steps, each one message + captured |
| +answer or tap-chip, each landing on an *existing* endpoint (now reachable with |
| +role-based auth): |
| + |
| +1. **Your name** → `postLabel` (encrypted under the instance key, as today). |
| +2. **Group name** → `POST /api/admin/group`. |
| +3. **Contact email** → `admin_email` in `POST /api/admin/settings` (exists). |
| +4. **Server photo** → `POST /api/admin/server-avatar` (plaintext branding, as |
| + designed). Offered as a chip that opens the picker; skippable. |
| +5. **Enable AI?** → a new instance setting (see §6). Presented honestly: on |
| + hosted instances it takes effect; on self-hosted it's a preference the |
| + sidecar respects when one exists. |
| +6. **Can people invite other people?** → `user_invites` (exists). |
| +7. **The key ceremony** (next section) — deliberately last, so it's the note |
| + the setup ends on. |
| + |
| +On completion the script sets `setup_complete` and hands over: "you can change |
| +any of this later — I've put an **Admin** app in your sidebar." |
| + |
| +Answers are captured the `/tutorial` way — the script asks, and either a chip |
| +tap or the next composer submit is consumed as the answer (deterministic |
| +"extraction"; no parsing cleverness). The conversation itself is local |
| +notices (`buildLocalNotice`), not server messages: nothing needs to persist |
| +except the settings each step writes. |
| + |
| +### The key ceremony |
| + |
| +One precision the copy must get right: what the admin must not lose is their |
| +**credential bundle** (private key + session), not "the instance key" per se. |
| +The instance key is re-wrappable by any other holder — but on a fresh |
| +one-member server *they are the only holder*, so losing their keys loses the |
| +server transitively. The script says so, sternly, in Eleven's voice: **we |
| +cannot get this back for you, and that is the design working.** |
| + |
| +Offered paths, in order: |
| + |
| +1. **Primary — "Save a passkey"** (one click). The shipped phase-3a home-server |
| + enrolment popup: credentials into the vault blob, one Face ID. This is the |
| + same mechanism as the fragility banner's CTA; completing it here means the |
| + banner never appears for this user. |
| +2. **Secondary — download a recovery kit.** A small file (1Password |
| + Emergency-Kit style) for people who won't do passkeys: origin, display |
| + name, the private key, and a re-auth credential. **New work + an open |
| + design question:** session tokens are revocable, so the kit likely wants a |
| + durable server-side credential (a long-lived "recovery token" whose hash |
| + the server stores, exchangeable once for a fresh session — same |
| + hash-only-storage discipline as the admin token). Needs its own short |
| + design pass before building. |
| +3. **Not offered: screenshots.** Screenshots sync to cloud photo libraries in |
| + plaintext and are casually browsed and shared. Recommending one would |
| + undercut the seriousness the step is trying to convey. (A QR inside the |
| + recovery-kit file is possible later polish for cross-device import.) |
| + |
| +The step is skippable — it's their server — but skipping routes into the |
| +existing fragility banner's double-dismiss discipline rather than silence. |
| + |
| +## 4. User onboarding (joining from an invite) |
| + |
| +Same dance, assembled almost entirely from shipped parts: |
| + |
| +- **Chat view first, no overlays.** The join flow's name overlay goes away; |
| + claiming an invite lands directly in the chat view with Eleven's welcome |
| + conversation open. |
| +- **Welcome script** (client-driven, keyed on first-open-after-join): |
| + 1. **Name** → `postLabel`. |
| + 2. **Avatar** → existing avatar upload; chip opens the picker; skippable. |
| + 3. **"Save your profile"** — the phase-3a enrolment popup, one click and |
| + redirect. This *is* the post-join nudge from `onboarding-and-home-server.md`, |
| + now delivered in Eleven's voice (one voice: the old backup toast is |
| + already slated for retirement; this replaces the banner's first |
| + appearance too, for joiners who complete it). |
| + 4. **"Take the tour"** — the existing chip into `/tutorial`. |
| +- **Sidecar stand-down.** On bot instances, sidecar-Eleven currently |
| + welcome-DMs new members. The client script owns onboarding; the sidecar's |
| + welcome DM is suppressed (or reduced to creating the DM thread the script |
| + runs in). Two Elevens must not greet the same person. |
| +- **Admin-editable welcome (later).** The script reads an optional |
| + admin-authored welcome blob from config (written via the admin app) and |
| + plays it before/instead of the stock greeting. Cheap hook to leave in the |
| + script's structure now; UI later. |
| + |
| +## 5. Roles vs identity: who is "the admin" in the script? |
| + |
| +`spaceInfo()` already exposes an administrator identity to the tutorial. With |
| +roles on the roster this stops being config-only: the roster itself knows who |
| +the admins are (useful for "contact an admin" chips, the moderator table |
| +above, and the admin app's role management). `admin_name`/`admin_email` in |
| +settings remain the *outward* contact surface (they're branding-adjacent); |
| +role is the *authorisation* surface. Keep them distinct. |
| + |
| +## 6. The AI toggle (honest scope) |
| + |
| +"Enable AI on this server?" is a real setting with an operator-shaped |
| +dependency: AI is a **sidecar deployment with an API key**, not something an |
| +instance flag can conjure. Design: |
| + |
| +- A new instance setting (e.g. `ai_enabled`), admin-writable, readable by the |
| + sidecar. |
| +- The sidecar respects it: when off, Eleven runs deterministic-only (the |
| + existing per-bot kind machinery); when on — and a sidecar with a key exists — |
| + conversational AI is active. |
| +- Hosted instances: flipping the toggle works end-to-end (sidecars are |
| + provisioned). Self-hosted without a sidecar: the setting records intent and |
| + the script/admin app says what's missing, pointing at `add-bot` docs. |
| + |
| +This slice gets its own small design pass before building (interaction with |
| +per-bot config, and whether "off" also silences AI for bots other than |
| +Eleven). |
| + |
| +--- |
| + |
| +## Phasing (each slice independently shippable) |
| + |
| +1. **Roles** — `users.role`, first-claimer-is-admin, role-based |
| + `requireAdmin` alternative, moderator in the enum (unenforced). Pure |
| + server + schema; no UI change. |
| +2. **Admin mini-app** — `adminOnly` app gate + the app itself over existing |
| + admin endpoints. `/admin` untouched. |
| +3. **First-run setup script** — `grants_admin` setup invite from |
| + `add-instance`, the Eleven setup conversation, `setup_complete` flag. |
| + Ends with the passkey step only (recovery kit not yet built). |
| +4. **Key ceremony completion** — recovery-kit design pass, then the |
| + download path. |
| +5. **Joiner welcome script** — name/avatar/save-profile/tour, join overlay |
| + removal, sidecar welcome stand-down. |
| +6. **Moderator enforcement** — the permission table in §1 enforced |
| + server-side; role management UI in the admin app. |
| +7. **AI toggle** — design pass + `ai_enabled` + sidecar respect. |
| +8. **Retire `/admin`** — after the app proves parity in use. |
| + |
| +## Open decisions |
| + |
| +- **Recovery kit re-auth credential**: durable recovery token (hash-only |
| + server storage) vs re-using the add-device machinery with a non-expiring |
| + variant. (§3, key ceremony — blocks phase 4 only.) |
| +- **Sidecar stand-down mechanism**: suppress the welcome DM entirely, or keep |
| + the sidecar creating the DM thread and have the script play inside it? |
| +- **Does `grants_admin` ride on key-invites or user-invites?** Setup is a |
| + key-invite shape (there's no inviter to wrap thread keys), but confirm |
| + against the invite-redesign fragment format (`#e1.<T>`). |
| +- **Admin-editable welcome**: plain text blob vs a tiny script format |
| + (chips?). Start with text. |
| +- **Moderator table details**: can moderators cancel invites? See other |
| + members' invite links? Decide when enforcing (phase 6). |
diff --git a/handlers_test.go b/handlers_test.go| index ab7b995..f629130 100644 |
| --- a/handlers_test.go |
| +++ b/handlers_test.go |
| @@ -5,6 +5,7 @@ import ( |
| "encoding/json" |
| "io" |
| "net/http" |
| + "net/url" |
| "net/http/httptest" |
| "os" |
| "strconv" |
| @@ -651,6 +652,43 @@ func adminReq(t *testing.T, method, url, contentType string, body []byte) *http. |
| return resp |
| } |
| +// Alt text travels with the photo (plaintext branding, like the photo itself): |
| +// set via ?alt= on upload, served by /api/group, reset by a new photo or clear. |
| +func TestServerAvatarAltText(t *testing.T) { |
| + ts, _ := newTestServer(t) |
| + png := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 64)...) |
| + |
| + groupAlt := func() (string, bool) { |
| + resp, _ := http.Get(ts.URL + "/api/group") |
| + var g map[string]any |
| + json.NewDecoder(resp.Body).Decode(&g) |
| + alt, present := g["avatar_alt"] |
| + s, _ := alt.(string) |
| + return s, present |
| + } |
| + |
| + if resp := adminReq(t, "POST", ts.URL+"/api/admin/server-avatar?alt="+url.QueryEscape("Two canaries on a branch"), "", png); resp.StatusCode != http.StatusOK { |
| + t.Fatalf("set with alt: %d", resp.StatusCode) |
| + } |
| + if alt, _ := groupAlt(); alt != "Two canaries on a branch" { |
| + t.Fatalf("alt round-trip: got %q", alt) |
| + } |
| + // A new photo without alt resets it (the text belongs to the image). |
| + if resp := adminReq(t, "POST", ts.URL+"/api/admin/server-avatar", "", png); resp.StatusCode != http.StatusOK { |
| + t.Fatalf("re-set: %d", resp.StatusCode) |
| + } |
| + if alt, _ := groupAlt(); alt != "" { |
| + t.Fatalf("alt should reset with a new photo, got %q", alt) |
| + } |
| + // Clearing the photo drops the field from /api/group entirely. |
| + if resp := adminReq(t, "DELETE", ts.URL+"/api/admin/server-avatar", "", nil); resp.StatusCode != http.StatusOK { |
| + t.Fatalf("clear: %d", resp.StatusCode) |
| + } |
| + if _, present := groupAlt(); present { |
| + t.Fatal("avatar_alt should be absent with no photo") |
| + } |
| +} |
| + |
| // The server avatar is admin-set, public plaintext branding: set/clear are admin |
| // only; read is unauthenticated and reports the stored bytes + type, and the |
| // group endpoint flags its presence so clients know whether to fetch it. |
diff --git a/main.go b/main.go| index cb7f212..c0cbf2a 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -24,6 +24,7 @@ import ( |
| "sync" |
| "syscall" |
| "time" |
| + "unicode/utf8" |
| "lchat/internal/apns" |
| ) |
| @@ -125,6 +126,9 @@ func main() { |
| case "admin-link": |
| runAdminLink(os.Args[2:]) |
| return |
| + case "setup-invite": |
| + runSetupInvite(os.Args[2:]) |
| + return |
| case "version": |
| // The build id compiled in via -ldflags "-X main.buildVersion=<id>". |
| // `stage` reads it from a built binary; the running instance reports it |
| @@ -796,7 +800,14 @@ func (s *Server) getMe(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, map[string]any{"device_count": 0}) |
| return |
| } |
| - writeJSON(w, http.StatusOK, map[string]any{"user_id": uid, "device_count": s.db.deviceCount(uid)}) |
| + writeJSON(w, http.StatusOK, map[string]any{ |
| + "user_id": uid, |
| + "device_count": s.db.deviceCount(uid), |
| + "role": s.db.userRole(uid), |
| + // setup_complete gates the first-run setup conversation. It's instance |
| + // state (not a secret), but only an admin's client acts on it. |
| + "setup_complete": s.db.setupComplete(), |
| + }) |
| } |
| // signout revokes the caller's own device token (sign out on this device) and drops |
| @@ -1565,6 +1576,10 @@ func (s *Server) createKeyInvite(w http.ResponseWriter, r *http.Request) { |
| // Listen mode (only when Agent): when the bot replies in groups — |
| // ""/"mention" = @mention only (default), "name" = also on its bare name. |
| Listen string `json:"listen"` |
| + // GrantsAdmin marks a setup invite: its claimer becomes an admin. Minted by |
| + // 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"` |
| } |
| if json.NewDecoder(r.Body).Decode(&body) != nil { |
| httpError(w, http.StatusBadRequest, "bad request") |
| @@ -1615,6 +1630,12 @@ func (s *Server) createKeyInvite(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| } |
| + if body.GrantsAdmin { |
| + if err := s.db.setInviteGrantsAdmin(body.ID); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + } |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true, "user_id": userID}) |
| } |
| @@ -1633,6 +1654,10 @@ func (s *Server) getKeyInvite(w http.ResponseWriter, r *http.Request) { |
| // A user invite drops the joiner into a pending DM. The client uses this to |
| // (a) re-wrap the DM key to itself and (b) know an existing member can adopt. |
| resp["dm"] = ki.ThreadID != "" |
| + // A setup invite lands chat-first: the join page skips its name form (the |
| + // setup conversation asks instead). Only link-holders can derive the id, so |
| + // this flags nothing to anyone who couldn't claim it anyway. |
| + resp["grants_admin"] = ki.GrantsAdmin |
| // 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. |
| } |
| @@ -1995,8 +2020,18 @@ func (s *Server) cancelInvite(w http.ResponseWriter, r *http.Request) { |
| func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool { |
| const p = "Bearer " |
| h := r.Header.Get("Authorization") |
| - if strings.HasPrefix(h, p) && s.db.checkAdmin(strings.TrimSpace(strings.TrimPrefix(h, p))) { |
| - return true |
| + if strings.HasPrefix(h, p) { |
| + bearer := strings.TrimSpace(strings.TrimPrefix(h, p)) |
| + // Two ways in: the admin token (break-glass / provisioning), or a member's |
| + // own device token when their user holds the admin role — so the in-app |
| + // admin surface works with the session it already has. Either way this is |
| + // authorisation only; no key material rides on it. |
| + if s.db.checkAdmin(bearer) { |
| + return true |
| + } |
| + if uid, ok := s.db.deviceTokenUser(bearer); ok && s.db.userRole(uid) == "admin" { |
| + return true |
| + } |
| } |
| httpError(w, http.StatusUnauthorized, "admin auth required") |
| return false |
| @@ -2122,7 +2157,15 @@ func (s *Server) getGroup(w http.ResponseWriter, r *http.Request) { |
| // speculative image fetch (used by the join cards, home Save-passkey card, and |
| // the native add-chat screens). |
| _, _, _, hasAvatar := s.db.getInstanceAvatar() |
| - out := map[string]any{"name": s.db.getGroupName(), "avatar": hasAvatar} |
| + // name_set distinguishes an admin-chosen name from the compiled-in default, |
| + // so first-run setup asks for a name instead of offering "keep the default". |
| + out := map[string]any{"name": s.db.getGroupName(), "name_set": s.db.groupNameSet(), "avatar": hasAvatar} |
| + if hasAvatar { |
| + // The photo's alt text (plaintext branding, admin-set) — clients set it |
| + // on every rendering of /api/server-avatar, join cards included. |
| + alt, _, _ := s.db.getConfig("server_avatar_alt") |
| + out["avatar_alt"] = alt |
| + } |
| // The administrator identity is member-only, not public branding: this |
| // endpoint is also the unauthenticated onboarding read, so only add the |
| // admin's name + contact email when a valid member token is presented. |
| @@ -2190,6 +2233,13 @@ func (s *Server) setServerAvatar(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| } |
| + // Optional alt text rides as a query param (the body is the raw image). |
| + // Plaintext branding like the photo itself — it must be readable on keyless |
| + // surfaces (the join card). A new photo always resets it (empty clears). |
| + if err := s.db.setConfig("server_avatar_alt", trimAltText(r.URL.Query().Get("alt"))); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| } |
| @@ -2202,6 +2252,7 @@ func (s *Server) deleteServerAvatar(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| } |
| + _ = s.db.setConfig("server_avatar_alt", "") // the alt text goes with the photo |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| } |
| @@ -2248,6 +2299,20 @@ func (s *Server) setGroup(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true, "name": name, "admin_name": adminName, "admin_email": adminEmail}) |
| } |
| +// trimAltText caps the photo's alt text at a RUNE boundary — a plain byte slice |
| +// mid-emoji would store invalid UTF-8 and render as U+FFFD on the join card. |
| +func trimAltText(s string) string { |
| + s = strings.TrimSpace(s) |
| + if len(s) <= 300 { |
| + return s |
| + } |
| + cut := 300 |
| + for cut > 0 && !utf8.RuneStart(s[cut]) { |
| + cut-- |
| + } |
| + return s[:cut] |
| +} |
| + |
| // isRoughEmail is a deliberately loose check — just enough to reject obvious |
| // typos in the admin's optional contact address without pretending to validate |
| // deliverability. "a@b.c" passes; "not an email" doesn't. |
| @@ -2267,9 +2332,10 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| writeJSON(w, http.StatusOK, map[string]any{ |
| - "user_invites": s.db.usersCanInvite(), |
| - "admin_name": s.db.getAdminName(), |
| - "admin_email": s.db.getAdminEmail(), |
| + "user_invites": s.db.usersCanInvite(), |
| + "admin_name": s.db.getAdminName(), |
| + "admin_email": s.db.getAdminEmail(), |
| + "setup_complete": s.db.setupComplete(), |
| }) |
| } |
| @@ -2281,6 +2347,11 @@ func (s *Server) setSettings(w http.ResponseWriter, r *http.Request) { |
| } |
| var body struct { |
| UserInvites *bool `json:"user_invites"` |
| + // The setup conversation marks itself done (or the admin dismisses it). |
| + SetupComplete *bool `json:"setup_complete"` |
| + // The photo's alt text, editable without re-uploading the image (the |
| + // admin panel's alt field). Plaintext branding, like the photo itself. |
| + AvatarAlt *string `json:"avatar_alt"` |
| } |
| if json.NewDecoder(r.Body).Decode(&body) != nil { |
| httpError(w, http.StatusBadRequest, "bad request") |
| @@ -2292,6 +2363,18 @@ func (s *Server) setSettings(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| } |
| + if body.SetupComplete != nil { |
| + if err := s.db.setSetupComplete(*body.SetupComplete); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + } |
| + if body.AvatarAlt != nil { |
| + if err := s.db.setConfig("server_avatar_alt", trimAltText(*body.AvatarAlt)); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + } |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true, "user_invites": s.db.usersCanInvite()}) |
| } |
diff --git a/roles_test.go b/roles_test.go| new file mode 100644 |
| index 0000000..28197c0 |
| --- /dev/null |
| +++ b/roles_test.go |
| @@ -0,0 +1,185 @@ |
| +package main |
| + |
| +import ( |
| + "encoding/json" |
| + "net/http" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// A setup invite (grants_admin) makes its claimer an admin; ordinary invites |
| +// leave the claimer a member. See docs/conversational-onboarding.md. |
| +func TestSetupInviteGrantsAdminRole(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + |
| + // Minted the way add-instance does: admin-authed create with grants_admin. |
| + r := adminPost(t, ts.URL+"/api/admin/key-invites", |
| + `{"id":"setup-inv","claim_hash":"`+testClaimHash+`","grants_admin":true}`) |
| + if r.StatusCode != http.StatusOK { |
| + t.Fatalf("create setup invite: want 200, got %d", r.StatusCode) |
| + } |
| + var created struct { |
| + UserID string `json:"user_id"` |
| + } |
| + json.NewDecoder(r.Body).Decode(&created) |
| + |
| + // The join page learns it's a setup invite from the (public) inspect. |
| + var info struct { |
| + OK bool `json:"ok"` |
| + GrantsAdmin bool `json:"grants_admin"` |
| + } |
| + resp, _ := http.Get(ts.URL + "/api/key-invite/setup-inv") |
| + json.NewDecoder(resp.Body).Decode(&info) |
| + if !info.OK || !info.GrantsAdmin { |
| + t.Fatalf("inspect: want ok+grants_admin, got %+v", info) |
| + } |
| + |
| + if _, ok, _ := srv.db.claimKeyInvite("setup-inv", "", "", testClaimSecret); !ok { |
| + t.Fatalf("claim failed") |
| + } |
| + if role := srv.db.userRole(created.UserID); role != "admin" { |
| + t.Fatalf("claimer role: want admin, got %q", role) |
| + } |
| + |
| + // An ordinary invite must not grant anything. |
| + adminPost(t, ts.URL+"/api/admin/key-invites", `{"id":"plain-inv","claim_hash":"`+testClaimHash+`"}`) |
| + resp, _ = http.Get(ts.URL + "/api/key-invite/plain-inv") |
| + info = struct { |
| + OK bool `json:"ok"` |
| + GrantsAdmin bool `json:"grants_admin"` |
| + }{} |
| + json.NewDecoder(resp.Body).Decode(&info) |
| + if info.GrantsAdmin { |
| + t.Fatalf("plain invite must not grant admin") |
| + } |
| + uid, _ := srv.db.createUser("") |
| + srv.db.createKeyInvite("plain-inv2", uid, "", testClaimHash, 0) |
| + srv.db.claimKeyInvite("plain-inv2", "", "", testClaimSecret) |
| + if role := srv.db.userRole(uid); role != "member" { |
| + t.Fatalf("ordinary claimer role: want member, got %q", role) |
| + } |
| +} |
| + |
| +// Admin endpoints accept a member device token when the member's user holds the |
| +// admin role — and still reject everyone else's. |
| +func TestRoleBasedAdminAuth(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + |
| + adminUID, _ := srv.db.createUser("") |
| + srv.db.createKeyInvite("adm", adminUID, "", testClaimHash, 0) |
| + srv.db.setInviteGrantsAdmin("adm") |
| + adminTok, _, _ := srv.db.claimKeyInvite("adm", "", "", testClaimSecret) |
| + |
| + memberUID, _ := srv.db.createUser("") |
| + srv.db.createKeyInvite("mem", memberUID, "", testClaimHash, 0) |
| + memberTok, _, _ := srv.db.claimKeyInvite("mem", "", "", testClaimSecret) |
| + |
| + get := func(bearer string) int { |
| + req, _ := http.NewRequest("GET", ts.URL+"/api/admin/settings", nil) |
| + req.Header.Set("Authorization", "Bearer "+bearer) |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatalf("request: %v", err) |
| + } |
| + resp.Body.Close() |
| + return resp.StatusCode |
| + } |
| + if code := get(adminTok); code != http.StatusOK { |
| + t.Fatalf("admin-role device token: want 200, got %d", code) |
| + } |
| + if code := get(memberTok); code != http.StatusUnauthorized { |
| + t.Fatalf("member device token: want 401, got %d", code) |
| + } |
| + if code := get("admintok"); code != http.StatusOK { |
| + t.Fatalf("admin bearer token: want 200, got %d", code) |
| + } |
| + |
| + // A role-admin can write settings with their device token too — the setup |
| + // conversation's whole write path. |
| + req, _ := http.NewRequest("POST", ts.URL+"/api/admin/settings", strings.NewReader(`{"setup_complete":true}`)) |
| + req.Header.Set("Authorization", "Bearer "+adminTok) |
| + req.Header.Set("Content-Type", "application/json") |
| + resp, _ := http.DefaultClient.Do(req) |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("role-admin set settings: want 200, got %d", resp.StatusCode) |
| + } |
| + if !srv.db.setupComplete() { |
| + t.Fatalf("setup_complete not persisted") |
| + } |
| +} |
| + |
| +// Established instances (any device token at upgrade time) get setup_complete |
| +// backfilled ONCE, so promoting someone to admin later never triggers first-run |
| +// onboarding on a live space; a fresh instance stays pending for its first |
| +// claimer, and a reopen mid-setup doesn't re-run the backfill. |
| +func TestSetupCompleteBackfill(t *testing.T) { |
| + dir := t.TempDir() |
| + |
| + // Fresh DB: no devices → backfill marker recorded, setup stays pending. |
| + db, err := openDB(dir + "/fresh.db") |
| + if err != nil { |
| + t.Fatalf("openDB: %v", err) |
| + } |
| + if db.setupComplete() { |
| + t.Fatal("fresh instance: setup should be pending for the first claimer") |
| + } |
| + // Claim a device (mid-setup), then simulate a restart: the marker must stop |
| + // the check from re-running and wrongly completing an in-progress setup. |
| + uid, _ := db.createUser("") |
| + db.createKeyInvite("inv", uid, "", testClaimHash, 0) |
| + db.claimKeyInvite("inv", "", "", testClaimSecret) |
| + db.sql.Close() |
| + db, err = openDB(dir + "/fresh.db") |
| + if err != nil { |
| + t.Fatalf("reopen: %v", err) |
| + } |
| + if db.setupComplete() { |
| + t.Fatal("restart mid-setup must not mark setup complete (backfill runs once)") |
| + } |
| + db.sql.Close() |
| + |
| + // Established DB shape: a device token exists but the backfill never ran |
| + // (simulates a pre-roles instance upgrading). Clearing the marker and |
| + // reopening must complete setup. |
| + db, err = openDB(dir + "/fresh.db") |
| + if err != nil { |
| + t.Fatalf("reopen 2: %v", err) |
| + } |
| + db.sql.Exec("DELETE FROM config WHERE key IN ('setup_backfilled', 'setup_complete')") |
| + db.sql.Close() |
| + db, err = openDB(dir + "/fresh.db") |
| + if err != nil { |
| + t.Fatalf("reopen 3: %v", err) |
| + } |
| + if !db.setupComplete() { |
| + t.Fatal("established instance (devices exist, no marker) should backfill setup_complete") |
| + } |
| +} |
| + |
| +// /api/me tells a member their role and whether first-run setup is done — the |
| +// client-side gate for the setup conversation. |
| +func TestMeExposesRoleAndSetupState(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + uid, _ := srv.db.createUser("") |
| + srv.db.createKeyInvite("adm", uid, "", testClaimHash, 0) |
| + srv.db.setInviteGrantsAdmin("adm") |
| + tok, _, _ := srv.db.claimKeyInvite("adm", "", "", testClaimSecret) |
| + |
| + me := func() (role string, setup bool) { |
| + resp, _ := http.Get(ts.URL + "/api/me?token=" + tok) |
| + var body struct { |
| + Role string `json:"role"` |
| + SetupComplete bool `json:"setup_complete"` |
| + } |
| + json.NewDecoder(resp.Body).Decode(&body) |
| + return body.Role, body.SetupComplete |
| + } |
| + role, setup := me() |
| + if role != "admin" || setup { |
| + t.Fatalf("fresh admin: want admin/incomplete, got %q/%v", role, setup) |
| + } |
| + srv.db.setSetupComplete(true) |
| + if _, setup = me(); !setup { |
| + t.Fatalf("want setup_complete true after set") |
| + } |
| +} |
diff --git a/setup_invite.go b/setup_invite.go| new file mode 100644 |
| index 0000000..eefee52 |
| --- /dev/null |
| +++ b/setup_invite.go |
| @@ -0,0 +1,141 @@ |
| +package main |
| + |
| +// The setup invite: a one-time, admin-granting invite minted for a fresh |
| +// instance's first user, so standing up a server lands its operator straight in |
| +// the chat view (where the Eleven setup conversation takes over) instead of on |
| +// /admin. See docs/conversational-onboarding.md. |
| +// |
| +// The link is /join/setup#<T>. Everything the server stores is derived from T |
| +// exactly the way the browser does it (deriveInvite in crypto.js): the invite id |
| +// and the claim hash via domain-separated HKDF. The minter here is the operator's |
| +// control binary — the same trust context that already holds the admin token — |
| +// so T never rides through anything the operator doesn't already control. |
| + |
| +import ( |
| + "bytes" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "encoding/json" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "net/http" |
| + "path/filepath" |
| + "strings" |
| + "time" |
| + |
| + "golang.org/x/crypto/hkdf" |
| +) |
| + |
| +// deriveInviteParts mirrors crypto.js deriveInvite: from the invite secret T |
| +// (base64url), derive the invite id, the claim secret, and the claim hash the |
| +// server stores. HKDF-SHA256 with an empty salt and domain-separated info |
| +// strings; the wrap key (unused here — a setup invite wraps nothing) is a third |
| +// derivation the client makes on its own. |
| +func deriveInviteParts(tB64 string) (id, claimSecret, claimHash string, err error) { |
| + ikm, err := base64.RawURLEncoding.DecodeString(tB64) |
| + if err != nil { |
| + return "", "", "", fmt.Errorf("bad invite secret: %v", err) |
| + } |
| + derive := func(info string) (string, error) { |
| + out := make([]byte, 32) |
| + if _, err := io.ReadFull(hkdf.New(sha256.New, ikm, nil, []byte(info)), out); err != nil { |
| + return "", err |
| + } |
| + return base64.RawURLEncoding.EncodeToString(out), nil |
| + } |
| + if id, err = derive("lchat-invite-id"); err != nil { |
| + return "", "", "", err |
| + } |
| + if claimSecret, err = derive("lchat-invite-claim"); err != nil { |
| + return "", "", "", err |
| + } |
| + // hex SHA-256 of the claim secret string — matches the server's hashToken and |
| + // the browser's sha256hex. |
| + sum := sha256.Sum256([]byte(claimSecret)) |
| + return id, claimSecret, hex.EncodeToString(sum[:]), nil |
| +} |
| + |
| +// mintSetupInvite creates a grants_admin key-invite on a running instance via |
| +// its admin API and returns the full join link. client lets the caller reach the |
| +// instance however it's listening (unix socket on the box, TCP locally); baseURL |
| +// is what the printed link should start with (https://<host> in production). |
| +func mintSetupInvite(client *http.Client, apiBase, linkBase, adminToken string) (string, error) { |
| + // 16 random bytes, like the browser's randomSecret(). |
| + var raw [16]byte |
| + if _, err := rand.Read(raw[:]); err != nil { |
| + return "", err |
| + } |
| + t := base64.RawURLEncoding.EncodeToString(raw[:]) |
| + id, _, claimHash, err := deriveInviteParts(t) |
| + if err != nil { |
| + return "", err |
| + } |
| + // An UNCLAIMED admin-granting link must not live forever — a stale one in |
| + // terminal scrollback or provisioning logs would mint an admin months later. |
| + // Seven days covers any realistic bootstrap; `lchat setup-invite` re-mints. |
| + body, _ := json.Marshal(map[string]any{ |
| + "id": id, "claim_hash": claimHash, "grants_admin": true, |
| + "expires": time.Now().Add(7 * 24 * time.Hour).Unix(), |
| + }) |
| + req, err := http.NewRequest("POST", apiBase+"/api/admin/key-invites", bytes.NewReader(body)) |
| + if err != nil { |
| + return "", err |
| + } |
| + req.Header.Set("Authorization", "Bearer "+adminToken) |
| + req.Header.Set("Content-Type", "application/json") |
| + resp, err := client.Do(req) |
| + if err != nil { |
| + return "", err |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + b, _ := io.ReadAll(resp.Body) |
| + return "", fmt.Errorf("mint setup invite: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) |
| + } |
| + return linkBase + "/join/setup#" + t, nil |
| +} |
| + |
| +// runSetupInvite: `lchat setup-invite <host>` mints a fresh setup link for an |
| +// instance on this box (reading its admin token from /data/<host>.env), for when |
| +// the one add-instance printed was lost before it was claimed. With --url and |
| +// --token it targets any reachable instance instead (local dev). |
| +func runSetupInvite(args []string) { |
| + fs := flag.NewFlagSet("setup-invite", flag.ExitOnError) |
| + url := fs.String("url", "", "instance base URL (dev; e.g. http://127.0.0.1:1747) — with --token, bypasses the on-box env lookup") |
| + token := fs.String("token", "", "admin token to authenticate the mint (with --url)") |
| + _ = fs.Parse(reorderFlagsFirst(fs, args)) |
| + |
| + if *url != "" || *token != "" { |
| + if *url == "" || *token == "" { |
| + die("setup-invite: --url and --token go together") |
| + } |
| + link, err := mintSetupInvite(http.DefaultClient, strings.TrimRight(*url, "/"), strings.TrimRight(*url, "/"), *token) |
| + if err != nil { |
| + die("setup-invite: %v", err) |
| + } |
| + fmt.Println(link) |
| + return |
| + } |
| + |
| + host := strings.ToLower(strings.TrimSpace(fs.Arg(0))) |
| + if !validHost(host) { |
| + die("setup-invite: invalid host %q (or pass --url + --token)", host) |
| + } |
| + env, err := readEnvFile(filepath.Join(instanceDataDir, host+".env")) |
| + if err != nil { |
| + die("setup-invite: %v", err) |
| + } |
| + tok := env["LCHAT_ADMIN_TOKEN"] |
| + if tok == "" { |
| + die("setup-invite: no LCHAT_ADMIN_TOKEN in env for %s", host) |
| + } |
| + socket := filepath.Join(instanceSocketDir, host+".sock") |
| + link, err := mintSetupInvite(socketClient(socket), "http://lchat", "https://"+host, tok) |
| + if err != nil { |
| + die("setup-invite: %v", err) |
| + } |
| + fmt.Println(link) |
| +} |
diff --git a/setup_invite_test.go b/setup_invite_test.go| new file mode 100644 |
| index 0000000..c18dafd |
| --- /dev/null |
| +++ b/setup_invite_test.go |
| @@ -0,0 +1,91 @@ |
| +package main |
| + |
| +import ( |
| + "encoding/json" |
| + "net/http" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// The Go derivation must match crypto.js deriveInvite exactly, or a link minted |
| +// by add-instance would be unclaimable in the browser. The expected values are a |
| +// vector computed by the real crypto.js (node, WebCrypto) for T = bytes 0x00..0x0f. |
| +func TestDeriveInvitePartsMatchesCryptoJS(t *testing.T) { |
| + id, claimSecret, claimHash, err := deriveInviteParts("AAECAwQFBgcICQoLDA0ODw") |
| + if err != nil { |
| + t.Fatalf("derive: %v", err) |
| + } |
| + if id != "5ReoOHe_g9MBI9A925thvq4BM7NNXPgx3tOzReMa6A4" { |
| + t.Fatalf("id mismatch: %s", id) |
| + } |
| + if claimSecret != "Kog7DRX7AZVY-PF5H-8FjH7Oc6BOPacaZjLLRzJ9Q_g" { |
| + t.Fatalf("claimSecret mismatch: %s", claimSecret) |
| + } |
| + if claimHash != "79b97fffe2f562bc4a5c6e6ab76147447aff96fd553fc4b1cd6cb83a537c0caa" { |
| + t.Fatalf("claimHash mismatch: %s", claimHash) |
| + } |
| +} |
| + |
| +// mintSetupInvite against a real (test) instance produces a link whose fragment |
| +// secret derives to a claimable, admin-granting invite — the whole add-instance → |
| +// browser handshake in one test. |
| +func TestMintSetupInviteEndToEnd(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + |
| + link, err := mintSetupInvite(http.DefaultClient, ts.URL, "https://canary.example", "admintok") |
| + if err != nil { |
| + t.Fatalf("mint: %v", err) |
| + } |
| + prefix := "https://canary.example/join/setup#" |
| + if !strings.HasPrefix(link, prefix) { |
| + t.Fatalf("link shape: %s", link) |
| + } |
| + tB64 := strings.TrimPrefix(link, prefix) |
| + |
| + // The browser side: derive from the fragment, inspect, claim. |
| + id, claimSecret, _, err := deriveInviteParts(tB64) |
| + if err != nil { |
| + t.Fatalf("derive: %v", err) |
| + } |
| + var info struct { |
| + OK bool `json:"ok"` |
| + GrantsAdmin bool `json:"grants_admin"` |
| + } |
| + resp, _ := http.Get(ts.URL + "/api/key-invite/" + id) |
| + json.NewDecoder(resp.Body).Decode(&info) |
| + if !info.OK || !info.GrantsAdmin { |
| + t.Fatalf("inspect: %+v", info) |
| + } |
| + // An admin-granting link must not be claimable forever: minting sets a |
| + // real expiry (a stale link in scrollback/logs shouldn't mint an admin). |
| + ki, found, _ := srv.db.getKeyInvite(id) |
| + if !found { |
| + t.Fatalf("invite not found") |
| + } |
| + if ki.Expired { |
| + t.Fatalf("fresh setup invite should not be expired") |
| + } |
| + var expiresAt int64 |
| + srv.db.sql.QueryRow("SELECT COALESCE(expires_at, 0) FROM key_invites WHERE id = ?", id).Scan(&expiresAt) |
| + if expiresAt == 0 { |
| + t.Fatalf("setup invite has no expiry — a stale admin-granting link would live forever") |
| + } |
| + |
| + tok, ok, err := srv.db.claimKeyInvite(id, "", "", claimSecret) |
| + if err != nil || !ok || tok == "" { |
| + t.Fatalf("claim: ok=%v err=%v", ok, err) |
| + } |
| + uid, _ := srv.db.deviceTokenUser(tok) |
| + if srv.db.userRole(uid) != "admin" { |
| + t.Fatalf("claimer should be admin") |
| + } |
| +} |
| + |
| +// A rejected mint (wrong token) surfaces the HTTP failure rather than printing a |
| +// dead link. |
| +func TestMintSetupInviteAuthFailure(t *testing.T) { |
| + ts, _ := newTestServer(t) |
| + if _, err := mintSetupInvite(http.DefaultClient, ts.URL, ts.URL, "wrong"); err == nil { |
| + t.Fatalf("want error on bad admin token") |
| + } |
| +} |
diff --git a/test/apps.test.mjs b/test/apps.test.mjs| index 92dd9d9..faf4066 100644 |
| --- a/test/apps.test.mjs |
| +++ b/test/apps.test.mjs |
| @@ -32,10 +32,14 @@ import { |
| DONE_GRACE_MS, |
| keyedList, |
| appWantsChatView, |
| + appChatOnly, |
| noticeParts, |
| noticeIsCompact, |
| noticeCalmsDown, |
| NOTICE_COMPACT_AFTER_MS, |
| + awaitAnswer, |
| + cancelAnswer, |
| + deliverAnswer, |
| } from "../web/static/apps.js"; |
| import { app as todoApp } from "../web/static/apps/todo.js"; // registers the /todo app |
| import { app as pollApp, pollSummary } from "../web/static/apps/poll.js"; |
| @@ -43,6 +47,8 @@ import { app as remindApp, parseReminder } from "../web/static/apps/remind.js"; |
| import { app as bookmarksApp } from "../web/static/apps/bookmarks.js"; // registers "Save link" |
| import { app as boardApp, sanitizePoints } from "../web/static/apps/board.js"; |
| import { app as dnsimpleApp } from "../web/static/apps/dnsimple.js"; // registers the DNSimple companion app |
| +import { app as setupApp } from "../web/static/apps/setup.js"; // the first-run setup conversation's app |
| +import { app as adminApp } from "../web/static/apps/admin.js"; // the admin panel (registers /admin) |
| import { app as chatApp, viewItems } from "../web/static/apps/chat.js"; // the chat app |
| // Each app's fold is synthesised from its `events` map by defineApp — the same |
| @@ -1126,5 +1132,54 @@ await test("dnsimple: registers no slash command (the Interface IS the list)", ( |
| assert.equal(parseCommand("/list").app, null, "nothing owns /list"); |
| }); |
| +await test("setup: steps fold idempotently and hostile events are no-ops", () => { |
| + const r = setupApp.reduce; |
| + let s = r(undefined, { type: "setup.step", step: "group", label: "named this space “C”", value: "C" }); |
| + assert.equal(s.steps.group.done, true); |
| + assert.equal(s.steps.group.value, "C"); |
| + const again = r(s, { type: "setup.step", step: "group", label: "named this space “C”", value: "C" }); |
| + assert.equal(again.steps.group.done, true, "refold converges"); |
| + assert.equal(r(s, { type: "setup.step" }), s, "missing step → unchanged"); |
| + const two = r(s, { type: "setup.step", step: "keys", label: "skipped the key backup", value: "skipped" }); |
| + assert.equal(two.steps.group.done, true, "earlier steps survive"); |
| + assert.equal(two.steps.keys.value, "skipped"); |
| +}); |
| + |
| +await test("setup: chat-only projection — completed steps become action lines", () => { |
| + assert.equal(appWantsChatView("setup"), true, "chatMessage → the chat view is the surface"); |
| + assert.equal(appChatOnly("setup"), true, "no Interface view or toggle — the conversation is the thread"); |
| + assert.equal(setupApp.render, undefined, "the progress card is gone (the Admin panel superseded it)"); |
| + const line = setupApp.chatMessage({ type: "setup.step", step: "name", label: "noted: you're Paul" }); |
| + assert.deepEqual(line, { text: "noted: you're Paul", action: true }); |
| + assert.equal(setupApp.chatMessage({ type: "setup.step", step: "x" }), null, "no label → silent"); |
| + assert.equal(setupApp.chatMessage({ type: "other" }), null); |
| + assert.equal(setupApp.scope, "system", "never offered in the Apps menu"); |
| +}); |
| + |
| +await test("admin: system-scope takeover, badged as an app-persona's opposite", () => { |
| + assert.equal(adminApp.scope, "system", "never offered in the Apps menu"); |
| + assert.equal(appWantsChatView("admin"), false, "no chatMessage → takeover (the panel IS the thread)"); |
| + assert.equal(adminApp.senderKind, undefined, "Admin reads as an APP; only setup overrides to BOT"); |
| + assert.equal(setupApp.senderKind, "bot", "the setup thread presents as Eleven (BOT), not APP"); |
| +}); |
| + |
| +await test("conversational answers: one waiter per thread, consumed on delivery", () => { |
| + const got = []; |
| + awaitAnswer("t1", (text) => got.push(text)); |
| + assert.equal(deliverAnswer("t1", "Paul", () => {}), true, "waiter consumes the answer"); |
| + assert.deepEqual(got, ["Paul"]); |
| + assert.equal(deliverAnswer("t1", "again", () => {}), false, "consumed — nothing listening"); |
| + awaitAnswer("t2", () => {}); |
| + cancelAnswer("t2"); |
| + assert.equal(deliverAnswer("t2", "x", () => {}), false, "cancelled waiter never fires"); |
| +}); |
| + |
| +await test("conversational answers: attachments ride the payload (the photo step)", () => { |
| + const got = []; |
| + awaitAnswer("t3", (text, payload) => got.push([text, ((payload && payload.attachments) || []).length])); |
| + deliverAnswer("t3", { text: "", attachments: [{ id: "b1", type: "image/jpeg" }] }, () => {}); |
| + assert.deepEqual(got, [["", 1]], "an attachment-only send still answers the waiter"); |
| +}); |
| + |
| console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| process.exit(failures === 0 ? 0 : 1); |
diff --git a/test/backup.test.mjs b/test/backup.test.mjs| index 6ee8a71..c14bf37 100644 |
| --- a/test/backup.test.mjs |
| +++ b/test/backup.test.mjs |
| @@ -1,6 +1,6 @@ |
| // Tests for the passkey-backup banner visibility rule. node test/backup.test.mjs |
| import assert from "node:assert/strict"; |
| -import { backupBannerState, parseReturnNonce, parseRestoreReturn } from "../web/static/backup.js"; |
| +import { backupBannerState, parseReturnNonce, parseRestoreReturn, REMIND_RUNGS_MS } from "../web/static/backup.js"; |
| let failures = 0; |
| async function test(name, fn) { |
| @@ -40,6 +40,34 @@ await test("enrolled wins even if the snooze has expired", () => { |
| assert.equal(backupBannerState({ enrolled: true, dismissed: false, snoozedUntil: now - DAY, now }), "hidden"); |
| }); |
| +// The deferral schedule: a setup-time skip keeps the banner quiet until each |
| +// escalating rung (24h → 72h → 1w) is due; past the rungs, classic rules apply. |
| +await test("setup skip: quiet before the 24h rung, shows once due", () => { |
| + const base = { enrolled: false, dismissed: false, snoozedUntil: 0, deferredAt: now, remindStage: 0 }; |
| + assert.equal(backupBannerState({ ...base, now: now + DAY - 1 }), "hidden"); |
| + assert.equal(backupBannerState({ ...base, now: now + DAY }), "show"); |
| +}); |
| + |
| +await test("setup skip: each dismissal advances to the next rung", () => { |
| + const base = { enrolled: false, dismissed: false, snoozedUntil: 0, deferredAt: now }; |
| + assert.equal(backupBannerState({ ...base, remindStage: 1, now: now + DAY + 1 }), "hidden", "72h rung not yet due"); |
| + assert.equal(backupBannerState({ ...base, remindStage: 1, now: now + 3 * DAY }), "show"); |
| + assert.equal(backupBannerState({ ...base, remindStage: 2, now: now + 6 * DAY }), "hidden", "final rung not yet due"); |
| + assert.equal(backupBannerState({ ...base, remindStage: 2, now: now + 7 * DAY }), "show"); |
| +}); |
| + |
| +await test("setup skip: past the final rung the classic snooze rule owns it", () => { |
| + const base = { enrolled: false, dismissed: false, deferredAt: now, remindStage: REMIND_RUNGS_MS.length }; |
| + assert.equal(backupBannerState({ ...base, snoozedUntil: now + 30 * DAY, now: now + 8 * DAY }), "hidden"); |
| + assert.equal(backupBannerState({ ...base, snoozedUntil: 0, now: now + 8 * DAY }), "show"); |
| +}); |
| + |
| +await test("setup skip: enrolment or opt-out silences the schedule", () => { |
| + const due = now + 2 * DAY; |
| + assert.equal(backupBannerState({ enrolled: true, dismissed: false, snoozedUntil: 0, deferredAt: now, remindStage: 0, now: due }), "hidden"); |
| + assert.equal(backupBannerState({ enrolled: false, dismissed: true, snoozedUntil: 0, deferredAt: now, remindStage: 0, now: due }), "hidden"); |
| +}); |
| + |
| // parseReturnNonce: pull our one-time nonce out of the /enroll return fragment. |
| await test("return nonce parses from a lone marker", () => { |
| assert.equal(parseReturnNonce("#lchat_backup=abc123"), "abc123"); |
diff --git a/web/pages/join.html b/web/pages/join.html| index 29ad4bf..020a373 100644 |
| --- a/web/pages/join.html |
| +++ b/web/pages/join.html |
| @@ -57,10 +57,14 @@ |
| })(); |
| </script> |
| +<!-- The one loading state: the same full-screen loader the app itself uses |
| + (index.html #app-loading). It covers the page from load until the welcome |
| + card is ready, and again while a claim is in flight — no per-card |
| + "Loading…" / spinner-card variants. --> |
| +<div id="app-loading" class="app-loading"><div class="spinner" aria-label="Loading"></div></div> |
| + |
| <div class="center"> |
| - <div class="card" id="card"> |
| - <h1>Loading…</h1> |
| - </div> |
| + <div class="card hidden" id="card"></div> |
| </div> |
| <template id="homescreen-tpl"> |
diff --git a/web/static/apps.js b/web/static/apps.js| index ba4db49..0937237 100644 |
| --- a/web/static/apps.js |
| +++ b/web/static/apps.js |
| @@ -267,6 +267,15 @@ export function appWantsChatView(name) { |
| return !!(app && typeof app.chatMessage === "function"); |
| } |
| +// appChatOnly is the third presentation: a companion that is ONLY its chat view |
| +// (chatMessage projections, no Interface, no view toggle). For app-threads that |
| +// are purely a conversation — the setup thread, whose old progress card the |
| +// Admin panel superseded. |
| +export function appChatOnly(name) { |
| + const app = appByName(name); |
| + return !!(app && typeof app.chatMessage === "function" && app.chatOnly); |
| +} |
| + |
| // isAppType reports whether some app owns this payload type — i.e. this message |
| // is an app event to fold, not a chat bubble to render. |
| export function isAppType(type) { |
| @@ -475,6 +484,13 @@ export function setEmitTransport(fn) { |
| transport = fn; |
| } |
| +// emitAppEvent lets a HOST-LEVEL script (the setup conversation) emit a typed |
| +// event into a thread outside any app ctx — same transport, same encryption, |
| +// same injected name/ts/uid. Apps themselves should keep using ctx.emit. |
| +export function emitAppEvent(threadId, event) { |
| + return transport ? transport(threadId, event) : Promise.resolve(false); |
| +} |
| + |
| // Scheduling: the server holds an encrypted "future message" and delivers it |
| // into the thread at the due time — it arrives exactly like any other event |
| // (folded, notified, pushed to closed apps), the server knowing only (thread, |
| @@ -592,6 +608,48 @@ export function elevenTour() { |
| .catch(() => null); |
| } |
| +// ---- conversational answers ---------------------------------------------- |
| +// |
| +// A scripted conversation (the setup flow) can ask a question and take the next |
| +// thing typed in that thread's composer as the answer. The message still sends |
| +// normally — the answer is a real chat bubble in the transcript — the waiter is |
| +// just told what it said. One waiter per thread; asking again replaces it. |
| +const answerWaiters = new Map(); // threadId → fn(text, respond) |
| + |
| +export function awaitAnswer(threadId, fn) { |
| + answerWaiters.set(threadId, fn); |
| +} |
| +export function cancelAnswer(threadId) { |
| + answerWaiters.delete(threadId); |
| +} |
| +// Called by the chat app after a message actually sent. Consumes the waiter — |
| +// a follow-up question re-arms it — and returns whether one was listening. |
| +// `payload` is the sent message ({ text, attachments }) so a script can also |
| +// take a PHOTO as the answer (the setup flow's server-photo step); a bare |
| +// string is accepted for convenience. |
| +export function deliverAnswer(threadId, payload, respond) { |
| + const fn = answerWaiters.get(threadId); |
| + if (!fn) return false; |
| + answerWaiters.delete(threadId); |
| + const p = typeof payload === "string" ? { text: payload } : payload || {}; |
| + fn(p.text || "", p, respond); |
| + return true; |
| +} |
| + |
| +// ---- setup bridge ---------------------------------------------------------- |
| +// |
| +// The first-run setup conversation (apps/setup.js) is a HOST-LEVEL script like |
| +// the tour — it writes real instance settings, which no mini-app may do (apps |
| +// never fetch). shell.js injects these capabilities; the script only ever talks |
| +// to this bridge. Null until a host provides it (e.g. Node tests, native embed). |
| +let setupBridgeImpl = null; |
| +export function setSetupBridge(b) { |
| + setupBridgeImpl = b; |
| +} |
| +export function setupBridge() { |
| + return setupBridgeImpl; |
| +} |
| + |
| function makeCtx(threadId, app) { |
| return { |
| threadId, |
| @@ -858,11 +916,15 @@ function renderNoticeBody(text, el) { |
| } |
| } |
| -function fillFullNotice(row, spec = {}) { |
| +function fillFullNotice(row, spec = {}, opts = {}) { |
| const { sender, text, actions } = spec; |
| + // A continuation row in a same-speaker run drops the name/badge (they live on |
| + // the run's first bubble, like regular message runs) — but NEVER the privacy |
| + // tag: outside a solo thread, every full private notice stays labelled, or a |
| + // continuation would read as a public message. |
| const head = document.createElement("div"); |
| head.className = "local-notice-head"; |
| - if (sender && sender.name) { |
| + if (!opts.cont && sender && sender.name) { |
| if (sender.glyph) { |
| const g = document.createElement("span"); |
| g.className = "local-notice-glyph"; |
| @@ -880,11 +942,14 @@ function fillFullNotice(row, spec = {}) { |
| head.appendChild(badge); |
| } |
| } |
| - const tag = document.createElement("span"); |
| - tag.className = "local-notice-tag"; |
| - tag.textContent = "Only visible to you"; |
| - head.appendChild(tag); |
| - row.appendChild(head); |
| + // In a solo thread privacy is implicit — everything here is only yours. |
| + if (!opts.solo) { |
| + const tag = document.createElement("span"); |
| + tag.className = "local-notice-tag"; |
| + tag.textContent = "Only visible to you"; |
| + head.appendChild(tag); |
| + } |
| + if (head.childElementCount) row.appendChild(head); |
| const bubble = document.createElement("div"); |
| bubble.className = "local-notice-bubble"; |
| @@ -892,6 +957,15 @@ function fillFullNotice(row, spec = {}) { |
| body.className = "local-notice-body"; |
| renderNoticeBody(text || "", body); |
| bubble.appendChild(body); |
| + // An optional preview image (spec.img). Only ever an IN-CODE same-origin URL |
| + // (e.g. the setup conversation previewing /api/server-avatar) — never user text. |
| + if (spec.img) { |
| + const img = document.createElement("img"); |
| + img.className = "local-notice-img"; |
| + img.src = spec.img; |
| + img.alt = spec.imgAlt || ""; |
| + bubble.appendChild(img); |
| + } |
| row.appendChild(bubble); |
| if (Array.isArray(actions) && actions.length) { |
| @@ -990,19 +1064,26 @@ function collapseNotice(row, spec) { |
| // NOTICE_COMPACT_AFTER_MS, blind-collapses to a compact one-line record; a reply |
| // already older than that (rebuilt when you reopen the thread) starts compact. |
| // The chat app appends the returned node into its feed (chat.js keys it as a row). |
| -export function buildLocalNotice(spec = {}) { |
| +export function buildLocalNotice(spec = {}, opts = {}) { |
| const row = document.createElement("div"); |
| row.className = "local-notice"; |
| + // Notices behave like regular participants: a run of them from the same |
| + // speaker keeps one header (opts.cont drops it), and in a solo thread the |
| + // "Only visible to you" tag is implicit (opts.solo drops it). |
| + if (opts.cont) row.classList.add("local-notice--cont"); |
| row.setAttribute("role", "status"); // announced to screen readers on insert |
| row.setAttribute("aria-live", "polite"); |
| const at = spec.at || 0; |
| const now = Date.now(); |
| + // A fresh notice eases up into the feed like a live message; rebuilds of |
| + // older ones (thread reopened, feed refresh) don't re-animate. |
| + if (at && now - at < 3000) row.classList.add("appear"); |
| // Only confirmations calm down; a content reply (/help) renders full and stays. |
| const calms = noticeCalmsDown(spec); |
| if (calms && noticeIsCompact(at, now)) { |
| fillCompactNotice(row, spec); |
| } else { |
| - fillFullNotice(row, spec); |
| + fillFullNotice(row, spec, opts); |
| // A confirmation still inside its loud window schedules its collapse for the |
| // time remaining before it's due to calm down. |
| const remaining = calms && at ? Math.max(0, NOTICE_COMPACT_AFTER_MS - (now - at)) : 0; |
diff --git a/web/static/apps/admin.js b/web/static/apps/admin.js| new file mode 100644 |
| index 0000000..0454045 |
| --- /dev/null |
| +++ b/web/static/apps/admin.js |
| @@ -0,0 +1,324 @@ |
| +// The Admin app — the in-app replacement for the /admin console page |
| +// (docs/conversational-onboarding.md §2). Admin-only: it's system-scope (never |
| +// offered in the Apps menu); the ways in are the `/admin` command and the setup |
| +// conversation's "Open admin" chip, both gated on the caller's role. |
| +// |
| +// Like the setup conversation (and unlike ordinary mini-apps), this is a |
| +// HOST-OWNED surface: instance settings are real server state, not thread |
| +// events, so everything reads and writes through the host bridge |
| +// (setSetupBridge in shell.js) — the app itself holds no tokens and no fetch |
| +// calls of its own. Its thread's event log stays empty by design. |
| +import { defineApp, setupBridge, registerFloorCommand, openAppThread } from "../apps.js"; |
| + |
| +// /admin works from any chat (like /tutorial): gated on the caller's role, it |
| +// answers in place for non-admins and opens the panel for admins. |
| +registerFloorCommand("/admin", { |
| + desc: "Open the admin panel (admins only)", |
| + run: async (_args, _threadId, reply) => { |
| + const bridge = setupBridge(); |
| + if (!bridge || !(await bridge.isAdmin())) { |
| + reply({ text: "Only this space's admins can open the admin panel." }); |
| + return; |
| + } |
| + openAppThread("admin"); |
| + }, |
| +}); |
| + |
| +export const app = defineApp({ |
| + name: "admin", |
| + title: "Admin", |
| + glyph: "🛠️", |
| + scope: "system", |
| + snapshot: false, |
| + initialState: () => ({}), |
| + events: {}, |
| + |
| + styles: ` |
| +.admin-root { max-width: 480px; margin: 0 auto; padding: 28px 20px 48px; } |
| + |
| +/* The one card on this page: a live miniature of the invitation card — the |
| + actual surface these settings control (branding renders where no key exists |
| + yet). Editing below updates it as you type. */ |
| +.admin-preview { |
| + display: flex; flex-direction: column; align-items: center; text-align: center; |
| + padding: 28px 22px 24px; border-radius: 22px; |
| + background: var(--panel); |
| + border: 1px solid color-mix(in srgb, var(--ink) 8%, transparent); |
| + box-shadow: 0 10px 34px color-mix(in srgb, var(--ink) 10%, transparent); |
| +} |
| +.admin-preview-photo { |
| + width: 64px; height: 64px; border-radius: 16px; object-fit: cover; margin-bottom: 12px; |
| +} |
| +.admin-preview-glyph { font-size: 44px; line-height: 64px; margin-bottom: 12px; } |
| +.admin-preview-title { font-size: 18px; font-weight: 700; color: var(--ink); overflow-wrap: anywhere; } |
| +.admin-preview-sub { font-size: 13px; color: var(--muted); margin-top: 4px; } |
| +.admin-preview-caption { |
| + text-align: center; font-size: 12px; color: var(--muted); margin: 10px 0 6px; |
| +} |
| + |
| +/* Sections sit unboxed on the page: a small-caps eyebrow over a hairline — |
| + quiet structure, so the preview card stays the one loud thing. */ |
| +.admin-group { border-top: 1px solid var(--border); margin-top: 26px; padding-top: 16px; } |
| +.admin-eyebrow { |
| + font-size: 11px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; |
| + color: var(--muted); margin: 0 0 12px; |
| +} |
| +.admin-row { display: flex; gap: 10px; align-items: center; margin: 10px 0; flex-wrap: wrap; } |
| +.admin-row label { flex: 0 0 104px; font-size: 14px; color: var(--muted); } |
| +.admin-row input[type="text"], .admin-row input[type="email"] { |
| + flex: 1 1 160px; min-width: 0; font: inherit; padding: 8px 12px; |
| + border: 1px solid var(--border); border-radius: 10px; background: transparent; color: var(--ink); |
| +} |
| +.admin-row input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: transparent; } |
| +.admin-row button { flex: none; } |
| +.admin-status { font-size: 13px; color: var(--muted); min-height: 1.2em; } |
| +.admin-photo { width: 56px; height: 56px; border-radius: 12px; object-fit: cover; border: 1px solid var(--border); } |
| +.admin-row-end { justify-content: flex-end; } |
| +/* More specific than the generic .admin-row label column rule above — the |
| + switch's label IS the whole row (sentence left, switch right). */ |
| +.admin-row .admin-switch-label { display: flex; align-items: center; gap: 10px; cursor: pointer; font-size: 14px; flex: 1 1 auto; min-width: 0; } |
| +.admin-switch-text { flex: 1 1 auto; } |
| +.admin-switch { position: relative; width: 42px; height: 26px; flex: none; } |
| +.admin-switch input { position: absolute; inset: 0; margin: 0; opacity: 0; cursor: pointer; } |
| +.admin-switch .knob { |
| + position: absolute; inset: 0; border-radius: 999px; pointer-events: none; |
| + background: color-mix(in srgb, var(--ink) 22%, transparent); transition: background 0.15s ease; |
| +} |
| +.admin-switch .knob::after { |
| + content: ""; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; |
| + border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); transition: left 0.15s ease; |
| +} |
| +.admin-switch input:checked + .knob { background: var(--accent); } |
| +.admin-switch input:checked + .knob::after { left: 19px; } |
| +@media (prefers-reduced-motion: reduce) { |
| + .admin-switch .knob, .admin-switch .knob::after { transition: none; } |
| +} |
| +`, |
| + |
| + // Full-pane takeover. render() is sync; the data arrives via the bridge, so |
| + // each section draws its skeleton and fills itself in. |
| + render(state, el, ctx) { |
| + const bridge = setupBridge(); |
| + const root = document.createElement("div"); |
| + root.className = "admin-root"; |
| + el.appendChild(root); |
| + if (!bridge) return; |
| + |
| + const section = (title) => { |
| + const s = document.createElement("div"); |
| + s.className = "admin-group"; |
| + const h = document.createElement("h2"); |
| + h.className = "admin-eyebrow"; |
| + h.textContent = title; |
| + s.appendChild(h); |
| + root.appendChild(s); |
| + return s; |
| + }; |
| + const statusLine = (parent) => { |
| + const p = document.createElement("div"); |
| + p.className = "admin-status"; |
| + parent.appendChild(p); |
| + return p; |
| + }; |
| + const flash = (elm, msg) => { |
| + elm.textContent = msg; |
| + setTimeout(() => { |
| + if (elm.textContent === msg) elm.textContent = ""; |
| + }, 2500); |
| + }; |
| + |
| + // ---- The invitation preview: what these settings actually control ------- |
| + // A live miniature of the join card (the one keyless surface): the photo and |
| + // name here are exactly what an invited person sees, so edits below show |
| + // their effect the moment you type. |
| + const preview = document.createElement("div"); |
| + preview.className = "admin-preview"; |
| + const previewGlyph = document.createElement("div"); |
| + previewGlyph.className = "admin-preview-glyph"; |
| + previewGlyph.textContent = "💬"; |
| + const previewPhoto = document.createElement("img"); |
| + previewPhoto.className = "admin-preview-photo"; |
| + previewPhoto.alt = ""; |
| + previewPhoto.style.display = "none"; |
| + const previewTitle = document.createElement("div"); |
| + previewTitle.className = "admin-preview-title"; |
| + const previewSub = document.createElement("div"); |
| + previewSub.className = "admin-preview-sub"; |
| + previewSub.textContent = "Private, end-to-end encrypted chat."; |
| + preview.append(previewGlyph, previewPhoto, previewTitle, previewSub); |
| + root.appendChild(preview); |
| + const previewCaption = document.createElement("div"); |
| + previewCaption.className = "admin-preview-caption"; |
| + previewCaption.textContent = "What people see when you invite them"; |
| + root.appendChild(previewCaption); |
| + |
| + const paintPreview = (name, hasPhoto, alt) => { |
| + previewTitle.textContent = "You're invited to " + (name || "your space"); |
| + previewGlyph.style.display = hasPhoto ? "none" : ""; |
| + previewPhoto.style.display = hasPhoto ? "" : "none"; |
| + if (hasPhoto) { |
| + previewPhoto.src = "/api/server-avatar?t=" + Date.now(); |
| + previewPhoto.alt = alt || ""; |
| + } |
| + }; |
| + |
| + // ---- This space: name + contact, saved together ------------------------- |
| + const space = section("This space"); |
| + const nameRow = document.createElement("div"); |
| + nameRow.className = "admin-row"; |
| + const nameLabel = document.createElement("label"); |
| + nameLabel.textContent = "Name"; |
| + const nameInput = document.createElement("input"); |
| + nameInput.type = "text"; |
| + nameInput.maxLength = 60; |
| + nameRow.append(nameLabel, nameInput); |
| + space.appendChild(nameRow); |
| + const photoState = { has: false, alt: "" }; |
| + nameInput.addEventListener("input", () => paintPreview(nameInput.value.trim(), photoState.has, photoState.alt)); |
| + |
| + const emailRow = document.createElement("div"); |
| + emailRow.className = "admin-row"; |
| + const emailLabel = document.createElement("label"); |
| + emailLabel.textContent = "Contact email"; |
| + const emailInput = document.createElement("input"); |
| + emailInput.type = "email"; |
| + emailRow.append(emailLabel, emailInput); |
| + space.appendChild(emailRow); |
| + |
| + const saveRow = document.createElement("div"); |
| + saveRow.className = "admin-row admin-row-end"; |
| + const save = document.createElement("button"); |
| + save.textContent = "Save"; |
| + saveRow.appendChild(save); |
| + space.appendChild(saveRow); |
| + const spaceStatus = statusLine(space); |
| + |
| + save.onclick = async () => { |
| + const result = await bridge.saveSpace({ name: nameInput.value.trim(), email: emailInput.value.trim() }); |
| + flash( |
| + spaceStatus, |
| + result === "ok" |
| + ? "Saved." |
| + : result === "invalid" |
| + ? "That didn't save — names are 1–60 characters, and the email has to look like one." |
| + : "Couldn't save — try again.", |
| + ); |
| + }; |
| + |
| + // ---- Permissions --------------------------------------------------------- |
| + const perms = section("Permissions"); |
| + const invRow = document.createElement("div"); |
| + invRow.className = "admin-row"; |
| + // The setting IS the sentence: text on the left, switch on the right. |
| + const invWrap = document.createElement("label"); |
| + invWrap.className = "admin-switch-label"; |
| + const invText = document.createElement("span"); |
| + invText.className = "admin-switch-text"; |
| + invText.textContent = "Members can invite people"; |
| + const invSwitch = document.createElement("span"); |
| + invSwitch.className = "admin-switch"; |
| + const invCheck = document.createElement("input"); |
| + invCheck.type = "checkbox"; |
| + const invKnob = document.createElement("span"); |
| + invKnob.className = "knob"; |
| + invSwitch.append(invCheck, invKnob); |
| + invWrap.append(invText, invSwitch); |
| + invRow.appendChild(invWrap); |
| + perms.appendChild(invRow); |
| + const permsStatus = statusLine(perms); |
| + |
| + invCheck.onchange = async () => { |
| + const want = invCheck.checked; |
| + const ok = await bridge.setUserInvites(want); |
| + if (ok) { |
| + flash(permsStatus, want ? "Members can now invite people." : "Only admins can invite now."); |
| + } else { |
| + invCheck.checked = !want; // revert — the server didn't take it |
| + flash(permsStatus, "Couldn't save — try again."); |
| + } |
| + }; |
| + |
| + // ---- Photo ---------------------------------------------------------------- |
| + const photoSec = section("Photo"); |
| + const photoRow = document.createElement("div"); |
| + photoRow.className = "admin-row"; |
| + const photoImg = document.createElement("img"); |
| + photoImg.className = "admin-photo"; |
| + photoImg.alt = ""; |
| + photoImg.style.display = "none"; |
| + const photoPick = document.createElement("button"); |
| + photoPick.textContent = "Upload"; |
| + const photoRemove = document.createElement("button"); |
| + photoRemove.className = "linkish danger"; |
| + photoRemove.textContent = "Remove"; |
| + photoRemove.style.display = "none"; |
| + photoRow.append(photoImg, photoPick, photoRemove); |
| + photoSec.appendChild(photoRow); |
| + // Alt text lives with the photo (and would otherwise be silently reset by a |
| + // photo change) — editable here without re-uploading the image. |
| + const altRow = document.createElement("div"); |
| + altRow.className = "admin-row"; |
| + altRow.style.display = "none"; |
| + const altLabel = document.createElement("label"); |
| + altLabel.textContent = "Alt text"; |
| + const altInput = document.createElement("input"); |
| + altInput.type = "text"; |
| + altInput.maxLength = 300; |
| + altInput.placeholder = "Describe the photo"; |
| + const altSave = document.createElement("button"); |
| + altSave.textContent = "Save"; |
| + altRow.append(altLabel, altInput, altSave); |
| + photoSec.appendChild(altRow); |
| + const photoStatus = statusLine(photoSec); |
| + altSave.onclick = async () => { |
| + const ok = await bridge.setAvatarAlt(altInput.value.trim()); |
| + if (ok) { |
| + photoState.alt = altInput.value.trim(); |
| + photoImg.alt = photoState.alt; |
| + paintPreview(nameInput.value.trim(), photoState.has, photoState.alt); |
| + flash(photoStatus, "Alt text saved."); |
| + } else flash(photoStatus, "Couldn't save — try again."); |
| + }; |
| + |
| + const paintPhoto = (has) => { |
| + photoState.has = has; |
| + photoImg.style.display = has ? "" : "none"; |
| + photoRemove.style.display = has ? "" : "none"; |
| + altRow.style.display = has ? "" : "none"; // alt text only means something with a photo |
| + if (has) photoImg.src = "/api/server-avatar?t=" + Date.now(); |
| + photoPick.textContent = has ? "Change" : "Upload"; |
| + paintPreview(nameInput.value.trim(), has, photoState.alt); |
| + }; |
| + |
| + bridge.groupInfo().then((info) => { |
| + if (!info) return flash(spaceStatus, "Couldn't load settings — are you an admin?"); |
| + nameInput.value = info.name || ""; |
| + emailInput.value = info.email || ""; |
| + invCheck.checked = !!info.userInvites; |
| + photoState.alt = info.avatarAlt || ""; |
| + photoImg.alt = photoState.alt; |
| + altInput.value = photoState.alt; |
| + paintPhoto(!!info.avatar); |
| + }); |
| + |
| + photoPick.onclick = async () => { |
| + // Pass the current alt text with the upload — the server resets alt on |
| + // every photo change, and a swap shouldn't silently drop the description. |
| + if (await bridge.pickServerPhoto(altInput.value.trim())) { |
| + photoState.alt = altInput.value.trim(); |
| + paintPhoto(true); |
| + flash(photoStatus, "Photo saved."); |
| + } |
| + }; |
| + photoRemove.onclick = async () => { |
| + if (await bridge.removeServerPhoto()) { |
| + paintPhoto(false); |
| + flash(photoStatus, "Photo removed."); |
| + } else flash(photoStatus, "Couldn't remove the photo."); |
| + }; |
| + |
| + // Deliberately NOT here: inviting people (part of the main chat experience — |
| + // the + menu) and the member list (people show up in New chat). The panel is |
| + // for what ONLY an admin can touch. |
| + }, |
| +}); |
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js| index 2e40327..30364bc 100644 |
| --- a/web/static/apps/chat.js |
| +++ b/web/static/apps/chat.js |
| @@ -22,7 +22,7 @@ |
| // |
| // This is the DEFAULT chat experience for group/DM threads; a device can |
| // switch back per-device (⋮ menu → "Use original chat" — see chat.js). |
| -import { defineApp, keyedList, runCommand, messageActionsFor, renderSurfaces, commandCatalog, buildLocalNotice, renderApp, parseMeCommand } from "../apps.js"; |
| +import { defineApp, keyedList, runCommand, messageActionsFor, renderSurfaces, commandCatalog, buildLocalNotice, renderApp, deliverAnswer, parseMeCommand } from "../apps.js"; |
| import { blockTokens, richTokens, replySnippet, formatBytes, emojiOnlyCount, firstLink, youtubeId, typingSummary } from "../format.js"; |
| import { emojify } from "../emoji.js"; |
| import { fileIcon, shouldSend, composerToken, fileAllowed, ownMessage } from "../ui.js"; |
| @@ -89,6 +89,19 @@ export function appendNotice(threadId, spec) { |
| pendingNotices.set(threadId, q); |
| } |
| +// setNoticeTyping shows/clears a typing row for a SCRIPTED speaker (the setup |
| +// conversation's "Eleven is typing…" beat before each reply). Local-only — it |
| +// rides the same transient typing state as real members' beats, never the wire. |
| +// The until-cap self-expires a beat whose clear got lost. |
| +export function setNoticeTyping(threadId, name, on) { |
| + let m = typing.get(threadId); |
| + if (!m) typing.set(threadId, (m = new Map())); |
| + if (on) m.set(name, Date.now() + 10000); |
| + else m.delete(name); |
| + const refresh = refreshTyping.get(threadId); |
| + if (refresh) refresh(); |
| +} |
| + |
| // addLocalNotice records a command reply for `threadId` and re-renders the pane so |
| // it appears immediately. Anchored just above the current newest message so it |
| // sits where you typed it and never jumps around as the conversation continues. |
| @@ -102,8 +115,12 @@ function addLocalNotice(ctx, spec) { |
| // `at` (creation time) drives the loud→compact collapse in buildLocalNotice: a |
| // fresh reply shows the full card then calms down; one rebuilt long after it |
| // landed (thread reopened) starts compact. `short` is the terse one-liner text. |
| - list.push({ id: "ln" + noticeSeq, seq: maxSid + noticeSeq * 1e-6, sender: spec.sender || null, text: spec.text || "", short: spec.short || "", actions: spec.actions || null, at: Date.now() }); |
| + list.push({ id: "ln" + noticeSeq, seq: maxSid + noticeSeq * 1e-6, sender: spec.sender || null, text: spec.text || "", short: spec.short || "", actions: spec.actions || null, img: spec.img || "", imgAlt: spec.imgAlt || "", at: Date.now() }); |
| localNotices.set(ctx.threadId, list); |
| + // A notice is always a response to something the reader just did (a command, |
| + // an action chip, a scripted step) — snap to it like a local send, even if |
| + // they'd scrolled up. |
| + pinNext.add(ctx.threadId); |
| const refresh = refreshFeed.get(ctx.threadId); |
| if (refresh) refresh(); |
| } |
| @@ -429,8 +446,23 @@ export const app = defineApp({ |
| // only when some exist, so a normal feed keeps its plain message order. |
| const notices = localNotices.get(ctx.threadId); |
| if (notices && notices.length) { |
| - for (const n of notices) items.push({ id: n.id, notice: n, seq: n.seq, vrev: "notice", srev: "" }); |
| + // In a solo thread (a persona conversation like setup — no other members) |
| + // "Only visible to you" is implicit, so the tag stays off. |
| + const solo = !!(host && host.soloThread && host.soloThread(ctx.threadId)); |
| + for (const n of notices) items.push({ id: n.id, notice: n, seq: n.seq, solo, vrev: "notice", srev: "" }); |
| items.sort((a, b) => (a.notice ? a.seq : a.sid) - (b.notice ? b.seq : b.sid)); |
| + // Notices stack in sender runs like regular messages: consecutive notices |
| + // from the same speaker keep one header (name + badge), the rest continue. |
| + let prevNotice = null; |
| + for (const it of items) { |
| + if (it.notice) { |
| + it.cont = !!(prevNotice && prevNotice.sender && it.notice.sender && prevNotice.sender.name === it.notice.sender.name); |
| + it.vrev = `notice|${it.cont ? 1 : 0}|${it.solo ? 1 : 0}`; |
| + prevNotice = it.notice; |
| + } else { |
| + prevNotice = null; |
| + } |
| + } |
| } |
| // The bot's intro ("This is the start of your chat history with To-do…") is a |
| // PERSISTENT first item — a keyed row that scrolls up as the conversation and |
| @@ -553,7 +585,7 @@ function renderItem(m, ctx) { |
| // A private "only visible to you" command reply — a client-only feed row that |
| // persists and stacks like a bubble (see addLocalNotice / buildLocalNotice). |
| if (m.notice) { |
| - item.appendChild(buildLocalNotice(m.notice)); |
| + item.appendChild(buildLocalNotice(m.notice, { cont: !!m.cont, solo: !!m.solo })); |
| return item; |
| } |
| @@ -2837,6 +2869,11 @@ function buildSkeleton(el, ctx) { |
| cancelReply(); |
| refreshStaged(); |
| input.focus(); |
| + // A scripted conversation awaiting an answer in this thread is told what |
| + // was sent — text AND attachments (the setup photo step takes a picture as |
| + // its answer). The message itself already sent normally; it's in the |
| + // transcript either way. |
| + deliverAnswer(ctx.threadId, payload, (r) => addLocalNotice(ctx, r)); |
| }); |
| }; |
diff --git a/web/static/apps/setup.js b/web/static/apps/setup.js| new file mode 100644 |
| index 0000000..b4ff709 |
| --- /dev/null |
| +++ b/web/static/apps/setup.js |
| @@ -0,0 +1,403 @@ |
| +// First-run setup — Eleven walks a fresh instance's admin through naming |
| +// themselves and the space, contact email, server photo, member invites, and — |
| +// most importantly — backing up their key. Chat-first: the whole thing is a |
| +// conversation (chips + typed answers), not a console. See |
| +// docs/conversational-onboarding.md. |
| +// |
| +// Like /tutorial, this is a CLIENT-DRIVEN script in Eleven's voice — it must |
| +// work with no AI and no bot at all (a fresh instance has neither, and "enable |
| +// AI?" will itself be a setup question). Unlike a mini-app it writes real |
| +// instance settings, so it talks to a host bridge (setSetupBridge in shell.js) |
| +// — the small "setup" app defined here exists only to give the conversation a |
| +// home: a personal thread whose chat view is the surface, whose `setup.step` |
| +// events make completed steps part of the encrypted transcript (the ✓ action |
| +// lines you see after a reload), and whose Interface view is a progress card. |
| +import { defineApp, setupBridge, awaitAnswer, cancelAnswer, appStateFor, emitAppEvent, spaceInfo, openAppThread } from "../apps.js"; |
| +import { startTour } from "./tutorial.js"; |
| + |
| +export const app = defineApp({ |
| + name: "setup", |
| + title: "Eleven", |
| + glyph: "🕚", |
| + // The sidebar badges this thread BOT, not APP — it presents as a conversation |
| + // with Eleven, and the app machinery underneath is an implementation detail. |
| + senderKind: "bot", |
| + // Chat-only: the conversation IS the thread — no Interface view, no toggle. |
| + // (The old setup progress card was superseded by the Admin panel.) |
| + chatOnly: true, |
| + // System scope: never offered in the Apps menu — the host mounts it for the |
| + // admin on first run (maybeStartSetup in shell.js), and only for them. |
| + scope: "system", |
| + initialState: () => ({ steps: {} }), |
| + events: { |
| + // A completed step. `label` is the human line the chat view shows; `value` |
| + // (optional) remembers the answer, for steps that resume on it. |
| + "setup.step": (s, e) => (e.step ? { steps: { ...s.steps, [e.step]: { done: true, value: e.value || "" } } } : s), |
| + }, |
| + // Completed steps project into the chat view as /me-style action lines, so the |
| + // transcript survives reloads even though the questions (local notices) don't. |
| + chatMessage(e) { |
| + if (e.type !== "setup.step" || !e.label) return null; |
| + return { text: e.label, action: true }; |
| + }, |
| +}); |
| + |
| +// ---- the conversation ----------------------------------------------------- |
| + |
| +// One live run per thread (a reload starts a fresh one; onceStep guards within). |
| +const running = new Set(); |
| + |
| +// startSetup(env): env = { threadId, respond } — respond appends one Eleven |
| +// notice to the setup thread's feed. The host guarantees we're the admin and |
| +// setup isn't complete before calling. |
| +export function startSetup(env) { |
| + const bridge = setupBridge(); |
| + if (!bridge || running.has(env.threadId)) return; |
| + running.add(env.threadId); |
| + env.sender = { name: "Eleven", kind: "bot" }; |
| + env.done = (step) => { |
| + const st = appStateFor(env.threadId, "setup"); |
| + return !!(st && st.steps && st.steps[step] && st.steps[step].done); |
| + }; |
| + env.record = (step, label, value) => emitAppEvent(env.threadId, { type: "setup.step", step, label, value: value || "" }); |
| + // A resumed run (reload, or the round-trip to the passkey page) gets a brief |
| + // re-greeting instead of the full hello; the steps then skip what's done. |
| + const resumed = !!bridge.myName(); |
| + if (resumed) say(env, "Welcome back — let's pick up where we left off."); |
| + spaceInfo() |
| + .then((info) => { |
| + env.space = info || {}; |
| + }) |
| + .catch(() => { |
| + env.space = {}; |
| + }) |
| + .finally(() => stepName(env, !resumed)); |
| +} |
| + |
| +// Replies play through a queue with a brief "Eleven is typing…" beat first — |
| +// instant answers read as canned, a beat reads as conversation. The beat scales |
| +// with the reply's length (capped) and the host provides the indicator hook. |
| +function sleep(ms) { |
| + return new Promise((r) => setTimeout(r, ms)); |
| +} |
| +let sayQueue = Promise.resolve(); |
| +let firstSay = true; // the opener greets faster — you just arrived, Eleven was expecting you |
| +function say(env, text, actions, extra) { |
| + const opener = firstSay; |
| + firstSay = false; |
| + sayQueue = sayQueue.then(async () => { |
| + if (env.typing) { |
| + // A beat of "thinking" before the indicator, then typing scaled to the |
| + // reply's length, with a little jitter — quick enough not to drag, uneven |
| + // enough to read as natural rather than mechanical. |
| + await sleep(opener ? 150 : 300 + Math.random() * 250); |
| + env.typing(true); |
| + const ms = Math.min(2000, (opener ? 350 : 600) + text.length * 4); |
| + await sleep(ms * (0.85 + Math.random() * 0.3)); |
| + env.typing(false); |
| + } |
| + env.respond({ sender: env.sender, text, actions, ...(extra || {}) }); |
| + }); |
| + return sayQueue; |
| +} |
| + |
| +// The same per-step tap guard as the tour: first advancing tap wins. |
| +function onceStep() { |
| + let done = false; |
| + return (fn) => { |
| + if (done) return; |
| + done = true; |
| + fn(); |
| + }; |
| +} |
| +const advance = (go, label, fn) => ({ label, run: () => go(fn) }); |
| + |
| +// Ask a typed question: the next composer message in this thread is the answer. |
| +// Chips stay live alongside (Keep / Skip), and whichever wins cancels the other. |
| +function ask(env, go, onText) { |
| + awaitAnswer(env.threadId, (text) => go(() => onText(text.trim()))); |
| +} |
| + |
| +// ---- steps ------------------------------------------------------------------ |
| + |
| +function stepName(env, greet) { |
| + // A name already exists (set on a previous run) — don't re-ask. |
| + if (env.done("name") || setupBridge().myName()) return stepGroup(env); |
| + const go = onceStep(); |
| + // Context first, then the request — two bubbles, so the scene-setting never |
| + // buries what's being asked. |
| + if (greet) { |
| + say( |
| + env, |
| + "Hi — I'm Eleven! 👋 This is your space now: everything in it is end-to-end encrypted, and you're its administrator.\n\n" + |
| + "Let's take two minutes to set things up.", |
| + ); |
| + } |
| + say(env, "First — what should everyone here call you? Reply with your name."); |
| + ask(env, go, async (name) => { |
| + if (!name) return stepName(env, false); |
| + name = name.slice(0, 32); // the same cap the profile rename applies |
| + await setupBridge().setMyName(name); |
| + await env.record("name", `noted: you're ${name}`, name); |
| + stepGroup(env, `Lovely to meet you, ${name}! 🤝`); |
| + }); |
| +} |
| + |
| +// Each step is two bubbles: a (multi-line) CONTEXT message, then a single-line |
| +// QUESTION. `ack` folds the previous step's acknowledgement into the context so |
| +// steps hand over in one breath, not three bubbles. |
| +function stepGroup(env, ack) { |
| + if (env.done("group")) return stepEmail(env); |
| + const go = onceStep(); |
| + // Offer "keep the current name" only when an admin explicitly chose one (a |
| + // re-run) — a fresh instance's compiled-in default is not worth keeping. |
| + const current = env.space && env.space.nameSet ? env.space.name || "" : ""; |
| + const chips = []; |
| + if (current) chips.push(advance(go, `Keep “${current}”`, () => stepEmail(env))); |
| + say( |
| + env, |
| + (ack ? ack + "\n\n" : "") + |
| + "Next, this space needs a name. It's the one everyone sees — on the sign-in page, in invites, on the app icon.", |
| + ); |
| + say( |
| + env, |
| + current ? `It's currently “${current}” — keep that, or reply with a new name.` : "What should we call it? Reply with a name.", |
| + chips.length ? chips : undefined, |
| + ); |
| + ask(env, go, async (name) => { |
| + if (!name) return stepGroup(env); |
| + const result = await setupBridge().setGroupName(name); |
| + if (result !== "ok") { |
| + // Be honest about which failure this is — a rejected value re-asks, but a |
| + // save that broke for other reasons (auth, network) shouldn't blame the name. |
| + say( |
| + env, |
| + result === "invalid" |
| + ? "Hmm, that name didn't stick — it needs to be 1–60 characters. Try another?" |
| + : "Something went wrong saving that — it's not the name's fault. Give it another go in a moment.", |
| + ); |
| + return stepGroup(env); |
| + } |
| + env.space.name = name; |
| + await env.record("group", `named this space “${name}”`, name); |
| + stepEmail(env); |
| + }); |
| +} |
| + |
| +function stepEmail(env) { |
| + if (env.done("email")) return stepPhoto(env); |
| + say(env, "Your contact email is shared with members — useful if someone's locked out, or something needs a human. It's optional."); |
| + askEmail(env, "How can people here reach you outside the chat? Reply with an email address, or skip it."); |
| +} |
| + |
| +// One bubble per attempt: a rejected address re-asks in place, not the whole |
| +// question again. |
| +function askEmail(env, text) { |
| + const go = onceStep(); |
| + say(env, text, [advance(go, "Skip for now", () => stepPhoto(env))]); |
| + ask(env, go, async (email) => { |
| + const result = await setupBridge().setContactEmail(email); |
| + if (result !== "ok") { |
| + return askEmail( |
| + env, |
| + result === "invalid" |
| + ? "That doesn't look like an email address — try again, or skip it." |
| + : "Something went wrong saving that — try again in a moment, or skip it.", |
| + ); |
| + } |
| + await env.record("email", `noted a contact email`, email); |
| + stepPhoto(env); |
| + }); |
| +} |
| + |
| +function stepPhoto(env) { |
| + if (env.done("photo")) return stepInvites(env); |
| + say(env, "Want to give this space a face? The photo shows on the welcome page and when people join — you can always change it later."); |
| + askPhoto(env, "Send me a photo — tap the + next to the message box and hit send. Type a message with it if you want to include alt text."); |
| +} |
| + |
| +// The photo arrives THROUGH THE CHAT, like sending any picture: the next |
| +// message with an image attachment is the answer (it's an ordinary encrypted |
| +// attachment; the host re-posts its bytes as the plaintext branding photo). |
| +function askPhoto(env, prompt) { |
| + const go = onceStep(); |
| + // Skipping must disarm the photo waiter, or the next thing typed (steps from |
| + // here on are chip-only) would still be treated as a photo attempt. |
| + say(env, prompt, [ |
| + advance(go, "Skip for now", () => { |
| + cancelAnswer(env.threadId); |
| + stepInvites(env); |
| + }), |
| + ]); |
| + const arm = () => |
| + awaitAnswer(env.threadId, (text, payload) => { |
| + const img = ((payload && payload.attachments) || []).find((a) => a && a.type && String(a.type).startsWith("image/")); |
| + if (!img) { |
| + say(env, "Send it as a picture — tap the + next to the message box — or tap Skip."); |
| + return arm(); |
| + } |
| + // The adopt runs INSIDE the step guard: if Skip won the race, the sent |
| + // photo is never installed as branding behind the admin's back. |
| + go(async () => { |
| + // Any text sent along with the photo becomes its alt text (screen |
| + // readers, and anywhere the image can't render) — the preview step |
| + // shows it back verbatim before it's kept, because it's published as |
| + // PLAINTEXT branding, unlike the chat message it arrived in. |
| + env.photoAlt = (text || "").trim(); |
| + const ok = await setupBridge().adoptServerPhoto(env.threadId, img, env.photoAlt); |
| + if (!ok) { |
| + say(env, "Hmm, I couldn't use that one — try another?"); |
| + return askPhoto(env, "Send another photo when you're ready, or skip it."); |
| + } |
| + stepPhotoPreview(env); |
| + }); |
| + }); |
| + arm(); |
| +} |
| + |
| +// Show the adopted photo back and let them swap it until it's right. |
| +// Recorded (and moved past) only on an explicit keep. The alt text is echoed |
| +// VERBATIM with a plain warning: it left the encrypted chat and is now public |
| +// branding — the admin must see exactly what got published before keeping it. |
| +function stepPhotoPreview(env) { |
| + const go = onceStep(); |
| + say( |
| + env, |
| + "Here's how it looks." + |
| + (env.photoAlt |
| + ? `\n\nIts alt text is the message you sent — “${env.photoAlt}” — and like the photo itself it's PUBLIC: anyone with an invite link can see both.` |
| + : "") + |
| + "\n\nHappy with it, or want to try another?", |
| + [ |
| + advance(go, "Keep this photo", async () => { |
| + await env.record("photo", "set the space photo", env.photoAlt || ""); |
| + stepInvites(env); |
| + }), |
| + advance(go, "Use a different photo", () => askPhoto(env, "Send me another photo, then — with a message for its alt text, if you like.")), |
| + ], |
| + { img: "/api/server-avatar?t=" + Date.now(), imgAlt: env.photoAlt || "The space photo" }, |
| + ); |
| +} |
| + |
| +function stepInvites(env) { |
| + if (env.done("invites")) return stepKeys(env); |
| + const go = onceStep(); |
| + say( |
| + env, |
| + "Who gets to bring people in? You can always invite people. The question is whether members can too, or whether new faces only arrive through you.", |
| + [ |
| + advance(go, "Members can invite people", async () => { |
| + await setupBridge().setUserInvites(true); |
| + await env.record("invites", "decided members can invite people", "members"); |
| + stepKeys(env); |
| + }), |
| + advance(go, "Only me", async () => { |
| + await setupBridge().setUserInvites(false); |
| + await env.record("invites", "decided only the admin invites people", "admin only"); |
| + stepKeys(env); |
| + }), |
| + ], |
| + ); |
| +} |
| + |
| +function stepKeys(env) { |
| + const bridge = setupBridge(); |
| + if (bridge.enrolled()) { |
| + if (!env.done("keys")) { |
| + env.record("keys", "Saved your credentials securely with a passkey", "passkey"); |
| + say(env, "Your key is safely backed up behind your passkey. ✅"); |
| + } |
| + return stepFinish(env, true); |
| + } |
| + const go = onceStep(); |
| + say( |
| + env, |
| + "Now the most important thing of all. 🔑\n\n" + |
| + "Everything here is locked with a key that exists only on your devices. There's no password reset and no support desk — not even whoever runs the server can read your messages or recover that key. That's not a flaw; it's the whole point.\n\n" + |
| + "But it means: right now, this browser holds the only key to your space. If it's lost — cleared, broken, stolen — your space is gone with it, and nobody can get it back.", |
| + ); |
| + say( |
| + env, |
| + "Shall we save a copy behind a passkey now? One tap — you'll be brought straight back.", |
| + [ |
| + { |
| + label: "Save a passkey (recommended)", |
| + run: () => { |
| + // On the web this navigates away and setup RESUMES here on return. |
| + // In the native app the ceremony opens in the system browser and this |
| + // page stays put — so if we're still here in a moment, offer the way |
| + // to continue once they're done over there. |
| + bridge.savePasskey(); |
| + setTimeout(() => { |
| + if (!setupBridge().enrolled()) go(() => stepKeysWaiting(env)); |
| + }, 1500); |
| + }, |
| + }, |
| + advance(go, "Not now", () => stepKeysConfirm(env)), |
| + ], |
| + ); |
| +} |
| + |
| +// The stayed-in-place passkey path (native / system browser): wait for them to |
| +// finish elsewhere, then continue on their say-so. When the ceremony happened in |
| +// THIS browser we can verify; when it happened in another, we can't — take their |
| +// word, and the sidebar reminder stays honest either way (it reads real state). |
| +function stepKeysWaiting(env) { |
| + const go = onceStep(); |
| + say(env, "I'll wait here while you finish saving the passkey. When you're done, tap Continue.", [ |
| + advance(go, "Continue", async () => { |
| + const label = setupBridge().enrolled() |
| + ? "Saved your credentials securely with a passkey" |
| + : "Saved your credentials with a passkey (in your browser)"; |
| + await env.record("keys", label, "passkey"); |
| + stepFinish(env, true); |
| + }), |
| + advance(go, "It didn't work — skip for now", async () => { |
| + await env.record("keys", "Skipped the credentials backup for now", "skipped"); |
| + stepFinish(env, false); |
| + }), |
| + ]); |
| +} |
| + |
| +function stepKeysConfirm(env) { |
| + const go = onceStep(); |
| + say( |
| + env, |
| + "I have to be straight with you: without a backup, one lost or cleared browser means this space and everything in it is gone — permanently, for everyone, and no one can help. I'll remind you again in a day or so.", |
| + [ |
| + { |
| + label: "Go back and save it", |
| + run: () => { |
| + setupBridge().savePasskey(); |
| + setTimeout(() => { |
| + if (!setupBridge().enrolled()) go(() => stepKeysWaiting(env)); |
| + }, 1500); |
| + }, |
| + }, |
| + advance(go, "I understand the risk", async () => { |
| + await env.record("keys", "Skipped the credentials backup for now", "skipped"); |
| + stepFinish(env, false); |
| + }), |
| + ], |
| + ); |
| +} |
| + |
| +async function stepFinish(env, backedUp) { |
| + const bridge = setupBridge(); |
| + // A skipped key backup hands the reminding over to the sidebar banner — on |
| + // the gentle escalating schedule, not immediately (Eleven just said its piece). |
| + if (!backedUp && bridge.deferBackupReminders) bridge.deferBackupReminders(); |
| + await bridge.markSetupComplete(); |
| + const name = (env.space && env.space.name) || "your space"; |
| + say( |
| + env, |
| + `That's it — ${name} is ready! 🎉\n\n` + |
| + `Everything we just set up lives in your Admin panel — invite links, the space's name and photo, who can invite. Type \`/admin\` anywhere to open it; it's yours alone as this space's admin.` + |
| + (backedUp ? "" : `\n\nAnd do come back to that key backup soon.`), |
| + ); |
| + say(env, "Where would you like to start?", [ |
| + { label: "Open admin", run: () => openAppThread("admin") }, |
| + { label: "Take the tour", run: () => startTour(env.respond) }, |
| + ]); |
| + running.delete(env.threadId); |
| +} |
diff --git a/web/static/backup.js b/web/static/backup.js| index 24a085c..d076bd1 100644 |
| --- a/web/static/backup.js |
| +++ b/web/static/backup.js |
| @@ -35,16 +35,46 @@ const RESTORE_MARK = "lchat_restore"; // return fragment key: #lchat_restore=<bl |
| const SNOOZE_MS = 7 * 24 * 60 * 60 * 1000; // "remind me later" = ~7 days |
| +// The escalating reminder schedule after someone SKIPS the key backup in the |
| +// setup conversation: quiet for a day, then a nudge, again at three days, and a |
| +// final one at a week — after which the classic snooze/opt-out cycle takes |
| +// over. Eleven already delivered the double-prompt in chat, so the banner |
| +// doesn't pile on immediately. |
| +export const REMIND_RUNGS_MS = [24, 72, 168].map((h) => h * 60 * 60 * 1000); |
| +const DEFERRED_KEY = "lchat_backup_deferred_at"; // unix-ms the setup skip happened |
| +const STAGE_KEY = "lchat_backup_remind_stage"; // reminders dismissed so far (0..) |
| + |
| // backupBannerState is the PURE visibility rule (no DOM, no storage), so it's |
| -// unit-testable. Enrolment and a permanent dismissal hide it forever; a live |
| -// snooze hides it until it expires; otherwise we nudge. |
| -export function backupBannerState({ enrolled, dismissed, snoozedUntil, now }) { |
| +// unit-testable. Enrolment and a permanent dismissal hide it forever; an active |
| +// deferral schedule (a setup-time skip) hides it until its next rung is due; a |
| +// live snooze hides it until it expires; otherwise we nudge. |
| +export function backupBannerState({ enrolled, dismissed, snoozedUntil, deferredAt, remindStage, now }) { |
| if (enrolled) return "hidden"; |
| if (dismissed) return "hidden"; |
| + if (deferredAt && (remindStage || 0) < REMIND_RUNGS_MS.length) { |
| + return now < deferredAt + REMIND_RUNGS_MS[remindStage || 0] ? "hidden" : "show"; |
| + } |
| if (snoozedUntil && now < snoozedUntil) return "hidden"; |
| return "show"; |
| } |
| +// deferBackupNudges starts the reminder schedule: called when the setup |
| +// conversation's key step is skipped, so the banner stays quiet for the first |
| +// day instead of contradicting the conversation that just happened. |
| +export function deferBackupNudges() { |
| + localStorage.setItem(DEFERRED_KEY, String(Date.now())); |
| + localStorage.setItem(STAGE_KEY, "0"); |
| +} |
| + |
| +function deferredAt() { |
| + const n = parseInt(localStorage.getItem(DEFERRED_KEY) || "", 10); |
| + return Number.isFinite(n) ? n : 0; |
| +} |
| +function remindStage() { |
| + const n = parseInt(localStorage.getItem(STAGE_KEY) || "", 10); |
| + return Number.isFinite(n) ? n : 0; |
| +} |
| + |
| // isEnrolled reads the local record set once the home server confirms a backup. |
| export function isEnrolled() { |
| return localStorage.getItem(ENROLLED_KEY) === "1"; |
| @@ -120,12 +150,17 @@ export function initBackupBanner(session, opts = {}) { |
| // If we've just returned from the home server's /enroll page, record the |
| // backup (and scrub the marker) before deciding whether to nudge. |
| checkEnrolReturn(); |
| + // While the setup conversation is running, backup messaging is Eleven's job — |
| + // one voice, not a sidebar banner competing with the in-chat key ceremony. |
| + if (opts.suppress) return; |
| const el = bannerEl(); |
| if (!el) return; |
| const state = backupBannerState({ |
| enrolled: isEnrolled(), |
| dismissed: isDismissed(), |
| snoozedUntil: snoozedUntil(), |
| + deferredAt: deferredAt(), |
| + remindStage: remindStage(), |
| now: opts.now ?? Date.now(), |
| }); |
| if (state !== "show") return; // leaves it .hidden |
| @@ -150,16 +185,31 @@ function renderBanner(el, session) { |
| cta.textContent = "Save securely"; |
| cta.addEventListener("click", () => launchEnrol(session)); |
| - // Double-dismiss: the first click snoozes for a week and escalates the label to |
| - // spell out the consequence; a second click on the escalated control opts out |
| - // for good. This makes "I never want this" a deliberate, informed choice. |
| + // Dismissal. On the deferral schedule's early rungs (24h / 72h), "later" just |
| + // advances to the next rung. The final rung — and the no-schedule case — keeps |
| + // the double-dismiss: first click snoozes for a week and escalates the label |
| + // to spell out the consequence; a second click on the escalated control opts |
| + // out for good. "I never want this" stays a deliberate, informed choice. |
| const dismiss = document.createElement("button"); |
| dismiss.className = "backup-banner-dismiss linkish"; |
| dismiss.textContent = "Remind me later"; |
| let escalated = false; |
| dismiss.addEventListener("click", () => { |
| + const stage = remindStage(); |
| + if (deferredAt() && stage < REMIND_RUNGS_MS.length - 1) { |
| + // Re-anchor the schedule at THIS dismissal: the rungs are minimum gaps |
| + // from the last time the user waved it away, not absolute deadlines — or |
| + // a user who first sees the banner days late would dismiss it only to |
| + // have an already-elapsed rung bring it straight back. |
| + localStorage.setItem(DEFERRED_KEY, String(Date.now())); |
| + localStorage.setItem(STAGE_KEY, String(stage + 1)); |
| + removeBanner(); |
| + return; |
| + } |
| if (!escalated) { |
| localStorage.setItem(SNOOZE_KEY, String(Date.now() + SNOOZE_MS)); |
| + // Leaving the final rung: the classic snooze cycle owns it from here. |
| + if (deferredAt()) localStorage.setItem(STAGE_KEY, String(REMIND_RUNGS_MS.length)); |
| dismiss.textContent = "I understand — I can lose access to this chat"; |
| dismiss.classList.add("danger"); |
| escalated = true; |
diff --git a/web/static/join.js b/web/static/join.js| index 4adf461..ad0369a 100644 |
| --- a/web/static/join.js |
| +++ b/web/static/join.js |
| @@ -8,6 +8,19 @@ import { parseInviteFragment } from "/static/invite.js"; |
| const SESSION_KEY = "lchat_session"; |
| const card = document.getElementById("card"); |
| +const loader = document.getElementById("app-loading"); |
| + |
| +// The ONE loading state: the same full-screen loader the app uses. It covers |
| +// the page until a card has real content, and again while a claim is in |
| +// flight — the card and the loader are never shown together. |
| +function showLoader() { |
| + if (loader) loader.classList.remove("hidden"); |
| + card.classList.add("hidden"); |
| +} |
| +function showCard() { |
| + if (loader) loader.classList.add("hidden"); |
| + card.classList.remove("hidden"); |
| +} |
| const isStandalone = |
| window.matchMedia("(display-mode: standalone)").matches || window.navigator.standalone === true; |
| @@ -31,6 +44,7 @@ let inviteClaimSecret = ""; |
| // group name). Fetched in start() and shown on the onboarding cards in place of |
| // the generic emoji. See docs/onboarding-and-home-server.md. |
| let serverHasAvatar = false; |
| +let serverAvatarAlt = ""; |
| async function start() { |
| let id, wrapKey, info; |
| @@ -50,7 +64,9 @@ async function start() { |
| // Note whether this instance has a branding photo (a public flag), so the |
| // onboarding cards below can show it instead of the placeholder emoji. |
| try { |
| - serverHasAvatar = !!(await (await fetch("/api/group")).json()).avatar; |
| + const g = await (await fetch("/api/group")).json(); |
| + serverHasAvatar = !!g.avatar; |
| + serverAvatarAlt = g.avatar_alt || ""; |
| } catch {} |
| // Everything below only *decides* what continuing means for this link — the |
| @@ -98,6 +114,17 @@ async function start() { |
| ); |
| } |
| + // Setup invite (a fresh instance's first user, who becomes its admin): no name |
| + // form — the claim lands straight in the chat view, where the Eleven setup |
| + // conversation asks for a name instead. See docs/conversational-onboarding.md. |
| + if (info.grants_admin) { |
| + return showWelcome("setup", async () => { |
| + showStatus("Setting up…"); |
| + const kp = await generateUserKeypair(); |
| + finish(id, "", kp.publicKey, kp.privateKey); |
| + }); |
| + } |
| + |
| // New person (or unknown payload shape): pick a screen name (pre-filled if the |
| // inviter suggested one), then generate a fresh keypair. |
| showWelcome("join", () => |
| @@ -117,6 +144,7 @@ function showWelcome(kind, onContinue) { |
| card.classList.add("onboard"); |
| card.classList.remove("status"); |
| card.innerHTML = ""; |
| + showCard(); |
| const tpl = document.getElementById("welcome-tpl"); |
| card.appendChild(tpl.content.cloneNode(true)); |
| applyServerPhoto(card); |
| @@ -131,6 +159,12 @@ function showWelcome(kind, onContinue) { |
| "You're already in this chat on this device — continue to open the new conversation."; |
| card.querySelector("#welcome-caveat").remove(); |
| card.querySelector("#welcome-web").textContent = "Continue"; |
| + } else if (kind === "setup") { |
| + card.querySelector("#welcome-title").textContent = "Welcome to your new space"; |
| + card.querySelector("#welcome-sub").textContent = |
| + "This one-time link makes you its administrator. Continue, and we'll set things up together."; |
| + card.querySelector("#welcome-caveat").remove(); |
| + card.querySelector("#welcome-web").textContent = "Let's get started"; |
| } |
| // "Open in the app" hands the full link — fragment included — to the installed |
| // app via its scheme; the scheme URL never reaches any server. There's no way |
| @@ -237,6 +271,7 @@ function applyServerPhoto(root) { |
| const img = root.querySelector(".onboard-photo"); |
| if (!img) return; |
| img.src = "/api/server-avatar"; |
| + img.alt = serverAvatarAlt; // the admin's alt text travels with the photo |
| img.classList.remove("hidden"); |
| const emoji = root.querySelector(".onboard-icon"); |
| if (emoji) emoji.classList.add("hidden"); |
| @@ -246,6 +281,7 @@ function showForm(defaultName, onSubmit) { |
| card.classList.remove("onboard", "status"); |
| card.innerHTML = ""; |
| card.appendChild(document.getElementById("form-tpl").content.cloneNode(true)); |
| + showCard(); |
| applyServerPhoto(card); |
| const input = card.querySelector("#name"); |
| if (defaultName) input.value = defaultName; |
| @@ -258,17 +294,11 @@ function showForm(defaultName, onSubmit) { |
| }; |
| } |
| -function showStatus(msg) { |
| - card.classList.remove("onboard"); |
| - card.classList.add("status"); // centers the spinner + message in the card |
| - card.innerHTML = ""; |
| - const spinner = document.createElement("div"); |
| - spinner.className = "status-spinner"; |
| - const p = document.createElement("p"); |
| - p.className = "status-msg"; |
| - p.textContent = msg; |
| - card.appendChild(spinner); |
| - card.appendChild(p); |
| +// In-flight states ("Setting up…", "Signing you in…") all render as the one |
| +// full-screen loader — the message argument is accepted for call-site clarity |
| +// but the loader speaks for itself. |
| +function showStatus(_msg) { |
| + showLoader(); |
| } |
| function showError(msg) { |
| @@ -276,5 +306,6 @@ function showError(msg) { |
| card.innerHTML = ""; |
| card.appendChild(document.getElementById("error-tpl").content.cloneNode(true)); |
| card.querySelector("#error-msg").textContent = msg; |
| + showCard(); |
| } |
diff --git a/web/static/shell.js b/web/static/shell.js| index 052538c..3416371 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -54,6 +54,7 @@ import { |
| setHostBridge, |
| setSpaceInfo, |
| setElevenTour, |
| + setSetupBridge, |
| runCommand, |
| commandCatalog, |
| buildLocalNotice, |
| @@ -71,6 +72,7 @@ import { |
| renderApp as renderMiniApp, |
| appByName, |
| appWantsChatView, |
| + appChatOnly, |
| personalApps, |
| messageActionsFor, |
| renderInline as renderAppLine, |
| @@ -86,10 +88,12 @@ import "/static/apps/board.js"; |
| import "/static/apps/connect.js"; |
| import "/static/apps/dnsimple.js"; |
| import { startTour } from "/static/apps/tutorial.js"; // also registers the /tutorial floor command |
| -import { setHost as setChatAppHost, setConnected as setChatAppConnected, appendNotice } from "/static/apps/chat.js"; |
| +import { startSetup } from "/static/apps/setup.js"; // the first-run setup conversation (also registers its app) |
| +import "/static/apps/admin.js"; // the admin panel (admin-only; opened via /admin or setup's chip) |
| +import { setHost as setChatAppHost, setConnected as setChatAppConnected, appendNotice, setNoticeTyping } from "/static/apps/chat.js"; |
| import { parseInviteFragment, buildInviteFragment } from "/static/invite.js"; |
| import { qrCanvas } from "/static/qrcode.js"; |
| -import { initBackupBanner, canRestore, launchRestore, checkRestoreReturn } from "/static/backup.js"; |
| +import { initBackupBanner, canRestore, launchRestore, checkRestoreReturn, launchEnrol, isEnrolled, deferBackupNudges } from "/static/backup.js"; |
| import { |
| needsHomeScreen, |
| pushAvailable, |
| @@ -517,6 +521,17 @@ async function boot() { |
| try { |
| await loadCustomApps(); |
| await ensurePubkey(); |
| + // Role + first-run state, fetched in parallel with the heavy loads: |
| + // maybeStartSetup awaits it at the end, and on first run the loading cover |
| + // holds until Eleven's conversation is open (no flash of an empty home). |
| + // Time-boxed: a stalled /api/me must never hold the reveal hostage for |
| + // everyone — on timeout the app opens normally and setup retries next load. |
| + const mePromise = Promise.race([ |
| + fetch(`/api/me?token=${encodeURIComponent(session.token)}`) |
| + .then((r) => r.json()) |
| + .catch(() => null), |
| + new Promise((resolve) => setTimeout(() => resolve(null), 4000)), |
| + ]); |
| await loadRoster(); |
| await Promise.all([setupInstanceKey(), loadThreads()]); |
| // Names are encrypted under the instance key, which is only ready now — decrypt |
| @@ -529,7 +544,8 @@ async function boot() { |
| connectWS(); |
| registerSW(session); // best-effort; subscribes if notifications already on |
| startHeartbeat(); |
| - initBackupBanner(session); // inline sidebar nudge to back up access with a passkey |
| + // The backup banner is initialised inside maybeStartSetup (below): while the |
| + // setup conversation runs, backup messaging is Eleven's — one voice. |
| // Tapping a notification while a tab is already open posts us the thread to |
| // open (the SW can't navigate a live SPA) — jump straight to it. |
| if (navigator.serviceWorker) { |
| @@ -540,6 +556,9 @@ async function boot() { |
| // Reveal only once the initial feed is loaded + scrolled — so the first paint |
| // is the finished layout, not an empty feed that then jumps to the bottom. |
| await opened; |
| + // First-run: open the admin's setup conversation before the reveal, so the |
| + // first thing an admin ever sees IS the chat with Eleven (no-op otherwise). |
| + await maybeStartSetup(mePromise); |
| } catch (e) { |
| console.error(e); |
| app.innerHTML = |
| @@ -570,7 +589,7 @@ async function bootEmbed() { |
| // embed path needs registration complete before its render too. |
| await loadCustomApps(); |
| myUserId = embed.myUserId || ""; |
| - setCurrentUser(myUserId, session.name); |
| + setCurrentUser(myUserId, session.name || "Admin"); |
| await loadRoster(); // token-only; gives sender labels + profile photos |
| if (embed.instanceKey) { |
| try { |
| @@ -703,7 +722,10 @@ async function loadRoster() { |
| const me = roster.find((e) => e.public_key === session.userPub); |
| myUserId = me ? me.user_id : ""; |
| } |
| - setCurrentUser(myUserId, session.name); // let apps do per-user logic (e.g. poll "your vote") |
| + // The "Admin" fallback (here and on every outbound payload): a setup-invite |
| + // claimer is signed in nameless until Eleven's first question — if they send |
| + // anything before answering, recipients see "Admin", never a blank sender. |
| + setCurrentUser(myUserId, session.name || "Admin"); // let apps do per-user logic (e.g. poll "your vote") |
| if (instanceKey) await decryptRosterLabels(); |
| } |
| @@ -1429,10 +1451,17 @@ function renderThreadList() { |
| } |
| body.appendChild(name); |
| if (appName) { |
| - const badge = document.createElement("span"); |
| - badge.className = "app-badge"; |
| - badge.textContent = "app"; |
| - name.appendChild(badge); |
| + // An app may present as a persona rather than a tool (the setup thread is |
| + // "Eleven", badged BOT) — senderKind overrides the generic APP chip. |
| + const a = appByName(appName); |
| + if (a && a.senderKind) { |
| + name.appendChild(agentTag(true, a.senderKind)); |
| + } else { |
| + const badge = document.createElement("span"); |
| + badge.className = "app-badge"; |
| + badge.textContent = "app"; |
| + name.appendChild(badge); |
| + } |
| } |
| if (threadPreview[t.id]) { |
| const prev = document.createElement("span"); |
| @@ -1754,8 +1783,9 @@ function paintViewToggle(id) { |
| let toggle = document.getElementById("view-toggle"); |
| const owner = threadApp[id]; |
| // Only a companion app-thread has more than one view to switch between; a |
| - // takeover app (no chatMessage) owns the whole surface, so it gets no toggle. |
| - if (!owner || !head || !appWantsChatView(owner)) { |
| + // takeover app (no chatMessage) owns the whole surface, and a chat-only app |
| + // (the setup conversation) has just its chat — neither gets a toggle. |
| + if (!owner || !head || !appWantsChatView(owner) || appChatOnly(owner)) { |
| if (toggle) toggle.remove(); |
| return; |
| } |
| @@ -2238,6 +2268,12 @@ setScheduleTransport({ |
| // apply identically); blobs, uploads, unfurls, and history pages reuse the |
| // original machinery — the app itself never touches crypto or tokens. |
| setChatAppHost({ |
| + // A thread with no other members (self threads — the setup conversation, app |
| + // threads): notices there are implicitly private, so no "Only visible to you". |
| + soloThread: (threadId) => { |
| + const t = threads.find((x) => x.id === threadId); |
| + return !!t && t.kind === "self"; |
| + }, |
| react: (threadId, messageId, emoji, on) => { |
| if (!ws || ws.readyState !== WebSocket.OPEN) return false; |
| ws.send(JSON.stringify({ type: on ? "react" : "unreact", thread_id: threadId, message_id: messageId, emoji })); |
| @@ -2565,7 +2601,7 @@ setThreadHasBot((id) => threadHasBot(id)); |
| setSpaceInfo(async () => { |
| try { |
| const g = await (await fetch(`/api/group?token=${encodeURIComponent(session.token)}`)).json(); |
| - return { name: g.name || "", adminName: g.admin_name || "", adminEmail: g.admin_email || "" }; |
| + return { name: g.name || "", nameSet: !!g.name_set, adminName: g.admin_name || "", adminEmail: g.admin_email || "" }; |
| } catch { |
| return {}; |
| } |
| @@ -2593,13 +2629,191 @@ setElevenTour(async () => { |
| }; |
| }); |
| +// The setup conversation's host bridge (docs/conversational-onboarding.md): the |
| +// script is a host-level feature like the tour, and these are the real-world |
| +// capabilities it drives. Admin writes authenticate with the member's own device |
| +// token — the server accepts it because their user holds the admin role. |
| +const adminAuth = () => ({ Authorization: "Bearer " + session.token, "Content-Type": "application/json" }); |
| +// setGroup clears any field it isn't sent, so writes always carry the full |
| +// current trio (name + admin name + contact email) with one field changed. |
| +// Returns "ok" | "invalid" (the server rejected the value) | "error" (auth / |
| +// network / anything else) — so the conversation can be honest about which. |
| +async function saveGroupSettings(patch) { |
| + try { |
| + const g = await (await fetch(`/api/group?token=${encodeURIComponent(session.token)}`)).json(); |
| + const body = { |
| + name: g.name || "", |
| + admin_name: g.admin_name || "", |
| + admin_email: g.admin_email || "", |
| + ...patch, |
| + }; |
| + const r = await fetch("/api/admin/group", { method: "POST", headers: adminAuth(), body: JSON.stringify(body) }); |
| + if (r.ok && patch.name) document.title = patch.name; // header text updates on next load |
| + return r.ok ? "ok" : r.status === 400 ? "invalid" : "error"; |
| + } catch { |
| + return "error"; |
| + } |
| +} |
| +setSetupBridge({ |
| + myName: () => session.name || "", |
| + setMyName: async (name) => { |
| + session.name = name.slice(0, 32); |
| + localStorage.setItem(SESSION_KEY, JSON.stringify(session)); |
| + setCurrentUser(myUserId, session.name || "Admin"); // emitted events carry the new name |
| + const me = document.getElementById("me-avatar"); |
| + if (me) paintAvatar(me, session.name, () => avatarFor(myUserId)); |
| + await publishMyLabel(); |
| + }, |
| + setGroupName: (name) => saveGroupSettings({ name }), |
| + setContactEmail: (email) => saveGroupSettings({ admin_email: email }), |
| + // The admin panel saves name + contact together (one Save for the section). |
| + saveSpace: ({ name, email }) => saveGroupSettings({ name, admin_email: email }), |
| + // Adopt a photo the admin just SENT in the chat as the server photo: fetch + |
| + // decrypt the attachment (it's an ordinary E2E blob) straight to bytes — NOT |
| + // via an object URL; the page CSP's connect-src 'self' blocks fetch(blob:) — |
| + // then re-post to the plaintext branding endpoint, deliberately so; the |
| + // server photo must render where no key exists yet (join/welcome cards). |
| + // `alt` (the text typed alongside the photo) rides as the photo's alt text. |
| + adoptServerPhoto: async (threadId, att, alt) => { |
| + try { |
| + const plain = await loadBlobBytes(att.id, threadId); |
| + if (!plain) return false; |
| + const { bytes } = await processImage(new Blob([plain], { type: att.type || "image/jpeg" }), 400); |
| + const r = await fetch("/api/admin/server-avatar?alt=" + encodeURIComponent(alt || ""), { |
| + method: "POST", |
| + headers: { Authorization: "Bearer " + session.token }, |
| + body: bytes, |
| + }); |
| + return r.ok; |
| + } catch { |
| + return false; |
| + } |
| + }, |
| + // The server photo is plaintext branding by design (it must render where no |
| + // key exists yet — the join/welcome cards). Same downscale as the admin |
| + // console. `alt` accompanies the upload so a photo change never silently |
| + // wipes the stored alt text (the server resets it on every upload). |
| + pickServerPhoto: (alt) => |
| + new Promise((resolve) => { |
| + const input = document.createElement("input"); |
| + input.type = "file"; |
| + input.accept = "image/*"; |
| + input.onchange = async () => { |
| + const f = input.files[0]; |
| + if (!f) return resolve(false); |
| + try { |
| + const { bytes } = await processImage(f, 400); |
| + const r = await fetch("/api/admin/server-avatar?alt=" + encodeURIComponent(alt || ""), { |
| + method: "POST", |
| + headers: { Authorization: "Bearer " + session.token }, |
| + body: bytes, |
| + }); |
| + resolve(r.ok); |
| + } catch { |
| + resolve(false); |
| + } |
| + }; |
| + input.oncancel = () => resolve(false); |
| + input.click(); |
| + }), |
| + setUserInvites: async (on) => { |
| + try { |
| + return (await fetch("/api/admin/settings", { method: "POST", headers: adminAuth(), body: JSON.stringify({ user_invites: on }) })).ok; |
| + } catch { |
| + return false; |
| + } |
| + }, |
| + markSetupComplete: async () => { |
| + try { |
| + return (await fetch("/api/admin/settings", { method: "POST", headers: adminAuth(), body: JSON.stringify({ setup_complete: true }) })).ok; |
| + } catch { |
| + return false; |
| + } |
| + }, |
| + savePasskey: () => launchEnrol(session), |
| + enrolled: () => isEnrolled(), |
| + // Skipping the key ceremony starts the banner's escalating reminder schedule |
| + // (24h → 72h → 1w) instead of an immediate contradiction of the conversation. |
| + deferBackupReminders: () => deferBackupNudges(), |
| + // ---- the Admin app's reads/writes (role-gated server-side either way) ---- |
| + isAdmin: async () => { |
| + try { |
| + const me = await (await fetch(`/api/me?token=${encodeURIComponent(session.token)}`)).json(); |
| + return me.role === "admin"; |
| + } catch { |
| + return false; |
| + } |
| + }, |
| + groupInfo: async () => { |
| + try { |
| + const [g, s] = await Promise.all([ |
| + fetch(`/api/group?token=${encodeURIComponent(session.token)}`).then((r) => r.json()), |
| + fetch("/api/admin/settings", { headers: adminAuth() }).then((r) => r.json()), |
| + ]); |
| + return { name: g.name || "", nameSet: !!g.name_set, email: g.admin_email || "", avatar: !!g.avatar, avatarAlt: g.avatar_alt || "", userInvites: !!s.user_invites }; |
| + } catch { |
| + return null; |
| + } |
| + }, |
| + // Edit the photo's alt text without re-uploading the image. |
| + setAvatarAlt: async (alt) => { |
| + try { |
| + return (await fetch("/api/admin/settings", { method: "POST", headers: adminAuth(), body: JSON.stringify({ avatar_alt: alt }) })).ok; |
| + } catch { |
| + return false; |
| + } |
| + }, |
| + removeServerPhoto: async () => { |
| + try { |
| + return (await fetch("/api/admin/server-avatar", { method: "DELETE", headers: adminAuth() })).ok; |
| + } catch { |
| + return false; |
| + } |
| + }, |
| +}); |
| + |
| +// First run: the instance's admin lands in a conversation with Eleven that walks |
| +// setup (name, space name, email, photo, invites, the key backup) — chat-first, |
| +// no /admin. Gated server-side by role + the setup_complete flag, so it appears |
| +// for exactly one person, until finished. |
| +async function maybeStartSetup(mePromise) { |
| + const me = await mePromise; |
| + const setupActive = !!me && me.role === "admin" && !me.setup_complete; |
| + // The sidebar backup nudge defers to the setup conversation entirely; once |
| + // setup is done (or for everyone else) it renders on its normal rules — which |
| + // include the escalating 24h/72h/1w schedule a setup-time skip starts. |
| + initBackupBanner(session, { suppress: setupActive }); |
| + if (!setupActive) return; |
| + try { |
| + const id = await resolveAppThread("setup"); |
| + if (!id) return; |
| + appThreadView[id] = "chat"; // the conversation IS the surface (Interface is a progress card) |
| + await openThread(id); |
| + // Notices pin the feed themselves (addLocalNotice arms pinNext); the typing |
| + // row needs the same nudge so the beat is visible before the reply lands. |
| + const pinFeed = () => |
| + requestAnimationFrame(() => { |
| + const feed = document.querySelector(".chatapp-feed"); |
| + if (feed) feed.scrollTop = feed.scrollHeight; |
| + }); |
| + startSetup({ |
| + threadId: id, |
| + typing: (on) => { |
| + setNoticeTyping(id, "Eleven", on); |
| + if (on) pinFeed(); |
| + }, |
| + respond: (spec) => appendNotice(id, spec), |
| + }); |
| + } catch {} |
| +} |
| + |
| // Send a normal chat message (name + text) into a thread, as if typed. Same |
| // envelope + encryption as the composer and the app-event transport; used by the |
| // tour to ask Eleven a question on the member's behalf. |
| async function sendChatText(threadId, text) { |
| const key = threadKeys[threadId]; |
| if (!key || !ws || ws.readyState !== WebSocket.OPEN) return false; |
| - const payload = { name: session.name, text, ts: Date.now() }; |
| + const payload = { name: session.name || "Admin", text, ts: Date.now() }; |
| if (myUserId) payload.uid = myUserId; |
| const blob = await encrypt(key, payload); |
| ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob })); |
| @@ -2645,7 +2859,7 @@ function sendAndConfirm(threadId, blob) { |
| async function announceJoin(threadId, uids) { |
| const key = threadKeys[threadId]; |
| if (!key || !uids.length || !ws || ws.readyState !== WebSocket.OPEN) return; |
| - const payload = { name: session.name, text: `${session.name} added ${nameList(uids.map(rosterLabel))}`, join: uids.slice(), ts: Date.now(), action: true }; |
| + const payload = { name: session.name || "Admin", text: `${session.name || "Admin"} added ${nameList(uids.map(rosterLabel))}`, join: uids.slice(), ts: Date.now(), action: true }; |
| if (myUserId) payload.uid = myUserId; |
| const blob = await encrypt(key, payload); |
| ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob })); |
| @@ -2660,7 +2874,7 @@ async function announceJoin(threadId, uids) { |
| function announceLeave(threadId) { |
| const key = threadKeys[threadId]; |
| if (!key || !ws || ws.readyState !== WebSocket.OPEN) return Promise.resolve(); |
| - const payload = { name: session.name, text: `${session.name} left`, left: true, ts: Date.now(), action: true }; |
| + const payload = { name: session.name || "Admin", text: `${session.name || "Admin"} left`, left: true, ts: Date.now(), action: true }; |
| if (myUserId) payload.uid = myUserId; |
| return encrypt(key, payload).then((blob) => sendAndConfirm(threadId, blob)); |
| } |
| @@ -2673,7 +2887,7 @@ function announceLeave(threadId) { |
| async function announceRemove(threadId, uids) { |
| const key = threadKeys[threadId]; |
| if (!key || !uids.length || !ws || ws.readyState !== WebSocket.OPEN) return; |
| - const payload = { name: session.name, text: `${session.name} removed ${nameList(uids.map(rosterLabel))}`, removed: uids.slice(), ts: Date.now(), action: true }; |
| + const payload = { name: session.name || "Admin", text: `${session.name || "Admin"} removed ${nameList(uids.map(rosterLabel))}`, removed: uids.slice(), ts: Date.now(), action: true }; |
| if (myUserId) payload.uid = myUserId; |
| const blob = await encrypt(key, payload); |
| ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob })); |
| @@ -2731,7 +2945,7 @@ setAppThreadResolver(resolveAppThread); |
| // Interface in view. On desktop the default split already shows it beside the chat; |
| // on a narrow/mobile layout, switch to the Interface pane so the list is what shows. |
| setThreadOpener((id) => { |
| - if (threadApp[id] && appWantsChatView(threadApp[id]) && matchMedia("(max-width: 720px)").matches) appThreadView[id] = "interface"; |
| + if (threadApp[id] && appWantsChatView(threadApp[id]) && !appChatOnly(threadApp[id]) && matchMedia("(max-width: 720px)").matches) appThreadView[id] = "interface"; |
| if (id === activeId) { |
| applyAppThreadView(id); |
| paintViewToggle(id); |
| @@ -2802,8 +3016,10 @@ function renderAppFor(id) { |
| const owner = threadApp[id]; |
| if (owner) { |
| // Takeover (no chatMessage): the app's own interface IS the thread, always. |
| - // Companion: the chat view by default, its interface in the "interface" view. |
| + // Chat-only: the conversation IS the thread, always. Companion: the chat |
| + // view by default, its interface in the "interface" view. |
| if (!appWantsChatView(owner)) return owner; |
| + if (appChatOnly(owner)) return "chat"; |
| return appThreadView[id] === "interface" ? owner : "chat"; |
| } |
| return chatAppFor(id); |
| @@ -2972,7 +3188,7 @@ function applyAppThreadView(id) { |
| // interface). A COMPANION app-thread defaults to split; a normal chat OR a |
| // takeover app-thread has NO view attribute (→ the single pane fills, the empty |
| // interface pane hides), so neither can inherit a stale one. |
| - if (threadApp[id] && appWantsChatView(threadApp[id])) tv.dataset.appView = appThreadView[id] || "split"; |
| + if (threadApp[id] && appWantsChatView(threadApp[id]) && !appChatOnly(threadApp[id])) tv.dataset.appView = appThreadView[id] || "split"; |
| else delete tv.dataset.appView; |
| } |
| @@ -2982,7 +3198,10 @@ function applyAppThreadView(id) { |
| function renderActiveSurfaces() { |
| const owner = threadApp[activeId]; |
| if (owner) { |
| - if (appWantsChatView(owner)) { |
| + if (appChatOnly(owner)) { |
| + // Chat-only: the conversation is the whole surface. |
| + renderMiniApp(activeId, "chat", document.getElementById("app-pane")); |
| + } else if (appWantsChatView(owner)) { |
| // Companion: both Views live off the same folded log — redraw each pane. |
| renderMiniApp(activeId, "chat", document.getElementById("app-pane")); |
| const iface = document.getElementById("interface-pane"); |
| @@ -3252,6 +3471,21 @@ function updateSendVisibility() { |
| sendBtn.disabled = pending.some((p) => p.loading) || !wsConnected; |
| } |
| +// Fetch + decrypt a blob to raw bytes (no object URL) — for consumers that |
| +// need the plaintext itself (the setup flow re-posting a sent photo as the |
| +// branding image; fetch(blob:) is blocked by the page CSP's connect-src). |
| +async function loadBlobBytes(id, threadId = activeId) { |
| + const key = threadKeys[threadId]; |
| + if (!key) return null; |
| + try { |
| + const resp = await fetch(`/api/blob/${encodeURIComponent(id)}?token=${encodeURIComponent(session.token)}`); |
| + if (!resp.ok) return null; |
| + return await decryptBytes(key, new Uint8Array(await resp.arrayBuffer())); |
| + } catch { |
| + return null; |
| + } |
| +} |
| + |
| // Fetch + decrypt a blob (with the active thread's key), cached as an object URL. |
| // `type` (optional) sets the Blob's MIME so non-image files open/download right. |
| async function loadBlob(id, threadId = activeId, type, onProgress) { |
| @@ -5537,7 +5771,9 @@ function showA2HS(done) { |
| function loadSession() { |
| try { |
| const s = JSON.parse(localStorage.getItem(SESSION_KEY) || "null"); |
| - if (s && s.token && s.name && s.userPub && s.userPriv) return s; |
| + // name may be EMPTY (not just missing): a setup-invite claim signs in |
| + // nameless and the setup conversation asks for the name in-chat. |
| + if (s && s.token && typeof s.name === "string" && s.userPub && s.userPriv) return s; |
| } catch {} |
| return null; |
| } |
diff --git a/web/static/style.css b/web/static/style.css| index bc4bc8a..5e3656d 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -150,6 +150,9 @@ html, body { |
| transition: opacity 0.25s ease; |
| } |
| .app-loading.hidden { opacity: 0; pointer-events: none; } |
| +/* The join page's card yields entirely to the full-screen loader while a claim |
| + is in flight (see showLoader/showCard in join.js). */ |
| +.card.hidden { display: none; } |
| .spinner { |
| width: 32px; |
| height: 32px; |
| @@ -783,9 +786,14 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| "Only visible to you" tag sit ABOVE, outside the bubble (like a message's name |
| row); the reply text is a chat-style bubble. */ |
| .local-notice { |
| - align-self: stretch; margin: 6px 10px 8px; |
| + /* No side inset: notice bubbles share the message grid, so they line up with |
| + .msg bubbles and the typing indicator (which sit flush with the feed). */ |
| + align-self: stretch; margin: 6px 0 8px; |
| display: flex; flex-direction: column; align-items: flex-start; |
| } |
| +/* A continuation in a same-speaker run: no header, tucked up under the previous |
| + bubble — the same rhythm as .msg.cont. */ |
| +.local-notice--cont { margin-top: -4px; } |
| .local-notice-head { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; align-self: stretch; } |
| .local-notice-glyph { font-size: 15px; line-height: 1; } |
| .local-notice-from { font-weight: 600; font-size: 14px; color: var(--ink); } |
| @@ -806,6 +814,19 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| border-radius: 5px; |
| } |
| .local-notice-body a { color: var(--accent); } |
| +/* Optional preview image on a notice (in-code same-origin URLs only — e.g. the |
| + setup conversation previewing the just-uploaded server photo). Fixed box so |
| + the feed's scroll position is right BEFORE the image loads. */ |
| +.local-notice-img { |
| + display: block; margin-top: 8px; width: 180px; height: 180px; object-fit: cover; |
| + border-radius: 12px; border: 1px solid var(--border); |
| + background: color-mix(in srgb, var(--ink) 6%, transparent); |
| +} |
| +/* Notices ease in like live messages (see .msg.appear). */ |
| +.local-notice.appear { animation: msg-appear 0.22s cubic-bezier(0.2, 0.8, 0.2, 1); } |
| +@media (prefers-reduced-motion: reduce) { |
| + .local-notice.appear { animation: none; } |
| +} |
| .local-notice .chip-row { margin-top: 8px; } |
| /* A confirmation is loud only for a while: after ~30s it "blinds up" (its height |
| animates down — see collapseNotice) to this compact one-line record |