Compare commits
7 commits
v0.1.0.004
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9364b4af81 | |||
| 6dedc6d432 | |||
| 4a9d5f7ede | |||
| 517c8849ec | |||
| e1a4566cc3 | |||
| f4fe31c561 | |||
| 5cff118d0c |
5 changed files with 388 additions and 78 deletions
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -1,5 +1,24 @@
|
|||
# Changelog
|
||||
|
||||
## 0.1.0.0048 — 2026-07-15
|
||||
|
||||
### Fixed
|
||||
- CSV import: loading spinner with "Importing..." message during upload
|
||||
- CSV import: better error display and proper modal close after completion
|
||||
- Admin panel: Import modal shows results and allows closing on success/failure
|
||||
|
||||
## 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
|
||||
- MFA enforcement: after saving email in settings, if TOTP is not enabled, a blocking overlay forces the user to set up two-factor on the Authelia portal before proceeding
|
||||
|
||||
## 0.1.0.0044 — 2026-07-11
|
||||
|
||||
### Added
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.1.0.0044
|
||||
0.1.0.0048
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ services:
|
|||
command:
|
||||
- sh
|
||||
- -c
|
||||
- "apk add --no-cache curl >/dev/null 2>&1 && exec /opt/nextworkspace/nextworkspace"
|
||||
- "apk add --no-cache curl sqlite >/dev/null 2>&1 && exec /opt/nextworkspace/nextworkspace"
|
||||
environment:
|
||||
- CONFIG_DIR=/opt/nextworkspace/config/nextworkspace
|
||||
- AUTHELIA_SECRET={AUTHELIA_SECRET}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,12 @@ access_control:
|
|||
- "group:admins"
|
||||
policy: one_factor
|
||||
|
||||
# Users with TFA enforcement — two-factor required
|
||||
- domain: "app.{DOMAIN}"
|
||||
subject:
|
||||
- "group:tfa_required"
|
||||
policy: two_factor
|
||||
|
||||
# Everything else — any authenticated user
|
||||
- domain: "app.{DOMAIN}"
|
||||
policy: one_factor
|
||||
|
|
@ -65,7 +71,8 @@ storage:
|
|||
|
||||
notifier:
|
||||
smtp:
|
||||
address: "submission://{SMTP_HOST}:{SMTP_PORT}"
|
||||
host: "{SMTP_HOST}"
|
||||
port: {SMTP_PORT}
|
||||
username: "{SMTP_USER}"
|
||||
password: "{SMTP_PASS}"
|
||||
sender: "{SMTP_USER}"
|
||||
|
|
|
|||
434
main.go
434
main.go
|
|
@ -2,6 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
|
|
@ -753,35 +754,223 @@ func publicSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||
settings.Company.Name, settings.Company.Subtitle, settings.Company.Logo)
|
||||
}
|
||||
|
||||
// --- MFA status check ---
|
||||
// --- CSV handlers ---
|
||||
|
||||
func checkMFAStatus(w http.ResponseWriter, r *http.Request) {
|
||||
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 enforcement ---
|
||||
|
||||
// Check if user has TOTP enrolled by querying Authelia's SQLite database directly.
|
||||
func checkTOTPEnrolled(username string) bool {
|
||||
dbPath := "/opt/nextworkspace/data/authelia/db.sqlite"
|
||||
out, err := exec.Command("sqlite3", dbPath,
|
||||
"SELECT COUNT(*) FROM totp_configurations WHERE username='"+username+"'").Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(string(out)) == "1"
|
||||
}
|
||||
|
||||
// Set user's preferred 2FA method to totp, triggering enrollment prompt on next login.
|
||||
func enforceTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
user := r.Header.Get("Remote-User")
|
||||
if user == "" {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Call Authelia API to check TOTP status
|
||||
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 {
|
||||
json.NewEncoder(w).Encode(map[string]bool{"mfa_enabled": false})
|
||||
if checkTOTPEnrolled(user) {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "already_enrolled",
|
||||
"totp_required": 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,
|
||||
// Try the new authelia-api policy endpoint first (if deployed)
|
||||
token := os.Getenv("AUTHELIA_SECRET")
|
||||
policyBody, _ := json.Marshal(map[string]interface{}{
|
||||
"name": "TOTP enforcement for " + user,
|
||||
"domain": []string{"*"},
|
||||
"subjects": []string{"user:" + user},
|
||||
"policy": "two_factor",
|
||||
})
|
||||
apiReq, _ := http.NewRequest("POST", "http://authelia:8080/api/policies", bytes.NewReader(policyBody))
|
||||
apiReq.Header.Set("Authorization", "Bearer "+token)
|
||||
apiReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
apiResp, apiErr := http.DefaultClient.Do(apiReq)
|
||||
apiOk := apiErr == nil && apiResp != nil && apiResp.StatusCode == 201
|
||||
|
||||
if apiOk {
|
||||
apiResp.Body.Close()
|
||||
}
|
||||
|
||||
// Set user_preference regardless (triggers Authelia's enrollment prompt on next login)
|
||||
exec.Command("sqlite3", "/opt/nextworkspace/data/authelia/db.sqlite",
|
||||
"INSERT OR REPLACE INTO user_preferences (username, method) VALUES ('"+user+"', 'totp')").Run()
|
||||
|
||||
// Add user to tfa_required group (enforces two_factor via access_control)
|
||||
token = os.Getenv("AUTHELIA_SECRET")
|
||||
userReq, _ := http.NewRequest("GET", "http://authelia:8080/api/users/"+user, nil)
|
||||
userReq.Header.Set("Authorization", "Bearer "+token)
|
||||
if userResp, err := http.DefaultClient.Do(userReq); err == nil && userResp.StatusCode == 200 {
|
||||
var ud struct {
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Email string `json:"email"`
|
||||
Groups []string `json:"groups"`
|
||||
}
|
||||
json.NewDecoder(userResp.Body).Decode(&ud)
|
||||
userResp.Body.Close()
|
||||
|
||||
hasTFA := false
|
||||
for _, g := range ud.Groups {
|
||||
if g == "tfa_required" {
|
||||
hasTFA = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasTFA {
|
||||
ud.Groups = append(ud.Groups, "tfa_required")
|
||||
body, _ := json.Marshal(map[string]interface{}{"users": []interface{}{ud}})
|
||||
|
||||
delR, _ := http.NewRequest("DELETE", "http://authelia:8080/api/users/"+user, nil)
|
||||
delR.Header.Set("Authorization", "Bearer "+token)
|
||||
http.DefaultClient.Do(delR)
|
||||
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
|
||||
crR, _ := http.NewRequest("POST", "http://authelia:8080/api/users/bulk", bytes.NewReader(body))
|
||||
crR.Header.Set("Authorization", "Bearer "+token)
|
||||
crR.Header.Set("Content-Type", "application/json")
|
||||
http.DefaultClient.Do(crR)
|
||||
}
|
||||
}
|
||||
|
||||
if apiOk {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "enforced",
|
||||
"totp_required": true,
|
||||
"policy_created": true,
|
||||
})
|
||||
} else {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "enforced",
|
||||
"totp_required": true,
|
||||
"policy_created": false,
|
||||
})
|
||||
}
|
||||
if apiResp != nil {
|
||||
apiResp.Body.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// --- Translation system ---
|
||||
|
|
@ -1054,21 +1243,7 @@ const settingsHTML = `<!DOCTYPE html>
|
|||
<span id="savemsg" class="saved-msg">{{t .Lang "saved"}}</span>
|
||||
</div>
|
||||
</form>
|
||||
<!-- MFA Section -->
|
||||
<div id="mfa-section" style="display:none;margin-top:2rem;padding:1.5rem;background:#f7fafc;border-radius:8px;border:1px solid #e2e8f0;">
|
||||
<h3>🔐 Two-Factor Authentication</h3>
|
||||
<div id="mfa-active" style="display:none;">
|
||||
<p style="color:#38a169;font-weight:500;">✅ Two-factor authentication is active. Your account is secure.</p>
|
||||
</div>
|
||||
<div id="mfa-setup-prompt">
|
||||
<p>You have configured a work email. For security, enable two-factor authentication with an authenticator app (Google Authenticator, Authy, etc.).</p>
|
||||
<a class="btn-secondary" href="https://auth.nextwks.eu" target="_blank">Set Up Two-Factor Now →</a>
|
||||
<p class="field-note" style="margin-top:0.5rem;">
|
||||
After setting up, click refresh to verify.
|
||||
<button class="btn-small" onclick="checkMFA()" style="padding:0.25rem 0.75rem;border:1px solid #e2e8f0;border-radius:4px;background:#fff;cursor:pointer;">Verify Setup</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mfa-msg" style="display:none;margin-top:1rem;padding:1rem;border-radius:8px;"></div>
|
||||
|
||||
{{if .IsAdmin}}
|
||||
<div class="card">
|
||||
|
|
@ -1079,40 +1254,43 @@ const settingsHTML = `<!DOCTYPE html>
|
|||
{{end}}
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
checkMFA();
|
||||
});
|
||||
|
||||
async function checkMFA() {
|
||||
const resp = await fetch('/api/user/mfa-status');
|
||||
const data = await resp.json();
|
||||
const section = document.getElementById('mfa-section');
|
||||
const prompt = document.getElementById('mfa-setup-prompt');
|
||||
const active = document.getElementById('mfa-active');
|
||||
const emailField = document.querySelector('input[name="email"]');
|
||||
|
||||
if (!emailField || !emailField.value) {
|
||||
section.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
section.style.display = 'block';
|
||||
if (data.mfa_enabled) {
|
||||
prompt.style.display = 'none';
|
||||
active.style.display = 'block';
|
||||
} else {
|
||||
prompt.style.display = 'block';
|
||||
active.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
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); }
|
||||
setTimeout(checkMFA, 1000);
|
||||
const savemsg = document.getElementById('savemsg');
|
||||
if (resp.ok) { savemsg.style.display = 'inline'; setTimeout(() => savemsg.style.display = 'none', 3000); }
|
||||
|
||||
// If user has an email, enforce TOTP enrollment
|
||||
const emailField = document.querySelector('input[name="email"]');
|
||||
if (emailField && emailField.value) {
|
||||
setTimeout(async () => {
|
||||
const enforce = await fetch('/api/user/enforce-totp');
|
||||
const result = await enforce.json();
|
||||
const msgDiv = document.getElementById('mfa-msg');
|
||||
if (result.totp_required && result.status === 'enforced') {
|
||||
msgDiv.style.display = 'block';
|
||||
msgDiv.style.background = '#fffbeb';
|
||||
msgDiv.style.border = '1px solid #fde68a';
|
||||
msgDiv.style.color = '#92400e';
|
||||
msgDiv.innerHTML = '<strong>🔐 Two-Factor Setup Required:</strong> Please visit <a href="https://auth.nextwks.eu" style="color:#3182ce;" target="_blank">the Authelia portal</a>, log in, and set up an authenticator app (Google Authenticator, Authy, etc.) under Security → Two-Factor. This is required after adding a work email.';
|
||||
} else if (result.totp_required && result.status === 'error') {
|
||||
msgDiv.style.display = 'block';
|
||||
msgDiv.style.background = '#fff5f5';
|
||||
msgDiv.style.border = '1px solid #fed7d7';
|
||||
msgDiv.style.color = '#9b2c2c';
|
||||
msgDiv.innerHTML = '<strong>⚠️ Could not enforce two-factor:</strong> ' + (result.error || 'Unknown error');
|
||||
} else if (!result.totp_required && result.status === 'already_enrolled') {
|
||||
msgDiv.style.display = 'block';
|
||||
msgDiv.style.background = '#f0fff4';
|
||||
msgDiv.style.border = '1px solid #c6f6d5';
|
||||
msgDiv.style.color = '#276749';
|
||||
msgDiv.innerHTML = '✅ Two-factor authentication is already active. Your account is secure.';
|
||||
setTimeout(() => { msgDiv.style.display = 'none'; }, 5000);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1419,6 +1597,8 @@ const adminHTML = `<!DOCTYPE html>
|
|||
.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; }
|
||||
|
|
@ -1504,7 +1684,10 @@ const adminHTML = `<!DOCTYPE html>
|
|||
<div id="page-access" class="page hidden">
|
||||
<div class="page-header" style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<div><h2>Access Management</h2><p>Manage users, groups, and authentication policies.</p></div>
|
||||
<button class="btn btn-primary" onclick="showCreateModal()">+ Add User</button>
|
||||
<div style="display:flex;gap:0.5rem;">
|
||||
<button class="btn btn-primary" onclick="showCreateModal()">+ Add User</button>
|
||||
<button class="btn btn-secondary" onclick="showImportModal()">📥 Import CSV</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:#fff;border-radius:10px;border:1px solid #edf2f7;overflow:hidden;">
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
|
|
@ -1567,6 +1750,33 @@ const adminHTML = `<!DOCTYPE html>
|
|||
<div class="card"><h3>Authentication</h3><p style="color:#718096;font-size:0.9rem;">Configured via Authelia. Policies enforced at the proxy level by Caddy.</p></div>
|
||||
</div>
|
||||
|
||||
<!-- Import CSV Modal -->
|
||||
<div id="import-csv-modal" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);z-index:1000;">
|
||||
<div style="background:#fff;border-radius:12px;padding:2rem;width:550px;max-width:90%;margin:5vh auto;position:relative;">
|
||||
<div id="import-loading" style="display:none;position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(255,255,255,0.85);border-radius:12px;z-index:10;align-items:center;justify-content:center;flex-direction:column;">
|
||||
<div style="width:40px;height:40px;border:4px solid #e2e8f0;border-top-color:#1a1a2e;border-radius:50%;animation:spin 0.8s linear infinite;margin-bottom:1rem;"></div>
|
||||
<p style="font-weight:600;color:#1a1a2e;">{{t .Lang "importing"}}</p>
|
||||
</div>
|
||||
<style>@keyframes spin{to{transform:rotate(360deg)}}</style>
|
||||
<h3 style="margin-bottom:1.5rem;">{{t .Lang "nav_access"}} — CSV Import</h3>
|
||||
<div id="import-step1" style="background:#f7fafc;padding:1rem;border-radius:8px;margin-bottom:1rem;">
|
||||
<p style="margin:0.25rem 0;font-size:0.9rem;"><strong>1.</strong> <a href="/api/templates/users.csv" download style="color:#3182ce;">Download CSV template</a></p>
|
||||
<p style="margin:0.25rem 0;font-size:0.9rem;"><strong>2.</strong> Fill in user data (Excel, LibreOffice, or text editor)</p>
|
||||
<p style="margin:0.25rem 0;font-size:0.9rem;"><strong>3.</strong> Upload the completed file</p>
|
||||
</div>
|
||||
<form id="csv-import-form" onsubmit="return importCSV(event)">
|
||||
<div id="import-form-fields">
|
||||
<div class="field"><label>CSV File</label><input type="file" name="csv_file" accept=".csv" required style="width:100%;"></div>
|
||||
<div style="display:flex;gap:0.75rem;margin-top:1.5rem;">
|
||||
<button type="submit" class="btn btn-primary" id="import-btn">{{t .Lang "import"}}</button>
|
||||
<button type="button" class="btn btn-ghost" onclick="closeImportModal()">{{t .Lang "cancel"}}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="import-results" style="display:none;margin-top:1rem;"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Domain -->
|
||||
<div id="page-domain" class="page hidden">
|
||||
<div class="page-header"><h2>Domain</h2><p>Domain mapping, email configuration, and network settings.</p></div>
|
||||
|
|
@ -1688,14 +1898,18 @@ const adminHTML = `<!DOCTYPE html>
|
|||
groups.push(cb.value);
|
||||
});
|
||||
|
||||
// Save original user data before deleting (safety net)
|
||||
let originalData = null;
|
||||
try {
|
||||
const origResp = await fetch('/api/users/' + username);
|
||||
if (origResp.ok) originalData = await origResp.json();
|
||||
} catch(e) {}
|
||||
|
||||
// 1. Delete user
|
||||
const delResp = await fetch('/api/users/' + username, { method: 'DELETE' });
|
||||
if (!delResp.ok) { alert('Failed to delete user for re-creation'); return; }
|
||||
|
||||
// Wait for SQLite to release the lock before recreating
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
|
||||
// 2. Recreate with new data
|
||||
// 2. Recreate with retry (API SQLite can be busy after delete)
|
||||
const body = JSON.stringify({
|
||||
users: [{
|
||||
username: username,
|
||||
|
|
@ -1704,13 +1918,19 @@ const adminHTML = `<!DOCTYPE html>
|
|||
groups: groups
|
||||
}]
|
||||
});
|
||||
const createResp = await fetch('/api/users/bulk', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: body
|
||||
});
|
||||
|
||||
const result = await createResp.json();
|
||||
let createResp, result;
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
createResp = await fetch('/api/users/bulk', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: body
|
||||
});
|
||||
result = await createResp.json();
|
||||
if (createResp.ok && result.users && result.users[0]) break;
|
||||
}
|
||||
|
||||
const resultDiv = document.getElementById('edit-result');
|
||||
resultDiv.style.display = 'block';
|
||||
|
||||
|
|
@ -1720,7 +1940,15 @@ const adminHTML = `<!DOCTYPE html>
|
|||
closeEditUserModal();
|
||||
loadUsers();
|
||||
} else {
|
||||
resultDiv.innerHTML = '<div style="padding:0.75rem 1rem;background:#fff5f5;border:1px solid #fed7d7;border-radius:8px;color:#c53030;font-size:0.88rem;">❌ Failed to recreate user: ' + JSON.stringify(result) + '</div>';
|
||||
// Restore original user if available
|
||||
if (originalData && originalData.username) {
|
||||
const restoreBody = JSON.stringify({users:[{username:originalData.username,display_name:originalData.display_name||originalData.username,email:originalData.email||'',groups:originalData.groups||['users']}]});
|
||||
await fetch('/api/users/bulk', {method:'POST', headers:{'Content-Type':'application/json'}, body:restoreBody});
|
||||
resultDiv.innerHTML = '<div style="padding:0.75rem 1rem;background:#fff5f5;border:1px solid #fed7d7;border-radius:8px;color:#c53030;font-size:0.88rem;">❌ Update failed. The user has been restored to their original state. Error: ' + JSON.stringify(result) + '</div>';
|
||||
loadUsers();
|
||||
} else {
|
||||
resultDiv.innerHTML = '<div style="padding:0.75rem 1rem;background:#fff5f5;border:1px solid #fed7d7;border-radius:8px;color:#c53030;font-size:0.88rem;">❌ FAILED to recreate user. The user was deleted but could not be recreated. Please manually add the user again. Error: ' + JSON.stringify(result) + '</div>';
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
@ -1728,6 +1956,60 @@ const adminHTML = `<!DOCTYPE html>
|
|||
function showCreateModal() { document.getElementById('createModal').style.display = 'block'; }
|
||||
function closeCreateModal() { document.getElementById('createModal').style.display = 'none'; }
|
||||
|
||||
function showImportModal() {
|
||||
document.getElementById('import-csv-modal').style.display = 'block';
|
||||
document.getElementById('import-results').style.display = 'none';
|
||||
}
|
||||
|
||||
function closeImportModal() {
|
||||
document.getElementById('import-csv-modal').style.display = 'none';
|
||||
}
|
||||
|
||||
async function importCSV(event) {
|
||||
event.preventDefault();
|
||||
|
||||
// Show loading spinner
|
||||
document.getElementById('import-form-fields').style.display = 'none';
|
||||
document.getElementById('import-step1').style.display = 'none';
|
||||
document.getElementById('import-loading').style.display = 'flex';
|
||||
document.querySelector('#import-csv-modal h3').textContent = 'Importing...';
|
||||
|
||||
const form = document.getElementById('csv-import-form');
|
||||
const formData = new FormData(form);
|
||||
|
||||
const resp = await fetch('/api/users/import', { method: 'POST', body: formData });
|
||||
const result = await resp.json();
|
||||
const resultsDiv = document.getElementById('import-results');
|
||||
|
||||
// Hide loading
|
||||
document.getElementById('import-loading').style.display = 'none';
|
||||
resultsDiv.style.display = 'block';
|
||||
|
||||
if (result.api_result && result.api_result.success) {
|
||||
const created = result.api_result.created || 0;
|
||||
let html = '<div style="background:#f0fff4;color:#276749;padding:1rem;border-radius:8px;margin-bottom:0.5rem;">✅ ' + created + ' users created successfully.</div>';
|
||||
if (result.api_result.users && result.api_result.users.length > 0) {
|
||||
html += '<table style="width:100%;border-collapse:collapse;"><tr style="background:#f7fafc;"><th style="padding:6px 12px;border:1px solid #e2e8f0;text-align:left;">User</th><th style="padding:6px 12px;border:1px solid #e2e8f0;text-align:left;">Password</th></tr>';
|
||||
result.api_result.users.forEach(u => {
|
||||
html += '<tr><td style="padding:6px 12px;border:1px solid #e2e8f0;">' + u.username + '</td><td style="padding:6px 12px;border:1px solid #e2e8f0;"><code style="background:#edf2f7;padding:2px 6px;border-radius:4px;font-size:0.85rem;">' + (u.placeholder_password || '—') + '</code></td></tr>';
|
||||
});
|
||||
html += '</table>';
|
||||
}
|
||||
html += '<div style="margin-top:1rem;"><button class="btn btn-primary" onclick="closeImportModal(); loadUsers();">Done</button></div>';
|
||||
resultsDiv.innerHTML = html;
|
||||
} else {
|
||||
let errorMsg = result.error || 'Unknown error';
|
||||
if (result.api_result && result.api_result.error) errorMsg = result.api_result.error;
|
||||
let html = '<div style="background:#fff5f5;color:#9b2c2c;padding:1rem;border-radius:8px;margin-bottom:0.5rem;">❌ Import failed: ' + errorMsg + '</div>';
|
||||
if (result.parse_errors && result.parse_errors.length) {
|
||||
html += '<ul style="color:#9b2c2c;font-size:0.88rem;">' + result.parse_errors.map(e => '<li>' + e + '</li>').join('') + '</ul>';
|
||||
}
|
||||
html += '<div style="margin-top:1rem;"><button class="btn btn-ghost" onclick="closeImportModal()">Close</button></div>';
|
||||
resultsDiv.innerHTML = html;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (document.querySelector('[data-section="access"].active')) loadUsers();
|
||||
</script>
|
||||
</body>
|
||||
|
|
@ -1781,7 +2063,9 @@ func main() {
|
|||
// Public
|
||||
mux.HandleFunc("/health", healthHandler)
|
||||
mux.HandleFunc("/api/settings/public", publicSettingsHandler)
|
||||
mux.HandleFunc("/api/user/mfa-status", authMiddleware(checkMFAStatus))
|
||||
mux.HandleFunc("/api/templates/users.csv", csvTemplateHandler)
|
||||
mux.Handle("/api/users/import", authMiddleware(csvImportHandler))
|
||||
mux.HandleFunc("/api/user/enforce-totp", authMiddleware(enforceTOTP))
|
||||
|
||||
// Protectected: launcher
|
||||
if companyName != "" {
|
||||
|
|
|
|||
Loading…
Reference in a new issue