Improve rTasks drag-drop UX + sync space members on invite claim
CI/CD / deploy (push) Failing after 9s
Details
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:
parent
fef217798e
commit
d2fa533519
|
|
@ -44,6 +44,8 @@ services:
|
|||
- SMTP_HOST=${SMTP_HOST:-mailcowdockerized-postfix-mailcow-1}
|
||||
- SMTP_PORT=${SMTP_PORT:-587}
|
||||
- SMTP_USER=${SMTP_USER:-noreply@rmail.online}
|
||||
- SMTP_PASS=${SMTP_PASS}
|
||||
- SMTP_FROM=${SMTP_FROM:-rSpace <noreply@rmail.online>}
|
||||
- SITE_URL=https://rspace.online
|
||||
- RTASKS_REPO_BASE=/repos
|
||||
- SPLAT_NOTIFY_EMAIL=jeffemmett@gmail.com
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ class FolkTasksBoard extends HTMLElement {
|
|||
private boardView: "board" | "checklist" = "board";
|
||||
private dragOverStatus: string | null = null;
|
||||
private dragOverIndex = -1;
|
||||
private dragSourceStatus: string | null = null;
|
||||
private priorities = ["LOW", "MEDIUM", "HIGH", "URGENT"];
|
||||
private _offlineUnsubs: (() => void)[] = [];
|
||||
private _history = new ViewHistory<"list" | "board">("list");
|
||||
|
|
@ -481,16 +482,100 @@ class FolkTasksBoard extends HTMLElement {
|
|||
} 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
|
||||
.filter(t => t.status === targetStatus && t.id !== this.dragTaskId)
|
||||
.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;
|
||||
if (insertIndex >= columnTasks.length) return (columnTasks[columnTasks.length - 1].sort_order ?? 0) + 1000;
|
||||
const before = columnTasks[insertIndex - 1].sort_order ?? 0;
|
||||
const after = columnTasks[insertIndex].sort_order ?? 0;
|
||||
return (before + after) / 2;
|
||||
|
||||
const prev = insertIndex > 0 ? columnTasks[insertIndex - 1] : null;
|
||||
const next = insertIndex < columnTasks.length ? columnTasks[insertIndex] : null;
|
||||
const { ordinal, requiresRebalance } = this.calculateNewOrdinal(prev, next);
|
||||
|
||||
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) {
|
||||
|
|
@ -539,6 +624,7 @@ class FolkTasksBoard extends HTMLElement {
|
|||
.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-source { opacity: 0.55; }
|
||||
|
||||
.task-card {
|
||||
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); }
|
||||
|
||||
/* 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); } }
|
||||
|
||||
/* 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 { max-width: 720px; }
|
||||
.checklist-group { margin-bottom: 16px; }
|
||||
|
|
@ -746,14 +836,16 @@ class FolkTasksBoard extends HTMLElement {
|
|||
const columnTasks = this.tasks
|
||||
.filter(t => t.status === status)
|
||||
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
||||
const isDragSource = this.dragTaskId && this.dragSourceStatus === status;
|
||||
return `
|
||||
<div class="column" data-status="${status}">
|
||||
<div class="column${isDragSource ? ' drag-source' : ''}" data-status="${status}">
|
||||
<div class="col-header">
|
||||
<span>${this.esc(status.replace(/_/g, " "))}</span>
|
||||
<span class="col-count">${columnTasks.length}</span>
|
||||
</div>
|
||||
${status === "TODO" ? this.renderCreateForm() : ""}
|
||||
${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>
|
||||
`;
|
||||
}).join("")}
|
||||
|
|
@ -990,6 +1082,9 @@ class FolkTasksBoard extends HTMLElement {
|
|||
if (pe.button !== 0) return;
|
||||
if (el.getAttribute("draggable") === "false") return;
|
||||
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.setPointerCapture(pe.pointerId);
|
||||
el.style.touchAction = "none";
|
||||
|
|
@ -999,14 +1094,30 @@ class FolkTasksBoard extends HTMLElement {
|
|||
const pe = e as PointerEvent;
|
||||
pe.preventDefault();
|
||||
// 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();
|
||||
// Find target column
|
||||
const target = this.shadow.elementFromPoint(pe.clientX, pe.clientY) as HTMLElement | null;
|
||||
const targetCol = target?.closest?.(".column[data-status]") as HTMLElement | null;
|
||||
// Find target column via bounding-box hit test (fixes Shadow DOM elementFromPoint issues)
|
||||
let targetCol: HTMLElement | null = 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) {
|
||||
targetCol.classList.add("drag-over");
|
||||
// Calculate insert position
|
||||
// Don't highlight source column green — it's already dimmed
|
||||
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)'));
|
||||
let insertIndex = cards.length;
|
||||
for (let i = 0; i < cards.length; i++) {
|
||||
|
|
@ -1020,9 +1131,11 @@ class FolkTasksBoard extends HTMLElement {
|
|||
indicator.className = 'drop-indicator';
|
||||
if (insertIndex < cards.length) {
|
||||
cards[insertIndex].before(indicator);
|
||||
} else {
|
||||
targetCol.appendChild(indicator);
|
||||
} else if (cards.length > 0) {
|
||||
// After last card
|
||||
cards[cards.length - 1].after(indicator);
|
||||
}
|
||||
// For empty columns the empty-drop-zone CSS handles the visual
|
||||
} else {
|
||||
this.dragOverStatus = null;
|
||||
this.dragOverIndex = -1;
|
||||
|
|
@ -1032,29 +1145,44 @@ class FolkTasksBoard extends HTMLElement {
|
|||
if (!this.dragTaskId) return;
|
||||
el.classList.remove("dragging");
|
||||
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();
|
||||
if (this.dragOverStatus && this.dragOverIndex >= 0) {
|
||||
const task = this.tasks.find(t => t.id === this.dragTaskId);
|
||||
const sortOrder = this.computeSortOrder(this.dragOverStatus, this.dragOverIndex);
|
||||
if (task) {
|
||||
if (task.status === this.dragOverStatus) {
|
||||
this.updateTask(this.dragTaskId!, { sort_order: sortOrder });
|
||||
} else {
|
||||
this.moveTask(this.dragTaskId!, this.dragOverStatus, sortOrder);
|
||||
// No-op detection: same column, same position → skip
|
||||
const colTasks = this.tasks
|
||||
.filter(t => t.status === this.dragOverStatus && t.id !== this.dragTaskId)
|
||||
.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.dragSourceStatus = null;
|
||||
this.dragOverStatus = null;
|
||||
this.dragOverIndex = -1;
|
||||
});
|
||||
el.addEventListener("pointercancel", () => {
|
||||
el.classList.remove("dragging");
|
||||
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.dragTaskId = null;
|
||||
this.dragSourceStatus = null;
|
||||
this.dragOverStatus = null;
|
||||
this.dragOverIndex = -1;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -373,6 +373,39 @@ routes.post("/api/spaces/:slug/tasks", async (c) => {
|
|||
}, 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.)
|
||||
routes.patch("/api/tasks/:id", async (c) => {
|
||||
// Optional auth — track who updated
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
listCommunities,
|
||||
deleteCommunity,
|
||||
updateSpaceMeta,
|
||||
setMember,
|
||||
} from "./community-store";
|
||||
import type { NestPermissions, SpaceRefFilter } from "./community-store";
|
||||
import { ensureDemoCommunity } from "./seed-demo";
|
||||
|
|
@ -586,6 +587,23 @@ app.post("/api/internal/provision", async (c) => {
|
|||
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
|
||||
app.post("/api/internal/mint-crdt", async (c) => {
|
||||
const internalKey = c.req.header("X-Internal-Key");
|
||||
|
|
@ -3081,7 +3099,19 @@ const server = Bun.serve<WSData>({
|
|||
if (vis === "private" && claims && spaceData) {
|
||||
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 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) {
|
||||
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
|
||||
if (spaceData) {
|
||||
if (claims && spaceData.meta.ownerDID === claims.sub) {
|
||||
// Resolve the caller's space role (re-read doc in case setMember was called during fallback sync)
|
||||
const spaceDataFresh = getDocumentData(communitySlug);
|
||||
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';
|
||||
} else if (claims && spaceData.members?.[claims.sub]) {
|
||||
spaceRole = spaceData.members[claims.sub].role;
|
||||
} else if (claims && (sd!.members?.[claims.sub]?.role || (callerDid && sd!.members?.[callerDid]?.role))) {
|
||||
spaceRole = sd!.members?.[claims.sub]?.role || sd!.members?.[callerDid!]?.role;
|
||||
} else {
|
||||
// Non-member defaults by visibility
|
||||
const vis = spaceConfig?.visibility;
|
||||
|
|
|
|||
|
|
@ -5592,6 +5592,18 @@ app.post('/api/invites/identity/:token/claim', async (c) => {
|
|||
const inviterUser = await getUserById(invite.invitedByUserId);
|
||||
const inviterDid = inviterUser?.did || `did:key:${invite.invitedByUserId.slice(0, 32)}`;
|
||||
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
|
||||
|
|
@ -5909,9 +5921,12 @@ function joinPage(token: string): string {
|
|||
// Success!
|
||||
document.getElementById('registerForm').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.' +
|
||||
(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';
|
||||
|
||||
} catch (err) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue