From 7cc73fe02c6cad6669208781411d346ad0a9a5e1 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Sat, 14 Feb 2026 22:17:32 -0800 Subject: [PATCH] Add admin interface with trip and trip-admin management --- main.go | 228 ++++++++++++++++++++++++++++++++++++++++------ static/admin.html | 77 ++++++++++++++++ static/admin.js | 99 ++++++++++++++++++++ static/app.js | 27 +++++- static/index.html | 9 +- 5 files changed, 409 insertions(+), 31 deletions(-) create mode 100644 static/admin.html create mode 100644 static/admin.js diff --git a/main.go b/main.go index 3f44bd3..a8a65e9 100644 --- a/main.go +++ b/main.go @@ -14,7 +14,7 @@ import ( "log" "net/http" "os" - "path/filepath" + "strconv" "strings" _ "github.com/lib/pq" @@ -30,7 +30,7 @@ var ( ) func main() { - for _, key := range []string{"PGCONN", "CLIENT_ID", "CLIENT_SECRET"} { + for _, key := range []string{"PGCONN", "CLIENT_ID", "CLIENT_SECRET", "ADMINS"} { if os.Getenv(key) == "" { log.Fatalf("%s environment variable is required", key) } @@ -54,8 +54,17 @@ func main() { htmlTemplates = template.Must(template.New("").ParseGlob("static/*.html")) jsTemplates = texttemplate.Must(texttemplate.New("").ParseGlob("static/*.js")) - http.HandleFunc("/", handleStatic) + http.HandleFunc("GET /{$}", serveHTML("index.html")) + http.HandleFunc("GET /admin", serveHTML("admin.html")) + http.HandleFunc("GET /app.js", serveJS("app.js")) + http.HandleFunc("GET /admin.js", serveJS("admin.js")) http.HandleFunc("POST /auth/google/callback", handleGoogleCallback) + http.HandleFunc("GET /api/admin/check", handleAdminCheck) + http.HandleFunc("GET /api/trips", handleListTrips(db)) + http.HandleFunc("POST /api/trips", handleCreateTrip(db)) + http.HandleFunc("DELETE /api/trips/{tripID}", handleDeleteTrip(db)) + http.HandleFunc("POST /api/trips/{tripID}/admins", handleAddTripAdmin(db)) + http.HandleFunc("DELETE /api/trips/{tripID}/admins/{adminID}", handleRemoveTripAdmin(db)) http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { if err := db.Ping(); err != nil { http.Error(w, "db unhealthy", http.StatusServiceUnavailable) @@ -84,39 +93,22 @@ func envMap() map[string]string { return m } -func handleStatic(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Cache-Control", "no-cache") - - path := r.URL.Path - if path == "/" { - path = "/index.html" - } - - name := strings.TrimPrefix(path, "/") - - if strings.HasSuffix(name, ".html") { - t := htmlTemplates.Lookup(name) - if t == nil { - http.NotFound(w, r) - return - } +func serveHTML(name string) http.HandlerFunc { + t := htmlTemplates.Lookup(name) + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Content-Type", "text/html") t.Execute(w, templateData()) - return } +} - if strings.HasSuffix(name, ".js") { - t := jsTemplates.Lookup(name) - if t == nil { - http.NotFound(w, r) - return - } +func serveJS(name string) http.HandlerFunc { + t := jsTemplates.Lookup(name) + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Content-Type", "application/javascript") t.Execute(w, templateData()) - return } - - http.ServeFile(w, r, filepath.Join("static", name)) } func handleGoogleCallback(w http.ResponseWriter, r *http.Request) { @@ -169,3 +161,181 @@ func authorize(r *http.Request) (string, bool) { } return email, true } + +func isAdmin(email string) bool { + for _, a := range strings.Split(os.Getenv("ADMINS"), ",") { + if strings.TrimSpace(a) == email { + return true + } + } + return false +} + +func requireAdmin(w http.ResponseWriter, r *http.Request) (string, bool) { + email, ok := authorize(r) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return "", false + } + if !isAdmin(email) { + http.Error(w, "forbidden", http.StatusForbidden) + return "", false + } + return email, true +} + +func handleAdminCheck(w http.ResponseWriter, r *http.Request) { + email, ok := authorize(r) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]bool{"admin": isAdmin(email)}) +} + +func handleListTrips(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if _, ok := requireAdmin(w, r); !ok { + return + } + rows, err := db.Query(` + SELECT t.id, t.name, COALESCE( + json_agg(json_build_object('id', ta.id, 'email', ta.email)) FILTER (WHERE ta.id IS NOT NULL), + '[]' + ) + FROM trips t + LEFT JOIN trip_admins ta ON ta.trip_id = t.id + GROUP BY t.id, t.name + ORDER BY t.id`) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer rows.Close() + + type tripAdmin struct { + ID int64 `json:"id"` + Email string `json:"email"` + } + type trip struct { + ID int64 `json:"id"` + Name string `json:"name"` + Admins []tripAdmin `json:"admins"` + } + + var trips []trip + for rows.Next() { + var t trip + var adminsJSON string + if err := rows.Scan(&t.ID, &t.Name, &adminsJSON); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + json.Unmarshal([]byte(adminsJSON), &t.Admins) + trips = append(trips, t) + } + if trips == nil { + trips = []trip{} + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(trips) + } +} + +func handleCreateTrip(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if _, ok := requireAdmin(w, r); !ok { + return + } + var body struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Name == "" { + http.Error(w, "name is required", http.StatusBadRequest) + return + } + var id int64 + err := db.QueryRow("INSERT INTO trips (name) VALUES ($1) RETURNING id", body.Name).Scan(&id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"id": id, "name": body.Name}) + } +} + +func handleDeleteTrip(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if _, ok := requireAdmin(w, r); !ok { + return + } + tripID, err := strconv.ParseInt(r.PathValue("tripID"), 10, 64) + if err != nil { + http.Error(w, "invalid trip ID", http.StatusBadRequest) + return + } + result, err := db.Exec("DELETE FROM trips WHERE id = $1", tripID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if n, _ := result.RowsAffected(); n == 0 { + http.Error(w, "trip not found", http.StatusNotFound) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +func handleAddTripAdmin(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if _, ok := requireAdmin(w, r); !ok { + return + } + tripID, err := strconv.ParseInt(r.PathValue("tripID"), 10, 64) + if err != nil { + http.Error(w, "invalid trip ID", http.StatusBadRequest) + return + } + var body struct { + Email string `json:"email"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Email == "" { + http.Error(w, "email is required", http.StatusBadRequest) + return + } + var id int64 + err = db.QueryRow("INSERT INTO trip_admins (trip_id, email) VALUES ($1, $2) RETURNING id", tripID, body.Email).Scan(&id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"id": id, "email": body.Email}) + } +} + +func handleRemoveTripAdmin(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if _, ok := requireAdmin(w, r); !ok { + return + } + adminID, err := strconv.ParseInt(r.PathValue("adminID"), 10, 64) + if err != nil { + http.Error(w, "invalid admin ID", http.StatusBadRequest) + return + } + result, err := db.Exec("DELETE FROM trip_admins WHERE id = $1", adminID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if n, _ := result.RowsAffected(); n == 0 { + http.Error(w, "trip admin not found", http.StatusNotFound) + return + } + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/static/admin.html b/static/admin.html new file mode 100644 index 0000000..a507c42 --- /dev/null +++ b/static/admin.html @@ -0,0 +1,77 @@ + + + + + + Admin - Rooms + + + + + + + + diff --git a/static/admin.js b/static/admin.js new file mode 100644 index 0000000..84f02dd --- /dev/null +++ b/static/admin.js @@ -0,0 +1,99 @@ +import { init, logout, api } from '/app.js'; + +const profile = await init(); + +try { + const check = await api('GET', '/api/admin/check'); + if (!check.admin) { + document.body.style.opacity = 1; + document.body.textContent = 'Access denied.'; + throw new Error('not admin'); + } +} catch (e) { + document.body.style.opacity = 1; + if (!document.body.textContent) document.body.textContent = 'Access denied.'; + throw e; +} + +document.getElementById('main').style.display = 'block'; +document.getElementById('logout-btn').addEventListener('click', logout); + +async function loadTrips() { + const trips = await api('GET', '/api/trips'); + const container = document.getElementById('trips'); + container.innerHTML = ''; + for (const trip of trips) { + const card = document.createElement('wa-card'); + const header = document.createElement('div'); + header.className = 'trip-header'; + const h3 = document.createElement('h3'); + h3.textContent = trip.name; + const deleteBtn = document.createElement('wa-button'); + deleteBtn.variant = 'danger'; + deleteBtn.size = 'small'; + deleteBtn.textContent = 'Delete Trip'; + deleteBtn.addEventListener('click', async () => { + if (!confirm('Delete trip "' + trip.name + '"?')) return; + await api('DELETE', '/api/trips/' + trip.id); + loadTrips(); + }); + header.appendChild(h3); + header.appendChild(deleteBtn); + card.appendChild(header); + + const adminLabel = document.createElement('strong'); + adminLabel.textContent = 'Trip Admins:'; + card.appendChild(adminLabel); + + for (const admin of trip.admins) { + const row = document.createElement('div'); + row.className = 'admin-row'; + const span = document.createElement('span'); + span.textContent = admin.email; + const removeBtn = document.createElement('wa-button'); + removeBtn.variant = 'danger'; + removeBtn.size = 'small'; + removeBtn.textContent = 'Remove'; + removeBtn.addEventListener('click', async () => { + await api('DELETE', '/api/trips/' + trip.id + '/admins/' + admin.id); + loadTrips(); + }); + row.appendChild(span); + row.appendChild(removeBtn); + card.appendChild(row); + } + + const addRow = document.createElement('div'); + addRow.className = 'add-admin-row'; + const input = document.createElement('wa-input'); + input.placeholder = 'Admin email'; + const addBtn = document.createElement('wa-button'); + addBtn.variant = 'neutral'; + addBtn.size = 'small'; + addBtn.textContent = 'Add Admin'; + addBtn.addEventListener('click', async () => { + const email = input.value.trim(); + if (!email) return; + await api('POST', '/api/trips/' + trip.id + '/admins', { email }); + loadTrips(); + }); + addRow.appendChild(input); + addRow.appendChild(addBtn); + card.appendChild(addRow); + + container.appendChild(card); + } +} + +document.getElementById('create-trip-btn').addEventListener('click', async () => { + const input = document.getElementById('new-trip-name'); + const name = input.value.trim(); + if (!name) return; + await api('POST', '/api/trips', { name }); + input.value = ''; + loadTrips(); +}); + +await loadTrips(); +await customElements.whenDefined('wa-button'); +document.body.style.opacity = 1; diff --git a/static/app.js b/static/app.js index 49a626e..0ddfdf1 100644 --- a/static/app.js +++ b/static/app.js @@ -1,5 +1,29 @@ const CLIENT_ID = '{{.env.CLIENT_ID}}'; +function initHead() { + document.documentElement.className = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'wa-dark' : 'wa-light'; + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => { + document.documentElement.className = e.matches ? 'wa-dark' : 'wa-light'; + }); + + const head = document.head; + const addLink = (rel, type, href) => { + const link = document.createElement('link'); + link.rel = rel; + if (type) link.type = type; + link.href = href; + head.appendChild(link); + }; + addLink('stylesheet', null, 'https://cdn.jsdelivr.net/npm/@awesome.me/webawesome@3/dist-cdn/styles/themes/default.css'); + + const script = document.createElement('script'); + script.type = 'module'; + script.src = 'https://cdn.jsdelivr.net/npm/@awesome.me/webawesome@3/dist-cdn/webawesome.loader.js'; + head.appendChild(script); +} + +initHead(); + function getProfile() { const data = localStorage.getItem('profile'); return data ? JSON.parse(data) : null; @@ -97,9 +121,10 @@ export async function init() { const buttonContainer = document.createElement('div'); signin.appendChild(buttonContainer); + const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; google.accounts.id.renderButton(buttonContainer, { type: 'standard', - theme: 'filled_black', + theme: isDark ? 'outline' : 'filled_black', size: 'large', text: 'sign_in_with', shape: 'pill', diff --git a/static/index.html b/static/index.html index c2ae218..c98f4fb 100644 --- a/static/index.html +++ b/static/index.html @@ -10,11 +10,18 @@