514 lines
18 KiB
TypeScript
514 lines
18 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);
|
|
}
|
|
|
|
// ── 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">
|
|
<button class="dropdown-item" data-action="profile">👤 Profile</button>
|
|
<button class="dropdown-item" data-action="recovery">🛡️ 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 === "profile") {
|
|
window.open(ENCRYPTID_URL, "_blank");
|
|
} else if (action === "recovery") {
|
|
window.open(`${ENCRYPTID_URL}/recover`, "_blank");
|
|
}
|
|
});
|
|
});
|
|
} 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">
|
|
<h2>Sign in with EncryptID</h2>
|
|
<p>Use your passkey to sign in. No passwords needed.</p>
|
|
<div class="actions">
|
|
<button class="btn btn--secondary" data-action="cancel">Cancel</button>
|
|
<button class="btn btn--primary" data-action="signin">🔑 Sign In with Passkey</button>
|
|
</div>
|
|
<div class="error" id="auth-error"></div>
|
|
<div class="toggle">Don't have an account? <a data-action="switch-register">Create one</a></div>
|
|
</div>
|
|
`;
|
|
|
|
const registerHTML = () => `
|
|
<style>${MODAL_STYLES}</style>
|
|
<div class="auth-modal">
|
|
<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="cancel">Cancel</button>
|
|
<button class="btn btn--primary" data-action="register">🔐 Create Passkey</button>
|
|
</div>
|
|
<div class="error" id="auth-error"></div>
|
|
<div class="toggle">Already have an account? <a data-action="switch-signin">Sign in</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?.();
|
|
} 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?.();
|
|
} 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();
|
|
}
|
|
|
|
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-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;
|
|
}
|
|
@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); } }
|
|
`;
|