feat(spaces): bridge email invites with EncryptID identity system
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 <noreply@anthropic.com>
This commit is contained in:
parent
ad2120f4fb
commit
b679fc9f1f
139
server/spaces.ts
139
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);
|
return c.json({ error: `Invalid role. Must be one of: ${validRoles.join(", ")}` }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create invite token via EncryptID API
|
// Try identity invite (handles new-user registration + auto-space-join)
|
||||||
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 {
|
try {
|
||||||
await fetch(`${ENCRYPTID_URL}/api/spaces/${slug}/invites`, {
|
const identityRes = await fetch(`${ENCRYPTID_URL}/api/invites/identity`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Authorization": `Bearer ${token}`,
|
"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
|
if (identityRes.status === 201 || identityRes.ok) {
|
||||||
const inviteUrl = `https://${slug}.rspace.online/?invite=${inviteToken}`;
|
// New user — EncryptID sent the /join email with space info
|
||||||
|
return c.json({ ok: true, type: "identity-invite" });
|
||||||
|
}
|
||||||
|
|
||||||
if (!inviteTransport) {
|
if (identityRes.status === 409) {
|
||||||
console.warn("Invite email skipped (SMTP not configured) —", body.email, inviteUrl);
|
// Existing user — resolve email to DID, then direct-add
|
||||||
return c.json({ ok: true, inviteUrl, note: "Email not configured — share the link manually" });
|
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 {
|
// Add to Automerge doc
|
||||||
await inviteTransport.sendMail({
|
setMember(slug, existingUser.did, role as any, existingUser.displayName || existingUser.username);
|
||||||
from: process.env.SMTP_FROM || "rSpace <noreply@rmail.online>",
|
|
||||||
to: body.email,
|
// Sync to EncryptID PostgreSQL
|
||||||
subject: `You're invited to join "${slug}" on rSpace`,
|
try {
|
||||||
html: [
|
await fetch(`${ENCRYPTID_URL}/api/spaces/${slug}/members`, {
|
||||||
`<p>You've been invited to collaborate on <strong>${slug}</strong> as a <strong>${role}</strong>.</p>`,
|
method: "POST",
|
||||||
`<p><a href="${inviteUrl}" style="display:inline-block;padding:12px 24px;background:#14b8a6;color:white;border-radius:8px;text-decoration:none;font-weight:600;">Join Space</a></p>`,
|
headers: {
|
||||||
`<p style="color:#64748b;font-size:12px;">This invite expires in 7 days. rSpace — collaborative knowledge work</p>`,
|
"Content-Type": "application/json",
|
||||||
].join("\n"),
|
"Authorization": `Bearer ${token}`,
|
||||||
});
|
},
|
||||||
return c.json({ ok: true, inviteUrl });
|
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 <noreply@rmail.online>",
|
||||||
|
to: body.email,
|
||||||
|
subject: `You've been added to "${slug}" on rSpace`,
|
||||||
|
html: [
|
||||||
|
`<p>You've been added to <strong>${slug}</strong> as a <strong>${role}</strong>.</p>`,
|
||||||
|
`<p><a href="${spaceUrl}" style="display:inline-block;padding:12px 24px;background:#14b8a6;color:white;border-radius:8px;text-decoration:none;font-weight:600;">Open Space</a></p>`,
|
||||||
|
`<p style="color:#64748b;font-size:12px;">rSpace — collaborative knowledge work</p>`,
|
||||||
|
].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<string, unknown>;
|
||||||
|
console.error("Identity invite failed:", identityRes.status, errBody);
|
||||||
|
return c.json({ error: (errBody.error as string) || "Failed to send invite" }, 500);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Invite email failed:", err.message);
|
console.error("Invite flow error:", err.message);
|
||||||
return c.json({ error: "Failed to send email" }, 500);
|
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);
|
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);
|
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 },
|
metadata: { role },
|
||||||
}).catch(() => {});
|
}).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 <noreply@rmail.online>",
|
||||||
|
to: targetEmail,
|
||||||
|
subject: `You've been added to "${slug}" on rSpace`,
|
||||||
|
html: [
|
||||||
|
`<p>You've been added to <strong>${slug}</strong> as a <strong>${role}</strong>.</p>`,
|
||||||
|
`<p><a href="${spaceUrl}" style="display:inline-block;padding:12px 24px;background:#14b8a6;color:white;border-radius:8px;text-decoration:none;font-weight:600;">Open Space</a></p>`,
|
||||||
|
`<p style="color:#64748b;font-size:12px;">rSpace — collaborative knowledge work</p>`,
|
||||||
|
].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 });
|
return c.json({ ok: true, did: user.did, username: user.username, role });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -385,6 +385,7 @@ export class RStackSpaceSettings extends HTMLElement {
|
||||||
</select>
|
</select>
|
||||||
<button class="add-btn" id="add-by-email">Send Invite</button>
|
<button class="add-btn" id="add-by-email">Send Invite</button>
|
||||||
</div>
|
</div>
|
||||||
|
<span id="email-invite-feedback" style="font-size:12px;margin-top:4px;display:block;min-height:16px;"></span>
|
||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -536,6 +537,7 @@ export class RStackSpaceSettings extends HTMLElement {
|
||||||
const sr = this.shadowRoot!;
|
const sr = this.shadowRoot!;
|
||||||
const input = sr.getElementById("add-email") as HTMLInputElement;
|
const input = sr.getElementById("add-email") as HTMLInputElement;
|
||||||
const roleSelect = sr.getElementById("add-email-role") as HTMLSelectElement;
|
const roleSelect = sr.getElementById("add-email-role") as HTMLSelectElement;
|
||||||
|
const feedback = sr.getElementById("email-invite-feedback");
|
||||||
if (!input?.value) return;
|
if (!input?.value) return;
|
||||||
|
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
|
|
@ -551,10 +553,34 @@ export class RStackSpaceSettings extends HTMLElement {
|
||||||
body: JSON.stringify({ email: input.value, role: roleSelect.value }),
|
body: JSON.stringify({ email: input.value, role: roleSelect.value }),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
const data = await res.json() as { type?: string; username?: string };
|
||||||
input.value = "";
|
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();
|
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) {
|
private async _changeRole(did: string, newRole: string) {
|
||||||
|
|
|
||||||
|
|
@ -3770,9 +3770,28 @@ app.delete('/api/spaces/:slug/members/:did', async (c) => {
|
||||||
app.get('/api/internal/user-email/:userId', async (c) => {
|
app.get('/api/internal/user-email/:userId', async (c) => {
|
||||||
const userId = c.req.param('userId');
|
const userId = c.req.param('userId');
|
||||||
if (!userId) return c.json({ error: 'userId required' }, 400);
|
if (!userId) return c.json({ error: 'userId required' }, 400);
|
||||||
const profile = await getUserProfile(userId);
|
const [user, profile] = await Promise.all([getUserById(userId), getUserProfile(userId)]);
|
||||||
if (!profile) return c.json({ error: 'User not found' }, 404);
|
if (!user && !profile) return c.json({ error: 'User not found' }, 404);
|
||||||
return c.json({ email: profile.profileEmail || null, username: profile.username || null });
|
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);
|
if (!user) return c.json({ error: 'User not found' }, 404);
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
|
id: user.id,
|
||||||
did: user.did,
|
did: user.did,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
displayName: user.display_name || user.username,
|
displayName: user.display_name || user.username,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue