feat(rdocs,encryptid): persist suggestions to Automerge + fix invite join page
rDocs: Suggestion metadata (author, type, text, status) is now persisted to Automerge alongside comments, surviving page reloads and syncing across clients. Accept/reject actions record final status (accepted/rejected). EncryptID: The /join page now has "I'm new" / "I have an account" tabs so existing users can sign in with their passkey to accept space invites, instead of being forced to create a duplicate account. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7a1ffbe635
commit
519404cb69
|
|
@ -34,6 +34,7 @@ interface CommentThread {
|
|||
interface NotebookDoc {
|
||||
items: Record<string, {
|
||||
comments?: Record<string, CommentThread>;
|
||||
suggestions?: Record<string, any>;
|
||||
[key: string]: any;
|
||||
}>;
|
||||
[key: string]: any;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
import * as Automerge from '@automerge/automerge';
|
||||
import { makeDraggableAll } from '../../../shared/draggable';
|
||||
import { notebookSchema } from '../schemas';
|
||||
import type { SuggestionRecord } from '../schemas';
|
||||
import type { DocumentId } from '../../../shared/local-first/document';
|
||||
import { getAccessToken } from '../../../shared/components/rstack-identity';
|
||||
import { broadcastPresence as sharedBroadcastPresence, startPresenceHeartbeat } from '../../../shared/collab-presence';
|
||||
|
|
@ -207,6 +208,8 @@ class FolkDocsApp extends HTMLElement {
|
|||
// ── Demo data ──
|
||||
private demoNotebooks: (Notebook & { notes: Note[] })[] = [];
|
||||
private _demoThreads = new Map<string, Record<string, any>>();
|
||||
private _demoSuggestions = new Map<string, Record<string, any>>();
|
||||
private _lastSuggestionAction: { id: string; action: 'accepted' | 'rejected' } | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
|
@ -2836,6 +2839,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
|||
|
||||
pop.querySelector('.sp-accept')!.addEventListener('click', () => {
|
||||
if (this.editor) {
|
||||
this._lastSuggestionAction = { id: suggestionId, action: 'accepted' };
|
||||
acceptSuggestion(this.editor, suggestionId);
|
||||
this.updateSuggestionReviewBar();
|
||||
this.syncSuggestionsToPanel(true);
|
||||
|
|
@ -2844,6 +2848,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
|||
});
|
||||
pop.querySelector('.sp-reject')!.addEventListener('click', () => {
|
||||
if (this.editor) {
|
||||
this._lastSuggestionAction = { id: suggestionId, action: 'rejected' };
|
||||
rejectSuggestion(this.editor, suggestionId);
|
||||
this.updateSuggestionReviewBar();
|
||||
this.syncSuggestionsToPanel(true);
|
||||
|
|
@ -2899,6 +2904,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
|||
if (!panel) return;
|
||||
const suggestions = this.collectSuggestions();
|
||||
panel.suggestions = suggestions;
|
||||
this.persistSuggestionsToAutomerge(suggestions);
|
||||
const sidebar = this.shadow.getElementById('comment-sidebar');
|
||||
if (sidebar && suggestions.length > 0) {
|
||||
sidebar.classList.add('has-comments');
|
||||
|
|
@ -2911,6 +2917,79 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
|||
}
|
||||
}
|
||||
|
||||
/** Persist suggestion metadata to Automerge so they survive across sessions. */
|
||||
private persistSuggestionsToAutomerge(editorSuggestions: { id: string; type: 'insert' | 'delete'; text: string; authorId: string; authorName: string; createdAt: number }[]) {
|
||||
const noteId = this.editorNoteId;
|
||||
if (!noteId) return;
|
||||
|
||||
const editorIds = new Set(editorSuggestions.map(s => s.id));
|
||||
|
||||
// ── Demo mode ──
|
||||
if (this.space === 'demo') {
|
||||
if (!this._demoSuggestions.has(noteId)) {
|
||||
this._demoSuggestions.set(noteId, {});
|
||||
}
|
||||
const store = this._demoSuggestions.get(noteId)!;
|
||||
for (const s of editorSuggestions) {
|
||||
if (!store[s.id]) {
|
||||
store[s.id] = { ...s, status: 'pending' };
|
||||
}
|
||||
}
|
||||
// Clean up suggestions no longer in the editor (accepted/rejected)
|
||||
for (const id of Object.keys(store)) {
|
||||
if (store[id].status === 'pending' && !editorIds.has(id)) {
|
||||
const action = this._lastSuggestionAction;
|
||||
store[id].status = (action?.id === id) ? action.action : 'accepted';
|
||||
}
|
||||
}
|
||||
this._lastSuggestionAction = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Automerge mode ──
|
||||
if (!this.doc?.items?.[noteId] || !this.subscribedDocId) return;
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (!runtime?.isInitialized) return;
|
||||
|
||||
const existing: Record<string, SuggestionRecord> = (this.doc.items[noteId] as any).suggestions || {};
|
||||
|
||||
// Check if anything actually changed to avoid no-op writes
|
||||
const hasNew = editorSuggestions.some(s => !existing[s.id]);
|
||||
const hasRemoved = Object.entries(existing).some(([id, rec]) =>
|
||||
rec.status === 'pending' && !editorIds.has(id));
|
||||
if (!hasNew && !hasRemoved) return;
|
||||
|
||||
const lastAction = this._lastSuggestionAction;
|
||||
this._lastSuggestionAction = null;
|
||||
|
||||
runtime.change(this.subscribedDocId as DocumentId, 'Sync suggestions', (d: any) => {
|
||||
const item = d.items[noteId];
|
||||
if (!item) return;
|
||||
if (!item.suggestions) item.suggestions = {};
|
||||
// Add new suggestions
|
||||
for (const s of editorSuggestions) {
|
||||
if (!item.suggestions[s.id]) {
|
||||
item.suggestions[s.id] = {
|
||||
id: s.id,
|
||||
type: s.type,
|
||||
text: s.text,
|
||||
authorId: s.authorId,
|
||||
authorName: s.authorName,
|
||||
createdAt: s.createdAt,
|
||||
status: 'pending',
|
||||
};
|
||||
}
|
||||
}
|
||||
// Mark removed suggestions with final status
|
||||
for (const id of Object.keys(item.suggestions)) {
|
||||
if (item.suggestions[id].status === 'pending' && !editorIds.has(id)) {
|
||||
item.suggestions[id].status = (lastAction?.id === id) ? lastAction.action : 'accepted';
|
||||
}
|
||||
}
|
||||
});
|
||||
this.doc = runtime.get(this.subscribedDocId as DocumentId);
|
||||
}
|
||||
|
||||
/** Show comment panel for a specific thread. */
|
||||
private showCommentPanel(threadId?: string) {
|
||||
const sidebar = this.shadow.getElementById('comment-sidebar');
|
||||
|
|
@ -2928,6 +3007,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
|||
// Listen for suggestion accept/reject from comment panel
|
||||
panel.addEventListener('suggestion-accept', (e: CustomEvent) => {
|
||||
if (this.editor && e.detail?.suggestionId) {
|
||||
this._lastSuggestionAction = { id: e.detail.suggestionId, action: 'accepted' };
|
||||
acceptSuggestion(this.editor, e.detail.suggestionId);
|
||||
this.updateSuggestionReviewBar();
|
||||
this.syncSuggestionsToPanel(true);
|
||||
|
|
@ -2935,6 +3015,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
|||
});
|
||||
panel.addEventListener('suggestion-reject', (e: CustomEvent) => {
|
||||
if (this.editor && e.detail?.suggestionId) {
|
||||
this._lastSuggestionAction = { id: e.detail.suggestionId, action: 'rejected' };
|
||||
rejectSuggestion(this.editor, e.detail.suggestionId);
|
||||
this.updateSuggestionReviewBar();
|
||||
this.syncSuggestionsToPanel(true);
|
||||
|
|
|
|||
|
|
@ -37,6 +37,16 @@ export interface CommentThread {
|
|||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface SuggestionRecord {
|
||||
id: string;
|
||||
type: 'insert' | 'delete';
|
||||
text: string;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
createdAt: number;
|
||||
status: 'pending' | 'accepted' | 'rejected';
|
||||
}
|
||||
|
||||
export interface NoteItem {
|
||||
id: string;
|
||||
notebookId: string;
|
||||
|
|
@ -62,6 +72,7 @@ export interface NoteItem {
|
|||
conflictContent?: string;
|
||||
collabEnabled?: boolean;
|
||||
comments?: Record<string, CommentThread>;
|
||||
suggestions?: Record<string, SuggestionRecord>;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
visibility?: import('../../shared/membrane').ObjectVisibility;
|
||||
|
|
|
|||
|
|
@ -6215,6 +6215,18 @@ function joinPage(token: string): string {
|
|||
font-style: italic;
|
||||
display: none;
|
||||
}
|
||||
/* Tabs */
|
||||
.tabs { display: flex; gap: 0; margin-bottom: 1.5rem; border-radius: 0.5rem; overflow: hidden; border: 1px solid rgba(255,255,255,0.15); }
|
||||
.tab {
|
||||
flex: 1; padding: 0.6rem; background: transparent; border: none;
|
||||
color: #94a3b8; font-size: 0.85rem; font-weight: 500; cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.tab.active { background: rgba(124,58,237,0.2); color: #fff; }
|
||||
.tab:hover:not(.active) { background: rgba(255,255,255,0.05); }
|
||||
.panel { display: none; }
|
||||
.panel.active { display: block; }
|
||||
|
||||
.form-group { margin-bottom: 1rem; text-align: left; }
|
||||
.form-group label { display: block; font-size: 0.8rem; color: #94a3b8; margin-bottom: 0.4rem; font-weight: 500; }
|
||||
.form-group input {
|
||||
|
|
@ -6244,15 +6256,6 @@ function joinPage(token: string): string {
|
|||
color: #86efac; display: none;
|
||||
}
|
||||
.status { color: #94a3b8; font-size: 0.85rem; margin-top: 1rem; display: none; }
|
||||
.step-indicator {
|
||||
display: flex; justify-content: center; gap: 0.5rem; margin-bottom: 1.5rem;
|
||||
}
|
||||
.step {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: rgba(255,255,255,0.15); transition: background 0.3s;
|
||||
}
|
||||
.step.active { background: #7c3aed; }
|
||||
.step.done { background: #22c55e; }
|
||||
.features {
|
||||
margin-top: 1.5rem; text-align: left; font-size: 0.8rem; color: #94a3b8;
|
||||
}
|
||||
|
|
@ -6263,31 +6266,41 @@ function joinPage(token: string): string {
|
|||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">🔐</div>
|
||||
<h1>Claim your rSpace</h1>
|
||||
<p id="subtitle" class="sub">Create your passkey-protected identity</p>
|
||||
|
||||
<div class="step-indicator">
|
||||
<div id="step1" class="step active"></div>
|
||||
<div id="step2" class="step"></div>
|
||||
<div id="step3" class="step"></div>
|
||||
</div>
|
||||
<h1 id="title">Join rSpace</h1>
|
||||
<p id="subtitle" class="sub">Loading invite details...</p>
|
||||
|
||||
<div id="messageBox" class="message-box"></div>
|
||||
<div id="error" class="error"></div>
|
||||
<div id="success" class="success"></div>
|
||||
|
||||
<div id="registerForm">
|
||||
<div class="form-group">
|
||||
<label>Username</label>
|
||||
<input type="text" id="username" placeholder="Choose a username" autocomplete="username" />
|
||||
<div id="authSection" style="display:none;">
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('new')">I'm new</button>
|
||||
<button class="tab" onclick="switchTab('existing')">I have an account</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Email</label>
|
||||
<input type="email" id="email" placeholder="your@email.com" readonly />
|
||||
|
||||
<div id="panel-new" class="panel active">
|
||||
<div class="form-group">
|
||||
<label>Username</label>
|
||||
<input type="text" id="username" placeholder="Choose a username" autocomplete="username" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Email</label>
|
||||
<input type="email" id="email" placeholder="your@email.com" readonly />
|
||||
</div>
|
||||
<button id="registerBtn" class="btn-primary" onclick="register()">
|
||||
Create Passkey & Join
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="panel-existing" class="panel">
|
||||
<p style="color: #cbd5e1; font-size: 0.9rem; margin-bottom: 1rem;">
|
||||
Sign in with your existing passkey to accept this invitation.
|
||||
</p>
|
||||
<button id="loginBtn" class="btn-primary" onclick="doLogin()">
|
||||
Sign in with Passkey
|
||||
</button>
|
||||
</div>
|
||||
<button id="registerBtn" class="btn-primary" onclick="register()" disabled>
|
||||
Create Passkey & Claim
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p id="status" class="status"></p>
|
||||
|
|
@ -6325,9 +6338,6 @@ function joinPage(token: string): string {
|
|||
const errorEl = document.getElementById('error');
|
||||
const successEl = document.getElementById('success');
|
||||
const statusEl = document.getElementById('status');
|
||||
const registerBtn = document.getElementById('registerBtn');
|
||||
const usernameInput = document.getElementById('username');
|
||||
const emailInput = document.getElementById('email');
|
||||
const messageBox = document.getElementById('messageBox');
|
||||
|
||||
function showError(msg) {
|
||||
|
|
@ -6341,11 +6351,24 @@ function joinPage(token: string): string {
|
|||
statusEl.style.display = 'block';
|
||||
errorEl.style.display = 'none';
|
||||
}
|
||||
function setStep(n) {
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const el = document.getElementById('step' + i);
|
||||
el.className = 'step' + (i < n ? ' done' : i === n ? ' active' : '');
|
||||
}
|
||||
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
function switchTab(tab) {
|
||||
document.querySelectorAll('.tab').forEach((t, i) => {
|
||||
t.classList.toggle('active', (tab === 'new' && i === 0) || (tab === 'existing' && i === 1));
|
||||
});
|
||||
document.getElementById('panel-new').classList.toggle('active', tab === 'new');
|
||||
document.getElementById('panel-existing').classList.toggle('active', tab === 'existing');
|
||||
errorEl.style.display = 'none';
|
||||
}
|
||||
|
||||
// base64url helpers
|
||||
function toB64url(buf) {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buf)))
|
||||
.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');
|
||||
}
|
||||
function fromB64url(s) {
|
||||
return Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
// Load invite info
|
||||
|
|
@ -6362,21 +6385,51 @@ function joinPage(token: string): string {
|
|||
return;
|
||||
}
|
||||
inviteData = await res.json();
|
||||
const spaceName = inviteData.spaceSlug || 'rSpace';
|
||||
document.getElementById('title').textContent = 'Join ' + spaceName;
|
||||
document.getElementById('subtitle').innerHTML =
|
||||
'<span class="inviter">' + inviteData.invitedBy + '</span> invited you to join rSpace';
|
||||
emailInput.value = inviteData.email;
|
||||
'<span class="inviter">' + esc(inviteData.invitedBy) + '</span> invited you to join <strong>' + esc(spaceName) + '</strong>';
|
||||
document.getElementById('email').value = inviteData.email;
|
||||
if (inviteData.message) {
|
||||
messageBox.textContent = '"' + inviteData.message + '"';
|
||||
messageBox.textContent = '\\u201c' + inviteData.message + '\\u201d';
|
||||
messageBox.style.display = 'block';
|
||||
}
|
||||
registerBtn.disabled = false;
|
||||
document.getElementById('authSection').style.display = 'block';
|
||||
} catch (err) {
|
||||
showError('Failed to load invite. Please try again.');
|
||||
}
|
||||
})();
|
||||
|
||||
// Shared: claim invite and show success with redirect
|
||||
async function claimAndRedirect(sessionToken) {
|
||||
showStatus('Claiming invitation...');
|
||||
const claimRes = await fetch('/api/invites/identity/' + encodeURIComponent(TOKEN) + '/claim', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionToken }),
|
||||
});
|
||||
const claimData = await claimRes.json();
|
||||
if (claimData.error) {
|
||||
showError('Invite claim failed: ' + claimData.error);
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('authSection').style.display = 'none';
|
||||
statusEl.style.display = 'none';
|
||||
|
||||
const destUrl = claimData.spaceSlug
|
||||
? 'https://' + claimData.spaceSlug + '.rspace.online'
|
||||
: 'https://rspace.online';
|
||||
successEl.innerHTML = '<strong>Welcome!</strong><br>Your invitation has been accepted.' +
|
||||
(claimData.spaceSlug ? '<br>You\\'ve been added to <strong>' + esc(claimData.spaceSlug) + '</strong>.' : '') +
|
||||
'<br><br><a href="' + esc(destUrl) + '" style="display:inline-block;padding:0.7rem 1.5rem;background:linear-gradient(90deg,#00d4ff,#7c3aed);color:#fff;text-decoration:none;border-radius:0.5rem;font-weight:600;">Go to ' + esc(claimData.spaceSlug || 'rSpace') + ' \\u2192</a>' +
|
||||
'<p style="color:#94a3b8;font-size:0.8rem;margin-top:1rem;">You\\u2019ll sign in with your passkey when you get there.</p>';
|
||||
successEl.style.display = 'block';
|
||||
}
|
||||
|
||||
// ── New account: register with passkey ──
|
||||
async function register() {
|
||||
const username = usernameInput.value.trim();
|
||||
const username = document.getElementById('username').value.trim();
|
||||
if (!username || username.length < 2) {
|
||||
showError('Username must be at least 2 characters');
|
||||
return;
|
||||
|
|
@ -6386,126 +6439,121 @@ function joinPage(token: string): string {
|
|||
return;
|
||||
}
|
||||
|
||||
registerBtn.disabled = true;
|
||||
const btn = document.getElementById('registerBtn');
|
||||
btn.disabled = true;
|
||||
errorEl.style.display = 'none';
|
||||
setStep(2);
|
||||
showStatus('Starting passkey registration...');
|
||||
|
||||
try {
|
||||
// Step 1: Start registration
|
||||
const startRes = await fetch('/api/register/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
const startData = await startRes.json();
|
||||
if (startData.error) {
|
||||
showError(startData.error);
|
||||
registerBtn.disabled = false;
|
||||
setStep(1);
|
||||
return;
|
||||
}
|
||||
if (startData.error) { showError(startData.error); btn.disabled = false; return; }
|
||||
|
||||
// Step 2: Create credential
|
||||
showStatus('Follow your browser prompt to create a passkey...');
|
||||
const { options } = startData;
|
||||
const challengeBytes = Uint8Array.from(
|
||||
atob(options.challenge.replace(/-/g, '+').replace(/_/g, '/')),
|
||||
c => c.charCodeAt(0)
|
||||
);
|
||||
const userIdBytes = Uint8Array.from(
|
||||
atob(options.user.id.replace(/-/g, '+').replace(/_/g, '/')),
|
||||
c => c.charCodeAt(0)
|
||||
);
|
||||
|
||||
const publicKeyOptions = {
|
||||
challenge: challengeBytes,
|
||||
rp: options.rp,
|
||||
user: { ...options.user, id: userIdBytes },
|
||||
pubKeyCredParams: options.pubKeyCredParams,
|
||||
timeout: options.timeout,
|
||||
authenticatorSelection: options.authenticatorSelection,
|
||||
attestation: options.attestation,
|
||||
};
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: {
|
||||
challenge: fromB64url(options.challenge),
|
||||
rp: options.rp,
|
||||
user: { ...options.user, id: fromB64url(options.user.id) },
|
||||
pubKeyCredParams: options.pubKeyCredParams,
|
||||
timeout: options.timeout,
|
||||
authenticatorSelection: options.authenticatorSelection,
|
||||
attestation: options.attestation,
|
||||
},
|
||||
});
|
||||
|
||||
const credential = await navigator.credentials.create({ publicKey: publicKeyOptions });
|
||||
|
||||
// Step 3: Complete registration
|
||||
setStep(3);
|
||||
showStatus('Completing registration...');
|
||||
|
||||
const credentialId = btoa(String.fromCharCode(...new Uint8Array(credential.rawId)))
|
||||
.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');
|
||||
const attestationObject = btoa(String.fromCharCode(...new Uint8Array(credential.response.attestationObject)))
|
||||
.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');
|
||||
const clientDataJSON = btoa(String.fromCharCode(...new Uint8Array(credential.response.clientDataJSON)))
|
||||
.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');
|
||||
const publicKeyBytes = credential.response.getPublicKey ? credential.response.getPublicKey() : null;
|
||||
const publicKey = publicKeyBytes
|
||||
? btoa(String.fromCharCode(...new Uint8Array(publicKeyBytes)))
|
||||
.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '')
|
||||
: '';
|
||||
|
||||
const completeRes = await fetch('/api/register/complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
userId: startData.userId,
|
||||
username,
|
||||
challenge: options.challenge,
|
||||
deviceName: detectDeviceName(),
|
||||
credential: {
|
||||
credentialId,
|
||||
publicKey,
|
||||
attestationObject,
|
||||
clientDataJSON,
|
||||
credentialId: toB64url(credential.rawId),
|
||||
attestationObject: toB64url(credential.response.attestationObject),
|
||||
clientDataJSON: toB64url(credential.response.clientDataJSON),
|
||||
transports: credential.response.getTransports ? credential.response.getTransports() : [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
const completeData = await completeRes.json();
|
||||
if (!completeData.success) { showError(completeData.error || 'Registration failed'); btn.disabled = false; return; }
|
||||
|
||||
if (!completeData.success) {
|
||||
showError(completeData.error || 'Registration failed');
|
||||
registerBtn.disabled = false;
|
||||
setStep(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 4: Claim the invite
|
||||
showStatus('Claiming your invite...');
|
||||
const claimRes = await fetch('/api/invites/identity/' + encodeURIComponent(TOKEN) + '/claim', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionToken: completeData.token }),
|
||||
});
|
||||
const claimData = await claimRes.json();
|
||||
|
||||
if (claimData.error) {
|
||||
// Registration succeeded but claim failed — still show partial success
|
||||
showError('Account created but invite claim failed: ' + claimData.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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="' + destUrl + '" style="color: #7c3aed;">Go to ' + (claimData.spaceSlug || 'rSpace') + ' →</a>';
|
||||
successEl.style.display = 'block';
|
||||
|
||||
await claimAndRedirect(completeData.token);
|
||||
} catch (err) {
|
||||
if (err.name === 'NotAllowedError') {
|
||||
showError('Passkey creation was cancelled. Please try again.');
|
||||
} else {
|
||||
showError(err.message || 'Registration failed');
|
||||
}
|
||||
registerBtn.disabled = false;
|
||||
setStep(1);
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Existing account: sign in with passkey ──
|
||||
async function doLogin() {
|
||||
const btn = document.getElementById('loginBtn');
|
||||
btn.disabled = true;
|
||||
errorEl.style.display = 'none';
|
||||
showStatus('Starting passkey authentication...');
|
||||
|
||||
try {
|
||||
const startRes = await fetch('/api/auth/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { options } = await startRes.json();
|
||||
|
||||
showStatus('Touch your passkey...');
|
||||
const pubKeyOpts = {
|
||||
challenge: fromB64url(options.challenge),
|
||||
rpId: options.rpId,
|
||||
userVerification: options.userVerification,
|
||||
timeout: options.timeout,
|
||||
};
|
||||
if (options.allowCredentials) {
|
||||
pubKeyOpts.allowCredentials = options.allowCredentials.map(c => ({
|
||||
type: c.type,
|
||||
id: fromB64url(c.id),
|
||||
transports: c.transports,
|
||||
}));
|
||||
}
|
||||
|
||||
const assertion = await navigator.credentials.get({ publicKey: pubKeyOpts });
|
||||
|
||||
const completeRes = await fetch('/api/auth/complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
challenge: options.challenge,
|
||||
credential: {
|
||||
credentialId: toB64url(assertion.rawId),
|
||||
authenticatorData: toB64url(assertion.response.authenticatorData),
|
||||
clientDataJSON: toB64url(assertion.response.clientDataJSON),
|
||||
signature: toB64url(assertion.response.signature),
|
||||
},
|
||||
}),
|
||||
});
|
||||
const authResult = await completeRes.json();
|
||||
if (!authResult.success) { showError(authResult.error || 'Authentication failed'); btn.disabled = false; return; }
|
||||
|
||||
await claimAndRedirect(authResult.token);
|
||||
} catch (err) {
|
||||
if (err.name === 'NotAllowedError') {
|
||||
showError('Passkey authentication was cancelled.');
|
||||
} else {
|
||||
showError(err.message || 'Authentication failed');
|
||||
}
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Reference in New Issue