feat: base UI shell with themes, auth pages, profile, and role navigation (phase 4)
- embedded Go templates + vanilla ES-module JS + hand-written CSS, no build step
- exact §10 beige light / dark design tokens, square edges (radius 2px),
system font stack, visible focus rings
- theme toggle persisted to localStorage and the user profile
- login/register/change-password pages wired to the auth API
- profile page: avatar upload (image-sniffed, old file cleanup), bio,
contacts, arbitrary extra key/value fields, optimistic-concurrency 409
- role-based top navigation with placeholders for later-phase areas
- GET /files/{id} with scope-based access (session) or signed token (§5.1)
- security headers incl. CSP without unsafe-inline scripts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
// Tiny API client: JSON + CSRF double-submit header + error envelope.
|
||||
export function csrfToken() {
|
||||
const m = document.cookie.match(/(?:^|;\s*)bb_csrf=([^;]+)/);
|
||||
return m ? decodeURIComponent(m[1]) : '';
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(status, code, message) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export async function api(method, path, body) {
|
||||
const opts = { method, headers: {} };
|
||||
if (method !== 'GET' && method !== 'HEAD') {
|
||||
opts.headers['X-CSRF-Token'] = csrfToken();
|
||||
}
|
||||
if (body !== undefined) {
|
||||
if (body instanceof FormData) {
|
||||
opts.body = body;
|
||||
} else {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
}
|
||||
const resp = await fetch(path, opts);
|
||||
if (resp.status === 204) return null;
|
||||
let data = null;
|
||||
try { data = await resp.json(); } catch (e) { /* non-JSON */ }
|
||||
if (!resp.ok) {
|
||||
const err = (data && data.error) || {};
|
||||
throw new ApiError(resp.status, err.code || 'unknown', err.message || resp.statusText);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export function toast(message, kind) {
|
||||
let host = document.getElementById('toasts');
|
||||
if (!host) {
|
||||
host = document.createElement('div');
|
||||
host.id = 'toasts';
|
||||
document.body.appendChild(host);
|
||||
}
|
||||
const t = document.createElement('div');
|
||||
t.className = 'toast' + (kind ? ' ' + kind : '');
|
||||
t.setAttribute('role', 'status');
|
||||
t.textContent = message;
|
||||
host.appendChild(t);
|
||||
setTimeout(() => t.remove(), 6000);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { api } from '/static/js/api.js';
|
||||
|
||||
const form = document.getElementById('change-password-form');
|
||||
const errorBox = document.getElementById('error');
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
errorBox.textContent = '';
|
||||
const newPw = document.getElementById('new').value;
|
||||
if (newPw !== document.getElementById('confirm').value) {
|
||||
errorBox.textContent = 'New passwords do not match.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api('POST', '/api/v1/auth/change-password', {
|
||||
currentPassword: document.getElementById('current').value,
|
||||
newPassword: newPw,
|
||||
});
|
||||
window.location.href = '/';
|
||||
} catch (err) {
|
||||
errorBox.textContent = err.message;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { api } from '/static/js/api.js';
|
||||
|
||||
const form = document.getElementById('login-form');
|
||||
const errorBox = document.getElementById('error');
|
||||
|
||||
function safeNext() {
|
||||
const next = new URLSearchParams(window.location.search).get('next') || '/';
|
||||
return next.startsWith('/') && !next.startsWith('//') ? next : '/';
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
errorBox.textContent = '';
|
||||
try {
|
||||
const res = await api('POST', '/api/v1/auth/login', {
|
||||
email: document.getElementById('email').value.trim(),
|
||||
password: document.getElementById('password').value,
|
||||
});
|
||||
window.location.href = res.mustChangePassword ? '/change-password' : safeNext();
|
||||
} catch (err) {
|
||||
errorBox.textContent = err.message;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
// Top navigation behaviors: logout + theme toggle (persisted to
|
||||
// localStorage immediately and to the profile when logged in).
|
||||
import { api, toast } from '/static/js/api.js';
|
||||
|
||||
const logoutBtn = document.getElementById('nav-logout');
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
await api('POST', '/api/v1/auth/logout', {});
|
||||
} catch (e) { /* session may already be gone */ }
|
||||
window.location.href = '/login';
|
||||
});
|
||||
}
|
||||
|
||||
const themeBtn = document.getElementById('nav-theme');
|
||||
if (themeBtn) {
|
||||
themeBtn.addEventListener('click', async () => {
|
||||
const el = document.documentElement;
|
||||
const next = el.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||
el.dataset.theme = next;
|
||||
try { localStorage.setItem('theme', next); } catch (e) { /* ignore */ }
|
||||
if (document.body.dataset.loggedIn === '1') {
|
||||
try {
|
||||
await api('PATCH', '/api/v1/profile', { settings: { theme: next } });
|
||||
} catch (e) {
|
||||
toast('Could not save theme preference: ' + e.message, 'err');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { api, toast } from '/static/js/api.js';
|
||||
|
||||
const form = document.getElementById('profile-form');
|
||||
const errorBox = document.getElementById('error');
|
||||
const okBox = document.getElementById('ok');
|
||||
const extraRows = document.getElementById('extra-rows');
|
||||
|
||||
let version = parseInt(form.dataset.version, 10);
|
||||
|
||||
function addExtraRow(key, value) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'kv-row';
|
||||
const k = document.createElement('input');
|
||||
k.type = 'text'; k.placeholder = 'Field name'; k.value = key || '';
|
||||
k.setAttribute('aria-label', 'Field name');
|
||||
const v = document.createElement('input');
|
||||
v.type = 'text'; v.placeholder = 'Value'; v.value = value || '';
|
||||
v.setAttribute('aria-label', 'Field value');
|
||||
const rm = document.createElement('button');
|
||||
rm.type = 'button'; rm.className = 'btn small'; rm.textContent = '×';
|
||||
rm.setAttribute('aria-label', 'Remove field');
|
||||
rm.addEventListener('click', () => row.remove());
|
||||
row.append(k, v, rm);
|
||||
extraRows.appendChild(row);
|
||||
}
|
||||
|
||||
async function loadExtra() {
|
||||
const me = await api('GET', '/api/v1/auth/me');
|
||||
version = me.user.version;
|
||||
const extra = me.user.extra || {};
|
||||
Object.keys(extra).forEach((k) => addExtraRow(k, String(extra[k])));
|
||||
}
|
||||
loadExtra().catch((e) => { errorBox.textContent = e.message; });
|
||||
|
||||
document.getElementById('extra-add').addEventListener('click', () => addExtraRow('', ''));
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
errorBox.textContent = '';
|
||||
okBox.textContent = '';
|
||||
const extra = {};
|
||||
extraRows.querySelectorAll('.kv-row').forEach((row) => {
|
||||
const [k, v] = row.querySelectorAll('input');
|
||||
if (k.value.trim()) extra[k.value.trim()] = v.value;
|
||||
});
|
||||
const links = document.getElementById('p-links').value
|
||||
.split('\n').map((s) => s.trim()).filter(Boolean);
|
||||
try {
|
||||
const res = await api('PATCH', '/api/v1/profile', {
|
||||
version,
|
||||
name: document.getElementById('p-name').value.trim(),
|
||||
bio: document.getElementById('p-bio').value,
|
||||
contact: {
|
||||
phone: document.getElementById('p-phone').value.trim(),
|
||||
location: document.getElementById('p-location').value.trim(),
|
||||
links,
|
||||
},
|
||||
extra,
|
||||
settings: { theme: document.getElementById('p-theme').value },
|
||||
});
|
||||
version = res.user.version;
|
||||
const theme = res.user.settings.theme;
|
||||
document.documentElement.dataset.theme = theme;
|
||||
try { localStorage.setItem('theme', theme); } catch (err) { /* ignore */ }
|
||||
okBox.textContent = 'Profile saved.';
|
||||
} catch (err) {
|
||||
if (err.status === 409) {
|
||||
errorBox.textContent = 'This profile changed elsewhere — reload the page and try again.';
|
||||
} else {
|
||||
errorBox.textContent = err.message;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('avatar-file').addEventListener('change', async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
try {
|
||||
const res = await api('POST', '/api/v1/profile/avatar', fd);
|
||||
const img = document.createElement('img');
|
||||
img.className = 'avatar large';
|
||||
img.alt = 'Current avatar';
|
||||
img.src = '/files/' + res.fileId + '?v=' + Date.now();
|
||||
const preview = document.getElementById('avatar-preview');
|
||||
preview.replaceChildren(img);
|
||||
toast('Avatar updated.', 'ok');
|
||||
} catch (err) {
|
||||
toast('Avatar upload failed: ' + err.message, 'err');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('logout-all').addEventListener('click', async () => {
|
||||
try {
|
||||
await api('POST', '/api/v1/auth/logout-all', {});
|
||||
} catch (e) { /* session is gone either way */ }
|
||||
window.location.href = '/login';
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { api } from '/static/js/api.js';
|
||||
|
||||
const form = document.getElementById('register-form');
|
||||
const errorBox = document.getElementById('error');
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
errorBox.textContent = '';
|
||||
try {
|
||||
await api('POST', '/api/v1/auth/register', {
|
||||
name: document.getElementById('name').value.trim(),
|
||||
email: document.getElementById('email').value.trim(),
|
||||
password: document.getElementById('password').value,
|
||||
});
|
||||
window.location.href = '/';
|
||||
} catch (err) {
|
||||
errorBox.textContent = err.message;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
// Theme bootstrap: runs synchronously in <head> to avoid a flash of the
|
||||
// wrong theme. The server renders data-default-theme from the user profile;
|
||||
// localStorage wins so the choice applies before login too.
|
||||
(function () {
|
||||
var el = document.documentElement;
|
||||
var saved = null;
|
||||
try { saved = localStorage.getItem('theme'); } catch (e) { /* private mode */ }
|
||||
var theme = saved || el.dataset.defaultTheme || 'light';
|
||||
if (theme !== 'light' && theme !== 'dark') theme = 'light';
|
||||
el.dataset.theme = theme;
|
||||
})();
|
||||
Reference in New Issue
Block a user