diff --git a/README.md b/README.md index 93a4670..ac963ba 100644 --- a/README.md +++ b/README.md @@ -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 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 - No client-to-site VPN (Pangolin's Olm/private resources) and no blueprints/YAML import. diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..1bc165e --- /dev/null +++ b/docs/design.md @@ -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. diff --git a/resauth.go b/resauth.go index 5284e99..57fab68 100644 --- a/resauth.go +++ b/resauth.go @@ -108,7 +108,7 @@ func (s *Server) resourceInfo(w http.ResponseWriter, r *http.Request) { defer s.mu.Unlock() m := s.protectedMapping(r.PathValue("id")) 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 } 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() 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 } 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 } 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 } s.mu.Lock() @@ -169,7 +169,7 @@ func (s *Server) resourceAuth(method string, fn func(w http.ResponseWriter, r *h } s.mu.Unlock() 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 } 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) { 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 } 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) { 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 } 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) { p := s.principalFrom(r) 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 } 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) s.mu.Unlock() 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 } 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) { email := strings.ToLower(strings.TrimSpace(body["email"])) 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 } // 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() 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 } 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() 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 } if name == "" { diff --git a/scripts/lint-design.mjs b/scripts/lint-design.mjs new file mode 100644 index 0000000..22f588e --- /dev/null +++ b/scripts/lint-design.mjs @@ -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."); diff --git a/store.go b/store.go index 6a08c15..0878b93 100644 --- a/store.go +++ b/store.go @@ -18,6 +18,8 @@ import ( "git.veillax.com/Veillax/hammerhead/internal/secretbox" "git.veillax.com/Veillax/reef/model" + "unicode" + "unicode/utf8" ) type Org struct { @@ -433,8 +435,13 @@ func writeJSON(w http.ResponseWriter, code int, v any) { 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) { - 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) { diff --git a/web/index.html b/web/index.html index 5080ea8..4a360dc 100644 --- a/web/index.html +++ b/web/index.html @@ -3,158 +3,217 @@ + Hammerhead
-