feat(identity): local persona switcher for client-side multi-account
Store known personas in localStorage, auto-register on login. Dropdown shows other personas with one-click switch (triggers passkey re-auth), add/remove persona buttons, and cross-tab sync. Also fix pre-existing TS errors: non-null assert on filtered functionCall, add optional VerifyOptions param to authenticateWSUpgrade type declaration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
73cc1d1cc4
commit
1930d7dab0
|
|
@ -18,7 +18,7 @@ declare module '@encryptid/sdk/server' {
|
|||
options: { getSpaceConfig: (slug: string) => Promise<SpaceAuthConfig | null> },
|
||||
): Promise<{ allowed: boolean; readOnly: boolean; reason?: string; claims?: EncryptIDClaims }>;
|
||||
export function extractToken(headers: Headers): string | null;
|
||||
export function authenticateWSUpgrade(req: Request): Promise<EncryptIDClaims | null>;
|
||||
export function authenticateWSUpgrade(req: Request, options?: VerifyOptions): Promise<EncryptIDClaims | null>;
|
||||
|
||||
export interface EncryptIDClaims {
|
||||
sub: string;
|
||||
|
|
|
|||
|
|
@ -1743,7 +1743,7 @@ app.post("/api/prompt", async (c) => {
|
|||
// Record tool calls and build function responses
|
||||
const fnResponseParts: any[] = [];
|
||||
for (const part of fnCalls) {
|
||||
const fc = part.functionCall;
|
||||
const fc = part.functionCall!;
|
||||
const tool = findTool(fc.name);
|
||||
const label = tool?.actionLabel(fc.args) || fc.name;
|
||||
toolCalls.push({ name: fc.name, args: fc.args, label });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue