Improve rTasks drag-drop UX + sync space members on invite claim
CI/CD / deploy (push) Failing after 9s Details

rTasks: port backlog-md ordinal algorithm (bisection + rebalance),
fix column detection via bounding-box hit test, add empty-column
drop zones, source column dimming, no-op detection, and optimistic
DOM updates (no flash). New bulk-sort-order rebalance endpoint.

EncryptID: sync claimed invite members to Automerge doc immediately,
redirect to space subdomain after identity claim.

Server: add /api/internal/sync-space-member endpoint, fallback
member check in WebSocket auth for not-yet-synced invites.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-01 12:19:37 -07:00
parent fef217798e
commit d2fa533519
5 changed files with 242 additions and 31 deletions

View File

@ -44,6 +44,8 @@ services:
- SMTP_HOST=${SMTP_HOST:-mailcowdockerized-postfix-mailcow-1} - SMTP_HOST=${SMTP_HOST:-mailcowdockerized-postfix-mailcow-1}
- SMTP_PORT=${SMTP_PORT:-587} - SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER:-noreply@rmail.online} - SMTP_USER=${SMTP_USER:-noreply@rmail.online}
- SMTP_PASS=${SMTP_PASS}
- SMTP_FROM=${SMTP_FROM:-rSpace <noreply@rmail.online>}
- SITE_URL=https://rspace.online - SITE_URL=https://rspace.online
- RTASKS_REPO_BASE=/repos - RTASKS_REPO_BASE=/repos
- SPLAT_NOTIFY_EMAIL=jeffemmett@gmail.com - SPLAT_NOTIFY_EMAIL=jeffemmett@gmail.com

View File

