fix(spaces): dark-mode role dropdown, invite revoke route, fuzzy username search
CI/CD / deploy (push) Failing after 2m33s Details

- 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) <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-18 13:58:41 -04:00
parent c7c43e4cab
commit 82d4f5220d
4 changed files with 61 additions and 7 deletions

View File

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

View File

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

View File

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

View File

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