@ -2,6 +2,7 @@ package main
import (
import (
"bytes"
"bytes"
"encoding/csv"
"encoding/json"
"encoding/json"
"fmt"
"fmt"
"html/template"
"html/template"
@ -753,6 +754,225 @@ func publicSettingsHandler(w http.ResponseWriter, r *http.Request) {
settings . Company . Name , settings . Company . Subtitle , settings . Company . Logo )
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 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
}
if checkTOTPEnrolled ( user ) {
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
"status" : "already_enrolled" ,
"totp_required" : false ,
} )
return
}
// 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 ---
// --- Translation system ---
var translations = make ( map [ string ] map [ string ] string )
var translations = make ( map [ string ] map [ string ] string )
@ -954,6 +1174,8 @@ const settingsHTML = `<!DOCTYPE html>
. btn { display : inline - flex ; align - items : center ; gap : 0.35 rem ; padding : 0.5 rem 1 rem ; border - radius : 6 px ; font - size : 0.88 rem ; font - weight : 500 ; cursor : pointer ; border : none ; }
. btn { display : inline - flex ; align - items : center ; gap : 0.35 rem ; padding : 0.5 rem 1 rem ; border - radius : 6 px ; font - size : 0.88 rem ; font - weight : 500 ; cursor : pointer ; border : none ; }
. btn - primary { background : # 1 a1a2e ; color : # fff ; }
. btn - primary { background : # 1 a1a2e ; color : # fff ; }
. btn - primary : hover { background : # 2 d3748 ; }
. btn - primary : hover { background : # 2 d3748 ; }
. btn - secondary { display : inline - block ; background : # 1 a1a2e ; color : # fff ; padding : 8 px 20 px ; border - radius : 6 px ; text - decoration : none ; margin - top : 0.5 rem ; }
. btn - secondary : hover { background : # 2 d3748 ; }
. actions { display : flex ; gap : 0.75 rem ; align - items : center ; margin - top : 1 rem ; }
. actions { display : flex ; gap : 0.75 rem ; align - items : center ; margin - top : 1 rem ; }
. saved - msg { color : # 48 bb78 ; font - size : 0.9 rem ; display : none ; }
. saved - msg { color : # 48 bb78 ; font - size : 0.9 rem ; display : none ; }
< / style >
< / style >
@ -1021,6 +1243,8 @@ const settingsHTML = `<!DOCTYPE html>
< span id = "savemsg" class = "saved-msg" > { { t . Lang "saved" } } < / span >
< span id = "savemsg" class = "saved-msg" > { { t . Lang "saved" } } < / span >
< / div >
< / div >
< / form >
< / form >
< div id = "mfa-msg" style = "display:none;margin-top:1rem;padding:1rem;border-radius:8px;" > < / div >
{ { if . IsAdmin } }
{ { if . IsAdmin } }
< div class = "card" >
< div class = "card" >
< h2 > Administration < / h2 >
< h2 > Administration < / h2 >
@ -1035,8 +1259,38 @@ const settingsHTML = `<!DOCTYPE html>
const form = document . getElementById ( ' settings - form ' ) ;
const form = document . getElementById ( ' settings - form ' ) ;
const data = new FormData ( form ) ;
const data = new FormData ( form ) ;
const resp = await fetch ( ' / settings / save ' , { method : ' POST ' , body : new URLSearchParams ( data ) } ) ;
const resp = await fetch ( ' / settings / save ' , { method : ' POST ' , body : new URLSearchParams ( data ) } ) ;
const msg = document . getElementById ( ' savemsg ' ) ;
const savemsg = document . getElementById ( ' savemsg ' ) ;
if ( resp . ok ) { msg . style . display = ' inline ' ; setTimeout ( ( ) = > msg . style . display = ' none ' , 3000 ) ; }
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 = ' 1 px solid # fde68a ' ;
msgDiv . style . color = ' # 92400 e ' ;
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 = ' 1 px solid # fed7d7 ' ;
msgDiv . style . color = ' # 9 b2c2c ' ;
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 = ' 1 px 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 ;
return false ;
}
}
< / script >
< / script >
@ -1316,6 +1570,8 @@ const adminHTML = `<!DOCTYPE html>
. btn { display : inline - flex ; align - items : center ; gap : 0.35 rem ; padding : 0.45 rem 0.9 rem ; border - radius : 6 px ; font - size : 0.85 rem ; font - weight : 500 ; cursor : pointer ; border : none ; text - decoration : none ; transition : all .12 s ; }
. btn { display : inline - flex ; align - items : center ; gap : 0.35 rem ; padding : 0.45 rem 0.9 rem ; border - radius : 6 px ; font - size : 0.85 rem ; font - weight : 500 ; cursor : pointer ; border : none ; text - decoration : none ; transition : all .12 s ; }
. btn - primary { background : # 1 a1a2e ; color : # fff ; }
. btn - primary { background : # 1 a1a2e ; color : # fff ; }
. btn - primary : hover { background : # 2 d3748 ; }
. btn - primary : hover { background : # 2 d3748 ; }
. btn - edit { background : # fff ; color : # 3182 ce ; border : 1 px solid # bee3f8 ; }
. btn - edit : hover { background : # ebf8ff ; }
. btn - danger { background : # fff ; color : # e53e3e ; border : 1 px solid # fed7d7 ; }
. btn - danger { background : # fff ; color : # e53e3e ; border : 1 px solid # fed7d7 ; }
. btn - danger : hover { background : # fff5f5 ; }
. btn - danger : hover { background : # fff5f5 ; }
. btn - ghost { background : transparent ; color : # 718096 ; border : 1 px solid # e2e8f0 ; }
. btn - ghost { background : transparent ; color : # 718096 ; border : 1 px solid # e2e8f0 ; }
@ -1341,6 +1597,8 @@ const adminHTML = `<!DOCTYPE html>
. error { background : # fed7d7 ; color : # c53030 ; padding : 0.75 rem 1 rem ; border - radius : 8 px ; margin - bottom : 1 rem ; font - size : 0.88 rem ; border : 1 px solid # feb2b2 ; }
. error { background : # fed7d7 ; color : # c53030 ; padding : 0.75 rem 1 rem ; border - radius : 8 px ; margin - bottom : 1 rem ; font - size : 0.88 rem ; border : 1 px solid # feb2b2 ; }
. password - box { background : # 1 a1a2e ; color : # 63 b3ed ; padding : 0.65 rem 1 rem ; border - radius : 6 px ; font - family : ' SF Mono ' , ' Fira Code ' , monospace ; font - size : 0.85 rem ; margin - top : 0.5 rem ; display : inline - block ; }
. password - box { background : # 1 a1a2e ; color : # 63 b3ed ; padding : 0.65 rem 1 rem ; border - radius : 6 px ; font - family : ' SF Mono ' , ' Fira Code ' , monospace ; font - size : 0.85 rem ; margin - top : 0.5 rem ; display : inline - block ; }
. hidden { display : none ; }
. hidden { display : none ; }
. btn - secondary { background : # fff ; color : # 1 a1a2e ; padding : 8 px 20 px ; border : 1 px solid # 1 a1a2e ; border - radius : 6 px ; cursor : pointer ; font - size : 0.88 rem ; }
. btn - secondary : hover { background : # f7fafc ; }
. empty - state { text - align : center ; padding : 2.5 rem 1 rem ; color : # a0aec0 ; }
. empty - state { text - align : center ; padding : 2.5 rem 1 rem ; color : # a0aec0 ; }
. empty - state . icon { font - size : 2.5 rem ; margin - bottom : 0.75 rem ; }
. empty - state . icon { font - size : 2.5 rem ; margin - bottom : 0.75 rem ; }
. empty - state p { font - size : 0.9 rem ; }
. empty - state p { font - size : 0.9 rem ; }
@ -1426,7 +1684,10 @@ const adminHTML = `<!DOCTYPE html>
< div id = "page-access" class = "page hidden" >
< div id = "page-access" class = "page hidden" >
< div class = "page-header" style = "display:flex;justify-content:space-between;align-items:center;" >
< 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 >
< 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 >
< div style = "background:#fff;border-radius:10px;border:1px solid #edf2f7;overflow:hidden;" >
< div style = "background:#fff;border-radius:10px;border:1px solid #edf2f7;overflow:hidden;" >
< table style = "width:100%;border-collapse:collapse;" >
< table style = "width:100%;border-collapse:collapse;" >
@ -1459,12 +1720,63 @@ const adminHTML = `<!DOCTYPE html>
< / div >
< / div >
< / div >
< / div >
< ! -- Edit User Modal -- >
< div id = "edit-user-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:500px;max-width:90%;margin:5vh auto;" >
< h3 style = "margin-bottom:1.5rem;" > Edit User < / h3 >
< p id = "edit-username-display" style = "font-weight:600;margin-bottom:1rem;" > < / p >
< form id = "edit-user-form" onsubmit = "return saveEditUser(event)" >
< input type = "hidden" name = "edit_username" id = "edit-username" >
< div class = "field" > < label > Email < / label > < input type = "email" name = "edit_email" id = "edit-email" required > < / div >
< div class = "field" > < label > Groups < / label >
< div class = "checkbox-group" >
< label style = "font-size:0.9rem;display:flex;align-items:center;gap:0.3rem;" > < input type = "checkbox" name = "edit_groups" value = "users" checked > User ( access to all apps ) < / label >
< label style = "font-size:0.9rem;display:flex;align-items:center;gap:0.3rem;" > < input type = "checkbox" name = "edit_groups" value = "admins" > Admin ( access to config panel ) < / label >
< / div >
< / div >
< p style = "color:#718096;font-size:0.82rem;margin:0.5rem 0;" > The user will receive a new generated password . Share it with them . < / p >
< div style = "display:flex;gap:0.75rem;margin-top:1.5rem;" >
< button type = "submit" class = "btn btn-primary" > Save < / button >
< button type = "button" class = "btn btn-ghost" onclick = "closeEditUserModal()" > Cancel < / button >
< / div >
< / form >
< div id = "edit-result" style = "display:none;margin-top:1rem;" > < / div >
< / div >
< / div >
< ! -- Security -- >
< ! -- Security -- >
< div id = "page-security" class = "page hidden" >
< div id = "page-security" class = "page hidden" >
< div class = "page-header" > < h2 > Security < / h2 > < p > Authentication policies , tokens , and session configuration . < / p > < / div >
< div class = "page-header" > < h2 > Security < / h2 > < p > Authentication policies , tokens , and session configuration . < / p > < / div >
< 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 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 >
< / 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 ( 360 deg ) } } < / 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 -- >
< ! -- Domain -- >
< div id = "page-domain" class = "page hidden" >
< div id = "page-domain" class = "page hidden" >
< div class = "page-header" > < h2 > Domain < / h2 > < p > Domain mapping , email configuration , and network settings . < / p > < / div >
< div class = "page-header" > < h2 > Domain < / h2 > < p > Domain mapping , email configuration , and network settings . < / p > < / div >
@ -1527,8 +1839,9 @@ const adminHTML = `<!DOCTYPE html>
tbody . innerHTML = users . map ( u = > {
tbody . innerHTML = users . map ( u = > {
const groups = ( u . groups || [ ] ) . map ( g = > ' < span class = "badge" > ' + esc ( g ) + ' < / span > ' ) . join ( ' ' ) ;
const groups = ( u . groups || [ ] ) . map ( g = > ' < span class = "badge" > ' + esc ( g ) + ' < / span > ' ) . join ( ' ' ) ;
const status = u . disabled ? ' < span style = "color:#e53e3e;font-weight:500;" > Disabled < / span > ' : ' < span style = "color:#38a169;font-weight:500;" > Active < / span > ' ;
const status = u . disabled ? ' < span style = "color:#e53e3e;font-weight:500;" > Disabled < / span > ' : ' < span style = "color:#38a169;font-weight:500;" > Active < / span > ' ;
const del = u . username == = ' master ' ? ' < button class = "btn btn-danger btn-sm" disabled title = "Cannot delete master" > Delete < / button > ' : ' < button class = "btn btn-danger btn-sm" onclick = "deleteUser(\'' + u.username + '\')" > Delete < / button > ' ;
const editBtn = ' < button class = "btn btn-edit btn-sm" onclick = "editUser(\'' + u.username + '\',\'' + esc(u.email||'') + '\',\'' + (u.groups||[]).join(',') + '\')" > Edit < / button > ' ;
return ' < tr > < td style = "padding:12px 16px;border-top:1px solid #edf2f7;" > < strong > ' + esc ( u . username ) + ' < / strong > < / td > < td style = "padding:12px 16px;border-top:1px solid #edf2f7;" > ' + esc ( u . display_name || ' ' ) + ' < / td > < td style = "padding:12px 16px;border-top:1px solid #edf2f7;" > ' + esc ( u . email || ' ' ) + ' < / td > < td style = "padding:12px 16px;border-top:1px solid #edf2f7;" > ' + groups + ' < / td > < td style = "padding:12px 16px;border-top:1px solid #edf2f7;" > ' + status + ' < / td > < td style = "padding:12px 16px;border-top:1px solid #edf2f7;" > ' + del + ' < / td > < / tr > ' ;
const delBtn = u . username == = ' master ' ? ' < button class = "btn btn-danger btn-sm" disabled title = "Cannot delete master" > Delete < / button > ' : ' < button class = "btn btn-danger btn-sm" onclick = "deleteUser(\'' + u.username + '\')" > Delete < / button > ' ;
return ' < tr > < td style = "padding:12px 16px;border-top:1px solid #edf2f7;" > < strong > ' + esc ( u . username ) + ' < / strong > < / td > < td style = "padding:12px 16px;border-top:1px solid #edf2f7;" > ' + esc ( u . display_name || ' ' ) + ' < / td > < td style = "padding:12px 16px;border-top:1px solid #edf2f7;" > ' + esc ( u . email || ' ' ) + ' < / td > < td style = "padding:12px 16px;border-top:1px solid #edf2f7;" > ' + groups + ' < / td > < td style = "padding:12px 16px;border-top:1px solid #edf2f7;" > ' + status + ' < / td > < td style = "padding:12px 16px;border-top:1px solid #edf2f7;" > ' + editBtn + ' ' + delBtn + ' < / td > < / tr > ' ;
} ) . join ( ' ' ) ;
} ) . join ( ' ' ) ;
}
}
@ -1560,9 +1873,143 @@ const adminHTML = `<!DOCTYPE html>
else alert ( ' Failed to delete user ' ) ;
else alert ( ' Failed to delete user ' ) ;
}
}
function editUser ( username , email , groups ) {
document . getElementById ( ' edit - username ' ) . value = username ;
document . getElementById ( ' edit - username - display ' ) . textContent = ' Editing : ' + username ;
document . getElementById ( ' edit - email ' ) . value = email ;
const groupList = groups . split ( ',' ) ;
document . querySelectorAll ( ' # edit - user - form input [ name = "edit_groups" ] ' ) . forEach ( cb = > {
cb . checked = groupList . includes ( cb . value ) ;
} ) ;
document . getElementById ( ' edit - result ' ) . style . display = ' none ' ;
document . getElementById ( ' edit - user - modal ' ) . style . display = ' block ' ;
}
function closeEditUserModal ( ) {
document . getElementById ( ' edit - user - modal ' ) . style . display = ' none ' ;
}
async function saveEditUser ( event ) {
event . preventDefault ( ) ;
const username = document . getElementById ( ' edit - username ' ) . value ;
const email = document . getElementById ( ' edit - email ' ) . value ;
const groups = [ ] ;
document . querySelectorAll ( ' # edit - user - form input [ name = "edit_groups" ] : checked ' ) . forEach ( cb = > {
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 ; }
// 2. Recreate with retry (API SQLite can be busy after delete)
const body = JSON . stringify ( {
users : [ {
username : username ,
display_name : username ,
email : email ,
groups : groups
} ]
} ) ;
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 ' ;
if ( createResp . ok && result . users && result . users [ 0 ] ) {
const pwd = result . users [ 0 ] . placeholder_password || ' ( unchanged ) ' ;
resultDiv . innerHTML = ' < div style = "padding:0.75rem 1rem;background:#f0fff4;border:1px solid #c6f6d5;border-radius:8px;color:#276749;font-size:0.88rem;" > ✅ User updated . < br > New password : < code style = "background:#edf2f7;padding:0.15rem 0.4rem;border-radius:4px;font-size:0.82rem;" > ' + pwd + ' < / code > < br > Share this with the user . < / div > ' ;
closeEditUserModal ( ) ;
loadUsers ( ) ;
} else {
// 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 ;
}
function showCreateModal ( ) { document . getElementById ( ' createModal ' ) . style . display = ' block ' ; }
function showCreateModal ( ) { document . getElementById ( ' createModal ' ) . style . display = ' block ' ; }
function closeCreateModal ( ) { document . getElementById ( ' createModal ' ) . style . display = ' none ' ; }
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 ( ) ;
if ( document . querySelector ( ' [ data - section = "access" ] . active ' ) ) loadUsers ( ) ;
< / script >
< / script >
< / body >
< / body >
@ -1616,6 +2063,9 @@ func main() {
// Public
// Public
mux . HandleFunc ( "/health" , healthHandler )
mux . HandleFunc ( "/health" , healthHandler )
mux . HandleFunc ( "/api/settings/public" , publicSettingsHandler )
mux . HandleFunc ( "/api/settings/public" , publicSettingsHandler )
mux . HandleFunc ( "/api/templates/users.csv" , csvTemplateHandler )
mux . Handle ( "/api/users/import" , authMiddleware ( csvImportHandler ) )
mux . HandleFunc ( "/api/user/enforce-totp" , authMiddleware ( enforceTOTP ) )
// Protectected: launcher
// Protectected: launcher
if companyName != "" {
if companyName != "" {