Skip to content

Commit ca7f9ec

Browse files
James McHughclaude
andcommitted
Add admin users page gated to james67@gmail.com
Lists registered users with workout counts, signup date, and last login. Non-admins get a 404 so the route is indistinguishable from a missing page. Topbar Admin link renders only for the admin account. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 869afcb commit ca7f9ec

8 files changed

Lines changed: 233 additions & 0 deletions

File tree

admin.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package main
2+
3+
import (
4+
"net/http"
5+
"time"
6+
)
7+
8+
// adminEmail is the only account allowed to view admin pages. Hard-coded
9+
// because there is exactly one operator and no plan to expand the role.
10+
const adminEmail = "james67@gmail.com"
11+
12+
func isAdmin(u *currentUser) bool {
13+
return u != nil && u.Email == adminEmail
14+
}
15+
16+
// requireAdmin layers on top of requireAuth: a non-admin authenticated user
17+
// gets a 404 (not 403) so the page is indistinguishable from a missing route.
18+
func requireAdmin(next http.Handler) http.Handler {
19+
return requireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20+
if !isAdmin(userFrom(r)) {
21+
handle404(w, r)
22+
return
23+
}
24+
next.ServeHTTP(w, r)
25+
}))
26+
}
27+
28+
type viewAdminUserRow struct {
29+
ID int64
30+
Email string
31+
Name string
32+
Created string
33+
LastLogin string
34+
WorkoutCount int64
35+
}
36+
37+
type viewAdminUsers struct {
38+
UserName string
39+
ThemeMode string
40+
Users []viewAdminUserRow
41+
}
42+
43+
func handleAdminUsers(w http.ResponseWriter, r *http.Request) {
44+
user := userFrom(r)
45+
rows, err := queries.ListUsersForAdmin(r.Context())
46+
if err != nil {
47+
serverError(w, "admin: list users", err)
48+
return
49+
}
50+
out := make([]viewAdminUserRow, 0, len(rows))
51+
for _, u := range rows {
52+
out = append(out, viewAdminUserRow{
53+
ID: u.ID,
54+
Email: u.Email,
55+
Name: u.Name,
56+
Created: formatAdminTimestamp(u.CreatedAt),
57+
LastLogin: formatAdminTimestamp(u.LastLoginAt),
58+
WorkoutCount: u.WorkoutCount,
59+
})
60+
}
61+
renderHTML(w, "admin_users.html", viewAdminUsers{
62+
UserName: user.Name,
63+
ThemeMode: themeFromRequest(r),
64+
Users: out,
65+
})
66+
}
67+
68+
// formatAdminTimestamp parses an RFC3339 string (created_at / last_login_at
69+
// are stored that way) and renders it in the configured app timezone. Falls
70+
// back to the raw value so a malformed row stays visible rather than blank.
71+
func formatAdminTimestamp(s string) string {
72+
t, err := time.Parse(time.RFC3339, s)
73+
if err != nil {
74+
return s
75+
}
76+
return t.In(appLocation).Format("2 Jan 2006 15:04")
77+
}

db/queries.sql

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ FROM users WHERE id = (SELECT MAX(id) FROM users);
2323
-- name: UpdateUserLastLogin :exec
2424
UPDATE users SET last_login_at = ? WHERE id = ?;
2525

26+
-- name: ListUsersForAdmin :many
27+
-- All registered users with workout counts, newest signup first. Admin page only.
28+
SELECT u.id, u.email, u.name, u.created_at, u.last_login_at,
29+
(SELECT COUNT(*) FROM workouts w WHERE w.user_id = u.id) AS workout_count
30+
FROM users u
31+
ORDER BY u.created_at DESC;
32+
2633
-- =============================================================================
2734
-- SESSIONS
2835
-- =============================================================================

db/queries.sql.go

Lines changed: 47 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

