Compare commits

..

4 commits

5 changed files with 144 additions and 36 deletions

View file

@ -1,5 +1,12 @@
# Changelog # 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 ## 0.1.0.0046 — 2026-07-11
### Added ### Added

View file

@ -1 +1 @@
0.1.0.0045 0.1.0.0048

View file

@ -54,7 +54,7 @@ services:
command: command:
- sh - sh
- -c - -c
- "apk add --no-cache curl sqlite3 >/dev/null 2>&1 && exec /opt/nextworkspace/nextworkspace" - "apk add --no-cache curl sqlite >/dev/null 2>&1 && exec /opt/nextworkspace/nextworkspace"
environment: environment:
- CONFIG_DIR=/opt/nextworkspace/config/nextworkspace - CONFIG_DIR=/opt/nextworkspace/config/nextworkspace
- AUTHELIA_SECRET={AUTHELIA_SECRET} - AUTHELIA_SECRET={AUTHELIA_SECRET}

View file

@ -35,6 +35,12 @@ access_control:
- "group:admins" - "group:admins"
policy: one_factor 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 # Everything else — any authenticated user
- domain: "app.{DOMAIN}" - domain: "app.{DOMAIN}"
policy: one_factor policy: one_factor
@ -65,7 +71,8 @@ storage:
notifier: notifier:
smtp: smtp:
address: "submission://{SMTP_HOST}:{SMTP_PORT}" host: "{SMTP_HOST}"
port: {SMTP_PORT}
username: "{SMTP_USER}" username: "{SMTP_USER}"
password: "{SMTP_PASS}" password: "{SMTP_PASS}"
sender: "{SMTP_USER}" sender: "{SMTP_USER}"

160
main.go
View file

