468 lines
19 KiB
JavaScript
468 lines
19 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]');
|
|
|
|
const authHeading = document.querySelector('[data-auth-heading]');
|
|
|
|
if (registerToggle && registerPanel && loginSection) {
|
|
registerToggle.addEventListener('click', () => {
|
|
loginSection.hidden = true;
|
|
registerPanel.hidden = false;
|
|
if (authHeading) authHeading.textContent = 'Create an Account';
|
|
document.title = 'Create an Account';
|
|
});
|
|
}
|
|
|
|
if (registerCancel && registerPanel && loginSection) {
|
|
registerCancel.addEventListener('click', () => {
|
|
registerPanel.hidden = true;
|
|
loginSection.hidden = false;
|
|
if (authHeading) authHeading.textContent = 'Log In';
|
|
document.title = 'Sign in';
|
|
// 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-dev-login]")?.addEventListener("click", async () => {
|
|
try {
|
|
await postJSON("/api/auth/login/dev", {});
|
|
window.location.href = authNext();
|
|
} catch (error) {
|
|
setStatus("[data-dev-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 () => {
|
|
try {
|
|
await postJSON("/api/auth/logout", {});
|
|
} catch (error) {
|
|
console.warn("Logout request failed:", error);
|
|
} finally {
|
|
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());
|
|
if (!data.tokens?.length) {
|
|
const empty = document.createElement("li");
|
|
empty.className = "token-empty";
|
|
empty.textContent = "No access tokens yet. Create one when you connect the macOS app, CLI, or another trusted client.";
|
|
list.replaceChildren(empty);
|
|
return;
|
|
}
|
|
list.replaceChildren(
|
|
...data.tokens.map((token) => {
|
|
const item = document.createElement("li");
|
|
item.className = `token-row${token.revokedAt ? " is-revoked" : ""}`;
|
|
const meta = document.createElement("span");
|
|
meta.className = "token-meta";
|
|
const name = document.createElement("strong");
|
|
name.textContent = token.name;
|
|
const details = document.createElement("small");
|
|
details.textContent = `${token.scopes.join(", ")}${token.revokedAt ? " - revoked" : ""}`;
|
|
meta.append(name, details);
|
|
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-password-reset]")?.addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
try {
|
|
await postJSON("/api/auth/password/reset", formJSON(event.currentTarget));
|
|
setStatus("[data-password-reset-status]", "Password reset. You can sign in with your new password.");
|
|
event.currentTarget.querySelector('input[name="newPassword"]').value = "";
|
|
} catch (error) {
|
|
setStatus("[data-password-reset-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-user-list]");
|
|
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("div");
|
|
row.className = "user-row";
|
|
row.dataset.userId = user.id;
|
|
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);
|
|
}
|
|
select.name = "role";
|
|
select.setAttribute("aria-label", `Role for ${user.displayName}`);
|
|
const disabledLabel = document.createElement("label");
|
|
disabledLabel.className = "user-disable-toggle";
|
|
const disabled = document.createElement("input");
|
|
disabled.type = "checkbox";
|
|
disabled.name = "disabled";
|
|
disabled.checked = Boolean(user.disabled);
|
|
const disabledText = document.createElement("span");
|
|
disabledText.textContent = "Disabled";
|
|
disabledLabel.append(disabled, disabledText);
|
|
const reset = document.createElement("button");
|
|
reset.type = "button";
|
|
reset.textContent = "Send password reset";
|
|
reset.addEventListener("click", async () => {
|
|
if (!confirm(`Send a new password reset email to ${user.email}? Any earlier reset link will stop working.`)) return;
|
|
try {
|
|
await postJSON(`/api/admin/auth-users/${encodeURIComponent(user.id)}/password-reset`, {});
|
|
setStatus("[data-admin-users-status]", `Password reset email sent to ${user.email}.`);
|
|
} catch (error) {
|
|
setStatus("[data-admin-users-status]", error.message, true);
|
|
}
|
|
});
|
|
row.append(identity, select, disabledLabel, reset);
|
|
return row;
|
|
}),
|
|
);
|
|
};
|
|
|
|
document.querySelector("[data-admin-users]")?.addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
const form = event.currentTarget;
|
|
const users = [...form.querySelectorAll("[data-user-id]")].map((row) => ({
|
|
id: row.dataset.userId,
|
|
role: row.querySelector('select[name="role"]').value,
|
|
disabled: row.querySelector('input[name="disabled"]').checked,
|
|
}));
|
|
try {
|
|
await postJSON("/api/admin/auth-users", { users }, { method: "PATCH" });
|
|
setStatus("[data-admin-users-status]", "User access settings saved.");
|
|
await loadAdminUsers();
|
|
} catch (error) {
|
|
setStatus("[data-admin-users-status]", error.message, true);
|
|
}
|
|
});
|
|
|
|
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(() => {});
|
|
})();
|