home.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const (
2626
type viewHome struct {
2727
UserName string
2828
ThemeMode string // "light" | "dark" | "auto"
29+
IsAdmin bool // toggles the Admin link in the topbar
2930
Today viewTodayCard
3031
Stats viewStats
3132
Weights []viewWeightRow
@@ -133,6 +134,7 @@ func handleHome(w http.ResponseWriter, r *http.Request) {
133134
vh := viewHome{
134135
UserName: user.Name,
135136
ThemeMode: themeFromRequest(r),
137+
IsAdmin: isAdmin(user),
136138
}
137139

138140
// Today's card.

static/styles.css

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -903,6 +903,48 @@ a { color: var(--brand); text-decoration: none; }
903903
transform: translateX(20px);
904904
}
905905

906+
/* ========== admin users list ========== */
907+
.admin-users {
908+
list-style: none;
909+
margin: 0;
910+
padding: 0;
911+
border-top: 1px solid var(--hairline);
912+
}
913+
.admin-user {
914+
padding: 0.75rem 0.25rem;
915+
border-bottom: 1px solid var(--hairline);
916+
display: flex;
917+
flex-direction: column;
918+
gap: 0.2rem;
919+
}
920+
.admin-user__head {
921+
display: flex;
922+
align-items: baseline;
923+
justify-content: space-between;
924+
gap: 0.5rem;
925+
}
926+
.admin-user__name {
927+
font-size: 1rem;
928+
font-weight: 600;
929+
}
930+
.admin-user__count {
931+
font-size: 0.8rem;
932+
color: var(--ink-soft);
933+
white-space: nowrap;
934+
}
935+
.admin-user__email {
936+
font-size: 0.85rem;
937+
color: var(--ink-soft);
938+
word-break: break-all;
939+
}
940+
.admin-user__meta {
941+
display: flex;
942+
flex-wrap: wrap;
943+
gap: 0.75rem;
944+
font-size: 0.75rem;
945+
color: var(--muted);
946+
}
947+
906948
/* ========== toast (lock-violation feedback) ========== */
907949
.toast {
908950
position: fixed;

templates/admin_users.html

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
{{- /* viewAdminUsers */ -}}
2+
<!DOCTYPE html>
3+
<html lang="en" data-theme="{{.ThemeMode}}">
4+
<head>
5+
<meta charset="utf-8">
6+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
7+
<meta name="theme-color" content="{{if eq .ThemeMode "dark"}}#000000{{else}}#0f172a{{end}}">
8+
<title>Admin &mdash; Users</title>
9+
<link rel="icon" type="image/svg+xml" href="{{asset "favicon.svg"}}">
10+
<link rel="alternate icon" href="/favicon.ico">
11+
<link rel="apple-touch-icon" href="{{asset "favicon.svg"}}">
12+
<link rel="mask-icon" href="{{asset "favicon.svg"}}" color="#0f172a">
13+
<link rel="stylesheet" href="{{asset "styles.css"}}">
14+
</head>
15+
<body class="page page--settings">
16+
<div class="too-wide"><p>Designed for iPhone &mdash; open on your phone.</p></div>
17+
18+
<div class="phone">
19+
<header class="topbar">
20+
<a class="topbar__back" href="/" aria-label="back to home">&lsaquo; Back</a>
21+
<div class="topbar__date">Users</div>
22+
<span class="topbar__action"></span>
23+
</header>
24+
25+
<main class="settings">
26+
<section class="settings__section">
27+
<h2 class="settings__title">Registered users ({{len .Users}})</h2>
28+
<p class="settings__hint">Sorted by signup, newest first.</p>
29+
{{if .Users}}
30+
<ul class="admin-users">
31+
{{range .Users}}
32+
<li class="admin-user">
33+
<div class="admin-user__head">
34+
<span class="admin-user__name">{{.Name}}</span>
35+
<span class="admin-user__count">{{.WorkoutCount}} workout{{if ne .WorkoutCount 1}}s{{end}}</span>
36+
</div>
37+
<div class="admin-user__email">{{.Email}}</div>
38+
<div class="admin-user__meta">
39+
<span>Joined {{.Created}}</span>
40+
<span>Last login {{.LastLogin}}</span>
41+
</div>
42+
</li>
43+
{{end}}
44+
</ul>
45+
{{else}}
46+
<p class="settings__hint">No users yet.</p>
47+
{{end}}
48+
</section>
49+
</main>
50+
51+
<div class="bottom-spacer"></div>
52+
</div>
53+
</body>
54+
</html>

templates/home.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ <h1 class="topbar__brand">Train</h1>
2121
<nav class="topbar__nav">
2222
<a href="/charts" class="topbar__nav-link">Charts</a>
2323
<a href="/settings" class="topbar__nav-link">Settings</a>
24+
{{if .IsAdmin}}<a href="/admin/users" class="topbar__nav-link">Admin</a>{{end}}
2425
<form class="topbar__action" method="post" action="/auth/logout">
2526
<button type="submit" class="topbar__nav-link">Sign out</button>
2627
</form>

train.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,9 @@ func makeHTTPServer(isProd bool) *http.Server {
287287
mux.Handle("POST /exercise/{id}/walking-done", requireAuth(http.HandlerFunc(handleWalkingDone)))
288288
mux.Handle("POST /exercise/{id}/walking-adjust", requireAuth(http.HandlerFunc(handleWalkingAdjust)))
289289

290+
// Admin (gated to adminEmail; non-admins get 404).
291+
mux.Handle("GET /admin/users", requireAdmin(http.HandlerFunc(handleAdminUsers)))
292+
290293
// Static.
291294
cwd, _ := os.Getwd()
292295
fileServer := http.FileServer(http.Dir(cwd + "/static"))

0 commit comments

Comments
 (0)