@ -29,6 +29,7 @@ class FolkTasksBoard extends HTMLElement {
private boardView: "board" | "checklist" = "board"; private boardView: "board" | "checklist" = "board";
private dragOverStatus: string | null = null; private dragOverStatus: string | null = null;
private dragOverIndex = -1; private dragOverIndex = -1;
private dragSourceStatus: string | null = null;
private priorities = ["LOW", "MEDIUM", "HIGH", "URGENT"]; private priorities = ["LOW", "MEDIUM", "HIGH", "URGENT"];
private _offlineUnsubs: (() => void)[] = []; private _offlineUnsubs: (() => void)[] = [];
private _history = new ViewHistory<"list" | "board">("list"); private _history = new ViewHistory<"list" | "board">("list");
@ -481,16 +482,100 @@ class FolkTasksBoard extends HTMLElement {
} catch { this.error = "Failed to move task"; this.render(); } } catch { this.error = "Failed to move task"; this.render(); }
} }
private computeSortOrder(targetStatus: string, insertIndex: number): number { /** Optimistic drop — update local state immediately, persist in background */
private persistDrop(taskId: string, newStatus: string, sortOrder: number, rebalanceUpdates: { id: string; sort_order: number }[]) {
// Update local tasks array immediately
const task = this.tasks.find(t => t.id === taskId);
if (!task) return;
task.status = newStatus;
task.sort_order = sortOrder;
// Apply rebalance updates to local state
for (const u of rebalanceUpdates) {
const t = this.tasks.find(t => t.id === u.id);
if (t) t.sort_order = u.sort_order;
}
this.render();
if (this.isDemo) return;
// Fire-and-forget API calls
const base = this.getApiBase();
fetch(`${base}/api/tasks/${taskId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: newStatus, sort_order: sortOrder }),
}).catch(() => { this.error = "Failed to save task move"; this.render(); });
if (rebalanceUpdates.length > 0) {
fetch(`${base}/api/tasks/bulk-sort-order`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ updates: rebalanceUpdates }),
}).catch(() => {}); // non-critical
}
}
// ── Ordinal algorithm (ported from backlog-md) ──
private static readonly ORDINAL_STEP = 1000;
private static readonly ORDINAL_EPSILON = 1e-6;
private calculateNewOrdinal(
prev: { sort_order: number } | null,
next: { sort_order: number } | null,
): { ordinal: number; requiresRebalance: boolean } {
const STEP = FolkTasksBoard.ORDINAL_STEP;
const EPS = FolkTasksBoard.ORDINAL_EPSILON;
const p = prev?.sort_order;
const n = next?.sort_order;
if (p === undefined && n === undefined) return { ordinal: STEP, requiresRebalance: false };
if (p === undefined || p === null) {
const candidate = (n ?? STEP) / 2;
return { ordinal: candidate, requiresRebalance: !Number.isFinite(candidate) || candidate <= 0 || candidate >= (n ?? STEP) - EPS };
}
if (n === undefined || n === null) {
const candidate = p + STEP;
return { ordinal: candidate, requiresRebalance: !Number.isFinite(candidate) };
}
const gap = n - p;
if (gap <= EPS) return { ordinal: p + STEP, requiresRebalance: true };
const candidate = p + gap / 2;
return { ordinal: candidate, requiresRebalance: candidate <= p + EPS || candidate >= n - EPS };
}
private resolveOrdinalConflicts(tasks: any[]): { id: string; sort_order: number }[] {
const STEP = FolkTasksBoard.ORDINAL_STEP;
const updates: { id: string; sort_order: number }[] = [];
let last: number | undefined;
for (let i = 0; i < tasks.length; i++) {
const t = tasks[i];
const assigned = i === 0 ? STEP : (last ?? STEP) + STEP;
if (assigned !== (t.sort_order ?? 0)) updates.push({ id: t.id, sort_order: assigned });
t.sort_order = assigned;
last = assigned;
}
return updates;
}
private computeSortOrder(targetStatus: string, insertIndex: number): { sortOrder: number; rebalanceUpdates: { id: string; sort_order: number }[] } {
const columnTasks = this.tasks const columnTasks = this.tasks
.filter(t => t.status === targetStatus && t.id !== this.dragTaskId) .filter(t => t.status === targetStatus && t.id !== this.dragTaskId)
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)); .sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
if (columnTasks.length === 0) return 0;
if (insertIndex <= 0) return (columnTasks[0].sort_order ?? 0) - 1000; const prev = insertIndex > 0 ? columnTasks[insertIndex - 1] : null;
if (insertIndex >= columnTasks.length) return (columnTasks[columnTasks.length - 1].sort_order ?? 0) + 1000; const next = insertIndex < columnTasks.length ? columnTasks[insertIndex] : null;
const before = columnTasks[insertIndex - 1].sort_order ?? 0; const { ordinal, requiresRebalance } = this.calculateNewOrdinal(prev, next);
const after = columnTasks[insertIndex].sort_order ?? 0;
return (before + after) / 2; let rebalanceUpdates: { id: string; sort_order: number }[] = [];
if (requiresRebalance) {
// Insert dragged task at correct position for rebalance
const all = [...columnTasks];
all.splice(insertIndex, 0, { id: this.dragTaskId, sort_order: ordinal });
rebalanceUpdates = this.resolveOrdinalConflicts(all);
}
return { sortOrder: ordinal, rebalanceUpdates };
} }
private openBoard(slug: string) { private openBoard(slug: string) {
@ -539,6 +624,7 @@ class FolkTasksBoard extends HTMLElement {
.col-count { background: var(--rs-border); border-radius: 10px; padding: 0 8px; font-size: 11px; } .col-count { background: var(--rs-border); border-radius: 10px; padding: 0 8px; font-size: 11px; }
.column.drag-over { background: var(--rs-bg-surface); border-color: var(--rs-primary); } .column.drag-over { background: var(--rs-bg-surface); border-color: var(--rs-primary); }
.column.drag-source { opacity: 0.55; }
.task-card { .task-card {
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong); border-radius: 8px; background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong); border-radius: 8px;
@ -590,9 +676,13 @@ class FolkTasksBoard extends HTMLElement {
.view-toggle__btn:hover:not(.active) { color: var(--rs-text-secondary); } .view-toggle__btn:hover:not(.active) { color: var(--rs-text-secondary); }
/* Drop indicator */ /* Drop indicator */
.drop-indicator { height: 2px; background: var(--rs-primary); border-radius: 1px; margin: 2px 0; animation: pulse-indicator 1.5s ease-in-out infinite; } .drop-indicator { height: 3px; background: var(--rs-primary); border-radius: 2px; margin: 2px 0; pointer-events: none; animation: pulse-indicator 1.5s ease-in-out infinite; }
@keyframes pulse-indicator { 0%, 100% { opacity: 0.6; } 50% { opacity: 1; box-shadow: 0 0 8px var(--rs-primary); } } @keyframes pulse-indicator { 0%, 100% { opacity: 0.6; } 50% { opacity: 1; box-shadow: 0 0 8px var(--rs-primary); } }
/* Empty column drop zone */
.empty-drop-zone { border: 2px dashed #22c55e; border-radius: 8px; padding: 24px 12px; text-align: center; color: #22c55e; font-size: 12px; font-weight: 600; pointer-events: none; opacity: 0; transition: opacity 0.15s; }
.column.drag-over .empty-drop-zone { opacity: 1; }
/* Checklist view */ /* Checklist view */
.checklist { max-width: 720px; } .checklist { max-width: 720px; }
.checklist-group { margin-bottom: 16px; } .checklist-group { margin-bottom: 16px; }
@ -746,14 +836,16 @@ class FolkTasksBoard extends HTMLElement {
const columnTasks = this.tasks const columnTasks = this.tasks
.filter(t => t.status === status) .filter(t => t.status === status)
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)); .sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
const isDragSource = this.dragTaskId && this.dragSourceStatus === status;
return ` return `
<div class="column" data-status="${status}"> <div class="column${isDragSource ? ' drag-source' : ''}" data-status="${status}">
<div class="col-header"> <div class="col-header">
<span>${this.esc(status.replace(/_/g, " "))}</span> <span>${this.esc(status.replace(/_/g, " "))}</span>
<span class="col-count">${columnTasks.length}</span> <span class="col-count">${columnTasks.length}</span>
</div> </div>
${status === "TODO" ? this.renderCreateForm() : ""} ${status === "TODO" ? this.renderCreateForm() : ""}
${columnTasks.map(t => this.renderTaskCard(t, status)).join("")} ${columnTasks.map(t => this.renderTaskCard(t, status)).join("")}
${columnTasks.length === 0 && status !== "TODO" ? '<div class="empty-drop-zone">Drop task here to change status</div>' : ''}
</div> </div>
`; `;
}).join("")} }).join("")}
@ -990,6 +1082,9 @@ class FolkTasksBoard extends HTMLElement {
if (pe.button !== 0) return; if (pe.button !== 0) return;
if (el.getAttribute("draggable") === "false") return; if (el.getAttribute("draggable") === "false") return;
this.dragTaskId = el.dataset.taskId || null; this.dragTaskId = el.dataset.taskId || null;
// Track source column for dimming
const task = this.tasks.find(t => t.id === this.dragTaskId);
this.dragSourceStatus = task?.status || null;
el.classList.add("dragging"); el.classList.add("dragging");
el.setPointerCapture(pe.pointerId); el.setPointerCapture(pe.pointerId);
el.style.touchAction = "none"; el.style.touchAction = "none";
@ -999,14 +1094,30 @@ class FolkTasksBoard extends HTMLElement {
const pe = e as PointerEvent; const pe = e as PointerEvent;
pe.preventDefault(); pe.preventDefault();
// Clear previous state // Clear previous state
this.shadow.querySelectorAll(".column[data-status]").forEach(c => (c as HTMLElement).classList.remove("drag-over")); this.shadow.querySelectorAll(".column[data-status]").forEach(c => {
(c as HTMLElement).classList.remove("drag-over");
// Re-apply drag-source class since we're clearing
if (this.dragSourceStatus && (c as HTMLElement).dataset.status === this.dragSourceStatus) {
(c as HTMLElement).classList.add("drag-source");
}
});
this.shadow.querySelector('.drop-indicator')?.remove(); this.shadow.querySelector('.drop-indicator')?.remove();
// Find target column // Find target column via bounding-box hit test (fixes Shadow DOM elementFromPoint issues)
const target = this.shadow.elementFromPoint(pe.clientX, pe.clientY) as HTMLElement | null; let targetCol: HTMLElement | null = null;
const targetCol = target?.closest?.(".column[data-status]") as HTMLElement | null; const columns = this.shadow.querySelectorAll(".column[data-status]");
for (const col of columns) {
const rect = col.getBoundingClientRect();
if (pe.clientX >= rect.left && pe.clientX <= rect.right && pe.clientY >= rect.top && pe.clientY <= rect.bottom) {
targetCol = col as HTMLElement;
break;
}
}
if (targetCol) { if (targetCol) {
targetCol.classList.add("drag-over"); // Don't highlight source column green — it's already dimmed
// Calculate insert position if (targetCol.dataset.status !== this.dragSourceStatus) {
targetCol.classList.add("drag-over");
}
// Calculate insert position among non-dragging cards
const cards = Array.from(targetCol.querySelectorAll('.task-card:not(.dragging)')); const cards = Array.from(targetCol.querySelectorAll('.task-card:not(.dragging)'));
let insertIndex = cards.length; let insertIndex = cards.length;
for (let i = 0; i < cards.length; i++) { for (let i = 0; i < cards.length; i++) {
@ -1020,9 +1131,11 @@ class FolkTasksBoard extends HTMLElement {
indicator.className = 'drop-indicator'; indicator.className = 'drop-indicator';
if (insertIndex < cards.length) { if (insertIndex < cards.length) {
cards[insertIndex].before(indicator); cards[insertIndex].before(indicator);
} else { } else if (cards.length > 0) {
targetCol.appendChild(indicator); // After last card
cards[cards.length - 1].after(indicator);
} }
// For empty columns the empty-drop-zone CSS handles the visual
} else { } else {
this.dragOverStatus = null; this.dragOverStatus = null;
this.dragOverIndex = -1; this.dragOverIndex = -1;
@ -1032,29 +1145,44 @@ class FolkTasksBoard extends HTMLElement {
if (!this.dragTaskId) return; if (!this.dragTaskId) return;
el.classList.remove("dragging"); el.classList.remove("dragging");
el.style.touchAction = ""; el.style.touchAction = "";
this.shadow.querySelectorAll(".column[data-status]").forEach(c => (c as HTMLElement).classList.remove("drag-over")); this.shadow.querySelectorAll(".column[data-status]").forEach(c => {
(c as HTMLElement).classList.remove("drag-over", "drag-source");
});
this.shadow.querySelector('.drop-indicator')?.remove(); this.shadow.querySelector('.drop-indicator')?.remove();
if (this.dragOverStatus && this.dragOverIndex >= 0) { if (this.dragOverStatus && this.dragOverIndex >= 0) {
const task = this.tasks.find(t => t.id === this.dragTaskId); const task = this.tasks.find(t => t.id === this.dragTaskId);
const sortOrder = this.computeSortOrder(this.dragOverStatus, this.dragOverIndex);
if (task) { if (task) {
if (task.status === this.dragOverStatus) { // No-op detection: same column, same position → skip
this.updateTask(this.dragTaskId!, { sort_order: sortOrder }); const colTasks = this.tasks
} else { .filter(t => t.status === this.dragOverStatus && t.id !== this.dragTaskId)
this.moveTask(this.dragTaskId!, this.dragOverStatus, sortOrder); .sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
const currentIdx = this.tasks
.filter(t => t.status === task.status)
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
.findIndex(t => t.id === task.id);
const sameColumn = task.status === this.dragOverStatus;
const samePosition = sameColumn && (this.dragOverIndex === currentIdx || this.dragOverIndex === currentIdx + 1);
if (!samePosition) {
const { sortOrder, rebalanceUpdates } = this.computeSortOrder(this.dragOverStatus!, this.dragOverIndex);
this.persistDrop(this.dragTaskId!, this.dragOverStatus!, sortOrder, rebalanceUpdates);
} }
} }
} }
this.dragTaskId = null; this.dragTaskId = null;
this.dragSourceStatus = null;
this.dragOverStatus = null; this.dragOverStatus = null;
this.dragOverIndex = -1; this.dragOverIndex = -1;
}); });
el.addEventListener("pointercancel", () => { el.addEventListener("pointercancel", () => {
el.classList.remove("dragging"); el.classList.remove("dragging");
el.style.touchAction = ""; el.style.touchAction = "";
this.shadow.querySelectorAll(".column[data-status]").forEach(c => (c as HTMLElement).classList.remove("drag-over")); this.shadow.querySelectorAll(".column[data-status]").forEach(c => {
(c as HTMLElement).classList.remove("drag-over", "drag-source");
});
this.shadow.querySelector('.drop-indicator')?.remove(); this.shadow.querySelector('.drop-indicator')?.remove();
this.dragTaskId = null; this.dragTaskId = null;
this.dragSourceStatus = null;
this.dragOverStatus = null; this.dragOverStatus = null;
this.dragOverIndex = -1; this.dragOverIndex = -1;
}); });

View File

@ -373,6 +373,39 @@ routes.post("/api/spaces/:slug/tasks", async (c) => {
}, 201); }, 201);
}); });
// PATCH /api/tasks/bulk-sort-order — rebalance sort orders for multiple tasks
// Registered BEFORE /api/tasks/:id to avoid param capture
routes.patch("/api/tasks/bulk-sort-order", async (c) => {
const body = await c.req.json();
const { updates } = body;
if (!Array.isArray(updates) || updates.length === 0) {
return c.json({ error: "updates array required" }, 400);
}
// Group updates by board doc
const allBoardIds = _syncServer!.getDocIds().filter((docId) => docId.includes(':tasks:boards:'));
let applied = 0;
for (const { id, sort_order } of updates) {
if (!id || sort_order === undefined) continue;
for (const docId of allBoardIds) {
const doc = _syncServer!.getDoc<BoardDoc>(docId);
if (doc && doc.tasks[id]) {
_syncServer!.changeDoc<BoardDoc>(docId, `Rebalance sort order ${id}`, (d) => {
if (d.tasks[id]) {
d.tasks[id].sortOrder = sort_order;
d.tasks[id].updatedAt = Date.now();
}
});
applied++;
break;
}
}
}
return c.json({ ok: true, applied });
});
// PATCH /api/tasks/:id — update task (status change, assignment, etc.) // PATCH /api/tasks/:id — update task (status change, assignment, etc.)
routes.patch("/api/tasks/:id", async (c) => { routes.patch("/api/tasks/:id", async (c) => {
// Optional auth — track who updated // Optional auth — track who updated

View File

@ -29,6 +29,7 @@ import {
listCommunities, listCommunities,
deleteCommunity, deleteCommunity,
updateSpaceMeta, updateSpaceMeta,
setMember,
} from "./community-store"; } from "./community-store";
import type { NestPermissions, SpaceRefFilter } from "./community-store"; import type { NestPermissions, SpaceRefFilter } from "./community-store";
import { ensureDemoCommunity } from "./seed-demo"; import { ensureDemoCommunity } from "./seed-demo";
@ -586,6 +587,23 @@ app.post("/api/internal/provision", async (c) => {
return c.json({ status: "created", slug: space }, 201); return c.json({ status: "created", slug: space }, 201);
}); });
// POST /api/internal/sync-space-member — called by EncryptID after identity invite claim
// Syncs a member from EncryptID's space_members (PostgreSQL) to the Automerge doc
app.post("/api/internal/sync-space-member", async (c) => {
const body = await c.req.json<{ spaceSlug: string; userDid: string; role: string; username?: string }>();
if (!body.spaceSlug || !body.userDid || !body.role) {
return c.json({ error: "spaceSlug, userDid, and role are required" }, 400);
}
try {
await loadCommunity(body.spaceSlug);
setMember(body.spaceSlug, body.userDid, body.role as any, body.username);
return c.json({ ok: true });
} catch (err: any) {
console.error("sync-space-member error:", err.message);
return c.json({ error: "Failed to sync member" }, 500);
}
});
// POST /api/internal/mint-crdt — called by onramp-service after fiat payment confirmed // POST /api/internal/mint-crdt — called by onramp-service after fiat payment confirmed
app.post("/api/internal/mint-crdt", async (c) => { app.post("/api/internal/mint-crdt", async (c) => {
const internalKey = c.req.header("X-Internal-Key"); const internalKey = c.req.header("X-Internal-Key");
@ -3081,7 +3099,19 @@ const server = Bun.serve<WSData>({
if (vis === "private" && claims && spaceData) { if (vis === "private" && claims && spaceData) {
const callerDid = (claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`; const callerDid = (claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`;
const isOwner = spaceData.meta?.ownerDID === claims.sub || spaceData.meta?.ownerDID === callerDid; const isOwner = spaceData.meta?.ownerDID === claims.sub || spaceData.meta?.ownerDID === callerDid;
const isMember = spaceData.members?.[claims.sub] || spaceData.members?.[callerDid]; let isMember = spaceData.members?.[claims.sub] || spaceData.members?.[callerDid];
// Fallback: check EncryptID space_members (handles identity-invite claims not yet synced to Automerge)
if (!isOwner && !isMember) {
try {
const memberRes = await fetch(`${ENCRYPTID_INTERNAL}/api/spaces/${encodeURIComponent(communitySlug)}/members/${encodeURIComponent(callerDid)}`);
if (memberRes.ok) {
const memberData = await memberRes.json() as { role: string; userDID: string };
// Sync to Automerge so future connections don't need the fallback
setMember(communitySlug, callerDid, memberData.role as any, (claims as any).username);
isMember = { role: memberData.role };
}
} catch {}
}
if (!isOwner && !isMember) { if (!isOwner && !isMember) {
return new Response("You don't have access to this space", { status: 403 }); return new Response("You don't have access to this space", { status: 403 });
} }
@ -3091,12 +3121,15 @@ const server = Bun.serve<WSData>({
} }
} }
// Resolve the caller's space role // Resolve the caller's space role (re-read doc in case setMember was called during fallback sync)
if (spaceData) { const spaceDataFresh = getDocumentData(communitySlug);
if (claims && spaceData.meta.ownerDID === claims.sub) { if (spaceDataFresh || spaceData) {
const sd = spaceDataFresh || spaceData;
const callerDid = claims ? ((claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`) : undefined;
if (claims && sd!.meta.ownerDID === claims.sub) {
spaceRole = 'admin'; spaceRole = 'admin';
} else if (claims && spaceData.members?.[claims.sub]) { } else if (claims && (sd!.members?.[claims.sub]?.role || (callerDid && sd!.members?.[callerDid]?.role))) {
spaceRole = spaceData.members[claims.sub].role; spaceRole = sd!.members?.[claims.sub]?.role || sd!.members?.[callerDid!]?.role;
} else { } else {
// Non-member defaults by visibility // Non-member defaults by visibility
const vis = spaceConfig?.visibility; const vis = spaceConfig?.visibility;

View File

@ -5592,6 +5592,18 @@ app.post('/api/invites/identity/:token/claim', async (c) => {
const inviterUser = await getUserById(invite.invitedByUserId); const inviterUser = await getUserById(invite.invitedByUserId);
const inviterDid = inviterUser?.did || `did:key:${invite.invitedByUserId.slice(0, 32)}`; const inviterDid = inviterUser?.did || `did:key:${invite.invitedByUserId.slice(0, 32)}`;
await upsertSpaceMember(invite.spaceSlug, did, invite.spaceRole, inviterDid); await upsertSpaceMember(invite.spaceSlug, did, invite.spaceRole, inviterDid);
// Sync to rSpace's Automerge doc so the member has immediate access
const rspaceUrl = process.env.RSPACE_INTERNAL_URL || 'http://rspace-online:3000';
fetch(`${rspaceUrl}/api/internal/sync-space-member`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
spaceSlug: invite.spaceSlug,
userDid: did,
role: invite.spaceRole,
username: inviteeUser?.username,
}),
}).catch((err) => console.error('EncryptID: Failed to sync space member to Automerge:', err.message));
} }
// Auto-add email to OIDC client allowlist if this is a client invite // Auto-add email to OIDC client allowlist if this is a client invite
@ -5909,9 +5921,12 @@ function joinPage(token: string): string {
// Success! // Success!
document.getElementById('registerForm').style.display = 'none'; document.getElementById('registerForm').style.display = 'none';
statusEl.style.display = 'none'; statusEl.style.display = 'none';
const destUrl = claimData.spaceSlug
? 'https://' + claimData.spaceSlug + '.rspace.online'
: 'https://rspace.online';
successEl.innerHTML = '<strong>Welcome to rSpace!</strong><br>Your identity has been created and your passkey is set up.' + successEl.innerHTML = '<strong>Welcome to rSpace!</strong><br>Your identity has been created and your passkey is set up.' +
(claimData.spaceSlug ? '<br>You\\'ve been added to <strong>' + claimData.spaceSlug + '</strong>.' : '') + (claimData.spaceSlug ? '<br>You\\'ve been added to <strong>' + claimData.spaceSlug + '</strong>.' : '') +
'<br><br><a href="https://rspace.online" style="color: #7c3aed;">Go to rSpace →</a>'; '<br><br><a href="' + destUrl + '" style="color: #7c3aed;">Go to ' + (claimData.spaceSlug || 'rSpace') + ' →</a>';
successEl.style.display = 'block'; successEl.style.display = 'block';
} catch (err) { } catch (err) {