Global Settings
General system configuration for your workspace.
package main import ( "bytes" "encoding/csv" "encoding/json" "fmt" "html/template" "io" "log" "net" "net/http" "net/http/httputil" "net/url" "os" "os/exec" "path/filepath" "strconv" "strings" "time" "gopkg.in/yaml.v3" ) // --- Config types --- type ServerConfig struct { Port int `yaml:"port"` Host string `yaml:"host"` } type AppConfig struct { Name string `yaml:"name"` Description string `yaml:"description"` } type Config struct { Server ServerConfig `yaml:"server"` App AppConfig `yaml:"app"` } type AppEntry struct { Name string `yaml:"name"` Subtitle string `yaml:"subtitle"` Path string `yaml:"path"` Upstream string `yaml:"upstream"` Icon string `yaml:"icon"` Groups []string `yaml:"groups"` } type AppsFile struct { Apps []AppEntry `yaml:"apps"` } type Settings struct { Company struct { Name string `yaml:"name"` Subtitle string `yaml:"subtitle"` Logo string `yaml:"logo"` TopUser string `yaml:"top_user"` Language string `yaml:"language"` Timezone string `yaml:"timezone"` } `yaml:"company"` SMTP struct { Host string `yaml:"host"` Port int `yaml:"port"` User string `yaml:"user"` Sender string `yaml:"sender"` } `yaml:"smtp"` IMAP struct { Host string `yaml:"host"` Port int `yaml:"port"` } `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"` Phone string `json:"phone,omitempty"` Address string `json:"address,omitempty"` Messenger string `json:"messenger,omitempty"` Bio string `json:"bio,omitempty"` Manager string `json:"manager,omitempty"` JobTitle string `json:"job_title,omitempty"` Dept string `json:"dept,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) { path := filepath.Join(configDir, "config.yaml") data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("reading config: %w", err) } var cfg Config if err := yaml.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("parsing config: %w", err) } return &cfg, nil } func loadApps(configDir string) ([]AppEntry, error) { path := filepath.Join(configDir, "apps.yaml") data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return []AppEntry{}, nil } return nil, fmt.Errorf("reading apps: %w", err) } var appsFile AppsFile if err := yaml.Unmarshal(data, &appsFile); err != nil { return nil, fmt.Errorf("parsing apps: %w", err) } return appsFile.Apps, nil } // --- Settings loading --- func loadSettings(configDir string) (*Settings, error) { path := filepath.Join(configDir, "settings.yaml") data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return &Settings{}, nil } return nil, fmt.Errorf("reading settings: %w", err) } var s Settings if err := yaml.Unmarshal(data, &s); err != nil { return nil, fmt.Errorf("parsing settings: %w", err) } return &s, nil } func saveSettings(configDir string, s *Settings) error { path := filepath.Join(configDir, "settings.yaml") data, err := yaml.Marshal(s) if err != nil { return fmt.Errorf("marshaling settings: %w", err) } return os.WriteFile(path, data, 0644) } func updateEnvFile(path string, values map[string]string) error { data, err := os.ReadFile(path) if err != nil { return err } lines := strings.Split(string(data), "\n") for k, v := range values { found := false for i, line := range lines { if strings.HasPrefix(line, k+"=") { lines[i] = k + "=" + v found = true break } } if !found { lines = append(lines, k+"="+v) } } return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0600) } // --- Helpers --- func userHasAnyGroup(userGroups []string, requiredGroups []string) bool { for _, ug := range userGroups { for _, rg := range requiredGroups { if strings.TrimSpace(ug) == rg { return true } } } return false } // --- Auth middleware (trusts Remote-User from Caddy forward auth) --- func authMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user := r.Header.Get("Remote-User") if user == "" { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } r.Header.Set("X-Auth-User", user) next(w, r) } } func adminGroupMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { groups := r.Header.Get("Remote-Groups") if !strings.Contains(groups, "admins") { http.Error(w, "Forbidden — admins only", http.StatusForbidden) return } next(w, r) } } // --- Handlers --- func healthHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) fmt.Fprint(w, "OK") } func launcherHandler(cfg *Config, apps []AppEntry) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user := r.Header.Get("Remote-User") groupsHeader := r.Header.Get("Remote-Groups") userGroups := strings.Split(groupsHeader, ",") settings, _ := loadSettings(os.Getenv("CONFIG_DIR")) 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 { if len(app.Groups) == 0 || userHasAnyGroup(userGroups, app.Groups) { allowedApps = append(allowedApps, app) } } data := struct { AppName string Description string User string DisplayName string IsAdmin bool Apps []AppEntry CompanyLogo string Subtitle string Lang string }{ AppName: cfg.App.Name, Description: cfg.App.Description, User: user, DisplayName: displayName, IsAdmin: strings.Contains(groupsHeader, "admins"), Apps: allowedApps, CompanyLogo: settings.Company.Logo, Subtitle: settings.Company.Subtitle, Lang: lang, } w.Header().Set("Content-Type", "text/html; charset=utf-8") launcherTmpl.Execute(w, data) } } func proxyToUpstream(upstream string) http.HandlerFunc { target, err := url.Parse(upstream) if err != nil { log.Fatalf("Invalid upstream URL %q: %v", upstream, err) } proxy := httputil.NewSingleHostReverseProxy(target) return func(w http.ResponseWriter, r *http.Request) { r.Header.Set("Remote-User", r.Header.Get("Remote-User")) proxy.ServeHTTP(w, r) } } func adminHandler(w http.ResponseWriter, r *http.Request) { apiBase := "http://authelia:8080" apiToken := os.Getenv("AUTHELIA_SECRET") configDir := os.Getenv("CONFIG_DIR") if configDir == "" { configDir = "/opt/nextworkspace/config/nextworkspace" } // Load settings for Global tab settings, _ := loadSettings(configDir) domain := os.Getenv("DOMAIN") adminEmail := os.Getenv("TLS_EMAIL") switch r.Method { case http.MethodGet: // If settings JSON format requested if r.URL.Query().Get("format") == "settings" { w.Header().Set("Content-Type", "application/json") jsonData := fmt.Sprintf(`{"company_name":"%s","language":"%s","timezone":"%s","smtp_host":"%s","smtp_port":"%d","smtp_user":"%s","smtp_sender":"%s","imap_host":"%s","imap_port":"%d"}`, settings.Company.Name, settings.Company.Language, settings.Company.Timezone, settings.SMTP.Host, settings.SMTP.Port, settings.SMTP.User, settings.SMTP.Sender, settings.IMAP.Host, settings.IMAP.Port) w.Write([]byte(jsonData)) return } // If JSON format requested, return raw API response if r.URL.Query().Get("format") == "json" { req, _ := http.NewRequest("GET", apiBase+"/api/users", nil) req.Header.Set("Authorization", "Bearer "+apiToken) resp, err := http.DefaultClient.Do(req) if err != nil { http.Error(w, fmt.Sprintf("API error: %v", err), http.StatusBadGateway) return } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) w.Header().Set("Content-Type", "application/json") w.Write(body) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") // Check service statuses autheliaUp := false if resp, err := http.Get("http://authelia:9091/api/health"); err == nil { autheliaUp = resp.StatusCode == 200 resp.Body.Close() } caddyUp := false // Check port 80 responds (don't follow HTTPS redirect) if conn, err := net.DialTimeout("tcp", "caddy:80", 3*time.Second); err == nil { caddyUp = true conn.Close() } launcherUp := false if resp, err := http.Get("http://127.0.0.1:9000/health"); err == nil { launcherUp = resp.StatusCode == 200 resp.Body.Close() } userLang := os.Getenv("LANG") if userLang == "" { userLang = "en" } if settings != nil && settings.Company.Language != "" { userLang = settings.Company.Language } // Fetch all users for TopUser dropdown allUsers, _ := fetchAllUsers() type UserOption struct { Username string DisplayName string } var allUserOptions []UserOption for _, u := range allUsers { profile := loadUserSettings(u.Username) name := u.DisplayName if profile.FirstName != "" { name = profile.FirstName + " " + profile.LastName } allUserOptions = append(allUserOptions, UserOption{Username: u.Username, DisplayName: name}) } adminTmpl.Execute(w, map[string]interface{}{ "Settings": settings, "Domain": domain, "AdminEmail": adminEmail, "AllUsers": allUserOptions, "AutheliaUp": autheliaUp, "CaddyUp": caddyUp, "LauncherUp": launcherUp, "Lang": userLang, "Version": appVersion, }) case http.MethodPost: // Create user body, _ := io.ReadAll(r.Body) req, _ := http.NewRequest("POST", apiBase+"/api/users/bulk", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+apiToken) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { http.Error(w, fmt.Sprintf("API error: %v", err), http.StatusBadGateway) return } w.WriteHeader(resp.StatusCode) io.Copy(w, resp.Body) case http.MethodDelete: username := r.URL.Query().Get("username") req, _ := http.NewRequest("DELETE", apiBase+"/api/users/"+username, nil) req.Header.Set("Authorization", "Bearer "+apiToken) resp, err := http.DefaultClient.Do(req) if err != nil { http.Error(w, fmt.Sprintf("API error: %v", err), http.StatusBadGateway) return } w.WriteHeader(resp.StatusCode) io.Copy(w, resp.Body) } } 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" } // Fetch all users for manager dropdown allUsers, _ := fetchAllUsers() type UserOption struct { Username string DisplayName string } var allUserOptions []UserOption for _, u := range allUsers { profile := loadUserSettings(u.Username) name := u.DisplayName if profile.FirstName != "" { name = profile.FirstName + " " + profile.LastName } allUserOptions = append(allUserOptions, UserOption{Username: u.Username, DisplayName: name}) } tmpl := template.Must(template.New("settings").Funcs(funcMap).Parse(settingsHTML)) w.Header().Set("Content-Type", "text/html; charset=utf-8") tmpl.Execute(w, map[string]interface{}{ "User": user, "IsAdmin": isAdmin, "UserSettings": us, "AllUsers": allUserOptions, "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"), Phone: r.FormValue("phone"), Address: r.FormValue("address"), Messenger: r.FormValue("messenger"), Bio: r.FormValue("bio"), Manager: r.FormValue("manager"), JobTitle: r.FormValue("job_title"), Dept: r.FormValue("dept"), } if err := saveUserSettings(user, us); err != nil { http.Error(w, "Failed to save", http.StatusInternalServerError) return } w.Write([]byte(`{"status":"ok"}`)) } // --- People directory --- type apiUser struct { Username string `json:"username"` DisplayName string `json:"display_name"` } func fetchAllUsers() ([]apiUser, error) { apiToken := os.Getenv("AUTHELIA_SECRET") req, _ := http.NewRequest("GET", "http://authelia:8080/api/users", nil) req.Header.Set("Authorization", "Bearer "+apiToken) resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() var users []apiUser json.NewDecoder(resp.Body).Decode(&users) return users, nil } func peopleDirectoryHandler(w http.ResponseWriter, r *http.Request) { // If path is /people/{username}, show profile username := strings.TrimPrefix(r.URL.Path, "/people/") if username != "" && username != "/" && !strings.Contains(username, "/") { peopleProfileHandler(w, r, username) return } users, err := fetchAllUsers() if err != nil { http.Error(w, "Failed to fetch users", http.StatusBadGateway) return } type Person struct { Username string DisplayName string JobTitle string Dept string ProfilePic string } var people []Person for _, u := range users { us := loadUserSettings(u.Username) displayName := u.DisplayName if us.FirstName != "" { displayName = us.FirstName + " " + us.LastName } people = append(people, Person{ Username: u.Username, DisplayName: displayName, JobTitle: us.JobTitle, Dept: us.Dept, ProfilePic: us.ProfilePicture, }) } tmpl := template.Must(template.New("people").Funcs(template.FuncMap{"t": t, "initial": initials}).Parse(peopleHTML)) w.Header().Set("Content-Type", "text/html; charset=utf-8") tmpl.Execute(w, map[string]interface{}{"People": people, "Lang": "en"}) } func peopleProfileHandler(w http.ResponseWriter, r *http.Request, username string) { us := loadUserSettings(username) var managerName string if us.Manager != "" { mgr := loadUserSettings(us.Manager) if mgr.FirstName != "" { managerName = mgr.FirstName + " " + mgr.LastName } else { managerName = us.Manager } } tmpl := template.Must(template.New("profile").Funcs(template.FuncMap{"t": t, "initial": initials}).Parse(profileHTML)) w.Header().Set("Content-Type", "text/html; charset=utf-8") tmpl.Execute(w, map[string]interface{}{ "Username": username, "Profile": us, "ManagerName": managerName, "Lang": "en", }) } func initials(s string) string { parts := strings.Fields(s) if len(parts) == 0 { return "?" } var result string for _, p := range parts { if len(p) > 0 { result += strings.ToUpper(string(p[0])) } } if len(result) > 2 { result = result[:2] } return result } // --- API proxy for authelia-api --- func apiProxyHandler(w http.ResponseWriter, r *http.Request) { target, _ := url.Parse("http://authelia:8080") proxy := httputil.NewSingleHostReverseProxy(target) // Inject Bearer token for authelia-api auth token := os.Getenv("AUTHELIA_SECRET") if token != "" { r.Header.Set("Authorization", "Bearer "+token) } // Forward the full path (authelia-api expects /api/... prefix) proxy.ServeHTTP(w, r) } // --- Global settings handlers --- func globalSettingsHandler(w http.ResponseWriter, r *http.Request) { configDir := os.Getenv("CONFIG_DIR") if configDir == "" { configDir = "/opt/nextworkspace/config/nextworkspace" } settings, err := loadSettings(configDir) if err != nil { settings = &Settings{} } domain := os.Getenv("DOMAIN") adminEmail := os.Getenv("TLS_EMAIL") smtpHost := os.Getenv("SMTP_HOST") smtpPort := os.Getenv("SMTP_PORT") // Check Authelia health autheliaUp := false if resp, err := http.Get("http://authelia:9091/api/health"); err == nil { autheliaUp = resp.StatusCode == 200 resp.Body.Close() } tmpl := template.Must(template.New("global").Parse(globalSettingsHTML)) w.Header().Set("Content-Type", "text/html; charset=utf-8") tmpl.Execute(w, map[string]interface{}{ "CompanyName": settings.Company.Name, "Language": settings.Company.Language, "Timezone": settings.Company.Timezone, "SMTPHost": smtpHost, "SMTPPort": smtpPort, "SMTPUser": settings.SMTP.User, "SMTPSender": settings.SMTP.Sender, "Domain": domain, "AdminEmail": adminEmail, "AutheliaUp": autheliaUp, "Saved": r.URL.Query().Get("saved") == "ok", }) } func globalSettingsSaveHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } configDir := os.Getenv("CONFIG_DIR") if configDir == "" { configDir = "/opt/nextworkspace/config/nextworkspace" } settings, _ := loadSettings(configDir) if settings == nil { settings = &Settings{} } settings.Company.Name = r.FormValue("company_name") settings.Company.Subtitle = r.FormValue("company_subtitle") settings.Company.Logo = r.FormValue("company_logo") settings.Company.TopUser = r.FormValue("top_user") settings.Company.Language = r.FormValue("language") settings.Company.Timezone = r.FormValue("timezone") settings.SMTP.Host = r.FormValue("smtp_host") settings.SMTP.Port, _ = strconv.Atoi(r.FormValue("smtp_port")) settings.SMTP.User = r.FormValue("smtp_user") settings.SMTP.Sender = r.FormValue("smtp_sender") settings.IMAP.Host = r.FormValue("imap_host") settings.IMAP.Port, _ = strconv.Atoi(r.FormValue("imap_port")) saveSettings(configDir, settings) // Update .env with SMTP values envPath := os.Getenv("ENV_FILE") if envPath == "" { envPath = "/opt/backup/.env" } envUpdates := map[string]string{ "SMTP_HOST": r.FormValue("smtp_host"), "SMTP_PORT": r.FormValue("smtp_port"), "SMTP_USER": r.FormValue("smtp_user"), "SMTP_SENDER": r.FormValue("smtp_sender"), } if pwd := r.FormValue("smtp_password"); pwd != "" { envUpdates["SMTP_PASS"] = pwd } updateEnvFile(envPath, envUpdates) http.Redirect(w, r, "/config?s=global&saved=ok", http.StatusFound) } func applySettingsHandler(w http.ResponseWriter, r *http.Request) { // Find nextwks.sh in common locations scriptPaths := []string{ "/home/master/nextwks.sh", "/root/nextwks.sh", "/opt/backup/nextwks.sh", } script := "" for _, p := range scriptPaths { if _, err := os.Stat(p); err == nil { script = p break } } if script == "" { http.Error(w, `{"success":false,"error":"nextwks.sh not found"}`, http.StatusInternalServerError) return } cmd := exec.Command("bash", script, "--update") output, err := cmd.CombinedOutput() if err != nil { http.Error(w, fmt.Sprintf("Apply failed: %v\n%s", err, string(output)), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") 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) } // --- CSV handlers --- func csvTemplateHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/csv") w.Header().Set("Content-Disposition", "attachment; filename=users-template.csv") // BOM for Excel compatibility w.Write([]byte{0xEF, 0xBB, 0xBF}) fmt.Fprintln(w, "username,display_name,email,is_admin") fmt.Fprintln(w, "jane.doe,Jane Doe,jane@example.com,no") fmt.Fprintln(w, "john.smith,John Smith,john@example.com,yes") fmt.Fprintln(w, "# is_admin: yes = admin access, no = regular user. Leave empty for regular user.") } func csvImportHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } err := r.ParseMultipartForm(10 << 20) if err != nil { http.Error(w, "File too large", http.StatusBadRequest) return } file, _, err := r.FormFile("csv_file") if err != nil { http.Error(w, "No file uploaded", http.StatusBadRequest) return } defer file.Close() reader := csv.NewReader(file) reader.TrimLeadingSpace = true records, err := reader.ReadAll() if err != nil { http.Error(w, "Invalid CSV format", http.StatusBadRequest) return } if len(records) < 2 { http.Error(w, "CSV must have a header row and at least one data row", http.StatusBadRequest) return } type BulkUser struct { Username string `json:"username"` DisplayName string `json:"display_name"` Email string `json:"email"` Groups []string `json:"groups"` } var users []BulkUser var errors []string for i, row := range records[1:] { line := i + 2 if len(row) < 3 { errors = append(errors, fmt.Sprintf("Line %d: missing fields", line)) continue } username := strings.TrimSpace(row[0]) if username == "" || strings.HasPrefix(username, "#") { continue } isAdmin := strings.ToLower(strings.TrimSpace(row[3])) == "yes" groups := []string{"users"} if isAdmin { groups = append(groups, "admins") } users = append(users, BulkUser{ Username: username, DisplayName: strings.TrimSpace(row[1]), Email: strings.TrimSpace(row[2]), Groups: groups, }) } if len(users) == 0 { json.NewEncoder(w).Encode(map[string]interface{}{ "success": false, "error": "No valid users found in CSV", "errors": errors, }) return } body, _ := json.Marshal(map[string]interface{}{"users": users}) token := os.Getenv("AUTHELIA_SECRET") req, _ := http.NewRequest("POST", "http://authelia:8080/api/users/bulk", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { http.Error(w, "Failed to contact authelia-api", http.StatusInternalServerError) return } defer resp.Body.Close() var result interface{} json.NewDecoder(resp.Body).Decode(&result) json.NewEncoder(w).Encode(map[string]interface{}{ "api_result": result, "parse_errors": errors, }) } // --- MFA status check --- func checkMFAStatus(w http.ResponseWriter, r *http.Request) { user := r.Header.Get("Remote-User") if user == "" { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } // Attempt to call Authelia API to check TOTP status. // This requires an active user session (firstfactor completed), // so it may return 403 if called server-side without session. token := os.Getenv("AUTHELIA_SECRET") req, _ := http.NewRequest("GET", "http://authelia:9091/api/user/info", nil) req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil || resp.StatusCode != 200 { // Can't verify TOTP status server-side. Show prompt if email is set. json.NewEncoder(w).Encode(map[string]bool{"mfa_enabled": false}) return } defer resp.Body.Close() var userInfo struct { TOTP bool `json:"totp"` } json.NewDecoder(resp.Body).Decode(&userInfo) json.NewEncoder(w).Encode(map[string]bool{ "mfa_enabled": userInfo.TOTP, }) } // --- Translation system --- var translations = make(map[string]map[string]string) var ( launcherTmpl *template.Template funcMap template.FuncMap ) var adminTmpl *template.Template var appVersion = "dev" func loadTranslations(lngDir string) { langs := []string{"en", "de"} files := []string{"launcher.yaml", "admin.yaml"} for _, lang := range langs { if translations[lang] == nil { translations[lang] = make(map[string]string) } for _, file := range files { path := filepath.Join(lngDir, lang, file) data, err := os.ReadFile(path) if err != nil { continue } var keys map[string]string if err := yaml.Unmarshal(data, &keys); err != nil { continue } for k, v := range keys { translations[lang][k] = v } } } } func t(lang, key string, args ...string) string { text := translations[lang][key] if text == "" { text = translations["en"][key] } if text == "" { return key } for i := 0; i < len(args); i += 2 { text = strings.ReplaceAll(text, "{"+args[i]+"}", args[i+1]) } return text } func getUserLang(r *http.Request) string { lang := r.Header.Get("Accept-Language") if strings.HasPrefix(lang, "de") { return "de" } settings, _ := loadSettings(os.Getenv("CONFIG_DIR")) if settings != nil && settings.Company.Language == "de" { return "de" } return "en" } // --- Templates --- const landingPageHTML = `
Your Self-Hosted Workspace for Startups
NextWorkspace was developed with assistance from AI coding tools, using DeepSeek as the provider and OpenCode as the development framework.
{{.Subtitle}}
{{end}}{{.Subtitle}}
{{if .Upstream}}{{else}}General system configuration for your workspace.