From b679fc9f1f923fe42e4370e21373339d1bf2dfdf Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 21 Mar 2026 16:04:22 -0700 Subject: [PATCH] feat(spaces): bridge email invites with EncryptID identity system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New users get sent to /join for passkey registration + auto-space-join. Existing users are directly added with in-app + email notification. Add-by-username now also sends email notification if email is on file. - Add id to /api/users/lookup response - Enhance /api/internal/user-email/:userId with recovery + profile email - Add GET /api/internal/user-by-email for email→DID resolution - Rewrite POST /:slug/invite to use identity invite flow - Add email notification to POST /:slug/members/add - Add success/error feedback to space settings invite UI Co-Authored-By: Claude Opus 4.6 --- server/spaces.ts | 139 ++++++++++++++++----- shared/components/rstack-space-settings.ts | 28 ++++- src/encryptid/server.ts | 26 +++- 3 files changed, 156 insertions(+), 37 deletions(-) diff --git a/server/spaces.ts b/server/spaces.ts index b159c3e..b57291e 100644 --- a/server/spaces.ts +++ b/server/spaces.ts @@ -2118,49 +2118,94 @@ spaces.post("/:slug/invite", async (c) => { return c.json({ error: `Invalid role. Must be one of: ${validRoles.join(", ")}` }, 400); } - // Create invite token via EncryptID API - const inviteId = crypto.randomUUID(); - const inviteToken = crypto.randomUUID(); - const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7 days - - // Store invite (call EncryptID API internally) - + // Try identity invite (handles new-user registration + auto-space-join) try { - await fetch(`${ENCRYPTID_URL}/api/spaces/${slug}/invites`, { + const identityRes = await fetch(`${ENCRYPTID_URL}/api/invites/identity`, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}`, }, - body: JSON.stringify({ email: body.email, role }), + body: JSON.stringify({ email: body.email, spaceSlug: slug, spaceRole: role }), }); - } catch (e) { - console.error("Failed to create invite in EncryptID:", e); - } - // Send email - const inviteUrl = `https://${slug}.rspace.online/?invite=${inviteToken}`; + if (identityRes.status === 201 || identityRes.ok) { + // New user — EncryptID sent the /join email with space info + return c.json({ ok: true, type: "identity-invite" }); + } - if (!inviteTransport) { - console.warn("Invite email skipped (SMTP not configured) —", body.email, inviteUrl); - return c.json({ ok: true, inviteUrl, note: "Email not configured — share the link manually" }); - } + if (identityRes.status === 409) { + // Existing user — resolve email to DID, then direct-add + const emailLookupRes = await fetch( + `${ENCRYPTID_URL}/api/internal/user-by-email?email=${encodeURIComponent(body.email)}`, + ); + if (!emailLookupRes.ok) { + return c.json({ error: "User exists but could not be resolved" }, 500); + } + const existingUser = await emailLookupRes.json() as { + id: string; did: string; username: string; displayName: string; + }; - try { - await inviteTransport.sendMail({ - from: process.env.SMTP_FROM || "rSpace ", - to: body.email, - subject: `You're invited to join "${slug}" on rSpace`, - html: [ - `

You've been invited to collaborate on ${slug} as a ${role}.

`, - `

Join Space

`, - `

This invite expires in 7 days. rSpace — collaborative knowledge work

`, - ].join("\n"), - }); - return c.json({ ok: true, inviteUrl }); + // Add to Automerge doc + setMember(slug, existingUser.did, role as any, existingUser.displayName || existingUser.username); + + // Sync to EncryptID PostgreSQL + try { + await fetch(`${ENCRYPTID_URL}/api/spaces/${slug}/members`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}`, + }, + body: JSON.stringify({ userDID: existingUser.did, role }), + }); + } catch (e) { + console.error("Failed to sync member to EncryptID:", e); + } + + // In-app notification + notify({ + userDid: existingUser.did, + category: "space", + eventType: "member_joined", + title: `You were added to "${slug}"`, + body: `You were added as ${role} by ${claims.username || "an admin"}.`, + spaceSlug: slug, + actorDid: claims.sub, + actorUsername: claims.username, + actionUrl: `/${slug}/rspace`, + metadata: { role }, + }).catch(() => {}); + + // Send "you've been added" email + if (inviteTransport) { + try { + const spaceUrl = `https://${slug}.rspace.online`; + await inviteTransport.sendMail({ + from: process.env.SMTP_FROM || "rSpace ", + to: body.email, + subject: `You've been added to "${slug}" on rSpace`, + html: [ + `

You've been added to ${slug} as a ${role}.

`, + `

Open Space

`, + `

rSpace — collaborative knowledge work

