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>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NextWorkspace — Your Self-Hosted Workspace for Startups</title>
<title>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;
min-height: 100vh;
}
header {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #fff;
padding: 3rem 2rem;
text-align: center;
}
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;
}
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #1a1a2e; color: #fff; display: flex; align-items: center;
justify-content: center; min-height: 100vh; margin: 0; }
.hero { text-align: center; max-width: 600px; padding: 2rem; }
.hero h1 { font-size: 2.5rem; margin-bottom: 0.5rem; }
.hero p { color: #a0aec0; font-size: 1.2rem; margin-bottom: 2rem; }
.hero img { max-height: 80px; margin-bottom: 1rem; }
.btn { display: inline-block; padding: 0.75rem 2rem; background: #63b3ed;
color: #fff; text-decoration: none; border-radius: 8px; font-weight: 500; }
.links { margin-top: 3rem; display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
.links a { color: #a0aec0; text-decoration: none; font-size: 0.9rem; }
.links a:hover { color: #63b3ed; }
</style>
</head>
<body>
<header>
<h1>NextWorkspace</h1>
<p>Your Self-Hosted Workspace for Startups</p>
<div class="domain">nextwks.eu</div>
</header>
<div class="container">
<h2>Components</h2>
<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 class="hero">
<img id="company-logo" style="display:none" alt="Logo">
<h1 id="company-name">NextWorkspace</h1>
<p id="company-subtitle">Your Self-Hosted Workspace for Startups</p>
<a class="btn" href="https://app.nextwks.eu/">Launch Workspace</a>
<div class="links">
<a href="https://auth.nextwks.eu/">Admin Login</a>
</div>
</div>
<footer>
&copy; 2026 NextWorkspace &mdash; nextwks.eu
</footer>
<script>
fetch('/api/settings/public')
.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>
</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"

126
main.go
View file

@ -51,6 +51,8 @@ type AppsFile struct {
type Settings struct {
Company struct {
Name string `yaml:"name"`
Subtitle string `yaml:"subtitle"`
Logo string `yaml:"logo"`
Language string `yaml:"language"`
Timezone string `yaml:"timezone"`
} `yaml:"company"`
@ -188,12 +190,22 @@ func healthHandler(w http.ResponseWriter, r *http.Request) {
}
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) {
user := r.Header.Get("Remote-User")
groupsHeader := r.Header.Get("Remote-Groups")
userGroups := strings.Split(groupsHeader, ",")
lang := getUserLang(r)
settings, _ := loadSettings(os.Getenv("CONFIG_DIR"))
var allowedApps []AppEntry
for _, app := range apps {
@ -208,12 +220,18 @@ func launcherHandler(cfg *Config, apps []AppEntry) http.HandlerFunc {
User string
IsAdmin bool
Apps []AppEntry
Lang string
CompanyLogo string
Subtitle string
}{
AppName: cfg.App.Name,
Description: cfg.App.Description,
User: user,
IsAdmin: strings.Contains(groupsHeader, "admins"),
Apps: allowedApps,
Lang: lang,
CompanyLogo: settings.Company.Logo,
Subtitle: settings.Company.Subtitle,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl.Execute(w, data)
@ -273,7 +291,10 @@ func adminHandler(w http.ResponseWriter, r *http.Request) {
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")
autheliaUp := false
@ -287,6 +308,7 @@ func adminHandler(w http.ResponseWriter, r *http.Request) {
"Domain": domain,
"AdminEmail": adminEmail,
"AutheliaUp": autheliaUp,
"T": func(key string) string { return T(settings.Company.Language, key) },
})
case http.MethodPost:
@ -397,6 +419,8 @@ func globalSettingsSaveHandler(w http.ResponseWriter, r *http.Request) {
}
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.Timezone = r.FormValue("timezone")
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), `"`, `\"`))
}
// --- 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 ---
const landingPageHTML = `<!DOCTYPE html>
@ -495,7 +595,7 @@ const landingPageHTML = `<!DOCTYPE html>
</html>`
const launcherHTML = `<!DOCTYPE html>
<html lang="en">
<html lang="{{.Lang}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@ -509,6 +609,7 @@ const launcherHTML = `<!DOCTYPE html>
min-height: 100vh;
}
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 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; }
@ -524,13 +625,15 @@ const launcherHTML = `<!DOCTYPE html>
</head>
<body>
<header>
{{if .CompanyLogo}}<img src="{{.CompanyLogo}}" class="logo" alt="Logo">{{end}}
<h1>{{.AppName}}</h1>
<p>{{.Description}}</p>
{{if .Subtitle}}<p>{{.Subtitle}}</p>{{end}}
</header>
<div class="user-banner">
<span>Welcome, {{.User}}</span>
<a href="/settings">&#9881; Settings</a>
<a href="https://auth.nextwks.eu/logout">Logout</a>
<span>{{t "welcome" "user" .User}}</span>
{{if .IsAdmin}}<a href="/config">{{t "admin_panel"}}</a>{{end}}
<a href="/settings">{{t "settings"}}</a>
<a href="https://auth.nextwks.eu/logout">{{t "logout"}}</a>
</div>
<div class="grid">
{{range .Apps}}
@ -828,10 +931,12 @@ const adminHTML = `<!DOCTYPE html>
<form id="global-form" onsubmit="return saveGlobal(event)">
<div class="card">
<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"><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>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 "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 "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 class="card">
@ -1040,6 +1145,7 @@ func main() {
// Public
mux.HandleFunc("/health", healthHandler)
mux.HandleFunc("/api/settings/public", publicSettingsHandler)
// Protectected: launcher
if companyName != "" {