From 9813124ba7a05ff81b0aca88cf0ea1983493aeee Mon Sep 17 00:00:00 2001 From: cclohmar Date: Mon, 15 Jun 2026 04:34:49 +0000 Subject: [PATCH] feat(admin): global settings page for config editing --- src/core/admin/handlers.go | 4 +- src/core/admin/templates/global.templ | 65 ++++++++ src/core/admin/templates/global_templ.go | 198 +++++++++++++++++++++++ src/core/admin/templates/layout.templ | 12 +- src/core/admin/templates/layout_templ.go | 38 ++++- src/core/admin/ui.go | 128 +++++++++++++++ src/main.go | 2 +- 7 files changed, 433 insertions(+), 14 deletions(-) create mode 100644 src/core/admin/templates/global.templ create mode 100644 src/core/admin/templates/global_templ.go diff --git a/src/core/admin/handlers.go b/src/core/admin/handlers.go index 4f2a472..6f2d57a 100644 --- a/src/core/admin/handlers.go +++ b/src/core/admin/handlers.go @@ -12,14 +12,16 @@ type Handler struct { store *UserStore syncWriter *SyncWriter logger *slog.Logger + configPath string } // NewHandler creates a new admin Handler. -func NewHandler(store *UserStore, syncWriter *SyncWriter, logger *slog.Logger) *Handler { +func NewHandler(store *UserStore, syncWriter *SyncWriter, logger *slog.Logger, configPath string) *Handler { return &Handler{ store: store, syncWriter: syncWriter, logger: logger, + configPath: configPath, } } diff --git a/src/core/admin/templates/global.templ b/src/core/admin/templates/global.templ new file mode 100644 index 0000000..e505284 --- /dev/null +++ b/src/core/admin/templates/global.templ @@ -0,0 +1,65 @@ +package templates + +templ GlobalSettings(smtpHost, smtpPort, smtpUser, smtpPass, imapHost, imapPort, nextwksURL, authURL, proxyIP, msg string) { + @Layout("global") { +

Global Settings

+ if msg != "" { +
{ msg }
+ } +
+
+

Email

+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ +
+

URLs

+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ } +} diff --git a/src/core/admin/templates/global_templ.go b/src/core/admin/templates/global_templ.go new file mode 100644 index 0000000..fc291d4 --- /dev/null +++ b/src/core/admin/templates/global_templ.go @@ -0,0 +1,198 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func GlobalSettings(smtpHost, smtpPort, smtpUser, smtpPass, imapHost, imapPort, nextwksURL, authURL, proxyIP, msg string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Global Settings

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if msg != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(msg) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/global.templ`, Line: 7, Col: 35} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "

Email

URLs

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = Layout("global").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/src/core/admin/templates/layout.templ b/src/core/admin/templates/layout.templ index b810b9a..049fa14 100644 --- a/src/core/admin/templates/layout.templ +++ b/src/core/admin/templates/layout.templ @@ -22,10 +22,14 @@ templ Layout(page string) {
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\"> Global ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 = []any{"nav-link" + activeClass(page, "users")} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var6...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " Users
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -89,7 +111,7 @@ func Layout(page string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -120,12 +142,12 @@ func adminStyles() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var6 := templ.GetChildren(ctx) - if templ_7745c5c3_Var6 == nil { - templ_7745c5c3_Var6 = templ.NopComponent + templ_7745c5c3_Var8 := templ.GetChildren(ctx) + if templ_7745c5c3_Var8 == nil { + templ_7745c5c3_Var8 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/src/core/admin/ui.go b/src/core/admin/ui.go index 82e1f22..1ccddba 100644 --- a/src/core/admin/ui.go +++ b/src/core/admin/ui.go @@ -2,8 +2,10 @@ package admin import ( "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. @@ -11,6 +13,8 @@ func (h *Handler) RegisterUIRoutes(mux *http.ServeMux, authMiddleware func(http. // 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))) @@ -38,6 +42,130 @@ 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["smtp_host"], cfg["smtp_port"], cfg["smtp_user"], cfg["smtp_pass"], + cfg["imap_host"], cfg["imap_port"], cfg["nextwks_url"], cfg["auth_url"], + cfg["proxy_ip"], "", + ) + 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) + // Re-read to show updated values + cfg := readConfigRaw(h.configPath) + component := templates.GlobalSettings( + 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["proxy_ip"], 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["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"] = "" // stored in .env only + cfg["imap_port"] = "" // stored in .env only + 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["proxy_ip"] = "" // stored in .env only + 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("smtp_host"), "smtp", "host") + setNested(m, r.FormValue("smtp_port"), "smtp", "port") + setNested(m, r.FormValue("smtp_user"), "smtp", "username") + setNested(m, r.FormValue("smtp_pass"), "smtp", "password") + 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 { + s, _ := v.(string) + return s + } + } + 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{ diff --git a/src/main.go b/src/main.go index 8a8836a..2fa7466 100644 --- a/src/main.go +++ b/src/main.go @@ -71,7 +71,7 @@ func main() { } // Create admin handler - adminHandler := admin.NewHandler(userStore, syncWriter, logger) + adminHandler := admin.NewHandler(userStore, syncWriter, logger, *configPath) // Initialize session store and OIDC auth sessionStore := auth.NewSessionStore(database.DB)