Files
cairnquire/apps/server/internal/httpserver/static/auth.js
2026-05-31 23:08:53 -04:00

356 lines
14 KiB
JavaScript

(() => {
// 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());
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;
}),
);
};
loadTokens().catch(() => {});
loadAdminUsers().catch(() => {});
})();