From 82d4f5220d5d2fc4d1000ab1ff298fde6d34994e Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 18 Apr 2026 13:58:41 -0400 Subject: [PATCH] fix(spaces): dark-mode role dropdown, invite revoke route, fuzzy username search - EncryptID SMTP override in docker-compose.encryptid.yml now defaults to internal postfix container (no auth), matching the main rspace pattern. The external mail.rmail.online + rspace.online creds have been rejecting auth for weeks, leaving EncryptID in [NO SMTP] mode and silently dropping invite emails. - New POST /api/spaces/:slug/invites/:id/revoke on rspace: admin check via Automerge space doc, proxies to new internal EncryptID endpoint. Fixes invitation revoke that previously 404'd. - /api/users/search switched from prefix to substring ILIKE %q%, min query length 1, limit up to 20, ranked exact > prefix > substring. - Dark-mode fix: color-scheme: dark + explicit option styling on role, scope, and input selects in the edit-space modal so dropdown text is readable on dark backgrounds. Co-Authored-By: Claude Opus 4.7 (1M context) --- docker-compose.encryptid.yml | 2 +- server/spaces.ts | 30 ++++++++++++++++++++++ shared/components/rstack-space-switcher.ts | 9 ++++++- src/encryptid/server.ts | 27 +++++++++++++++---- 4 files changed, 61 insertions(+), 7 deletions(-) diff --git a/docker-compose.encryptid.yml b/docker-compose.encryptid.yml index 2be3c62a..f1871db9 100644 --- a/docker-compose.encryptid.yml +++ b/docker-compose.encryptid.yml @@ -18,7 +18,7 @@ services: - PORT=3000 - JWT_SECRET=${JWT_SECRET} - DATABASE_URL=postgres://encryptid:${ENCRYPTID_DB_PASSWORD}@encryptid-db:5432/encryptid - - SMTP_HOST=${SMTP_HOST:-mail.rmail.online} + - SMTP_HOST=${SMTP_HOST:-mailcowdockerized-postfix-mailcow-1} - SMTP_PORT=${SMTP_PORT:-587} - SMTP_USER=${SMTP_USER:-noreply@rspace.online} - SMTP_PASS=${SMTP_PASS} diff --git a/server/spaces.ts b/server/spaces.ts index d4b65e11..14f1a221 100644 --- a/server/spaces.ts +++ b/server/spaces.ts @@ -2535,6 +2535,36 @@ spaces.post("/:slug/invites", async (c) => { } }); +// ── Revoke an invite ── + +spaces.post("/:slug/invites/:id/revoke", async (c) => { + const { slug, id } = c.req.param(); + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + + let claims: EncryptIDClaims; + try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + await loadCommunity(slug); + const data = getDocumentData(slug); + if (!data) return c.json({ error: "Space not found" }, 404); + + 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); + } + + try { + const res = await fetch(`${ENCRYPTID_URL}/api/internal/spaces/${slug}/invites/${id}/revoke`, { method: "POST" }); + const payload = await res.json().catch(() => ({} as any)); + return c.json(payload as any, res.status as any); + } catch { + return c.json({ error: "Failed to revoke invite" }, 500); + } +}); + // ── List invites for settings panel ── spaces.get("/:slug/invites", async (c) => { diff --git a/shared/components/rstack-space-switcher.ts b/shared/components/rstack-space-switcher.ts index 94372292..ddd981a0 100644 --- a/shared/components/rstack-space-switcher.ts +++ b/shared/components/rstack-space-switcher.ts @@ -2090,7 +2090,8 @@ const EDIT_SPACE_MODAL_STYLES = ` transition: border-color 0.2s; box-sizing: border-box; font-family: inherit; } .input:focus { border-color: #06b6d4; } -select.input { appearance: auto; } +select.input { appearance: auto; color-scheme: dark; } +select.input option { background: var(--rs-bg-primary, #0a0a0a); color: var(--rs-text-primary, #e5e5e5); } .actions { display: flex; gap: 12px; margin-top: 0.5rem; } .btn { @@ -2128,7 +2129,9 @@ select.input { appearance: auto; } .role-select { padding: 4px 8px; border-radius: 6px; border: 1px solid var(--rs-input-border); background: var(--rs-bg-hover); color: var(--rs-text-primary); font-size: 0.8rem; outline: none; + color-scheme: dark; } +.role-select option { background: var(--rs-bg-primary, #0a0a0a); color: var(--rs-text-primary, #e5e5e5); } .member-remove { background: none; border: none; color: var(--rs-text-muted); font-size: 1.2rem; cursor: pointer; padding: 2px 6px; border-radius: 4px; line-height: 1; @@ -2356,7 +2359,11 @@ const CREATE_SPACE_MODAL_STYLES = ` padding: 8px 10px; border-radius: 8px; border: 1px solid var(--rs-input-border, #404040); background: var(--rs-bg-hover, #171717); color: var(--rs-text-primary, #e5e5e5); font-size: 0.82rem; outline: none; + color-scheme: dark; } +.cs-role-select option { background: var(--rs-bg-primary, #0a0a0a); color: var(--rs-text-primary, #e5e5e5); } +.scope-select { color-scheme: dark; } +.scope-select option { background: var(--rs-bg-primary, #0a0a0a); color: var(--rs-text-primary, #e5e5e5); } .cs-add-btn { padding: 8px 16px; border-radius: 8px; border: none; diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index 153f1c0f..d735bcbc 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -4798,17 +4798,24 @@ app.get('/api/users/search', async (c) => { if (!claims) return c.json({ error: 'Authentication required' }, 401); const q = (c.req.query('q') || '').trim(); - if (q.length < 2) return c.json({ error: 'Query must be at least 2 characters' }, 400); + if (q.length < 1) return c.json({ error: 'Query must be at least 1 character' }, 400); - const limit = Math.min(Math.max(parseInt(c.req.query('limit') || '5'), 1), 10); - const pattern = `${q}%`; + const limit = Math.min(Math.max(parseInt(c.req.query('limit') || '10'), 1), 20); + const substr = `%${q}%`; + const prefix = `${q}%`; + // Rank: exact match > prefix match > substring match; username over display_name. const rows = await sql` SELECT id, did, username, display_name FROM users - WHERE username ILIKE ${pattern} OR display_name ILIKE ${pattern} + WHERE username ILIKE ${substr} OR display_name ILIKE ${substr} ORDER BY - CASE WHEN username ILIKE ${q} THEN 0 ELSE 1 END, + CASE + WHEN username ILIKE ${q} THEN 0 + WHEN username ILIKE ${prefix} THEN 1 + WHEN display_name ILIKE ${prefix} THEN 2 + ELSE 3 + END, username LIMIT ${limit} `; @@ -4883,6 +4890,16 @@ app.get('/api/spaces/:slug/invites', async (c) => { return c.json({ invites, total: invites.length }); }); +// POST /api/internal/spaces/:slug/invites/:id/revoke — internal (no auth) revoke +// for rspace proxy. Authority check lives on the rspace side against the Automerge +// space doc. Only exposed on the internal Docker network. +app.post('/api/internal/spaces/:slug/invites/:id/revoke', async (c) => { + const { slug, id } = c.req.param(); + const revoked = await revokeSpaceInvite(id, slug); + if (!revoked) return c.json({ error: 'Invite not found or already used' }, 404); + return c.json({ ok: true }); +}); + // 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.