feat: add authentication, audit log, HTTP API and base web UI

Phase 1 foundation. Local auth per ADR-0006: Argon2id passwords, opaque
hashed session tokens with idle/absolute expiry, CSRF token plus Origin
check on cookie sessions, per-IP token bucket and per-account exponential
lockout, three roles enforced by middleware, personal API tokens for
noemactl. Audit log writer used by every admin mutation.

API: request ids, panic recovery, strict CSP and security headers, body
limits, single JSON error envelope, health/ready/status, users, sessions,
tokens, audit, token-protected /metrics.

UI: embedded html/template pages (login, first-run setup, dashboard,
system, users, account, audit, interpretation boundary), light/dark
theme, dependency-free app.js with CSRF-aware fetch and SSE helper.

Binaries: noemad with graceful shutdown, migrations at start, bootstrap
admin from environment, supervised background tasks; noemactl with
status, migrate, user create and meaningful exit codes.
This commit is contained in:
Andy Dixon
2026-06-15 20:22:19 +01:00
parent 6374e174c6
commit a002a2c100
40 changed files with 3081 additions and 20 deletions
+17
View File
@@ -0,0 +1,17 @@
// Package web embeds the templates and static assets of the administration
// interface (ADR-0007). Handlers live in internal/ui.
package web
import (
"embed"
"io/fs"
)
//go:embed templates static
var files embed.FS
// Templates returns the template tree.
func Templates() fs.FS { f, _ := fs.Sub(files, "templates"); return f }
// Static returns the static asset tree.
func Static() fs.FS { f, _ := fs.Sub(files, "static"); return f }
+113
View File
@@ -0,0 +1,113 @@
/* Noema administration interface. Design tokens first, then layout, then components.
Light theme on :root, dark via data-theme or system preference. */
:root {
--font: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
--mono: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace;
--fs: 15px;
--bg: #f4f6f9; --surface: #ffffff; --surface-2: #eef1f5; --text: #1a1f26; --muted: #5d6673; --border: #dfe4ea;
--accent: #2f6fed; --accent-ink: #ffffff; --accent-soft: #e6eefc;
--ok: #178a5b; --ok-soft: #e1f5ec; --warn: #b7791f; --warn-soft: #fbf1dc; --danger: #c93b3b; --danger-soft: #fbe5e5; --info: #2b6cb0;
--shadow: 0 1px 2px rgba(16,24,40,.06), 0 1px 3px rgba(16,24,40,.08);
--radius: 10px; --sidenav: 232px;
color-scheme: light;
}
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --bg:#0f1318; --surface:#161b22; --surface-2:#1d242d; --text:#e6eaf0; --muted:#8d97a5; --border:#28303b; --accent:#6c9cff; --accent-ink:#0b1220; --accent-soft:#1a2740; --ok:#3ecf8e; --ok-soft:#14301f; --warn:#e0b04c; --warn-soft:#34290f; --danger:#ff6b6b; --danger-soft:#3a1717; --info:#7ab8ff; --shadow:0 1px 2px rgba(0,0,0,.4); color-scheme: dark; } }
:root[data-theme="dark"] { --bg:#0f1318; --surface:#161b22; --surface-2:#1d242d; --text:#e6eaf0; --muted:#8d97a5; --border:#28303b; --accent:#6c9cff; --accent-ink:#0b1220; --accent-soft:#1a2740; --ok:#3ecf8e; --ok-soft:#14301f; --warn:#e0b04c; --warn-soft:#34290f; --danger:#ff6b6b; --danger-soft:#3a1717; --info:#7ab8ff; --shadow:0 1px 2px rgba(0,0,0,.4); color-scheme: dark; }
*, *::before, *::after { box-sizing: border-box; }
html { font-size: var(--fs); }
body { margin: 0; font-family: var(--font); color: var(--text); background: var(--bg); line-height: 1.5; -webkit-font-smoothing: antialiased; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
code, pre, .mono { font-family: var(--mono); font-size: .92em; }
h1 { font-size: 1.6rem; margin: 0 0 .25rem; letter-spacing: -.01em; }
h2 { font-size: 1.05rem; margin: 0 0 .75rem; }
h3 { font-size: .95rem; margin: 0 0 .5rem; }
p { margin: 0 0 .75rem; }
.muted { color: var(--muted); }
.small { font-size: .85rem; }
.nowrap { white-space: nowrap; }
.truncate { max-width: 22ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ok { color: var(--ok); } .warn { color: var(--warn); } .danger { color: var(--danger); }
.skip { position: absolute; left: -999px; } .skip:focus { left: 8px; top: 8px; background: var(--surface); padding: .5rem; z-index: 99; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* Shell */
.topbar { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: .75rem; height: 52px; padding: 0 1rem; background: var(--surface); border-bottom: 1px solid var(--border); }
.topbar-spacer { flex: 1; }
.brand { display: inline-flex; align-items: center; gap: .5rem; font-weight: 650; color: var(--text); letter-spacing: -.01em; }
.brand.big { font-size: 1.4rem; margin-bottom: .5rem; }
.brand-mark { width: 18px; height: 18px; border-radius: 50%; background: radial-gradient(circle at center, var(--surface) 0 3px, var(--accent) 4px 6px, transparent 7px), var(--accent); box-shadow: inset 0 0 0 3px var(--accent); }
.shell { display: grid; grid-template-columns: var(--sidenav) 1fr; min-height: calc(100vh - 52px - 40px); }
.sidenav { border-right: 1px solid var(--border); padding: 1rem .75rem; background: var(--surface); position: sticky; top: 52px; height: calc(100vh - 52px); overflow-y: auto; }
.navgroup { margin-bottom: 1.1rem; }
.navhead { font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); padding: 0 .6rem .35rem; }
.sidenav a { display: block; padding: .42rem .6rem; border-radius: 7px; color: var(--text); font-size: .93rem; }
.sidenav a:hover { background: var(--surface-2); text-decoration: none; }
.sidenav a[aria-current="page"] { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
.content { padding: 1.5rem 2rem 3rem; max-width: 1400px; width: 100%; }
.footer { text-align: center; padding: .75rem; border-top: 1px solid var(--border); }
.nav-toggle { display: none; }
@media (max-width: 900px) {
.shell { grid-template-columns: 1fr; }
.sidenav { display: none; position: static; height: auto; border-right: 0; border-bottom: 1px solid var(--border); }
.sidenav.open { display: block; }
.nav-toggle { display: inline-flex; }
.content { padding: 1rem; }
}
.bare-main { min-height: calc(100vh - 40px); display: grid; place-items: center; padding: 1rem; }
/* Components */
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.1rem 1.25rem; box-shadow: var(--shadow); }
.card.highlight { border-color: var(--accent); }
.auth-card { width: min(420px, 100%); }
.grid { display: grid; gap: 1rem; margin-bottom: 1rem; }
.cols-2 { grid-template-columns: repeat(2, minmax(0,1fr)); }
.cols-3 { grid-template-columns: repeat(3, minmax(0,1fr)); }
.cols-4 { grid-template-columns: repeat(4, minmax(0,1fr)); }
.cols-3-1 { grid-template-columns: 3fr 1fr; }
@media (max-width: 1100px) { .cols-3, .cols-4 { grid-template-columns: repeat(2, minmax(0,1fr)); } .cols-3-1 { grid-template-columns: 1fr; } }
@media (max-width: 700px) { .cols-2, .cols-3, .cols-4 { grid-template-columns: 1fr; } }
.pagehead { margin-bottom: 1.25rem; }
.stat-label { font-size: .78rem; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
.stat-value { font-size: 1.9rem; font-weight: 650; letter-spacing: -.02em; line-height: 1.2; margin: .15rem 0; }
.stat-value.small { font-size: 1.25rem; }
.badge { display: inline-block; font-size: .74rem; font-weight: 600; padding: .1rem .5rem; border-radius: 999px; background: var(--surface-2); color: var(--muted); vertical-align: middle; }
.badge.ok { background: var(--ok-soft); color: var(--ok); } .badge.warn { background: var(--warn-soft); color: var(--warn); } .badge.danger { background: var(--danger-soft); color: var(--danger); } .badge.accent { background: var(--accent-soft); color: var(--accent); }
.flash { padding: .6rem .9rem; border-radius: 8px; margin-bottom: 1rem; border: 1px solid var(--border); background: var(--surface); }
.flash-ok { border-color: var(--ok); background: var(--ok-soft); } .flash-error { border-color: var(--danger); background: var(--danger-soft); } .flash-warn { border-color: var(--warn); background: var(--warn-soft); }
.error { color: var(--danger); }
.prose { max-width: 70ch; }
.steps li { margin-bottom: .4rem; }
/* Forms */
.stack { display: flex; flex-direction: column; gap: .8rem; }
.row { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; margin-top: .5rem; }
.inline { display: inline; }
label { display: flex; flex-direction: column; gap: .3rem; font-size: .9rem; font-weight: 500; }
input, select, textarea { font: inherit; color: var(--text); background: var(--surface); border: 1px solid var(--border); border-radius: 7px; padding: .5rem .65rem; }
input:focus, select:focus, textarea:focus { border-color: var(--accent); outline: 2px solid var(--accent-soft); }
textarea { min-height: 6rem; resize: vertical; }
.btn { font: inherit; font-size: .9rem; font-weight: 550; padding: .45rem .9rem; border-radius: 7px; border: 1px solid var(--border); background: var(--surface); color: var(--text); cursor: pointer; display: inline-flex; align-items: center; gap: .35rem; }
.btn:hover { background: var(--surface-2); text-decoration: none; }
.btn.small { padding: .25rem .6rem; font-size: .8rem; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
.btn-primary:hover { filter: brightness(1.08); background: var(--accent); }
.btn-danger { color: var(--danger); border-color: var(--danger); }
.btn-ghost { border-color: transparent; background: transparent; }
.iconbtn { font: inherit; width: 34px; height: 34px; border-radius: 7px; border: 1px solid var(--border); background: var(--surface); color: var(--text); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
/* Tables and definition lists */
.table { width: 100%; border-collapse: collapse; font-size: .92rem; }
.table th { text-align: left; font-weight: 600; font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); padding: .45rem .5rem; border-bottom: 1px solid var(--border); }
.table td { padding: .55rem .5rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
.table tr:last-child td { border-bottom: 0; }
.table.compact td, .table.compact th { padding: .35rem .45rem; }
.kv { display: grid; grid-template-columns: max-content 1fr; gap: .35rem 1rem; margin: 0; }
.kv dt { color: var(--muted); font-size: .85rem; } .kv dd { margin: 0; }
pre.token { background: var(--surface-2); padding: .75rem; border-radius: 8px; overflow-x: auto; user-select: all; }
/* Confidence bar: inline meter used across belief/memory views */
.conf { display: inline-flex; align-items: center; gap: .4rem; font-variant-numeric: tabular-nums; }
.conf-bar { width: 64px; height: 6px; border-radius: 3px; background: var(--surface-2); overflow: hidden; }
.conf-bar i { display: block; height: 100%; background: var(--accent); }
+83
View File
@@ -0,0 +1,83 @@
/* Noema UI enhancements. No frameworks, no inline scripts (CSP). */
(function () {
"use strict";
const root = document.documentElement;
const csrf = () => (document.querySelector('meta[name="csrf-token"]') || {}).content || "";
// Theme: auto → light → dark, remembered per browser.
try {
const saved = localStorage.getItem("noema.theme");
if (saved) root.setAttribute("data-theme", saved);
} catch (e) { /* storage unavailable */ }
function cycleTheme() {
const order = ["auto", "light", "dark"];
const cur = root.getAttribute("data-theme") || "auto";
const next = order[(order.indexOf(cur) + 1) % order.length];
root.setAttribute("data-theme", next);
try { localStorage.setItem("noema.theme", next); } catch (e) { /* ignore */ }
}
// fetch wrapper that carries the CSRF token and same-origin credentials.
async function api(method, url, body) {
const res = await fetch(url, {
method, credentials: "same-origin",
headers: Object.assign({ "X-CSRF-Token": csrf() }, body ? { "Content-Type": "application/json" } : {}),
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 204) return null;
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((data.error && data.error.message) || res.statusText);
return data;
}
// Server-Sent Events helper: handlers keyed by event name; html payloads are
// inserted into [data-stream-target] containers by the caller.
function stream(url, handlers, opts) {
opts = opts || {};
let es, retry = 1000;
function connect() {
es = new EventSource(url, { withCredentials: true });
es.onopen = () => { retry = 1000; document.body.classList.remove("stream-offline"); };
es.onerror = () => { es.close(); document.body.classList.add("stream-offline"); setTimeout(connect, retry); retry = Math.min(retry * 2, 30000); };
Object.keys(handlers).forEach((name) => es.addEventListener(name, (ev) => {
let data = ev.data;
try { data = JSON.parse(ev.data); } catch (e) { /* plain text */ }
handlers[name](data, ev);
}));
}
connect();
return { close: () => es && es.close() };
}
document.addEventListener("DOMContentLoaded", () => {
const tt = document.querySelector("[data-theme-toggle]");
if (tt) tt.addEventListener("click", cycleTheme);
const nt = document.querySelector("[data-nav-toggle]");
const nav = document.querySelector("[data-nav]");
if (nt && nav) nt.addEventListener("click", () => { const open = nav.classList.toggle("open"); nt.setAttribute("aria-expanded", String(open)); });
// Confirmation on destructive buttons.
document.addEventListener("click", (ev) => {
const el = ev.target.closest("[data-confirm]");
if (el && !window.confirm(el.getAttribute("data-confirm"))) { ev.preventDefault(); ev.stopImmediatePropagation(); }
}, true);
// Auto-dismiss flashes.
document.querySelectorAll(".flash").forEach((f) => setTimeout(() => f.remove(), 8000));
// Keyboard: "t" toggles theme, "g d" dashboard, "g m" minds, "g s" system.
let pending = "";
document.addEventListener("keydown", (ev) => {
if (ev.target.matches("input, textarea, select") || ev.metaKey || ev.ctrlKey || ev.altKey) return;
if (ev.key === "t" && !pending) { cycleTheme(); return; }
if (ev.key === "g") { pending = "g"; setTimeout(() => (pending = ""), 800); return; }
if (pending === "g") {
const map = { d: "/", m: "/minds", s: "/system", a: "/audit", l: "/llm", p: "/approvals" };
if (map[ev.key]) location.href = map[ev.key];
pending = "";
}
});
});
window.noema = { api, stream, csrf };
})();
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="#2f6fed"/><circle cx="16" cy="16" r="6" fill="none" stroke="#fff" stroke-width="2.5"/><circle cx="16" cy="16" r="1.8" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 235 B

+60
View File
@@ -0,0 +1,60 @@
{{define "layout"}}<!doctype html>
<html lang="en" data-theme="auto">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{.CSRF}}">
<title>{{if .Title}}{{.Title}} · {{end}}Noema</title>
<link rel="stylesheet" href="/static/app.css">
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
<script src="/static/app.js" defer></script>
</head>
<body class="{{if .Principal}}app{{else}}bare{{end}}">
{{if .Principal}}
<a class="skip" href="#main">Skip to content</a>
<header class="topbar">
<button class="iconbtn nav-toggle" aria-label="Toggle navigation" aria-expanded="false" data-nav-toggle>☰</button>
<a class="brand" href="/"><span class="brand-mark" aria-hidden="true"></span>Noema</a>
<div class="topbar-spacer"></div>
<span class="muted small">{{.Principal.User.Username}} · {{.Principal.User.Role}}</span>
<button class="iconbtn" data-theme-toggle aria-label="Toggle colour theme" title="Toggle theme (t)">◐</button>
<form method="post" action="/logout" class="inline"><input type="hidden" name="_csrf" value="{{.CSRF}}"><button class="btn btn-ghost small" type="submit">Sign out</button></form>
</header>
<div class="shell">
<nav class="sidenav" aria-label="Primary" data-nav>
{{template "nav" .}}
</nav>
<main id="main" class="content" tabindex="-1">
{{with .Flash}}<div class="flash flash-{{.Kind}}" role="status">{{.Message}}</div>{{end}}
{{template "content" .}}
</main>
</div>
{{else}}
<main id="main" class="bare-main">
{{with .Flash}}<div class="flash flash-{{.Kind}}" role="status">{{.Message}}</div>{{end}}
{{template "content" .}}
</main>
{{end}}
<footer class="footer muted small">Noema {{.Version}} · models cognitive processes; it does not claim consciousness. <a href="/about">Interpretation boundary</a></footer>
</body>
</html>{{end}}
{{define "nav"}}
<div class="navgroup"><div class="navhead">Observe</div>
<a href="/" {{if eq .Path "/"}}aria-current="page"{{end}}>Dashboard</a>
<a href="/minds" {{if hasPrefix .Path "/minds"}}aria-current="page"{{end}}>Minds</a>
<a href="/approvals" {{if hasPrefix .Path "/approvals"}}aria-current="page"{{end}}>Approvals</a>
</div>
<div class="navgroup"><div class="navhead">Configure</div>
<a href="/llm" {{if hasPrefix .Path "/llm"}}aria-current="page"{{end}}>LLM</a>
<a href="/prompts" {{if hasPrefix .Path "/prompts"}}aria-current="page"{{end}}>Prompts</a>
<a href="/integrations" {{if hasPrefix .Path "/integrations"}}aria-current="page"{{end}}>Integrations</a>
<a href="/policies" {{if hasPrefix .Path "/policies"}}aria-current="page"{{end}}>Policies</a>
</div>
<div class="navgroup"><div class="navhead">System</div>
<a href="/system" {{if eq .Path "/system"}}aria-current="page"{{end}}>System</a>
<a href="/audit" {{if eq .Path "/audit"}}aria-current="page"{{end}}>Audit</a>
{{if .Principal.User.Role.AtLeast "administrator"}}<a href="/admin/users" {{if hasPrefix .Path "/admin/users"}}aria-current="page"{{end}}>Users</a>{{end}}
<a href="/account" {{if eq .Path "/account"}}aria-current="page"{{end}}>Account</a>
</div>
{{end}}
+9
View File
@@ -0,0 +1,9 @@
{{define "content"}}
<h1>Interpretation boundary</h1>
<div class="prose">
<p>Noema models cognitive processes. It does not prove, and its authors do not claim, that it is conscious, sentient, self-aware or a person.</p>
<p>Components are named after psychological concepts (attention, working memory, emotion-like state, curiosity, drives, self-model, reflection, dreaming) because those names describe the functional role each plays in the architecture. They are ordinary data structures and deterministic rules, sometimes assisted by language models.</p>
<p>When a mind reports that it is "curious" or "concerned", it is reporting the value of a stored scalar that the architecture updates by rule. When it says "I believe", it refers to a belief record with evidence links. Such reports are useful because they are grounded in inspectable state, not because they indicate experience.</p>
<p>Treat resemblance to emotion, preference or identity as a property of the model being run, to be studied. Rely on the evidence and provenance views rather than on fluent self-description.</p>
</div>
{{end}}
+42
View File
@@ -0,0 +1,42 @@
{{define "content"}}
<div class="pagehead"><h1>Account</h1><p class="muted">Signed in as <strong>{{.Principal.User.Username}}</strong> ({{.Principal.User.Role}}).</p></div>
{{with .Data.NewToken}}<div class="card highlight"><h2>New API token</h2><p>Copy it now. It will not be shown again.</p><pre class="token"><code>{{.}}</code></pre><p class="muted small">Use it as <code>Authorization: Bearer …</code> or <code>NOEMA_API_TOKEN</code> for noemactl.</p></div>{{end}}
<section class="grid cols-2">
<div class="card">
<h2>Sessions</h2>
<table class="table compact">
<thead><tr><th>Where</th><th>Client</th><th>Last seen</th><th></th></tr></thead>
<tbody>{{range .Data.Sessions}}<tr>
<td>{{.IP}}{{if eq .ID $.Principal.SessionID}} <span class="badge">current</span>{{end}}</td>
<td class="small muted truncate" title="{{.UserAgent}}">{{.UserAgent}}</td>
<td class="small muted nowrap">{{ago .LastSeenAt}}</td>
<td>{{if ne .ID $.Principal.SessionID}}<form method="post" action="/account/sessions/{{.ID}}/revoke" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="btn small">Revoke</button></form>{{end}}</td>
</tr>{{end}}</tbody>
</table>
</div>
<div class="card">
<h2>Change password</h2>
<form method="post" action="/account/password" class="stack">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label>Current password <input type="password" name="current" required autocomplete="current-password"></label>
<label>New password <input type="password" name="password" required minlength="12" autocomplete="new-password"></label>
<label>Confirm <input type="password" name="confirm" required minlength="12" autocomplete="new-password"></label>
<button class="btn btn-primary" type="submit">Change password</button>
<p class="muted small">Other sessions are signed out when the password changes.</p>
</form>
</div>
<div class="card">
<h2>API tokens</h2>
<table class="table compact">
<thead><tr><th>Name</th><th>Created</th><th>Last used</th><th></th></tr></thead>
<tbody>{{range .Data.Tokens}}<tr><td>{{.Name}}</td><td class="small muted">{{date .CreatedAt}}</td><td class="small muted">{{if .LastUsedAt}}{{ago .LastUsedAt}}{{else}}never{{end}}</td>
<td><form method="post" action="/account/tokens/{{.ID}}/revoke" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="btn small">Revoke</button></form></td></tr>{{else}}<tr><td colspan="4" class="muted">No tokens.</td></tr>{{end}}</tbody>
</table>
<form method="post" action="/account/tokens" class="row">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<input name="name" placeholder="Token name (e.g. laptop cli)" required maxlength="64">
<button class="btn" type="submit">Create token</button>
</form>
</div>
</section>
{{end}}
+15
View File
@@ -0,0 +1,15 @@
{{define "content"}}
<div class="pagehead"><h1>Audit</h1><p class="muted">Every administrative change, append-only.</p></div>
<div class="card">
<table class="table compact">
<thead><tr><th>#</th><th>When</th><th>Actor</th><th>Action</th><th>Target</th><th>Details</th><th>IP</th></tr></thead>
<tbody>{{range .Data.Entries}}<tr>
<td class="muted">{{.Seq}}</td><td class="nowrap muted small">{{.At}}</td>
<td>{{if .ActorName}}{{.ActorName}}{{else}}<span class="muted">{{.ActorKind}}</span>{{end}}</td>
<td><code>{{.Action}}</code></td><td class="small">{{.TargetKind}} <span class="muted">{{.TargetID}}</span></td>
<td><code class="small">{{printf "%s" .Details}}</code></td><td class="muted small">{{.IP}}</td>
</tr>{{else}}<tr><td colspan="7" class="muted">No entries.</td></tr>{{end}}</tbody>
</table>
{{with .Data.Next}}<p><a class="btn" href="/audit?before={{.}}">Older</a></p>{{end}}
</div>
{{end}}
+27
View File
@@ -0,0 +1,27 @@
{{define "content"}}
<div class="pagehead"><h1>Dashboard</h1><p class="muted">System overview. Minds, cognition and approvals appear here as they come online.</p></div>
<section class="grid cols-3">
<div class="card stat"><div class="stat-label">Running minds</div><div class="stat-value">{{.Data.Minds}}</div><div class="muted small">No minds yet. Create one under Minds.</div></div>
<div class="card stat"><div class="stat-label">System health</div><div class="stat-value">{{if .Data.Status.Database.OK}}<span class="ok">Healthy</span>{{else}}<span class="danger">Degraded</span>{{end}}</div><div class="muted small">Database {{if .Data.Status.Database.OK}}connected{{else}}unavailable{{end}} · up {{duration .Data.Status.UptimeSeconds}}</div></div>
<div class="card stat"><div class="stat-label">Outstanding approvals</div><div class="stat-value">0</div><div class="muted small">Capability layer arrives in a later phase.</div></div>
</section>
<section class="grid cols-2">
<div class="card">
<h2>Recent administrative activity</h2>
{{if .Data.Audit}}
<table class="table compact">
<thead><tr><th>When</th><th>Actor</th><th>Action</th><th>Target</th></tr></thead>
<tbody>{{range .Data.Audit}}<tr><td class="nowrap muted">{{.At}}</td><td>{{.ActorName}}{{if not .ActorName}}<span class="muted">{{.ActorKind}}</span>{{end}}</td><td><code>{{.Action}}</code></td><td class="muted small">{{.TargetKind}} {{.TargetID}}</td></tr>{{end}}</tbody>
</table>
{{else}}<p class="muted">Nothing yet.</p>{{end}}
</div>
<div class="card">
<h2>Getting started</h2>
<ol class="steps">
<li>Add an LLM provider under <a href="/llm">LLM</a>.</li>
<li>Create a mind under <a href="/minds">Minds</a> and shape its personality.</li>
<li>Open its conversation page and watch the thought stream.</li>
</ol>
</div>
</section>
{{end}}
+7
View File
@@ -0,0 +1,7 @@
{{define "content"}}
<div class="card auth-card">
<h1>{{.Data.Status}}</h1>
<p>{{.Data.Message}}</p>
<p><a href="/">Back to dashboard</a></p>
</div>
{{end}}
+14
View File
@@ -0,0 +1,14 @@
{{define "content"}}
<div class="card auth-card">
<div class="brand big"><span class="brand-mark" aria-hidden="true"></span>Noema</div>
<p class="muted">Sign in to the cognitive runtime.</p>
<form method="post" action="/login" class="stack">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<input type="hidden" name="next" value="{{.Data.Next}}">
<label>Username <input name="username" autocomplete="username" required autofocus></label>
<label>Password <input type="password" name="password" autocomplete="current-password" required></label>
{{with .Data.Error}}<p class="error" role="alert">{{.}}</p>{{end}}
<button class="btn btn-primary" type="submit">Sign in</button>
</form>
</div>
{{end}}
+3
View File
@@ -0,0 +1,3 @@
{{define "content"}}
<div class="pagehead"><h1>{{.Title}}</h1><p class="muted">{{.Data}}</p></div>
{{end}}
+15
View File
@@ -0,0 +1,15 @@
{{define "content"}}
<div class="card auth-card">
<div class="brand big"><span class="brand-mark" aria-hidden="true"></span>Noema</div>
<h1>First-run setup</h1>
<p class="muted">No accounts exist yet. Create the first administrator. This page disappears once an account exists.</p>
<form method="post" action="/setup" class="stack">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label>Username <input name="username" autocomplete="username" required minlength="2" maxlength="64" autofocus></label>
<label>Password <input type="password" name="password" autocomplete="new-password" required minlength="12"></label>
<label>Confirm password <input type="password" name="confirm" autocomplete="new-password" required minlength="12"></label>
{{with .Data.Error}}<p class="error" role="alert">{{.}}</p>{{end}}
<button class="btn btn-primary" type="submit">Create administrator</button>
</form>
</div>
{{end}}
+28
View File
@@ -0,0 +1,28 @@
{{define "content"}}
<div class="pagehead"><h1>System</h1><p class="muted">Runtime, database and migration state.</p></div>
{{$s := .Data}}
<section class="grid cols-4">
<div class="card stat"><div class="stat-label">Version</div><div class="stat-value small">{{$s.Version}}</div><div class="muted small">{{$s.GoVersion}}</div></div>
<div class="card stat"><div class="stat-label">Uptime</div><div class="stat-value small">{{duration $s.UptimeSeconds}}</div></div>
<div class="card stat"><div class="stat-label">Goroutines</div><div class="stat-value small">{{$s.Goroutines}}</div><div class="muted small">{{$s.NumCPU}} CPUs</div></div>
<div class="card stat"><div class="stat-label">Memory</div><div class="stat-value small">{{bytes $s.HeapBytes}}</div><div class="muted small">heap · {{bytes $s.SysBytes}} from OS</div></div>
</section>
<section class="grid cols-2">
<div class="card">
<h2>Database</h2>
<dl class="kv">
<dt>Status</dt><dd>{{if $s.Database.OK}}<span class="badge ok">connected</span>{{else}}<span class="badge danger">error</span> {{$s.Database.Error}}{{end}}</dd>
<dt>Server</dt><dd class="small">{{$s.Database.Version}}</dd>
<dt>pgvector</dt><dd>{{if $s.Database.PGVector}}<span class="badge ok">available</span>{{else}}<span class="badge warn">not installed</span> <span class="muted small">semantic retrieval falls back to text and graph</span>{{end}}</dd>
<dt>Pool</dt><dd>{{$s.Database.PoolTotal}} open · {{$s.Database.PoolIdle}} idle · {{$s.Database.PoolMax}} max</dd>
</dl>
</div>
<div class="card">
<h2>Migrations</h2>
<table class="table compact">
<thead><tr><th>#</th><th>Name</th><th>State</th><th>Applied</th></tr></thead>
<tbody>{{range $s.Migrations}}<tr><td>{{printf "%04d" .Version}}</td><td><code>{{.Name}}</code></td><td>{{if .Drift}}<span class="badge danger">drift</span>{{else if .Applied}}<span class="badge ok">applied</span>{{else}}<span class="badge warn">pending</span>{{end}}</td><td class="muted small">{{.AppliedAt}}</td></tr>{{end}}</tbody>
</table>
</div>
</section>
{{end}}
+36
View File
@@ -0,0 +1,36 @@
{{define "content"}}
<div class="pagehead"><h1>Users</h1><p class="muted">Operator accounts. Roles: observer (read), operator (act), administrator (configure).</p></div>
<section class="grid cols-3-1">
<div class="card">
<table class="table">
<thead><tr><th>Username</th><th>Role</th><th>State</th><th>MFA</th><th>Created</th><th></th></tr></thead>
<tbody>{{range .Data.Users}}<tr>
<td><strong>{{.Username}}</strong>{{if eq .ID $.Principal.User.ID}} <span class="muted small">(you)</span>{{end}}</td>
<td>
<form method="post" action="/admin/users/{{.ID}}" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}">
<select name="role" aria-label="Role for {{.Username}}" {{if eq .ID $.Principal.User.ID}}disabled{{end}}>
{{range $r := roles}}<option value="{{$r}}" {{if eq $r .Role}}selected{{end}}>{{$r}}</option>{{end}}
</select>
{{if ne .ID $.Principal.User.ID}}<button class="btn small" type="submit" name="action" value="role">Set</button>{{end}}
</form>
</td>
<td>{{if .Disabled}}<span class="badge danger">disabled</span>{{else}}<span class="badge ok">active</span>{{end}}{{if .LockedUntil}} <span class="badge warn" title="Locked after failed logins">locked</span>{{end}}</td>
<td>{{if .TOTPEnabled}}<span class="badge ok">TOTP</span>{{else}}<span class="muted">off</span>{{end}}</td>
<td class="muted small nowrap">{{date .CreatedAt}}</td>
<td>{{if ne .ID $.Principal.User.ID}}<form method="post" action="/admin/users/{{.ID}}" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}">
{{if .Disabled}}<button class="btn small" name="action" value="enable">Enable</button>{{else}}<button class="btn small btn-danger" name="action" value="disable" data-confirm="Disable {{.Username}} and revoke their sessions?">Disable</button>{{end}}</form>{{end}}</td>
</tr>{{end}}</tbody>
</table>
</div>
<div class="card">
<h2>Create user</h2>
<form method="post" action="/admin/users" class="stack">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label>Username <input name="username" required minlength="2" maxlength="64" autocomplete="off"></label>
<label>Password <input type="password" name="password" required minlength="12" autocomplete="new-password"></label>
<label>Role <select name="role">{{range roles}}<option value="{{.}}" {{if eq . "observer"}}selected{{end}}>{{.}}</option>{{end}}</select></label>
<button class="btn btn-primary" type="submit">Create</button>
</form>
</div>
</section>
{{end}}