package admin import ( "fmt" "net/http" "os" "git.lohmar.co.uk/lexton-it/NextWks/core/admin/templates" "gopkg.in/yaml.v3" ) // RegisterUIRoutes mounts the admin UI (Templ-rendered) routes. func (h *Handler) RegisterUIRoutes(mux *http.ServeMux, authMiddleware func(http.Handler) http.Handler) { // Admin dashboard page mux.Handle("GET /admin", authMiddleware(http.HandlerFunc(h.adminDashboard))) mux.Handle("GET /admin/", authMiddleware(http.HandlerFunc(h.adminDashboard))) mux.Handle("GET /admin/global", authMiddleware(http.HandlerFunc(h.adminGlobal))) mux.Handle("POST /admin/global", authMiddleware(http.HandlerFunc(h.adminGlobalSave))) mux.Handle("GET /admin/users", authMiddleware(http.HandlerFunc(h.adminUsers))) mux.Handle("GET /admin/users/create-form", authMiddleware(http.HandlerFunc(h.createUserForm))) mux.Handle("GET /admin/users/cancel-form", authMiddleware(http.HandlerFunc(h.cancelForm))) mux.Handle("GET /admin/users/edit-form/{username}", authMiddleware(http.HandlerFunc(h.editUserForm))) mux.Handle("PUT /admin/users/{username}", authMiddleware(http.HandlerFunc(h.updateUser))) // Groups mux.Handle("GET /admin/groups", authMiddleware(http.HandlerFunc(h.groupsPage))) mux.Handle("GET /admin/groups/list", authMiddleware(http.HandlerFunc(h.groupList))) mux.Handle("POST /admin/groups", authMiddleware(http.HandlerFunc(h.createGroup))) mux.Handle("DELETE /admin/groups/{name}", authMiddleware(http.HandlerFunc(h.deleteGroup))) mux.Handle("GET /admin/groups/create-form", authMiddleware(http.HandlerFunc(h.createGroupForm))) mux.Handle("GET /admin/groups/cancel-form", authMiddleware(http.HandlerFunc(h.cancelForm))) mux.Handle("GET /admin/groups/edit-form/{name}", authMiddleware(http.HandlerFunc(h.editGroupForm))) mux.Handle("PUT /admin/groups/{name}", authMiddleware(http.HandlerFunc(h.updateGroup))) } func (h *Handler) adminDashboard(w http.ResponseWriter, r *http.Request) { // Count users for the dashboard count, _ := h.store.Count() component := templates.Dashboard(count) component.Render(r.Context(), w) } func (h *Handler) adminUsers(w http.ResponseWriter, r *http.Request) { component := templates.UserDashboard() component.Render(r.Context(), w) } func (h *Handler) createUserForm(w http.ResponseWriter, r *http.Request) { grps, _ := h.groupStore.List() groupNames := make([]string, 0, len(grps)) for _, g := range grps { groupNames = append(groupNames, g.Name) } component := templates.CreateUserForm(groupNames) component.Render(r.Context(), w) } func (h *Handler) cancelForm(w http.ResponseWriter, r *http.Request) { w.Write([]byte("")) } // adminGlobal serves the global settings form. func (h *Handler) adminGlobal(w http.ResponseWriter, r *http.Request) { cfg := readConfigRaw(h.configPath) component := templates.GlobalSettings( cfg["db_type"], cfg["db_path"], cfg["db_host"], cfg["db_port"], cfg["db_user"], cfg["db_pass"], cfg["db_name"], cfg["smtp_host"], cfg["smtp_port"], cfg["smtp_user"], cfg["smtp_pass"], cfg["imap_host"], cfg["imap_port"], cfg["nextwks_url"], cfg["auth_url"], cfg["client_id"], cfg["callback_url"], cfg["lang"], cfg["tz"], "", ) component.Render(r.Context(), w) } // adminGlobalSave handles POST to update the config. func (h *Handler) adminGlobalSave(w http.ResponseWriter, r *http.Request) { r.ParseForm() msg := writeConfigRaw(h.configPath, r) // Snip: update second call same way // Re-read to show updated values cfg := readConfigRaw(h.configPath) component := templates.GlobalSettings( cfg["db_type"], cfg["db_path"], cfg["db_host"], cfg["db_port"], cfg["db_user"], cfg["db_pass"], cfg["db_name"], cfg["smtp_host"], cfg["smtp_port"], cfg["smtp_user"], cfg["smtp_pass"], cfg["imap_host"], cfg["imap_port"], cfg["nextwks_url"], cfg["auth_url"], cfg["client_id"], cfg["callback_url"], cfg["lang"], cfg["tz"], msg, ) component.Render(r.Context(), w) } type rawConfig map[string]string func readConfigRaw(path string) rawConfig { data, err := os.ReadFile(path) if err != nil { return rawConfig{} } var m map[string]interface{} yaml.Unmarshal(data, &m) cfg := rawConfig{} cfg["db_type"] = getNested(m, "database", "type") cfg["db_path"] = getNested(m, "database", "path") cfg["db_host"] = getNested(m, "database", "host") cfg["db_port"] = getNested(m, "database", "port") cfg["db_user"] = getNested(m, "database", "user") cfg["db_pass"] = getNested(m, "database", "password") cfg["db_name"] = getNested(m, "database", "name") cfg["smtp_host"] = getNested(m, "smtp", "host") cfg["smtp_port"] = getNested(m, "smtp", "port") cfg["smtp_user"] = getNested(m, "smtp", "username") cfg["smtp_pass"] = getNested(m, "smtp", "password") cfg["imap_host"] = getNested(m, "imap", "host") cfg["imap_port"] = getNested(m, "imap", "port") cfg["nextwks_url"] = getNested(m, "oidc", "redirect_url") if cfg["nextwks_url"] != "" { cfg["nextwks_url"] = trimSuffix(cfg["nextwks_url"], "/auth/callback") } cfg["auth_url"] = getNested(m, "oidc", "issuer_url") cfg["client_id"] = getNested(m, "oidc", "client_id") cfg["callback_url"] = getNested(m, "oidc", "redirect_url") cfg["lang"] = getNested(m, "locale", "language") cfg["tz"] = getNested(m, "locale", "timezone") return cfg } func writeConfigRaw(path string, r *http.Request) string { data, err := os.ReadFile(path) if err != nil { return "Error reading config" } var m map[string]interface{} yaml.Unmarshal(data, &m) setNested(m, r.FormValue("db_type"), "database", "type") setNested(m, r.FormValue("db_path"), "database", "path") setNested(m, r.FormValue("db_host"), "database", "host") setNested(m, r.FormValue("db_port"), "database", "port") setNested(m, r.FormValue("db_user"), "database", "user") setNested(m, r.FormValue("db_pass"), "database", "password") setNested(m, r.FormValue("db_name"), "database", "name") setNested(m, r.FormValue("smtp_port"), "smtp", "port") setNested(m, r.FormValue("smtp_user"), "smtp", "username") setNested(m, r.FormValue("smtp_pass"), "smtp", "password") setNested(m, r.FormValue("imap_host"), "imap", "host") setNested(m, r.FormValue("imap_port"), "imap", "port") setNested(m, r.FormValue("lang"), "locale", "language") setNested(m, r.FormValue("tz"), "locale", "timezone") nextwks := r.FormValue("nextwks_url") if nextwks != "" { setNested(m, nextwks+"/auth/callback", "oidc", "redirect_url") setNested(m, stripScheme(nextwks), "oidc", "domain") } auth := r.FormValue("auth_url") if auth != "" { setNested(m, auth, "oidc", "issuer_url") } out, _ := yaml.Marshal(m) os.WriteFile(path, out, 0600) return "Settings saved — restart service to apply" } func getNested(m map[string]interface{}, keys ...string) string { v := interface{}(m) for i, k := range keys { mp, ok := v.(map[string]interface{}) if !ok { return "" } v = mp[k] if i == len(keys)-1 { switch val := v.(type) { case string: return val case int: return fmt.Sprintf("%d", val) case float64: return fmt.Sprintf("%.0f", val) } return "" } } return "" } func setNested(m map[string]interface{}, val string, keys ...string) { for i, k := range keys { if i == len(keys)-1 { m[k] = val return } if _, ok := m[k]; !ok { m[k] = make(map[string]interface{}) } m = m[k].(map[string]interface{}) } } func trimSuffix(s, suffix string) string { if len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix { return s[:len(s)-len(suffix)] } return s } func stripScheme(url string) string { if len(url) > 8 && url[:8] == "https://" { return url[8:] } if len(url) > 7 && url[:7] == "http://" { return url[7:] } return url } // UserToRow converts a User model to a template UserRow. func UserToRow(u User) templates.UserRow { return templates.UserRow{ Username: u.Username, DisplayName: u.DisplayName, Email: u.Email, Role: u.Role, Groups: u.Groups, Disabled: u.Disabled, } } // userRowsHandler returns user rows for HTMX partial updates. func (h *Handler) userRowsHandler(w http.ResponseWriter, r *http.Request) { users, err := h.store.List() if err != nil { http.Error(w, "failed to load users", http.StatusInternalServerError) return } rows := make([]templates.UserRow, 0, len(users)) for _, u := range users { rows = append(rows, UserToRow(u)) } component := templates.UserList(rows) component.Render(r.Context(), w) } // createUsersHandler processes the form submission via HTMX. func (h *Handler) createUsersHandler(w http.ResponseWriter, r *http.Request) { // Parse form data if err := r.ParseForm(); err != nil { http.Error(w, "invalid form data", http.StatusBadRequest) return } username := r.FormValue("username") displayName := r.FormValue("display_name") email := r.FormValue("email") role := r.FormValue("role") // Collect groups from checkboxes groupVals := r.Form["group"] groups := "" for i, g := range groupVals { if i > 0 { groups += "," } groups += g } req := CreateUserRequest{ Users: []CreateUserInput{ { Username: username, DisplayName: displayName, Email: email, Role: role, Groups: groups, }, }, } results := h.store.Create(req) // Sync to Authelia YAML h.syncWriter.Sync() // Send welcome emails for _, r := range results { if r.Error == "" && r.GeneratedPassword != "" { email := "" for _, input := range req.Users { if input.Username == r.Username { email = input.Email } } if email != "" { go h.emailer.SendWelcome(email, r.Username, r.GeneratedPassword, "") // workspace URL from config } } } // Convert to template results resultRows := make([]templates.CreateUserResultRow, 0, len(results)) for _, r := range results { resultRows = append(resultRows, templates.CreateUserResultRow{ Username: r.Username, GeneratedPassword: r.GeneratedPassword, Error: r.Error, }) } component := templates.CreateUserSuccess(resultRows) component.Render(r.Context(), w) } // RegisterHTMXRoutes mounts the HTMX partial-update endpoints. func (h *Handler) RegisterHTMXRoutes(mux *http.ServeMux, authMiddleware func(http.Handler) http.Handler) { // HTMX returns HTML fragments, not full pages mux.Handle("GET /admin/users/list", authMiddleware(http.HandlerFunc(h.userRowsHandler))) mux.Handle("POST /admin/users/create", authMiddleware(http.HandlerFunc(h.createUsersHandler))) } // --- Group Handlers --- func (h *Handler) groupsPage(w http.ResponseWriter, r *http.Request) { component := templates.GroupDashboard() component.Render(r.Context(), w) } func (h *Handler) groupList(w http.ResponseWriter, r *http.Request) { groups, err := h.groupStore.List() if err != nil { http.Error(w, "failed to list groups", http.StatusInternalServerError) return } rows := make([]templates.GroupRow, 0, len(groups)) colors := []string{"#58a6ff", "#3fb950", "#d2991d", "#f85149", "#a371f7", "#db61a2"} for i, g := range groups { init := "?" if len(g.Name) > 0 { init = string(g.Name[0]) } rows = append(rows, templates.GroupRow{ Name: g.Name, UserCount: g.UserCount, Initial: init, Color: colors[i%len(colors)], }) } if rows == nil { rows = []templates.GroupRow{} } component := templates.GroupList(rows) component.Render(r.Context(), w) } func (h *Handler) createGroupForm(w http.ResponseWriter, r *http.Request) { component := templates.CreateGroupForm() component.Render(r.Context(), w) } func (h *Handler) createGroup(w http.ResponseWriter, r *http.Request) { r.ParseForm() name := r.FormValue("name") desc := r.FormValue("description") if name == "" { http.Error(w, "name required", http.StatusBadRequest) return } // Collect checked apps apps := r.Form["app"] appsStr := "" for i, a := range apps { if i > 0 { appsStr += "," } appsStr += a } if err := h.groupStore.Create(name, desc, appsStr); err != nil { http.Error(w, err.Error(), http.StatusConflict) return } h.groupList(w, r) } func (h *Handler) deleteGroup(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") if err := h.groupStore.Delete(name); err != nil { http.Error(w, err.Error(), http.StatusNotFound) return } h.groupList(w, r) } func (h *Handler) editGroupForm(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") g, err := h.groupStore.GetByName(name) if err != nil || g == nil { http.Error(w, "group not found", http.StatusNotFound) return } component := templates.EditGroupForm(g.Name, g.Description) component.Render(r.Context(), w) } func (h *Handler) updateGroup(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") desc := r.FormValue("description") _, err := h.groupStore.db.Exec(`UPDATE groups SET description = ? WHERE name = ?`, desc, name) if err != nil { http.Error(w, "update failed", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html") w.Write([]byte(`
Group updated successfully
`)) } func (h *Handler) editUserForm(w http.ResponseWriter, r *http.Request) { username := r.PathValue("username") u, _ := h.store.GetByUsername(username) if u == nil { http.Error(w, "user not found", http.StatusNotFound) return } grps, _ := h.groupStore.List() groupNames := make([]string, 0, len(grps)) for _, g := range grps { groupNames = append(groupNames, g.Name) } row := UserToRow(*u) component := templates.EditUserForm(row, groupNames) component.Render(r.Context(), w) } func (h *Handler) updateUser(w http.ResponseWriter, r *http.Request) { username := r.PathValue("username") r.ParseForm() role := r.FormValue("role") groupVals := r.Form["group"] groups := "" for i, g := range groupVals { if i > 0 { groups += "," } groups += g } h.store.GetDB().Exec(`UPDATE users SET role = ?, groups = ?, updated_at = CURRENT_TIMESTAMP WHERE username = ?`, role, groups, username) h.syncWriter.Sync() // Return success message and refresh user list w.Header().Set("Content-Type", "text/html") w.Write([]byte(`
User updated successfully
`)) // Trigger the refresh of the user list via HTMX }