@ -893,25 +893,84 @@ func enforceTOTP(w http.ResponseWriter, r *http.Request) {
return return
} }
// Update Authelia's user_preferences to require TOTP on next login // Try the new authelia-api policy endpoint first (if deployed)
dbPath := "/opt/nextworkspace/data/authelia/db.sqlite" token := os.Getenv("AUTHELIA_SECRET")
cmd := exec.Command("sqlite3", dbPath, policyBody, _ := json.Marshal(map[string]interface{}{
"INSERT OR REPLACE INTO user_preferences (id, username, method) VALUES ((SELECT id FROM user_preferences WHERE username='"+user+"'), '"+user+"', 'totp')") "name": "TOTP enforcement for " + user,
err := cmd.Run() "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")
if err != nil { apiResp, apiErr := http.DefaultClient.Do(apiReq)
json.NewEncoder(w).Encode(map[string]interface{}{ apiOk := apiErr == nil && apiResp != nil && apiResp.StatusCode == 201
"status": "error",
"error": "Failed to update preferences", if apiOk {
"totp_required": true, apiResp.Body.Close()
})
return
} }
json.NewEncoder(w).Encode(map[string]interface{}{ // Set user_preference regardless (triggers Authelia's enrollment prompt on next login)
"status": "enforced", exec.Command("sqlite3", "/opt/nextworkspace/data/authelia/db.sqlite",
"totp_required": true, "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 --- // --- Translation system ---
@ -1212,10 +1271,10 @@ const settingsHTML = `<!DOCTYPE html>
const msgDiv = document.getElementById('mfa-msg'); const msgDiv = document.getElementById('mfa-msg');
if (result.totp_required && result.status === 'enforced') { if (result.totp_required && result.status === 'enforced') {
msgDiv.style.display = 'block'; msgDiv.style.display = 'block';
msgDiv.style.background = '#fff5f5'; msgDiv.style.background = '#fffbeb';
msgDiv.style.border = '1px solid #fed7d7'; msgDiv.style.border = '1px solid #fde68a';
msgDiv.style.color = '#9b2c2c'; msgDiv.style.color = '#92400e';
msgDiv.innerHTML = '<strong>🔐 Two-Factor Required:</strong> On your next login, you will be prompted to set up an authenticator app (Google Authenticator, Authy, etc.). This is required after adding a work email.'; 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') { } else if (result.totp_required && result.status === 'error') {
msgDiv.style.display = 'block'; msgDiv.style.display = 'block';
msgDiv.style.background = '#fff5f5'; msgDiv.style.background = '#fff5f5';
@ -1693,21 +1752,28 @@ const adminHTML = `<!DOCTYPE html>
<!-- Import CSV Modal --> <!-- 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 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;"> <div style="background:#fff;border-radius:12px;padding:2rem;width:550px;max-width:90%;margin:5vh auto;position:relative;">
<h3 style="margin-bottom:1.5rem;">Import Users from CSV</h3> <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="background:#f7fafc;padding:1rem;border-radius:8px;margin-bottom:1rem;"> <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>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>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> <p style="margin:0.25rem 0;font-size:0.9rem;"><strong>3.</strong> Upload the completed file</p>
</div> </div>
<form id="csv-import-form" onsubmit="return importCSV(event)"> <form id="csv-import-form" onsubmit="return importCSV(event)">
<div class="field"><label>CSV File</label><input type="file" name="csv_file" accept=".csv" required style="width:100%;"></div> <div id="import-form-fields">
<div style="display:flex;gap:0.75rem;margin-top:1.5rem;"> <div class="field"><label>CSV File</label><input type="file" name="csv_file" accept=".csv" required style="width:100%;"></div>
<button type="submit" class="btn btn-primary">Import</button> <div style="display:flex;gap:0.75rem;margin-top:1.5rem;">
<button type="button" class="btn btn-ghost" onclick="closeImportModal()">Cancel</button> <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>
<div id="import-results" style="display:none;margin-top:1rem;"></div>
</form> </form>
<div id="import-results" style="display:none;margin-top:1rem;"></div>
</div> </div>
</div> </div>
@ -1832,6 +1898,13 @@ const adminHTML = `<!DOCTYPE html>
groups.push(cb.value); 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 // 1. Delete user
const delResp = await fetch('/api/users/' + username, { method: 'DELETE' }); const delResp = await fetch('/api/users/' + username, { method: 'DELETE' });
if (!delResp.ok) { alert('Failed to delete user for re-creation'); return; } if (!delResp.ok) { alert('Failed to delete user for re-creation'); return; }
@ -1867,7 +1940,15 @@ const adminHTML = `<!DOCTYPE html>
closeEditUserModal(); closeEditUserModal();
loadUsers(); loadUsers();
} else { } 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>'; // 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; return false;
} }
@ -1886,31 +1967,44 @@ const adminHTML = `<!DOCTYPE html>
async function importCSV(event) { async function importCSV(event) {
event.preventDefault(); 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 form = document.getElementById('csv-import-form');
const formData = new FormData(form); const formData = new FormData(form);
const resp = await fetch('/api/users/import', { method: 'POST', body: formData }); const resp = await fetch('/api/users/import', { method: 'POST', body: formData });
const result = await resp.json(); const result = await resp.json();
const resultsDiv = document.getElementById('import-results'); const resultsDiv = document.getElementById('import-results');
// Hide loading
document.getElementById('import-loading').style.display = 'none';
resultsDiv.style.display = 'block'; resultsDiv.style.display = 'block';
if (result.api_result && result.api_result.success) { if (result.api_result && result.api_result.success) {
const created = result.api_result.created || 0; 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>'; 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) { 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>'; 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 => { 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 += '<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 += '</table>';
} }
html += '<div style="margin-top:1rem;"><button class="btn btn-primary" onclick="closeImportModal(); loadUsers();">Done</button></div>';
resultsDiv.innerHTML = html; resultsDiv.innerHTML = html;
closeImportModal();
loadUsers();
} else { } else {
let html = '<div style="background:#fff5f5;color:#9b2c2c;padding:1rem;border-radius:8px;margin-bottom:0.5rem;"> Import failed: ' + (result.error || 'Unknown error') + '</div>'; 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) { 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 += '<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; resultsDiv.innerHTML = html;
} }
return false; return false;