Merge branch 'dev'
This commit is contained in:
commit
9d6783604a
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);
|
||||
}
|
||||
|
||||
// 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 <noreply@rmail.online>",
|
||||
to: body.email,
|
||||
subject: `You're invited to join "${slug}" on rSpace`,
|
||||
html: [
|
||||
`<p>You've been invited to collaborate on <strong>${slug}</strong> as a <strong>${role}</strong>.</p>`,
|
||||
`<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>`,
|
||||
`<p style="color:#64748b;font-size:12px;">This invite expires in 7 days. rSpace — collaborative knowledge work</p>`,
|
||||
].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 <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) {
|
||||
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 <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 });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -385,6 +385,7 @@ export class RStackSpaceSettings extends HTMLElement {
|
|||
</select>
|
||||
<button class="add-btn" id="add-by-email">Send Invite</button>
|
||||
</div>
|
||||
<span id="email-invite-feedback" style="font-size:12px;margin-top:4px;display:block;min-height:16px;"></span>
|
||||
</div>
|
||||
`}
|
||||
</section>
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue