feat: global settings, multi-language (en/de), company branding on launcher + www

This commit is contained in:
Claus Lohmar 2026-07-08 15:32:17 +01:00
parent 857ce833cf
commit 9790d9338a
6 changed files with 228 additions and 81 deletions

View file

@ -3,81 +3,42 @@
<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>NextWorkspace — Your Self-Hosted Workspace for Startups</title> <title>NextWorkspace</title>
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
body { background: #1a1a2e; color: #fff; display: flex; align-items: center;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; justify-content: center; min-height: 100vh; margin: 0; }
background: #f0f2f5; .hero { text-align: center; max-width: 600px; padding: 2rem; }
color: #1a1a2e; .hero h1 { font-size: 2.5rem; margin-bottom: 0.5rem; }
min-height: 100vh; .hero p { color: #a0aec0; font-size: 1.2rem; margin-bottom: 2rem; }
} .hero img { max-height: 80px; margin-bottom: 1rem; }
header { .btn { display: inline-block; padding: 0.75rem 2rem; background: #63b3ed;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); color: #fff; text-decoration: none; border-radius: 8px; font-weight: 500; }
color: #fff; .links { margin-top: 3rem; display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
padding: 3rem 2rem; .links a { color: #a0aec0; text-decoration: none; font-size: 0.9rem; }
text-align: center; .links a:hover { color: #63b3ed; }
}
header h1 { font-size: 2.5rem; margin-bottom: 0.5rem; }
header p { color: #a0aec0; font-size: 1.2rem; }
.domain { color: #63b3ed; font-size: 0.9rem; margin-top: 0.5rem; }
.container { max-width: 800px; margin: 0 auto; padding: 2rem; }
h2 { font-size: 1.5rem; margin: 2rem 0 1rem; color: #2d3748; }
ul { list-style: none; padding: 0; }
li {
background: #fff;
border-radius: 8px;
padding: 1rem 1.25rem;
margin-bottom: 0.75rem;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
li a { color: #1a1a2e; text-decoration: none; font-weight: 600; font-size: 1.05rem; }
li a:hover { color: #63b3ed; }
li span { color: #718096; font-size: 0.9rem; margin-left: 0.5rem; }
.ai-credit {
background: #edf2f7;
border-radius: 8px;
padding: 1.5rem;
margin-top: 2rem;
text-align: center;
font-size: 0.9rem;
color: #4a5568;
}
.ai-credit strong { color: #1a1a2e; }
footer {
text-align: center;
padding: 2rem;
color: #a0aec0;
font-size: 0.85rem;
}
</style> </style>
</head> </head>
<body> <body>
<header> <div class="hero">
<h1>NextWorkspace</h1> <img id="company-logo" style="display:none" alt="Logo">
<p>Your Self-Hosted Workspace for Startups</p> <h1 id="company-name">NextWorkspace</h1>
<div class="domain">nextwks.eu</div> <p id="company-subtitle">Your Self-Hosted Workspace for Startups</p>
</header> <a class="btn" href="https://app.nextwks.eu/">Launch Workspace</a>
<div class="container"> <div class="links">
<h2>Components</h2> <a href="https://auth.nextwks.eu/">Admin Login</a>
<ul>
<li><a href="https://app.nextwks.eu/home">NextWks Core</a><span>— Launcher &amp; Workspace Hub</span></li>
<li><a href="https://app.nextwks.eu/drive">OpenCloud</a><span>— File Storage</span></li>
<li><a href="https://app.nextwks.eu/connect">Alps Webmail</a><span>— Email Client</span></li>
<li><a href="https://app.nextwks.eu/office">Euro Office</a><span>— Collaborative Suite</span></li>
<li><a href="https://app.nextwks.eu/enterprise">ERPNext</a><span>— Enterprise ERP</span></li>
<li><a href="https://app.nextwks.eu/chat">Element Web</a><span>— Matrix Chat</span></li>
<li><a href="https://app.nextwks.eu/meet">Jitsi</a><span>— Video Conferencing</span></li>
<li><a href="https://app.nextwks.eu/aida">Open WebUI</a><span>— AI Chat Frontend</span></li>
</ul>
<div class="ai-credit">
<h2>Built with AI</h2>
<p>NextWorkspace was developed with assistance from AI coding tools, using <strong>DeepSeek</strong> as the provider and <strong>OpenCode</strong> as the development framework.</p>
</div> </div>
</div> </div>
<footer> <script>
&copy; 2026 NextWorkspace &mdash; nextwks.eu fetch('/api/settings/public')
</footer> .then(r => r.json())
.then(s => {
if (s.logo) { const img = document.getElementById('company-logo');
img.src = s.logo; img.style.display = 'block'; }
document.getElementById('company-name').textContent = s.name;
document.getElementById('company-subtitle').textContent = s.subtitle;
document.title = s.name;
});
</script>
</body> </body>
</html> </html>

32
lng/de/admin.yaml Normal file
View file

@ -0,0 +1,32 @@
page_title: "Admin Bereich"
nav_global: "Global"
nav_access: "Zugriff"
nav_security: "Sicherheit"
nav_domain: "Domain"
nav_mail: "E-Mail"
nav_docs: "Dokumente"
nav_calendar: "Kalender"
save: "Einstellungen speichern"
saved: "✓ Gespeichert"
cancel: "Abbrechen"
company_section: "Unternehmen"
company_name: "Unternehmensname"
company_subtitle: "Untertitel"
company_logo: "Logo URL"
language: "Standardsprache"
timezone: "Zeitzone"
smtp_section: "SMTP"
smtp_host: "SMTP Host"
smtp_port: "SMTP Port"
smtp_user: "SMTP Benutzer"
smtp_password: "SMTP Passwort"
smtp_sender: "Absender E-Mail"
system_section: "System (schreibgeschützt)"
domain_label: "Domain"
admin_email: "Admin E-Mail"
authelia_status: "Authelia"
version_label: "Version"
global_title: "Globale Einstellungen"
access_title: "Zugriffsverwaltung"
users_label: "Benutzer"
create_user: "Benutzer anlegen"

8
lng/de/launcher.yaml Normal file
View file

@ -0,0 +1,8 @@
app_title: "NextWorkspace"
welcome: "Willkommen, {user}"
login: "Anmelden"
logout: "Abmelden"
launcher_title: "Ihr Arbeitsbereich"
no_apps: "Keine Anwendungen verfügbar"
admin_panel: "Admin Bereich"
settings: "Einstellungen"

32
lng/en/admin.yaml Normal file
View file

@ -0,0 +1,32 @@
page_title: "Admin Panel"
nav_global: "Global"
nav_access: "Access"
nav_security: "Security"
nav_domain: "Domain"
nav_mail: "Mail"
nav_docs: "Docs"
nav_calendar: "Calendar"
save: "Save Settings"
saved: "✓ Saved"
cancel: "Cancel"
company_section: "Company"
company_name: "Company Name"
company_subtitle: "Subtitle"
company_logo: "Logo URL"
language: "Default Language"
timezone: "Timezone"
smtp_section: "SMTP"
smtp_host: "SMTP Host"
smtp_port: "SMTP Port"
smtp_user: "SMTP User"
smtp_password: "SMTP Password"
smtp_sender: "Sender Email"
system_section: "System (read-only)"
domain_label: "Domain"
admin_email: "Admin Email"
authelia_status: "Authelia"
version_label: "Version"
global_title: "Global Settings"
access_title: "Access Management"
users_label: "Users"
create_user: "Create User"

8
lng/en/launcher.yaml Normal file
View file

@ -0,0 +1,8 @@
app_title: "NextWorkspace"
welcome: "Welcome, {user}"
login: "Sign In"
logout: "Logout"
launcher_title: "Your Workspace"
no_apps: "No applications available"
admin_panel: "Admin Panel"
settings: "Settings"

128
main.go
View file

@ -51,6 +51,8 @@ type AppsFile struct {
type Settings struct { type Settings struct {
Company struct { Company struct {
Name string `yaml:"name"` Name string `yaml:"name"`
Subtitle string `yaml:"subtitle"`
Logo string `yaml:"logo"`
Language string `yaml:"language"` Language string `yaml:"language"`
Timezone string `yaml:"timezone"` Timezone string `yaml:"timezone"`
} `yaml:"company"` } `yaml:"company"`
@ -188,12 +190,22 @@ func healthHandler(w http.ResponseWriter, r *http.Request) {
} }
func launcherHandler(cfg *Config, apps []AppEntry) http.HandlerFunc { func launcherHandler(cfg *Config, apps []AppEntry) http.HandlerFunc {
tmpl := template.Must(template.New("launcher").Parse(launcherHTML)) funcMap := template.FuncMap{"t": func(key string, args ...string) string {
lang := "en"
settings, _ := loadSettings(os.Getenv("CONFIG_DIR"))
if settings != nil && settings.Company.Language != "" {
lang = settings.Company.Language
}
return T(lang, key, args...)
}}
tmpl := template.Must(template.New("launcher").Funcs(funcMap).Parse(launcherHTML))
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
user := r.Header.Get("Remote-User") user := r.Header.Get("Remote-User")
groupsHeader := r.Header.Get("Remote-Groups") groupsHeader := r.Header.Get("Remote-Groups")
userGroups := strings.Split(groupsHeader, ",") userGroups := strings.Split(groupsHeader, ",")
lang := getUserLang(r)
settings, _ := loadSettings(os.Getenv("CONFIG_DIR"))
var allowedApps []AppEntry var allowedApps []AppEntry
for _, app := range apps { for _, app := range apps {
@ -208,12 +220,18 @@ func launcherHandler(cfg *Config, apps []AppEntry) http.HandlerFunc {
User string User string
IsAdmin bool IsAdmin bool
Apps []AppEntry Apps []AppEntry
Lang string
CompanyLogo string
Subtitle string
}{ }{
AppName: cfg.App.Name, AppName: cfg.App.Name,
Description: cfg.App.Description, Description: cfg.App.Description,
User: user, User: user,
IsAdmin: strings.Contains(groupsHeader, "admins"), IsAdmin: strings.Contains(groupsHeader, "admins"),
Apps: allowedApps, Apps: allowedApps,
Lang: lang,
CompanyLogo: settings.Company.Logo,
Subtitle: settings.Company.Subtitle,
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl.Execute(w, data) tmpl.Execute(w, data)
@ -273,7 +291,10 @@ func adminHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
tmpl := template.Must(template.New("admin").Parse(adminHTML)) funcMap := template.FuncMap{"t": func(key string) string {
return T(settings.Company.Language, key)
}}
tmpl := template.Must(template.New("admin").Funcs(funcMap).Parse(adminHTML))
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
autheliaUp := false autheliaUp := false
@ -287,6 +308,7 @@ func adminHandler(w http.ResponseWriter, r *http.Request) {
"Domain": domain, "Domain": domain,
"AdminEmail": adminEmail, "AdminEmail": adminEmail,
"AutheliaUp": autheliaUp, "AutheliaUp": autheliaUp,
"T": func(key string) string { return T(settings.Company.Language, key) },
}) })
case http.MethodPost: case http.MethodPost:
@ -397,6 +419,8 @@ func globalSettingsSaveHandler(w http.ResponseWriter, r *http.Request) {
} }
settings.Company.Name = r.FormValue("company_name") settings.Company.Name = r.FormValue("company_name")
settings.Company.Subtitle = r.FormValue("company_subtitle")
settings.Company.Logo = r.FormValue("company_logo")
settings.Company.Language = r.FormValue("language") settings.Company.Language = r.FormValue("language")
settings.Company.Timezone = r.FormValue("timezone") settings.Company.Timezone = r.FormValue("timezone")
settings.SMTP.Host = r.FormValue("smtp_host") settings.SMTP.Host = r.FormValue("smtp_host")
@ -436,6 +460,82 @@ func applySettingsHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `{"success":true,"output":"%s"}`, strings.ReplaceAll(string(output), `"`, `\"`)) fmt.Fprintf(w, `{"success":true,"output":"%s"}`, strings.ReplaceAll(string(output), `"`, `\"`))
} }
// --- Public settings API ---
func publicSettingsHandler(w http.ResponseWriter, r *http.Request) {
configDir := os.Getenv("CONFIG_DIR")
if configDir == "" {
configDir = "/opt/nextworkspace/config/nextworkspace"
}
settings, _ := loadSettings(configDir)
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"name":"%s","subtitle":"%s","logo":"%s"}`,
settings.Company.Name, settings.Company.Subtitle, settings.Company.Logo)
}
// --- Translation system ---
type Translations map[string]string
var translationCache map[string]Translations
func loadTranslations(lang string) Translations {
if translationCache != nil {
if cached, ok := translationCache[lang]; ok {
return cached
}
}
basePath := filepath.Join(os.Getenv("CONFIG_DIR"), "../../lng", lang)
result := Translations{}
files := []string{"launcher.yaml", "admin.yaml"}
for _, f := range files {
data, err := os.ReadFile(filepath.Join(basePath, f))
if err != nil {
continue
}
var m map[string]string
yaml.Unmarshal(data, &m)
for k, v := range m {
result[k] = v
}
}
if translationCache == nil {
translationCache = make(map[string]Translations)
}
translationCache[lang] = result
return result
}
func T(lang, key string, args ...string) string {
t := loadTranslations(lang)
text, ok := t[key]
if !ok {
en := loadTranslations("en")
text = en[key]
if text == "" {
return key
}
}
for i := 0; i < len(args); i += 2 {
if i+1 < len(args) {
text = strings.ReplaceAll(text, "{"+args[i]+"}", args[i+1])
}
}
return text
}
func getUserLang(r *http.Request) string {
headerLang := r.Header.Get("Remote-Lang")
if headerLang == "de" || headerLang == "en" {
return headerLang
}
settings, _ := loadSettings(os.Getenv("CONFIG_DIR"))
if settings != nil && settings.Company.Language == "de" {
return "de"
}
return "en"
}
// --- Templates --- // --- Templates ---
const landingPageHTML = `<!DOCTYPE html> const landingPageHTML = `<!DOCTYPE html>
@ -495,7 +595,7 @@ const landingPageHTML = `<!DOCTYPE html>
</html>` </html>`
const launcherHTML = `<!DOCTYPE html> const launcherHTML = `<!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">
@ -509,6 +609,7 @@ const launcherHTML = `<!DOCTYPE html>
min-height: 100vh; min-height: 100vh;
} }
header { background: linear-gradient(135deg, #1a1a2e, #16213e); color: #fff; padding: 2rem; text-align: center; } header { background: linear-gradient(135deg, #1a1a2e, #16213e); color: #fff; padding: 2rem; text-align: center; }
header .logo { max-height: 64px; margin-bottom: 0.5rem; }
header h1 { font-size: 2rem; margin-bottom: 0.25rem; } header h1 { font-size: 2rem; margin-bottom: 0.25rem; }
header p { color: #a0aec0; font-size: 1rem; } header p { color: #a0aec0; font-size: 1rem; }
.user-banner { background: #2d3748; color: #e2e8f0; padding: 0.75rem 2rem; text-align: center; font-size: 0.9rem; display: flex; justify-content: center; gap: 1rem; } .user-banner { background: #2d3748; color: #e2e8f0; padding: 0.75rem 2rem; text-align: center; font-size: 0.9rem; display: flex; justify-content: center; gap: 1rem; }
@ -524,13 +625,15 @@ const launcherHTML = `<!DOCTYPE html>
</head> </head>
<body> <body>
<header> <header>
{{if .CompanyLogo}}<img src="{{.CompanyLogo}}" class="logo" alt="Logo">{{end}}
<h1>{{.AppName}}</h1> <h1>{{.AppName}}</h1>
<p>{{.Description}}</p> {{if .Subtitle}}<p>{{.Subtitle}}</p>{{end}}
</header> </header>
<div class="user-banner"> <div class="user-banner">
<span>Welcome, {{.User}}</span> <span>{{t "welcome" "user" .User}}</span>
<a href="/settings">&#9881; Settings</a> {{if .IsAdmin}}<a href="/config">{{t "admin_panel"}}</a>{{end}}
<a href="https://auth.nextwks.eu/logout">Logout</a> <a href="/settings">{{t "settings"}}</a>
<a href="https://auth.nextwks.eu/logout">{{t "logout"}}</a>
</div> </div>
<div class="grid"> <div class="grid">
{{range .Apps}} {{range .Apps}}
@ -828,10 +931,12 @@ const adminHTML = `<!DOCTYPE html>
<form id="global-form" onsubmit="return saveGlobal(event)"> <form id="global-form" onsubmit="return saveGlobal(event)">
<div class="card"> <div class="card">
<h3>Company</h3> <h3>Company</h3>
<div class="field"><label>Company Name</label><input type="text" name="company_name" value="{{.Settings.Company.Name}}"></div> <div class="field"><label>{{.T "company_name"}}</label><input type="text" name="company_name" value="{{.Settings.Company.Name}}"></div>
<div class="field"><label>{{.T "company_subtitle"}}</label><input type="text" name="company_subtitle" value="{{.Settings.Company.Subtitle}}"></div>
<div class="field"><label>{{.T "company_logo"}}</label><input type="url" name="company_logo" value="{{.Settings.Company.Logo}}" placeholder="https://example.com/logo.png"></div>
<div class="field-row"> <div class="field-row">
<div class="field"><label>Language</label><select name="language"><option value="en" {{if eq .Settings.Company.Language "en"}}selected{{end}}>English</option><option value="de" {{if eq .Settings.Company.Language "de"}}selected{{end}}>Deutsch</option></select></div> <div class="field"><label>{{.T "language"}}</label><select name="language"><option value="en" {{if eq .Settings.Company.Language "en"}}selected{{end}}>English</option><option value="de" {{if eq .Settings.Company.Language "de"}}selected{{end}}>Deutsch</option></select></div>
<div class="field"><label>Timezone</label><select name="timezone">{{$tz := .Settings.Company.Timezone}}<option value="UTC" {{if eq $tz "UTC"}}selected{{end}}>UTC</option><option value="Europe/London" {{if eq $tz "Europe/London"}}selected{{end}}>Europe/London</option><option value="Europe/Berlin" {{if eq $tz "Europe/Berlin"}}selected{{end}}>Europe/Berlin</option><option value="America/New_York" {{if eq $tz "America/New_York"}}selected{{end}}>America/New_York</option><option value="Asia/Tokyo" {{if eq $tz "Asia/Tokyo"}}selected{{end}}>Asia/Tokyo</option></select></div> <div class="field"><label>{{.T "timezone"}}</label><select name="timezone">{{$tz := .Settings.Company.Timezone}}<option value="UTC" {{if eq $tz "UTC"}}selected{{end}}>UTC</option><option value="Europe/London" {{if eq $tz "Europe/London"}}selected{{end}}>Europe/London</option><option value="Europe/Berlin" {{if eq $tz "Europe/Berlin"}}selected{{end}}>Europe/Berlin</option><option value="America/New_York" {{if eq $tz "America/New_York"}}selected{{end}}>America/New_York</option><option value="Asia/Tokyo" {{if eq $tz "Asia/Tokyo"}}selected{{end}}>Asia/Tokyo</option></select></div>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
@ -1040,6 +1145,7 @@ func main() {
// Public // Public
mux.HandleFunc("/health", healthHandler) mux.HandleFunc("/health", healthHandler)
mux.HandleFunc("/api/settings/public", publicSettingsHandler)
// Protectected: launcher // Protectected: launcher
if companyName != "" { if companyName != "" {