-
Notifications
You must be signed in to change notification settings - Fork 15
feat(admin): proof-of-concept admin UI behind ADMIN_UI_ENABLED flag #84
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,209 @@ | ||
| package main | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "fmt" | ||
| "html/template" | ||
| "io/fs" | ||
| "log/slog" | ||
| "net/http" | ||
| "os" | ||
| "path" | ||
| "strconv" | ||
| ) | ||
|
|
||
| // adminUIEnabled reports whether the admin UI should be served, controlled by | ||
| // the ADMIN_UI_ENABLED environment variable (default false). Any value | ||
| // strconv.ParseBool accepts as true (1, t, T, TRUE, true, ...) turns it on; | ||
| // unset or unparseable values leave it off. | ||
| func adminUIEnabled() bool { | ||
| enabled, _ := strconv.ParseBool(os.Getenv("ADMIN_UI_ENABLED")) | ||
| return enabled | ||
| } | ||
|
|
||
| // registerAdminUI loads the embedded templates, mounts the static file server, | ||
| // and registers the admin UI routes on mux. It returns an error if the | ||
| // templates or static assets cannot be prepared from the embedded filesystem. | ||
| func registerAdminUI(mux *http.ServeMux) error { | ||
| tmpls, err := loadTemplates() | ||
| if err != nil { | ||
| return fmt.Errorf("load templates: %w", err) | ||
| } | ||
| templates = tmpls | ||
|
|
||
| staticFiles, err := fs.Sub(files, "web/static") | ||
| if err != nil { | ||
| return fmt.Errorf("prepare static files: %w", err) | ||
| } | ||
| mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFiles)))) | ||
|
|
||
| mux.HandleFunc("GET /admin/login", AdminLoginHandler) | ||
| mux.HandleFunc("GET /admin/signup", AdminSignupHandler) | ||
| mux.HandleFunc("GET /admin/map", AdminMapHandler) | ||
| mux.HandleFunc("GET /admin/dashboard", AdminDashboardHandler) | ||
| mux.HandleFunc("GET /admin/vehicles", AdminVehiclesHandler) | ||
| mux.HandleFunc("GET /admin/users", AdminUsersHandler) | ||
| mux.HandleFunc("GET /admin/trips", AdminTripsHandler) | ||
| return nil | ||
| } | ||
|
|
||
| // templates holds the parsed admin UI templates. It stays nil until | ||
| // loadTemplates succeeds in main(); the admin routes that use it are only | ||
| // registered when the admin UI is enabled, so handlers never run against nil. | ||
| var templates *embeddedTemplates | ||
|
|
||
| type embeddedTemplates struct { | ||
| public map[string]*template.Template | ||
| admin map[string]*template.Template | ||
| } | ||
|
|
||
| // loadTemplates parses the embedded admin UI templates once at startup. It | ||
| // returns an error rather than panicking so main() can log it with context and | ||
| // exit cleanly, consistent with the rest of the server's startup error handling. | ||
| func loadTemplates() (*embeddedTemplates, error) { | ||
| adminViews := []string{ | ||
| "dashboard.html", | ||
| "map.html", | ||
| "trips.html", | ||
| "users.html", | ||
| "vehicles.html", | ||
| } | ||
|
|
||
| admin := make(map[string]*template.Template, len(adminViews)) | ||
| for _, view := range adminViews { | ||
| tmpl, err := template.ParseFS( | ||
| files, | ||
| "web/templates/layout/*.html", | ||
| path.Join("web/templates/views", view), | ||
| ) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("parse admin view %q: %w", view, err) | ||
| } | ||
| admin[view] = tmpl | ||
| } | ||
|
|
||
| login, err := template.ParseFS(files, "web/templates/views/login.html") | ||
| if err != nil { | ||
| return nil, fmt.Errorf("parse public view %q: %w", "login.html", err) | ||
| } | ||
|
|
||
| return &embeddedTemplates{ | ||
| public: map[string]*template.Template{"login.html": login}, | ||
| admin: admin, | ||
| }, nil | ||
| } | ||
|
|
||
| // render looks up the parsed template set by view name and executes rootName | ||
| // into a buffer first, so a mid-render failure yields a clean 500 instead of a | ||
| // half-written 200 with a corrupted body. An unknown view is a programmer error | ||
| // (the route registered it) but is still reported rather than silently ignored. | ||
| func render(w http.ResponseWriter, set map[string]*template.Template, view, rootName string, data map[string]interface{}) { | ||
| tmpl, ok := set[path.Base(view)] | ||
| if !ok { | ||
| slog.Error("template render failed", "view", view, "error", "no such template") | ||
| http.Error(w, "internal server error", http.StatusInternalServerError) | ||
| return | ||
| } | ||
|
|
||
| var buf bytes.Buffer | ||
| if err := tmpl.ExecuteTemplate(&buf, rootName, data); err != nil { | ||
| slog.Error("template render failed", "view", view, "error", err) | ||
| http.Error(w, "internal server error", http.StatusInternalServerError) | ||
| return | ||
| } | ||
| if _, err := buf.WriteTo(w); err != nil { | ||
| // The 200 header is already committed, so we can't convert this to a | ||
| // 500 — log it so a truncated response is at least visible server-side. | ||
| slog.Error("template response write failed", "view", view, "error", err) | ||
| } | ||
| } | ||
|
|
||
| // renderPublic renders a standalone public page (e.g. login) by its own name. | ||
| func renderPublic(w http.ResponseWriter, view string, data map[string]interface{}) { | ||
| render(w, templates.public, view, path.Base(view), data) | ||
| } | ||
|
|
||
| // renderAdmin renders an admin page through the shared base.html layout, which | ||
| // pulls in the view's {{define "content"}} block. | ||
| func renderAdmin(w http.ResponseWriter, view string, data map[string]interface{}) { | ||
| render(w, templates.admin, view, "base.html", data) | ||
| } | ||
|
|
||
| func AdminMapHandler(w http.ResponseWriter, r *http.Request) { | ||
| renderAdmin(w, "web/templates/views/map.html", map[string]interface{}{ | ||
| "Title": "Live Map", | ||
| "Page": "map", | ||
| }) | ||
| } | ||
|
|
||
| func AdminLoginHandler(w http.ResponseWriter, r *http.Request) { | ||
| renderPublic(w, "web/templates/views/login.html", map[string]interface{}{ | ||
| "Title": "Welcome", | ||
| "Mode": "login", | ||
| "LoginEndpoint": "/api/v1/auth/login", | ||
| "SignupEndpoint": "/api/v1/auth/signup", | ||
| }) | ||
| } | ||
|
|
||
| func AdminSignupHandler(w http.ResponseWriter, r *http.Request) { | ||
| renderPublic(w, "web/templates/views/login.html", map[string]interface{}{ | ||
| "Title": "Create Account", | ||
| "Mode": "signup", | ||
| "LoginEndpoint": "/api/v1/auth/login", | ||
| "SignupEndpoint": "/api/v1/auth/signup", | ||
| }) | ||
| } | ||
|
|
||
| func AdminDashboardHandler(w http.ResponseWriter, r *http.Request) { | ||
| renderAdmin(w, "web/templates/views/dashboard.html", map[string]interface{}{ | ||
| "Title": "Dashboard", | ||
| "Page": "dashboard", | ||
| "TotalVehicles": "24", | ||
| "ActiveVehicles": "18", | ||
| "TotalDrivers": "32", | ||
| "ActiveTrips": "15", | ||
| "RecentVehicles": []map[string]string{ | ||
| {"Name": "Bus 001", "Route": "Route A", "Status": "active", "LastSeen": "2 min ago"}, | ||
| {"Name": "Bus 002", "Route": "Route B", "Status": "active", "LastSeen": "5 min ago"}, | ||
| {"Name": "Bus 003", "Route": "Route C", "Status": "idle", "LastSeen": "12 min ago"}, | ||
| {"Name": "Bus 004", "Route": "Route A", "Status": "active", "LastSeen": "1 min ago"}, | ||
| {"Name": "Bus 005", "Route": "Route D", "Status": "active", "LastSeen": "3 min ago"}, | ||
| }, | ||
| }) | ||
| } | ||
|
|
||
| func AdminVehiclesHandler(w http.ResponseWriter, r *http.Request) { | ||
| renderAdmin(w, "web/templates/views/vehicles.html", map[string]interface{}{ | ||
| "Title": "Vehicles", | ||
| "Page": "vehicles", | ||
| "Vehicles": []map[string]string{ | ||
| {"ID": "V001", "Name": "Bus 001", "Route": "Route A", "Driver": "Chaitanya K", "Status": "active", "LastSeen": "2 min ago"}, | ||
| {"ID": "V002", "Name": "Bus 002", "Route": "Route B", "Driver": "Aron", "Status": "active", "LastSeen": "5 min ago"}, | ||
| {"ID": "V003", "Name": "Bus 003", "Route": "Route C", "Driver": "Brad Pitt", "Status": "idle", "LastSeen": "12 min ago"}, | ||
| }, | ||
| }) | ||
| } | ||
|
|
||
| func AdminUsersHandler(w http.ResponseWriter, r *http.Request) { | ||
| renderAdmin(w, "web/templates/views/users.html", map[string]interface{}{ | ||
| "Title": "Users", | ||
| "Page": "users", | ||
| "Users": []map[string]string{ | ||
| {"Name": "Chaitanya K", "Email": "kbc@transit.co.ke", "Role": "driver", "LastSeen": "Today"}, | ||
| {"Name": "To Holland", "Email": "tom@transit.co.ke", "Role": "driver", "LastSeen": "Today"}, | ||
| {"Name": "Open transit", "Email": "brian@transit.co.ke", "Role": "driver", "LastSeen": "Yesterday"}, | ||
| }, | ||
| }) | ||
| } | ||
|
|
||
| func AdminTripsHandler(w http.ResponseWriter, r *http.Request) { | ||
| renderAdmin(w, "web/templates/views/trips.html", map[string]interface{}{ | ||
| "Title": "Trips", | ||
| "Page": "trips", | ||
| "Trips": []map[string]string{ | ||
| {"ID": "T001", "Vehicle": "Bus 001", "Driver": "Tom Hiddlestone", "Route": "Route A", "Start": "07:00", "End": "08:45", "Status": "completed"}, | ||
| {"ID": "T002", "Vehicle": "Bus 002", "Driver": "Chris Hensworth", "Route": "Route B", "Start": "07:15", "End": "\u2014", "Status": "active"}, | ||
| {"ID": "T003", "Vehicle": "Bus 003", "Driver": "Bruce Wayne", "Route": "Route C", "Start": "06:45", "End": "08:30", "Status": "completed"}, | ||
| }, | ||
| }) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| package main | ||
|
|
||
| import ( | ||
| "html/template" | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| // loadAdminTemplates populates the package-level templates var for handler | ||
| // tests, mirroring what registerAdminUI does at startup. | ||
| func loadAdminTemplates(t *testing.T) { | ||
| t.Helper() | ||
| tmpls, err := loadTemplates() | ||
| require.NoError(t, err) | ||
| templates = tmpls | ||
| } | ||
|
|
||
| func TestLoadTemplates(t *testing.T) { | ||
| tmpls, err := loadTemplates() | ||
| require.NoError(t, err) | ||
| require.NotNil(t, tmpls) | ||
|
|
||
| for _, view := range []string{"dashboard.html", "map.html", "trips.html", "users.html", "vehicles.html"} { | ||
| assert.Contains(t, tmpls.admin, view, "admin view %q should be parsed", view) | ||
| } | ||
| assert.Contains(t, tmpls.public, "login.html") | ||
| } | ||
|
|
||
| func TestAdminHandlersRenderOK(t *testing.T) { | ||
| loadAdminTemplates(t) | ||
|
|
||
| cases := []struct { | ||
| name string | ||
| handler http.HandlerFunc | ||
| path string | ||
| want string | ||
| }{ | ||
| {"dashboard", AdminDashboardHandler, "/admin/dashboard", "Bus 001"}, | ||
| {"vehicles", AdminVehiclesHandler, "/admin/vehicles", "Bus 001"}, | ||
| {"users", AdminUsersHandler, "/admin/users", "Chaitanya K"}, | ||
| {"trips", AdminTripsHandler, "/admin/trips", "Route A"}, | ||
| {"map", AdminMapHandler, "/admin/map", "Live Map"}, | ||
| {"login", AdminLoginHandler, "/admin/login", "Welcome"}, | ||
| {"signup", AdminSignupHandler, "/admin/signup", "Create Account"}, | ||
| } | ||
|
|
||
| for _, tc := range cases { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| req := httptest.NewRequest(http.MethodGet, tc.path, nil) | ||
| rec := httptest.NewRecorder() | ||
|
|
||
| tc.handler(rec, req) | ||
|
|
||
| assert.Equal(t, http.StatusOK, rec.Code) | ||
| assert.Contains(t, rec.Body.String(), tc.want) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| // TestRenderUnknownViewWritesCleanError verifies that rendering a view absent | ||
| // from the template set yields a clean 500 rather than silently falling back to | ||
| // another template or writing a partial 200 body. | ||
| func TestRenderUnknownViewWritesCleanError(t *testing.T) { | ||
| loadAdminTemplates(t) | ||
|
|
||
| for _, set := range []map[string]*template.Template{templates.admin, templates.public} { | ||
| rec := httptest.NewRecorder() | ||
| render(rec, set, "ghost.html", "base.html", map[string]interface{}{}) | ||
|
|
||
| assert.Equal(t, http.StatusInternalServerError, rec.Code) | ||
| assert.Contains(t, rec.Body.String(), "internal server error") | ||
| } | ||
| } | ||
|
|
||
| // TestAdminUIEnabledFlag pins the gate that keeps the unauthenticated admin UI | ||
| // off by default — the single safety mechanism behind the feature. | ||
| func TestAdminUIEnabledFlag(t *testing.T) { | ||
| cases := map[string]bool{ | ||
| "true": true, | ||
| "1": true, | ||
| "TRUE": true, | ||
| "t": true, | ||
| "false": false, | ||
| "0": false, | ||
| "": false, | ||
| "nonsense": false, | ||
| } | ||
|
|
||
| for val, want := range cases { | ||
| t.Run("val="+val, func(t *testing.T) { | ||
| t.Setenv("ADMIN_UI_ENABLED", val) | ||
| assert.Equal(t, want, adminUIEnabled()) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| // TestRenderExecutionErrorIsCleanError verifies the buffered-write contract: a | ||
| // template that fails partway through must not leak partial output — the client | ||
| // gets a clean 500, not a half-written 200. | ||
| func TestRenderExecutionErrorIsCleanError(t *testing.T) { | ||
| tmpl := template.Must(template.New("base.html").Parse(`PARTIAL-OUTPUT{{index .Items 99}}`)) | ||
| set := map[string]*template.Template{"boom.html": tmpl} | ||
|
|
||
| rec := httptest.NewRecorder() | ||
| render(rec, set, "boom.html", "base.html", map[string]interface{}{"Items": []int{}}) | ||
|
|
||
| assert.Equal(t, http.StatusInternalServerError, rec.Code) | ||
| assert.Contains(t, rec.Body.String(), "internal server error") | ||
| assert.NotContains(t, rec.Body.String(), "PARTIAL-OUTPUT") | ||
| } | ||
|
|
||
| func TestRegisterAdminUI(t *testing.T) { | ||
| mux := http.NewServeMux() | ||
| require.NoError(t, registerAdminUI(mux)) | ||
|
|
||
| t.Run("admin route is served", func(t *testing.T) { | ||
| req := httptest.NewRequest(http.MethodGet, "/admin/dashboard", nil) | ||
| rec := httptest.NewRecorder() | ||
| mux.ServeHTTP(rec, req) | ||
| assert.Equal(t, http.StatusOK, rec.Code) | ||
| }) | ||
|
|
||
| t.Run("static asset is served from embedded fs", func(t *testing.T) { | ||
| req := httptest.NewRequest(http.MethodGet, "/static/js/admin.js", nil) | ||
| rec := httptest.NewRecorder() | ||
| mux.ServeHTTP(rec, req) | ||
| assert.Equal(t, http.StatusOK, rec.Code) | ||
| assert.NotEmpty(t, rec.Body.String()) | ||
| }) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wire the signup page to a real backend route or remove it for now.
Line 153 submits to
/api/v1/auth/signup, butmain.goonly registersPOST /api/v1/auth/loginon Line 80 and no corresponding signup route exists on this mux. WithADMIN_UI_ENABLED=true,/admin/signuprenders a flow that can only fail at submit time.🤖 Prompt for AI Agents