(() => { // Tab switching document.querySelectorAll('.auth-tab-list').forEach(tabList => { const tabs = tabList.querySelectorAll('[data-tab]'); const panels = tabList.closest('.auth-tabs').querySelectorAll('[data-tab-panel]'); tabs.forEach(tab => { tab.addEventListener('click', () => { const target = tab.dataset.tab; tabs.forEach(t => { t.classList.remove('is-active'); t.setAttribute('aria-selected', 'false'); }); tab.classList.add('is-active'); tab.setAttribute('aria-selected', 'true'); panels.forEach(p => { if (p.dataset.tabPanel === target) { p.classList.add('is-active'); p.hidden = false; } else { p.classList.remove('is-active'); p.hidden = true; } }); }); }); }); // Register toggle const registerToggle = document.querySelector('[data-register-toggle]'); const registerPanel = document.querySelector('[data-register-panel]'); const loginSection = document.querySelector('[data-login-section]'); const registerCancel = document.querySelector('[data-register-cancel]'); if (registerToggle && registerPanel && loginSection) { registerToggle.addEventListener('click', () => { loginSection.hidden = true; registerPanel.hidden = false; }); } if (registerCancel && registerPanel && loginSection) { registerCancel.addEventListener('click', () => { registerPanel.hidden = true; loginSection.hidden = false; // Clear any registration form status messages document.querySelector('[data-passkey-register-status]').textContent = ''; document.querySelector('[data-register-status]').textContent = ''; }); } const jsonHeaders = { "Content-Type": "application/json" }; const postJSON = async (url, body, options = {}) => { const response = await fetch(url, { method: options.method || "POST", headers: jsonHeaders, credentials: "same-origin", body: body === undefined ? undefined : JSON.stringify(body), }); const data = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(data.error || `Request failed (${response.status})`); } return data; }; const setStatus = (selector, message, isError = false) => { const target = document.querySelector(selector); if (!target) return; target.textContent = message; target.dataset.state = isError ? "error" : "ok"; }; const authNext = () => document.querySelector("[data-auth-next]")?.value || "/account"; const formJSON = (form) => Object.fromEntries(new FormData(form).entries()); document.querySelector("[data-initial-setup]")?.addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget; try { await postJSON("/api/setup", { email: form.elements.email.value, displayName: form.elements.displayName.value, password: form.elements.password.value, signupsEnabled: form.elements.signupsEnabled.checked, }); window.location.href = "/account"; } catch (error) { setStatus("[data-setup-status]", error.message, true); } }); const base64URLToBuffer = (value) => { const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "="); const binary = atob(padded); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); return bytes.buffer; }; const bufferToBase64URL = (value) => { const bytes = new Uint8Array(value); let binary = ""; for (const byte of bytes) binary += String.fromCharCode(byte); return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); }; const credentialToJSON = (credential) => { if (credential instanceof ArrayBuffer) return bufferToBase64URL(credential); if (ArrayBuffer.isView(credential)) return bufferToBase64URL(credential.buffer); if (Array.isArray(credential)) return credential.map(credentialToJSON); if (credential && typeof credential === "object") { const next = {}; for (const [key, value] of Object.entries(credential)) next[key] = credentialToJSON(value); return next; } return credential; }; const normalizeCreateOptions = (options) => { const publicKey = { ...options.publicKey }; publicKey.challenge = base64URLToBuffer(publicKey.challenge); publicKey.user = { ...publicKey.user, id: base64URLToBuffer(publicKey.user.id) }; publicKey.excludeCredentials = (publicKey.excludeCredentials || []).map((credential) => ({ ...credential, id: base64URLToBuffer(credential.id), })); return { publicKey }; }; const normalizeRequestOptions = (options) => { const publicKey = { ...options.publicKey }; publicKey.challenge = base64URLToBuffer(publicKey.challenge); publicKey.allowCredentials = (publicKey.allowCredentials || []).map((credential) => ({ ...credential, id: base64URLToBuffer(credential.id), })); return { publicKey }; }; document.querySelector("[data-auth-login]")?.addEventListener("submit", async (event) => { event.preventDefault(); try { await postJSON("/api/auth/login/password", formJSON(event.currentTarget)); window.location.href = authNext(); } catch (error) { setStatus("[data-login-status]", error.message, true); } }); document.querySelector("[data-auth-register]")?.addEventListener("submit", async (event) => { event.preventDefault(); try { await postJSON("/api/auth/register/password", formJSON(event.currentTarget)); setStatus("[data-register-status]", "Account created. Sign in with your password."); event.currentTarget.reset(); } catch (error) { setStatus("[data-register-status]", error.message, true); } }); const webAuthnErrorMessage = (error) => { console.error("WebAuthn error:", error.name, error.message, error); switch (error.name) { case "NotAllowedError": return "Passkey creation was cancelled or no authenticator is available. If you dismissed a prompt, try again. If no prompt appeared, your device may not support passkeys."; case "NotSupportedError": return "This browser or device does not support passkeys."; case "SecurityError": return "Passkeys require a secure context. Use https:// or localhost."; case "InvalidStateError": return "A passkey may already exist for this account."; default: return error.message || "Passkey failed. Check the console for details."; } }; const checkPasskeySupport = async () => { if (!window.PublicKeyCredential) { return { supported: false, reason: "Passkeys are not available in this browser." }; } try { if (PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable) { const available = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); if (!available) { return { supported: true, reason: "No platform authenticator found (e.g., Touch ID, Windows Hello). You can still use a security key if you have one." }; } } return { supported: true }; } catch { return { supported: true }; } }; document.querySelector("[data-passkey-register]")?.addEventListener("submit", async (event) => { event.preventDefault(); const support = await checkPasskeySupport(); if (!support.supported) { setStatus("[data-passkey-register-status]", support.reason, true); return; } if (support.reason) { console.warn("Passkey support:", support.reason); } try { const begin = await postJSON("/api/auth/passkeys/register/begin", formJSON(event.currentTarget)); const credential = await navigator.credentials.create(normalizeCreateOptions(begin.options)); await postJSON(`/api/auth/passkeys/register/finish?challengeId=${encodeURIComponent(begin.challengeId)}`, credentialToJSON(credential)); setStatus("[data-passkey-register-status]", "Passkey created. Sign in with your passkey."); } catch (error) { setStatus("[data-passkey-register-status]", webAuthnErrorMessage(error), true); } }); document.querySelector("[data-passkey-login]")?.addEventListener("submit", async (event) => { event.preventDefault(); const support = await checkPasskeySupport(); if (!support.supported) { setStatus("[data-passkey-login-status]", support.reason, true); return; } if (support.reason) { console.warn("Passkey support:", support.reason); } try { const begin = await postJSON("/api/auth/passkeys/login/begin", formJSON(event.currentTarget)); const credential = await navigator.credentials.get(normalizeRequestOptions(begin.options)); await postJSON(`/api/auth/passkeys/login/finish?challengeId=${encodeURIComponent(begin.challengeId)}`, credentialToJSON(credential)); window.location.href = authNext(); } catch (error) { setStatus("[data-passkey-login-status]", webAuthnErrorMessage(error), true); } }); document.querySelector("[data-auth-logout]")?.addEventListener("click", async () => { await postJSON("/api/auth/logout", {}); window.location.href = "/login"; }); const loadTokens = async () => { const list = document.querySelector("[data-token-list]"); if (!list) return; const data = await fetch("/api/tokens", { credentials: "same-origin" }).then((response) => response.json()); list.replaceChildren( ...(data.tokens || []).map((token) => { const item = document.createElement("li"); const meta = document.createElement("span"); meta.textContent = `${token.name} - ${token.scopes.join(", ")}${token.revokedAt ? " - revoked" : ""}`; const button = document.createElement("button"); button.type = "button"; button.textContent = "Revoke"; button.disabled = Boolean(token.revokedAt); button.addEventListener("click", async () => { await fetch(`/api/tokens/${encodeURIComponent(token.id.replace(/^api:/, ""))}`, { method: "DELETE", credentials: "same-origin", }); await loadTokens(); }); item.append(meta, button); return item; }), ); }; document.querySelector("[data-token-create]")?.addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget; const scopes = [...form.querySelectorAll('input[name="scope"]:checked')].map((input) => input.value); try { const created = await postJSON("/api/tokens", { name: form.elements.name.value, scopes }); const output = document.querySelector("[data-token-output]"); if (output) { output.hidden = false; output.textContent = created.token; } setStatus("[data-token-status]", "Token created. Copy it now; it will not be shown again."); form.reset(); await loadTokens(); } catch (error) { setStatus("[data-token-status]", error.message, true); } }); document.querySelector("[data-password-change]")?.addEventListener("submit", async (event) => { event.preventDefault(); try { await postJSON("/api/auth/password/change", formJSON(event.currentTarget)); setStatus("[data-password-status]", "Password changed."); event.currentTarget.reset(); } catch (error) { setStatus("[data-password-status]", error.message, true); } }); document.querySelector("[data-account-delete]")?.addEventListener("submit", async (event) => { event.preventDefault(); if (!confirm("Delete this account permanently?")) return; try { await postJSON("/api/auth/account", formJSON(event.currentTarget), { method: "DELETE" }); window.location.href = "/login"; } catch (error) { setStatus("[data-delete-status]", error.message, true); } }); document.querySelector("[data-device-approve]")?.addEventListener("submit", async (event) => { event.preventDefault(); try { await postJSON("/api/device/approve", formJSON(event.currentTarget)); setStatus("[data-device-status]", "Device approved."); } catch (error) { if (error.message === "authentication required") { window.location.href = `/login?next=${encodeURIComponent(window.location.pathname + window.location.search)}`; return; } setStatus("[data-device-status]", error.message, true); } }); const loadAdminUsers = async () => { const section = document.querySelector("[data-admin-users-section]"); const list = document.querySelector("[data-admin-users]"); if (!section || !list) return; const me = await fetch("/api/auth/me", { credentials: "same-origin" }).then((response) => response.json()).catch(() => null); if (me?.principal?.role !== "admin") return; section.hidden = false; const data = await fetch("/api/admin/auth-users", { credentials: "same-origin" }).then((response) => response.json()); list.replaceChildren( ...(data.users || []).map((user) => { const row = document.createElement("form"); row.className = "user-row"; const identity = document.createElement("span"); const name = document.createElement("strong"); name.textContent = user.displayName; const email = document.createElement("small"); email.textContent = user.email; identity.append(name, email); const select = document.createElement("select"); for (const role of ["viewer", "editor", "admin"]) { const option = document.createElement("option"); option.value = role; option.textContent = role; option.selected = user.role === role; select.append(option); } const button = document.createElement("button"); button.type = "submit"; button.textContent = "Save"; row.append(identity, select, button); row.addEventListener("submit", async (event) => { event.preventDefault(); try { await postJSON(`/api/admin/users/${encodeURIComponent(user.id)}/role`, { role: select.value }, { method: "PATCH" }); setStatus("[data-admin-users-status]", "Role updated."); } catch (error) { setStatus("[data-admin-users-status]", error.message, true); } }); return row; }), ); }; const loadAdminSettings = async () => { const section = document.querySelector("[data-admin-settings-section]"); const form = document.querySelector("[data-admin-settings]"); if (!section || !form) return; const me = await fetch("/api/auth/me", { credentials: "same-origin" }).then((response) => response.json()).catch(() => null); if (me?.principal?.role !== "admin") return; section.hidden = false; const data = await fetch("/api/admin/settings", { credentials: "same-origin" }).then((response) => response.json()); form.elements.signupsEnabled.checked = Boolean(data.settings?.signupsEnabled); }; document.querySelector("[data-admin-settings]")?.addEventListener("submit", async (event) => { event.preventDefault(); try { const form = event.currentTarget; await postJSON("/api/admin/settings", { signupsEnabled: form.elements.signupsEnabled.checked }, { method: "PATCH" }); setStatus("[data-admin-settings-status]", "Registration setting saved."); } catch (error) { setStatus("[data-admin-settings-status]", error.message, true); } }); loadTokens().catch(() => {}); loadAdminUsers().catch(() => {}); loadAdminSettings().catch(() => {}); })();