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

+4
View File
@@ -259,6 +259,10 @@ Blacktip redirects an unauthenticated visitor to Hammerhead's login page. Hammer
token with a per-edge key and sends the visitor back; Blacktip verifies it and sets a cookie. The same token with a per-edge key and sends the visitor back; Blacktip verifies it and sets a cookie. The same
decision function runs in both proxy modes. decision function runs in both proxy modes.
## Design
The dashboard follows the Lightwell design language: warm neutrals, one teal accent, system fonts, status badges with text, and dialogs that name what they affect. See [docs/design.md](docs/design.md). `node scripts/lint-design.mjs` checks the rules that a machine can check.
## Limits and notes ## Limits and notes
- No client-to-site VPN (Pangolin's Olm/private resources) and no blueprints/YAML import. - No client-to-site VPN (Pangolin's Olm/private resources) and no blueprints/YAML import.
+55
View File
@@ -0,0 +1,55 @@
# Hammerhead design language
The dashboard and the visitor sign-in page follow the Lightwell design language. The reference is `docs/design.md` in the Lightwell repository. This page records how that language is applied here and where Hammerhead needs its own pieces. When the two disagree, Lightwell's document wins.
## What is shared with Lightwell
* **Tokens.** Colors, spacing (4px scale), radius (6px and 10px), type sizes (12 to 28px), the single shadow and the dark theme are the Lightwell values. The accent is Lightwell's teal, `#1f5c5c`, with white text on it. Everything derived from the accent uses `color-mix`.
* **Principles.** Quiet neutrals with one accent, plain and specific wording, one way to do each thing, calm density, and WCAG 2.1 AA.
* **Voice.** No emoji, no em or en dashes, no exclamation marks, sentence case, buttons that name the result, and errors that say what happened and what to do next. Errors that come from the server are capitalized in one place (`fail` in `store.go`).
* **Status carries text.** A status is a badge with a label ("Online", "Listening", "Down"), never a colored dot on its own.
## Where it lives
| File | What it holds |
| --- | --- |
| `web/index.html` | The dashboard. The token block sits between `tokens:begin` and `tokens:end`; raw colors are allowed only there. |
| `web/resource.html` | The sign-in page that visitors see in front of a protected resource. Same token block. |
Both are single files with no build step, so the token block is repeated in each. Change a token in both.
## Components
Built from the helpers at the top of the script in `web/index.html`. Feature code uses these and does not style one-off.
| Helper | Use |
| --- | --- |
| `card`, `pageHeader` | Surfaces and the page title with its one primary action |
| `field`, `chk` | Label above the control, hint below |
| `badge(text, tone)` | Status pill. Tones: success, warning, danger, accent, or none for neutral |
| `emptyState(icon, title, text, action)` | Dashed outline, icon, a title that says "No X yet", one sentence |
| `modal`, `dialogForm` | Dialogs. Focus moves in, Tab stays inside, Escape closes, focus returns to the opener |
| `confirmDialog`, `ask` | Every destructive action. The title names the object, the body says what is lost and whether it can be undone |
| `toast` | Confirmations at the bottom right. Errors stay longer and are announced as alerts |
| `skeleton` | Placeholder rows while a page loads, not a spinner |
| `updatePanel` | Progress bar and live log for an update |
## Icons
Lucide style, outline only, stroke 1.75, round caps, `currentColor`. Sizes are 16, 18 and 22 pixels. They are inline in the `ICONS` table. An icon goes with a text label, and icon-only buttons carry an `aria-label`.
Approved names: `fin`, `mine`, `ports`, `sites`, `domains`, `access`, `logs`, `edges`, `orgs`, `users`, `idps`, `settings`, `audit`, `account`, `egress`, `alerts`, `out`, `menu`, `close`, `add`, `check`, `light`, `dark`, `alert`. Add a name here and in `ICONS` together.
## Layout
* Admin content is capped at 1120px. Below 860px the sidebar becomes a slide-in drawer with a top bar, and grids collapse. Phones keep a 16px side gutter.
* On touch screens controls are at least 44px high and inputs are 16px so the page does not zoom.
* Motion is minimal and turns off under `prefers-reduced-motion`. The theme follows the system and can be switched from the sidebar.
## Checking a change
```
node scripts/lint-design.mjs
```
It fails on em and en dashes, emoji, and raw colors outside the token block, in the pages, the docs and the Go source. Then check the page in light and dark, at phone and desktop width, using only the keyboard.
+11 -11
View File
@@ -108,7 +108,7 @@ func (s *Server) resourceInfo(w http.ResponseWriter, r *http.Request) {
defer s.mu.Unlock() defer s.mu.Unlock()
m := s.protectedMapping(r.PathValue("id")) m := s.protectedMapping(r.PathValue("id"))
if m == nil { if m == nil {
fail(w, 404, errors.New("this resource doesn't need a login, or doesn't exist")) fail(w, 404, errors.New("This page does not need a sign-in, or it no longer exists. Check the address, or ask the person who shared it."))
return return
} }
if _, err := checkReturn(m, r.URL.Query().Get("return")); err != nil { if _, err := checkReturn(m, r.URL.Query().Get("return")); err != nil {
@@ -141,7 +141,7 @@ func (s *Server) grant(w http.ResponseWriter, r *http.Request, m *model.Mapping,
} }
s.mu.Unlock() s.mu.Unlock()
if key == "" { if key == "" {
fail(w, 500, errors.New("edge is not set up for logins")) fail(w, 500, errors.New("This server is not set up for sign-ins yet. Ask the administrator to finish setting it up."))
return return
} }
tok := authtoken.Sign(key, authtoken.Token{Mapping: m.ID, Sub: sub, Kind: authtoken.KindCallback, Epoch: m.AuthEpoch, Exp: time.Now().Add(2 * time.Minute).Unix()}) tok := authtoken.Sign(key, authtoken.Token{Mapping: m.ID, Sub: sub, Kind: authtoken.KindCallback, Epoch: m.AuthEpoch, Exp: time.Now().Add(2 * time.Minute).Unix()})
@@ -158,7 +158,7 @@ func (s *Server) resourceAuth(method string, fn func(w http.ResponseWriter, r *h
return return
} }
if !s.allow("res|"+method+"|"+clientIP(r)+"|"+r.PathValue("id"), 12, 5*time.Minute) { if !s.allow("res|"+method+"|"+clientIP(r)+"|"+r.PathValue("id"), 12, 5*time.Minute) {
fail(w, 429, errors.New("too many attempts, wait a few minutes")) fail(w, 429, errors.New("Too many attempts. Wait a few minutes and try again."))
return return
} }
s.mu.Lock() s.mu.Lock()
@@ -169,7 +169,7 @@ func (s *Server) resourceAuth(method string, fn func(w http.ResponseWriter, r *h
} }
s.mu.Unlock() s.mu.Unlock()
if pm == nil { if pm == nil {
fail(w, 404, errors.New("no such resource")) fail(w, 404, errors.New("That page does not exist. Check the address."))
return return
} }
back, err := checkReturn(&m, body["return"]) back, err := checkReturn(&m, body["return"])
@@ -191,7 +191,7 @@ func (s *Server) resourcePassword(w http.ResponseWriter, r *http.Request, m *mod
} }
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(body["password"])) != nil || hash == string(dummyHash) { if bcrypt.CompareHashAndPassword([]byte(hash), []byte(body["password"])) != nil || hash == string(dummyHash) {
time.Sleep(300 * time.Millisecond) time.Sleep(300 * time.Millisecond)
fail(w, 401, errors.New("wrong password")) fail(w, 401, errors.New("That password is not right. Check it and try again."))
return return
} }
s.grant(w, r, m, back, "password") s.grant(w, r, m, back, "password")
@@ -207,7 +207,7 @@ func (s *Server) resourcePin(w http.ResponseWriter, r *http.Request, m *model.Ma
} }
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(body["pin"])) != nil || hash == string(dummyHash) { if bcrypt.CompareHashAndPassword([]byte(hash), []byte(body["pin"])) != nil || hash == string(dummyHash) {
time.Sleep(300 * time.Millisecond) time.Sleep(300 * time.Millisecond)
fail(w, 401, errors.New("wrong PIN")) fail(w, 401, errors.New("That PIN is not right. Check it and try again."))
return return
} }
s.grant(w, r, m, back, "pin") s.grant(w, r, m, back, "pin")
@@ -216,7 +216,7 @@ func (s *Server) resourcePin(w http.ResponseWriter, r *http.Request, m *model.Ma
func (s *Server) resourceSSO(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) { func (s *Server) resourceSSO(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) {
p := s.principalFrom(r) p := s.principalFrom(r)
if p == nil || p.User.ID == "" || !m.Auth.SSO { if p == nil || p.User.ID == "" || !m.Auth.SSO {
fail(w, 401, errors.New("sign in first")) fail(w, 401, errors.New("Sign in first, then try again."))
return return
} }
s.mu.Lock() s.mu.Lock()
@@ -224,7 +224,7 @@ func (s *Server) resourceSSO(w http.ResponseWriter, r *http.Request, m *model.Ma
allowed := u != nil && s.canAccess(u, m) allowed := u != nil && s.canAccess(u, m)
s.mu.Unlock() s.mu.Unlock()
if !allowed { if !allowed {
fail(w, 403, errors.New("your account doesn't have access to this resource")) fail(w, 403, errors.New("Your account does not have access to this page. Ask the person who shared it to add you."))
return return
} }
s.grant(w, r, m, back, p.User.Email) s.grant(w, r, m, back, p.User.Email)
@@ -243,7 +243,7 @@ func mailAllowed(list []string, email string) bool {
func (s *Server) resourceEmailStart(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) { func (s *Server) resourceEmailStart(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) {
email := strings.ToLower(strings.TrimSpace(body["email"])) email := strings.ToLower(strings.TrimSpace(body["email"]))
if !m.Auth.Email || !s.mailConfigured() { if !m.Auth.Email || !s.mailConfigured() {
fail(w, 400, errors.New("email codes are not available for this resource")) fail(w, 400, errors.New("Email codes are not turned on for this page. Use another sign-in method."))
return return
} }
// always answer the same way so addresses can't be probed // always answer the same way so addresses can't be probed
@@ -279,7 +279,7 @@ func (s *Server) resourceEmailVerify(w http.ResponseWriter, r *http.Request, m *
} }
s.mu.Unlock() s.mu.Unlock()
if !good || !mailAllowed(m.Auth.AllowedMails, email) { if !good || !mailAllowed(m.Auth.AllowedMails, email) {
fail(w, 401, errors.New("wrong or expired code")) fail(w, 401, errors.New("That code is wrong or has expired. Request a new one and try again."))
return return
} }
s.grant(w, r, m, back, email) s.grant(w, r, m, back, email)
@@ -303,7 +303,7 @@ func (s *Server) resourceShare(w http.ResponseWriter, r *http.Request, m *model.
} }
s.mu.Unlock() s.mu.Unlock()
if !valid { if !valid {
fail(w, 401, errors.New("this share link is invalid, expired or used up")) fail(w, 401, errors.New("This share link is invalid, expired or already used up. Ask for a new one."))
return return
} }
if name == "" { if name == "" {
+57
View File
@@ -0,0 +1,57 @@
// Style gate for the dashboard, following the Lightwell design language (see docs/design.md).
// Fails on em or en dashes, emoji, exclamation marks in interface copy, and raw colors outside the
// token block of web/index.html. Run with: node scripts/lint-design.mjs
import { readdirSync, readFileSync, statSync } from "node:fs";
import { extname, join, relative, sep } from "node:path";
const root = new URL("..", import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1");
const targets = ["web", "docs", "README.md", "deploy", "scripts", "cmd", "."];
const skipDirs = new Set(["node_modules", ".git", "e2e", "internal"]);
const exts = new Set([".html", ".md", ".go", ".sh", ".mjs", ".example"]);
const dash = /[–—−]/;
const emoji = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{1F900}-\u{1F9FF}\u{FE0F}\u{2B50}\u{2705}\u{274C}]/u;
const rawColor = /#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(/;
const seen = new Set();
function* walk(p, top) {
const st = statSync(p, { throwIfNoEntry: false });
if (!st || seen.has(p)) return;
if (st.isFile()) { seen.add(p); return yield p; }
for (const name of readdirSync(p)) {
if (skipDirs.has(name) || (top && name.startsWith("."))) continue;
const full = join(p, name);
// the "." target only contributes the files at the top level
if (p === root && statSync(full).isDirectory()) continue;
yield* walk(full, false);
}
}
const problems = [];
for (const t of targets) {
for (const file of walk(join(root, t), true)) {
if (!exts.has(extname(file)) && !file.endsWith(".env.example")) continue;
const rel = relative(root, file).split(sep).join("/");
if (rel.endsWith("go.sum") || rel === "scripts/lint-design.mjs") continue;
const isPage = rel.startsWith("web/") && rel.endsWith(".html");
let inTokens = false;
readFileSync(file, "utf8").split("\n").forEach((line, i) => {
const at = (msg) => problems.push(`${rel}:${i + 1}: ${msg}`);
if (line.includes("tokens:begin")) inTokens = true;
if (dash.test(line)) at("em dash, en dash or minus sign. Use a comma, colon, period or the word to.");
if (emoji.test(line)) at("emoji or pictographic symbol. Use an approved outline icon.");
if (isPage && !inTokens && rawColor.test(line.replace(/\/\*.*?\*\//g, "")) && !/placeholder|href="#|&#/.test(line)) {
at("raw color outside the token block. Use a token.");
}
if (isPage && /(?:toast|textContent\s*=|class:'(?:empty|notice)')[^;\n]*'[^']*[A-Za-z]![^']*'/.test(line)) at("exclamation mark in interface copy.");
if (line.includes("tokens:end")) inTokens = false;
});
}
}
if (problems.length) {
console.error(problems.join("\n"));
console.error(`\n${problems.length} style problem(s).`);
process.exit(1);
}
console.log("Style checks passed.");
+8 -1
View File
@@ -18,6 +18,8 @@ import (
"git.veillax.com/Veillax/hammerhead/internal/secretbox" "git.veillax.com/Veillax/hammerhead/internal/secretbox"
"git.veillax.com/Veillax/reef/model" "git.veillax.com/Veillax/reef/model"
"unicode"
"unicode/utf8"
) )
type Org struct { type Org struct {
@@ -433,8 +435,13 @@ func writeJSON(w http.ResponseWriter, code int, v any) {
json.NewEncoder(w).Encode(v) json.NewEncoder(w).Encode(v)
} }
// fail sends an error to the browser. Messages are shown to people, so they start with a capital letter.
func fail(w http.ResponseWriter, code int, err error) { func fail(w http.ResponseWriter, code int, err error) {
writeJSON(w, code, map[string]string{"error": err.Error()}) msg := err.Error()
if r, n := utf8.DecodeRuneInString(msg); n > 0 {
msg = string(unicode.ToUpper(r)) + msg[n:]
}
writeJSON(w, code, map[string]string{"error": msg})
} }
func failf(w http.ResponseWriter, code int, format string, a ...any) { func failf(w http.ResponseWriter, code int, format string, a ...any) {
+321 -187
View File
@@ -3,158 +3,217 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Hammerhead</title> <title>Hammerhead</title>
<style> <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 { :root {
--accent:#0891b2; --accent-contrast:#fff; --accent:#1f5c5c; --accent-contrast:#ffffff;
--bg:#fafafa; --surface:#fff; --surface-2:#f4f4f5; --border:#e4e4e7; --border-strong:#d4d4d8; --bg:#f6f5f3; --surface:#ffffff; --surface-2:#efede9; --border:#e0ddd7; --border-strong:#c4c0b8;
--text:#09090b; --text-muted:#52525b; --text-faint:#71717a; --text:#1d1c1a; --text-muted:#5f5b54; --text-faint:#7a766e;
--accent-text:color-mix(in srgb, var(--accent) 85%, #000); --accent-text:var(--accent);
--accent-soft:color-mix(in srgb, var(--accent) 10%, var(--surface)); --accent-soft:color-mix(in srgb, var(--accent) 9%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 86%, #000); --accent-hover:color-mix(in srgb, var(--accent) 88%, #000000);
--focus:color-mix(in srgb, var(--accent) 65%, #fff); --focus:color-mix(in srgb, var(--accent) 70%, #ffffff);
--success:#1c6b3a; --success-bg:#e3f1e8; --warning:#85560a; --warning-bg:#f9eed6; --danger:#a3271e; --danger-bg:#f9e6e3; --success:#1c6b3a; --success-bg:#e3f1e8; --warning:#85560a; --warning-bg:#f9eed6; --danger:#a3271e; --danger-bg:#f9e6e3; --neutral-bg:#eceae6;
--overlay:rgb(15 18 22 / .5); --shadow:0 8px 24px rgb(15 18 22 / .14); --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; --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:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--font-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace; --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; --text-xs:12px; --text-sm:13px; --text-base:15px; --text-lg:18px; --text-xl:22px; --text-2xl:28px; --leading:1.5;
--control-h:36px; --sidebar-w:248px; --content-max:1180px; --ease:140ms ease; --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; color-scheme:light;
} }
@media (prefers-color-scheme: dark) { :root { @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {
--accent:#22b8d4; --accent-contrast:#04222a; --bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943;
--bg:#09090b; --surface:#0f0f11; --surface-2:#18181b; --border:#27272a; --border-strong:#3f3f46; --text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84;
--text:#fafafa; --text-muted:#a1a1aa; --text-faint:#71717a; --accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff);
--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); --accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface));
--success:#86d3a0; --success-bg:#16301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --accent-hover:color-mix(in srgb, var(--accent) 85%, #ffffff);
--overlay:rgb(0 0 0 / .65); --shadow:0 8px 24px rgb(0 0 0 / .5); color-scheme:dark; --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%; } 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 { 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); } :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 */ /* shell */
.app { display:flex; min-height:100vh; } .shell { display:grid; grid-template-columns:var(--sidebar-w) minmax(0,1fr); 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; } .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; }
.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); } .sidebar-head { display:flex; align-items:center; justify-content:space-between; gap:var(--s2); }
.brand svg { color:var(--accent); } .sidebar button.sidebar-close { display:none; }
.orgsel { margin:0 0 var(--s2); } .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; }
.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); } .brand .icon { color:var(--accent-text); }
a.navitem { text-decoration:none; } .orgsel select { width:100%; }
.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); } .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:hover { background:var(--surface-2); color:var(--text); }
.navitem { border-radius:var(--radius); } .navitem.on { background:var(--accent-soft); color:var(--accent-text); }
.navitem.on { background:var(--surface-2); color:var(--text); } .grow { flex:1 1 auto; min-width:0; }
.navitem.on svg { color:var(--accent); } .userbox { border-top:var(--border-w) solid var(--border); padding-top:var(--s3); }
.navitem svg { flex:none; } .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; }
.grow { flex:1; } .content { min-width:0; }
.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; }
main { max-width:var(--content-max); margin:0 auto; padding:var(--s6) var(--s5) var(--s8); } 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 { 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); max-width:70ch; }
.page-h p { margin:var(--s1) 0 0; color:var(--text-muted); } .page-h .btn { white-space:nowrap; }
/* cards, layout */ /* 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.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 { display:flex; align-items:center; justify-content:flex-end; gap:var(--s3); margin-bottom:var(--s3); flex-wrap:wrap; }
.row-h h2 { display:none; } .row-h h2 { display:none; }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:var(--s4); align-items:end; } .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); } .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); } .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 { 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 { border-color:var(--border-strong); background:var(--surface-2); } .choice:hover { background:var(--surface-2); }
.choice.on { border-color:var(--accent); background:var(--accent-soft); } .choice.on { border-color:var(--accent); background:var(--accent-soft); }
.choice-t { font-weight:600; } .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; } /* update progress and log */
.progress > div { height:100%; background:var(--accent); transition:width .4s ease; } .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); } .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 */ /* forms */
label { display:flex; flex-direction:column; gap:var(--s1); font-size:var(--text-sm); font-weight:600; color:var(--text); } 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; } 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: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%; } 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; } 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:focus, select:focus, textarea:focus { outline:2px solid var(--focus); outline-offset:0; border-color:var(--accent); }
input:disabled, select:disabled { opacity:.6; } 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; } .sub { color:var(--text-muted); font-size:var(--text-sm); font-weight:400; }
/* buttons: one height, one shape; ghost is the quiet variant */ /* buttons: one height and shape. btn is the primary action, ghost the default, danger and quiet the others */
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, 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:1px solid var(--accent); } 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.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.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); } 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; } 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 */ /* tables */
table { width:100%; border-collapse:collapse; } 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); } 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:1px solid var(--border); vertical-align:top; } 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); } tbody tr:hover td { background:var(--surface-2); }
tr:last-child td { border-bottom:0; } tr:last-child td { border-bottom:0; }
.card > table, .card > .scroll > table { margin:calc(var(--s1) * -1) 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 */ /* badges and tags: status always carries a text label, never color alone */
.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; } .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; }
.dot { display:inline-block; width:8px; height:8px; border-radius:50%; background:var(--text-faint); margin-right:var(--s2); flex:none; } .badge-success { background:var(--success-bg); color:var(--success); }
.dot.ok { background:var(--success); box-shadow:0 0 0 3px var(--success-bg); } .dot.bad { background:var(--danger); } .dot.warn { background:var(--warning); } .badge-warning { background:var(--warning-bg); color:var(--warning); }
code, .mono { font-family:var(--font-mono); font-size:var(--text-sm); } .badge-danger { background:var(--danger-bg); color:var(--danger); }
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; } .badge-accent { background:var(--accent-soft); color:var(--accent-text); }
.dim { color:var(--text-muted); } .err { color:var(--danger); } .small { font-size:var(--text-sm); } .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); }
.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); } 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; }
.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); }
/* dialogs, tabs, toast */ /* empty state, notice, loading */
.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; } .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); }
.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); } .empty h3 { color:var(--text); margin-bottom:var(--s1); }
.modal h2 { margin:0 0 var(--s4); font-size:var(--text-lg); } .empty p { margin:0 auto var(--s3); max-width:56ch; }
fieldset { border:1px solid var(--border); border-radius:var(--radius-lg); padding:var(--s4); margin:0; } .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); } 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; } .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); } .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 { 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:hover { background:var(--surface-2); }
.tabs2 button.on { background:var(--accent-soft); color:var(--accent-text); } .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); } .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.bad { background:var(--danger); color:var(--accent-contrast); } .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); }
@media (prefers-reduced-motion: reduce) { * { transition:none !important; } } .toast-error { background:var(--danger); color:var(--accent-contrast); }
@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; }
}
@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> </style>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<div id="toast"></div>
<script> <script>
// ---------- tiny toolkit ---------- // ---------- tiny toolkit ----------
function h(tag, props, ...kids) { function h(tag, props, ...kids) {
@@ -169,11 +228,21 @@ function h(tag, props, ...kids) {
return el; return el;
} }
const $app = document.getElementById('app'); 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) { function toast(msg, bad) {
const t = document.getElementById('toast'); if (!toastBox) { toastBox = h('div', {class:'toasts', 'aria-live':'polite'}); document.body.append(toastBox); }
t.textContent = msg; t.className = bad ? 'bad' : ''; t.style.display = 'block'; const t = h('div', {class:'toast' + (bad ? ' toast-error' : ''), role:bad ? 'alert' : 'status'}, msg);
clearTimeout(toastTimer); toastTimer = setTimeout(() => t.style.display = 'none', bad ? 6000 : 2500); toastBox.append(t);
setTimeout(() => t.remove(), bad ? 7000 : 3000);
} }
async function api(method, url, body) { async function api(method, url, body) {
const r = await fetch(url, { method, headers: {'Content-Type':'application/json'}, body: body !== undefined ? JSON.stringify(body) : undefined }); 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')))); 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); 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 = {}) { function modal(title, body, opts = {}) {
const opener = document.activeElement, id = 'dialog-title-' + (++dialogSeq);
const shade = h('div', {class:'shade'}); 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.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.body.append(shade);
document.addEventListener('keydown', onKey, true);
(box.querySelector('input,select,textarea') || focusables()[0] || box).focus();
return close; 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'); const modalOpen = () => !!document.querySelector('.shade');
// ---------- icons (lucide-style, static strings) ---------- // ---------- icons (lucide-style, static strings) ----------
@@ -283,14 +395,21 @@ const ICONS = {
egress:'<path d="M7 17 17 7M8 7h9v9"/>', 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"/>', 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"/>', 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) { function icon(name, size = 18) {
const s = document.createElement('span'); const s = document.createElement('span');
s.style.display = 'inline-flex'; s.style.display = 'inline-flex'; s.setAttribute('aria-hidden', 'true');
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.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; return s;
} }
const brand = () => h('div', {class:'brand'}, icon('fin', 24), 'Hammerhead'); const brand = () => h('div', {class:'brand'}, icon('fin', 22), 'Hammerhead');
// ---------- state ---------- // ---------- state ----------
const S = { me: null, org: localStorage.getItem('org'), tab: 'ports', st: null, pub: null }; 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 = { const PAGES = {
mine: ['My apps', 'Apps you have been given access to.'], 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.'], 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.'], 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.'], 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.'], 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.'], 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(); } }); window.addEventListener('popstate', () => { const k = tabFromPath(location.pathname); if (S.me && k) { selectTab(k); render(); } });
async function 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; if (e.metaKey || e.ctrlKey || e.shiftKey || e.button) return;
e.preventDefault(); selectTab(key); history.pushState(null, '', TAB_PATH[key]); render(); }}, icon(ic), label); 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(); })}, 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))))); S.me.orgs.map(o => h('option', {value:o.id, selected:o.id === S.org}, o.name)))));
const all = []; const all = [];
@@ -404,15 +523,30 @@ async function render() {
if (!all.includes(S.tab)) S.tab = all[0]; if (!all.includes(S.tab)) S.tab = all[0];
if (location.pathname !== TAB_PATH[S.tab]) history.replaceState(null, '', TAB_PATH[S.tab]); 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'), 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'))); 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(); 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) { function pageHeader(tab) {
const [t, d] = PAGES[tab] || ['', '']; const [t, d] = PAGES[tab] || ['', ''];
const a = S.headerAction; 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; let seq = 0;
@@ -421,10 +555,12 @@ async function refresh() {
try { try {
if (S.org && !SRV[S.tab] && S.tab !== 'account') await loadOrg(); if (S.org && !SRV[S.tab] && S.tab !== 'account') await loadOrg();
S.headerAction = null; 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]()); const view = await (SRV[S.tab] ? views.server() : views[S.tab]());
if (my !== seq) return; if (my !== seq) return;
const v = document.getElementById('view'); 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); } } catch (e) { if (e.message !== 'Please sign in') toast(e.message, true); }
} }
setInterval(() => { setInterval(() => {
@@ -443,7 +579,7 @@ views.mine = async () => {
const list = await api('GET', '/api/me/resources'); 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)))), 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('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 ----- // ----- 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 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 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', {}, 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; 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()]; 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'; }); }}); 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); 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) { function mappingRow(m, st, series) {
const edge = byId(st.edges, m.edgeId), s = m.status || {}; const edge = byId(st.edges, m.edgeId), s = m.status || {};
let dot = 'dot', why = 'paused'; let tone = '', label = 'Paused', why = 'paused';
if (m.enabled) { if (m.enabled) {
const th = s.targets || []; const th = s.targets || [];
if (!m.statusKnown) { dot += ' warn'; why = 'waiting for the edge'; } if (!m.statusKnown) { tone = 'warning'; label = 'Waiting'; why = 'waiting for the edge'; }
else if (s.error) { dot += ' bad'; why = s.error; } else if (s.error) { tone = 'danger'; label = 'Error'; why = s.error; }
else if (th.length && th.every(t => !t.healthy)) { dot += ' bad'; why = 'no healthy target'; } else if (th.length && th.every(t => !t.healthy)) { tone = 'danger'; label = 'Down'; why = 'no healthy target'; }
else if (th.some(t => !t.healthy)) { dot += ' warn'; why = 'some targets are down'; } else if (th.some(t => !t.healthy)) { tone = 'warning'; label = 'Degraded'; why = 'some targets are down'; }
else { dot += ' ok'; why = 'listening'; } else { tone = 'success'; label = 'Listening'; why = 'listening'; }
} }
const web = m.proto === 'http' || m.proto === 'https'; 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 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); 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', {}, return h('tr', {},
h('td', {title:why}, h('span', {class:dot})), 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', {class:'tag', style:'margin-left:4px', title:'login required'}, '🔒') : null), 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.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'}, 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) => { h('td', {class:'mono small'}, m.targets.map((t, i) => {
const th = (s.targets || [])[i], site = byId(st.sites, t.siteId); 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('td', {}, h('div', {class:'actions'},
h('button', {class:'ghost', onclick:() => portForm(m)}, 'Edit'), 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'), 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', 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 () => { 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,
if (confirm(`Sign everyone out of ${m.name}?`)) { await api('POST', O(`/mappings/${m.id}/revoke`)); toast('Visitors signed out'); } 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'))));
})}, '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'))));
} }
function portForm(existing) { 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 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}); 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), 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)}, '🐳'), 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', onclick:() => { targets.splice(i, 1); drawTargets(); }}, '✕') : h('span')); 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); }), 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) => { 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.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('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('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')); 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 drawWeb = () => {
const p = protoSel.value, web = p === 'http' || p === 'https'; 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. // The add flow for a new resource: a few short steps that reuse the controls above.
function wizardModal() { function wizardModal() {
const TYPES = [ const TYPES = [
['https', '🌐', 'Website', 'A site or web app, with an automatic HTTPS certificate.'], ['https', 'Website', 'A site or web app, with an automatic HTTPS certificate.'],
['tcp', '🎮', 'Game or app port (TCP)', 'Minecraft, SSH, databases: anything over TCP.'], ['tcp', 'Game or app port (TCP)', 'Minecraft, SSH, databases: anything over TCP.'],
['udp', '📡', 'UDP port', 'Voice chat, some games, DNS.'], ['udp', 'UDP port', 'Voice chat, some games, DNS.'],
['both', '🔁', 'TCP + UDP', 'The same port for both, as many games want.'], ['both', 'TCP + UDP', 'The same port for both, as many games want.'],
['tls', '🔒', 'TLS passthrough', 'Route encrypted traffic by hostname without decrypting it.'], ['tls', 'TLS passthrough', 'Route encrypted traffic by hostname without decrypting it.'],
['http', '🌍', 'Website, plain HTTP', 'No certificate: for internal use or behind another proxy.'], ['http', 'Website, plain HTTP', 'No certificate: for internal use or behind another proxy.'],
]; ];
const [accessFs, rulesFs, advFs] = [...webBox.children]; const [accessFs, rulesFs, advFs] = [...webBox.children];
let step = 0, chosen = false; let step = 0, chosen = false;
@@ -692,8 +826,8 @@ function portForm(existing) {
back.style.display = step ? '' : 'none'; back.style.display = step ? '' : 'none';
const kids = [h('h3', {style:'margin:0'}, titles[cur])]; const kids = [h('h3', {style:'margin:0'}, titles[cur])];
if (cur === 'type') { 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)}, 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'}, icon + ' ' + t), h('div', {class:'small dim'}, d))))); h('div', {class:'choice-t'}, t), h('div', {class:'small dim'}, d)))));
} else if (cur === 'target') { } else if (cur === 'target') {
if (st.edges.length > 1) kids.push(field('Public server (edge)', edgeSel, 'The server that listens for visitors.')); 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); 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)); } out.replaceChildren(h('p', {class:'small'}, 'Copy this now; it is not shown again:'), cmdBox(r.url)); }
)}, 'Create link')), out, )}, 'Create link')), out,
links.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Label', 'Used', 'Expires', ''].map(t => h('th', {}, t)))), 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('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: act(async () => { await api('DELETE', O(`/mappings/${m.id}/shares/${l.id}`)); draw(); })}, 'Revoke')))))) : h('div', {class:'dim'}, 'No links yet.')); 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); 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')))); }]; h('div', {class:'actions'}, h('button', {class:'btn', onclick:() => { close(); add(); }}, 'Add site'), h('button', {class:'ghost', onclick:() => close()}, 'Cancel')))); }];
const latest = await latestVersions(); const latest = await latestVersions();
return h('div', {}, 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('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 ? 'this machine (direct)' : 'bamboo agent'),
h('td', {}, s.local ? '—' : ((s.connected || []).map(id => (byId(st.edges, id) || {name:id}).name).join(', ') || 'nothing connected')), 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'}, '—') : versionCell(s, latest.bamboo, !!s.updateId, O(`/sites/${s.id}/update`), O(`/sites/${s.id}/update/log`)), 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 () => { h('td', {}, h('button', {class:'ghost', onclick: act(async () => {
const l = await api('GET', O(`/sites/${s.id}/containers`)); 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(', ')))))) : 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('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('td', {}, s.local ? null : h('div', {class:'actions'},
h('button', {class:'ghost', onclick:() => showCmd(s)}, 'Show command'), 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 ----- // ----- domains -----
@@ -773,9 +907,9 @@ views.domains = async () => {
return h('div', {}, 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)))), 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('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', {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('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('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) { function checklist(e) {
const c = e.check, ip = e.sourceIp, helo = e.heloName; 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'}, 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)); h('div', {}, h('div', {}, title), fix ? h('div', {class:'dim small'}, fix) : null));
const body = h('div', {}, 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.'), 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 remote = st.sites.filter(s => !s.local);
const rows = st.mailEgress || []; const rows = st.mailEgress || [];
const table = rows.length ? h('div', {class:'scroll'}, h('table', {}, 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 => { h('tbody', {}, rows.map(e => {
const site = byId(st.sites, e.siteId), edge = byId(st.edges, e.edgeId), c = e.check; 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.enabled) {
if (!e.statusKnown) { dot += ' warn'; why = 'waiting for the site'; } if (!e.statusKnown) { tone = 'warning'; label = 'Waiting'; why = 'waiting for the site'; }
else if (e.status) { dot += ' bad'; why = e.status; } else if (e.status) { tone = 'danger'; label = 'Error'; why = e.status; }
else { dot += ' ok'; why = 'listening'; } else { tone = 'success'; label = 'Listening'; why = 'listening'; }
} }
const good = c && c.ptrMatchesHelo && c.heloResolves && c.port25 === 'open'; const good = c && c.ptrMatchesHelo && c.heloResolves && c.port25 === 'open';
return h('tr', {}, 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, 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.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', {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))}, 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('td', {}, h('div', {class:'actions'},
h('button', {class:'ghost', onclick:() => mailForm(e)}, 'Edit'), 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', 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('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'))));
})))) : h('div', {class:'empty'}, 'Nothing set up yet. Add one so your mail server sends from the edge\'s IP.'); })))) : 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()]; S.headerAction = ['Add mail egress', () => mailForm()];
return h('div', {}, 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), h('div', {class:'card flush'}, table),
await dkimCard(), 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.'), 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() { async function dkimCard() {
const keys = await api('GET', O('/dkim')); const keys = await api('GET', O('/dkim'));
const dom = h('input', {placeholder:'example.com'}), sel = h('input', {placeholder:'blank = automatic, e.g. hh202610'}); 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.'), 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))); 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.'), 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', {}, 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('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', 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'))); 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('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: 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', 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}); 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)]; S.headerAction = ['Add channel', () => channelForm(base, null, trusted)];
return h('div', {}, 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, !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', {}, 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)))))) : 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)))))) :
h('div', {class:'empty'}, '✓ Nothing is wrong.')), emptyState('check', 'Nothing is wrong', 'Problems that are firing or about to fire appear here.')),
card('Where alerts go', 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.'), 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('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, 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()), 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))); 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)))), 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('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', {}, 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.totp ? badge('On', 'success') : badge('Off')),
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.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()), 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.'), 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('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'))), 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 + '/…'), 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')))); 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'}, 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))), 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), 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 ----- // ----- server administration -----
@@ -1026,11 +1160,11 @@ serverViews.edges = async () => {
h('button', {class:'ghost', onclick:() => close()}, 'Cancel')))); }]; h('button', {class:'ghost', onclick:() => close()}, 'Cancel')))); }];
const latest = await latestVersions(); const latest = await latestVersions();
return h('div', {}, 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)))), 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', {}, h('span', {class:'dot ' + (e.online ? 'ok' : 'bad'), title:e.online ? 'online' : 'offline'})), h('td', {}, e.name), h('td', {class:'mono'}, e.tunnelAddr), 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(', ') || '—'), 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', {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('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 () => { serverViews.orgs = async () => {
@@ -1043,7 +1177,7 @@ serverViews.orgs = async () => {
h('td', {}, h('button', {class:'ghost', onclick: act(async () => { h('td', {}, h('button', {class:'ghost', onclick: act(async () => {
const st = await api('GET', `/api/orgs/${o.id}/state`); 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')), 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'); } 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(); })]; 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', {}, return h('div', {},
card('Users', h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Email', 'Organizations', 'Flags', ''].map(t => h('th', {}, t)))), 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.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('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'), 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, 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 () => { 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', 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 () => { serverViews.idps = async () => {
@@ -1082,7 +1216,7 @@ serverViews.idps = async () => {
S.headerAction = ['Add provider', () => form()]; 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.'), 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'), 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 () => { serverViews.settings = async () => {
@@ -1127,7 +1261,7 @@ serverViews.settings = async () => {
serverViews.audit = async () => { serverViews.audit = async () => {
const rows = await api('GET', '/api/admin/audit'); 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 ----- // ----- account -----
@@ -1138,7 +1272,7 @@ views.account = async () => {
const draw2fa = () => { const draw2fa = () => {
if (S.me.totpEnabled) { if (S.me.totpEnabled) {
const pw = h('input', {type:'password', placeholder:'Password'}), code = h('input', {placeholder:'Authenticator code'}); 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'))); 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.'), } 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 () => { 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, 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('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', {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'))), 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), 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'))), 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> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Sign in</title> <title>Sign in</title>
<style> <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 { :root {
--accent:#0891b2; --accent-contrast:#fff; --accent:#1f5c5c; --accent-contrast:#ffffff;
--bg:#f7f8f9; --surface:#fff; --surface-2:#f0f2f4; --border:#e2e5e9; --border-strong:#c6ccd3; --bg:#f6f5f3; --surface:#ffffff; --surface-2:#efede9; --border:#e0ddd7; --border-strong:#c4c0b8;
--text:#16191d; --text-muted:#566069; --text-faint:#75808b; --text:#1d1c1a; --text-muted:#5f5b54; --text-faint:#7a766e;
--accent-text:color-mix(in srgb, var(--accent) 85%, #000); --accent-text:var(--accent);
--accent-soft:color-mix(in srgb, var(--accent) 10%, var(--surface)); --accent-soft:color-mix(in srgb, var(--accent) 9%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 86%, #000); --accent-hover:color-mix(in srgb, var(--accent) 88%, #000000);
--focus:color-mix(in srgb, var(--accent) 65%, #fff); --focus:color-mix(in srgb, var(--accent) 70%, #ffffff);
--success:#1c6b3a; --success-bg:#e3f1e8; --warning:#85560a; --warning-bg:#f9eed6; --danger:#a3271e; --danger-bg:#f9e6e3; --success:#1c6b3a; --success-bg:#e3f1e8; --warning:#85560a; --warning-bg:#f9eed6; --danger:#a3271e; --danger-bg:#f9e6e3; --neutral-bg:#eceae6;
--overlay:rgb(15 18 22 / .5); --shadow:0 8px 24px rgb(15 18 22 / .14); --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; --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:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--font-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace; --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; --text-xs:12px; --text-sm:13px; --text-base:15px; --text-lg:18px; --text-xl:22px; --text-2xl:28px; --leading:1.5;
--control-h:36px; --sidebar-w:248px; --content-max:1180px; --ease:140ms ease; --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; color-scheme:light;
} }
@media (prefers-color-scheme: dark) { :root { @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {
--accent:#22b8d4; --accent-contrast:#04222a; --bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943;
--bg:#0c0d0f; --surface:#141517; --surface-2:#1d1f22; --border:#2a2d31; --border-strong:#41454b; --text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84;
--text:#eceef0; --text-muted:#aab1b9; --text-faint:#8a929b; --accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff);
--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); --accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface));
--success:#86d3a0; --success-bg:#16301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --accent-hover:color-mix(in srgb, var(--accent) 85%, #ffffff);
--overlay:rgb(0 0 0 / .65); --shadow:0 8px 24px rgb(0 0 0 / .5); color-scheme:dark; --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"] {
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; } --bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943;
.card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-lg); padding:var(--s5); width:100%; max-width:400px; } --text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84;
h1 { font-size:var(--text-xl); font-weight:600; letter-spacing:-.01em; margin:0 0 var(--s1); } --accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff);
.sub { color:var(--text-muted); margin:0 0 var(--s4); font-size:var(--text-sm); } --accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface));
.sec { border-top:1px solid var(--border); padding-top:var(--s4); margin-top:var(--s4); display:flex; flex-direction:column; gap:var(--s2); } --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; } .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; } 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:1px solid var(--border-strong); border-radius:var(--radius); padding:var(--s2) var(--s3); min-height:var(--control-h); font:inherit; width:100%; } 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); } 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); } :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: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); } 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); } #err { color:var(--danger); font-size:var(--text-sm); margin-top:var(--s3); }
.ok { color:var(--text-muted); font-size:var(--text-sm); } #err:empty { display:none; }
.hint { color:var(--text-muted); font-size:var(--text-sm); margin:0; }
[hidden] { display:none !important; } [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> </style>
</head> </head>
<body> <body>
<div class="card"> <main class="card">
<h1 id="title">Sign in</h1> <h1 id="title">Sign in</h1>
<p class="sub" id="sub"></p> <p class="lead" id="sub"></p>
<div id="body"></div> <div id="body"></div>
<div id="err"></div> <div id="err" role="alert"></div>
</div> </main>
<script> <script>
const id = location.pathname.split('/')[3]; const id = location.pathname.split('/')[3];
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
@@ -77,6 +96,9 @@ function h(tag, props, ...kids) {
return el; return el;
} }
const err = m => $('#err').textContent = m || ''; 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) { async function post(path, data) {
err(''); err('');
@@ -95,54 +117,57 @@ async function finish(path, data) {
function section(title, ...kids) { return h('div', {class:'sec'}, h('h2', {}, title), ...kids); } function section(title, ...kids) { return h('div', {class:'sec'}, h('h2', {}, title), ...kids); }
function passwordForm(kind, label, type) { 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 }); const go = () => finish(kind, { [kind]: i.value });
i.addEventListener('keydown', e => e.key === 'Enter' && go()); 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() { function emailForm() {
const email = h('input', {type:'email', placeholder:'you@example.com', autocomplete:'email'}); 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 code = h('input', {inputmode:'numeric', autocomplete:'one-time-code'});
const msg = h('div', {class:'ok'}); const codeField = field('6-digit code', code); codeField.hidden = true;
const msg = h('p', {class:'hint'});
const go = h('button', {}, 'Email me a code'); const go = h('button', {}, 'Email me a code');
go.onclick = async () => { go.onclick = async () => {
try { try {
if (code.hidden) { if (codeField.hidden) {
const j = await post(`/auth/r/${id}/email/start`, { email: email.value, return: ret }); 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 }); } else await finish('email/verify', { email: email.value, code: code.value });
} catch (e) { err(e.message); } } 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) { function ssoForm(info) {
const kids = []; const kids = [];
if (info.user) { if (info.user) {
if (info.user.allowed) { 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 { } 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')); kids.push(h('button', {class:'ghost', onclick: async () => { await fetch('/api/logout', {method:'POST'}); location.reload(); }}, 'Use a different account'));
return section('Account', ...kids); return section('Account', ...kids);
} }
const email = h('input', {type:'email', placeholder:'Email', autocomplete:'username'}); const email = h('input', {type:'email', autocomplete:'username'});
const pw = h('input', {type:'password', placeholder:'Password', autocomplete:'current-password'}); const pw = h('input', {type:'password', autocomplete:'current-password'});
const code = h('input', {placeholder:'Authenticator code', inputmode:'numeric', autocomplete:'one-time-code', hidden:true}); 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; let ticket = null;
const go = async () => { const go = async () => {
try { try {
let j; let j;
if (ticket) j = await post('/api/login/totp', { ticket, code: code.value }); if (ticket) j = await post('/api/login/totp', { ticket, code: code.value });
else j = await post('/api/login', { email: email.value, password: pw.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(); location.reload();
} catch (e) { err(e.message); } } catch (e) { err(e.message); }
}; };
[email, pw, code].forEach(i => i.addEventListener('keydown', e => e.key === 'Enter' && go())); [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 || []) { for (const p of info.providers || []) {
const back = `/auth/r/${id}?return=${encodeURIComponent(ret)}`; 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}`)); 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() { async function main() {
// share links skip the form // share links skip the form
const share = params.get('share'); 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 { try {
const r = await fetch(`/auth/r/${id}/info?return=${encodeURIComponent(ret)}`); const r = await fetch(`/auth/r/${id}/info?return=${encodeURIComponent(ret)}`);
const info = await r.json(); const info = await r.json();
if (!r.ok) throw new Error(info.error); if (!r.ok) throw new Error(info.error);
$('#title').textContent = info.name; $('#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.sso) body.append(ssoForm(info));
if (info.password) body.append(passwordForm('password', 'Password', 'password')); if (info.password) body.append(passwordForm('password', 'Password', 'password'));
if (info.pin) body.append(passwordForm('pin', 'PIN', 'password')); if (info.pin) body.append(passwordForm('pin', 'PIN', 'password'));
if (info.email) body.append(emailForm()); if (info.email) body.append(emailForm());
if (!body.children.length) err('No sign-in method is available.'); 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 = 'Unavailable'; err(e.message); } } catch (e) { $('#title').textContent = 'This page is unavailable'; err(e.message); }
} }
main(); main();
</script> </script>