NextWks/src/core/admin/ui.go

454 lines
14 KiB
Go

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(`<div class="alert success">Group updated successfully</div>
<script>setTimeout(function(){htmx.trigger('#group-detail','click')},800)</script>`))
}
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(`<div class="alert success">User updated successfully</div>
<script>setTimeout(function(){htmx.trigger('#user-detail','click')},800)</script>`))
// Trigger the refresh of the user list via HTMX
}