feat(admin): global settings page for config editing

This commit is contained in:
Claus Lohmar 2026-06-15 04:34:49 +00:00
parent da575701e4
commit 9813124ba7
7 changed files with 433 additions and 14 deletions

View file

@ -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,
}
}

View file

@ -0,0 +1,65 @@
package templates
templ GlobalSettings(smtpHost, smtpPort, smtpUser, smtpPass, imapHost, imapPort, nextwksURL, authURL, proxyIP, msg string) {
@Layout("global") {
<h2 style="font-size:1.15rem;font-weight:600;margin-bottom:20px">Global Settings</h2>
if msg != "" {
<div class="alert success">{ msg }</div>
}
<form hx-post="/admin/global" hx-target="body" hx-swap="outerHTML">
<div class="form-card">
<h3 style="font-size:0.95rem;font-weight:600;margin-bottom:12px">Email</h3>
<div class="form-row">
<div class="form-group">
<label>SMTP Host</label>
<input type="text" name="smtp_host" class="input" value={ smtpHost }/>
</div>
<div class="form-group">
<label>SMTP Port</label>
<input type="text" name="smtp_port" class="input" value={ smtpPort }/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>SMTP Username</label>
<input type="text" name="smtp_user" class="input" value={ smtpUser }/>
</div>
<div class="form-group">
<label>SMTP Password</label>
<input type="password" name="smtp_pass" class="input" value={ smtpPass }/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>IMAP Host</label>
<input type="text" name="imap_host" class="input" value={ imapHost }/>
</div>
<div class="form-group">
<label>IMAP Port</label>
<input type="text" name="imap_port" class="input" value={ imapPort }/>
</div>
</div>
</div>
<div class="form-card">
<h3 style="font-size:0.95rem;font-weight:600;margin-bottom:12px">URLs</h3>
<div class="form-row">
<div class="form-group">
<label>NextWks URL</label>
<input type="text" name="nextwks_url" class="input" value={ nextwksURL }/>
</div>
<div class="form-group">
<label>Auth URL</label>
<input type="text" name="auth_url" class="input" value={ authURL }/>
</div>
</div>
<div class="form-group">
<label>Reverse Proxy IP</label>
<input type="text" name="proxy_ip" class="input" value={ proxyIP }/>
</div>
</div>
<button type="submit" class="btn" style="width:100%">Save Settings</button>
</form>
}
}

View file

@ -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, "<h2 style=\"font-size:1.15rem;font-weight:600;margin-bottom:20px\">Global Settings</h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if msg != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"alert success\">")
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, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " <form hx-post=\"/admin/global\" hx-target=\"body\" hx-swap=\"outerHTML\"><div class=\"form-card\"><h3 style=\"font-size:0.95rem;font-weight:600;margin-bottom:12px\">Email</h3><div class=\"form-row\"><div class=\"form-group\"><label>SMTP Host</label> <input type=\"text\" name=\"smtp_host\" class=\"input\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(smtpHost)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/global.templ`, Line: 15, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\"></div><div class=\"form-group\"><label>SMTP Port</label> <input type=\"text\" name=\"smtp_port\" class=\"input\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(smtpPort)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/global.templ`, Line: 19, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\"></div></div><div class=\"form-row\"><div class=\"form-group\"><label>SMTP Username</label> <input type=\"text\" name=\"smtp_user\" class=\"input\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(smtpUser)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/global.templ`, Line: 25, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\"></div><div class=\"form-group\"><label>SMTP Password</label> <input type=\"password\" name=\"smtp_pass\" class=\"input\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(smtpPass)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/global.templ`, Line: 29, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"></div></div><div class=\"form-row\"><div class=\"form-group\"><label>IMAP Host</label> <input type=\"text\" name=\"imap_host\" class=\"input\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(imapHost)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/global.templ`, Line: 35, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"></div><div class=\"form-group\"><label>IMAP Port</label> <input type=\"text\" name=\"imap_port\" class=\"input\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(imapPort)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/global.templ`, Line: 39, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\"></div></div></div><div class=\"form-card\"><h3 style=\"font-size:0.95rem;font-weight:600;margin-bottom:12px\">URLs</h3><div class=\"form-row\"><div class=\"form-group\"><label>NextWks URL</label> <input type=\"text\" name=\"nextwks_url\" class=\"input\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(nextwksURL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/global.templ`, Line: 49, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\"></div><div class=\"form-group\"><label>Auth URL</label> <input type=\"text\" name=\"auth_url\" class=\"input\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(authURL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/global.templ`, Line: 53, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"></div></div><div class=\"form-group\"><label>Reverse Proxy IP</label> <input type=\"text\" name=\"proxy_ip\" class=\"input\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(proxyIP)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/global.templ`, Line: 58, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"></div></div><button type=\"submit\" class=\"btn\" style=\"width:100%\">Save Settings</button></form>")
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

View file

@ -22,10 +22,14 @@ templ Layout(page string) {
</header>
<nav class="sidebar">
<div class="sidebar-nav">
<a href="/admin" class={ "nav-link" + activeClass(page, "dashboard") }>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
Dashboard
</a>
<a href="/admin" class={ "nav-link" + activeClass(page, "dashboard") }>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
Dashboard
</a>
<a href="/admin/global" class={ "nav-link" + activeClass(page, "global") }>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
Global
</a>
<a href="/admin/users" class={ "nav-link" + activeClass(page, "users") }>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Users

File diff suppressed because one or more lines are too long

View file

@ -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{

View file

@ -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)