Files
echolot/server/internal/adminui/render.go
T
mrambossekandClaude Opus 5 621ee99b77 adminui: make the web UI usable on a phone
The header was a rigid flex row, so on a narrow screen the account name
and the sign-out button were pushed off the side of the viewport where
they could not be reached at all — not merely ugly, unusable. It wraps
now, and below 40rem the account block takes its own full-width row so a
long display name cannot crowd out the navigation.

Wide content scrolls inside its own box rather than dragging the page
sideways with it. Tables sit in an overflow-x container and <pre> is
capped at the viewport width; without that, one long self-test line or
one device table makes every other column of text unreadable, and on a
phone it is not obvious that the page has moved at all. Long opaque
strings — device ids, enrolment links — wrap anywhere rather than
insisting on a width nothing has.

Also: box-sizing on everything, stat cards that share a row instead of
each claiming the full width, and larger touch targets on buttons, where
.4rem is comfortable with a mouse and fiddly with a thumb.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 22:33:41 +02:00

207 lines
8.9 KiB
Go

// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package adminui
import (
"bytes"
"html/template"
"log/slog"
"net/http"
)
// Templates are parsed once at start. html/template escapes by context, which is what makes it
// safe to render device names and finding text that ultimately arrived over a network.
var tpl = template.Must(template.New("base").Funcs(template.FuncMap{
"kb": func(n int64) int64 { return n / 1024 },
}).Parse(baseHTML))
func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, data map[string]any) {
data["Page"] = page
var buf bytes.Buffer
if err := tpl.Execute(&buf, data); err != nil {
slog.Error("admin template", "page", page, "err", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// There is no script here and nothing loaded from anywhere else, so a strict policy costs
// nothing and closes injected-script attacks even if an escaping bug ever slips through.
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Content-Type-Options", "nosniff")
_, _ = buf.WriteTo(w)
}
const baseHTML = `<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Echolot &mdash; {{.Page}}</title>
<style>
:root{color-scheme:dark}
*{box-sizing:border-box}
body{font:15px/1.5 system-ui,sans-serif;margin:0;background:#14161a;color:#e6e6e6}
/* The header wraps rather than overflowing: on a phone a rigid flex row pushes the account name
and the sign-out button off the side of the screen, where they cannot be reached at all. */
header{display:flex;gap:.6rem 1.2rem;align-items:baseline;flex-wrap:wrap;
padding:.8rem 1.2rem;background:#1c1f25;border-bottom:1px solid #2b2f36}
header h1{font-size:1.1rem;margin:0;font-weight:600}
header nav{display:flex;flex-wrap:wrap;gap:.2rem 1rem}
header nav a{color:#9ecbff;text-decoration:none}
header .who{margin-left:auto;color:#9aa3ad;font-size:.9rem;
display:flex;align-items:center;gap:.6rem;flex-wrap:wrap}
main{padding:1.2rem;max-width:70rem}
table{border-collapse:collapse;width:100%;margin:0}
th,td{text-align:left;padding:.45rem .6rem;border-bottom:1px solid #2b2f36;vertical-align:top}
th{color:#9aa3ad;font-weight:500;font-size:.85rem}
/* Wide content scrolls inside its own box. Letting the page scroll sideways instead makes every
other column of text unreadable, and on a phone it is not obvious that it happened. */
.tablewrap{overflow-x:auto;margin:.6rem 0;-webkit-overflow-scrolling:touch}
.tablewrap table{min-width:32rem}
code,pre{font-family:ui-monospace,monospace;font-size:.85rem}
code{overflow-wrap:anywhere}
pre{background:#0f1114;padding:.8rem;border-radius:6px;overflow:auto;max-height:34rem;max-width:100%}
.card{background:#1c1f25;border:1px solid #2b2f36;border-radius:8px;padding:1rem;margin:.8rem 0;
min-width:0}
.grid{display:flex;gap:1rem;flex-wrap:wrap}
.stat{background:#1c1f25;border:1px solid #2b2f36;border-radius:8px;padding:.8rem 1.2rem;
flex:1 1 8rem;min-width:8rem}
.stat b{display:block;font-size:1.6rem;font-weight:600}
.stat span{color:#9aa3ad;font-size:.85rem}
button{font:inherit;background:#2d6cdf;color:#fff;border:0;border-radius:6px;padding:.4rem .8rem;cursor:pointer}
button.danger{background:#8b2f2f}
button.plain{background:#3a3f47}
input{font:inherit;background:#0f1114;color:#e6e6e6;border:1px solid #2b2f36;border-radius:6px;
padding:.4rem .6rem;max-width:100%}
.err{background:#3a1f1f;border:1px solid #7a3b3b;padding:.6rem .8rem;border-radius:6px}
.muted{color:#9aa3ad}
form.inline{display:inline}
@media (max-width:40rem){
main{padding:.9rem}
header{padding:.7rem .9rem}
/* Full width on its own row, so the name can be long without stealing the nav's space. */
header .who{margin-left:0;width:100%;justify-content:space-between}
/* Touch targets: .4rem of padding is comfortable with a mouse and fiddly with a thumb. */
button{padding:.5rem .9rem}
.stat{padding:.7rem .9rem}
.stat b{font-size:1.4rem}
}
</style></head><body>
{{if ne .Page "login"}}
<header>
<h1>Echolot</h1>
<nav><a href="/">Overview</a><a href="/devices">Devices</a><a href="/runs">Runs</a></nav>
<span class="who">{{.Session.Display}}{{if not .Session.Admin}} <span class="muted">(your account)</span>{{end}}
<form method="post" action="/logout" class="inline"><button class="plain">Sign out</button></form>
</span>
</header>
{{end}}
<main>
{{if eq .Page "login"}}
<h2>Sign in</h2>
{{with .Error}}<p class="err">{{.}}</p>{{end}}
{{if .OIDC}}
<p><a href="/auth/start"><button>Sign in with your identity provider</button></a></p>
<p class="muted">or use the break-glass account:</p>
{{end}}
{{if .LocalSet}}
<form method="post" action="/login" class="card">
<p><label>Username<br><input name="username" value="{{.AdminUser}}" autocomplete="username"></label></p>
<p><label>Password<br><input name="password" type="password" autocomplete="current-password"></label></p>
<p><button>Sign in</button></p>
</form>
{{else}}
<p class="err">No break-glass admin is set. Run
<code>echolot-server --set-admin-password</code> on the host.</p>
{{end}}
{{else if eq .Page "dashboard"}}
<div class="grid">
<div class="stat"><b>{{.Devices}}</b><span>{{if .Admin}}devices{{else}}your devices{{end}}</span></div>
{{if .Admin}}<div class="stat"><b>{{.Linked}}</b><span>signed in</span></div>{{end}}
<div class="stat"><b>{{.Runs}}</b><span>{{if .Admin}}stored runs{{else}}your runs{{end}}</span></div>
</div>
{{if not .Admin}}
<div class="card">
<p>This is your account. You can see the devices you have signed in on, review everything
they have uploaded, and delete any of it.</p>
<p class="muted">Administering the server &mdash; enrolling devices, revoking them, and
seeing other people's uploads &mdash; needs an administrator account.</p>
</div>
{{end}}
{{if .Admin}}
<div class="card">
<h3>Server</h3>
<p class="muted">version {{.Version}}</p>
{{with .SelfTest}}<pre>{{printf "%+v" .}}</pre>{{end}}
</div>
{{end}}
{{else if eq .Page "devices"}}
<h2>{{if .Admin}}Devices{{else}}Your devices{{end}}</h2>
{{with .Link}}
<div class="card">
<p><b>Enrolment link</b> &mdash; single use, valid 24 hours. Treat it like a password until spent.</p>
<p><code>{{.}}</code></p>
<p class="muted">On a device with adb:<br>
<code>adb shell am start -a android.intent.action.VIEW -d "{{.}}"</code></p>
</div>
{{end}}
{{if .Admin}}
<form method="post" action="/enroll-tokens">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button>Create enrolment link</button>
</form>
{{end}}
<div class="tablewrap"><table>
<tr><th>Device</th><th>Name</th><th>Account</th><th>Enrolled</th><th>Runs</th><th></th></tr>
{{range .Rows}}
<tr>
<td><code>{{.ID}}</code></td>
<td>{{if .Name}}{{.Name}}{{else}}<span class="muted">&mdash;</span>{{end}}</td>
<td>{{if .LinkedToAccount}}{{.AccountName}}{{else}}<span class="muted">not signed in</span>{{end}}</td>
<td>{{.Enrolled.Format "2006-01-02 15:04"}}</td>
<td>{{.Runs}}</td>
<td>{{if $.Admin}}<form method="post" action="/devices/{{.ID}}/revoke" class="inline">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="danger">Revoke</button></form>{{end}}</td>
</tr>
{{else}}
<tr><td colspan="6" class="muted">{{if $.Admin}}No devices enrolled.{{else}}You have not signed in on any device yet. Sign in from the Echolot app to link one.{{end}}</td></tr>
{{end}}
</table></div>
{{else if eq .Page "runs"}}
<h2>{{if .Admin}}Uploaded runs{{else}}Your uploaded runs{{end}}</h2>
<p class="muted">Shown exactly as uploaded, at the privacy level the uploader chose. Nothing
here can un-redact a run.</p>
<div class="tablewrap"><table>
<tr><th>Uploaded</th><th>Device</th><th>Verdict</th><th>Findings</th><th>Size</th><th>Level</th><th></th></tr>
{{range .Rows}}
<tr>
<td>{{.UploadedAt.Format "2006-01-02 15:04"}}</td>
<td>{{.DeviceName}}</td>
<td>{{if .Verdict}}{{.Verdict}}{{else}}<span class="muted">&mdash;</span>{{end}}</td>
<td>{{.FindingCount}}</td>
<td>{{kb .SizeBytes}} kB</td>
<td>{{.Anonymization}}</td>
<td><a href="/runs/{{.DeviceID}}/{{.ID}}">open</a></td>
</tr>
{{else}}
<tr><td colspan="7" class="muted">Nothing uploaded yet.</td></tr>
{{end}}
</table></div>
{{else if eq .Page "run"}}
<h2>Run {{.ID}}</h2>
<form method="post" action="/runs/{{.Device}}/{{.ID}}/delete" class="inline">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button class="danger">Delete this run</button>
</form>
<pre>{{.JSON}}</pre>
{{end}}
</main></body></html>
`