`, + ].join("\n"), + }); + } catch (emailErr: any) { + console.error("Direct-add email notification failed:", emailErr.message); + } + } + + return c.json({ ok: true, type: "direct-add", username: existingUser.username }); + } + + // Other error from identity invite + const errBody = await identityRes.json().catch(() => ({})) as Record; + console.error("Identity invite failed:", identityRes.status, errBody); + return c.json({ error: (errBody.error as string) || "Failed to send invite" }, 500); } catch (err: any) { - console.error("Invite email failed:", err.message); - return c.json({ error: "Failed to send email" }, 500); + console.error("Invite flow error:", err.message); + return c.json({ error: "Failed to process invite" }, 500); } }); @@ -2205,7 +2250,7 @@ spaces.post("/:slug/members/add", async (c) => { } return c.json({ error: (errBody.error as string) || "User lookup failed" }, lookupRes.status as any); } - const user = await lookupRes.json() as { did: string; username: string; displayName: string }; + const user = await lookupRes.json() as { id: string; did: string; username: string; displayName: string }; if (!user.did) return c.json({ error: "User has no DID" }, 400); @@ -2240,6 +2285,34 @@ spaces.post("/:slug/members/add", async (c) => { metadata: { role }, }).catch(() => {}); + // Send email notification (non-fatal) + if (inviteTransport && user.id) { + try { + const emailRes = await fetch(`${ENCRYPTID_URL}/api/internal/user-email/${user.id}`); + if (emailRes.ok) { + const emailData = await emailRes.json() as { + recoveryEmail: string | null; profileEmail: string | null; + }; + const targetEmail = emailData.recoveryEmail || emailData.profileEmail; + if (targetEmail) { + const spaceUrl = `https://${slug}.rspace.online`; + await inviteTransport.sendMail({ + from: process.env.SMTP_FROM || "rSpace ", + to: targetEmail, + subject: `You've been added to "${slug}" on rSpace`, + html: [ + `

You've been added to ${slug} as a ${role}.

`, + `

Open Space

`, + `

rSpace — collaborative knowledge work

`, + ].join("\n"), + }); + } + } + } catch (emailErr: any) { + console.error("Member-add email notification failed:", emailErr.message); + } + } + return c.json({ ok: true, did: user.did, username: user.username, role }); }); diff --git a/shared/components/rstack-space-settings.ts b/shared/components/rstack-space-settings.ts index 98188a8..f4f3f5f 100644 --- a/shared/components/rstack-space-settings.ts +++ b/shared/components/rstack-space-settings.ts @@ -385,6 +385,7 @@ export class RStackSpaceSettings extends HTMLElement { + `} @@ -536,6 +537,7 @@ export class RStackSpaceSettings extends HTMLElement { const sr = this.shadowRoot!; const input = sr.getElementById("add-email") as HTMLInputElement; const roleSelect = sr.getElementById("add-email-role") as HTMLSelectElement; + const feedback = sr.getElementById("email-invite-feedback"); if (!input?.value) return; const token = getToken(); @@ -551,10 +553,34 @@ export class RStackSpaceSettings extends HTMLElement { body: JSON.stringify({ email: input.value, role: roleSelect.value }), }); if (res.ok) { + const data = await res.json() as { type?: string; username?: string }; input.value = ""; + if (feedback) { + if (data.type === "direct-add") { + feedback.textContent = `${data.username || "User"} added`; + feedback.style.color = "#14b8a6"; + } else { + feedback.textContent = "Invite sent"; + feedback.style.color = "#14b8a6"; + } + setTimeout(() => { feedback.textContent = ""; }, 3000); + } this._loadData(); + } else { + const err = await res.json().catch(() => ({ error: "Failed to invite" })) as { error?: string }; + if (feedback) { + feedback.textContent = err.error || "Failed to invite"; + feedback.style.color = "#ef4444"; + setTimeout(() => { feedback.textContent = ""; }, 3000); + } } - } catch {} + } catch { + if (feedback) { + feedback.textContent = "Network error"; + feedback.style.color = "#ef4444"; + setTimeout(() => { feedback.textContent = ""; }, 3000); + } + } } private async _changeRole(did: string, newRole: string) { diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index 52fc1fd..8682169 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -3770,9 +3770,28 @@ app.delete('/api/spaces/:slug/members/:did', async (c) => { app.get('/api/internal/user-email/:userId', async (c) => { const userId = c.req.param('userId'); if (!userId) return c.json({ error: 'userId required' }, 400); - const profile = await getUserProfile(userId); - if (!profile) return c.json({ error: 'User not found' }, 404); - return c.json({ email: profile.profileEmail || null, username: profile.username || null }); + const [user, profile] = await Promise.all([getUserById(userId), getUserProfile(userId)]); + if (!user && !profile) return c.json({ error: 'User not found' }, 404); + return c.json({ + recoveryEmail: user?.email || null, + profileEmail: profile?.profileEmail || null, + username: profile?.username || user?.username || null, + displayName: profile?.displayName || user?.display_name || null, + }); +}); + +// ── Internal: resolve email → user (no auth, internal network only) ── +app.get('/api/internal/user-by-email', async (c) => { + const email = c.req.query('email'); + if (!email) return c.json({ error: 'email query parameter required' }, 400); + const user = await getUserByEmail(email.toLowerCase().trim()); + if (!user) return c.json({ error: 'No user with that email' }, 404); + return c.json({ + id: user.id, + did: user.did, + username: user.username, + displayName: user.display_name || user.username, + }); }); // ============================================================================ @@ -3791,6 +3810,7 @@ app.get('/api/users/lookup', async (c) => { if (!user) return c.json({ error: 'User not found' }, 404); return c.json({ + id: user.id, did: user.did, username: user.username, displayName: user.display_name || user.username,