feat: user settings page with profile, mail, preferences

This commit is contained in:
Claus Lohmar 2026-07-10 12:25:18 +01:00
parent e7566e66c7
commit 1e37c33603

188
main.go
View file

@ -2,6 +2,7 @@ package main
import ( import (
"bytes" "bytes"
"encoding/json"
"fmt" "fmt"
"html/template" "html/template"
"io" "io"
@ -70,6 +71,36 @@ type Settings struct {
} `yaml:"imap"` } `yaml:"imap"`
} }
// --- User settings ---
type UserSettings struct {
FirstName string `json:"first_name,omitempty"`
LastName string `json:"last_name,omitempty"`
ProfilePicture string `json:"profile_picture,omitempty"`
Email string `json:"email,omitempty"`
EmailPassword string `json:"email_password,omitempty"`
Language string `json:"language,omitempty"`
Timezone string `json:"timezone,omitempty"`
}
func loadUserSettings(username string) *UserSettings {
path := filepath.Join("/opt/nextworkspace/data/users", username+".json")
data, err := os.ReadFile(path)
if err != nil {
return &UserSettings{}
}
var s UserSettings
json.Unmarshal(data, &s)
return &s
}
func saveUserSettings(username string, s *UserSettings) error {
path := filepath.Join("/opt/nextworkspace/data/users", username+".json")
os.MkdirAll(filepath.Dir(path), 0700)
data, _ := json.MarshalIndent(s, "", " ")
return os.WriteFile(path, data, 0600)
}
// --- Config loading --- // --- Config loading ---
func loadConfig(configDir string) (*Config, error) { func loadConfig(configDir string) (*Config, error) {
@ -201,7 +232,22 @@ func launcherHandler(cfg *Config, apps []AppEntry) http.HandlerFunc {
groupsHeader := r.Header.Get("Remote-Groups") groupsHeader := r.Header.Get("Remote-Groups")
userGroups := strings.Split(groupsHeader, ",") userGroups := strings.Split(groupsHeader, ",")
settings, _ := loadSettings(os.Getenv("CONFIG_DIR")) settings, _ := loadSettings(os.Getenv("CONFIG_DIR"))
lang := getUserLang(r) us := loadUserSettings(user)
lang := us.Language
if lang == "" && settings != nil {
lang = settings.Company.Language
}
if lang == "" {
lang = "en"
}
// Use display name if available
displayName := user
if us.FirstName != "" {
displayName = us.FirstName
if us.LastName != "" {
displayName += " " + us.LastName
}
}
var allowedApps []AppEntry var allowedApps []AppEntry
for _, app := range apps { for _, app := range apps {
@ -214,6 +260,7 @@ func launcherHandler(cfg *Config, apps []AppEntry) http.HandlerFunc {
AppName string AppName string
Description string Description string
User string User string
DisplayName string
IsAdmin bool IsAdmin bool
Apps []AppEntry Apps []AppEntry
CompanyLogo string CompanyLogo string
@ -223,6 +270,7 @@ func launcherHandler(cfg *Config, apps []AppEntry) http.HandlerFunc {
AppName: cfg.App.Name, AppName: cfg.App.Name,
Description: cfg.App.Description, Description: cfg.App.Description,
User: user, User: user,
DisplayName: displayName,
IsAdmin: strings.Contains(groupsHeader, "admins"), IsAdmin: strings.Contains(groupsHeader, "admins"),
Apps: allowedApps, Apps: allowedApps,
CompanyLogo: settings.Company.Logo, CompanyLogo: settings.Company.Logo,
@ -358,15 +406,53 @@ func settingsHandler(w http.ResponseWriter, r *http.Request) {
user := r.Header.Get("Remote-User") user := r.Header.Get("Remote-User")
groups := r.Header.Get("Remote-Groups") groups := r.Header.Get("Remote-Groups")
isAdmin := strings.Contains(groups, "admins") isAdmin := strings.Contains(groups, "admins")
us := loadUserSettings(user)
settings, _ := loadSettings(os.Getenv("CONFIG_DIR"))
lang := us.Language
if lang == "" && settings != nil {
lang = settings.Company.Language
}
if lang == "" {
lang = "en"
}
tmpl := template.Must(template.New("settings").Parse(settingsHTML)) tmpl := template.Must(template.New("settings").Parse(settingsHTML))
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl.Execute(w, map[string]interface{}{ tmpl.Execute(w, map[string]interface{}{
"User": user, "User": user,
"IsAdmin": isAdmin, "IsAdmin": isAdmin,
"UserSettings": us,
"Settings": settings,
"Lang": lang,
}) })
} }
func userSettingsSaveHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
user := r.Header.Get("Remote-User")
if user == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
us := &UserSettings{
FirstName: r.FormValue("first_name"),
LastName: r.FormValue("last_name"),
ProfilePicture: r.FormValue("profile_picture"),
Email: r.FormValue("email"),
EmailPassword: r.FormValue("email_password"),
Language: r.FormValue("language"),
Timezone: r.FormValue("timezone"),
}
if err := saveUserSettings(user, us); err != nil {
http.Error(w, "Failed to save", http.StatusInternalServerError)
return
}
w.Write([]byte(`{"status":"ok"}`))
}
// --- API proxy for authelia-api --- // --- API proxy for authelia-api ---
func apiProxyHandler(w http.ResponseWriter, r *http.Request) { func apiProxyHandler(w http.ResponseWriter, r *http.Request) {
@ -641,7 +727,7 @@ const launcherHTML = `<!DOCTYPE html>
{{if .Subtitle}}<p>{{.Subtitle}}</p>{{end}} {{if .Subtitle}}<p>{{.Subtitle}}</p>{{end}}
</header> </header>
<div class="user-banner"> <div class="user-banner">
<span>{{t .Lang "welcome" "user" .User}}</span> <span>{{t .Lang "welcome" "user" .DisplayName}}</span>
<a href="/settings">{{t .Lang "settings"}}</a> <a href="/settings">{{t .Lang "settings"}}</a>
<a href="https://auth.nextwks.eu/logout">{{t .Lang "logout"}}</a> <a href="https://auth.nextwks.eu/logout">{{t .Lang "logout"}}</a>
</div> </div>
@ -662,44 +748,104 @@ const launcherHTML = `<!DOCTYPE html>
</html>` </html>`
const settingsHTML = `<!DOCTYPE html> const settingsHTML = `<!DOCTYPE html>
<html lang="en"> <html lang="{{.Lang}}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Settings NextWorkspace</title> <title>{{t .Lang "settings"}} NextWorkspace</title>
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f0f2f5; color: #1a1a2e; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f0f2f5; color: #1a1a2e; }
header { background: linear-gradient(135deg, #1a1a2e, #16213e); color: #fff; padding: 1.5rem 2rem; display: flex; justify-content: space-between; align-items: center; } header { background: linear-gradient(135deg, #1a1a2e, #16213e); color: #fff; padding: 1rem 1.5rem; display: flex; justify-content: space-between; align-items: center; }
header h1 { font-size: 1.5rem; } header h1 { font-size: 1.2rem; }
header a { color: #63b3ed; text-decoration: none; font-size: 0.9rem; } header a { color: #63b3ed; text-decoration: none; font-size: 0.85rem; }
.container { max-width: 800px; margin: 0 auto; padding: 2rem; } .container { max-width: 700px; margin: 2rem auto; padding: 0 1rem; }
.card { background: #fff; border-radius: 8px; padding: 1.5rem; margin-bottom: 1rem; box-shadow: 0 1px 4px rgba(0,0,0,0.06); } .card { background: #fff; border-radius: 10px; padding: 1.5rem; margin-bottom: 1.25rem; box-shadow: 0 1px 3px rgba(0,0,0,0.05); border: 1px solid #edf2f7; }
.card h2 { font-size: 1.1rem; margin-bottom: 0.5rem; color: #2d3748; } .card h2 { font-size: 1rem; font-weight: 600; color: #2d3748; margin-bottom: 1rem; padding-bottom: 0.5rem; border-bottom: 1px solid #edf2f7; }
.card p { color: #718096; font-size: 0.9rem; } .field { margin-bottom: 0.9rem; }
.card a { display: inline-block; margin-top: 0.5rem; color: #63b3ed; text-decoration: none; } .field label { display: block; font-size: 0.82rem; font-weight: 500; color: #4a5568; margin-bottom: 0.25rem; }
.card a:hover { text-decoration: underline; } .field input, .field select { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 0.88rem; outline: none; }
.badge { display: inline-block; background: #edf2f7; padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.8rem; margin-right: 0.25rem; } .field input:focus, .field select:focus { border-color: #63b3ed; box-shadow: 0 0 0 2px rgba(99,179,237,0.15); }
.field-note { font-size: 0.8rem; color: #a0aec0; margin-bottom: 0.5rem; }
.field-row { display: flex; gap: 1rem; }
.field-row .field { flex: 1; }
.btn { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.88rem; font-weight: 500; cursor: pointer; border: none; }
.btn-primary { background: #1a1a2e; color: #fff; }
.btn-primary:hover { background: #2d3748; }
.actions { display: flex; gap: 0.75rem; align-items: center; margin-top: 1rem; }
.saved-msg { color: #48bb78; font-size: 0.9rem; display: none; }
</style> </style>
</head> </head>
<body> <body>
<header> <header>
<h1>Settings</h1> <h1>{{t .Lang "settings"}}</h1>
<a href="/home/">Back to Launcher</a> <a href="/home/">{{t .Lang "launcher_title"}}</a>
</header> </header>
<div class="container"> <div class="container">
<form id="settings-form" onsubmit="return saveSettings(event)">
<div class="card"> <div class="card">
<h2>Account</h2> <h2>Profile</h2>
<p>Logged in as <strong>{{.User}}</strong></p> <div class="field-row">
<div class="field"><label>First Name</label><input type="text" name="first_name" value="{{.UserSettings.FirstName}}"></div>
<div class="field"><label>Last Name</label><input type="text" name="last_name" value="{{.UserSettings.LastName}}"></div>
</div> </div>
<div class="field"><label>Profile Picture URL</label><input type="url" name="profile_picture" value="{{.UserSettings.ProfilePicture}}" placeholder="https://example.com/avatar.jpg"></div>
</div>
<div class="card">
<h2>Mail</h2>
<p class="field-note">Server: {{.Settings.IMAP.Host}}:{{.Settings.IMAP.Port}}</p>
<div class="field"><label>Email Address</label><input type="email" name="email" value="{{.UserSettings.Email}}"></div>
<div class="field"><label>Email Password</label><input type="password" name="email_password" value="{{.UserSettings.EmailPassword}}"></div>
</div>
<div class="card">
<h2>Preferences</h2>
<div class="field-row">
<div class="field">
<label>Language</label>
<select name="language">
<option value="">{{.Settings.Company.Language}} (System default)</option>
<option value="en" {{if eq .UserSettings.Language "en"}}selected{{end}}>English</option>
<option value="de" {{if eq .UserSettings.Language "de"}}selected{{end}}>Deutsch</option>
</select>
</div>
<div class="field">
<label>Timezone</label>
<select name="timezone">
<option value="">{{.Settings.Company.Timezone}} (System default)</option>
<option value="UTC">UTC</option>
<option value="Europe/London">Europe/London</option>
<option value="Europe/Berlin">Europe/Berlin</option>
<option value="Europe/Paris">Europe/Paris</option>
<option value="America/New_York">America/New_York</option>
<option value="Asia/Tokyo">Asia/Tokyo</option>
</select>
</div>
</div>
</div>
<div class="actions">
<button type="submit" class="btn btn-primary">{{t .Lang "save"}}</button>
<span id="savemsg" class="saved-msg">{{t .Lang "saved"}}</span>
</div>
</form>
{{if .IsAdmin}} {{if .IsAdmin}}
<div class="card"> <div class="card">
<h2>Administration</h2> <h2>Administration</h2>
<p>Manage users, groups, and workspace configuration.</p> <p style="color:#718096;font-size:0.9rem;">Manage users, groups, and workspace configuration.</p>
<a href="/config">&#9878; Admin Panel </a> <a href="/config" style="color:#63b3ed;text-decoration:none;font-size:0.9rem;">&#9878; Admin Panel </a>
</div> </div>
{{end}} {{end}}
</div> </div>
<script>
async function saveSettings(e) {
e.preventDefault();
const form = document.getElementById('settings-form');
const data = new FormData(form);
const resp = await fetch('/settings/save', {method:'POST', body:new URLSearchParams(data)});
const msg = document.getElementById('savemsg');
if (resp.ok) { msg.style.display = 'inline'; setTimeout(() => msg.style.display = 'none', 3000); }
return false;
}
</script>
</body> </body>
</html>` </html>`
@ -1188,7 +1334,7 @@ func main() {
// Protected: settings // Protected: settings
mux.Handle("/settings", authMiddleware(settingsHandler)) mux.Handle("/settings", authMiddleware(settingsHandler))
mux.Handle("/settings/", authMiddleware(settingsHandler)) mux.Handle("/settings/save", authMiddleware(http.HandlerFunc(userSettingsSaveHandler)))
// Protected: admin — admins only // Protected: admin — admins only
mux.Handle("/config", adminGroupMiddleware(authMiddleware(adminHandler))) mux.Handle("/config", adminGroupMiddleware(authMiddleware(adminHandler)))