|
|
|
|
@ -223,6 +223,38 @@ function storeSession(token: string, username: string, did: string): void {
|
|
|
|
|
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
|
|
|
|
|
if (username) localStorage.setItem("rspace-username", username);
|
|
|
|
|
_setSessionCookie(token);
|
|
|
|
|
if (username && did) addKnownPersona(username, did);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Persona helpers (client-side multi-account) ──
|
|
|
|
|
|
|
|
|
|
const PERSONAS_KEY = "rspace-known-personas";
|
|
|
|
|
|
|
|
|
|
interface KnownPersona {
|
|
|
|
|
username: string;
|
|
|
|
|
did: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getKnownPersonas(): KnownPersona[] {
|
|
|
|
|
try {
|
|
|
|
|
return JSON.parse(localStorage.getItem(PERSONAS_KEY) || "[]");
|
|
|
|
|
} catch { return []; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function addKnownPersona(username: string, did: string): void {
|
|
|
|
|
const personas = getKnownPersonas();
|
|
|
|
|
const idx = personas.findIndex(p => p.did === did);
|
|
|
|
|
if (idx >= 0) {
|
|
|
|
|
personas[idx] = { username, did };
|
|
|
|
|
} else {
|
|
|
|
|
personas.push({ username, did });
|
|
|
|
|
}
|
|
|
|
|
localStorage.setItem(PERSONAS_KEY, JSON.stringify(personas));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function removeKnownPersona(did: string): void {
|
|
|
|
|
const personas = getKnownPersonas().filter(p => p.did !== did);
|
|
|
|
|
localStorage.setItem(PERSONAS_KEY, JSON.stringify(personas));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Auto-space resolution after auth ──
|
|
|
|
|
@ -311,11 +343,24 @@ export class RStackIdentity extends HTMLElement {
|
|
|
|
|
if (session?.accessToken && session.claims.username) {
|
|
|
|
|
autoProvisionSpace(session.accessToken);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Propagate login/logout across tabs via storage events
|
|
|
|
|
window.addEventListener("storage", this.#onStorageChange);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
disconnectedCallback() {
|
|
|
|
|
window.removeEventListener("storage", this.#onStorageChange);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#onStorageChange = (e: StorageEvent) => {
|
|
|
|
|
if (e.key === "encryptid_session" || e.key === PERSONAS_KEY) {
|
|
|
|
|
this.#render();
|
|
|
|
|
if (e.key === "encryptid_session") {
|
|
|
|
|
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
async #refreshIfNeeded() {
|
|
|
|
|
let session = getSession();
|
|
|
|
|
let token = session?.accessToken ?? null;
|
|
|
|
|
@ -369,6 +414,8 @@ export class RStackIdentity extends HTMLElement {
|
|
|
|
|
const did = session.claims.did || session.claims.sub;
|
|
|
|
|
const displayName = username || (did.length > 24 ? did.slice(0, 16) + "..." + did.slice(-6) : did);
|
|
|
|
|
const initial = username ? username[0].toUpperCase() : did.slice(8, 10).toUpperCase();
|
|
|
|
|
const currentDid = session.claims.did || "";
|
|
|
|
|
const otherPersonas = getKnownPersonas().filter(p => p.did !== currentDid);
|
|
|
|
|
|
|
|
|
|
this.#shadow.innerHTML = `
|
|
|
|
|
<style>${STYLES}</style>
|
|
|
|
|
@ -379,6 +426,21 @@ export class RStackIdentity extends HTMLElement {
|
|
|
|
|
<span class="name">${displayName}</span>
|
|
|
|
|
<div class="dropdown" id="dropdown">
|
|
|
|
|
<div class="dropdown-header">${displayName}</div>
|
|
|
|
|
${otherPersonas.length > 0 ? `
|
|
|
|
|
<div class="dropdown-divider"></div>
|
|
|
|
|
<div class="dropdown-label">Switch Persona</div>
|
|
|
|
|
${otherPersonas.map(p => `
|
|
|
|
|
<div class="persona-row">
|
|
|
|
|
<button class="dropdown-item persona-item" data-action="switch-persona" data-did="${p.did}">
|
|
|
|
|
<div class="persona-avatar">${p.username[0].toUpperCase()}</div>
|
|
|
|
|
<span>${p.username}</span>
|
|
|
|
|
</button>
|
|
|
|
|
<button class="persona-remove" data-action="remove-persona" data-did="${p.did}">×</button>
|
|
|
|
|
</div>
|
|
|
|
|
`).join("")}
|
|
|
|
|
` : ""}
|
|
|
|
|
<div class="dropdown-divider"></div>
|
|
|
|
|
<button class="dropdown-item" data-action="add-persona">+ Add Persona</button>
|
|
|
|
|
<div class="dropdown-divider"></div>
|
|
|
|
|
<button class="dropdown-item" data-action="my-account">👤 My Account</button>
|
|
|
|
|
<button class="dropdown-item" data-action="my-spaces">🌐 My Spaces</button>
|
|
|
|
|
@ -446,6 +508,23 @@ export class RStackIdentity extends HTMLElement {
|
|
|
|
|
}
|
|
|
|
|
} else if (action === "settings") {
|
|
|
|
|
(document.getElementById("settings-btn") as HTMLElement)?.click();
|
|
|
|
|
} else if (action === "switch-persona") {
|
|
|
|
|
const targetDid = (el as HTMLElement).dataset.did || "";
|
|
|
|
|
const persona = getKnownPersonas().find(p => p.did === targetDid);
|
|
|
|
|
if (!persona) return;
|
|
|
|
|
clearSession();
|
|
|
|
|
resetDocBridge();
|
|
|
|
|
this.showAuthModal({ onSuccess: () => {}, onCancel: () => { this.#render(); } }, persona.username);
|
|
|
|
|
} else if (action === "add-persona") {
|
|
|
|
|
clearSession();
|
|
|
|
|
resetDocBridge();
|
|
|
|
|
this.#render();
|
|
|
|
|
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
|
|
|
|
|
this.showAuthModal();
|
|
|
|
|
} else if (action === "remove-persona") {
|
|
|
|
|
const targetDid = (el as HTMLElement).dataset.did || "";
|
|
|
|
|
removeKnownPersona(targetDid);
|
|
|
|
|
this.#render();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
@ -493,8 +572,9 @@ export class RStackIdentity extends HTMLElement {
|
|
|
|
|
return getSession() !== null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Public method: show the auth modal programmatically */
|
|
|
|
|
showAuthModal(callbacks?: { onSuccess?: () => void; onCancel?: () => void }): void {
|
|
|
|
|
/** Public method: show the auth modal programmatically.
|
|
|
|
|
* Pass usernameHint to auto-trigger passkey sign-in for a specific persona. */
|
|
|
|
|
showAuthModal(callbacks?: { onSuccess?: () => void; onCancel?: () => void }, usernameHint?: string): void {
|
|
|
|
|
if (document.querySelector(".rstack-auth-overlay")) return;
|
|
|
|
|
|
|
|
|
|
const overlay = document.createElement("div");
|
|
|
|
|
@ -542,17 +622,16 @@ export class RStackIdentity extends HTMLElement {
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleSignIn = async () => {
|
|
|
|
|
const errEl = overlay.querySelector("#auth-error") as HTMLElement;
|
|
|
|
|
const btn = overlay.querySelector('[data-action="signin"]') as HTMLButtonElement;
|
|
|
|
|
errEl.textContent = "";
|
|
|
|
|
btn.disabled = true;
|
|
|
|
|
btn.innerHTML = '<span class="spinner"></span> Authenticating...';
|
|
|
|
|
const errEl = overlay.querySelector("#auth-error") as HTMLElement | null;
|
|
|
|
|
const btn = overlay.querySelector('[data-action="signin"]') as HTMLButtonElement | null;
|
|
|
|
|
if (errEl) errEl.textContent = "";
|
|
|
|
|
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Authenticating...'; }
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const startRes = await fetch(`${ENCRYPTID_URL}/api/auth/start`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({}),
|
|
|
|
|
body: JSON.stringify(usernameHint ? { username: usernameHint } : {}),
|
|
|
|
|
});
|
|
|
|
|
if (!startRes.ok) throw new Error("Failed to start authentication");
|
|
|
|
|
const { options: serverOptions } = await startRes.json();
|
|
|
|
|
@ -586,9 +665,11 @@ export class RStackIdentity extends HTMLElement {
|
|
|
|
|
// Auto-redirect to personal space
|
|
|
|
|
autoResolveSpace(data.token, data.username || "");
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
btn.disabled = false;
|
|
|
|
|
btn.innerHTML = "🔑 Sign In with Passkey";
|
|
|
|
|
errEl.textContent = err.name === "NotAllowedError" ? "Authentication was cancelled." : err.message || "Authentication failed.";
|
|
|
|
|
if (btn) { btn.disabled = false; btn.innerHTML = "🔑 Sign In with Passkey"; }
|
|
|
|
|
const msg = err.name === "NotAllowedError" ? "Authentication was cancelled." : err.message || "Authentication failed.";
|
|
|
|
|
if (errEl) errEl.textContent = msg;
|
|
|
|
|
// If auto-triggered persona switch was cancelled, close modal and restore previous state
|
|
|
|
|
if (usernameHint) { close(); this.#render(); callbacks?.onCancel?.(); }
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
@ -696,6 +777,11 @@ export class RStackIdentity extends HTMLElement {
|
|
|
|
|
|
|
|
|
|
document.body.appendChild(overlay);
|
|
|
|
|
render();
|
|
|
|
|
|
|
|
|
|
// If switching persona, auto-trigger sign-in immediately
|
|
|
|
|
if (usernameHint) {
|
|
|
|
|
handleSignIn();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Account modal (consolidated) ──
|
|
|
|
|
@ -1668,6 +1754,28 @@ const STYLES = `
|
|
|
|
|
padding: 0 4px; border: 2px solid var(--rs-bg-surface); line-height: 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Persona switcher in dropdown */
|
|
|
|
|
.dropdown-label {
|
|
|
|
|
padding: 6px 16px; font-size: 0.7rem; text-transform: uppercase;
|
|
|
|
|
letter-spacing: 0.05em; color: var(--rs-text-secondary); font-weight: 600;
|
|
|
|
|
}
|
|
|
|
|
.persona-row {
|
|
|
|
|
display: flex; align-items: center; padding-right: 8px;
|
|
|
|
|
}
|
|
|
|
|
.persona-row .persona-item { flex: 1; }
|
|
|
|
|
.persona-avatar {
|
|
|
|
|
width: 24px; height: 24px; border-radius: 50%;
|
|
|
|
|
background: linear-gradient(135deg, #06b6d4, #7c3aed);
|
|
|
|
|
display: flex; align-items: center; justify-content: center;
|
|
|
|
|
font-weight: 700; font-size: 0.65rem; color: white; flex-shrink: 0;
|
|
|
|
|
}
|
|
|
|
|
.persona-remove {
|
|
|
|
|
opacity: 0.4; cursor: pointer; font-size: 0.75rem;
|
|
|
|
|
padding: 2px 6px; border-radius: 4px; border: none;
|
|
|
|
|
background: none; color: var(--rs-text-secondary); flex-shrink: 0;
|
|
|
|
|
}
|
|
|
|
|
.persona-remove:hover { opacity: 1; background: var(--rs-bg-hover); }
|
|
|
|
|
|
|
|
|
|
/* Notification items in dropdown */
|
|
|
|
|
.dropdown-section-label {
|
|
|
|
|
padding: 8px 16px 4px; font-size: 0.65rem; font-weight: 600;
|
|
|
|
|
|