server auth

This commit is contained in:
2026-05-28 08:35:50 -04:00
parent 3d44a392f1
commit fc63f9c44a
37 changed files with 3877 additions and 74 deletions

View 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(() => {});
})();

View File

@@ -5,6 +5,7 @@
const STORE_CONTENT = "content";
const STORE_PAGES = "pages";
const STORE_PENDING_EDITS = "pending_edits";
const CACHE_ENTRY_LIMIT = 100;
function openDB() {
return new Promise(function (resolve, reject) {
@@ -39,6 +40,27 @@
return path.replace(/\/$/, "") || "/";
}
function pruneStore(store, limit) {
const request = store.getAll();
request.onsuccess = function () {
const records = request.result || [];
if (records.length <= limit) {
return;
}
records
.sort(function (a, b) {
return (a.cachedAt || 0) - (b.cachedAt || 0);
})
.slice(0, records.length - limit)
.forEach(function (record) {
if (record && record.path) {
store.delete(record.path);
}
});
};
}
function cacheDocuments(documents) {
return openDB().then(function (db) {
return new Promise(function (resolve, reject) {
@@ -63,6 +85,7 @@
const tx = db.transaction(STORE_CONTENT, "readwrite");
const store = tx.objectStore(STORE_CONTENT);
store.put({ path: path, html: html, title: title, cachedAt: Date.now() });
pruneStore(store, CACHE_ENTRY_LIMIT);
tx.oncomplete = function () {
resolve();
};
@@ -117,6 +140,7 @@
title: title,
cachedAt: Date.now(),
});
pruneStore(store, CACHE_ENTRY_LIMIT);
tx.oncomplete = function () {
resolve();
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -10,7 +10,9 @@
pre.replaceWith(container);
});
mermaid.initialize({ startOnLoad: false });
mermaid.run({ querySelector: ".mermaid" });
mermaid.run({ querySelector: ".mermaid:not([data-processed])" }).catch(function (error) {
console.warn("Mermaid render failed:", error);
});
}
function renderMath() {
@@ -66,8 +68,8 @@
}
function init() {
renderMermaid();
renderMath();
renderMermaid();
}
window.renderMermaid = renderMermaid;

View File

@@ -152,6 +152,229 @@ code {
height: calc(100vh - 64px);
}
.auth-shell,
.account-shell {
min-height: 100%;
overflow: auto;
padding: clamp(1rem, 3vw, 2.5rem);
}
.auth-shell {
display: grid;
align-items: start;
justify-items: center;
}
.auth-panel,
.account-section {
width: min(100%, 920px);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--panel);
box-shadow: var(--shadow);
}
.auth-panel {
padding: clamp(1rem, 3vw, 2rem);
}
.auth-panel__header,
.account-header {
margin-bottom: 1.25rem;
}
.auth-panel h1,
.account-header h1 {
margin: 0.1rem 0 0;
font-size: clamp(2rem, 4vw, 3.25rem);
line-height: 1;
}
.eyebrow {
margin: 0;
color: var(--muted);
font: 700 0.72rem/1.2 ui-monospace, SFMono-Regular, monospace;
letter-spacing: 0;
text-transform: uppercase;
}
.auth-grid,
.account-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
gap: 1rem;
}
.auth-form {
display: grid;
gap: 0.75rem;
}
.auth-form h2,
.account-section h2 {
margin: 0;
font-size: 1.05rem;
}
.auth-form label,
.scope-grid {
display: grid;
gap: 0.35rem;
color: var(--muted);
font-size: 0.9rem;
}
.auth-form input,
.auth-form select,
.user-row select {
width: 100%;
padding: 0.55rem 0.65rem;
border: 1px solid var(--border);
border-radius: 0;
background: var(--panel-strong);
color: var(--text);
font: inherit;
}
.auth-form button,
.account-header button,
.token-list button,
.user-row button {
min-height: 2.35rem;
padding: 0.45rem 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--panel-strong);
color: var(--text);
font: inherit;
cursor: pointer;
}
.auth-form button[type="submit"],
.account-header button {
border-color: color-mix(in srgb, var(--accent) 50%, var(--border));
background: var(--accent);
color: white;
}
.auth-details {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
.auth-details summary {
cursor: pointer;
color: var(--accent);
font-weight: 700;
}
.form-status {
min-height: 1.2rem;
margin: 0;
color: var(--muted);
font-size: 0.88rem;
}
.form-status[data-state="error"] {
color: #b42318;
}
.form-status[data-state="ok"] {
color: #067647;
}
.auth-note {
margin: 1rem 0 0;
color: var(--muted);
}
.account-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
width: min(100%, 1120px);
}
.account-header p {
margin: 0.35rem 0 0;
color: var(--muted);
}
.account-grid {
width: min(100%, 1120px);
}
.account-section {
padding: 1rem;
}
.account-section--danger {
border-color: color-mix(in srgb, #b42318 40%, var(--border));
}
.scope-grid {
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
margin: 0;
padding: 0.75rem;
border: 1px solid var(--border);
}
.scope-grid legend {
padding: 0 0.25rem;
color: var(--text);
}
.scope-grid label {
display: flex;
align-items: center;
gap: 0.4rem;
}
.token-output {
max-width: 100%;
overflow: auto;
padding: 0.75rem;
border: 1px solid var(--border);
background: var(--panel-strong);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.token-list,
.user-list {
display: grid;
gap: 0.6rem;
margin: 1rem 0 0;
padding: 0;
list-style: none;
}
.token-list li,
.user-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
padding: 0.75rem;
border: 1px solid var(--border);
background: var(--panel-strong);
}
.user-row {
grid-template-columns: minmax(0, 1fr) minmax(8rem, 10rem) auto;
}
.user-row span {
display: grid;
gap: 0.15rem;
}
.user-row small {
color: var(--muted);
}
/* Workspace layout - adaptive grid */
.workspace-shell {
display: grid;
@@ -583,7 +806,9 @@ code {
}
.markdown-body img {
max-width: 100%;
display: block;
max-width: min(85%, 100%);
height: auto;
}
.version-notice {
@@ -640,6 +865,96 @@ code {
display: none;
}
.sync-queue {
position: fixed;
left: 1rem;
bottom: 7.9rem;
z-index: 30;
display: grid;
gap: 0.65rem;
width: min(28rem, calc(100vw - 2rem));
padding: 0.75rem 0.85rem;
border: 1px solid rgba(146, 64, 14, 0.24);
background: rgba(255, 251, 235, 0.97);
color: #78350f;
box-shadow: 0 18px 48px rgba(24, 32, 42, 0.12);
font-size: 0.9rem;
}
.sync-queue[hidden] {
display: none;
}
.sync-queue__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.sync-queue__header strong,
.sync-queue__header p {
margin: 0;
}
.sync-queue__header p {
color: #92400e;
font-size: 0.84rem;
}
.sync-queue button {
min-height: 2rem;
flex: 0 0 auto;
padding: 0 0.65rem;
border: 1px solid rgba(146, 64, 14, 0.25);
background: #fff;
color: #78350f;
cursor: pointer;
font: 700 0.82rem/1 ui-monospace, SFMono-Regular, monospace;
}
.sync-queue button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.sync-queue ul {
display: grid;
gap: 0.35rem;
margin: 0;
padding: 0;
list-style: none;
}
.sync-queue li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
min-width: 0;
color: #78350f;
}
.sync-queue li span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: ui-monospace, SFMono-Regular, monospace;
}
.sync-queue li span:last-child {
flex: 0 0 auto;
color: #92400e;
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
}
.sync-queue li[data-state="conflict"] span:last-child {
color: #b91c1c;
}
.offline-icon {
display: inline-block;
width: 0.6rem;
@@ -1231,6 +1546,29 @@ code {
background: oklch(0.60 0.15 250);
}
.sync-queue {
border-color: oklch(0.65 0.12 75 / 0.32);
background: oklch(0.21 0.035 75 / 0.96);
color: oklch(0.75 0.12 75);
box-shadow: 0 18px 48px oklch(0.08 0.02 55 / 0.32);
}
.sync-queue__header p,
.sync-queue li,
.sync-queue li span:last-child {
color: oklch(0.72 0.11 75);
}
.sync-queue button {
border-color: oklch(0.65 0.12 75 / 0.28);
background: oklch(0.17 0.018 55);
color: oklch(0.75 0.12 75);
}
.sync-queue li[data-state="conflict"] span:last-child {
color: oklch(0.68 0.16 25);
}
.version-notice button {
background: var(--accent);
}

View File

@@ -1,4 +1,4 @@
const STATIC_CACHE = "md-hub-static-v1";
const STATIC_CACHE = "md-hub-static-v2";
const DB_NAME = "md-hub-cache";
const DB_VERSION = 3;
const STORE_PAGES = "pages";

View File

@@ -3,11 +3,107 @@
return;
}
const syncQueue = document.querySelector("[data-sync-queue]");
const syncQueueTitle = document.querySelector("[data-sync-queue-title]");
const syncQueueSummary = document.querySelector("[data-sync-queue-summary]");
const syncQueueList = document.querySelector("[data-sync-queue-list]");
const syncNow = document.querySelector("[data-sync-now]");
function normalizeDocPath(path) {
if (!path) return "";
return path.replace(/^\/+/, "");
}
function formatPath(path) {
return (path || "").replace(/^\/docs\//, "") || "/";
}
function pluralize(count, singular, plural) {
return count === 1 ? singular : plural;
}
function emitSyncState(detail) {
window.dispatchEvent(new CustomEvent("mdhub:sync-state", { detail: detail }));
}
function renderPendingEdits(pending, state) {
if (!syncQueue || !syncQueueTitle || !syncQueueSummary || !syncQueueList) {
return;
}
const conflicts = pending.filter(function (edit) {
return Boolean(edit.conflict);
});
const clean = pending.filter(function (edit) {
return !edit.conflict;
});
syncQueue.hidden = pending.length === 0;
if (pending.length === 0) {
syncQueueList.replaceChildren();
syncQueueTitle.textContent = "Synced";
syncQueueSummary.textContent = "No pending changes.";
if (syncNow) {
syncNow.disabled = true;
}
return;
}
const countLabel = pending.length + " pending " + pluralize(pending.length, "change", "changes");
syncQueueTitle.textContent = conflicts.length > 0 ? countLabel + " with conflicts" : countLabel;
if (!navigator.onLine) {
syncQueueSummary.textContent = "Offline. Changes will sync when the connection returns.";
} else if (state === "syncing") {
syncQueueSummary.textContent = "Syncing queued edits...";
} else if (conflicts.length > 0) {
syncQueueSummary.textContent = conflicts.length + " " + pluralize(conflicts.length, "file needs", "files need") + " conflict resolution.";
} else {
syncQueueSummary.textContent = "Queued edits are ready to sync.";
}
syncQueueList.replaceChildren();
pending.slice(0, 5).forEach(function (edit) {
const item = document.createElement("li");
item.dataset.state = edit.conflict ? "conflict" : "queued";
const path = document.createElement("span");
path.textContent = formatPath(edit.path);
item.appendChild(path);
const status = document.createElement("span");
status.textContent = edit.conflict ? "Conflict" : "Queued";
item.appendChild(status);
syncQueueList.appendChild(item);
});
if (pending.length > 5) {
const overflow = document.createElement("li");
overflow.textContent = "+" + (pending.length - 5) + " more";
syncQueueList.appendChild(overflow);
}
if (syncNow) {
syncNow.disabled = !navigator.onLine || clean.length === 0 || state === "syncing";
}
}
async function refreshSyncQueue(state) {
const pending = await window.MDHubCache.getPendingEdits();
pending.sort(function (a, b) {
return (b.updatedAt || 0) - (a.updatedAt || 0);
});
renderPendingEdits(pending, state || "idle");
emitSyncState({
state: state || "idle",
pending: pending.length,
conflicts: pending.filter(function (edit) {
return Boolean(edit.conflict);
}).length,
});
return pending;
}
async function postDocument(path, content, baseHash) {
const response = await fetch("/api/documents/" + normalizeDocPath(path), {
method: "POST",
@@ -40,6 +136,7 @@
try {
const result = await postDocument(path, content, baseHash);
await window.MDHubCache.deletePendingEdit(normalizedPath);
await refreshSyncQueue("saved");
return {
status: "saved",
result: result,
@@ -48,12 +145,14 @@
if (error.name === "DocumentConflictError") {
await window.MDHubCache.putPendingEdit(normalizedPath, content, baseHash);
await window.MDHubCache.markPendingEditConflict(normalizedPath, error.details);
await refreshSyncQueue("conflict");
return {
status: "conflict",
conflict: error.details,
};
}
await window.MDHubCache.putPendingEdit(normalizedPath, content, baseHash);
await refreshSyncQueue("queued");
return {
status: "queued",
};
@@ -62,15 +161,19 @@
async function syncPending() {
if (!navigator.onLine) {
await refreshSyncQueue("offline");
return;
}
const pending = await window.MDHubCache.getPendingEdits();
const pending = await refreshSyncQueue("syncing");
pending.sort(function (a, b) {
return (a.updatedAt || 0) - (b.updatedAt || 0);
});
for (const edit of pending) {
if (edit.conflict) {
continue;
}
try {
await postDocument(edit.path.replace(/^\/docs\//, ""), edit.content, edit.baseHash);
await window.MDHubCache.deletePendingEdit(edit.path);
@@ -81,16 +184,31 @@
break;
}
}
await refreshSyncQueue("idle");
}
window.addEventListener("online", function () {
syncPending().catch(function () {});
});
window.addEventListener("offline", function () {
refreshSyncQueue("offline").catch(function () {});
});
if (syncNow) {
syncNow.addEventListener("click", function () {
syncPending().catch(function () {
refreshSyncQueue("queued").catch(function () {});
});
});
}
syncPending().catch(function () {});
refreshSyncQueue("idle").catch(function () {});
window.MDHubSync = {
saveDocument: saveDocument,
syncPending: syncPending,
refreshSyncQueue: refreshSyncQueue,
};
})();