2 Commits
Author SHA1 Message Date
VeillaxandClaude Opus 5.5 c585ddbf63 Review pass on the Lightwell redesign: layout, copy and behavior fixes
CI / test (push) Successful in 3m20s
Release / release (push) Successful in 5m6s
Dialog buttons are Cancel then the primary action at the bottom right, and each page has one primary
action. List pages no longer repeat their title. Dates have the month written out. Fields in a row
line up, hints sit under fields instead of inside labels, and long certificate errors are clamped.
Organizations show whether local access is on and name the action; setting a user's password uses a
dialog instead of a browser prompt. Server errors for sign-in and ranges are reworded with a next step,
and the stray scrollbar inside tables is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:38:56 -06:00
VeillaxandClaude Sonnet 5.5 d74e6c1e72 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>
2026-10-26 18:18:55 -06:00
12 changed files with 654 additions and 349 deletions

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
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.
+11 -11
View File
@@ -66,7 +66,7 @@ func (s *Server) withUser(fn func(http.ResponseWriter, *http.Request, *principal
return func(w http.ResponseWriter, r *http.Request) {
p := s.principalFrom(r)
if p == nil || p.User.ID == "" {
fail(w, 401, errors.New("not logged in"))
fail(w, 401, errors.New("Sign in to continue."))
return
}
fn(w, r, p)
@@ -96,7 +96,7 @@ func (s *Server) withOrg(needAdmin bool, fn func(http.ResponseWriter, *http.Requ
return func(w http.ResponseWriter, r *http.Request) {
p := s.principalFrom(r)
if p == nil {
fail(w, 401, errors.New("not logged in"))
fail(w, 401, errors.New("Sign in to continue."))
return
}
id := r.PathValue("org")
@@ -169,7 +169,7 @@ func (s *Server) createFirstAdmin(email, name, password, orgName string) error {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.st.Users) != 0 {
return errors.New("setup already done")
return errors.New("Hammerhead is already set up. Sign in instead.")
}
u := &User{ID: newID(), Email: email, Name: name, PassHash: hash, ServerAdmin: true, Created: time.Now()}
s.st.Users = append(s.st.Users, u)
@@ -240,7 +240,7 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
}
email := strings.ToLower(strings.TrimSpace(in.Email))
if !s.allow("login|"+clientIP(r)+"|"+email, 8, 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()
@@ -254,7 +254,7 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
if u == nil || !good || u.PassHash == "" {
s.audit("", email, "login.failed", clientIP(r))
time.Sleep(300 * time.Millisecond)
fail(w, 401, errors.New("wrong email or password"))
fail(w, 401, errors.New("That email and password do not match. Check them and try again."))
return
}
if u.TOTPEnabled {
@@ -276,7 +276,7 @@ func (s *Server) loginTOTP(w http.ResponseWriter, r *http.Request) {
return
}
if !s.allow("totp|"+clientIP(r), 10, 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()
@@ -307,7 +307,7 @@ func (s *Server) loginTOTP(w http.ResponseWriter, r *http.Request) {
}
s.mu.Unlock()
if !good {
fail(w, 401, errors.New("wrong code"))
fail(w, 401, errors.New("That code is not right. Check your authenticator and try again."))
return
}
s.startSession(w, r, u.ID)
@@ -413,7 +413,7 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request, p *princ
return
}
if p.User.PassHash != "" && bcrypt.CompareHashAndPassword([]byte(p.User.PassHash), []byte(in.Current)) != nil {
fail(w, 400, errors.New("current password is wrong"))
fail(w, 400, errors.New("Your current password is not right. Check it and try again."))
return
}
hash, err := hashPassword(in.New)
@@ -464,7 +464,7 @@ func (s *Server) totpEnable(w http.ResponseWriter, r *http.Request, p *principal
defer s.mu.Unlock()
u := s.findUser(p.User.ID)
if u.TOTPSecret == "" || !totpOK(u.TOTPSecret, strings.TrimSpace(in.Code)) {
fail(w, 400, errors.New("that code doesn't match; check your authenticator's clock"))
fail(w, 400, errors.New("That code does not match. Check that your authenticator's clock is right and try again."))
return
}
codes, hashes := make([]string, 8), make([]string, 8)
@@ -483,14 +483,14 @@ func (s *Server) totpDisable(w http.ResponseWriter, r *http.Request, p *principa
return
}
if p.User.PassHash != "" && bcrypt.CompareHashAndPassword([]byte(p.User.PassHash), []byte(in.Password)) != nil {
fail(w, 400, errors.New("password is wrong"))
fail(w, 400, errors.New("That password is not right. Check it and try again."))
return
}
s.mu.Lock()
defer s.mu.Unlock()
u := s.findUser(p.User.ID)
if !totpOK(u.TOTPSecret, strings.TrimSpace(in.Code)) {
fail(w, 400, errors.New("code is wrong"))
fail(w, 400, errors.New("That code is not right. Check your authenticator and try again."))
return
}
u.TOTPEnabled, u.TOTPSecret, u.BackupCodes = false, "", nil
+1 -1
View File
@@ -156,7 +156,7 @@ func (s *Server) alertSettings(w http.ResponseWriter, r *http.Request, sc alertS
return
}
if in.DebounceMin < 1 || in.DebounceMin > 120 || in.RepeatHours < 0 || in.RepeatHours > 168 {
fail(w, 400, errors.New("wait 1-120 minutes before alerting; remind every 0-168 hours (0 = never)"))
fail(w, 400, errors.New("Wait 1 to 120 minutes before alerting, and remind every 0 to 168 hours (0 means no reminders)."))
return
}
s.mu.Lock()
+64
View File
@@ -0,0 +1,64 @@
# 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.
## Patterns
* **One primary action per view.** It sits in the page header. Smaller forms inside a page use the default button.
* **List pages** show the page header, then the table in a card with no second title.
* **Dialogs and forms** end with Cancel and then the primary action, at the bottom right. Labels sit above fields, and rules such as "At least 8 characters" are hints below them, not part of the label.
* **Dates** have the month written out ("Nov 2, 2026"), with `fmtDate` and `fmtTime`.
* **Empty states** say why the list is empty: nothing yet, or nothing matching the filters.
* **Long machine messages**, such as certificate errors, wrap and stop at three lines (`.clip`), with the full text in a tooltip.
## 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.
+6 -6
View File
@@ -87,7 +87,7 @@ func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
return errors.New("every target needs a host (e.g. 127.0.0.1 or 192.168.1.20)")
}
if t.Port < 1 || t.Port > 65535 {
return errors.New("target ports must be 1-65535")
return errors.New("Target ports must be between 1 and 65535.")
}
}
if m.BindIP == "" {
@@ -99,7 +99,7 @@ func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
}
m.BindIP = ip.String()
if m.Port < 1 || m.Port > 65535 {
return errors.New("public port must be 1-65535")
return errors.New("The public port must be between 1 and 65535.")
}
switch {
case m.PortEnd == 0 || m.PortEnd == m.Port:
@@ -107,7 +107,7 @@ func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
case m.PortEnd < m.Port:
return errors.New("the end of a port range can't be before its start")
case m.PortEnd > 65535:
return errors.New("public ports must be 1-65535")
return errors.New("Public ports must be between 1 and 65535.")
case m.Proto != model.ProtoTCP && m.Proto != model.ProtoUDP:
return errors.New("port ranges work for tcp and udp only")
case m.PortEnd-m.Port+1 > 1000:
@@ -193,7 +193,7 @@ func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
}
}
if m.HealthSeconds != 0 && m.HealthSeconds != -1 && (m.HealthSeconds < 5 || m.HealthSeconds > 3600) {
return errors.New("health check interval must be 5-3600 seconds, or off")
return errors.New("The health check interval must be 5 to 3600 seconds, or off.")
}
for _, o := range s.st.Mappings {
@@ -204,7 +204,7 @@ func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
if m.Proto == o.Proto && model.HostBased(m.Proto) && m.BindIP == o.BindIP && m.Hostname != o.Hostname {
continue // same listener, different hostname
}
return fmt.Errorf("%s port %s on %s is already used by %q", netClass(m.Proto), portLabel(*m), o.BindIP, o.Name)
return fmt.Errorf("%s port %s on %s is already used by %q. Choose another port or address.", strings.ToUpper(netClass(m.Proto)), portLabel(*m), o.BindIP, o.Name)
}
if m.Name == "" {
if m.Hostname != "" {
@@ -226,7 +226,7 @@ func portLabel(m model.Mapping) string {
func checkPIN(p string) error {
if len(p) < 4 || len(p) > 12 {
return errors.New("a PIN is 4-12 digits")
return errors.New("A PIN is 4 to 12 digits.")
}
for _, c := range p {
if c < '0' || c > '9' {
+1 -1
View File
@@ -349,7 +349,7 @@ func (s *Server) acceptInvite(w http.ResponseWriter, r *http.Request) {
return
}
if !s.allow("invite|"+clientIP(r), 20, 10*time.Minute) {
fail(w, 429, errors.New("too many attempts"))
fail(w, 429, errors.New("Too many attempts. Wait a few minutes and try again."))
return
}
h := sha(in.Token)
+14 -14
View File
@@ -54,10 +54,10 @@ func (s *Server) protectedMapping(id string) *model.Mapping {
func checkReturn(m *model.Mapping, raw string) (*url.URL, error) {
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return nil, errors.New("invalid return address")
return nil, errors.New("This sign-in link is incomplete. Open the page you wanted again and you will be brought back here.")
}
if (m.Proto == model.ProtoHTTPS) != (u.Scheme == "https") {
return nil, errors.New("return address uses the wrong scheme")
return nil, errors.New("This sign-in link is not valid. Open the page you wanted again and you will be brought back here.")
}
host := u.Hostname()
port := u.Port()
@@ -68,7 +68,7 @@ func checkReturn(m *model.Mapping, raw string) (*url.URL, error) {
}
}
if !model.HostMatches(m.Hostname, strings.ToLower(host)) || port != fmt.Sprint(m.Port) {
return nil, errors.New("return address doesn't belong to this resource")
return nil, errors.New("This sign-in link points somewhere else. Open the page you wanted again and you will be brought back here.")
}
return u, nil
}
@@ -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 == "" {
+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.");
+1 -1
View File
@@ -69,7 +69,7 @@ func (s *Server) smtpSend(to, subject, body string) error {
c := s.st.Settings.SMTP
s.mu.Unlock()
if c.Host == "" {
return errors.New("email is not configured (Settings -> Email)")
return errors.New("Email is not set up yet. Set it up under Server, Settings.")
}
if c.Port == 0 {
c.Port = 587
+8 -1
View File
@@ -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) {
+407 -260
View File
File diff suppressed because it is too large. Load diff
+80 -54
View File
@@ -3,62 +3,81 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Sign in</title>
<style>
/* Design tokens: the only place raw colors, spacing and sizes are defined (same system as Lightwell). */
/* tokens:begin Design tokens, the Lightwell design language (docs/design.md). This is the only place raw colors and sizes are defined. */
:root {
--accent:#0891b2; --accent-contrast:#fff;
--bg:#f7f8f9; --surface:#fff; --surface-2:#f0f2f4; --border:#e2e5e9; --border-strong:#c6ccd3;
--text:#16191d; --text-muted:#566069; --text-faint:#75808b;
--accent-text:color-mix(in srgb, var(--accent) 85%, #000);
--accent-soft:color-mix(in srgb, var(--accent) 10%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 86%, #000);
--focus:color-mix(in srgb, var(--accent) 65%, #fff);
--success:#1c6b3a; --success-bg:#e3f1e8; --warning:#85560a; --warning-bg:#f9eed6; --danger:#a3271e; --danger-bg:#f9e6e3;
--overlay:rgb(15 18 22 / .5); --shadow:0 8px 24px rgb(15 18 22 / .14);
--accent:#1f5c5c; --accent-contrast:#ffffff;
--bg:#f6f5f3; --surface:#ffffff; --surface-2:#efede9; --border:#e0ddd7; --border-strong:#c4c0b8;
--text:#1d1c1a; --text-muted:#5f5b54; --text-faint:#7a766e;
--accent-text:var(--accent);
--accent-soft:color-mix(in srgb, var(--accent) 9%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 88%, #000000);
--focus:color-mix(in srgb, var(--accent) 70%, #ffffff);
--success:#1c6b3a; --success-bg:#e3f1e8; --warning:#85560a; --warning-bg:#f9eed6; --danger:#a3271e; --danger-bg:#f9e6e3; --neutral-bg:#eceae6;
--overlay:rgb(20 19 17 / 0.55); --shadow:0 8px 24px rgb(20 19 17 / 0.14);
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px; --s6:32px; --s7:48px; --s8:64px;
--radius:6px; --radius-lg:10px;
--radius:6px; --radius-lg:10px; --border-w:1px;
--font:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--font-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
--text-xs:12px; --text-sm:13px; --text-base:14px; --text-lg:16px; --text-xl:20px; --text-2xl:26px;
--control-h:36px; --sidebar-w:248px; --content-max:1180px; --ease:140ms ease;
--text-xs:12px; --text-sm:13px; --text-base:15px; --text-lg:18px; --text-xl:22px; --text-2xl:28px; --leading:1.5;
--sidebar-w:232px; --content-max:1120px; --touch:44px; --control-h:38px;
--z-sticky:20; --z-overlay:100; --z-toast:200; --ease:140ms ease;
color-scheme:light;
}
@media (prefers-color-scheme: dark) { :root {
--accent:#22b8d4; --accent-contrast:#04222a;
--bg:#0c0d0f; --surface:#141517; --surface-2:#1d1f22; --border:#2a2d31; --border-strong:#41454b;
--text:#eceef0; --text-muted:#aab1b9; --text-faint:#8a929b;
--accent-text:color-mix(in srgb, var(--accent) 80%, #fff); --accent-soft:color-mix(in srgb, var(--accent) 18%, var(--surface)); --accent-hover:color-mix(in srgb, var(--accent) 85%, #fff);
--success:#86d3a0; --success-bg:#16301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18;
--overlay:rgb(0 0 0 / .65); --shadow:0 8px 24px rgb(0 0 0 / .5); color-scheme:dark;
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {
--bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943;
--text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84;
--accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff);
--accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 85%, #ffffff);
--success:#86d3a0; --success-bg:#17301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --neutral-bg:#2b2a27;
--overlay:rgb(0 0 0 / 0.65); --shadow:0 8px 24px rgb(0 0 0 / 0.5); color-scheme:dark;
} }
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--text); font:var(--text-base)/1.5 var(--font); display:grid; place-items:center; min-height:100vh; padding:var(--s4); -webkit-font-smoothing:antialiased; }
.card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-lg); padding:var(--s5); width:100%; max-width:400px; }
h1 { font-size:var(--text-xl); font-weight:600; letter-spacing:-.01em; margin:0 0 var(--s1); }
.sub { color:var(--text-muted); margin:0 0 var(--s4); font-size:var(--text-sm); }
.sec { border-top:1px solid var(--border); padding-top:var(--s4); margin-top:var(--s4); display:flex; flex-direction:column; gap:var(--s2); }
:root[data-theme="dark"] {
--bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943;
--text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84;
--accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff);
--accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 85%, #ffffff);
--success:#86d3a0; --success-bg:#17301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --neutral-bg:#2b2a27;
--overlay:rgb(0 0 0 / 0.65); --shadow:0 8px 24px rgb(0 0 0 / 0.5); color-scheme:dark;
}
/* tokens:end */
*, *::before, *::after { box-sizing:border-box; }
html { -webkit-text-size-adjust:100%; }
body { margin:0; background:var(--bg); color:var(--text); font:var(--text-base)/var(--leading) var(--font); display:grid; place-items:center; min-height:100vh; padding:var(--s4); -webkit-font-smoothing:antialiased; }
.card { background:var(--surface); border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); padding:var(--s5); width:100%; max-width:440px; }
h1 { font-size:var(--text-xl); font-weight:600; letter-spacing:-.01em; line-height:1.25; margin:0 0 var(--s1); }
h2 { font-size:var(--text-base); font-weight:600; margin:0; }
.lead { color:var(--text-muted); margin:0 0 var(--s4); }
.sec { border-top:var(--border-w) solid var(--border); padding-top:var(--s4); margin-top:var(--s4); display:flex; flex-direction:column; gap:var(--s3); }
.sec:first-of-type { border-top:0; padding-top:0; margin-top:0; }
h2 { font-size:var(--text-xs); text-transform:uppercase; letter-spacing:.06em; color:var(--text-faint); margin:0; font-weight:600; }
input { background:var(--surface); color:var(--text); border:1px solid var(--border-strong); border-radius:var(--radius); padding:var(--s2) var(--s3); min-height:var(--control-h); font:inherit; width:100%; }
label { display:flex; flex-direction:column; gap:var(--s1); font-size:var(--text-sm); font-weight:600; }
input { background:var(--surface); color:var(--text); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius); padding:var(--s2) var(--s3); min-height:var(--control-h); font:inherit; font-weight:400; width:100%; }
input:focus { outline:2px solid var(--focus); outline-offset:0; border-color:var(--accent); }
:focus-visible { outline:2px solid var(--focus); outline-offset:2px; border-radius:var(--radius); }
button, a.btn { display:flex; align-items:center; justify-content:center; min-height:var(--control-h); padding:0 var(--s4); background:var(--accent); color:var(--accent-contrast); border:1px solid var(--accent); border-radius:var(--radius); font:inherit; font-weight:500; cursor:pointer; text-decoration:none; transition:background var(--ease); }
button, a.btn { display:flex; align-items:center; justify-content:center; min-height:var(--control-h); padding:0 var(--s4); background:var(--accent); color:var(--accent-contrast); border:var(--border-w) solid var(--accent); border-radius:var(--radius); font:inherit; font-weight:500; cursor:pointer; text-decoration:none; transition:background var(--ease), border-color var(--ease); }
button:hover, a.btn:hover { background:var(--accent-hover); border-color:var(--accent-hover); }
button.ghost, a.ghost { background:var(--surface); border:1px solid var(--border-strong); color:var(--text); }
button.ghost, a.ghost { background:var(--surface); border-color:var(--border-strong); color:var(--text); }
button.ghost:hover, a.ghost:hover { background:var(--surface-2); border-color:var(--border-strong); }
#err { color:var(--danger); min-height:18px; font-size:var(--text-sm); margin-top:var(--s3); }
.ok { color:var(--text-muted); font-size:var(--text-sm); }
#err { color:var(--danger); font-size:var(--text-sm); margin-top:var(--s3); }
#err:empty { display:none; }
.hint { color:var(--text-muted); font-size:var(--text-sm); margin:0; }
[hidden] { display:none !important; }
@media (prefers-reduced-motion: reduce) { * { transition:none !important; } }
@media (pointer: coarse) { button, a.btn, input { min-height:var(--touch); } input { font-size:16px; } }
</style>
</head>
<body>
<div class="card">
<main class="card">
<h1 id="title">Sign in</h1>
<p class="sub" id="sub"></p>
<p class="lead" id="sub"></p>
<div id="body"></div>
<div id="err"></div>
</div>
<div id="err" role="alert"></div>
</main>
<script>
const id = location.pathname.split('/')[3];
const params = new URLSearchParams(location.search);
@@ -77,6 +96,9 @@ function h(tag, props, ...kids) {
return el;
}
const err = m => $('#err').textContent = m || '';
let n = 0;
// a visible label above the control, as the design language requires
const field = (label, input) => { input.id = input.id || 'f' + (++n); return h('label', {for:input.id}, label, input); };
async function post(path, data) {
err('');
@@ -95,54 +117,57 @@ async function finish(path, data) {
function section(title, ...kids) { return h('div', {class:'sec'}, h('h2', {}, title), ...kids); }
function passwordForm(kind, label, type) {
const i = h('input', {type, placeholder:label, autocomplete: kind === 'password' ? 'current-password' : 'off', inputmode: kind === 'pin' ? 'numeric' : null});
const i = h('input', {type, autocomplete: kind === 'password' ? 'current-password' : 'off', inputmode: kind === 'pin' ? 'numeric' : null});
const go = () => finish(kind, { [kind]: i.value });
i.addEventListener('keydown', e => e.key === 'Enter' && go());
return section(label, i, h('button', {onclick:go}, 'Continue'));
return h('div', {class:'sec'}, field(label, i), h('button', {onclick:go}, 'Continue'));
}
function emailForm() {
const email = h('input', {type:'email', placeholder:'you@example.com', autocomplete:'email'});
const code = h('input', {placeholder:'6-digit code', inputmode:'numeric', hidden:true});
const msg = h('div', {class:'ok'});
const code = h('input', {inputmode:'numeric', autocomplete:'one-time-code'});
const codeField = field('6-digit code', code); codeField.hidden = true;
const msg = h('p', {class:'hint'});
const go = h('button', {}, 'Email me a code');
go.onclick = async () => {
try {
if (code.hidden) {
if (codeField.hidden) {
const j = await post(`/auth/r/${id}/email/start`, { email: email.value, return: ret });
msg.textContent = j.status; code.hidden = false; go.textContent = 'Verify code'; code.focus();
msg.textContent = j.status; codeField.hidden = false; go.textContent = 'Verify code'; code.focus();
} else await finish('email/verify', { email: email.value, code: code.value });
} catch (e) { err(e.message); }
};
return section('Email code', email, code, msg, go);
return section('Email code', field('Email address', email), codeField, msg, go);
}
function ssoForm(info) {
const kids = [];
if (info.user) {
if (info.user.allowed) {
kids.push(h('div', {class:'ok'}, `Signed in as ${info.user.email}`), h('button', {onclick:() => finish('sso', {})}, 'Continue'));
kids.push(h('p', {class:'hint'}, `Signed in as ${info.user.email}.`), h('button', {onclick:() => finish('sso', {})}, 'Continue'));
} else {
kids.push(h('div', {class:'ok'}, `${info.user.email} doesn't have access to this resource.`));
kids.push(h('p', {class:'hint'}, `${info.user.email} does not have access to this resource. Ask the person who shared it to add you.`));
}
kids.push(h('button', {class:'ghost', onclick: async () => { await fetch('/api/logout', {method:'POST'}); location.reload(); }}, 'Use a different account'));
return section('Account', ...kids);
}
const email = h('input', {type:'email', placeholder:'Email', autocomplete:'username'});
const pw = h('input', {type:'password', placeholder:'Password', autocomplete:'current-password'});
const code = h('input', {placeholder:'Authenticator code', inputmode:'numeric', autocomplete:'one-time-code', hidden:true});
const email = h('input', {type:'email', autocomplete:'username'});
const pw = h('input', {type:'password', autocomplete:'current-password'});
const code = h('input', {inputmode:'numeric', autocomplete:'one-time-code'});
const emailField = field('Email address', email), pwField = field('Password', pw), codeField = field('Authenticator code', code);
codeField.hidden = true;
let ticket = null;
const go = async () => {
try {
let j;
if (ticket) j = await post('/api/login/totp', { ticket, code: code.value });
else j = await post('/api/login', { email: email.value, password: pw.value });
if (j.needTotp) { ticket = j.ticket; code.hidden = false; email.hidden = pw.hidden = true; code.focus(); return; }
if (j.needTotp) { ticket = j.ticket; codeField.hidden = false; emailField.hidden = pwField.hidden = true; code.focus(); return; }
location.reload();
} catch (e) { err(e.message); }
};
[email, pw, code].forEach(i => i.addEventListener('keydown', e => e.key === 'Enter' && go()));
kids.push(email, pw, code, h('button', {onclick:go}, 'Sign in'));
kids.push(emailField, pwField, codeField, h('button', {onclick:go}, 'Sign in'));
for (const p of info.providers || []) {
const back = `/auth/r/${id}?return=${encodeURIComponent(ret)}`;
kids.push(h('a', {class:'btn ghost', href:`/auth/oidc/${p.id}/start?return=${encodeURIComponent(back)}`}, `Continue with ${p.name}`));
@@ -153,19 +178,20 @@ function ssoForm(info) {
async function main() {
// share links skip the form
const share = params.get('share');
if (share) { $('#title').textContent = 'Checking link…'; return finish('share', { token: share }); }
if (share) { $('#title').textContent = 'Checking your link'; return finish('share', { token: share }); }
try {
const r = await fetch(`/auth/r/${id}/info?return=${encodeURIComponent(ret)}`);
const info = await r.json();
if (!r.ok) throw new Error(info.error);
$('#title').textContent = info.name;
$('#sub').textContent = `${info.host} is protected. Sign in to continue.`;
document.title = 'Sign in to ' + info.name;
$('#sub').textContent = `Sign in to open ${info.host}.`;
if (info.sso) body.append(ssoForm(info));
if (info.password) body.append(passwordForm('password', 'Password', 'password'));
if (info.pin) body.append(passwordForm('pin', 'PIN', 'password'));
if (info.email) body.append(emailForm());
if (!body.children.length) err('No sign-in method is available.');
} catch (e) { $('#title').textContent = 'Unavailable'; err(e.message); }
if (!body.children.length) err('No sign-in method is set up for this page. Ask the person who shared it with you.');
} catch (e) { $('#title').textContent = 'This page is unavailable'; err(e.message); }
}
main();
</script>