Redesign the dashboard and sign-in page with the Lightwell design language
CI / test (push) Successful in 2m27s

Warm neutrals, teal accent, 15px type, and Lightwell's component shapes. Status is a badge with text
instead of a colored dot. Every destructive action asks first, naming the object and what is lost.
Dialogs trap focus, close on Escape and return focus. The sidebar is a drawer on phones, there is a
theme switch, loading shows skeleton rows, and empty states explain what belongs there. Emoji and
dashes are gone, and server errors are capitalized and tell visitors what to do next.
Adds scripts/lint-design.mjs and docs/design.md.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-26 18:18:55 -06:00
1 parent 2b7cb5dd35
commit d74e6c1e72
7 files changed
+536 -253

No files matched your search

+321 -187
View File
@@ -3,158 +3,217 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Hammerhead</title>
<style>
/* Design tokens: the only place raw colors, spacing and sizes are defined (same system as Lightwell). */
/* tokens:begin Design tokens, the Lightwell design language (docs/design.md). This is the only place raw colors and sizes are defined. */
:root {
--accent:#0891b2; --accent-contrast:#fff;
--bg:#fafafa; --surface:#fff; --surface-2:#f4f4f5; --border:#e4e4e7; --border-strong:#d4d4d8;
--text:#09090b; --text-muted:#52525b; --text-faint:#71717a;
--accent-text:color-mix(in srgb, var(--accent) 85%, #000);
--accent-soft:color-mix(in srgb, var(--accent) 10%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 86%, #000);
--focus:color-mix(in srgb, var(--accent) 65%, #fff);
--success:#1c6b3a; --success-bg:#e3f1e8; --warning:#85560a; --warning-bg:#f9eed6; --danger:#a3271e; --danger-bg:#f9e6e3;
--overlay:rgb(15 18 22 / .5); --shadow:0 8px 24px rgb(15 18 22 / .14);
--accent:#1f5c5c; --accent-contrast:#ffffff;
--bg:#f6f5f3; --surface:#ffffff; --surface-2:#efede9; --border:#e0ddd7; --border-strong:#c4c0b8;
--text:#1d1c1a; --text-muted:#5f5b54; --text-faint:#7a766e;
--accent-text:var(--accent);
--accent-soft:color-mix(in srgb, var(--accent) 9%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 88%, #000000);
--focus:color-mix(in srgb, var(--accent) 70%, #ffffff);
--success:#1c6b3a; --success-bg:#e3f1e8; --warning:#85560a; --warning-bg:#f9eed6; --danger:#a3271e; --danger-bg:#f9e6e3; --neutral-bg:#eceae6;
--overlay:rgb(20 19 17 / 0.55); --shadow:0 8px 24px rgb(20 19 17 / 0.14);
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px; --s6:32px; --s7:48px; --s8:64px;
--radius:8px; --radius-lg:12px;
--radius:6px; --radius-lg:10px; --border-w:1px;
--font:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--font-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
--text-xs:12px; --text-sm:13px; --text-base:14px; --text-lg:16px; --text-xl:20px; --text-2xl:26px;
--control-h:36px; --sidebar-w:248px; --content-max:1180px; --ease:140ms ease;
--text-xs:12px; --text-sm:13px; --text-base:15px; --text-lg:18px; --text-xl:22px; --text-2xl:28px; --leading:1.5;
--sidebar-w:232px; --content-max:1120px; --touch:44px; --control-h:38px;
--z-sticky:20; --z-overlay:100; --z-toast:200; --ease:140ms ease;
color-scheme:light;
}
@media (prefers-color-scheme: dark) { :root {
--accent:#22b8d4; --accent-contrast:#04222a;
--bg:#09090b; --surface:#0f0f11; --surface-2:#18181b; --border:#27272a; --border-strong:#3f3f46;
--text:#fafafa; --text-muted:#a1a1aa; --text-faint:#71717a;
--accent-text:color-mix(in srgb, var(--accent) 80%, #fff); --accent-soft:color-mix(in srgb, var(--accent) 18%, var(--surface)); --accent-hover:color-mix(in srgb, var(--accent) 85%, #fff);
--success:#86d3a0; --success-bg:#16301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18;
--overlay:rgb(0 0 0 / .65); --shadow:0 8px 24px rgb(0 0 0 / .5); color-scheme:dark;
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {
--bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943;
--text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84;
--accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff);
--accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 85%, #ffffff);
--success:#86d3a0; --success-bg:#17301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --neutral-bg:#2b2a27;
--overlay:rgb(0 0 0 / 0.65); --shadow:0 8px 24px rgb(0 0 0 / 0.5); color-scheme:dark;
} }
* { box-sizing:border-box; }
:root[data-theme="dark"] {
--bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943;
--text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84;
--accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff);
--accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 85%, #ffffff);
--success:#86d3a0; --success-bg:#17301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --neutral-bg:#2b2a27;
--overlay:rgb(0 0 0 / 0.65); --shadow:0 8px 24px rgb(0 0 0 / 0.5); color-scheme:dark;
}
/* tokens:end */
*, *::before, *::after { box-sizing:border-box; }
html, body { height:100%; }
body { margin:0; background:var(--bg); color:var(--text); font:var(--text-base)/1.5 var(--font); -webkit-font-smoothing:antialiased; }
html { -webkit-text-size-adjust:100%; }
body { margin:0; background:var(--bg); color:var(--text); font:var(--text-base)/var(--leading) var(--font); -webkit-font-smoothing:antialiased; }
h1, h2, h3, h4 { margin:0; line-height:1.25; font-weight:600; letter-spacing:-.01em; }
h1 { font-size:var(--text-2xl); } h2 { font-size:var(--text-xl); } h3 { font-size:var(--text-lg); }
p { margin:0 0 var(--s3); }
a { color:var(--accent-text); text-underline-offset:2px; }
a:hover { text-decoration-thickness:2px; }
:focus-visible { outline:2px solid var(--focus); outline-offset:2px; border-radius:var(--radius); }
h1, h2 { line-height:1.25; letter-spacing:-.01em; }
.sr-only { position:absolute; width:1px; height:1px; margin:-1px; padding:0; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; border:0; }
.skip { position:absolute; left:var(--s3); top:-100px; z-index:var(--z-toast); background:var(--surface); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius); padding:var(--s2) var(--s3); }
.skip:focus { top:var(--s3); }
.dim { color:var(--text-muted); } .err { color:var(--danger); } .small { font-size:var(--text-sm); }
.num { font-variant-numeric:tabular-nums; }
code, .mono { font-family:var(--font-mono); font-size:var(--text-sm); }
.icon { width:18px; height:18px; flex:none; stroke-width:1.75; }
/* shell */
.app { display:flex; min-height:100vh; }
.side { width:var(--sidebar-w); flex:none; background:var(--bg); border-right:1px solid var(--border); display:flex; flex-direction:column; position:sticky; top:0; height:100vh; padding:var(--s5) var(--s3); gap:2px; overflow-y:auto; }
.brand { display:flex; align-items:center; gap:var(--s2); font-weight:700; font-size:var(--text-lg); letter-spacing:-.02em; padding:0 var(--s3) var(--s4); }
.brand svg { color:var(--accent); }
.orgsel { margin:0 0 var(--s2); }
.navsec { font-size:var(--text-xs); font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--text-faint); padding:var(--s4) var(--s3) var(--s1); }
a.navitem { text-decoration:none; }
.navitem { display:flex; align-items:center; gap:var(--s3); width:100%; background:none; border:0; color:var(--text-muted); padding:var(--s2) var(--s3); min-height:34px; border-radius:var(--radius); cursor:pointer; font:inherit; font-weight:500; text-align:left; transition:background var(--ease), color var(--ease); }
.shell { display:grid; grid-template-columns:var(--sidebar-w) minmax(0,1fr); min-height:100vh; }
.sidebar { position:sticky; top:0; height:100vh; display:flex; flex-direction:column; gap:var(--s3); padding:var(--s5) var(--s3); background:var(--surface); border-right:var(--border-w) solid var(--border); overflow-y:auto; }
.sidebar-head { display:flex; align-items:center; justify-content:space-between; gap:var(--s2); }
.sidebar button.sidebar-close { display:none; }
.brand { display:flex; align-items:center; gap:var(--s2); font-size:var(--text-lg); font-weight:700; letter-spacing:-.02em; padding:0 var(--s3); color:var(--text); text-decoration:none; }
.brand .icon { color:var(--accent-text); }
.orgsel select { width:100%; }
.navsec { font-size:var(--text-xs); font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--text-muted); padding:var(--s4) var(--s3) var(--s1); }
.nav { display:flex; flex-direction:column; gap:2px; }
.navitem { display:flex; align-items:center; gap:var(--s3); width:100%; min-height:36px; padding:var(--s2) var(--s3); border-radius:var(--radius); color:var(--text-muted); text-decoration:none; font:inherit; font-weight:500; background:none; border:0; cursor:pointer; text-align:left; }
.navitem:hover { background:var(--surface-2); color:var(--text); }
.navitem { border-radius:var(--radius); }
.navitem.on { background:var(--surface-2); color:var(--text); }
.navitem.on svg { color:var(--accent); }
.navitem svg { flex:none; }
.grow { flex:1; }
.userbox { border-top:1px solid var(--border); padding-top:var(--s3); margin-top:var(--s2); }
.userbox .who { padding:var(--s1) var(--s3) var(--s2); font-size:var(--text-xs); color:var(--text-muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.content { flex:1; min-width:0; }
.navitem.on { background:var(--accent-soft); color:var(--accent-text); }
.grow { flex:1 1 auto; min-width:0; }
.userbox { border-top:var(--border-w) solid var(--border); padding-top:var(--s3); }
.userbox .who { padding:var(--s1) var(--s3) var(--s2); font-size:var(--text-sm); color:var(--text-muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.content { min-width:0; }
main { max-width:var(--content-max); margin:0 auto; padding:var(--s6) var(--s5) var(--s8); }
.topbar, .backdrop { display:none; }
.page-h { display:flex; align-items:flex-start; justify-content:space-between; gap:var(--s4); margin-bottom:var(--s5); flex-wrap:wrap; }
.page-h h1 { font-size:var(--text-2xl); font-weight:600; margin:0; }
.page-h p { margin:var(--s1) 0 0; color:var(--text-muted); }
.page-h p { margin:var(--s1) 0 0; color:var(--text-muted); max-width:70ch; }
.page-h .btn { white-space:nowrap; }
/* cards, layout */
.card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-lg); padding:var(--s5); margin-bottom:var(--s4); }
.card { background:var(--surface); border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); padding:var(--s5); margin-bottom:var(--s4); overflow-x:auto; }
.card.flush { padding:0; overflow:hidden; }
.card > h2 { font-size:var(--text-lg); font-weight:600; margin:0 0 var(--s3); }
.card > h2 { font-size:var(--text-lg); margin:0 0 var(--s3); }
.row-h { display:flex; align-items:center; justify-content:flex-end; gap:var(--s3); margin-bottom:var(--s3); flex-wrap:wrap; }
.row-h h2 { display:none; }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:var(--s4); align-items:end; }
.stack { display:flex; flex-direction:column; gap:var(--s3); }
.sect-h { padding:var(--s3) var(--s4); font-weight:600; border-bottom:var(--border-w) solid var(--border); }
.scroll { overflow-x:auto; }
.actions { display:flex; gap:var(--s2); flex-wrap:wrap; }
.form-actions { justify-content:flex-end; margin-top:var(--s2); }
.toolbar { display:flex; align-items:center; gap:var(--s3); margin-bottom:var(--s3); flex-wrap:wrap; }
.toolbar input[type=search] { max-width:320px; }
.center { max-width:440px; margin:var(--s8) auto; padding:0 var(--s4); }
.center .brand { justify-content:center; font-size:var(--text-xl); padding-bottom:var(--s4); }
/* choices (add resource) */
.choices { display:grid; grid-template-columns:repeat(auto-fill,minmax(210px,1fr)); gap:var(--s3); }
.choice { text-align:left; background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-lg); padding:var(--s3) var(--s4); color:var(--text); cursor:pointer; font:inherit; display:flex; flex-direction:column; gap:var(--s1); }
.choice:hover { border-color:var(--border-strong); background:var(--surface-2); }
.choice { text-align:left; background:var(--surface); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius-lg); padding:var(--s3) var(--s4); color:var(--text); cursor:pointer; font:inherit; display:flex; flex-direction:column; gap:var(--s1); }
.choice:hover { background:var(--surface-2); }
.choice.on { border-color:var(--accent); background:var(--accent-soft); }
.choice-t { font-weight:600; }
.sect-h { padding:var(--s3) var(--s4); font-weight:600; border-bottom:1px solid var(--border); }
.progress { height:8px; background:var(--surface-2); border-radius:999px; overflow:hidden; }
.progress > div { height:100%; background:var(--accent); transition:width .4s ease; }
/* update progress and log */
.progress { height:6px; background:var(--surface-2); border-radius:999px; overflow:hidden; }
.progress > div { height:100%; background:var(--accent); transition:width var(--ease); }
.progress > div.bad { background:var(--danger); }
.logbox { margin:0; background:var(--bg); border:1px solid var(--border); border-radius:var(--radius); padding:var(--s3); font:var(--text-xs)/1.6 var(--font-mono); max-height:260px; overflow:auto; white-space:pre-wrap; word-break:break-word; }
.logbox { margin:0; background:var(--bg); border:var(--border-w) solid var(--border); border-radius:var(--radius); padding:var(--s3); font:var(--text-xs)/1.6 var(--font-mono); max-height:260px; overflow:auto; white-space:pre-wrap; word-break:break-word; }
/* forms */
label { display:flex; flex-direction:column; gap:var(--s1); font-size:var(--text-sm); font-weight:600; color:var(--text); }
label.chk { flex-direction:row; align-items:center; gap:var(--s2); font-size:var(--text-base); font-weight:400; }
input, select, textarea { background:var(--surface); color:var(--text); border:1px solid var(--border-strong); border-radius:var(--radius); padding:var(--s2) var(--s3); min-height:var(--control-h); font:inherit; font-weight:400; min-width:0; width:100%; }
label.chk { flex-direction:row; align-items:flex-start; gap:var(--s2); font-size:var(--text-base); font-weight:400; }
input, select, textarea { background:var(--surface); color:var(--text); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius); padding:var(--s2) var(--s3); min-height:var(--control-h); font:inherit; font-weight:400; min-width:0; width:100%; }
textarea { min-height:96px; resize:vertical; }
input[type=checkbox] { width:16px; height:16px; min-height:0; padding:0; accent-color:var(--accent); }
input[type=checkbox] { width:16px; height:16px; min-height:0; padding:0; margin-top:3px; accent-color:var(--accent); }
input:focus, select:focus, textarea:focus { outline:2px solid var(--focus); outline-offset:0; border-color:var(--accent); }
input:disabled, select:disabled { opacity:.6; }
input[aria-invalid="true"] { border-color:var(--danger); }
.sub { color:var(--text-muted); font-size:var(--text-sm); font-weight:400; }
/* buttons: one height, one shape; ghost is the quiet variant */
button.btn, button.ghost { display:inline-flex; align-items:center; justify-content:center; gap:var(--s2); min-height:var(--control-h); padding:0 var(--s4); border-radius:var(--radius); font:inherit; font-weight:500; cursor:pointer; white-space:nowrap; transition:background var(--ease), border-color var(--ease); }
button.btn { background:var(--accent); color:var(--accent-contrast); border:1px solid var(--accent); }
/* buttons: one height and shape. btn is the primary action, ghost the default, danger and quiet the others */
button.btn, button.ghost, .btn-link { display:inline-flex; align-items:center; justify-content:center; gap:var(--s2); min-height:var(--control-h); padding:0 var(--s4); border-radius:var(--radius); font:inherit; font-weight:500; cursor:pointer; white-space:nowrap; text-decoration:none; transition:background var(--ease), border-color var(--ease); }
button.btn { background:var(--accent); color:var(--accent-contrast); border:var(--border-w) solid var(--accent); }
button.btn:hover { background:var(--accent-hover); border-color:var(--accent-hover); }
button.ghost { background:var(--surface); border:1px solid var(--border-strong); color:var(--text); }
button.ghost { background:var(--surface); border:var(--border-w) solid var(--border-strong); color:var(--text); }
button.ghost:hover { background:var(--surface-2); }
button.danger:hover { border-color:var(--danger); color:var(--danger); background:var(--danger-bg); }
button.quiet { background:transparent; border-color:transparent; }
button.danger { color:var(--danger); border-color:var(--danger); }
button.danger:hover { background:var(--danger-bg); }
button.btn-icon { width:var(--control-h); padding:0; }
td button.ghost, .actions button.ghost, .tgt button.ghost { min-height:30px; padding:0 var(--s3); font-size:var(--text-sm); }
button:disabled { opacity:.55; cursor:not-allowed; }
/* toolbar above a table */
.toolbar { display:flex; align-items:center; gap:var(--s3); margin-bottom:var(--s3); }
.toolbar input[type=search] { max-width:320px; }
.page-h .btn { white-space:nowrap; }
/* tables */
table { width:100%; border-collapse:collapse; }
th { text-align:left; font-size:var(--text-sm); color:var(--text-muted); font-weight:600; padding:var(--s3) var(--s4); border-bottom:1px solid var(--border); white-space:nowrap; background:var(--surface); }
td { padding:var(--s3) var(--s4); border-bottom:1px solid var(--border); vertical-align:top; }
th { text-align:left; font-size:var(--text-sm); color:var(--text-muted); font-weight:600; padding:var(--s3) var(--s4); border-bottom:var(--border-w) solid var(--border); white-space:nowrap; background:var(--surface); }
td { padding:var(--s3) var(--s4); border-bottom:var(--border-w) solid var(--border); vertical-align:top; }
tbody tr:hover td { background:var(--surface-2); }
tr:last-child td { border-bottom:0; }
.card > table, .card > .scroll > table { margin:calc(var(--s1) * -1) 0; }
.scroll { overflow-x:auto; }
.actions { display:flex; gap:var(--s2); flex-wrap:wrap; }
/* badges and status */
.tag { display:inline-flex; align-items:center; gap:var(--s1); padding:2px var(--s2); border-radius:999px; font-size:var(--text-xs); font-weight:600; background:var(--surface-2); color:var(--text-muted); white-space:nowrap; }
.dot { display:inline-block; width:8px; height:8px; border-radius:50%; background:var(--text-faint); margin-right:var(--s2); flex:none; }
.dot.ok { background:var(--success); box-shadow:0 0 0 3px var(--success-bg); } .dot.bad { background:var(--danger); } .dot.warn { background:var(--warning); }
code, .mono { font-family:var(--font-mono); font-size:var(--text-sm); }
pre { background:var(--surface-2); border:1px solid var(--border); border-radius:var(--radius); padding:var(--s3); overflow:auto; margin:var(--s2) 0 0; user-select:all; white-space:pre-wrap; word-break:break-all; }
.dim { color:var(--text-muted); } .err { color:var(--danger); } .small { font-size:var(--text-sm); }
.empty { color:var(--text-muted); padding:var(--s7) var(--s5); text-align:center; border:1px dashed var(--border-strong); border-radius:var(--radius-lg); margin:var(--s3); }
.center { max-width:400px; margin:11vh auto; padding:0 var(--s4); }
.center .brand { justify-content:center; font-size:var(--text-xl); padding-bottom:var(--s5); }
.banner { background:var(--warning-bg); border:1px solid var(--warning); color:var(--warning); border-radius:var(--radius); padding:var(--s3) var(--s4); margin-bottom:var(--s4); }
/* badges and tags: status always carries a text label, never color alone */
.badge { display:inline-flex; align-items:center; gap:var(--s1); padding:2px var(--s2); border-radius:999px; font-size:var(--text-xs); font-weight:600; background:var(--neutral-bg); color:var(--text-muted); white-space:nowrap; }
.badge-success { background:var(--success-bg); color:var(--success); }
.badge-warning { background:var(--warning-bg); color:var(--warning); }
.badge-danger { background:var(--danger-bg); color:var(--danger); }
.badge-accent { background:var(--accent-soft); color:var(--accent-text); }
.tag { display:inline-block; padding:1px var(--s2); border:var(--border-w) solid var(--border); border-radius:var(--radius); font-size:var(--text-xs); color:var(--text-muted); }
pre { background:var(--surface-2); border:var(--border-w) solid var(--border); border-radius:var(--radius); padding:var(--s3); overflow:auto; margin:var(--s2) 0 0; user-select:all; white-space:pre-wrap; word-break:break-all; }
/* dialogs, tabs, toast */
.shade { position:fixed; inset:0; background:var(--overlay); display:flex; align-items:flex-start; justify-content:center; padding:4vh var(--s3); overflow:auto; z-index:10; }
.modal { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-lg); padding:var(--s5); width:100%; max-width:780px; box-shadow:var(--shadow); }
.modal h2 { margin:0 0 var(--s4); font-size:var(--text-lg); }
fieldset { border:1px solid var(--border); border-radius:var(--radius-lg); padding:var(--s4); margin:0; }
/* empty state, notice, loading */
.empty { text-align:center; padding:var(--s7) var(--s5); color:var(--text-muted); border:var(--border-w) dashed var(--border-strong); border-radius:var(--radius-lg); margin:var(--s3); }
.empty h3 { color:var(--text); margin-bottom:var(--s1); }
.empty p { margin:0 auto var(--s3); max-width:56ch; }
.empty .icon { width:28px; height:28px; margin:0 auto var(--s3); color:var(--text-faint); display:block; }
.card > .empty { margin:0; }
.notice { padding:var(--s3) var(--s4); border-radius:var(--radius); border:var(--border-w) solid var(--border); background:var(--surface-2); font-size:var(--text-sm); margin-bottom:var(--s4); }
.notice-danger { background:var(--danger-bg); border-color:var(--danger); color:var(--danger); }
.notice-success { background:var(--success-bg); border-color:var(--success); color:var(--success); }
.skeleton { background:linear-gradient(90deg, var(--surface-2), var(--border), var(--surface-2)); background-size:200% 100%; animation:shimmer 1.3s infinite; border-radius:var(--radius); }
@keyframes shimmer { to { background-position:-200% 0; } }
/* dialogs, tabs, toasts */
.shade { position:fixed; inset:0; background:var(--overlay); display:grid; place-items:start center; padding:var(--s7) var(--s4); overflow:auto; z-index:var(--z-overlay); }
.modal { background:var(--surface); border-radius:var(--radius-lg); box-shadow:var(--shadow); padding:var(--s5); width:min(480px,100%); }
.modal.wide { width:min(780px,100%); }
.modal h2 { margin:0 0 var(--s3); font-size:var(--text-lg); }
fieldset { border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); padding:var(--s4); margin:0; min-width:0; }
legend { font-size:var(--text-sm); font-weight:600; color:var(--text-muted); padding:0 var(--s2); }
.tgt { display:grid; grid-template-columns:1.2fr 1.4fr .7fr auto auto; gap:var(--s2); align-items:end; }
.pillrow { display:flex; flex-wrap:wrap; gap:var(--s2) var(--s4); }
.tabs2 { display:inline-flex; margin-bottom:var(--s4); border:1px solid var(--border-strong); border-radius:var(--radius); overflow:hidden; max-width:100%; flex-wrap:wrap; background:var(--surface); }
.tabs2 { display:inline-flex; margin-bottom:var(--s4); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius); overflow:hidden; max-width:100%; flex-wrap:wrap; background:var(--surface); }
.tabs2 button { background:var(--surface); border:0; color:var(--text-muted); padding:0 var(--s3); min-height:var(--control-h); cursor:pointer; font:inherit; font-weight:500; }
.tabs2 button + button { border-left:1px solid var(--border-strong); }
.tabs2 button + button { border-left:var(--border-w) solid var(--border-strong); }
.tabs2 button:hover { background:var(--surface-2); }
.tabs2 button.on { background:var(--accent-soft); color:var(--accent-text); }
#toast { position:fixed; bottom:var(--s4); right:var(--s4); background:var(--text); color:var(--bg); padding:var(--s3) var(--s4); border-radius:var(--radius); z-index:30; display:none; max-width:min(420px,90vw); box-shadow:var(--shadow); font-size:var(--text-sm); }
#toast.bad { background:var(--danger); color:var(--accent-contrast); }
@media (prefers-reduced-motion: reduce) { * { transition:none !important; } }
@media (max-width:860px) {
.app { flex-direction:column; } .side { width:100%; height:auto; position:static; flex-direction:row; flex-wrap:wrap; align-items:center; border-right:0; border-bottom:1px solid var(--border); }
.navsec, .userbox .who { display:none; } .userbox { border:0; margin:0; padding:0; display:flex; } .orgsel { margin:0; }
main { padding:var(--s5) var(--s4) var(--s7); } .tgt { grid-template-columns:1fr 1fr; }
}
.toasts { position:fixed; right:var(--s4); bottom:var(--s4); z-index:var(--z-toast); display:flex; flex-direction:column; gap:var(--s2); max-width:min(360px, calc(100vw - 32px)); }
.toast { background:var(--text); color:var(--bg); padding:var(--s3) var(--s4); border-radius:var(--radius); box-shadow:var(--shadow); font-size:var(--text-sm); }
.toast-error { background:var(--danger); color:var(--accent-contrast); }
@media (prefers-reduced-motion: reduce) { .skeleton { animation:none; } * { transition:none !important; } html { scroll-behavior:auto; } }
@media (pointer: coarse) {
button.btn, button.ghost, .navitem, input, select { min-height:var(--touch); }
td button.ghost, .actions button.ghost, .tgt button.ghost { min-height:36px; }
input, select, textarea { font-size:16px; }
}
@media (max-width:860px) {
.shell { grid-template-columns:minmax(0,1fr); grid-template-rows:auto minmax(0,1fr); }
.sidebar { display:none; }
.sidebar.open { display:flex; position:fixed; inset:0 auto 0 0; width:min(280px,85vw); height:100dvh; z-index:var(--z-overlay); box-shadow:var(--shadow); }
.sidebar button.sidebar-close { display:inline-flex; }
.topbar { display:flex; align-items:center; justify-content:space-between; min-height:52px; padding:var(--s2) var(--s4); background:var(--surface); border-bottom:var(--border-w) solid var(--border); position:sticky; top:0; z-index:var(--z-sticky); }
.backdrop.open { display:block; position:fixed; inset:0; background:var(--overlay); z-index:calc(var(--z-overlay) - 1); }
main { padding:var(--s5) var(--s4) var(--s7); }
.tgt { grid-template-columns:1fr 1fr; }
}
@media (max-width:640px) {
.modal { padding:var(--s4); }
.shade { padding:var(--s4) var(--s3); }
.toasts { left:var(--s3); right:var(--s3); bottom:var(--s3); max-width:none; }
.page-h > .btn { width:100%; }
}
</style>
</head>
<body>
<div id="app"></div>
<div id="toast"></div>
<script>
// ---------- tiny toolkit ----------
function h(tag, props, ...kids) {
@@ -169,11 +228,21 @@ function h(tag, props, ...kids) {
return el;
}
const $app = document.getElementById('app');
let toastTimer;
// theme: follows the system unless the person picked one
try { const t = localStorage.getItem('theme'); if (t) document.documentElement.dataset.theme = t; } catch (e) {}
function toggleTheme() {
const dark = document.documentElement.dataset.theme ? document.documentElement.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
const next = dark ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
try { localStorage.setItem('theme', next); } catch (e) {}
}
// short confirmations at the bottom right; errors stay longer and are announced as alerts
let toastBox;
function toast(msg, bad) {
const t = document.getElementById('toast');
t.textContent = msg; t.className = bad ? 'bad' : ''; t.style.display = 'block';
clearTimeout(toastTimer); toastTimer = setTimeout(() => t.style.display = 'none', bad ? 6000 : 2500);
if (!toastBox) { toastBox = h('div', {class:'toasts', 'aria-live':'polite'}); document.body.append(toastBox); }
const t = h('div', {class:'toast' + (bad ? ' toast-error' : ''), role:bad ? 'alert' : 'status'}, msg);
toastBox.append(t);
setTimeout(() => t.remove(), bad ? 7000 : 3000);
}
async function api(method, url, body) {
const r = await fetch(url, { method, headers: {'Content-Type':'application/json'}, body: body !== undefined ? JSON.stringify(body) : undefined });
@@ -254,14 +323,57 @@ function dialogForm(title, intro, fields, label, submit) {
h('div', {class:'actions'}, h('button', {class:'btn', onclick: act(async () => { await submit(); close(); })}, label), h('button', {class:'ghost', onclick:() => close()}, 'Cancel'))));
}
const cmdBox = t => h('pre', {class:'mono'}, t);
// A dialog: focus moves in, Tab stays inside, Escape closes, and focus goes back to what opened it.
// Forms pass sticky (they are wide and ignore clicks outside); onClose runs however the dialog ends.
let dialogSeq = 0;
function modal(title, body, opts = {}) {
const opener = document.activeElement, id = 'dialog-title-' + (++dialogSeq);
const shade = h('div', {class:'shade'});
const close = () => shade.remove();
const box = h('div', {class:'modal' + (opts.wide || opts.sticky ? ' wide' : ''), role:'dialog', 'aria-modal':'true', 'aria-labelledby':id, tabindex:'-1'}, h('h2', {id}, title), body);
let closed = false;
const close = () => {
if (closed) return; closed = true;
document.removeEventListener('keydown', onKey, true); shade.remove();
if (opener && opener.isConnected && opener.focus) opener.focus();
if (opts.onClose) opts.onClose();
};
const focusables = () => [...box.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')].filter(e => e.offsetParent !== null);
function onKey(e) {
const top = [...document.querySelectorAll('.shade')].pop();
if (top !== shade) return;
if (e.key === 'Escape') { e.stopPropagation(); close(); }
else if (e.key === 'Tab') {
const f = focusables(); if (!f.length) { e.preventDefault(); return; }
const first = f[0], last = f[f.length - 1];
if (e.shiftKey && (document.activeElement === first || document.activeElement === box)) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
}
shade.addEventListener('mousedown', e => { if (e.target === shade && !opts.sticky) close(); });
shade.append(h('div', {class:'modal'}, h('h2', {}, title), body));
shade.append(box);
document.body.append(shade);
document.addEventListener('keydown', onKey, true);
(box.querySelector('input,select,textarea') || focusables()[0] || box).focus();
return close;
}
// A confirmation that names what it affects and says what is lost. Resolves true only when confirmed.
function confirmDialog({title, body, confirm = 'Confirm', cancel = 'Cancel', danger = true}) {
return new Promise(resolve => {
let close;
const done = v => { resolve(v); close(); };
close = modal(title, h('div', {class:'stack'}, h('p', {class:'dim', style:'margin:0'}, body),
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => done(false)}, cancel),
h('button', {class:danger ? 'ghost danger' : 'btn', onclick:() => done(true)}, confirm))), {onClose:() => resolve(false)});
});
}
// button action with a confirmation in front of it
const ask = (opts, run) => act(async () => { if (await confirmDialog(opts)) await run(); });
// status pill: color always travels with a text label
const badge = (text, tone) => h('span', {class:'badge' + (tone ? ' badge-' + tone : '')}, text);
const emptyState = (name, title, text, action) => h('div', {class:'empty'}, icon(name, 22), h('h3', {}, title), h('p', {}, text), action || null);
// placeholder rows while a page loads, so the layout does not jump
const skeleton = () => h('div', {class:'card', role:'status', 'aria-busy':'true'}, h('span', {class:'sr-only'}, 'Loading'),
...[60, 100, 90, 100, 75].map(w => h('div', {class:'skeleton', style:`height:16px;width:${w}%;margin-bottom:14px`})));
const modalOpen = () => !!document.querySelector('.shade');
// ---------- icons (lucide-style, static strings) ----------
@@ -283,14 +395,21 @@ const ICONS = {
egress:'<path d="M7 17 17 7M8 7h9v9"/>',
alerts:'<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
out:'<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>',
menu:'<path d="M4 6h16M4 12h16M4 18h16"/>',
close:'<path d="M18 6 6 18M6 6l12 12"/>',
add:'<path d="M5 12h14M12 5v14"/>',
check:'<path d="M20 6 9 17l-5-5"/>',
light:'<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>',
dark:'<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
alert:'<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4M12 17h.01"/>',
};
function icon(name, size = 18) {
const s = document.createElement('span');
s.style.display = 'inline-flex';
s.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${ICONS[name] || ''}</svg>`;
s.style.display = 'inline-flex'; s.setAttribute('aria-hidden', 'true');
s.innerHTML = `<svg class="icon" xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" style="width:${size}px;height:${size}px">${ICONS[name] || ''}</svg>`;
return s;
}
const brand = () => h('div', {class:'brand'}, icon('fin', 24), 'Hammerhead');
const brand = () => h('div', {class:'brand'}, icon('fin', 22), 'Hammerhead');
// ---------- state ----------
const S = { me: null, org: localStorage.getItem('org'), tab: 'ports', st: null, pub: null };
@@ -354,8 +473,8 @@ const O = p => `/api/orgs/${S.org}${p}`;
const PAGES = {
mine: ['My apps', 'Apps you have been given access to.'],
ports: ['Resources', 'Open ports on your edge IPs and send them to your services, with an optional login in front.'],
sites: ['Sites', 'Private networks (or the edge machine itself) that hold your services.'],
ports: ['Resources', 'Websites and ports that visitors reach through your edges, with an optional login in front.'],
sites: ['Sites', 'Private networks, or the edge machine itself, that hold your services.'],
domains: ['Domains', 'Domains you own. Web hostnames must live under one of them.'],
access: ['Access control', 'Members, roles, invitations and API keys for this organization.'],
egress: ['Mail egress', 'Send your mail server\'s outgoing mail from an edge\'s public IP, so reverse DNS, SPF and your HELO name all line up.'],
@@ -381,10 +500,10 @@ function selectTab(key) { S.tab = key; if (SRV[key]) serverTab = SRV[key]; }
window.addEventListener('popstate', () => { const k = tabFromPath(location.pathname); if (S.me && k) { selectTab(k); render(); } });
async function render() {
const nav = (key, label, ic) => h('a', {class:'navitem' + (S.tab === key ? ' on' : ''), href:TAB_PATH[key], onclick:e => {
const nav = (key, label, ic) => h('a', {class:'navitem' + (S.tab === key ? ' on' : ''), href:TAB_PATH[key], 'aria-current':S.tab === key ? 'page' : null, onclick:e => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button) return;
e.preventDefault(); selectTab(key); history.pushState(null, '', TAB_PATH[key]); render(); }}, icon(ic), label);
const side = [h('div', {}, brand())];
const side = [h('div', {class:'sidebar-head'}, brand(), h('button', {class:'ghost quiet btn-icon sidebar-close', 'aria-label':'Close menu', onclick:() => setDrawer(false)}, icon('close')))];
if (S.me.orgs.length) side.push(h('div', {class:'orgsel'}, h('select', {onchange: act(async e => { S.org = e.target.value; localStorage.setItem('org', S.org); await render(); })},
S.me.orgs.map(o => h('option', {value:o.id, selected:o.id === S.org}, o.name)))));
const all = [];
@@ -404,15 +523,30 @@ async function render() {
if (!all.includes(S.tab)) S.tab = all[0];
if (location.pathname !== TAB_PATH[S.tab]) history.replaceState(null, '', TAB_PATH[S.tab]);
side.push(h('div', {class:'grow'}), h('div', {class:'userbox'}, h('div', {class:'who'}, S.me.email), nav('account', 'Account', 'account'),
h('button', {class:'navitem', onclick:toggleTheme}, icon('dark'), 'Switch theme'),
h('button', {class:'navitem', onclick: act(async () => { await api('POST', '/api/logout'); S.me = null; boot(); })}, icon('out'), 'Sign out')));
$app.replaceChildren(h('div', {class:'app'}, h('aside', {class:'side'}, side), h('div', {class:'content'}, h('main', {id:'view'}))));
S.drawn = null;
$app.replaceChildren(h('a', {class:'skip', href:'#view'}, 'Skip to content'), h('div', {class:'shell'},
h('div', {class:'topbar'}, brand(), h('button', {class:'ghost quiet btn-icon', id:'menu-btn', 'aria-label':'Open menu', 'aria-controls':'sidebar', 'aria-expanded':'false', onclick:() => setDrawer(true)}, icon('menu'))),
h('div', {class:'backdrop', id:'backdrop', onclick:() => setDrawer(false)}),
h('aside', {class:'sidebar', id:'sidebar', 'aria-label':'Main'}, side),
h('div', {class:'content'}, h('main', {id:'view', tabindex:'-1'}))));
await refresh();
}
// the sidebar is a slide-in drawer on small screens
function setDrawer(open) {
const sb = document.getElementById('sidebar'), bd = document.getElementById('backdrop'), mb = document.getElementById('menu-btn');
if (!sb) return;
sb.classList.toggle('open', open); bd.classList.toggle('open', open); mb.setAttribute('aria-expanded', open);
if (open) { const f = sb.querySelector('a,button'); if (f) f.focus(); } else mb.focus();
}
document.addEventListener('keydown', e => { if (e.key === 'Escape' && !modalOpen()) { const sb = document.getElementById('sidebar'); if (sb && sb.classList.contains('open')) setDrawer(false); } });
function pageHeader(tab) {
const [t, d] = PAGES[tab] || ['', ''];
const a = S.headerAction;
return h('div', {class:'page-h'}, h('div', {}, h('h1', {}, t), d ? h('p', {}, d) : null), a ? h('button', {class:'btn', onclick:a[1]}, '+ ' + a[0]) : null);
return h('div', {class:'page-h'}, h('div', {}, h('h1', {}, t), d ? h('p', {}, d) : null), a ? h('button', {class:'btn', onclick:a[1]}, icon('add', 16), a[0]) : null);
}
let seq = 0;
@@ -421,10 +555,12 @@ async function refresh() {
try {
if (S.org && !SRV[S.tab] && S.tab !== 'account') await loadOrg();
S.headerAction = null;
const first = document.getElementById('view');
if (first && S.drawn !== S.tab) first.replaceChildren(pageHeader(S.tab), skeleton()); // loading placeholder, not a spinner
const view = await (SRV[S.tab] ? views.server() : views[S.tab]());
if (my !== seq) return;
const v = document.getElementById('view');
if (v) v.replaceChildren(pageHeader(S.tab), view);
if (v) { v.replaceChildren(pageHeader(S.tab), view); S.drawn = S.tab; }
} catch (e) { if (e.message !== 'Please sign in') toast(e.message, true); }
}
setInterval(() => {
@@ -443,7 +579,7 @@ views.mine = async () => {
const list = await api('GET', '/api/me/resources');
return card('Apps you can open', list.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Organization', ''].map(t => h('th', {}, t)))),
h('tbody', {}, list.map(r => h('tr', {}, h('td', {}, r.name), h('td', {class:'dim'}, r.org), h('td', {}, h('a', {href:r.url, target:'_blank', rel:'noopener'}, h('button', {class:'ghost'}, 'Open'))))))) :
h('div', {class:'empty'}, 'Nothing here yet. Ask an administrator to give you access to an app.'));
emptyState('mine', 'No apps yet', 'Apps you are given access to appear here. Ask an administrator for access.'));
};
// ----- ports -----
@@ -457,12 +593,12 @@ views.ports = async () => {
const rows = st.mappings.slice().sort((a, b) => a.edgeId.localeCompare(b.edgeId) || a.bindIp.localeCompare(b.bindIp) || a.port - b.port);
const isWebRow = m => m.proto === 'http' || m.proto === 'https' || m.proto === 'tls';
const section = (title, list) => list.length ? h('div', {class:'card flush'}, h('div', {class:'sect-h'}, title + ' (' + list.length + ')'), h('div', {class:'scroll'}, h('table', {},
h('thead', {}, h('tr', {}, ['', 'Name', title === 'Websites' ? 'Address' : 'Public address', 'Targets', 'Traffic', ''].map(t => h('th', {}, t)))),
h('thead', {}, h('tr', {}, ['Status', 'Name', title === 'Websites' ? 'Address' : 'Public address', 'Targets', 'Traffic (24 hours)', ''].map(t => h('th', {}, t)))),
h('tbody', {}, list.map(m => mappingRow(m, st, traffic[m.id])))))) : null;
const table = rows.length ? h('div', {}, section('Websites', rows.filter(isWebRow)), section('Ports', rows.filter(m => !isWebRow(m)))) : h('div', {class:'card'}, h('div', {class:'empty'}, 'Nothing here yet. Use Add resource to share a website or open a port.'));
const table = rows.length ? h('div', {}, section('Websites', rows.filter(isWebRow)), section('Ports', rows.filter(m => !isWebRow(m)))) : h('div', {class:'card'}, emptyState('ports', 'No resources yet', 'A resource is a website or a port that visitors reach through one of your edges.', h('button', {class:'btn', onclick:() => portForm()}, icon('add', 16), 'Add resource')));
S.headerAction = ['Add resource', () => portForm()];
const q = h('input', {type:'search', placeholder:'Search resources…', oninput:() => { const t = q.value.toLowerCase(); table.querySelectorAll('tbody tr').forEach(tr => { tr.style.display = tr.textContent.toLowerCase().includes(t) ? '' : 'none'; }); }});
return h('div', {}, unsetDash ? h('div', {class:'banner'}, 'Some ports have a login in front of them, but the dashboard URL isn\'t set. Visitors are sent back to this address\'s login page, so set it under Server → Settings if people reach Hammerhead by a different address.') : null,
return h('div', {}, unsetDash ? h('div', {class:'notice'}, 'Some ports have a login in front of them, but the dashboard URL isn\'t set. Visitors are sent back to this address\'s login page, so set it under Server → Settings if people reach Hammerhead by a different address.') : null,
rows.length > 5 ? h('div', {class:'toolbar'}, q) : null, table);
};
@@ -481,37 +617,35 @@ const portText = m => m.portEnd > m.port ? m.port + '-' + m.portEnd : String(m.p
function mappingRow(m, st, series) {
const edge = byId(st.edges, m.edgeId), s = m.status || {};
let dot = 'dot', why = 'paused';
let tone = '', label = 'Paused', why = 'paused';
if (m.enabled) {
const th = s.targets || [];
if (!m.statusKnown) { dot += ' warn'; why = 'waiting for the edge'; }
else if (s.error) { dot += ' bad'; why = s.error; }
else if (th.length && th.every(t => !t.healthy)) { dot += ' bad'; why = 'no healthy target'; }
else if (th.some(t => !t.healthy)) { dot += ' warn'; why = 'some targets are down'; }
else { dot += ' ok'; why = 'listening'; }
if (!m.statusKnown) { tone = 'warning'; label = 'Waiting'; why = 'waiting for the edge'; }
else if (s.error) { tone = 'danger'; label = 'Error'; why = s.error; }
else if (th.length && th.every(t => !t.healthy)) { tone = 'danger'; label = 'Down'; why = 'no healthy target'; }
else if (th.some(t => !t.healthy)) { tone = 'warning'; label = 'Degraded'; why = 'some targets are down'; }
else { tone = 'success'; label = 'Listening'; why = 'listening'; }
}
const web = m.proto === 'http' || m.proto === 'https';
const prot = m.auth && (m.auth.sso || m.auth.password || m.auth.pin || m.auth.email);
const pubAddr = web ? m.proto + '://' + (m.hostname || '*') + (m.port === (m.proto === 'https' ? 443 : 80) ? '' : ':' + portText(m)) : (m.hostname ? m.hostname + ' @ ' : '') + m.bindIp + ':' + portText(m);
return h('tr', {},
h('td', {title:why}, h('span', {class:dot})),
h('td', {}, h('div', {}, m.name, ' ', h('span', {class:'tag'}, m.proto), m.proxyProtocol ? h('span', {class:'tag', style:'margin-left:4px', title:'the target is told the real client address'}, 'proxy ' + m.proxyProtocol) : null, prot ? h('span', {class:'tag', style:'margin-left:4px', title:'login required'}, '🔒') : null),
h('td', {title:why}, badge(label, tone)),
h('td', {}, h('div', {}, m.name, ' ', h('span', {class:'tag'}, m.proto), m.proxyProtocol ? h('span', {class:'tag', style:'margin-left:4px', title:'the target is told the real client address'}, 'proxy ' + m.proxyProtocol) : null, prot ? h('span', {style:'margin-left:4px', title:'Visitors have to sign in'}, badge('Login', 'accent')) : null),
s.error ? h('div', {class:'err small mono'}, s.error) : null,
s.cert && m.proto === 'https' ? h('div', {class:'small ' + (s.cert.startsWith('error') ? 'err' : 'dim')}, '🔐 ' + s.cert) : null),
s.cert && m.proto === 'https' ? h('div', {class:'small ' + (s.cert.startsWith('error') ? 'err' : 'dim')}, 'Certificate: ' + s.cert) : null),
h('td', {class:'mono'}, pubAddr, h('div', {class:'dim small'}, (edge ? edge.name : '?') + (web && m.bindIp !== '0.0.0.0' ? ' · ' + m.bindIp : ''))),
h('td', {class:'mono small'}, m.targets.map((t, i) => {
const th = (s.targets || [])[i], site = byId(st.sites, t.siteId);
return h('div', {title:th && !th.healthy ? th.detail : ''}, h('span', {class:'dot ' + (th ? (th.healthy ? 'ok' : 'bad') : '')}), (site ? site.name : '?') + ' → ' + t.host + ':' + t.port);
return h('div', {title:th && !th.healthy ? th.detail : ''}, badge(th ? (th.healthy ? 'Up' : 'Down') : 'Unknown', th ? (th.healthy ? 'success' : 'danger') : ''), ' ', (site ? site.name : '?') + ' → ' + t.host + ':' + t.port);
})),
h('td', {class:'small dim'}, m.total && m.total.conns ? [`${m.total.conns} conn`, h('br'), `↑${bytes(m.total.bytesIn)} ↓${bytes(m.total.bytesOut)}`] : '—', spark(series)),
h('td', {class:'small dim'}, m.total && m.total.conns ? [`${m.total.conns} conn`, h('br'), `${bytes(m.total.bytesIn)} in, ${bytes(m.total.bytesOut)} out`] : 'None yet', spark(series)),
h('td', {}, h('div', {class:'actions'},
h('button', {class:'ghost', onclick:() => portForm(m)}, 'Edit'),
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', O('/mappings/' + m.id), {...m, enabled:!m.enabled}); refresh(); })}, m.enabled ? 'Pause' : 'Enable'),
web && prot ? h('button', {class:'ghost', onclick:() => shareDialog(m)}, 'Share') : null,
web && prot ? h('button', {class:'ghost', title:'Everyone currently signed in to this resource has to sign in again', onclick: act(async () => {
if (confirm(`Sign everyone out of ${m.name}?`)) { await api('POST', O(`/mappings/${m.id}/revoke`)); toast('Visitors signed out'); }
})}, 'Sign out visitors') : null,
h('button', {class:'ghost danger', onclick: act(async () => { if (confirm(`Close ${m.name}?`)) { await api('DELETE', O('/mappings/' + m.id)); refresh(); } })}, 'Close'))));
web && prot ? h('button', {class:'ghost', title:'Everyone currently signed in to this resource has to sign in again', onclick: ask({title:`Sign everyone out of ${m.name}?`, body:'Everyone who is signed in to this resource has to sign in again. They can sign back in straight away.', confirm:'Sign out visitors', danger:false}, async () => { await api('POST', O(`/mappings/${m.id}/revoke`)); toast('Visitors signed out'); })}, 'Sign out visitors') : null,
h('button', {class:'ghost danger', onclick: ask({title:`Close ${m.name}?`, body:'The port closes and visitors can no longer reach it. You can open it again later.', confirm:'Close resource'}, async () => { await api('DELETE', O('/mappings/' + m.id)); refresh(); })}, 'Close'))));
}
function portForm(existing) {
@@ -553,8 +687,8 @@ function portForm(existing) {
const th = h('input', {value:t.host, oninput:e => t.host = e.target.value, placeholder:'127.0.0.1 or 192.168.1.20'});
const tp = h('input', {type:'number', value:t.port, placeholder:'port', oninput:e => t.port = +e.target.value});
return h('div', {class:'tgt'}, field(i ? 'Target ' + (i + 1) : 'Target site', site), field('Host', th), field('Port', tp),
h('button', {class:'ghost', type:'button', title:'Pick a Docker container on that site', onclick:() => discover(t, th, tp)}, '🐳'),
targets.length > 1 ? h('button', {class:'ghost danger', type:'button', onclick:() => { targets.splice(i, 1); drawTargets(); }}, '✕') : h('span'));
h('button', {class:'ghost', type:'button', title:'Pick a Docker container on that site', onclick:() => discover(t, th, tp)}, 'Containers'),
targets.length > 1 ? h('button', {class:'ghost danger', type:'button', 'aria-label':'Remove target', onclick:() => { targets.splice(i, 1); drawTargets(); }}, icon('close', 16)) : h('span'));
}), many ? h('button', {class:'ghost', type:'button', style:'align-self:flex-start', onclick:() => { targets.push({siteId:targets[0] ? targets[0].siteId : '', host:'127.0.0.1', port:''}); drawTargets(); }}, '+ Add another target (load balance / failover)') : null);
};
const discover = act(async (t, th, tp) => {
@@ -573,7 +707,7 @@ function portForm(existing) {
h('select', {onchange:e => r.action = e.target.value}, ['allow', 'deny'].map(v => h('option', {value:v, selected:r.action === v}, v === 'allow' ? 'Skip login' : 'Block'))),
h('select', {onchange:e => r.match = e.target.value}, ['path', 'ip'].map(v => h('option', {value:v, selected:r.match === v}, v === 'path' ? 'Path starts with' : 'IP / CIDR'))),
h('input', {value:r.value, oninput:e => r.value = e.target.value, placeholder:r.match === 'ip' ? '203.0.113.0/24' : '/api/public'}),
h('button', {class:'ghost danger', type:'button', onclick:() => { rules.splice(i, 1); drawRules(); }}, '✕'))),
h('button', {class:'ghost danger', type:'button', 'aria-label':'Remove rule', onclick:() => { rules.splice(i, 1); drawRules(); }}, icon('close', 16)))),
h('button', {class:'ghost', type:'button', style:'align-self:flex-start', onclick:() => { rules.push({action:'allow', match:'path', value:''}); drawRules(); }}, '+ Add a rule'));
const drawWeb = () => {
const p = protoSel.value, web = p === 'http' || p === 'https';
@@ -621,12 +755,12 @@ function portForm(existing) {
// The add flow for a new resource: a few short steps that reuse the controls above.
function wizardModal() {
const TYPES = [
['https', '🌐', 'Website', 'A site or web app, with an automatic HTTPS certificate.'],
['tcp', '🎮', 'Game or app port (TCP)', 'Minecraft, SSH, databases: anything over TCP.'],
['udp', '📡', 'UDP port', 'Voice chat, some games, DNS.'],
['both', '🔁', 'TCP + UDP', 'The same port for both, as many games want.'],
['tls', '🔒', 'TLS passthrough', 'Route encrypted traffic by hostname without decrypting it.'],
['http', '🌍', 'Website, plain HTTP', 'No certificate: for internal use or behind another proxy.'],
['https', 'Website', 'A site or web app, with an automatic HTTPS certificate.'],
['tcp', 'Game or app port (TCP)', 'Minecraft, SSH, databases: anything over TCP.'],
['udp', 'UDP port', 'Voice chat, some games, DNS.'],
['both', 'TCP + UDP', 'The same port for both, as many games want.'],
['tls', 'TLS passthrough', 'Route encrypted traffic by hostname without decrypting it.'],
['http', 'Website, plain HTTP', 'No certificate: for internal use or behind another proxy.'],
];
const [accessFs, rulesFs, advFs] = [...webBox.children];
let step = 0, chosen = false;
@@ -692,8 +826,8 @@ function portForm(existing) {
back.style.display = step ? '' : 'none';
const kids = [h('h3', {style:'margin:0'}, titles[cur])];
if (cur === 'type') {
kids.push(h('div', {class:'choices'}, TYPES.map(([v, icon, t, d]) => h('button', {type:'button', class:'choice' + (chosen && protoSel.value === v ? ' on' : ''), onclick:() => pick(v)},
h('div', {class:'choice-t'}, icon + ' ' + t), h('div', {class:'small dim'}, d)))));
kids.push(h('div', {class:'choices'}, TYPES.map(([v, t, d]) => h('button', {type:'button', class:'choice' + (chosen && protoSel.value === v ? ' on' : ''), onclick:() => pick(v)},
h('div', {class:'choice-t'}, t), h('div', {class:'small dim'}, d)))));
} else if (cur === 'target') {
if (st.edges.length > 1) kids.push(field('Public server (edge)', edgeSel, 'The server that listens for visitors.'));
kids.push(h('fieldset', {}, h('legend', {}, 'Where does the traffic go?'), tgtBox), proxyBox);
@@ -731,8 +865,8 @@ async function shareDialog(m) {
out.replaceChildren(h('p', {class:'small'}, 'Copy this now; it is not shown again:'), cmdBox(r.url)); }
)}, 'Create link')), out,
links.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Label', 'Used', 'Expires', ''].map(t => h('th', {}, t)))),
h('tbody', {}, links.map(l => h('tr', {}, h('td', {}, l.name || '—'), h('td', {}, l.uses + (l.maxUses ? ' / ' + l.maxUses : '')), h('td', {}, l.expires.startsWith('0001') ? 'never' : new Date(l.expires).toLocaleString()),
h('td', {}, h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', O(`/mappings/${m.id}/shares/${l.id}`)); draw(); })}, 'Revoke')))))) : h('div', {class:'dim'}, 'No links yet.'));
h('tbody', {}, links.map(l => h('tr', {}, h('td', {}, l.name || 'Unnamed'), h('td', {}, l.uses + (l.maxUses ? ' / ' + l.maxUses : '')), h('td', {}, l.expires.startsWith('0001') ? 'never' : new Date(l.expires).toLocaleString()),
h('td', {}, h('button', {class:'ghost danger', onclick: ask({title:`Revoke ${l.name || 'this share link'}?`, body:'Anyone using the link loses access straight away. This cannot be undone.', confirm:'Revoke link'}, async () => { await api('DELETE', O(`/mappings/${m.id}/shares/${l.id}`)); draw(); })}, 'Revoke')))))) : h('div', {class:'dim'}, 'No links yet.'));
};
await draw(); modal('Share links for ' + m.name, body);
}
@@ -748,19 +882,19 @@ views.sites = async () => {
h('div', {class:'actions'}, h('button', {class:'btn', onclick:() => { close(); add(); }}, 'Add site'), h('button', {class:'ghost', onclick:() => close()}, 'Cancel')))); }];
const latest = await latestVersions();
return h('div', {},
card('Sites', h('table', {}, h('thead', {}, h('tr', {}, ['', 'Name', 'Kind', 'Tunnels to', 'Version', 'Containers', ''].map(t => h('th', {}, t)))),
card('Sites', h('table', {}, h('thead', {}, h('tr', {}, ['Status', 'Name', 'Kind', 'Tunnels to', 'Version', 'Containers', ''].map(t => h('th', {}, t)))),
h('tbody', {}, st.sites.map(s => h('tr', {},
h('td', {}, h('span', {class:'dot ' + (st.online[s.id] ? 'ok' : 'bad')})), h('td', {}, s.name),
h('td', {}, st.online[s.id] ? badge('Online', 'success') : badge('Offline', 'danger')), h('td', {}, s.name),
h('td', {}, s.local ? 'this machine (direct)' : 'bamboo agent'),
h('td', {}, s.local ? '—' : ((s.connected || []).map(id => (byId(st.edges, id) || {name:id}).name).join(', ') || 'nothing connected')),
s.local ? h('td', {class:'dim'}, '—') : versionCell(s, latest.bamboo, !!s.updateId, O(`/sites/${s.id}/update`), O(`/sites/${s.id}/update/log`)),
h('td', {}, s.local ? 'Not applicable' : ((s.connected || []).map(id => (byId(st.edges, id) || {name:id}).name).join(', ') || 'nothing connected')),
s.local ? h('td', {class:'dim'}, 'Not applicable') : versionCell(s, latest.bamboo, !!s.updateId, O(`/sites/${s.id}/update`), O(`/sites/${s.id}/update/log`)),
h('td', {}, h('button', {class:'ghost', onclick: act(async () => {
const l = await api('GET', O(`/sites/${s.id}/containers`));
modal('Containers on ' + s.name, l.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Image', 'Ports'].map(t => h('th', {}, t)))), h('tbody', {}, l.map(c => h('tr', {}, h('td', {}, c.name), h('td', {class:'dim'}, c.image), h('td', {class:'mono small'}, c.ports.map(p => (p.public ? p.public + '→' : '') + p.private + '/' + p.type).join(', ')))))) :
h('p', {class:'dim'}, 'None reported. Start the agent with --docker (or BLACKTIP_DOCKER=1 / BAMBOO_DOCKER=1) on a machine where Docker is running.')); })}, 'View')),
h('td', {}, s.local ? null : h('div', {class:'actions'},
h('button', {class:'ghost', onclick:() => showCmd(s)}, 'Show command'),
h('button', {class:'ghost danger', onclick: act(async () => { if (confirm(`Delete ${s.name} and every port that uses only it?`)) { await api('DELETE', O('/sites/' + s.id)); refresh(); } })}, 'Delete')))))))));
h('button', {class:'ghost danger', onclick: ask({title:`Delete ${s.name}?`, body:'Every port that uses only this site is deleted with it, and its agent loses its connection. This cannot be undone.', confirm:'Delete site'}, async () => { await api('DELETE', O('/sites/' + s.id)); refresh(); })}, 'Delete')))))))));
};
// ----- domains -----
@@ -773,9 +907,9 @@ views.domains = async () => {
return h('div', {},
card('Domains', st.domains.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Domain', 'Edge', 'DNS record to create', ''].map(t => h('th', {}, t)))),
h('tbody', {}, st.domains.map(d => h('tr', {}, h('td', {}, d.name), h('td', {}, (byId(st.edges, d.edgeId) || {name:'?'}).name),
h('td', {class:'mono small'}, d.ip ? `A ${d.name} ${d.ip}` : '—', h('br'), d.ip ? `A *.${d.name} ${d.ip} (for subdomains)` : ''),
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick: act(async () => { const r = await api('POST', O(`/domains/${d.id}/check`)); toast(r.matches ? `✓ Resolves to ${r.resolved.join(', ')}` : r.error || `Resolves to ${r.resolved.join(', ')}, which isn't this edge`, !r.matches); })}, 'Check DNS'),
h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', O('/domains/' + d.id)); refresh(); })}, 'Remove'))))))) : h('div', {class:'empty'}, 'None yet.')));
h('td', {class:'mono small'}, d.ip ? `A ${d.name} ${d.ip}` : 'No IP set', h('br'), d.ip ? `A *.${d.name} ${d.ip} (for subdomains)` : ''),
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick: act(async () => { const r = await api('POST', O(`/domains/${d.id}/check`)); toast(r.matches ? `Resolves to ${r.resolved.join(', ')}` : r.error || `Resolves to ${r.resolved.join(', ')}, which isn't this edge`, !r.matches); })}, 'Check DNS'),
h('button', {class:'ghost danger', onclick: ask({title:`Remove ${d.name}?`, body:'The domain is removed from this organization. You can add it again later.', confirm:'Remove domain'}, async () => { await api('DELETE', O('/domains/' + d.id)); refresh(); })}, 'Remove'))))))) : emptyState('domains', 'No domains yet', 'Add a domain you own, then point its DNS at an edge.')));
};
@@ -785,7 +919,7 @@ const spfFor = ip => ip.includes(':') ? `v=spf1 ip6:${ip} ~all` : `v=spf1 ip4:${
function checklist(e) {
const c = e.check, ip = e.sourceIp, helo = e.heloName;
const item = (ok, title, fix) => h('div', {style:'display:flex;gap:10px;align-items:flex-start;margin-bottom:12px'},
h('span', {class:'dot ' + (ok === true ? 'ok' : ok === false ? 'bad' : 'warn'), style:'margin-top:6px'}),
h('span', {style:'margin-top:1px;flex:none'}, ok === true ? badge('Pass', 'success') : ok === false ? badge('Fix', 'danger') : badge('Check', 'warning')),
h('div', {}, h('div', {}, title), fix ? h('div', {class:'dim small'}, fix) : null));
const body = h('div', {},
c ? h('p', {class:'dim small'}, 'Checked ' + ago(c.checkedAt) + ' from the edge, using ' + ip + '.') : h('p', {class:'dim'}, 'No report yet. The edge sends one shortly after it picks up this egress.'),
@@ -806,32 +940,32 @@ views.egress = async () => {
const remote = st.sites.filter(s => !s.local);
const rows = st.mailEgress || [];
const table = rows.length ? h('div', {class:'scroll'}, h('table', {},
h('thead', {}, h('tr', {}, ['', 'Name', 'Relay (on the site\'s machine)', 'Mail leaves from', 'Deliverability', ''].map(t => h('th', {}, t)))),
h('thead', {}, h('tr', {}, ['Status', 'Name', 'Relay (on the site\'s machine)', 'Mail leaves from', 'Deliverability', ''].map(t => h('th', {}, t)))),
h('tbody', {}, rows.map(e => {
const site = byId(st.sites, e.siteId), edge = byId(st.edges, e.edgeId), c = e.check;
let dot = 'dot', why = 'paused';
let tone = '', label = 'Paused', why = 'paused';
if (e.enabled) {
if (!e.statusKnown) { dot += ' warn'; why = 'waiting for the site'; }
else if (e.status) { dot += ' bad'; why = e.status; }
else { dot += ' ok'; why = 'listening'; }
if (!e.statusKnown) { tone = 'warning'; label = 'Waiting'; why = 'waiting for the site'; }
else if (e.status) { tone = 'danger'; label = 'Error'; why = e.status; }
else { tone = 'success'; label = 'Listening'; why = 'listening'; }
}
const good = c && c.ptrMatchesHelo && c.heloResolves && c.port25 === 'open';
return h('tr', {},
h('td', {title:why}, h('span', {class:dot})),
h('td', {title:why}, badge(label, tone)),
h('td', {}, e.name, e.statusKnown && e.status ? h('div', {class:'err small mono'}, e.status) : null,
e.username ? h('span', {class:'tag', style:'margin-left:6px'}, '🔒') : null, e.requireTls ? h('span', {class:'tag', style:'margin-left:4px'}, 'tls required') : null),
e.username ? h('span', {style:'margin-left:6px'}, badge('Sign-in required', 'accent')) : null, e.requireTls ? h('span', {style:'margin-left:4px'}, badge('TLS required')) : null),
h('td', {class:'mono'}, e.listen, h('div', {class:'dim small'}, site ? site.name : '?')),
h('td', {class:'mono'}, e.sourceIp, h('div', {class:'dim small'}, e.heloName + (e.smarthost ? ' → ' + e.smarthost : ' → recipient MX'))),
h('td', {}, h('button', {class:'ghost', onclick:() => modal('Deliverability for ' + e.sourceIp, checklist(e))},
!c ? '…' : good ? '✓ looks good' : '⚠ needs attention')),
!c ? 'Checking' : good ? 'Looks good' : 'Needs attention')),
h('td', {}, h('div', {class:'actions'},
h('button', {class:'ghost', onclick:() => mailForm(e)}, 'Edit'),
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', O('/mail-egress/' + e.id), {...e, enabled:!e.enabled}); refresh(); })}, e.enabled ? 'Pause' : 'Enable'),
h('button', {class:'ghost danger', onclick: act(async () => { if (confirm(`Remove ${e.name}?`)) { await api('DELETE', O('/mail-egress/' + e.id)); refresh(); } })}, 'Remove'))));
})))) : h('div', {class:'empty'}, 'Nothing set up yet. Add one so your mail server sends from the edge\'s IP.');
h('button', {class:'ghost danger', onclick: ask({title:`Remove ${e.name}?`, body:'Mail from your server stops leaving through this edge. You can set it up again later.', confirm:'Remove mail egress'}, async () => { await api('DELETE', O('/mail-egress/' + e.id)); refresh(); })}, 'Remove'))));
})))) : emptyState('egress', 'No mail egress yet', 'Add one so your mail server sends from the edge\'s IP address.');
S.headerAction = ['Add mail egress', () => mailForm()];
return h('div', {},
!remote.length ? h('div', {class:'banner'}, 'This runs on a Bamboo site: add the network your mail server lives in on the Sites page and start Bamboo there first.') : null,
!remote.length ? h('div', {class:'notice'}, 'This runs on a Bamboo site: add the network your mail server lives in on the Sites page and start Bamboo there first.') : null,
h('div', {class:'card flush'}, table),
await dkimCard(),
card('How it works', h('p', {class:'dim'}, 'Bamboo opens a small SMTP relay on your mail server\'s network. Point your mail server at it, and each outgoing message is carried through the WireGuard tunnel to the edge, which delivers it straight to the recipient\'s mail server from the edge\'s IP, announcing itself with the HELO name you set. The recipient\'s replies come straight back, so your server\'s queue and retries work as usual.'),
@@ -842,14 +976,14 @@ views.egress = async () => {
async function dkimCard() {
const keys = await api('GET', O('/dkim'));
const dom = h('input', {placeholder:'example.com'}), sel = h('input', {placeholder:'blank = automatic, e.g. hh202610'});
const chip = k => ({published:['ok', '✓ published'], missing:['warn', 'not in DNS yet'], mismatch:['bad', 'DNS has a different key'], unknown:['', 'waiting for a check']}[k.status] || ['', k.status]);
const chip = k => ({published:['success', 'Published'], missing:['warning', 'Not in DNS yet'], mismatch:['danger', 'DNS has a different key'], unknown:['', 'Waiting for a check']}[k.status] || ['', k.status]);
const show = k => modal('DNS record for ' + k.domain, h('div', {}, h('p', {class:'dim'}, 'Add this TXT record at your DNS provider. Some providers want the name without the domain part, or the value split into quoted pieces: both are fine.'),
h('div', {class:'sub'}, 'Name'), cmdBox(k.recordName), h('div', {class:'sub', style:'margin-top:10px'}, 'Value'), cmdBox(k.record)));
return card('DKIM signing', h('p', {class:'dim'}, 'The edge signs your outgoing mail so receiving servers can prove it came from you. Make a key for each domain you send from, then publish its record. If your mail server already signs, it is left alone.'),
keys.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Domain', 'Selector', 'DNS', ''].map(x => h('th', {}, x)))), h('tbody', {}, keys.map(k => h('tr', {},
h('td', {}, k.domain), h('td', {class:'mono'}, k.selector), h('td', {}, h('span', {class:'dot ' + chip(k)[0]}), chip(k)[1]),
h('td', {}, k.domain), h('td', {class:'mono'}, k.selector), h('td', {}, badge(chip(k)[1], chip(k)[0])),
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => show(k)}, 'Show DNS record'),
h('button', {class:'ghost danger', onclick: act(async () => { if (confirm(`Stop signing mail for ${k.domain}?`)) { await api('DELETE', O('/dkim/' + k.id)); refresh(); } })}, 'Remove'))))))) : null,
h('button', {class:'ghost danger', onclick: ask({title:`Stop signing mail for ${k.domain}?`, body:'Outgoing mail for this domain is no longer signed, which can hurt delivery. The key is deleted and cannot be recovered.', confirm:'Remove key'}, async () => { await api('DELETE', O('/dkim/' + k.id)); refresh(); })}, 'Remove'))))))) : null,
h('div', {class:'row-h'}, h('button', {class:'btn', onclick:() => dialogForm('Create a DKIM key', 'Make a key for each domain you send mail from, then publish its DNS record.', [field('Domain', dom), field('Selector (optional)', sel)], 'Create key',
async () => { const k = await api('POST', O('/dkim'), {domain:dom.value, selector:sel.value}); show(k); refresh(); })}, '+ Create key')));
}
@@ -924,20 +1058,20 @@ async function alertsView(base, scoped, trusted) {
h('td', {}, h('div', {class:'actions'},
h('button', {class:'ghost', onclick: act(async () => { await api('POST', base + '/channels/' + c.id + '/test'); toast('Test sent'); })}, 'Send test'),
h('button', {class:'ghost', onclick:() => channelForm(base, c, trusted)}, 'Edit'),
h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', base + '/channels/' + c.id); refresh(); })}, 'Remove')))))));
h('button', {class:'ghost danger', onclick: ask({title:'Remove this alert channel?', body:'You will no longer be told here when something stops working. You can add it again later.', confirm:'Remove channel'}, async () => { await api('DELETE', base + '/channels/' + c.id); refresh(); })}, 'Remove')))))));
const debounce = h('input', {type:'number', min:1, max:120, value:d.settings.debounceMin}), repeat = h('input', {type:'number', min:0, max:168, value:d.settings.repeatHours});
S.headerAction = ['Add channel', () => channelForm(base, null, trusted)];
return h('div', {},
!d.emailConfigured ? h('div', {class:'banner'}, 'Email alerts need an email server. Set one up under Server → Settings, or use a webhook.') : null,
card('Right now', d.active.length ? h('table', {}, h('tbody', {}, d.active.map(a => h('tr', {}, h('td', {}, h('span', {class:'dot ' + (a.fired ? 'bad' : 'warn')}), a.fired ? 'Firing' : 'Waiting…'), h('td', {}, a.message), h('td', {class:'dim small'}, 'since ' + ago(a.since)))))) :
h('div', {class:'empty'}, '✓ Nothing is wrong.')),
!d.emailConfigured ? h('div', {class:'notice'}, 'Email alerts need an email server. Set one up under Server → Settings, or use a webhook.') : null,
card('Right now', d.active.length ? h('table', {}, h('tbody', {}, d.active.map(a => h('tr', {}, h('td', {}, a.fired ? badge('Firing', 'danger') : badge('Waiting', 'warning')), h('td', {}, a.message), h('td', {class:'dim small'}, 'since ' + ago(a.since)))))) :
emptyState('check', 'Nothing is wrong', 'Problems that are firing or about to fire appear here.')),
card('Where alerts go',
d.channels.length ? channels : h('div', {class:'empty'}, 'No channels yet. Add an email address or a webhook.')),
d.channels.length ? channels : emptyState('alerts', 'No channels yet', 'Add an email address or a webhook to be told when something stops working.')),
scoped ? card('Timing', h('p', {class:'dim'}, 'A problem has to last this long before you are told, so a restart or a brief blip stays quiet. You get one message when it starts and one when it clears.'),
h('div', {class:'grid'}, field('Wait this many minutes first', debounce), field('Remind me every (hours, 0 = never)', repeat),
h('button', {class:'btn', onclick: act(async () => { await api('PUT', base + '/settings', {debounceMin:+debounce.value, repeatHours:+repeat.value}); toast('Saved'); })}, 'Save'))) : null,
card('History', d.history.length ? h('table', {}, h('tbody', {}, d.history.map(e => h('tr', {}, h('td', {class:'dim small'}, new Date(e.time).toLocaleString()),
h('td', {}, h('span', {class:'tag'}, e.status)), h('td', {}, e.message))))) : h('div', {class:'empty'}, 'Nothing has fired yet.')));
h('td', {}, h('span', {class:'tag'}, e.status)), h('td', {}, e.message))))) : emptyState('alerts', 'No alerts yet', 'Alerts that fired and recovered are listed here.')));
}
views.alerts = () => alertsView(O('/alerts'), true, !!(S.me.serverAdmin || (S.st.org && S.st.org.allowLocal)));
@@ -955,16 +1089,16 @@ views.access = async () => {
card('Members', h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Email', 'Role', '2FA', ''].map(t => h('th', {}, t)))),
h('tbody', {}, members.map(mm => h('tr', {}, h('td', {}, mm.name), h('td', {}, mm.email, mm.sso ? h('span', {class:'tag', style:'margin-left:6px'}, 'sso') : null),
h('td', {}, h('select', {style:'width:auto', onchange: act(async e => { await api('PUT', O('/members/' + mm.userId), {roleId:e.target.value}); toast('Role changed'); })}, roles.map(r => h('option', {value:r.id, selected:r.id === mm.roleId}, r.name)))),
h('td', {}, mm.totp ? '✓' : '—'),
h('td', {}, mm.userId === S.me.id ? null : h('button', {class:'ghost danger', onclick: act(async () => { if (confirm('Remove this member?')) { await api('DELETE', O('/members/' + mm.userId)); refresh(); } })}, 'Remove'))))))),
h('td', {}, mm.totp ? badge('On', 'success') : badge('Off')),
h('td', {}, mm.userId === S.me.id ? null : h('button', {class:'ghost danger', onclick: ask({title:`Remove ${mm.name}?`, body:'They lose access to this organization straight away. You can invite them again later.', confirm:'Remove member'}, async () => { await api('DELETE', O('/members/' + mm.userId)); refresh(); })}, 'Remove'))))))),
invites.length ? card('Pending invitations', h('table', {}, h('tbody', {}, invites.map(i => h('tr', {}, h('td', {}, i.email), h('td', {class:'dim'}, (byId(roles, i.roleId) || {name:'?'}).name), h('td', {class:'dim'}, 'expires ' + new Date(i.expires).toLocaleDateString()),
h('td', {}, h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', O('/invites/' + i.id)); refresh(); })}, 'Cancel'))))))) : null,
h('td', {}, h('button', {class:'ghost danger', onclick: ask({title:`Cancel the invitation for ${i.email}?`, body:'The invitation link stops working. You can invite them again.', confirm:'Cancel invitation', cancel:'Keep invitation'}, async () => { await api('DELETE', O('/invites/' + i.id)); refresh(); })}, 'Cancel'))))))) : null,
card('Roles', h('p', {class:'dim'}, 'Admin roles manage the organization. Other roles are used to decide who may open protected apps.'),
h('table', {}, h('tbody', {}, roles.map(r => h('tr', {}, h('td', {}, r.name, r.builtin ? h('span', {class:'tag', style:'margin-left:6px'}, 'built-in') : null), h('td', {class:'dim'}, r.admin ? 'can manage the organization' : 'member'),
h('td', {}, r.builtin ? null : h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', O('/roles/' + r.id)); refresh(); })}, 'Delete')))))),
h('td', {}, r.builtin ? null : h('button', {class:'ghost danger', onclick: ask({title:`Delete the ${r.name} role?`, body:'The role is deleted. This cannot be undone.', confirm:'Delete role'}, async () => { await api('DELETE', O('/roles/' + r.id)); refresh(); })}, 'Delete')))))),
h('div', {class:'grid', style:'margin-top:12px'}, field('New role', rname), radmin, h('button', {class:'btn', onclick: act(async () => { await api('POST', O('/roles'), {name:rname.value, admin:radmin.box.checked}); refresh(); })}, 'Add role'))),
card('API keys', h('p', {class:'dim'}, 'Keys act as an administrator of this organization only. Send as "Authorization: Bearer <key>" to /api/orgs/' + S.org + '/…'),
keys.length ? h('table', {}, h('tbody', {}, keys.map(k => h('tr', {}, h('td', {}, k.name), h('td', {class:'dim'}, 'last used ' + ago(k.lastUsed)), h('td', {}, h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', O('/apikeys/' + k.id)); refresh(); })}, 'Revoke'))))) ) : null,
keys.length ? h('table', {}, h('tbody', {}, keys.map(k => h('tr', {}, h('td', {}, k.name), h('td', {class:'dim'}, 'last used ' + ago(k.lastUsed)), h('td', {}, h('button', {class:'ghost danger', onclick: ask({title:`Revoke ${k.name}?`, body:'Anything using this key stops working straight away. This cannot be undone.', confirm:'Revoke key'}, async () => { await api('DELETE', O('/apikeys/' + k.id)); refresh(); })}, 'Revoke'))))) ) : null,
h('div', {class:'grid', style:'margin-top:12px'}, field('Name', kname), h('button', {class:'btn', onclick: act(async () => { const r = await api('POST', O('/apikeys'), {name:kname.value}); modal('New API key', h('div', {}, h('p', {class:'dim'}, 'Copy it now; it is not shown again.'), cmdBox(r.key))); refresh(); })}, 'Create key'))));
};
@@ -1000,7 +1134,7 @@ views.logs = async () => {
return h('div', {}, h('div', {class:'row-h', style:'justify-content:space-between'},
h('div', {class:'tabs2', style:'margin:0'}, [['access', 'Access'], ['audit', 'Audit']].map(([k, l]) => h('button', {class:logKind === k ? 'on' : '', onclick:() => { logKind = k; logPaged = false; refresh(); }}, l))),
logKind === 'access' ? h('div', {class:'actions'}, search, mapSel, statusSel, resultSel) : null),
card('', rows.length ? h('div', {class:'scroll'}, h('table', {}, h('thead', {}, h('tr', {}, head.map(x => h('th', {}, x)))), tbody), rows.length >= 200 ? more : null) : h('div', {class:'empty'}, 'Nothing matches.')));
card('', rows.length ? h('div', {class:'scroll'}, h('table', {}, h('thead', {}, h('tr', {}, head.map(x => h('th', {}, x)))), tbody), rows.length >= 200 ? more : null) : emptyState('logs', 'No entries match', 'Nothing was logged for these filters. Try a wider search.')));
};
// ----- server administration -----
@@ -1026,11 +1160,11 @@ serverViews.edges = async () => {
h('button', {class:'ghost', onclick:() => close()}, 'Cancel')))); }];
const latest = await latestVersions();
return h('div', {},
card('Edges', edges.length ? h('table', {}, h('thead', {}, h('tr', {}, ['', 'Name', 'WireGuard endpoint', 'IP addresses', 'Version', 'Orgs', ''].map(t => h('th', {}, t)))),
h('tbody', {}, edges.map(e => h('tr', {}, h('td', {}, h('span', {class:'dot ' + (e.online ? 'ok' : 'bad'), title:e.online ? 'online' : 'offline'})), h('td', {}, e.name), h('td', {class:'mono'}, e.tunnelAddr),
h('td', {class:'mono small'}, (e.ips || []).join(', ') || '—'), versionCell(e, latest.blacktip, e.updatePending, '/api/edges/' + e.id + '/update', '/api/edges/' + e.id + '/update/log'), h('td', {class:'small dim'}, (e.orgIds || []).length ? e.orgIds.map(id => (byId(orgs, id) || {name:id}).name).join(', ') : 'all'),
card('Edges', edges.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Status', 'Name', 'WireGuard endpoint', 'IP addresses', 'Version', 'Organizations', ''].map(t => h('th', {}, t)))),
h('tbody', {}, edges.map(e => h('tr', {}, h('td', {}, e.online ? badge('Online', 'success') : badge('Offline', 'danger')), h('td', {}, e.name), h('td', {class:'mono'}, e.tunnelAddr),
h('td', {class:'mono small'}, (e.ips || []).join(', ') || 'None reported yet'), versionCell(e, latest.blacktip, e.updatePending, '/api/edges/' + e.id + '/update', '/api/edges/' + e.id + '/update/log'), h('td', {class:'small dim'}, (e.orgIds || []).length ? e.orgIds.map(id => (byId(orgs, id) || {name:id}).name).join(', ') : 'all'),
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => showCmd(e)}, 'Show command'),
h('button', {class:'ghost danger', onclick: act(async () => { if (confirm('Delete this edge and all its ports?')) { await api('DELETE', '/api/edges/' + e.id); refresh(); } })}, 'Delete'))))))) : h('div', {class:'empty'}, 'None yet.')));
h('button', {class:'ghost danger', onclick: ask({title:`Delete ${e.name}?`, body:'Every port on this edge is deleted and its tunnel closes. This cannot be undone.', confirm:'Delete edge'}, async () => { await api('DELETE', '/api/edges/' + e.id); refresh(); })}, 'Delete'))))))) : emptyState('edges', 'No edges yet', 'An edge is a public server running Blacktip. Add one to start opening ports.')));
};
serverViews.orgs = async () => {
@@ -1043,7 +1177,7 @@ serverViews.orgs = async () => {
h('td', {}, h('button', {class:'ghost', onclick: act(async () => {
const st = await api('GET', `/api/orgs/${o.id}/state`);
await api('PUT', `/api/orgs/${o.id}`, {allowLocal:!st.org.allowLocal}); toast(`Local access ${st.org.allowLocal ? 'off' : 'on'} for ${o.name}`); })}, 'Toggle local access')),
h('td', {}, h('button', {class:'ghost danger', onclick: act(async () => { if (confirm(`Delete ${o.name} and everything in it?`)) { await api('DELETE', '/api/orgs/' + o.id); S.me = await api('GET', '/api/me'); if (S.org === o.id) S.org = (S.me.orgs[0] || {}).id; refresh(); } })}, 'Delete'))))))));
h('td', {}, h('button', {class:'ghost danger', onclick: ask({title:`Delete ${o.name}?`, body:'Everything in it is deleted: resources, sites, domains, members and logs. This cannot be undone.', confirm:'Delete organization'}, async () => { await api('DELETE', '/api/orgs/' + o.id); S.me = await api('GET', '/api/me'); if (S.org === o.id) S.org = (S.me.orgs[0] || {}).id; refresh(); })}, 'Delete'))))))));
};
async function boot0() { S.me = await api('GET', '/api/me'); }
@@ -1054,14 +1188,14 @@ serverViews.users = async () => {
async () => { await api('POST', '/api/admin/users', {email:f.e.value, name:f.n.value, password:f.p.value, serverAdmin:adm.box.checked}); refresh(); })];
return h('div', {},
card('Users', h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Email', 'Organizations', 'Flags', ''].map(t => h('th', {}, t)))),
h('tbody', {}, users.map(u => h('tr', {}, h('td', {}, u.name), h('td', {}, u.email), h('td', {class:'dim'}, u.orgs.join(', ') || '—'),
h('tbody', {}, users.map(u => h('tr', {}, h('td', {}, u.name), h('td', {}, u.email), h('td', {class:'dim'}, u.orgs.join(', ') || 'None'),
h('td', {}, u.serverAdmin ? h('span', {class:'tag'}, 'admin') : null, u.totp ? h('span', {class:'tag', style:'margin-left:4px'}, '2fa') : null, u.sso ? h('span', {class:'tag', style:'margin-left:4px'}, 'sso') : null),
h('td', {}, u.id === S.me.id ? null : h('div', {class:'actions'},
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', '/api/admin/users/' + u.id, {serverAdmin:!u.serverAdmin}); refresh(); })}, u.serverAdmin ? 'Remove admin' : 'Make admin'),
u.totp ? h('button', {class:'ghost', onclick: act(async () => { await api('PUT', '/api/admin/users/' + u.id, {resetTotp:true}); toast('2FA reset'); refresh(); })}, 'Reset 2FA') : null,
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', '/api/admin/users/' + u.id, {signOut:true}); toast('Signed out everywhere'); })}, 'Sign out'),
h('button', {class:'ghost', onclick: act(async () => { const p = prompt('New password (8+ characters)'); if (p) { await api('PUT', '/api/admin/users/' + u.id, {password:p}); toast('Password changed'); } })}, 'Set password'),
h('button', {class:'ghost danger', onclick: act(async () => { if (confirm('Delete ' + u.email + '?')) { await api('DELETE', '/api/admin/users/' + u.id); refresh(); } })}, 'Delete')))))))));
h('button', {class:'ghost danger', onclick: ask({title:`Delete ${u.email}?`, body:'This person can no longer sign in. This cannot be undone.', confirm:'Delete user'}, async () => { await api('DELETE', '/api/admin/users/' + u.id); refresh(); })}, 'Delete')))))))));
};
serverViews.idps = async () => {
@@ -1082,7 +1216,7 @@ serverViews.idps = async () => {
S.headerAction = ['Add provider', () => form()];
return card('Single sign-on', h('p', {class:'dim'}, 'Let people sign in with Google, Authentik, Keycloak, Entra ID or any OpenID Connect provider.'),
idps.length ? h('table', {}, h('tbody', {}, idps.map(i => h('tr', {}, h('td', {}, i.name), h('td', {class:'mono small dim'}, i.issuer), h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => form(i)}, 'Edit'),
h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', '/api/admin/idps/' + i.id); refresh(); })}, 'Delete')))))) ) : h('div', {class:'empty'}, 'None configured.'));
h('button', {class:'ghost danger', onclick: ask({title:`Delete ${i.name}?`, body:'People can no longer sign in with this provider. This cannot be undone.', confirm:'Delete provider'}, async () => { await api('DELETE', '/api/admin/idps/' + i.id); refresh(); })}, 'Delete')))))) ) : emptyState('idps', 'No sign-in providers yet', 'Add an OpenID Connect provider so people can sign in with it.'));
};
serverViews.settings = async () => {
@@ -1127,7 +1261,7 @@ serverViews.settings = async () => {
serverViews.audit = async () => {
const rows = await api('GET', '/api/admin/audit');
return card('Server audit log', rows.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Time', 'Who', 'What', 'Detail'].map(x => h('th', {}, x)))), h('tbody', {}, rows.map(r => h('tr', {}, h('td', {class:'dim'}, new Date(r.time).toLocaleString()), h('td', {}, r.actor), h('td', {}, r.action), h('td', {class:'dim'}, r.detail))))) : h('div', {class:'empty'}, 'Nothing yet.'));
return card('Server audit log', rows.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Time', 'Who', 'What', 'Detail'].map(x => h('th', {}, x)))), h('tbody', {}, rows.map(r => h('tr', {}, h('td', {class:'dim'}, new Date(r.time).toLocaleString()), h('td', {}, r.actor), h('td', {}, r.action), h('td', {class:'dim'}, r.detail))))) : emptyState('audit', 'No activity yet', 'Server level changes appear here.'));
};
// ----- account -----
@@ -1138,7 +1272,7 @@ views.account = async () => {
const draw2fa = () => {
if (S.me.totpEnabled) {
const pw = h('input', {type:'password', placeholder:'Password'}), code = h('input', {placeholder:'Authenticator code'});
twofa.replaceChildren(h('p', {}, '✓ Two-factor authentication is on. ' + S.me.backupCodes + ' backup codes left.'), h('div', {class:'grid'}, field('Password', pw), field('Code', code),
twofa.replaceChildren(h('p', {}, 'Two-factor authentication is on. ' + S.me.backupCodes + ' backup codes left.'), h('div', {class:'grid'}, field('Password', pw), field('Code', code),
h('button', {class:'ghost danger', onclick: act(async () => { await api('POST', '/api/me/2fa/disable', {password:pw.value, code:code.value}); S.me = await api('GET', '/api/me'); draw2fa(); })}, 'Turn off')));
} else twofa.replaceChildren(h('p', {class:'dim'}, 'Add an authenticator app (Google Authenticator, 1Password, Aegis…) for a second step at sign-in.'),
h('button', {class:'btn', style:'align-self:flex-start', onclick: act(async () => {
@@ -1154,7 +1288,7 @@ views.account = async () => {
const devices = card('Signed-in devices', sessions.length > 1 ? h('div', {class:'row-h'}, h('button', {class:'ghost', onclick: act(async () => { await api('POST', '/api/me/sessions/revoke-others'); toast('Signed out everywhere else'); refresh(); })}, 'Sign out everywhere else')) : null,
h('table', {}, h('tbody', {}, sessions.map(x => h('tr', {}, h('td', {}, x.current ? h('span', {class:'tag'}, 'this device') : null),
h('td', {class:'small'}, (x.ua || 'unknown browser').slice(0, 80), h('div', {class:'dim'}, x.ip + ' · signed in ' + ago(x.created))),
h('td', {}, x.current ? null : h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', '/api/me/sessions/' + x.id); refresh(); })}, 'Sign out')))))));
h('td', {}, x.current ? null : h('button', {class:'ghost danger', onclick: ask({title:'Sign out this device?', body:'That session ends and its device has to sign in again.', confirm:'Sign out', danger:false}, async () => { await api('DELETE', '/api/me/sessions/' + x.id); refresh(); })}, 'Sign out')))))));
return h('div', {}, card('Profile', h('div', {class:'grid'}, field('Name', name), field('Email', h('input', {value:S.me.email, disabled:true})), h('button', {class:'btn', onclick: act(async () => { await api('PUT', '/api/me', {name:name.value}); toast('Saved'); })}, 'Save'))),
card('Password', h('div', {class:'grid'}, S.me.hasPassword ? field('Current password', cur) : null, field('New password (8+ characters)', nw),
h('button', {class:'btn', onclick: act(async () => { await api('POST', '/api/me/password', {current:cur.value, new:nw.value}); cur.value = nw.value = ''; toast('Password changed; other sessions were signed out'); })}, 'Change password'))),
+80 -54
View File
@@ -3,62 +3,81 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Sign in</title>
<style>
/* Design tokens: the only place raw colors, spacing and sizes are defined (same system as Lightwell). */
/* tokens:begin Design tokens, the Lightwell design language (docs/design.md). This is the only place raw colors and sizes are defined. */
:root {
--accent:#0891b2; --accent-contrast:#fff;
--bg:#f7f8f9; --surface:#fff; --surface-2:#f0f2f4; --border:#e2e5e9; --border-strong:#c6ccd3;
--text:#16191d; --text-muted:#566069; --text-faint:#75808b;
--accent-text:color-mix(in srgb, var(--accent) 85%, #000);
--accent-soft:color-mix(in srgb, var(--accent) 10%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 86%, #000);
--focus:color-mix(in srgb, var(--accent) 65%, #fff);
--success:#1c6b3a; --success-bg:#e3f1e8; --warning:#85560a; --warning-bg:#f9eed6; --danger:#a3271e; --danger-bg:#f9e6e3;
--overlay:rgb(15 18 22 / .5); --shadow:0 8px 24px rgb(15 18 22 / .14);
--accent:#1f5c5c; --accent-contrast:#ffffff;
--bg:#f6f5f3; --surface:#ffffff; --surface-2:#efede9; --border:#e0ddd7; --border-strong:#c4c0b8;
--text:#1d1c1a; --text-muted:#5f5b54; --text-faint:#7a766e;
--accent-text:var(--accent);
--accent-soft:color-mix(in srgb, var(--accent) 9%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 88%, #000000);
--focus:color-mix(in srgb, var(--accent) 70%, #ffffff);
--success:#1c6b3a; --success-bg:#e3f1e8; --warning:#85560a; --warning-bg:#f9eed6; --danger:#a3271e; --danger-bg:#f9e6e3; --neutral-bg:#eceae6;
--overlay:rgb(20 19 17 / 0.55); --shadow:0 8px 24px rgb(20 19 17 / 0.14);
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px; --s6:32px; --s7:48px; --s8:64px;
--radius:6px; --radius-lg:10px;
--radius:6px; --radius-lg:10px; --border-w:1px;
--font:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--font-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
--text-xs:12px; --text-sm:13px; --text-base:14px; --text-lg:16px; --text-xl:20px; --text-2xl:26px;
--control-h:36px; --sidebar-w:248px; --content-max:1180px; --ease:140ms ease;
--text-xs:12px; --text-sm:13px; --text-base:15px; --text-lg:18px; --text-xl:22px; --text-2xl:28px; --leading:1.5;
--sidebar-w:232px; --content-max:1120px; --touch:44px; --control-h:38px;
--z-sticky:20; --z-overlay:100; --z-toast:200; --ease:140ms ease;
color-scheme:light;
}
@media (prefers-color-scheme: dark) { :root {
--accent:#22b8d4; --accent-contrast:#04222a;
--bg:#0c0d0f; --surface:#141517; --surface-2:#1d1f22; --border:#2a2d31; --border-strong:#41454b;
--text:#eceef0; --text-muted:#aab1b9; --text-faint:#8a929b;
--accent-text:color-mix(in srgb, var(--accent) 80%, #fff); --accent-soft:color-mix(in srgb, var(--accent) 18%, var(--surface)); --accent-hover:color-mix(in srgb, var(--accent) 85%, #fff);
--success:#86d3a0; --success-bg:#16301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18;
--overlay:rgb(0 0 0 / .65); --shadow:0 8px 24px rgb(0 0 0 / .5); color-scheme:dark;
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {
--bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943;
--text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84;
--accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff);
--accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 85%, #ffffff);
--success:#86d3a0; --success-bg:#17301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --neutral-bg:#2b2a27;
--overlay:rgb(0 0 0 / 0.65); --shadow:0 8px 24px rgb(0 0 0 / 0.5); color-scheme:dark;
} }
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--text); font:var(--text-base)/1.5 var(--font); display:grid; place-items:center; min-height:100vh; padding:var(--s4); -webkit-font-smoothing:antialiased; }
.card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-lg); padding:var(--s5); width:100%; max-width:400px; }
h1 { font-size:var(--text-xl); font-weight:600; letter-spacing:-.01em; margin:0 0 var(--s1); }
.sub { color:var(--text-muted); margin:0 0 var(--s4); font-size:var(--text-sm); }
.sec { border-top:1px solid var(--border); padding-top:var(--s4); margin-top:var(--s4); display:flex; flex-direction:column; gap:var(--s2); }
:root[data-theme="dark"] {
--bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943;
--text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84;
--accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff);
--accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 85%, #ffffff);
--success:#86d3a0; --success-bg:#17301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --neutral-bg:#2b2a27;
--overlay:rgb(0 0 0 / 0.65); --shadow:0 8px 24px rgb(0 0 0 / 0.5); color-scheme:dark;
}
/* tokens:end */
*, *::before, *::after { box-sizing:border-box; }
html { -webkit-text-size-adjust:100%; }
body { margin:0; background:var(--bg); color:var(--text); font:var(--text-base)/var(--leading) var(--font); display:grid; place-items:center; min-height:100vh; padding:var(--s4); -webkit-font-smoothing:antialiased; }
.card { background:var(--surface); border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); padding:var(--s5); width:100%; max-width:440px; }
h1 { font-size:var(--text-xl); font-weight:600; letter-spacing:-.01em; line-height:1.25; margin:0 0 var(--s1); }
h2 { font-size:var(--text-base); font-weight:600; margin:0; }
.lead { color:var(--text-muted); margin:0 0 var(--s4); }
.sec { border-top:var(--border-w) solid var(--border); padding-top:var(--s4); margin-top:var(--s4); display:flex; flex-direction:column; gap:var(--s3); }
.sec:first-of-type { border-top:0; padding-top:0; margin-top:0; }
h2 { font-size:var(--text-xs); text-transform:uppercase; letter-spacing:.06em; color:var(--text-faint); margin:0; font-weight:600; }
input { background:var(--surface); color:var(--text); border:1px solid var(--border-strong); border-radius:var(--radius); padding:var(--s2) var(--s3); min-height:var(--control-h); font:inherit; width:100%; }
label { display:flex; flex-direction:column; gap:var(--s1); font-size:var(--text-sm); font-weight:600; }
input { background:var(--surface); color:var(--text); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius); padding:var(--s2) var(--s3); min-height:var(--control-h); font:inherit; font-weight:400; width:100%; }
input:focus { outline:2px solid var(--focus); outline-offset:0; border-color:var(--accent); }
:focus-visible { outline:2px solid var(--focus); outline-offset:2px; border-radius:var(--radius); }
button, a.btn { display:flex; align-items:center; justify-content:center; min-height:var(--control-h); padding:0 var(--s4); background:var(--accent); color:var(--accent-contrast); border:1px solid var(--accent); border-radius:var(--radius); font:inherit; font-weight:500; cursor:pointer; text-decoration:none; transition:background var(--ease); }
button, a.btn { display:flex; align-items:center; justify-content:center; min-height:var(--control-h); padding:0 var(--s4); background:var(--accent); color:var(--accent-contrast); border:var(--border-w) solid var(--accent); border-radius:var(--radius); font:inherit; font-weight:500; cursor:pointer; text-decoration:none; transition:background var(--ease), border-color var(--ease); }
button:hover, a.btn:hover { background:var(--accent-hover); border-color:var(--accent-hover); }
button.ghost, a.ghost { background:var(--surface); border:1px solid var(--border-strong); color:var(--text); }
button.ghost, a.ghost { background:var(--surface); border-color:var(--border-strong); color:var(--text); }
button.ghost:hover, a.ghost:hover { background:var(--surface-2); border-color:var(--border-strong); }
#err { color:var(--danger); min-height:18px; font-size:var(--text-sm); margin-top:var(--s3); }
.ok { color:var(--text-muted); font-size:var(--text-sm); }
#err { color:var(--danger); font-size:var(--text-sm); margin-top:var(--s3); }
#err:empty { display:none; }
.hint { color:var(--text-muted); font-size:var(--text-sm); margin:0; }
[hidden] { display:none !important; }
@media (prefers-reduced-motion: reduce) { * { transition:none !important; } }
@media (pointer: coarse) { button, a.btn, input { min-height:var(--touch); } input { font-size:16px; } }
</style>
</head>
<body>
<div class="card">
<main class="card">
<h1 id="title">Sign in</h1>
<p class="sub" id="sub"></p>
<p class="lead" id="sub"></p>
<div id="body"></div>
<div id="err"></div>
</div>
<div id="err" role="alert"></div>
</main>
<script>
const id = location.pathname.split('/')[3];
const params = new URLSearchParams(location.search);
@@ -77,6 +96,9 @@ function h(tag, props, ...kids) {
return el;
}
const err = m => $('#err').textContent = m || '';
let n = 0;
// a visible label above the control, as the design language requires
const field = (label, input) => { input.id = input.id || 'f' + (++n); return h('label', {for:input.id}, label, input); };
async function post(path, data) {
err('');
@@ -95,54 +117,57 @@ async function finish(path, data) {
function section(title, ...kids) { return h('div', {class:'sec'}, h('h2', {}, title), ...kids); }
function passwordForm(kind, label, type) {
const i = h('input', {type, placeholder:label, autocomplete: kind === 'password' ? 'current-password' : 'off', inputmode: kind === 'pin' ? 'numeric' : null});
const i = h('input', {type, autocomplete: kind === 'password' ? 'current-password' : 'off', inputmode: kind === 'pin' ? 'numeric' : null});
const go = () => finish(kind, { [kind]: i.value });
i.addEventListener('keydown', e => e.key === 'Enter' && go());
return section(label, i, h('button', {onclick:go}, 'Continue'));
return section(label, field(label, i), h('button', {onclick:go}, 'Continue'));
}
function emailForm() {
const email = h('input', {type:'email', placeholder:'you@example.com', autocomplete:'email'});
const code = h('input', {placeholder:'6-digit code', inputmode:'numeric', hidden:true});
const msg = h('div', {class:'ok'});
const code = h('input', {inputmode:'numeric', autocomplete:'one-time-code'});
const codeField = field('6-digit code', code); codeField.hidden = true;
const msg = h('p', {class:'hint'});
const go = h('button', {}, 'Email me a code');
go.onclick = async () => {
try {
if (code.hidden) {
if (codeField.hidden) {
const j = await post(`/auth/r/${id}/email/start`, { email: email.value, return: ret });
msg.textContent = j.status; code.hidden = false; go.textContent = 'Verify code'; code.focus();
msg.textContent = j.status; codeField.hidden = false; go.textContent = 'Verify code'; code.focus();
} else await finish('email/verify', { email: email.value, code: code.value });
} catch (e) { err(e.message); }
};
return section('Email code', email, code, msg, go);
return section('Email code', field('Email address', email), codeField, msg, go);
}
function ssoForm(info) {
const kids = [];
if (info.user) {
if (info.user.allowed) {
kids.push(h('div', {class:'ok'}, `Signed in as ${info.user.email}`), h('button', {onclick:() => finish('sso', {})}, 'Continue'));
kids.push(h('p', {class:'hint'}, `Signed in as ${info.user.email}.`), h('button', {onclick:() => finish('sso', {})}, 'Continue'));
} else {
kids.push(h('div', {class:'ok'}, `${info.user.email} doesn't have access to this resource.`));
kids.push(h('p', {class:'hint'}, `${info.user.email} does not have access to this resource. Ask the person who shared it to add you.`));
}
kids.push(h('button', {class:'ghost', onclick: async () => { await fetch('/api/logout', {method:'POST'}); location.reload(); }}, 'Use a different account'));
return section('Account', ...kids);
}
const email = h('input', {type:'email', placeholder:'Email', autocomplete:'username'});
const pw = h('input', {type:'password', placeholder:'Password', autocomplete:'current-password'});
const code = h('input', {placeholder:'Authenticator code', inputmode:'numeric', autocomplete:'one-time-code', hidden:true});
const email = h('input', {type:'email', autocomplete:'username'});
const pw = h('input', {type:'password', autocomplete:'current-password'});
const code = h('input', {inputmode:'numeric', autocomplete:'one-time-code'});
const emailField = field('Email address', email), pwField = field('Password', pw), codeField = field('Authenticator code', code);
codeField.hidden = true;
let ticket = null;
const go = async () => {
try {
let j;
if (ticket) j = await post('/api/login/totp', { ticket, code: code.value });
else j = await post('/api/login', { email: email.value, password: pw.value });
if (j.needTotp) { ticket = j.ticket; code.hidden = false; email.hidden = pw.hidden = true; code.focus(); return; }
if (j.needTotp) { ticket = j.ticket; codeField.hidden = false; emailField.hidden = pwField.hidden = true; code.focus(); return; }
location.reload();
} catch (e) { err(e.message); }
};
[email, pw, code].forEach(i => i.addEventListener('keydown', e => e.key === 'Enter' && go()));
kids.push(email, pw, code, h('button', {onclick:go}, 'Sign in'));
kids.push(emailField, pwField, codeField, h('button', {onclick:go}, 'Sign in'));
for (const p of info.providers || []) {
const back = `/auth/r/${id}?return=${encodeURIComponent(ret)}`;
kids.push(h('a', {class:'btn ghost', href:`/auth/oidc/${p.id}/start?return=${encodeURIComponent(back)}`}, `Continue with ${p.name}`));
@@ -153,19 +178,20 @@ function ssoForm(info) {
async function main() {
// share links skip the form
const share = params.get('share');
if (share) { $('#title').textContent = 'Checking link…'; return finish('share', { token: share }); }
if (share) { $('#title').textContent = 'Checking your link'; return finish('share', { token: share }); }
try {
const r = await fetch(`/auth/r/${id}/info?return=${encodeURIComponent(ret)}`);
const info = await r.json();
if (!r.ok) throw new Error(info.error);
$('#title').textContent = info.name;
$('#sub').textContent = `${info.host} is protected. Sign in to continue.`;
document.title = 'Sign in to ' + info.name;
$('#sub').textContent = `Sign in to open ${info.host}.`;
if (info.sso) body.append(ssoForm(info));
if (info.password) body.append(passwordForm('password', 'Password', 'password'));
if (info.pin) body.append(passwordForm('pin', 'PIN', 'password'));
if (info.email) body.append(emailForm());
if (!body.children.length) err('No sign-in method is available.');
} catch (e) { $('#title').textContent = 'Unavailable'; err(e.message); }
if (!body.children.length) err('No sign-in method is set up for this page. Ask the person who shared it with you.');
} catch (e) { $('#title').textContent = 'This page is unavailable'; err(e.message); }
}
main();
</script>