Invite redesign: short links + QR, server-blind key delivery (#15)
Short single-use invite links (/join/<label>#<T>) where the server stores only a client-wrapped key it can't unlock, plus QR codes, device tokens, and user accounts with multiple devices. Adds member self-service "Add a device", an iOS join-in-Safari → mint → Home-Screen flow, paste-to-join for new links, a first-time-only screen-name prompt, per-person join status in the admin People list, and copy/link affordances in the invite modals. Legacy #k= links and tokens keep working (additive migration).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
15 files changed,
+1211
−121
db.go+164 −0db_test.go+53 −0docs/invite-redesign.md+79 −0handlers_test.go+123 −0hub.go+4 −4main.go+153 −9test/crypto.test.mjs+29 −0web/pages/admin.html+7 −5web/pages/index.html+4 −3web/pages/join.html+26 −1web/static/admin.js+110 −62web/static/chat.js+243 −12web/static/crypto.js+36 −0web/static/join.js+179 −25web/static/style.css+1 −0
diff --git a/db.go b/db.go| index 2a83438..209a6ef 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -97,6 +97,25 @@ CREATE TABLE IF NOT EXISTS blobs ( |
| size INTEGER NOT NULL, |
| created_at INTEGER NOT NULL |
| ); |
| +CREATE TABLE IF NOT EXISTS users ( |
| + id TEXT PRIMARY KEY, |
| + label TEXT NOT NULL DEFAULT '', |
| + created_at INTEGER NOT NULL |
| +); |
| +CREATE TABLE IF NOT EXISTS key_invites ( |
| + id TEXT PRIMARY KEY, |
| + user_id TEXT NOT NULL, |
| + wrapped TEXT NOT NULL, |
| + created_at INTEGER NOT NULL, |
| + expires_at INTEGER, |
| + used_at INTEGER |
| +); |
| +CREATE TABLE IF NOT EXISTS device_tokens ( |
| + token_hash TEXT PRIMARY KEY, |
| + user_id TEXT NOT NULL, |
| + label TEXT NOT NULL DEFAULT '', |
| + created_at INTEGER NOT NULL |
| +); |
| CREATE TABLE IF NOT EXISTS link_previews ( |
| url TEXT PRIMARY KEY, |
| title TEXT NOT NULL DEFAULT '', |
| @@ -316,6 +335,151 @@ func (db *DB) revokeInvite(token string) error { |
| return err |
| } |
| +// ---- users, key-invites, device tokens (invite redesign) ---------------- |
| +// |
| +// The server stores a client-WRAPPED group key per invite, keyed by an id the |
| +// client derives from a secret it never sends (see docs/invite-redesign.md). The |
| +// server can't unwrap. Claiming an invite mints a device token for ongoing auth. |
| + |
| +type User struct { |
| + ID string `json:"id"` |
| + Label string `json:"label"` |
| + CreatedAt int64 `json:"created_at"` |
| + // Devices is how many devices have claimed in (0 = invited but not joined). |
| + Devices int `json:"devices"` |
| + // JoinedAt is when they first claimed a device (nil until they join). |
| + JoinedAt *int64 `json:"joined_at,omitempty"` |
| + // Pending is true while an unused, unexpired invite link is still out there. |
| + Pending bool `json:"pending"` |
| +} |
| + |
| +func (db *DB) createUser(label string) (string, error) { |
| + id, err := randToken() |
| + if err != nil { |
| + return "", err |
| + } |
| + _, err = db.sql.Exec("INSERT INTO users(id, label, created_at) VALUES(?, ?, ?)", id, label, time.Now().Unix()) |
| + return id, err |
| +} |
| + |
| +func (db *DB) listUsers() ([]User, error) { |
| + now := time.Now().Unix() |
| + rows, err := db.sql.Query(` |
| + SELECT u.id, u.label, u.created_at, |
| + (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 |
| + AND k.used_at IS NULL AND (k.expires_at IS NULL OR k.expires_at > ?)) |
| + FROM users u ORDER BY u.created_at DESC`, now) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + out := []User{} |
| + for rows.Next() { |
| + var u User |
| + var joinedAt sql.NullInt64 |
| + var pending int |
| + if err := rows.Scan(&u.ID, &u.Label, &u.CreatedAt, &u.Devices, &joinedAt, &pending); err != nil { |
| + return nil, err |
| + } |
| + if joinedAt.Valid { |
| + j := joinedAt.Int64 |
| + u.JoinedAt = &j |
| + } |
| + u.Pending = pending > 0 |
| + out = append(out, u) |
| + } |
| + return out, rows.Err() |
| +} |
| + |
| +func (db *DB) userExists(id string) bool { |
| + var x int |
| + return db.sql.QueryRow("SELECT 1 FROM users WHERE id = ?", id).Scan(&x) == nil |
| +} |
| + |
| +// createKeyInvite stores a client-wrapped key under a client-derived id. |
| +func (db *DB) createKeyInvite(id, userID, wrapped string, expiresAt int64) error { |
| + var exp any |
| + if expiresAt > 0 { |
| + exp = expiresAt |
| + } |
| + _, err := db.sql.Exec( |
| + "INSERT INTO key_invites(id, user_id, wrapped, created_at, expires_at) VALUES(?, ?, ?, ?, ?)", |
| + id, userID, wrapped, time.Now().Unix(), exp, |
| + ) |
| + return err |
| +} |
| + |
| +type KeyInvite struct { |
| + ID string |
| + UserID string |
| + Wrapped string |
| + Used bool |
| + Expired bool |
| +} |
| + |
| +func (db *DB) getKeyInvite(id string) (KeyInvite, bool, error) { |
| + var ki KeyInvite |
| + var used, exp sql.NullInt64 |
| + err := db.sql.QueryRow( |
| + "SELECT id, user_id, wrapped, used_at, expires_at FROM key_invites WHERE id = ?", id, |
| + ).Scan(&ki.ID, &ki.UserID, &ki.Wrapped, &used, &exp) |
| + if err == sql.ErrNoRows { |
| + return KeyInvite{}, false, nil |
| + } |
| + if err != nil { |
| + return KeyInvite{}, false, err |
| + } |
| + ki.Used = used.Valid |
| + ki.Expired = exp.Valid && time.Now().Unix() > exp.Int64 |
| + return ki, true, nil |
| +} |
| + |
| +// 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. |
| +func (db *DB) claimKeyInvite(id, deviceLabel string) (string, bool, error) { |
| + ki, found, err := db.getKeyInvite(id) |
| + if err != nil || !found || ki.Used || ki.Expired { |
| + return "", false, err |
| + } |
| + res, err := db.sql.Exec("UPDATE key_invites SET used_at = ? WHERE id = ? AND used_at IS NULL", time.Now().Unix(), id) |
| + if err != nil { |
| + return "", false, err |
| + } |
| + if n, _ := res.RowsAffected(); n == 0 { |
| + return "", false, nil // already claimed |
| + } |
| + tok, err := randToken() |
| + if err != nil { |
| + return "", false, err |
| + } |
| + _, err = db.sql.Exec( |
| + "INSERT INTO device_tokens(token_hash, user_id, label, created_at) VALUES(?, ?, ?, ?)", |
| + hashToken(tok), ki.UserID, deviceLabel, time.Now().Unix(), |
| + ) |
| + if err != nil { |
| + return "", false, err |
| + } |
| + return tok, true, nil |
| +} |
| + |
| +func (db *DB) deviceTokenValid(token string) bool { |
| + var x int |
| + return db.sql.QueryRow("SELECT 1 FROM device_tokens WHERE token_hash = ?", hashToken(token)).Scan(&x) == nil |
| +} |
| + |
| +// deviceTokenUser returns the user a device token belongs to (for self-service |
| +// "add a device"). ok=false for unknown or legacy (non-device) tokens. |
| +func (db *DB) deviceTokenUser(token string) (string, bool) { |
| + var uid string |
| + if db.sql.QueryRow("SELECT user_id FROM device_tokens WHERE token_hash = ?", hashToken(token)).Scan(&uid) != nil { |
| + return "", false |
| + } |
| + return uid, true |
| +} |
| + |
| // ---- messages ----------------------------------------------------------- |
| type Message struct { |
diff --git a/db_test.go b/db_test.go| index f574585..603b834 100644 |
| --- a/db_test.go |
| +++ b/db_test.go |
| @@ -232,3 +232,56 @@ func TestVAPIDPersists(t *testing.T) { |
| t.Fatal("VAPID keys should persist across calls") |
| } |
| } |
| + |
| +func findUser(t *testing.T, users []User, id string) User { |
| + t.Helper() |
| + for _, u := range users { |
| + if u.ID == id { |
| + return u |
| + } |
| + } |
| + t.Fatalf("user %s not in list", id) |
| + return User{} |
| +} |
| + |
| +func TestUserStatus(t *testing.T) { |
| + db := newTestDB(t) |
| + |
| + uid, err := db.createUser("Tester") |
| + if err != nil { |
| + t.Fatalf("createUser: %v", err) |
| + } |
| + if err := db.createKeyInvite("inv1", uid, "wrapped", 0); err != nil { |
| + t.Fatalf("createKeyInvite: %v", err) |
| + } |
| + |
| + // Invited but not joined: no devices, no join time, link still outstanding. |
| + users, err := db.listUsers() |
| + if err != nil { |
| + t.Fatalf("listUsers: %v", err) |
| + } |
| + if u := findUser(t, users, uid); u.Devices != 0 || u.JoinedAt != nil || !u.Pending { |
| + t.Fatalf("invited: got devices=%d joinedAt=%v pending=%v", u.Devices, u.JoinedAt, u.Pending) |
| + } |
| + |
| + // Claim it: now joined on one device, no longer pending. |
| + if _, ok, err := db.claimKeyInvite("inv1", "phone"); err != nil || !ok { |
| + t.Fatalf("claim inv1: ok=%v err=%v", ok, err) |
| + } |
| + users, _ = db.listUsers() |
| + if u := findUser(t, users, uid); u.Devices != 1 || u.JoinedAt == nil || u.Pending { |
| + t.Fatalf("joined: got devices=%d joinedAt=%v pending=%v", u.Devices, u.JoinedAt, u.Pending) |
| + } |
| + |
| + // A second device link, claimed → two devices. |
| + if err := db.createKeyInvite("inv2", uid, "wrapped", 0); err != nil { |
| + t.Fatalf("createKeyInvite inv2: %v", err) |
| + } |
| + if _, ok, err := db.claimKeyInvite("inv2", "laptop"); err != nil || !ok { |
| + t.Fatalf("claim inv2: ok=%v err=%v", ok, err) |
| + } |
| + users, _ = db.listUsers() |
| + if u := findUser(t, users, uid); u.Devices != 2 || u.Pending { |
| + t.Fatalf("two devices: got devices=%d pending=%v", u.Devices, u.Pending) |
| + } |
| +} |
diff --git a/docs/invite-redesign.md b/docs/invite-redesign.md| new file mode 100644 |
| index 0000000..583fc51 |
| --- /dev/null |
| +++ b/docs/invite-redesign.md |
| @@ -0,0 +1,79 @@ |
| +# Invite redesign — short links, server-blind key delivery |
| + |
| +Status: Phase 1 (link + QR). Phase 2 (spoken word-code via PAKE) is parked. |
| + |
| +## Goal |
| + |
| +Short, single-use, revocable invites with multi-device support and admin |
| +re-issue — **without the server ever storing anything that could recover the |
| +group key.** Same hard guarantee as today ("compromise the server, get |
| +nothing"), but nicer onboarding and manageable invites. |
| + |
| +## Crypto (load-bearing) |
| + |
| +- Invite secret `T` = 128-bit random → ~22 base64url chars, carried in the URL |
| + **fragment**: `…/join/<label>#<T>`. The fragment is never sent to the server. |
| +- The client derives two values from `T` (HKDF-SHA256, domain-separated): |
| + - `id = HKDF(T, "lchat-invite-id")` — the server's lookup handle. |
| + - `wrapKey = HKDF(T, "lchat-invite-wrap")` — an AES-GCM key. |
| +- Minting (admin's browser, which holds the group key via the master link): |
| + `wrapped = AES-GCM(groupKeyRaw, wrapKey)`. The browser POSTs only `{id, wrapped}`. |
| +- **The server stores `{id, wrapped}` and never sees `T`** → it can't unwrap. |
| + A stolen DB yields `{hash-like id, ciphertext}`; recovering the key needs the |
| + 128-bit `T`, which never left the client. |
| + |
| +The path segment (`<label>`) is cosmetic (server-visible, guessable) — all |
| +entropy lives in `T`. |
| + |
| +## Data model (additive — old invites/tokens keep working) |
| + |
| +- `users(id, label, created_at)` — a person. |
| +- `key_invites(id, user_id, wrapped, created_at, expires_at, used_at)` — |
| + server-blind wrapped key per invite; `id` is the HKDF handle. |
| +- `device_tokens(token_hash, user_id, label, created_at)` — ongoing transport |
| + credential (hashed, like the admin token), one per device. |
| + |
| +Messages remain opaque blobs; `author_hash` (already present from message |
| +editing) keeps working per device. Full "messages attributed to users" UI is |
| +out of Phase 1 scope — the model just makes it possible later. |
| + |
| +## Flows |
| + |
| +**Mint (admin):** browser generates `T`, derives `id`/`wrapKey`, wraps the group |
| +key → `POST /api/admin/invites {label, id, wrapped, expires}`. Server creates the |
| +user (if new) + `key_invite`. Browser shows the link `…/join/<label>#<T>` + a QR. |
| + |
| +**Join (new device):** |
| +1. Open `…/join/<label>#<T>` → `T` from the fragment. |
| +2. `GET /api/invite/<id>` → `wrapped`; unwrap with `wrapKey` → group key (stored |
| + locally, same session shape as today: `{token, key, name}`). |
| +3. `POST /api/invite/<id>/claim` → server marks it used (single-use) and issues a |
| + **device token**. Ongoing auth (messages/WebSocket/push) uses that token; `T` |
| + is one-time and never sent. |
| + |
| +**Add another device / re-issue a lost one:** mint a fresh invite (new `T`, new |
| +wrapped blob) from any device that holds the key — admin, or your own existing |
| +device. QR is the clean desktop→mobile path. |
| + |
| +## Transport auth (additive) |
| + |
| +`tokenOK(token)` accepts **either** a current non-revoked invite token (old model) |
| +**or** a valid device token (new model). So existing members are unaffected and |
| +new joiners use device tokens. |
| + |
| +## Migration |
| + |
| +Fully additive — no data loss, nothing forced: |
| +- Old `#k=<key>` links and existing tokens keep working. |
| +- The existing group key is reused (the admin's browser wraps it) — no key |
| + regeneration, no re-onboarding. |
| +- Old-style links age out as people move to new invites. |
| + |
| +The join page handles both: fragment `k=<key>` → old flow; raw `<T>` → new flow. |
| + |
| +## Phase 2 (parked): spoken word-code |
| + |
| +Four-ish words via **PAKE/SAS** (Magic-Wormhole style): the server relays an |
| +ephemeral key exchange it can't read; the words authenticate the live exchange |
| +against MITM; the key goes device-to-device. Low entropy is cryptographically |
| +safe in that construction. Documented now, built later. |
diff --git a/handlers_test.go b/handlers_test.go| index 34da310..a45fcb1 100644 |
| --- a/handlers_test.go |
| +++ b/handlers_test.go |
| @@ -143,6 +143,129 @@ func TestMessagesIncludeReactions(t *testing.T) { |
| } |
| } |
| +func TestKeyInviteFlow(t *testing.T) { |
| + ts, _ := newTestServer(t) |
| + |
| + // Admin mints a key-invite. The browser would derive `id` and wrap the key; |
| + // here we just store opaque strings (the server never interprets them). |
| + resp := adminPost(t, ts.URL+"/api/admin/key-invites", |
| + `{"label":"Paul","id":"derived-id-abc","wrapped":"wrapped-blob"}`) |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("create key-invite: %d", resp.StatusCode) |
| + } |
| + // Without admin auth it's rejected. |
| + r, _ := http.Post(ts.URL+"/api/admin/key-invites", "application/json", |
| + strings.NewReader(`{"label":"x","id":"y","wrapped":"z"}`)) |
| + if r.StatusCode != http.StatusUnauthorized { |
| + t.Fatalf("create without admin: want 401, got %d", r.StatusCode) |
| + } |
| + |
| + // Anyone with the id can fetch the (encrypted) wrapped blob. |
| + r, _ = http.Get(ts.URL + "/api/key-invite/derived-id-abc") |
| + var got struct { |
| + Ok bool `json:"ok"` |
| + Wrapped string `json:"wrapped"` |
| + } |
| + json.NewDecoder(r.Body).Decode(&got) |
| + if !got.Ok || got.Wrapped != "wrapped-blob" { |
| + t.Fatalf("get key-invite: %+v", got) |
| + } |
| + |
| + // A random device token is not yet valid. |
| + r, _ = http.Get(ts.URL + "/api/messages?token=randomdevice") |
| + if r.StatusCode != http.StatusForbidden { |
| + t.Fatalf("unclaimed device: want 403, got %d", r.StatusCode) |
| + } |
| + |
| + // Claiming mints a device token... |
| + r, _ = http.Post(ts.URL+"/api/key-invite/derived-id-abc/claim", "application/json", |
| + strings.NewReader(`{"label":"laptop"}`)) |
| + var claim struct { |
| + Ok bool `json:"ok"` |
| + Token string `json:"token"` |
| + } |
| + json.NewDecoder(r.Body).Decode(&claim) |
| + if !claim.Ok || claim.Token == "" { |
| + t.Fatalf("claim: %+v", claim) |
| + } |
| + // ...which then authorises the chat API. |
| + r, _ = http.Get(ts.URL + "/api/messages?token=" + claim.Token) |
| + if r.StatusCode != http.StatusOK { |
| + t.Fatalf("device-token messages: %d", r.StatusCode) |
| + } |
| + |
| + // Single-use: a second claim fails and the wrapped blob is no longer served. |
| + r, _ = http.Post(ts.URL+"/api/key-invite/derived-id-abc/claim", "application/json", strings.NewReader(`{}`)) |
| + if r.StatusCode == http.StatusOK { |
| + t.Fatal("second claim should fail") |
| + } |
| + r, _ = http.Get(ts.URL + "/api/key-invite/derived-id-abc") |
| + json.NewDecoder(r.Body).Decode(&got) |
| + if got.Ok { |
| + t.Fatal("used invite should report ok:false") |
| + } |
| +} |
| + |
| +func TestMemberAddsDevice(t *testing.T) { |
| + ts, _ := newTestServer(t) |
| + // Mint + claim a key-invite to get a member device token. |
| + adminPost(t, ts.URL+"/api/admin/key-invites", `{"label":"Paul","id":"inv-1","wrapped":"w1"}`) |
| + r, _ := http.Post(ts.URL+"/api/key-invite/inv-1/claim", "application/json", strings.NewReader(`{}`)) |
| + var claim struct { |
| + Token string `json:"token"` |
| + } |
| + json.NewDecoder(r.Body).Decode(&claim) |
| + |
| + before := countUsers(t, ts) // 1: Paul |
| + |
| + // The member mints a device invite for themselves (no admin token). |
| + r, _ = http.Post(ts.URL+"/api/device-invites", "application/json", |
| + strings.NewReader(`{"token":"`+claim.Token+`","label":"Paul","id":"inv-2","wrapped":"w2"}`)) |
| + if r.StatusCode != http.StatusOK { |
| + t.Fatalf("member add-device: %d", r.StatusCode) |
| + } |
| + // A stranger (no valid token) cannot. |
| + r, _ = http.Post(ts.URL+"/api/device-invites", "application/json", |
| + strings.NewReader(`{"token":"bogus","id":"x","wrapped":"y"}`)) |
| + if r.StatusCode != http.StatusForbidden { |
| + t.Fatalf("stranger add-device: want 403, got %d", r.StatusCode) |
| + } |
| + |
| + // The new invite is claimable and tied to the SAME user (no new user created). |
| + r, _ = http.Get(ts.URL + "/api/key-invite/inv-2") |
| + var got struct { |
| + Ok bool `json:"ok"` |
| + } |
| + json.NewDecoder(r.Body).Decode(&got) |
| + if !got.Ok { |
| + t.Fatal("new device invite should be claimable") |
| + } |
| + if after := countUsers(t, ts); after != before { |
| + t.Fatalf("add-device should reuse the user: before=%d after=%d", before, after) |
| + } |
| +} |
| + |
| +func countUsers(t *testing.T, ts *httptest.Server) int { |
| + t.Helper() |
| + r := adminGet(t, ts.URL+"/api/admin/users") |
| + var out struct { |
| + Users []struct{ ID string } `json:"users"` |
| + } |
| + json.NewDecoder(r.Body).Decode(&out) |
| + return len(out.Users) |
| +} |
| + |
| +func adminGet(t *testing.T, url string) *http.Response { |
| + t.Helper() |
| + req, _ := http.NewRequest("GET", url, nil) |
| + req.Header.Set("Authorization", "Bearer admintok") |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatalf("request: %v", err) |
| + } |
| + return resp |
| +} |
| + |
| func TestActiveTokenTracking(t *testing.T) { |
| _, srv := newTestServer(t) |
diff --git a/hub.go b/hub.go| index fbffab5..29bff21 100644 |
| --- a/hub.go |
| +++ b/hub.go |
| @@ -111,8 +111,7 @@ var allowedReactions = map[string]bool{ |
| func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { |
| token := r.URL.Query().Get("token") |
| - exists, revoked, err := s.db.inviteStatus(token) |
| - if err != nil || !exists || revoked { |
| + if !s.tokenOK(token) { |
| http.Error(w, "invalid invite", http.StatusForbidden) |
| return |
| } |
| @@ -162,8 +161,9 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { |
| if in.Type != "msg" && in.Type != "edit" && in.Type != "react" && in.Type != "unreact" { |
| continue |
| } |
| - // Re-check revocation so a revoked client can't keep posting or reacting. |
| - if _, rev, err := s.db.inviteStatus(token); err != nil || rev { |
| + // Re-check the credential so a revoked invite / removed device can't keep |
| + // posting or reacting. |
| + if !s.tokenOK(token) { |
| return |
| } |
diff --git a/main.go b/main.go| index 8bf5449..12cf8c8 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -114,10 +114,19 @@ func (s *Server) handler() http.Handler { |
| // caches it, and serves cached results immediately (refreshing stale ones). |
| mux.HandleFunc("GET /api/unfurl", s.unfurl) |
| - // Invite lifecycle (used by the join page) |
| + // Invite lifecycle (legacy join page) |
| mux.HandleFunc("GET /api/invite/{token}", s.getInvite) |
| mux.HandleFunc("POST /api/invite/{token}/claim", s.claimInvite) |
| + // Key-invites (invite redesign): server stores a client-wrapped key it can't |
| + // open; claiming mints a device token. See docs/invite-redesign.md. |
| + mux.HandleFunc("GET /api/key-invite/{id}", s.getKeyInvite) |
| + mux.HandleFunc("POST /api/key-invite/{id}/claim", s.claimKeyInvite) |
| + mux.HandleFunc("POST /api/admin/key-invites", s.createKeyInvite) |
| + mux.HandleFunc("GET /api/admin/users", s.listUsers) |
| + // A member can mint a fresh invite for their own account (add a device). |
| + mux.HandleFunc("POST /api/device-invites", s.createDeviceInvite) |
| + |
| // Group name (public read so the page/manifest can show it; admin write) |
| mux.HandleFunc("GET /api/group", s.getGroup) |
| mux.HandleFunc("POST /api/admin/group", s.setGroup) |
| @@ -172,8 +181,7 @@ func (s *Server) servePage(name string) http.HandlerFunc { |
| func (s *Server) getMessages(w http.ResponseWriter, r *http.Request) { |
| token := r.URL.Query().Get("token") |
| - exists, revoked, err := s.db.inviteStatus(token) |
| - if err != nil || !exists || revoked { |
| + if !s.tokenOK(token) { |
| httpError(w, http.StatusForbidden, "invalid invite") |
| return |
| } |
| @@ -188,6 +196,7 @@ func (s *Server) getMessages(w http.ResponseWriter, r *http.Request) { |
| // `before` paginates older history (id < before); otherwise `after` returns |
| // the page after a cursor, or the most recent `limit` when after is 0. |
| var msgs []Message |
| + var err error |
| if b := q.Get("before"); b != "" { |
| before, _ := strconv.ParseInt(b, 10, 64) |
| msgs, err = s.db.messagesBefore(before, limit) |
| @@ -214,10 +223,16 @@ func (s *Server) getMessages(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, map[string]any{"messages": msgs}) |
| } |
| -// tokenOK reports whether a token is a known, non-revoked invite. |
| +// tokenOK reports whether a token is a valid participant credential: either a |
| +// current non-revoked invite (legacy) or a device token (invite redesign). |
| func (s *Server) tokenOK(token string) bool { |
| - exists, revoked, err := s.db.inviteStatus(token) |
| - return err == nil && exists && !revoked |
| + if token == "" { |
| + return false |
| + } |
| + if exists, revoked, err := s.db.inviteStatus(token); err == nil && exists && !revoked { |
| + return true |
| + } |
| + return s.db.deviceTokenValid(token) |
| } |
| // ---- attachments -------------------------------------------------------- |
| @@ -355,6 +370,136 @@ func (s *Server) claimInvite(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| } |
| +// ---- key-invites (invite redesign) -------------------------------------- |
| + |
| +// createKeyInvite stores a client-wrapped group key (the admin's browser did the |
| +// wrapping; the server never sees the secret or plaintext key). Creates a user |
| +// when no user_id is given (re-issue passes an existing user_id). |
| +func (s *Server) createKeyInvite(w http.ResponseWriter, r *http.Request) { |
| + if !s.requireAdmin(w, r) { |
| + return |
| + } |
| + var body struct { |
| + Label string `json:"label"` |
| + UserID string `json:"user_id"` |
| + ID string `json:"id"` |
| + Wrapped string `json:"wrapped"` |
| + Expires int64 `json:"expires"` |
| + } |
| + if json.NewDecoder(r.Body).Decode(&body) != nil { |
| + httpError(w, http.StatusBadRequest, "bad request") |
| + return |
| + } |
| + if body.ID == "" || body.Wrapped == "" || len(body.ID) > 200 || len(body.Wrapped) > 4096 { |
| + httpError(w, http.StatusBadRequest, "missing id/wrapped") |
| + return |
| + } |
| + userID := body.UserID |
| + if userID == "" { |
| + id, err := s.db.createUser(strings.TrimSpace(body.Label)) |
| + if err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + userID = id |
| + } else if !s.db.userExists(userID) { |
| + httpError(w, http.StatusBadRequest, "unknown user") |
| + return |
| + } |
| + if err := s.db.createKeyInvite(body.ID, userID, body.Wrapped, body.Expires); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "user_id": userID}) |
| +} |
| + |
| +// getKeyInvite returns the wrapped key for an unused, unexpired invite. The id is |
| +// the client-derived handle; without the secret the wrapped blob is useless. |
| +func (s *Server) getKeyInvite(w http.ResponseWriter, r *http.Request) { |
| + ki, found, err := s.db.getKeyInvite(r.PathValue("id")) |
| + if err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + ok := found && !ki.Used && !ki.Expired |
| + resp := map[string]any{"ok": ok} |
| + if ok { |
| + resp["wrapped"] = ki.Wrapped |
| + } |
| + writeJSON(w, http.StatusOK, resp) |
| +} |
| + |
| +// claimKeyInvite marks the invite used (single-use) and mints a device token for |
| +// ongoing auth. The secret never reaches the server — only this claim does. |
| +func (s *Server) claimKeyInvite(w http.ResponseWriter, r *http.Request) { |
| + var body struct { |
| + Label string `json:"label"` |
| + } |
| + json.NewDecoder(r.Body).Decode(&body) |
| + tok, ok, err := s.db.claimKeyInvite(r.PathValue("id"), strings.TrimSpace(body.Label)) |
| + if err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + if !ok { |
| + httpError(w, http.StatusForbidden, "invite unavailable") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "token": tok}) |
| +} |
| + |
| +func (s *Server) listUsers(w http.ResponseWriter, r *http.Request) { |
| + if !s.requireAdmin(w, r) { |
| + return |
| + } |
| + users, err := s.db.listUsers() |
| + if err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"users": users}) |
| +} |
| + |
| +// createDeviceInvite lets a member mint a fresh invite for their OWN account (to |
| +// add another device), authenticated by their existing token. The browser wraps |
| +// the key it already holds; the server stays blind. Ties to the device token's |
| +// user, or creates one for a legacy member. |
| +func (s *Server) createDeviceInvite(w http.ResponseWriter, r *http.Request) { |
| + var body struct { |
| + Token string `json:"token"` |
| + Label string `json:"label"` |
| + ID string `json:"id"` |
| + Wrapped string `json:"wrapped"` |
| + } |
| + if json.NewDecoder(r.Body).Decode(&body) != nil { |
| + httpError(w, http.StatusBadRequest, "bad request") |
| + return |
| + } |
| + if !s.tokenOK(body.Token) { |
| + httpError(w, http.StatusForbidden, "not a member") |
| + return |
| + } |
| + if body.ID == "" || body.Wrapped == "" || len(body.ID) > 200 || len(body.Wrapped) > 4096 { |
| + httpError(w, http.StatusBadRequest, "missing id/wrapped") |
| + return |
| + } |
| + userID, ok := s.db.deviceTokenUser(body.Token) |
| + if !ok { |
| + // Legacy member (joined via an old #k= link) — give them a user now. |
| + id, err := s.db.createUser(strings.TrimSpace(body.Label)) |
| + if err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + userID = id |
| + } |
| + if err := s.db.createKeyInvite(body.ID, userID, body.Wrapped, 0); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| +} |
| + |
| // ---- admin API ---------------------------------------------------------- |
| func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool { |
| @@ -430,9 +575,8 @@ func (s *Server) subscribePush(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusBadRequest, "bad request") |
| return |
| } |
| - // Only valid (non-revoked) participants may register for notifications. |
| - exists, revoked, err := s.db.inviteStatus(body.Token) |
| - if err != nil || !exists || revoked { |
| + // Only valid participants (invite or device token) may register for push. |
| + if !s.tokenOK(body.Token) { |
| httpError(w, http.StatusForbidden, "invalid invite") |
| return |
| } |
diff --git a/test/crypto.test.mjs b/test/crypto.test.mjs| index dfe510e..8a0b1b0 100644 |
| --- a/test/crypto.test.mjs |
| +++ b/test/crypto.test.mjs |
| @@ -10,6 +10,10 @@ import { |
| decryptBytes, |
| b64urlToBytes, |
| bytesToB64url, |
| + randomSecret, |
| + deriveInvite, |
| + wrapGroupKey, |
| + unwrapGroupKey, |
| } from "../web/static/crypto.js"; |
| let failures = 0; |
| @@ -82,5 +86,30 @@ await test("encryptBytes output is not the plaintext", async () => { |
| assert.notDeepEqual(Array.from(enc.slice(12)), Array.from(original)); |
| }); |
| +await test("invite: same secret derives the same id (stable lookup)", async () => { |
| + const T = randomSecret(); |
| + const a = await deriveInvite(T); |
| + const b = await deriveInvite(T); |
| + assert.equal(a.id, b.id); |
| +}); |
| + |
| +await test("invite: a different secret derives a different id", async () => { |
| + const a = await deriveInvite(randomSecret()); |
| + const b = await deriveInvite(randomSecret()); |
| + assert.notEqual(a.id, b.id); |
| +}); |
| + |
| +await test("invite: wrap → unwrap recovers the group key; wrong secret can't", async () => { |
| + const { b64: groupKey } = await generateKey(); |
| + const T = randomSecret(); |
| + const { wrapKey } = await deriveInvite(T); |
| + const wrapped = await wrapGroupKey(groupKey, wrapKey); |
| + assert.ok(!wrapped.includes(groupKey), "wrapped must not contain the raw key"); |
| + assert.equal(await unwrapGroupKey(wrapped, wrapKey), groupKey); |
| + |
| + const { wrapKey: other } = await deriveInvite(randomSecret()); |
| + await assert.rejects(() => unwrapGroupKey(wrapped, other)); |
| +}); |
| + |
| console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| process.exit(failures === 0 ? 0 : 1); |
diff --git a/web/pages/admin.html b/web/pages/admin.html| index 579da3d..24f35f3 100644 |
| --- a/web/pages/admin.html |
| +++ b/web/pages/admin.html |
| @@ -64,10 +64,11 @@ |
| </section> |
| <section> |
| - <h2>Create an invite</h2> |
| + <h2>Invite someone</h2> |
| + <p class="sub" style="margin:0 0 8px">Creates a short, single-use link (and QR) for one person. The server never sees the chat key.</p> |
| <form id="createForm" class="inline-form" autocomplete="off"> |
| - <input id="label" placeholder="Who's this for? (e.g. Sophie) — optional" maxlength="60" /> |
| - <button type="submit">Create invite link</button> |
| + <input id="label" placeholder="Their name (e.g. Sophie)" maxlength="60" /> |
| + <button type="submit">Create invite</button> |
| </form> |
| </section> |
| @@ -78,8 +79,9 @@ |
| </section> |
| <section> |
| - <h2>Invites</h2> |
| - <div id="invites"><p class="sub">Loading…</p></div> |
| + <h2>People</h2> |
| + <p class="sub" style="margin:0 0 8px">Each person can have several devices. Re-issue a link to add a device or recover a lost one — it shares the same chat key.</p> |
| + <div id="people"><p class="sub">Loading…</p></div> |
| </section> |
| </div> |
| </div> |
diff --git a/web/pages/index.html b/web/pages/index.html| index 313e67a..bd164e0 100644 |
| --- a/web/pages/index.html |
| +++ b/web/pages/index.html |
| @@ -22,6 +22,7 @@ |
| <div class="menu-who" id="menu-who"></div> |
| <button class="menu-item" id="menu-notif" role="menuitem"></button> |
| <button class="menu-item" id="menu-rename" role="menuitem">Change screen name</button> |
| + <button class="menu-item" id="menu-add-device" role="menuitem">Add a device</button> |
| <button class="menu-item" id="menu-refresh" role="menuitem">Refresh</button> |
| <button class="menu-item danger" id="menu-leave" role="menuitem">Leave chat</button> |
| </div> |
| @@ -56,10 +57,10 @@ |
| <div class="center"> |
| <div class="card"> |
| <h1>Join the chat</h1> |
| - <p>Paste your secret invite link and pick a screen name.</p> |
| + <p id="join-intro">Paste your secret invite link to join.</p> |
| <form id="paste-join" autocomplete="off"> |
| <input id="link" placeholder="Paste your invite link" autocomplete="off" /> |
| - <input id="name" placeholder="Screen name (e.g. Sophie)" maxlength="32" autocomplete="off" style="margin-top: 10px" /> |
| + <input id="name" placeholder="Screen name (e.g. Sophie)" maxlength="32" autocomplete="off" class="hidden" style="margin-top: 10px" /> |
| <div class="row"><button type="submit">Join the chat</button></div> |
| </form> |
| <p id="join-err" class="banner warn hidden" style="text-align:left;padding:0;margin-top:10px"></p> |
| @@ -76,7 +77,7 @@ |
| <ol class="steps"> |
| <li>Tap the <strong>Share</strong> button (the square with an up arrow) in Safari's toolbar.</li> |
| <li>Scroll down and choose <strong>Add to Home Screen</strong>.</li> |
| - <li>Open <strong>__GROUP_NAME__</strong> from your Home Screen and join there.</li> |
| + <li>Open <strong>__GROUP_NAME__</strong> from your Home Screen, then paste your invite link to join.</li> |
| </ol> |
| <div class="row"><button id="a2hs-continue" class="secondary">Continue in browser</button></div> |
| </div> |
diff --git a/web/pages/join.html b/web/pages/join.html| index 4e8a408..f5dc36a 100644 |
| --- a/web/pages/join.html |
| +++ b/web/pages/join.html |
| @@ -1,7 +1,15 @@ |
| <meta charset="utf-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" /> |
| -<title>Join the chat</title> |
| +<meta name="theme-color" content="#5b6cff" /> |
| +<title>__GROUP_NAME__</title> |
| <link rel="stylesheet" href="/static/style.css" /> |
| +<!-- Same identity as the chat root, so "Add to Home Screen" from here installs as |
| + __GROUP_NAME__ (not a generic "Join") and launches at the app's start_url ("/"). --> |
| +<link rel="manifest" href="/manifest.webmanifest" /> |
| +<link rel="icon" href="/static/icon-192.png" /> |
| +<link rel="apple-touch-icon" href="/static/icon-180.png" /> |
| +<meta name="apple-mobile-web-app-capable" content="yes" /> |
| +<meta name="apple-mobile-web-app-title" content="__GROUP_NAME__" /> |
| <div class="center"> |
| <div class="card" id="card"> |
| @@ -9,6 +17,23 @@ |
| </div> |
| </div> |
| +<template id="homescreen-tpl"> |
| + <div class="onboard-icon">📲</div> |
| + <h1>You're in! Add it to your Home Screen</h1> |
| + <p> |
| + You're signed in here in Safari. On iPhone & iPad, add the chat to your Home |
| + Screen too, so it opens like an app and can send you notifications. You'll sign in |
| + there once, with the link below. |
| + </p> |
| + <ol class="steps"> |
| + <li>Tap <strong>Copy sign-in link</strong> below.</li> |
| + <li>Tap the <strong>Share</strong> button (the square with an up arrow) in Safari, then choose <strong>Add to Home Screen</strong>.</li> |
| + <li>Open the app from your Home Screen and <strong>paste the link</strong> to sign in.</li> |
| + </ol> |
| + <div class="row"><button id="hs-copy">Copy sign-in link</button></div> |
| + <div class="row"><button id="hs-open" class="secondary">Just use Safari for now</button></div> |
| +</template> |
| + |
| <template id="form-tpl"> |
| <h1>Pick a screen name</h1> |
| <p>This is what everyone in the chat will see. You can use your first name or a nickname.</p> |
diff --git a/web/static/admin.js b/web/static/admin.js| index 87c7ae5..0bca95b 100644 |
| --- a/web/static/admin.js |
| +++ b/web/static/admin.js |
| @@ -1,4 +1,10 @@ |
| -import { generateKey, importKey } from "/static/crypto.js"; |
| +import { |
| + generateKey, |
| + importKey, |
| + randomSecret, |
| + deriveInvite, |
| + wrapGroupKey, |
| +} from "/static/crypto.js"; |
| import { qrCanvas } from "/static/qrcode.js"; |
| const ADMIN_KEY = "lchat_admin_token"; |
| @@ -21,16 +27,23 @@ init(); |
| async function init() { |
| if (!adminToken) return show(locked); |
| - // Verify the token by listing invites (also tells us whether the chat already exists). |
| - const res = await api("GET", "/api/admin/invites"); |
| + // Verify the token, and learn whether the chat already has members (legacy |
| + // invites or new-model users) so we don't mint a fresh, conflicting key. |
| + const res = await api("GET", "/api/admin/users"); |
| if (!res.ok) { |
| localStorage.removeItem(ADMIN_KEY); |
| return show(locked); |
| } |
| - const invites = (await res.json()).invites || []; |
| + const users = (await res.json()).users || []; |
| + let legacyCount = 0; |
| + try { |
| + const inv = await api("GET", "/api/admin/invites"); |
| + if (inv.ok) legacyCount = ((await inv.json()).invites || []).length; |
| + } catch {} |
| + const chatExists = users.length > 0 || legacyCount > 0; |
| // Resolve the chat key before showing anything that depends on it. |
| - if (!(await resolveGroupKey(invites))) return; // key-required screen shown; await paste |
| + if (!(await resolveGroupKey(chatExists))) return; // key-required screen shown; await paste |
| hide(locked); |
| hide(keyRequired); |
| @@ -42,13 +55,13 @@ async function init() { |
| wireTestPush(); |
| wireCreate(); |
| refreshKeyUI(); |
| - renderInvites(invites); |
| + renderUsers(users); |
| } |
| // ---- group key ---------------------------------------------------------- |
| // Decide which key this browser should use, robustly avoiding accidental splits. |
| -async function resolveGroupKey(invites) { |
| +async function resolveGroupKey(chatExists) { |
| // 1. A key in the fragment is the canonical "master link" — always adopt it. |
| if (fragKey) { |
| try { |
| @@ -61,8 +74,8 @@ async function resolveGroupKey(invites) { |
| } |
| // 2. A key from a previous visit on this browser. |
| if (localStorage.getItem(GROUP_KEY)) return true; |
| - // 3. No key anywhere: only mint one for a brand-new chat (nobody invited yet). |
| - if (invites.length === 0) { |
| + // 3. No key anywhere: only mint one for a brand-new chat (no members yet). |
| + if (!chatExists) { |
| const { b64 } = await generateKey(); |
| localStorage.setItem(GROUP_KEY, b64); |
| return true; |
| @@ -157,40 +170,59 @@ function wireTestPush() { |
| }; |
| } |
| -// ---- invites ------------------------------------------------------------ |
| +// ---- invites & people --------------------------------------------------- |
| function wireCreate() { |
| document.getElementById("createForm").onsubmit = async (e) => { |
| e.preventDefault(); |
| const label = document.getElementById("label").value.trim(); |
| - const res = await api("POST", "/api/admin/invites", { label }); |
| - if (!res.ok) return toast("Couldn't create invite"); |
| - const inv = await res.json(); |
| + const minted = await mintInvite(label, null); |
| + if (!minted) return; |
| document.getElementById("label").value = ""; |
| - // Copy the new link straight away, then refresh the list (no blocking dialog). |
| - copy(linkFor(inv.token), "Invite link copied"); |
| - refresh(); |
| + showInviteModal(minted.link, label || "New invite"); |
| + loadUsers(); |
| }; |
| } |
| -async function refresh() { |
| - const res = await api("GET", "/api/admin/invites"); |
| - if (res.ok) renderInvites((await res.json()).invites || []); |
| +// Mint a single-use invite: generate the secret T client-side, wrap the group |
| +// key under it, and store only {id, wrapped} server-side. Pass a userId to |
| +// re-issue for an existing person (add/recover a device). Returns the link. |
| +async function mintInvite(label, userId) { |
| + const groupKey = localStorage.getItem(GROUP_KEY); |
| + if (!groupKey) { |
| + toast("No chat key in this browser"); |
| + return null; |
| + } |
| + const T = randomSecret(); |
| + const { id, wrapKey } = await deriveInvite(T); |
| + const wrapped = await wrapGroupKey(groupKey, wrapKey); |
| + const res = await api("POST", "/api/admin/key-invites", { |
| + label, |
| + user_id: userId || "", |
| + id, |
| + wrapped, |
| + }); |
| + if (!res.ok) { |
| + toast("Couldn't create invite"); |
| + return null; |
| + } |
| + const slug = (label || "join").trim().replace(/[^\w]+/g, "-").replace(/^-+|-+$/g, "") || "join"; |
| + return { link: `${location.origin}/join/${encodeURIComponent(slug)}#${T}` }; |
| } |
| -function linkFor(token) { |
| - const key = localStorage.getItem(GROUP_KEY); |
| - return `${location.origin}/join/${token}#k=${key}`; |
| +async function loadUsers() { |
| + const res = await api("GET", "/api/admin/users"); |
| + if (res.ok) renderUsers((await res.json()).users || []); |
| } |
| -function renderInvites(invites) { |
| - const box = document.getElementById("invites"); |
| +function renderUsers(users) { |
| + const box = document.getElementById("people"); |
| box.innerHTML = ""; |
| - if (!invites.length) { |
| - box.innerHTML = `<p class="sub">No invites yet. Create one above.</p>`; |
| + if (!users.length) { |
| + box.innerHTML = `<p class="sub">No one yet. Invite someone above.</p>`; |
| return; |
| } |
| - for (const inv of invites) { |
| + for (const u of users) { |
| const row = document.createElement("div"); |
| row.className = "invite"; |
| @@ -198,41 +230,35 @@ function renderInvites(invites) { |
| meta.className = "meta"; |
| const label = document.createElement("div"); |
| label.className = "label"; |
| - label.textContent = inv.label || "(no label)"; |
| + label.textContent = u.label || "(no name)"; |
| + const joined = u.devices > 0; |
| + const date = (t) => new Date(t * 1000).toLocaleDateString(); |
| const status = document.createElement("div"); |
| - status.className = "status" + (inv.revoked ? " revoked" : ""); |
| - status.textContent = inv.revoked |
| - ? "revoked" |
| - : inv.claimed_at |
| - ? "joined " + new Date(inv.claimed_at * 1000).toLocaleDateString() |
| - : "not used yet"; |
| + status.className = "status"; |
| + if (joined) { |
| + const when = date(u.joined_at || u.created_at); |
| + status.textContent = u.devices > 1 ? `joined ${when} · ${u.devices} devices` : `joined ${when}`; |
| + } else if (u.pending) { |
| + status.textContent = `invited ${date(u.created_at)} · not joined yet`; |
| + } else { |
| + status.textContent = `invited ${date(u.created_at)} · link expired`; |
| + } |
| meta.appendChild(label); |
| meta.appendChild(status); |
| row.appendChild(meta); |
| - if (!inv.revoked) { |
| - const copyBtn = document.createElement("button"); |
| - copyBtn.className = "secondary"; |
| - copyBtn.textContent = "Copy link"; |
| - copyBtn.onclick = () => copy(linkFor(inv.token), "Invite link copied"); |
| - row.appendChild(copyBtn); |
| - |
| - const qrBtn = document.createElement("button"); |
| - qrBtn.className = "secondary"; |
| - qrBtn.textContent = "QR"; |
| - qrBtn.onclick = () => showQRModal(inv); |
| - row.appendChild(qrBtn); |
| - |
| - const revokeBtn = document.createElement("button"); |
| - revokeBtn.className = "secondary"; |
| - revokeBtn.textContent = "Revoke"; |
| - revokeBtn.onclick = async () => { |
| - if (!confirm("Revoke this invite? They'll lose access to the server.")) return; |
| - await api("POST", `/api/admin/invites/${inv.token}/revoke`); |
| - refresh(); |
| - }; |
| - row.appendChild(revokeBtn); |
| - } |
| + // Both actions mint a fresh single-use link for this same person; the wording |
| + // reflects intent (add a device vs. re-send an invite that hasn't landed yet). |
| + // Any earlier link still works — the server can't revoke what it can't unlock. |
| + const reissue = document.createElement("button"); |
| + reissue.className = "secondary"; |
| + reissue.textContent = joined ? "New device link" : "Resend link"; |
| + reissue.onclick = async () => { |
| + const minted = await mintInvite(u.label, u.id); |
| + if (minted) showInviteModal(minted.link, u.label || (joined ? "New device" : "Invite")); |
| + }; |
| + row.appendChild(reissue); |
| + |
| box.appendChild(row); |
| } |
| } |
| @@ -241,9 +267,7 @@ function renderInvites(invites) { |
| // the FULL join link — including the #k= key fragment — so it's rendered here in |
| // the browser (never server-side, which would leak the key) and must be treated |
| // like the secret link: anyone who scans it can read the whole chat. |
| -function showQRModal(inv) { |
| - const link = linkFor(inv.token); |
| - |
| +function showInviteModal(link, title) { |
| // Full-screen, opaque takeover — hides the rest of the admin console so only |
| // the QR is on screen (easier to scan, nothing sensitive shown behind it). |
| const backdrop = document.createElement("div"); |
| @@ -252,7 +276,7 @@ function showQRModal(inv) { |
| card.className = "card qr-modal"; |
| const h = document.createElement("h2"); |
| - h.textContent = inv.label ? `Invite: ${inv.label}` : "Invite QR"; |
| + h.textContent = title ? `Invite: ${title}` : "Invite"; |
| card.appendChild(h); |
| const note = document.createElement("p"); |
| @@ -270,6 +294,21 @@ function showQRModal(inv) { |
| } |
| card.appendChild(wrap); |
| + // Show the full link too, so it can be selected and copied by hand if the |
| + // clipboard button is blocked. Tap to select all. It IS the secret — same |
| + // caveat as the QR: anyone with it can read the whole chat. |
| + const linkBox = document.createElement("div"); |
| + linkBox.className = "key-box mono"; |
| + linkBox.textContent = link; |
| + linkBox.onclick = () => { |
| + const range = document.createRange(); |
| + range.selectNodeContents(linkBox); |
| + const sel = window.getSelection(); |
| + sel.removeAllRanges(); |
| + sel.addRange(range); |
| + }; |
| + card.appendChild(linkBox); |
| + |
| const close = () => { |
| backdrop.remove(); |
| document.removeEventListener("keydown", onKey); |
| @@ -283,7 +322,16 @@ function showQRModal(inv) { |
| const copyBtn = document.createElement("button"); |
| copyBtn.className = "secondary"; |
| copyBtn.textContent = "Copy link"; |
| - copyBtn.onclick = () => copy(link, "Invite link copied"); |
| + // In-modal feedback: a toast would render behind this full-screen overlay. |
| + copyBtn.onclick = async () => { |
| + try { |
| + await navigator.clipboard.writeText(link); |
| + copyBtn.textContent = "Link copied ✓"; |
| + } catch { |
| + copyBtn.textContent = "Select the link above to copy"; |
| + } |
| + setTimeout(() => (copyBtn.textContent = "Copy link"), 2000); |
| + }; |
| const doneBtn = document.createElement("button"); |
| doneBtn.textContent = "Done"; |
| doneBtn.onclick = close; |
diff --git a/web/static/chat.js b/web/static/chat.js| index 8ad202c..bbd552c 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -1,4 +1,15 @@ |
| -import { importKey, encrypt, decrypt, encryptBytes, decryptBytes } from "/static/crypto.js"; |
| +import { |
| + importKey, |
| + encrypt, |
| + decrypt, |
| + encryptBytes, |
| + decryptBytes, |
| + randomSecret, |
| + deriveInvite, |
| + wrapGroupKey, |
| + unwrapGroupKey, |
| +} from "/static/crypto.js"; |
| +import { qrCanvas } from "/static/qrcode.js"; |
| import { shouldSend } from "/static/ui.js"; |
| import { tokenizeText, formatBytes, replySnippet, emojiOnlyCount } from "/static/format.js"; |
| import { MESSAGE_TYPE_CHAT, acceptsMessageType } from "/static/message.js"; |
| @@ -220,6 +231,12 @@ function setupMenu(session) { |
| } |
| }; |
| + document.getElementById("menu-add-device").onclick = async (e) => { |
| + e.stopPropagation(); |
| + close(); |
| + await addDevice(session); |
| + }; |
| + |
| document.getElementById("menu-leave").onclick = (e) => { |
| e.stopPropagation(); |
| if (confirm("Leave this chat on this device? You'll need your link to rejoin.")) { |
| @@ -275,6 +292,109 @@ function openLightbox(src, alt) { |
| document.body.appendChild(backdrop); |
| } |
| +// Mint a fresh single-use invite for the current user's OWN account (add another |
| +// device). The browser wraps the key it already holds; the server stays blind. |
| +async function addDevice(session) { |
| + let link; |
| + try { |
| + const T = randomSecret(); |
| + const { id, wrapKey } = await deriveInvite(T); |
| + const wrapped = await wrapGroupKey(session.key, wrapKey); |
| + const res = await fetch("/api/device-invites", { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ token: session.token, label: session.name, id, wrapped }), |
| + }); |
| + if (!res.ok) throw new Error("server"); |
| + const slug = |
| + (session.name || "device").trim().replace(/[^\w]+/g, "-").replace(/^-+|-+$/g, "") || "device"; |
| + // Carry your name in the fragment (after the secret) so your other device |
| + // joins as you without re-typing it. The fragment is never sent to the |
| + // server, so the name stays private. "~" can't appear in T (base64url). |
| + link = `${location.origin}/join/${encodeURIComponent(slug)}#${T}~${encodeURIComponent(session.name)}`; |
| + } catch { |
| + showBanner("Couldn't create a device link. Try again.", true); |
| + return; |
| + } |
| + showDeviceLinkModal(link); |
| +} |
| + |
| +function showDeviceLinkModal(link) { |
| + const backdrop = document.createElement("div"); |
| + backdrop.className = "qr-screen"; |
| + const card = document.createElement("div"); |
| + card.className = "card qr-modal"; |
| + |
| + const h = document.createElement("h2"); |
| + h.textContent = "Add a device"; |
| + card.appendChild(h); |
| + |
| + const note = document.createElement("p"); |
| + note.className = "sub"; |
| + note.textContent = |
| + "Open this on your other device, or scan the QR. It works once — anyone who uses it joins as you, so keep it to yourself."; |
| + card.appendChild(note); |
| + |
| + const wrap = document.createElement("div"); |
| + wrap.className = "qr-wrap"; |
| + try { |
| + wrap.appendChild(qrCanvas(link, { scale: 6, margin: 4 })); |
| + } catch { |
| + wrap.textContent = "Couldn't render a QR code."; |
| + } |
| + card.appendChild(wrap); |
| + |
| + // Show the full link too, so it can be selected/copied by hand. Tap to select. |
| + const linkBox = document.createElement("div"); |
| + linkBox.className = "key-box mono"; |
| + linkBox.textContent = link; |
| + linkBox.onclick = () => { |
| + const range = document.createRange(); |
| + range.selectNodeContents(linkBox); |
| + const sel = window.getSelection(); |
| + sel.removeAllRanges(); |
| + sel.addRange(range); |
| + }; |
| + card.appendChild(linkBox); |
| + |
| + const close = () => { |
| + backdrop.remove(); |
| + document.removeEventListener("keydown", onKey); |
| + }; |
| + const onKey = (e) => { |
| + if (e.key === "Escape") close(); |
| + }; |
| + |
| + const row = document.createElement("div"); |
| + row.className = "row"; |
| + const copyBtn = document.createElement("button"); |
| + copyBtn.className = "secondary"; |
| + copyBtn.textContent = "Copy link"; |
| + // In-modal feedback: a toast would render behind this full-screen overlay. |
| + copyBtn.onclick = async () => { |
| + try { |
| + await navigator.clipboard.writeText(link); |
| + copyBtn.textContent = "Link copied ✓"; |
| + } catch { |
| + copyBtn.textContent = "Select the link above to copy"; |
| + } |
| + setTimeout(() => (copyBtn.textContent = "Copy link"), 2000); |
| + }; |
| + const doneBtn = document.createElement("button"); |
| + doneBtn.textContent = "Done"; |
| + doneBtn.onclick = close; |
| + row.appendChild(copyBtn); |
| + row.appendChild(doneBtn); |
| + card.appendChild(row); |
| + |
| + backdrop.appendChild(card); |
| + backdrop.addEventListener("click", (e) => { |
| + if (e.target === backdrop) close(); |
| + }); |
| + document.addEventListener("keydown", onKey); |
| + document.body.appendChild(backdrop); |
| +} |
| + |
| // Generic dismissible modal (used for the "blocked" recovery instructions). |
| function openModal(innerHTML) { |
| const backdrop = document.createElement("div"); |
| @@ -316,32 +436,99 @@ function showNoInvite() { |
| err.classList.remove("hidden"); |
| }; |
| + // Parse a pasted link into one of two shapes (see docs/invite-redesign.md): |
| + // legacy: /join/<token>#k=<key> key carried directly in the fragment |
| + // new: /join/<label>#<T>[~<name>] T unwraps a server-stored key; the |
| + // optional ~name is a device-add link |
| + // that already knows who you are. |
| const parse = (text) => { |
| try { |
| const u = new URL(text.trim()); |
| - const token = u.pathname.split("/").filter(Boolean).pop(); |
| - const key = new URLSearchParams(u.hash.slice(1)).get("k"); |
| - return token && key ? { token, key } : null; |
| + const seg = decodeURIComponent(u.pathname.split("/").filter(Boolean).pop() || ""); |
| + const frag = u.hash.slice(1); |
| + if (new URLSearchParams(frag).get("k")) { |
| + return { kind: "legacy", token: seg, key: new URLSearchParams(frag).get("k") }; |
| + } |
| + if (!frag) return null; |
| + const i = frag.indexOf("~"); // "~" can't appear in T (base64url) |
| + const T = i < 0 ? frag : frag.slice(0, i); |
| + let name = ""; |
| + if (i >= 0) { |
| + try { |
| + name = decodeURIComponent(frag.slice(i + 1)); |
| + } catch {} |
| + } |
| + return { kind: "new", T, name, slug: seg }; |
| } catch { |
| return null; |
| } |
| }; |
| - // Pre-fill the screen name from the invite's label, if the field is empty. |
| - linkInput.addEventListener("change", async () => { |
| - const p = parse(linkInput.value); |
| - if (!p || nameInput.value.trim()) return; |
| + let joining = false; |
| + let autoTried = ""; |
| + |
| + // The screen name is a first-time-join thing: keep the field hidden until a |
| + // pasted link turns out to need one. A device-add link already knows who you |
| + // are, so it never appears for that. |
| + function revealName(defaultName) { |
| + const wasHidden = nameInput.classList.contains("hidden"); |
| + if (wasHidden) { |
| + nameInput.classList.remove("hidden"); |
| + const intro = document.getElementById("join-intro"); |
| + if (intro) intro.textContent = "Almost there — pick a screen name."; |
| + } |
| + if (defaultName && !nameInput.value.trim()) nameInput.value = defaultName; |
| + if (wasHidden) nameInput.focus(); |
| + } |
| + |
| + // On paste: a device-add link already carries your name, so just join. |
| + // A first-time invite (admin link or legacy) reveals the name field instead. |
| + const onLink = async () => { |
| + const raw = linkInput.value; |
| + const p = parse(raw); |
| + if (!p) return; |
| + if (p.kind === "new" && p.name) { |
| + if (joining || autoTried === raw) return; |
| + autoTried = raw; |
| + err.classList.add("hidden"); |
| + joining = true; |
| + await joinNew(p, p.name); |
| + joining = false; |
| + return; |
| + } |
| + if (!nameInput.classList.contains("hidden")) return revealName(""); |
| + if (p.kind === "new") return revealName(p.slug || ""); |
| + let label = ""; |
| try { |
| - const info = await (await fetch(`/api/invite/${encodeURIComponent(p.token)}`)).json(); |
| - if (info.label) nameInput.value = info.label; |
| + label = (await (await fetch(`/api/invite/${encodeURIComponent(p.token)}`)).json()).label || ""; |
| } catch {} |
| - }); |
| + revealName(label); |
| + }; |
| + linkInput.addEventListener("input", onLink); |
| + linkInput.addEventListener("change", onLink); |
| document.getElementById("paste-join").onsubmit = async (e) => { |
| e.preventDefault(); |
| err.classList.add("hidden"); |
| const p = parse(linkInput.value); |
| if (!p) return showErr("That doesn't look like a valid invite link."); |
| + if (joining) return; |
| + // Device-add link: no name needed — just join. |
| + if (p.kind === "new" && p.name) { |
| + joining = true; |
| + await joinNew(p, p.name); |
| + joining = false; |
| + return; |
| + } |
| + // First-time join needs a name; reveal the field before joining. |
| + if (nameInput.classList.contains("hidden")) return onLink(); |
| + joining = true; |
| + if (p.kind === "legacy") await joinLegacy(p); |
| + else await joinNew(p, nameInput.value); |
| + joining = false; |
| + }; |
| + |
| + async function joinLegacy(p) { |
| const name = nameInput.value.trim(); |
| if (!name) return showErr("Please enter a screen name."); |
| try { |
| @@ -363,7 +550,51 @@ function showNoInvite() { |
| } catch {} |
| localStorage.setItem(SESSION_KEY, JSON.stringify({ token: p.token, key: p.key, name })); |
| location.reload(); |
| - }; |
| + } |
| + |
| + async function joinNew(p, rawName) { |
| + const name = (rawName || "").trim(); |
| + if (!name) return showErr("Please enter a screen name."); |
| + let id, wrapKey; |
| + try { |
| + ({ id, wrapKey } = await deriveInvite(p.T)); |
| + } catch { |
| + return showErr("This link's secret is malformed."); |
| + } |
| + let info; |
| + try { |
| + info = await (await fetch(`/api/key-invite/${encodeURIComponent(id)}`)).json(); |
| + } catch { |
| + return showErr("Couldn't reach the server. Try again."); |
| + } |
| + if (!info.ok || !info.wrapped) { |
| + return showErr("This invite has been used or is no longer valid. Ask for a new one."); |
| + } |
| + let key; |
| + try { |
| + key = await unwrapGroupKey(info.wrapped, wrapKey); |
| + await importKey(key); // sanity-check the unwrapped key |
| + } catch { |
| + return showErr("Couldn't unlock this invite (wrong or corrupt secret)."); |
| + } |
| + let claim; |
| + try { |
| + claim = await ( |
| + await fetch(`/api/key-invite/${encodeURIComponent(id)}/claim`, { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ label: name }), |
| + }) |
| + ).json(); |
| + } catch { |
| + return showErr("Couldn't reach the server. Try again."); |
| + } |
| + if (!claim.ok || !claim.token) { |
| + return showErr("This invite was just used. Ask for a new one."); |
| + } |
| + localStorage.setItem(SESSION_KEY, JSON.stringify({ token: claim.token, key, name })); |
| + location.reload(); |
| + } |
| } |
| function loadSession() { |
diff --git a/web/static/crypto.js b/web/static/crypto.js| index 7258847..aefa793 100644 |
| --- a/web/static/crypto.js |
| +++ b/web/static/crypto.js |
| @@ -77,3 +77,39 @@ export async function decryptBytes(key, bytes) { |
| const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct); |
| return new Uint8Array(pt); |
| } |
| + |
| +// ---- invite key-wrapping (see docs/invite-redesign.md) ------------------ |
| +// The invite secret T (base64url) derives a server lookup id + an AES wrapping |
| +// key via HKDF. The server only ever stores the id and the wrapped key — never |
| +// T — so it can't recover the group key. |
| + |
| +// A fresh 128-bit invite secret. |
| +export function randomSecret() { |
| + return bytesToB64url(crypto.getRandomValues(new Uint8Array(16))); |
| +} |
| + |
| +async function hkdf(tB64, info) { |
| + return crypto.subtle.importKey("raw", b64urlToBytes(tB64), "HKDF", false, ["deriveBits", "deriveKey"]).then((ikm) => ({ |
| + ikm, |
| + params: { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: new TextEncoder().encode(info) }, |
| + })); |
| +} |
| + |
| +// deriveInvite returns { id, wrapKey } from the secret T, domain-separated. |
| +export async function deriveInvite(tB64) { |
| + const idd = await hkdf(tB64, "lchat-invite-id"); |
| + const idBits = await crypto.subtle.deriveBits(idd.params, idd.ikm, 256); |
| + const wkd = await hkdf(tB64, "lchat-invite-wrap"); |
| + const wrapKey = await crypto.subtle.deriveKey( |
| + wkd.params, wkd.ikm, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"] |
| + ); |
| + return { id: bytesToB64url(new Uint8Array(idBits)), wrapKey }; |
| +} |
| + |
| +// wrap/unwrap the group key (base64url) under a derived wrapping CryptoKey. |
| +export async function wrapGroupKey(groupKeyB64, wrapKey) { |
| + return bytesToB64url(await encryptBytes(wrapKey, b64urlToBytes(groupKeyB64))); |
| +} |
| +export async function unwrapGroupKey(wrappedB64, wrapKey) { |
| + return bytesToB64url(await decryptBytes(wrapKey, b64urlToBytes(wrappedB64))); |
| +} |
diff --git a/web/static/join.js b/web/static/join.js| index 26f1673..2ea6906 100644 |
| --- a/web/static/join.js |
| +++ b/web/static/join.js |
| @@ -1,31 +1,116 @@ |
| -// Reads the invite token from the path and the encryption key from the |
| -// #fragment (which the browser never sends to the server), then lets the user |
| -// pick a screen name and stashes everything locally. |
| -import { importKey } from "/static/crypto.js"; |
| +// Handles invite links. Shapes (see docs/invite-redesign.md): |
| +// legacy: /join/<token>#k=<key> — key carried in the fragment |
| +// new: /join/<label>#<T> — T (in the fragment) unwraps a |
| +// server-stored, server-blind key |
| +// device: /join/<label>#<T>~<name> — same, plus your name, so adding |
| +// your own device skips the prompt |
| +// The fragment is never sent to the server in any case. |
| +import { importKey, deriveInvite, unwrapGroupKey, randomSecret, wrapGroupKey } from "/static/crypto.js"; |
| +import { needsHomeScreen } from "/static/push.js"; |
| const SESSION_KEY = "lchat_session"; |
| const card = document.getElementById("card"); |
| -const token = location.pathname.split("/").filter(Boolean).pop(); |
| -const hash = new URLSearchParams(location.hash.slice(1)); |
| -const key = hash.get("k"); |
| +const isStandalone = |
| + window.matchMedia("(display-mode: standalone)").matches || window.navigator.standalone === true; |
| + |
| +const seg = decodeURIComponent(location.pathname.split("/").filter(Boolean).pop() || ""); |
| +const params = new URLSearchParams(location.hash.slice(1)); |
| +const legacyKey = params.get("k"); |
| +// New-style fragment: "<T>" or "<T>~<encoded name>" ("~" can't appear in T). |
| +const rawFrag = legacyKey ? "" : location.hash.slice(1); |
| +const tilde = rawFrag.indexOf("~"); |
| +const T = tilde < 0 ? rawFrag : rawFrag.slice(0, tilde); |
| +const fragName = tilde < 0 ? "" : safeDecode(rawFrag.slice(tilde + 1)); |
| init(); |
| -async function init() { |
| - if (!token || !key) return showError("This link is missing its secret key."); |
| +function init() { |
| + // Already running as the installed app? The link in this URL was the one used |
| + // to install it (likely already spent) — don't claim it here. Send them to the |
| + // root, where the app signs in via paste-to-join. |
| + if (isStandalone) return location.replace("/"); |
| + if (!legacyKey && !T) return showError("This link is missing its secret."); |
| + if (legacyKey) return initLegacy(); |
| + return initNew(); |
| +} |
| + |
| +// After a successful claim: store the session, then either open the chat or — on |
| +// iOS Safari, where notifications need a Home Screen app with its own storage — |
| +// guide the Home Screen setup (mint a fresh link to paste inside the app). |
| +function finishJoin(session) { |
| + localStorage.setItem(SESSION_KEY, JSON.stringify(session)); |
| + if (needsHomeScreen()) return showHomeScreenSetup(session); |
| + location.href = "/"; // drop the fragment from the URL bar |
| +} |
| - // The key must be a valid AES-GCM key before we trust the link. |
| +// ---- new: T unwraps a server-stored wrapped key ------------------------- |
| + |
| +async function initNew() { |
| + let id, wrapKey; |
| try { |
| - await importKey(key); |
| + ({ id, wrapKey } = await deriveInvite(T)); |
| } catch { |
| - return showError("This link's secret key is malformed."); |
| + return showError("This link's secret is malformed."); |
| + } |
| + let info; |
| + try { |
| + info = await (await fetch(`/api/key-invite/${encodeURIComponent(id)}`)).json(); |
| + } catch { |
| + return showError("Couldn't reach the server. Try again."); |
| } |
| + if (!info.ok || !info.wrapped) { |
| + return showError("This invite has been used or is no longer valid. Ask for a new one."); |
| + } |
| + let groupKey; |
| + try { |
| + groupKey = await unwrapGroupKey(info.wrapped, wrapKey); |
| + await importKey(groupKey); // sanity-check the unwrapped key |
| + } catch { |
| + return showError("Couldn't unlock this invite (wrong or corrupt secret)."); |
| + } |
| + |
| + const claimAndGo = async (name) => { |
| + let claim; |
| + try { |
| + claim = await ( |
| + await fetch(`/api/key-invite/${encodeURIComponent(id)}/claim`, { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ label: name }), |
| + }) |
| + ).json(); |
| + } catch { |
| + return showError("Couldn't reach the server. Try again."); |
| + } |
| + if (!claim.ok || !claim.token) { |
| + return showError("This invite was just used. Ask for a new one."); |
| + } |
| + // token is now a device token (ongoing auth); key is the unwrapped group key. |
| + finishJoin({ token: claim.token, key: groupKey, name }); |
| + }; |
| + |
| + // A device-add link already carries your name — just join, no prompt. |
| + if (fragName) { |
| + showStatus("Signing you in…"); |
| + return claimAndGo(fragName); |
| + } |
| + showForm(seg, claimAndGo); |
| +} |
| +// ---- legacy: key carried directly in the #fragment ---------------------- |
| + |
| +async function initLegacy() { |
| + const token = seg; |
| + if (!token || !legacyKey) return showError("This link is missing its secret key."); |
| + try { |
| + await importKey(legacyKey); |
| + } catch { |
| + return showError("This link's secret key is malformed."); |
| + } |
| let info; |
| try { |
| - const res = await fetch(`/api/invite/${encodeURIComponent(token)}`); |
| - info = await res.json(); |
| + info = await (await fetch(`/api/invite/${encodeURIComponent(token)}`)).json(); |
| } catch { |
| return showError("Couldn't reach the server. Try again."); |
| } |
| @@ -36,32 +121,101 @@ async function init() { |
| : "This invite link is invalid. Ask for a new one." |
| ); |
| } |
| + showForm(info.label || "", async (name) => { |
| + try { |
| + await fetch(`/api/invite/${encodeURIComponent(token)}/claim`, { method: "POST" }); |
| + } catch {} |
| + finishJoin({ token, key: legacyKey, name }); |
| + }); |
| +} |
| + |
| +// ---- iOS Safari: set up the Home Screen app ----------------------------- |
| + |
| +// Mint a fresh single-use device link tied to this member, for them to paste |
| +// inside the installed app. The browser wraps the key it already holds, so the |
| +// server stays blind. Mirrors chat.js's "Add a device". |
| +async function mintDeviceLink(session) { |
| + const t = randomSecret(); |
| + const { id, wrapKey } = await deriveInvite(t); |
| + const wrapped = await wrapGroupKey(session.key, wrapKey); |
| + const res = await fetch("/api/device-invites", { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ token: session.token, label: session.name, id, wrapped }), |
| + }); |
| + if (!res.ok) throw new Error("mint failed"); |
| + const slug = |
| + (session.name || "device").trim().replace(/[^\w]+/g, "-").replace(/^-+|-+$/g, "") || "device"; |
| + return `${location.origin}/join/${encodeURIComponent(slug)}#${t}~${encodeURIComponent(session.name)}`; |
| +} |
| + |
| +async function showHomeScreenSetup(session) { |
| + showStatus("Setting up…"); |
| + let link = ""; |
| + try { |
| + link = await mintDeviceLink(session); |
| + } catch { |
| + // Couldn't mint — they can still use Safari, or "Add a device" from the app menu. |
| + } |
| + |
| + card.classList.add("onboard"); |
| + card.innerHTML = ""; |
| + card.appendChild(document.getElementById("homescreen-tpl").content.cloneNode(true)); |
| + |
| + const copy = card.querySelector("#hs-copy"); |
| + if (link) { |
| + copy.onclick = async () => { |
| + try { |
| + await navigator.clipboard.writeText(link); |
| + copy.textContent = "Link copied ✓"; |
| + setTimeout(() => (copy.textContent = "Copy sign-in link"), 2500); |
| + } catch { |
| + copy.textContent = "Press and hold to copy, or use “Add a device” in the app"; |
| + } |
| + }; |
| + } else { |
| + copy.disabled = true; |
| + copy.textContent = "Couldn't make a link — use “Add a device” in the app"; |
| + } |
| - showForm(info.label || ""); |
| + card.querySelector("#hs-open").onclick = () => location.assign("/"); |
| } |
| -function showForm(label) { |
| +// ---- shared name form --------------------------------------------------- |
| + |
| +function showForm(defaultName, onSubmit) { |
| card.innerHTML = ""; |
| card.appendChild(document.getElementById("form-tpl").content.cloneNode(true)); |
| const input = card.querySelector("#name"); |
| - if (label) input.value = label; // pre-fill from the invite's label |
| + if (defaultName) input.value = defaultName; // pre-fill from the invite's label |
| input.focus(); |
| input.select(); |
| - card.querySelector("#join-form").onsubmit = async (e) => { |
| + card.querySelector("#join-form").onsubmit = (e) => { |
| e.preventDefault(); |
| const name = input.value.trim(); |
| - if (!name) return; |
| - try { |
| - await fetch(`/api/invite/${encodeURIComponent(token)}/claim`, { method: "POST" }); |
| - } catch {} |
| - localStorage.setItem(SESSION_KEY, JSON.stringify({ token, key, name })); |
| - // Redirect WITHOUT the fragment so the key isn't left in the URL bar. |
| - location.href = "/"; |
| + if (name) onSubmit(name); |
| }; |
| } |
| +function showStatus(msg) { |
| + card.innerHTML = ""; |
| + const p = document.createElement("p"); |
| + p.className = "sub"; |
| + p.textContent = msg; |
| + card.appendChild(p); |
| +} |
| + |
| function showError(msg) { |
| + card.classList.remove("onboard"); |
| card.innerHTML = ""; |
| card.appendChild(document.getElementById("error-tpl").content.cloneNode(true)); |
| card.querySelector("#error-msg").textContent = msg; |
| } |
| + |
| +function safeDecode(s) { |
| + try { |
| + return decodeURIComponent(s); |
| + } catch { |
| + return ""; |
| + } |
| +} |
diff --git a/web/static/style.css b/web/static/style.css| index fe2691e..aea420e 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -712,6 +712,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); |
| background: var(--ink); color: #fff; padding: 10px 16px; border-radius: 999px; |
| font-size: 14px; opacity: 0; transition: opacity .2s; pointer-events: none; |
| + z-index: 100; /* above full-screen overlays (qr-screen) so it's never hidden */ |
| } |
| .toast.show { opacity: 1; } |