feat: user settings page with profile, mail, preferences
This commit is contained in:
parent
e7566e66c7
commit
1e37c33603
1 changed files with 171 additions and 25 deletions
196
main.go
196
main.go
|
|
@ -2,6 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
|
|
@ -70,6 +71,36 @@ type Settings struct {
|
|||
} `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 ---
|
||||
|
||||
func loadConfig(configDir string) (*Config, error) {
|
||||
|
|
@ -201,7 +232,22 @@ func launcherHandler(cfg *Config, apps []AppEntry) http.HandlerFunc {
|
|||
groupsHeader := r.Header.Get("Remote-Groups")
|
||||
userGroups := strings.Split(groupsHeader, ",")
|
||||
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
|
||||
for _, app := range apps {
|
||||
|
|
@ -214,6 +260,7 @@ func launcherHandler(cfg *Config, apps []AppEntry) http.HandlerFunc {
|
|||
AppName string
|
||||
Description string
|
||||
User string
|
||||
DisplayName string
|
||||
IsAdmin bool
|
||||
Apps []AppEntry
|
||||
CompanyLogo string
|
||||
|
|
@ -223,6 +270,7 @@ func launcherHandler(cfg *Config, apps []AppEntry) http.HandlerFunc {
|
|||
AppName: cfg.App.Name,
|
||||
Description: cfg.App.Description,
|
||||
User: user,
|
||||
DisplayName: displayName,
|
||||
IsAdmin: strings.Contains(groupsHeader, "admins"),
|
||||
Apps: allowedApps,
|
||||
CompanyLogo: settings.Company.Logo,
|
||||
|
|
@ -358,15 +406,53 @@ func settingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||
user := r.Header.Get("Remote-User")
|
||||
groups := r.Header.Get("Remote-Groups")
|
||||
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))
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
tmpl.Execute(w, map[string]interface{}{
|
||||
"User": user,
|
||||
"IsAdmin": isAdmin,
|
||||
"User": user,
|
||||
"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 ---
|
||||
|
||||
func apiProxyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -641,7 +727,7 @@ const launcherHTML = `<!DOCTYPE html>
|
|||
{{if .Subtitle}}<p>{{.Subtitle}}</p>{{end}}
|
||||
</header>
|
||||
<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="https://auth.nextwks.eu/logout">{{t .Lang "logout"}}</a>
|
||||
</div>
|
||||
|
|
@ -662,44 +748,104 @@ const launcherHTML = `<!DOCTYPE html>
|
|||
</html>`
|
||||
|
||||
const settingsHTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="{{.Lang}}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Settings — NextWorkspace</title>
|
||||
<title>{{t .Lang "settings"}} — NextWorkspace</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
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 h1 { font-size: 1.5rem; }
|
||||
header a { color: #63b3ed; text-decoration: none; font-size: 0.9rem; }
|
||||
.container { max-width: 800px; margin: 0 auto; padding: 2rem; }
|
||||
.card { background: #fff; border-radius: 8px; padding: 1.5rem; margin-bottom: 1rem; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
|
||||
.card h2 { font-size: 1.1rem; margin-bottom: 0.5rem; color: #2d3748; }
|
||||
.card p { color: #718096; font-size: 0.9rem; }
|
||||
.card a { display: inline-block; margin-top: 0.5rem; color: #63b3ed; text-decoration: none; }
|
||||
.card a:hover { text-decoration: underline; }
|
||||
.badge { display: inline-block; background: #edf2f7; padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.8rem; margin-right: 0.25rem; }
|
||||
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.2rem; }
|
||||
header a { color: #63b3ed; text-decoration: none; font-size: 0.85rem; }
|
||||
.container { max-width: 700px; margin: 2rem auto; padding: 0 1rem; }
|
||||
.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: 1rem; font-weight: 600; color: #2d3748; margin-bottom: 1rem; padding-bottom: 0.5rem; border-bottom: 1px solid #edf2f7; }
|
||||
.field { margin-bottom: 0.9rem; }
|
||||
.field label { display: block; font-size: 0.82rem; font-weight: 500; color: #4a5568; margin-bottom: 0.25rem; }
|
||||
.field input, .field select { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 0.88rem; outline: none; }
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Settings</h1>
|
||||
<a href="/home/">Back to Launcher</a>
|
||||
<h1>{{t .Lang "settings"}}</h1>
|
||||
<a href="/home/">{{t .Lang "launcher_title"}}</a>
|
||||
</header>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<h2>Account</h2>
|
||||
<p>Logged in as <strong>{{.User}}</strong></p>
|
||||
</div>
|
||||
<form id="settings-form" onsubmit="return saveSettings(event)">
|
||||
<div class="card">
|
||||
<h2>Profile</h2>
|
||||
<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 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}}
|
||||
<div class="card">
|
||||
<h2>Administration</h2>
|
||||
<p>Manage users, groups, and workspace configuration.</p>
|
||||
<a href="/config">⚖ Admin Panel →</a>
|
||||
<p style="color:#718096;font-size:0.9rem;">Manage users, groups, and workspace configuration.</p>
|
||||
<a href="/config" style="color:#63b3ed;text-decoration:none;font-size:0.9rem;">⚖ Admin Panel →</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</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>
|
||||
</html>`
|
||||
|
||||
|
|
@ -1188,7 +1334,7 @@ func main() {
|
|||
|
||||
// Protected: settings
|
||||
mux.Handle("/settings", authMiddleware(settingsHandler))
|
||||
mux.Handle("/settings/", authMiddleware(settingsHandler))
|
||||
mux.Handle("/settings/save", authMiddleware(http.HandlerFunc(userSettingsSaveHandler)))
|
||||
|
||||
// Protected: admin — admins only
|
||||
mux.Handle("/config", adminGroupMiddleware(authMiddleware(adminHandler)))
|
||||
|
|
|
|||
Loading…
Reference in a new issue