rspace-online/shared/components/rstack-identity.ts

950 lines
37 KiB
TypeScript

/**
* <rstack-identity> — Custom element for EncryptID sign-in/sign-out.
*
* Renders either a "Sign In" button or the user avatar + dropdown.
* Contains the full WebAuthn auth modal (sign-in + register).
* Refactored from lib/rspace-header.ts into a standalone web component.
*/
const SESSION_KEY = "encryptid_session";
const ENCRYPTID_URL = "https://auth.rspace.online";
interface SessionState {
accessToken: string;
claims: {
sub: string;
exp: number;
username?: string;
did?: string;
eid: {
authLevel: number;
capabilities: { encrypt: boolean; sign: boolean; wallet: boolean };
};
};
}
// ── Session helpers (exported for use by other code) ──
export function getSession(): SessionState | null {
try {
const stored = localStorage.getItem(SESSION_KEY);
if (!stored) return null;
const session = JSON.parse(stored) as SessionState;
if (Math.floor(Date.now() / 1000) >= session.claims.exp) {
localStorage.removeItem(SESSION_KEY);
localStorage.removeItem("rspace-username");
return null;
}
return session;
} catch {
return null;
}
}
export function clearSession(): void {
localStorage.removeItem(SESSION_KEY);
localStorage.removeItem("rspace-username");
}
export function isAuthenticated(): boolean {
return getSession() !== null;
}
export function getAccessToken(): string | null {
return getSession()?.accessToken ?? null;
}
export function getUserDID(): string | null {
return getSession()?.claims.did ?? getSession()?.claims.sub ?? null;
}
export function getUsername(): string | null {
return getSession()?.claims.username ?? null;
}
// ── Helpers ──
function base64urlToBuffer(b64url: string): ArrayBuffer {
const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
const pad = "=".repeat((4 - (b64.length % 4)) % 4);
const bin = atob(b64 + pad);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes.buffer;
}
function bufferToBase64url(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf);
let bin = "";
for (let i = 0; i < bytes.byteLength; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
function parseJWT(token: string): Record<string, unknown> {
const parts = token.split(".");
if (parts.length < 2) return {};
try {
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
const pad = "=".repeat((4 - (b64.length % 4)) % 4);
return JSON.parse(atob(b64 + pad));
} catch {
return {};
}
}
function storeSession(token: string, username: string, did: string): void {
const payload = parseJWT(token) as Record<string, any>;
const session: SessionState = {
accessToken: token,
claims: {
sub: payload.sub || "",
exp: payload.exp || 0,
username,
did,
eid: payload.eid || { authLevel: 3, capabilities: { encrypt: true, sign: true, wallet: false } },
},
};
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
if (username) localStorage.setItem("rspace-username", username);
}
// ── Auto-space resolution after auth ──
function autoResolveSpace(token: string, username: string): void {
if (!username) return;
// Detect current space
const currentSpace = _getCurrentSpace();
if (currentSpace !== "demo") return; // Already on a non-demo space
// Provision personal space and redirect
fetch("/api/spaces/auto-provision", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
})
.then((r) => r.json())
.then((data) => {
if (!data.slug) return;
const moduleId = _getCurrentModule();
window.location.replace(_navUrl(data.slug, moduleId));
})
.catch(() => {});
}
// ── Inline URL helpers (avoid import cycle with url-helpers) ──
const _RESERVED = ["www", "rspace", "create", "new", "start", "auth"];
function _isSubdomain(): boolean {
const p = window.location.host.split(":")[0].split(".");
return p.length >= 3 && p.slice(-2).join(".") === "rspace.online" && !_RESERVED.includes(p[0]);
}
function _getCurrentSpace(): string {
if (_isSubdomain()) return window.location.host.split(":")[0].split(".")[0];
return window.location.pathname.split("/").filter(Boolean)[0] || "demo";
}
function _getCurrentModule(): string {
const parts = window.location.pathname.split("/").filter(Boolean);
return _isSubdomain() ? (parts[0] || "canvas") : (parts[1] || "canvas");
}
function _navUrl(space: string, moduleId: string): string {
const h = window.location.host.split(":")[0].split(".");
const onSub = h.length >= 3 && h.slice(-2).join(".") === "rspace.online" && !_RESERVED.includes(h[0]);
if (onSub) {
if (h[0] === space) return "/" + moduleId;
return window.location.protocol + "//" + space + "." + h.slice(-2).join(".") + "/" + moduleId;
}
if (window.location.host.includes("rspace.online") && !window.location.host.startsWith("www")) {
return window.location.protocol + "//" + space + ".rspace.online/" + moduleId;
}
return "/" + space + "/" + moduleId;
}
// ── The custom element ──
export class RStackIdentity extends HTMLElement {
#shadow: ShadowRoot;
constructor() {
super();
this.#shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.#render();
}
#render() {
const session = getSession();
const theme = this.closest("[data-theme]")?.getAttribute("data-theme") || "light";
if (session) {
const username = session.claims.username || "";
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();
this.#shadow.innerHTML = `
<style>${STYLES}</style>
<div class="user ${theme}" id="user-toggle">
<div class="avatar">${initial}</div>
<span class="name">${displayName}</span>
<div class="dropdown" id="dropdown">
<div class="dropdown-header">${displayName}</div>
<div class="dropdown-divider"></div>
<button class="dropdown-item" data-action="add-email">✉️ Add Email</button>
<button class="dropdown-item" data-action="add-device">📱 Add Second Device</button>
<button class="dropdown-item" data-action="add-recovery">🛡️ Add Social Recovery</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item dropdown-item--danger" data-action="signout">🚪 Sign Out</button>
</div>
</div>
`;
const toggle = this.#shadow.getElementById("user-toggle")!;
const dropdown = this.#shadow.getElementById("dropdown")!;
toggle.addEventListener("click", (e) => {
e.stopPropagation();
dropdown.classList.toggle("open");
});
document.addEventListener("click", () => dropdown.classList.remove("open"));
this.#shadow.querySelectorAll("[data-action]").forEach((el) => {
el.addEventListener("click", (e) => {
e.stopPropagation();
const action = (el as HTMLElement).dataset.action;
dropdown.classList.remove("open");
if (action === "signout") {
clearSession();
this.#render();
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
} else if (action === "add-email") {
this.#showAddEmailModal();
} else if (action === "add-device") {
this.#showAddDeviceModal();
} else if (action === "add-recovery") {
this.#showAddRecoveryModal();
}
});
});
} else {
this.#shadow.innerHTML = `
<style>${STYLES}</style>
<button class="signin-btn ${theme}" id="signin-btn">🔑 Sign In</button>
`;
this.#shadow.getElementById("signin-btn")!.addEventListener("click", () => {
this.showAuthModal();
});
}
}
/** Public method: show the auth modal programmatically */
showAuthModal(callbacks?: { onSuccess?: () => void; onCancel?: () => void }): void {
if (document.querySelector(".rstack-auth-overlay")) return;
const overlay = document.createElement("div");
overlay.className = "rstack-auth-overlay";
let mode: "signin" | "register" = "signin";
const render = () => {
overlay.innerHTML = mode === "signin" ? signinHTML() : registerHTML();
attachListeners();
};
const signinHTML = () => `
<style>${MODAL_STYLES}</style>
<div class="auth-modal">
<button class="close-btn" data-action="cancel">&times;</button>
<h2>Sign up / Sign in</h2>
<p>Secure, passwordless authentication powered by passkeys.</p>
<div class="actions actions--stack">
<button class="btn btn--primary" data-action="signin">🔑 Sign In with Passkey</button>
<button class="btn btn--outline" data-action="switch-register">🔐 Create New Account</button>
</div>
<div class="error" id="auth-error"></div>
<div class="learn-more">Powered by <a href="https://ridentity.online" target="_blank" rel="noopener">EncryptID</a></div>
</div>
`;
const registerHTML = () => `
<style>${MODAL_STYLES}</style>
<div class="auth-modal">
<button class="close-btn" data-action="cancel">&times;</button>
<h2>Create your EncryptID</h2>
<p>Set up a secure, passwordless identity.</p>
<input class="input" id="auth-username" type="text" placeholder="Choose a username" autocomplete="username webauthn" maxlength="32" />
<div class="actions">
<button class="btn btn--secondary" data-action="switch-signin">Back</button>
<button class="btn btn--primary" data-action="register">🔐 Create Passkey</button>
</div>
<div class="error" id="auth-error"></div>
<div class="learn-more">Powered by <a href="https://ridentity.online" target="_blank" rel="noopener">EncryptID</a></div>
</div>
`;
const close = () => {
overlay.remove();
};
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...';
try {
const startRes = await fetch(`${ENCRYPTID_URL}/api/auth/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (!startRes.ok) throw new Error("Failed to start authentication");
const { options: serverOptions } = await startRes.json();
const credential = (await navigator.credentials.get({
publicKey: {
challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)),
rpId: serverOptions.rpId || "rspace.online",
userVerification: "required",
timeout: 60000,
},
})) as PublicKeyCredential;
if (!credential) throw new Error("Authentication failed");
const completeRes = await fetch(`${ENCRYPTID_URL}/api/auth/complete`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
challenge: serverOptions.challenge,
credential: { credentialId: bufferToBase64url(credential.rawId) },
}),
});
const data = await completeRes.json();
if (!completeRes.ok || !data.success) throw new Error(data.error || "Authentication failed");
storeSession(data.token, data.username || "", data.did || "");
close();
this.#render();
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
callbacks?.onSuccess?.();
// 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.";
}
};
const handleRegister = async () => {
const usernameInput = overlay.querySelector("#auth-username") as HTMLInputElement;
const errEl = overlay.querySelector("#auth-error") as HTMLElement;
const btn = overlay.querySelector('[data-action="register"]') as HTMLButtonElement;
const username = usernameInput.value.trim();
if (!username) {
errEl.textContent = "Please enter a username.";
usernameInput.focus();
return;
}
errEl.textContent = "";
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Creating passkey...';
try {
const startRes = await fetch(`${ENCRYPTID_URL}/api/register/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, displayName: username }),
});
if (!startRes.ok) throw new Error("Failed to start registration");
const { options: serverOptions, userId } = await startRes.json();
const credential = (await navigator.credentials.create({
publicKey: {
challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)),
rp: { id: serverOptions.rp?.id || "rspace.online", name: serverOptions.rp?.name || "EncryptID" },
user: { id: new Uint8Array(base64urlToBuffer(serverOptions.user.id)), name: username, displayName: username },
pubKeyCredParams: serverOptions.pubKeyCredParams || [
{ alg: -7, type: "public-key" as const },
{ alg: -257, type: "public-key" as const },
],
authenticatorSelection: { residentKey: "required", requireResidentKey: true, userVerification: "required" },
attestation: "none",
timeout: 60000,
},
})) as PublicKeyCredential;
if (!credential) throw new Error("Failed to create credential");
const response = credential.response as AuthenticatorAttestationResponse;
const publicKey = response.getPublicKey?.();
const completeRes = await fetch(`${ENCRYPTID_URL}/api/register/complete`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
challenge: serverOptions.challenge,
credential: {
credentialId: bufferToBase64url(credential.rawId),
publicKey: publicKey ? bufferToBase64url(publicKey) : "",
transports: response.getTransports?.() || [],
},
userId,
username,
}),
});
const data = await completeRes.json();
if (!completeRes.ok || !data.success) throw new Error(data.error || "Registration failed");
storeSession(data.token, username, data.did || "");
close();
this.#render();
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
callbacks?.onSuccess?.();
// Auto-redirect to personal space
autoResolveSpace(data.token, username);
} catch (err: any) {
btn.disabled = false;
btn.innerHTML = "🔐 Create Passkey";
errEl.textContent = err.name === "NotAllowedError" ? "Passkey creation was cancelled." : err.message || "Registration failed.";
}
};
const attachListeners = () => {
overlay.querySelector('[data-action="cancel"]')?.addEventListener("click", () => {
close();
callbacks?.onCancel?.();
});
overlay.querySelector('[data-action="signin"]')?.addEventListener("click", handleSignIn);
overlay.querySelector('[data-action="register"]')?.addEventListener("click", handleRegister);
overlay.querySelector('[data-action="switch-register"]')?.addEventListener("click", () => {
mode = "register";
render();
setTimeout(() => (overlay.querySelector("#auth-username") as HTMLInputElement)?.focus(), 50);
});
overlay.querySelector('[data-action="switch-signin"]')?.addEventListener("click", () => {
mode = "signin";
render();
});
overlay.querySelector("#auth-username")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") handleRegister();
});
overlay.addEventListener("click", (e) => {
if (e.target === overlay) {
close();
callbacks?.onCancel?.();
}
});
};
document.body.appendChild(overlay);
render();
}
// ── Settings modals ──
#showAddEmailModal(): void {
if (document.querySelector(".rstack-auth-overlay")) return;
const overlay = document.createElement("div");
overlay.className = "rstack-auth-overlay";
let step: "input" | "verify" = "input";
let emailAddr = "";
const render = () => {
overlay.innerHTML = step === "input" ? `
<style>${MODAL_STYLES}${SETTINGS_STYLES}</style>
<div class="auth-modal">
<button class="close-btn" data-action="close">&times;</button>
<h2>Add Email</h2>
<p>Link an email for notifications and account recovery.</p>
<input class="input" id="s-email" type="email" placeholder="you@example.com" />
<div class="actions">
<button class="btn btn--primary" data-action="send-code">Send Verification Code</button>
</div>
<div class="error" id="s-error"></div>
</div>
` : `
<style>${MODAL_STYLES}${SETTINGS_STYLES}</style>
<div class="auth-modal">
<button class="close-btn" data-action="close">&times;</button>
<h2>Verify Email</h2>
<p>Enter the 6-digit code sent to <strong>${emailAddr.replace(/</g, "&lt;")}</strong></p>
<input class="input" id="s-code" type="text" placeholder="000000" maxlength="6" inputmode="numeric" />
<div class="actions">
<button class="btn btn--secondary" data-action="back">Back</button>
<button class="btn btn--primary" data-action="verify">Verify</button>
</div>
<div class="error" id="s-error"></div>
</div>
`;
attach();
};
const close = () => overlay.remove();
const attach = () => {
overlay.querySelector('[data-action="close"]')?.addEventListener("click", close);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
overlay.querySelector('[data-action="send-code"]')?.addEventListener("click", async () => {
const input = overlay.querySelector("#s-email") as HTMLInputElement;
const err = overlay.querySelector("#s-error") as HTMLElement;
const btn = overlay.querySelector('[data-action="send-code"]') as HTMLButtonElement;
emailAddr = input.value.trim();
if (!emailAddr || !emailAddr.includes("@")) { err.textContent = "Enter a valid email address."; input.focus(); return; }
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Sending...';
try {
const res = await fetch(`${ENCRYPTID_URL}/api/account/email/start`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
body: JSON.stringify({ email: emailAddr }),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "Failed to send verification code");
step = "verify"; render();
setTimeout(() => (overlay.querySelector("#s-code") as HTMLInputElement)?.focus(), 50);
} catch (e: any) {
btn.disabled = false; btn.innerHTML = "Send Verification Code";
err.textContent = e.message;
}
});
overlay.querySelector('[data-action="back"]')?.addEventListener("click", () => { step = "input"; render(); });
overlay.querySelector('[data-action="verify"]')?.addEventListener("click", async () => {
const input = overlay.querySelector("#s-code") as HTMLInputElement;
const err = overlay.querySelector("#s-error") as HTMLElement;
const btn = overlay.querySelector('[data-action="verify"]') as HTMLButtonElement;
const code = input.value.trim();
if (!code) { err.textContent = "Enter the verification code."; input.focus(); return; }
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Verifying...';
try {
const res = await fetch(`${ENCRYPTID_URL}/api/account/email/verify`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
body: JSON.stringify({ email: emailAddr, code }),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "Verification failed");
close();
this.dispatchEvent(new CustomEvent("identity-action", { bubbles: true, composed: true, detail: { action: "email-added", email: emailAddr } }));
} catch (e: any) {
btn.disabled = false; btn.innerHTML = "Verify";
err.textContent = e.message;
}
});
overlay.querySelector("#s-email")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") (overlay.querySelector('[data-action="send-code"]') as HTMLElement)?.click();
});
overlay.querySelector("#s-code")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") (overlay.querySelector('[data-action="verify"]') as HTMLElement)?.click();
});
};
document.body.appendChild(overlay);
render();
}
#showAddDeviceModal(): void {
if (document.querySelector(".rstack-auth-overlay")) return;
const session = getSession();
if (!session) return;
const overlay = document.createElement("div");
overlay.className = "rstack-auth-overlay";
overlay.innerHTML = `
<style>${MODAL_STYLES}${SETTINGS_STYLES}</style>
<div class="auth-modal">
<button class="close-btn" data-action="close">&times;</button>
<h2>Add Second Device</h2>
<p>Register an additional passkey for backup access. Use this on a different device or browser.</p>
<div class="actions actions--stack">
<button class="btn btn--primary" data-action="register-device">🔑 Register Passkey on This Device</button>
</div>
<div class="error" id="s-error"></div>
<div class="info-text">Each device you register can independently sign in to your account.</div>
</div>
`;
const close = () => overlay.remove();
overlay.querySelector('[data-action="close"]')?.addEventListener("click", close);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
overlay.querySelector('[data-action="register-device"]')?.addEventListener("click", async () => {
const err = overlay.querySelector("#s-error") as HTMLElement;
const btn = overlay.querySelector('[data-action="register-device"]') as HTMLButtonElement;
err.textContent = "";
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Registering...';
try {
const startRes = await fetch(`${ENCRYPTID_URL}/api/account/device/start`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
});
if (!startRes.ok) throw new Error((await startRes.json().catch(() => ({}))).error || "Failed to start device registration");
const { options: serverOptions, userId } = await startRes.json();
const username = session.claims.username || "";
const credential = (await navigator.credentials.create({
publicKey: {
challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)),
rp: { id: serverOptions.rp?.id || "rspace.online", name: serverOptions.rp?.name || "EncryptID" },
user: {
id: new Uint8Array(base64urlToBuffer(serverOptions.user?.id || userId)),
name: username || session.claims.sub,
displayName: username || session.claims.sub,
},
pubKeyCredParams: serverOptions.pubKeyCredParams || [
{ alg: -7, type: "public-key" as const },
{ alg: -257, type: "public-key" as const },
],
authenticatorSelection: { residentKey: "required", requireResidentKey: true, userVerification: "required" },
attestation: "none",
timeout: 60000,
},
})) as PublicKeyCredential;
if (!credential) throw new Error("Passkey creation failed");
const response = credential.response as AuthenticatorAttestationResponse;
const publicKey = response.getPublicKey?.();
const completeRes = await fetch(`${ENCRYPTID_URL}/api/account/device/complete`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
body: JSON.stringify({
challenge: serverOptions.challenge,
credential: {
credentialId: bufferToBase64url(credential.rawId),
publicKey: publicKey ? bufferToBase64url(publicKey) : "",
transports: response.getTransports?.() || [],
},
}),
});
if (!completeRes.ok) throw new Error((await completeRes.json().catch(() => ({}))).error || "Device registration failed");
btn.innerHTML = "Device Registered";
btn.className = "btn btn--success";
this.dispatchEvent(new CustomEvent("identity-action", { bubbles: true, composed: true, detail: { action: "device-added" } }));
setTimeout(close, 1500);
} catch (e: any) {
btn.disabled = false; btn.innerHTML = "🔑 Register Passkey on This Device";
err.textContent = e.name === "NotAllowedError" ? "Passkey creation was cancelled." : e.message;
}
});
document.body.appendChild(overlay);
}
#showAddRecoveryModal(): void {
if (document.querySelector(".rstack-auth-overlay")) return;
const overlay = document.createElement("div");
overlay.className = "rstack-auth-overlay";
let guardians: { id: string; name: string; email?: string; status: string }[] = [];
let threshold = 2;
let loading = true;
const render = () => {
const guardiansHTML = guardians.length > 0
? `<div class="contact-list">${guardians.map(g => `
<div class="contact-item">
<div style="display:flex;flex-direction:column;gap:2px;min-width:0;flex:1">
<span>${g.name.replace(/</g, "&lt;")}${g.email ? ` <span style="opacity:0.5;font-size:0.8rem">${g.email.replace(/</g, "&lt;")}</span>` : ""}</span>
<span style="font-size:0.7rem;color:${g.status === "accepted" ? "#34d399" : "#fbbf24"}">${g.status === "accepted" ? "Accepted" : "Pending invite"}</span>
</div>
<button class="contact-remove" data-remove-id="${g.id}">&times;</button>
</div>
`).join("")}</div>`
: "";
const infoHTML = guardians.length < 2
? `<div class="info-text">Add at least 2 trusted guardians to enable social recovery. Threshold: ${threshold} of ${Math.max(guardians.length, 2)} needed to recover.</div>`
: `<div class="info-text" style="color:#34d399">Social recovery is active. ${threshold} of ${guardians.length} guardians needed to recover your account.</div>`;
overlay.innerHTML = `
<style>${MODAL_STYLES}${SETTINGS_STYLES}</style>
<div class="auth-modal">
<button class="close-btn" data-action="close">&times;</button>
<h2>Social Recovery</h2>
<p>Choose trusted contacts who can help recover your account.</p>
${loading ? '<div style="text-align:center;padding:1rem;color:#94a3b8"><span class="spinner"></span> Loading guardians...</div>' : `
${guardians.length < 3 ? `<div class="input-row">
<input class="input input--inline" id="s-name" type="text" placeholder="Guardian name" />
<input class="input input--inline" id="s-email" type="email" placeholder="Email (optional)" />
<button class="btn btn--small btn--primary" data-action="add-guardian">Add</button>
</div>` : ""}
${guardiansHTML}
${infoHTML}
`}
<div class="error" id="s-error"></div>
</div>
`;
attach();
};
const close = () => overlay.remove();
const loadGuardians = async () => {
try {
const res = await fetch(`${ENCRYPTID_URL}/api/guardians`, {
headers: { Authorization: `Bearer ${getAccessToken()}` },
});
if (res.ok) {
const data = await res.json();
guardians = data.guardians || [];
threshold = data.threshold || 2;
}
} catch { /* offline */ }
loading = false;
render();
};
const attach = () => {
overlay.querySelector('[data-action="close"]')?.addEventListener("click", close);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
overlay.querySelector('[data-action="add-guardian"]')?.addEventListener("click", async () => {
const nameInput = overlay.querySelector("#s-name") as HTMLInputElement;
const emailInput = overlay.querySelector("#s-email") as HTMLInputElement;
const err = overlay.querySelector("#s-error") as HTMLElement;
const btn = overlay.querySelector('[data-action="add-guardian"]') as HTMLButtonElement;
const name = nameInput.value.trim();
const email = emailInput.value.trim();
if (!name) { err.textContent = "Enter a guardian name."; nameInput.focus(); return; }
err.textContent = "";
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span>';
try {
const res = await fetch(`${ENCRYPTID_URL}/api/guardians`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
body: JSON.stringify({ name, email: email || undefined }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to add guardian");
guardians.push({ id: data.guardian.id, name: data.guardian.name, email: data.guardian.email, status: data.guardian.status });
render();
setTimeout(() => (overlay.querySelector("#s-name") as HTMLInputElement)?.focus(), 50);
} catch (e: any) {
btn.disabled = false; btn.innerHTML = "Add";
err.textContent = e.message;
}
});
overlay.querySelector("#s-name")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") (overlay.querySelector('[data-action="add-guardian"]') as HTMLElement)?.click();
});
overlay.querySelector("#s-email")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") (overlay.querySelector('[data-action="add-guardian"]') as HTMLElement)?.click();
});
overlay.querySelectorAll("[data-remove-id]").forEach(el => {
el.addEventListener("click", async () => {
const id = (el as HTMLElement).dataset.removeId!;
const err = overlay.querySelector("#s-error") as HTMLElement;
try {
const res = await fetch(`${ENCRYPTID_URL}/api/guardians/${id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${getAccessToken()}` },
});
if (!res.ok) throw new Error("Failed to remove guardian");
guardians = guardians.filter(g => g.id !== id);
render();
} catch (e: any) {
err.textContent = e.message;
}
});
});
};
document.body.appendChild(overlay);
render();
loadGuardians();
}
static define(tag = "rstack-identity") {
if (!customElements.get(tag)) customElements.define(tag, RStackIdentity);
}
}
// ── Require auth helper (for use by module code) ──
export function requireAuth(onAuthenticated: () => void): boolean {
if (isAuthenticated()) return true;
const el = document.querySelector("rstack-identity") as RStackIdentity | null;
if (el) {
el.showAuthModal({ onSuccess: onAuthenticated });
}
return false;
}
// ── Styles ──
const STYLES = `
:host { display: contents; }
.signin-btn {
display: flex; align-items: center; gap: 8px;
padding: 8px 20px; border-radius: 8px; border: none;
font-size: 0.875rem; font-weight: 600; cursor: pointer;
transition: all 0.2s; text-decoration: none;
}
.signin-btn.light {
background: linear-gradient(135deg, #06b6d4, #0891b2); color: white;
}
.signin-btn.dark {
background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white;
}
.signin-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(6,182,212,0.3); }
.user {
display: flex; align-items: center; gap: 10px;
position: relative; cursor: pointer;
}
.avatar {
width: 34px; height: 34px; border-radius: 50%;
background: linear-gradient(135deg, #06b6d4, #7c3aed);
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 0.8rem; color: white;
}
.name {
font-size: 0.8rem; max-width: 140px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.user.light .name { color: #64748b; }
.user.dark .name { color: #94a3b8; }
.dropdown {
position: absolute; top: 100%; right: 0; margin-top: 8px;
min-width: 200px; border-radius: 10px; overflow: hidden;
box-shadow: 0 8px 30px rgba(0,0,0,0.2); display: none; z-index: 100;
}
.dropdown.open { display: block; }
.user.light .dropdown { background: white; border: 1px solid rgba(0,0,0,0.1); }
.user.dark .dropdown { background: #1e293b; border: 1px solid rgba(255,255,255,0.1); }
.dropdown-item {
display: flex; align-items: center; gap: 10px;
padding: 12px 16px; font-size: 0.875rem; cursor: pointer;
transition: background 0.15s; border: none; background: none;
width: 100%; text-align: left;
}
.user.light .dropdown-item { color: #374151; }
.user.light .dropdown-item:hover { background: #f1f5f9; }
.user.dark .dropdown-item { color: #e2e8f0; }
.user.dark .dropdown-item:hover { background: rgba(255,255,255,0.05); }
.dropdown-item--danger { color: #ef4444 !important; }
.dropdown-header {
padding: 10px 16px 6px; font-size: 0.8rem; font-weight: 700;
letter-spacing: 0.02em; white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; max-width: 200px;
}
.user.light .dropdown-header { color: #1e293b; }
.user.dark .dropdown-header { color: #e2e8f0; }
.dropdown-divider { height: 1px; margin: 4px 0; }
.user.light .dropdown-divider { background: rgba(0,0,0,0.08); }
.user.dark .dropdown-divider { background: rgba(255,255,255,0.08); }
`;
const MODAL_STYLES = `
.rstack-auth-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
backdrop-filter: blur(4px); display: flex; align-items: center;
justify-content: center; z-index: 10000; animation: fadeIn 0.2s;
}
.auth-modal {
background: #1e293b; border: 1px solid rgba(255,255,255,0.1);
border-radius: 16px; padding: 2rem; max-width: 420px; width: 90%;
text-align: center; color: white; box-shadow: 0 20px 60px rgba(0,0,0,0.4);
animation: slideUp 0.3s;
}
.auth-modal h2 {
font-size: 1.5rem; margin-bottom: 0.5rem;
background: linear-gradient(135deg, #06b6d4, #7c3aed);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.auth-modal p { color: #94a3b8; font-size: 0.95rem; line-height: 1.6; margin-bottom: 1.5rem; }
.input {
width: 100%; padding: 12px 16px; border-radius: 8px;
border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.05);
color: white; font-size: 1rem; margin-bottom: 1rem; outline: none;
transition: border-color 0.2s; box-sizing: border-box;
}
.input:focus { border-color: #06b6d4; }
.input::placeholder { color: #64748b; }
.actions { display: flex; gap: 12px; margin-top: 0.5rem; }
.btn {
flex: 1; padding: 12px 20px; border-radius: 8px; border: none;
font-size: 0.95rem; font-weight: 600; cursor: pointer; transition: all 0.2s;
}
.btn--primary { background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white; }
.btn--primary:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(6,182,212,0.3); }
.btn--primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.btn--secondary { background: rgba(255,255,255,0.08); color: #94a3b8; border: 1px solid rgba(255,255,255,0.1); }
.btn--secondary:hover { background: rgba(255,255,255,0.12); color: white; }
.error { color: #ef4444; font-size: 0.85rem; margin-top: 0.5rem; min-height: 1.2em; }
.toggle { margin-top: 1rem; font-size: 0.85rem; color: #64748b; }
.toggle a { color: #06b6d4; cursor: pointer; text-decoration: none; }
.toggle a:hover { text-decoration: underline; }
.spinner {
display: inline-block; width: 18px; height: 18px;
border: 2px solid transparent; border-top-color: currentColor;
border-radius: 50%; animation: spin 0.7s linear infinite;
vertical-align: middle; margin-right: 6px;
}
.close-btn {
position: absolute; top: 12px; right: 16px;
background: none; border: none; color: #64748b; font-size: 1.5rem;
cursor: pointer; line-height: 1; padding: 4px 8px; border-radius: 6px;
transition: all 0.15s;
}
.close-btn:hover { color: white; background: rgba(255,255,255,0.1); }
.auth-modal { position: relative; }
.actions--stack { flex-direction: column; }
.btn--outline {
background: transparent; color: #94a3b8;
border: 1px solid rgba(255,255,255,0.15);
padding: 12px 20px; border-radius: 8px; font-size: 0.95rem;
font-weight: 600; cursor: pointer; transition: all 0.2s;
}
.btn--outline:hover { border-color: #06b6d4; color: white; background: rgba(6,182,212,0.08); }
.learn-more { margin-top: 1.5rem; font-size: 0.8rem; color: #475569; }
.learn-more a { color: #06b6d4; text-decoration: none; }
.learn-more a:hover { text-decoration: underline; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
@keyframes spin { to { transform: rotate(360deg); } }
`;
const SETTINGS_STYLES = `
.info-text { margin-top: 1rem; font-size: 0.8rem; color: #475569; line-height: 1.5; }
.btn--success { background: #059669 !important; color: white; cursor: default; }
.btn--small { padding: 10px 16px; flex: none; }
.input-row { display: flex; gap: 8px; align-items: stretch; }
.input--inline { flex: 1; margin-bottom: 0; }
.contact-list { margin-top: 12px; display: flex; flex-direction: column; gap: 6px; }
.contact-item {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 12px; border-radius: 8px; background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1); font-size: 0.9rem; color: #e2e8f0;
}
.contact-remove {
background: none; border: none; color: #64748b; font-size: 1.2rem;
cursor: pointer; padding: 2px 6px; border-radius: 4px; line-height: 1;
}
.contact-remove:hover { color: #ef4444; background: rgba(239,68,68,0.1); }
.threshold-row {
display: flex; align-items: center; gap: 8px; margin-top: 12px;
font-size: 0.85rem; color: #94a3b8;
}
.threshold-row label { white-space: nowrap; }
.threshold-row select {
padding: 6px 10px; border-radius: 6px; background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.15); color: white; font-size: 0.85rem;
}
.threshold-hint { color: #64748b; font-size: 0.8rem; }
`;