fix(spaces): dark-mode role dropdown, invite revoke route, fuzzy username search
CI/CD / deploy (push) Failing after 2m33s
Details
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:
parent
c7c43e4cab
commit
82d4f5220d
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue