server auth
This commit is contained in:
262
apps/server/internal/httpserver/static/auth.js
Normal file
262
apps/server/internal/httpserver/static/auth.js
Normal file
@@ -0,0 +1,262 @@
|
||||
(() => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("[data-passkey-register]")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (!window.PublicKeyCredential) {
|
||||
setStatus("[data-passkey-register-status]", "Passkeys are not available in this browser.", true);
|
||||
return;
|
||||
}
|
||||
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]", error.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("[data-passkey-login]")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (!window.PublicKeyCredential) {
|
||||
setStatus("[data-passkey-login-status]", "Passkeys are not available in this browser.", true);
|
||||
return;
|
||||
}
|
||||
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]", error.message, 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(() => {});
|
||||
})();
|
||||
Reference in New Issue
Block a user