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:
etalon
2026-06-12 18:39:38 +02:00
parent c1cc781279
commit 34bf5b5ac2
27 changed files with 1579 additions and 5 deletions
+161
View File
@@ -0,0 +1,161 @@
/* Bounty Board — hand-written CSS, no framework (§2.2).
Theming via CSS custom properties on <html data-theme>. */
:root[data-theme=light] {
--bg:#f3ead9; --surface:#faf5ea; --surface2:#efe5d0; --border:#d8cbb0;
--text:#2b2620; --muted:#6f6353; --accent:#8a5a2b; --accent-contrast:#fff;
--ok:#3c6e47; --warn:#a06a1f; --err:#9c3a2e; --radius:2px;
}
:root[data-theme=dark] {
--bg:#191714; --surface:#221f1b; --surface2:#2b2722; --border:#3a342c;
--text:#ece5d8; --muted:#a59a87; --accent:#caa15e; --accent-contrast:#1a160f;
--ok:#7fb78a; --warn:#d9a44a; --err:#d97b6c; --radius:2px;
}
/* ---- base ---- */
* { box-sizing: border-box; }
html { font-size: 16px; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.5;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
h1, h2, h3 { line-height: 1.25; margin: 0 0 16px; }
h1 { font-size: 1.5rem; }
h2 { font-size: 1.2rem; }
h3 { font-size: 1rem; }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* ---- layout ---- */
.container { max-width: 1100px; margin: 0 auto; padding: 24px 16px; }
.narrow { max-width: 460px; }
.topnav {
display: flex; align-items: center; gap: 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 8px 16px;
}
.topnav .brand { font-weight: 700; color: var(--text); }
.topnav .links { display: flex; gap: 8px; flex: 1; flex-wrap: wrap; }
.topnav .links a {
color: var(--muted); padding: 6px 10px; border-radius: var(--radius);
}
.topnav .links a:hover { color: var(--text); background: var(--surface2); text-decoration: none; }
.topnav .links a[aria-current=page] { color: var(--text); background: var(--surface2); }
.topnav .right { display: flex; align-items: center; gap: 8px; }
/* ---- components ---- */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
}
.card + .card { margin-top: 16px; }
.btn {
display: inline-flex; align-items: center; gap: 8px;
font: inherit; cursor: pointer;
padding: 8px 16px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface2);
color: var(--text);
}
.btn:hover { filter: brightness(0.97); }
.btn.primary {
background: var(--accent); color: var(--accent-contrast); border-color: var(--accent);
}
.btn.danger { background: var(--err); color: #fff; border-color: var(--err); }
.btn.ghost { background: transparent; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn.small { padding: 4px 8px; font-size: 0.875rem; }
label { display: block; font-weight: 600; margin-bottom: 4px; }
.hint { color: var(--muted); font-size: 0.875rem; margin: 4px 0 0; }
input[type=text], input[type=email], input[type=password], input[type=number],
select, textarea {
width: 100%;
font: inherit; color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 8px;
}
textarea { resize: vertical; min-height: 80px; }
.field { margin-bottom: 16px; }
.row { display: flex; gap: 16px; }
.row > * { flex: 1; }
.error-box, .ok-box {
border: 1px solid var(--err); color: var(--err);
border-radius: var(--radius);
padding: 8px 12px; margin-bottom: 16px;
}
.ok-box { border-color: var(--ok); color: var(--ok); }
.error-box:empty, .ok-box:empty { display: none; }
.badge {
display: inline-block; font-size: 0.75rem; font-weight: 600;
padding: 2px 8px; border-radius: var(--radius);
background: var(--surface2); color: var(--muted);
border: 1px solid var(--border);
}
.badge.accent { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent); }
.avatar {
width: 32px; height: 32px; border-radius: var(--radius);
background: var(--accent); color: var(--accent-contrast);
display: inline-flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 0.875rem;
object-fit: cover; overflow: hidden;
}
.avatar.large { width: 96px; height: 96px; font-size: 2rem; }
img.avatar { background: var(--surface2); }
table.list { width: 100%; border-collapse: collapse; }
table.list th, table.list td {
text-align: left; padding: 8px;
border-bottom: 1px solid var(--border);
}
table.list th { color: var(--muted); font-size: 0.875rem; }
.grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
.muted { color: var(--muted); }
.spread { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
.stack > * + * { margin-top: 16px; }
.mt { margin-top: 16px; }
.kv-row { display: flex; gap: 8px; margin-bottom: 8px; }
.kv-row input { flex: 1; }
/* toast notifications */
#toasts {
position: fixed; bottom: 16px; right: 16px; z-index: 100;
display: flex; flex-direction: column; gap: 8px; max-width: 360px;
}
.toast {
background: var(--surface); border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: var(--radius);
padding: 12px 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.toast.err { border-left-color: var(--err); }
.toast.ok { border-left-color: var(--ok); }
@media (max-width: 640px) {
.row { flex-direction: column; gap: 0; }
.topnav { flex-wrap: wrap; }
}
+52
View File
@@ -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);
}
+23
View File
@@ -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;
}
});
+23
View File
@@ -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;
}
});
+30
View File
@@ -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');
}
}
});
}
+99
View File
@@ -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';
});
+19
View File
@@ -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;
}
});
+11
View File
@@ -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;
})();
+24
View File
@@ -0,0 +1,24 @@
{{define "content"}}
<div class="card">
<h1>Change password</h1>
{{if .User.MustChangePassword}}
<p class="hint">You must set a new password before continuing.</p>
{{end}}
<div class="error-box" id="error" role="alert"></div>
<form id="change-password-form" class="mt">
<div class="field">
<label for="current">Current password</label>
<input type="password" id="current" autocomplete="current-password" required>
</div>
<div class="field">
<label for="new">New password</label>
<input type="password" id="new" autocomplete="new-password" minlength="8" required>
</div>
<div class="field">
<label for="confirm">Confirm new password</label>
<input type="password" id="confirm" autocomplete="new-password" minlength="8" required>
</div>
<button class="btn primary" type="submit">Change password</button>
</form>
</div>
{{end}}
+41
View File
@@ -0,0 +1,41 @@
{{define "content"}}
<h1>Welcome, {{.User.Name}}</h1>
<div class="grid">
{{if .User.Roles.Developer}}
<div class="card">
<h2>Bounty Board</h2>
<p class="muted">Browse published tasks and request assignments.</p>
<a class="btn primary" href="/board">Open board</a>
</div>
<div class="card">
<h2>My Tasks</h2>
<p class="muted">Track your assigned and in-progress work.</p>
<a class="btn" href="/my-tasks">Open my tasks</a>
</div>
{{end}}
{{if .User.Roles.Consultant}}
<div class="card">
<h2>Atomization Board</h2>
<p class="muted">Subdivide imported tickets and publish bounties.</p>
<a class="btn primary" href="/consultant/board">Open atomization</a>
</div>
<div class="card">
<h2>Review Queue</h2>
<p class="muted">Approve or request changes on submitted work.</p>
<a class="btn" href="/consultant/reviews">Open reviews</a>
</div>
{{end}}
{{if .User.Roles.Admin}}
<div class="card">
<h2>Administration</h2>
<p class="muted">Customers, users, settings, audit log, service status.</p>
<a class="btn primary" href="/admin">Open admin</a>
</div>
{{end}}
<div class="card">
<h2>Messages</h2>
<p class="muted">Direct, group and project conversations.</p>
<a class="btn" href="/messages">Open messages</a>
</div>
</div>
{{end}}
+55
View File
@@ -0,0 +1,55 @@
{{define "layout"}}<!doctype html>
<html lang="en" data-theme="light" data-default-theme="{{.DefaultTheme}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · Bounty Board</title>
<link rel="stylesheet" href="/static/css/app.css">
<script src="/static/js/theme.js"></script>
</head>
<body {{if .User}}data-logged-in="1"{{end}}>
<nav class="topnav">
<a class="brand" href="/">Bounty Board</a>
<div class="links">
{{if .User}}
{{if .User.Roles.Developer}}
<a href="/board" {{if eq .Active "board"}}aria-current="page"{{end}}>Bounty Board</a>
<a href="/my-tasks" {{if eq .Active "my-tasks"}}aria-current="page"{{end}}>My Tasks</a>
{{end}}
{{if .User.Roles.Consultant}}
<a href="/consultant/board" {{if eq .Active "atomization"}}aria-current="page"{{end}}>Atomization</a>
<a href="/consultant/reviews" {{if eq .Active "reviews"}}aria-current="page"{{end}}>Reviews</a>
<a href="/consultant/pool" {{if eq .Active "pool"}}aria-current="page"{{end}}>Pool</a>
{{end}}
{{if .User.Roles.Admin}}
<a href="/admin" {{if eq .Active "admin"}}aria-current="page"{{end}}>Admin</a>
{{end}}
<a href="/messages" {{if eq .Active "messages"}}aria-current="page"{{end}}>Messages</a>
<a href="/metrics" {{if eq .Active "metrics"}}aria-current="page"{{end}}>Metrics</a>
{{end}}
</div>
<div class="right">
<button class="btn ghost small" id="nav-theme" type="button" aria-label="Toggle dark / light theme"></button>
{{if .User}}
<a href="/profile" aria-label="Your profile">
{{if .User.AvatarFileID}}
<img class="avatar" src="/files/{{.User.AvatarFileID}}" alt="">
{{else}}
<span class="avatar" aria-hidden="true">{{initials .User.Name}}</span>
{{end}}
</a>
<button class="btn small" id="nav-logout" type="button">Log out</button>
{{else}}
<a class="btn small" href="/login">Log in</a>
{{end}}
</div>
</nav>
<main class="container{{if .Narrow}} narrow{{end}}">
{{template "content" .}}
</main>
<div id="toasts" aria-live="polite"></div>
<script type="module" src="/static/js/nav.js"></script>
{{range .Scripts}}<script type="module" src="{{.}}"></script>
{{end}}
</body>
</html>{{end}}
+25
View File
@@ -0,0 +1,25 @@
{{define "content"}}
<div class="card">
<h1>Log in</h1>
<div class="error-box" id="error" role="alert">{{.Error}}</div>
<form id="login-form">
<div class="field">
<label for="email">Email</label>
<input type="email" id="email" name="email" autocomplete="email" required>
</div>
<div class="field">
<label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="current-password" required>
</div>
<div class="spread">
<button class="btn primary" type="submit">Log in</button>
<a href="/register">Create an account</a>
</div>
</form>
{{if .OIDCEnabled}}
<div class="mt">
<a class="btn" href="/api/v1/auth/oidc/login">Continue with SSO</a>
</div>
{{end}}
</div>
{{end}}
+7
View File
@@ -0,0 +1,7 @@
{{define "content"}}
<div class="card">
<h1>{{.Title}}</h1>
<p class="muted">This area is under construction — it arrives in a later build phase.</p>
<a class="btn" href="/">Back to home</a>
</div>
{{end}}
+78
View File
@@ -0,0 +1,78 @@
{{define "content"}}
<h1>Your profile</h1>
<div class="error-box" id="error" role="alert"></div>
<div class="ok-box" id="ok" role="status"></div>
<div class="card">
<h2>Avatar</h2>
<div class="spread">
<span id="avatar-preview">
{{if .User.AvatarFileID}}
<img class="avatar large" src="/files/{{.User.AvatarFileID}}" alt="Current avatar">
{{else}}
<span class="avatar large" aria-hidden="true">{{initials .User.Name}}</span>
{{end}}
</span>
<div>
<label for="avatar-file">Upload new avatar (image)</label>
<input type="file" id="avatar-file" accept="image/*">
</div>
</div>
</div>
<form id="profile-form" class="card" data-version="{{.User.Version}}">
<h2>Basics</h2>
<div class="field">
<label for="p-name">Name</label>
<input type="text" id="p-name" value="{{.User.Name}}" required>
</div>
<div class="field">
<label for="p-bio">Bio</label>
<textarea id="p-bio">{{.User.Bio}}</textarea>
</div>
<h2>Contact</h2>
<div class="row">
<div class="field">
<label for="p-phone">Phone</label>
<input type="text" id="p-phone" value="{{.User.Contact.Phone}}" autocomplete="tel">
</div>
<div class="field">
<label for="p-location">Location</label>
<input type="text" id="p-location" value="{{.User.Contact.Location}}">
</div>
</div>
<div class="field">
<label for="p-links">Links (one per line)</label>
<textarea id="p-links">{{range .User.Contact.Links}}{{.}}
{{end}}</textarea>
</div>
<h2>Additional fields</h2>
<p class="hint">Arbitrary key/value details shown on your profile card.</p>
<div id="extra-rows"></div>
<button class="btn small" type="button" id="extra-add">+ Add field</button>
<h2 class="mt">Appearance</h2>
<div class="field">
<label for="p-theme">Theme</label>
<select id="p-theme">
<option value="light" {{if eq .User.Settings.Theme "light"}}selected{{end}}>Light (beige)</option>
<option value="dark" {{if eq .User.Settings.Theme "dark"}}selected{{end}}>Dark</option>
</select>
</div>
<button class="btn primary" type="submit">Save profile</button>
</form>
<div class="card">
<h2>Security</h2>
{{if .HasLocalAuth}}
<p><a class="btn" href="/change-password">Change password</a></p>
{{else}}
<p class="muted">This account signs in via SSO and has no local password.</p>
{{end}}
<p><button class="btn danger" type="button" id="logout-all">Log out everywhere</button></p>
<p class="hint">Revokes every active session for your account, including this one.</p>
</div>
{{end}}
+26
View File
@@ -0,0 +1,26 @@
{{define "content"}}
<div class="card">
<h1>Create account</h1>
<p class="hint">Self-registration creates a developer account. Consultant and admin roles are granted by an administrator.</p>
<div class="error-box" id="error" role="alert"></div>
<form id="register-form" class="mt">
<div class="field">
<label for="name">Name</label>
<input type="text" id="name" name="name" autocomplete="name" required>
</div>
<div class="field">
<label for="email">Email</label>
<input type="email" id="email" name="email" autocomplete="email" required>
</div>
<div class="field">
<label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="new-password" minlength="8" required>
<p class="hint">At least 8 characters.</p>
</div>
<div class="spread">
<button class="btn primary" type="submit">Create account</button>
<a href="/login">I already have an account</a>
</div>
</form>
</div>
{{end}}
+8
View File
@@ -0,0 +1,8 @@
// Package web embeds all frontend assets: Go templates and static files.
// No build step — the binary serves everything (§2.2).
package web
import "embed"
//go:embed templates static
var FS embed.FS