Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-21 16:04:32 -07:00
commit 9d6783604a
3 changed files with 156 additions and 37 deletions

View File

@ -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 });
}); });

View File

@ -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) {

View File

@ -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,