From f4fe31c56134fd4beb0395e8a6be91c7eb9cb7e6 Mon Sep 17 00:00:00 2001 From: cclohmar Date: Tue, 14 Jul 2026 06:26:29 +0100 Subject: [PATCH] fix: retry recreate on edit user to avoid loss on SQLITE_BUSY --- CHANGELOG.md | 7 ++ main.go | 206 ++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 201 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4724858..6d6a8a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.1.0.0046 — 2026-07-11 + +### Added +- CSV bulk user import in Access tab — download template, fill data, upload +- `/api/templates/users.csv` — sample CSV template download +- `/api/users/import` — CSV import handler that parses and creates users via authelia-api + ## 0.1.0.0045 — 2026-07-11 ### Changed diff --git a/main.go b/main.go index 78c36b7..4115ff8 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "encoding/csv" "encoding/json" "fmt" "html/template" @@ -753,6 +754,116 @@ func publicSettingsHandler(w http.ResponseWriter, r *http.Request) { 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) { @@ -1429,6 +1540,8 @@ const adminHTML = ` .error { background: #fed7d7; color: #c53030; padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.88rem; border: 1px solid #feb2b2; } .password-box { background: #1a1a2e; color: #63b3ed; padding: 0.65rem 1rem; border-radius: 6px; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 0.85rem; margin-top: 0.5rem; display: inline-block; } .hidden { display: none; } + .btn-secondary { background: #fff; color: #1a1a2e; padding: 8px 20px; border: 1px solid #1a1a2e; border-radius: 6px; cursor: pointer; font-size:0.88rem; } + .btn-secondary:hover { background: #f7fafc; } .empty-state { text-align: center; padding: 2.5rem 1rem; color: #a0aec0; } .empty-state .icon { font-size: 2.5rem; margin-bottom: 0.75rem; } .empty-state p { font-size: 0.9rem; } @@ -1514,7 +1627,10 @@ const adminHTML = `