Compare commits

...

2 Commits

Author SHA1 Message Date
Jeff Emmett 48c86c6c1f Merge branch 'dev'
CI/CD / deploy (push) Failing after 2m40s Details
2026-04-17 09:53:59 -04:00
Jeff Emmett cfe060dc61 fix: invite UX — hide disabled modules, resolve usernames, show invites list, require userId on claim
- rApp dropdown no longer shows a "Manage rApps" panel for disabled modules.
  Enable/disable lives in Edit Space → Modules only.
- /api/internal/user-email/:userId now resolves by id OR did (real did🔑z6Mk…
  or legacy synthetic did🔑<32-char id prefix>), so Edit Space members list
  shows display names instead of raw DIDs.
- /api/spaces/:slug/invites admin check now accepts ownerDID stored as either
  raw userId or did🔑. Routes the list through a new internal endpoint on
  encryptid so Automerge-owned spaces without a space_members row still work.
- /join page registration payload was missing userId, causing every invite
  claim to fail with "Missing required fields: userId, credential, username".
  Same fix applied to the OIDC accept page flow.
2026-04-17 09:53:53 -04:00
4 changed files with 45 additions and 50 deletions

View File

@ -2479,20 +2479,21 @@ spaces.get("/:slug/invites", async (c) => {
const data = getDocumentData(slug);
if (!data) return c.json({ error: "Space not found" }, 404);
const isOwner = data.meta.ownerDID === claims.sub;
const callerMember = data.members?.[claims.sub];
// ownerDID may be stored as raw userId or did:key:... — check both.
const callerDid = (claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`;
const isOwner = data.meta.ownerDID === claims.sub || data.meta.ownerDID === callerDid;
const callerMember = data.members?.[claims.sub] || data.members?.[callerDid];
if (!isOwner && callerMember?.role !== "admin") {
return c.json({ error: "Admin access required" }, 403);
}
// Fetch from EncryptID
// Use the internal (no-auth) endpoint — rspace has already verified admin via the
// Automerge space doc. The public encryptid endpoint gates on the encryptid
// space_members table, which can be out of sync with Automerge ownership.
try {
const res = await fetch(`${ENCRYPTID_URL}/api/spaces/${slug}/invites`, {
headers: { "Authorization": `Bearer ${token}` },
});
const data = await res.json();
return c.json(data as any);
const res = await fetch(`${ENCRYPTID_URL}/api/internal/spaces/${slug}/invites`);
const payload = await res.json();
return c.json(payload as any);
} catch {
return c.json({ invites: [], total: 0 });
}

View File

@ -296,44 +296,7 @@ export class RStackAppSwitcher extends HTMLElement {
}
}
// "Manage rApps" catalog section
const disabledModules = this.#allModules.filter(
m => m.enabled === false && m.id !== 'rspace'
);
// Only show the Manage section when there are disabled modules to add,
// or when enabledModules is actively configured (not null/all-enabled)
const hasRestrictions = disabledModules.length > 0;
if (hasRestrictions || this.#allModules.length > this.#modules.length) {
html += `
<div class="catalog-divider">
<button class="catalog-toggle" id="catalog-toggle">
${this.#catalogOpen ? '▾' : '▸'} Manage rApps
${disabledModules.length > 0 ? `<span class="catalog-count">${disabledModules.length} more</span>` : ''}
</button>
</div>
`;
if (this.#catalogOpen) {
html += `<div class="catalog-panel" id="catalog-panel">`;
// Show disabled modules with add option
if (disabledModules.length > 0) {
html += `<div class="catalog-section-label">Available to Add</div>`;
for (const m of disabledModules) {
html += this.#renderCatalogItem(m, false);
}
}
// Show enabled modules with remove option (compact section below)
const enabledNonCore = this.#allModules.filter(
m => m.enabled !== false && m.id !== 'rspace'
);
if (enabledNonCore.length > 0) {
html += `<div class="catalog-section-label">Remove from Space</div>`;
for (const m of enabledNonCore) {
html += this.#renderCatalogItem(m, true);
}
}
html += `</div>`;
}
}
// Module enable/disable is managed via Edit Space → Modules tab, not from this dropdown.
// Sort toggle + Footer
html += `

View File

@ -270,6 +270,22 @@ export async function getUserById(userId: string) {
return user || null;
}
/** Resolve a user by internal id (UUID), real did (did:key:z6Mk...), or
* legacy synthetic did (did:key: + first 32 chars of userId). */
export async function getUserByIdOrDid(idOrDid: string) {
const [direct] = await sql`SELECT * FROM users WHERE id = ${idOrDid} OR did = ${idOrDid} LIMIT 1`;
if (direct) return direct;
// Legacy synthetic DID: did:key:<first 32 chars of userId>
if (idOrDid.startsWith('did:key:')) {
const idPrefix = idOrDid.slice('did:key:'.length);
if (idPrefix.length >= 16) {
const [legacy] = await sql`SELECT * FROM users WHERE id LIKE ${idPrefix + '%'} LIMIT 1`;
return legacy || null;
}
}
return null;
}
/** Record a global logout — all JWTs issued before this timestamp are revoked */
export async function setUserLoggedOutAt(userId: string): Promise<void> {
await sql`UPDATE users SET logged_out_at = NOW() WHERE id = ${userId}`;

View File

@ -29,6 +29,7 @@ import {
setUserEmail,
getUserByEmail,
getUserById,
getUserByIdOrDid,
getUserByUsername,
storeRecoveryToken,
getRecoveryToken,
@ -4629,9 +4630,12 @@ app.delete('/api/spaces/:slug/members/:did', async (c) => {
// ── Internal: email lookup by userId (no auth, internal network only) ──
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 [user, profile] = await Promise.all([getUserById(userId), getUserProfile(userId)]);
const idOrDid = c.req.param('userId');
if (!idOrDid) return c.json({ error: 'userId required' }, 400);
// Members dicts store either raw userId (UUID) or did:key:... — accept both.
const user = await getUserByIdOrDid(idOrDid);
const resolvedId = user?.id || idOrDid;
const profile = await getUserProfile(resolvedId);
if (!user && !profile) return c.json({ error: 'User not found' }, 404);
return c.json({
recoveryEmail: user?.email || null,
@ -4829,6 +4833,15 @@ app.get('/api/spaces/:slug/invites', async (c) => {
return c.json({ invites, total: invites.length });
});
// GET /api/internal/spaces/:slug/invites — internal (no auth) list for rspace proxy.
// Authority check lives on the rspace side against the Automerge space doc, so this
// route is safe to expose on the internal network only.
app.get('/api/internal/spaces/:slug/invites', async (c) => {
const { slug } = c.req.param();
const invites = await listSpaceInvites(slug);
return c.json({ invites, total: invites.length });
});
// POST /api/spaces/:slug/invites/:id/revoke — revoke an invite
app.post('/api/spaces/:slug/invites/:id/revoke', async (c) => {
const { slug, id } = c.req.param();
@ -6362,6 +6375,7 @@ function joinPage(token: string): string {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: startData.userId,
username,
challenge: options.challenge,
deviceName: detectDeviceName(),
@ -6703,6 +6717,7 @@ function oidcAcceptPage(token: string): string {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: startData.userId,
username,
challenge: options.challenge,
deviceName: detectDeviceName(),