2026-02-11 00:15:26 +01:00
<!DOCTYPE html>
< html lang = "en" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
2026-02-11 01:08:00 +01:00
< title > OxiCloud — Admin Panel</ title >
2026-02-11 14:09:40 +01:00
< link rel = "stylesheet" href = "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css" >
2026-02-11 00:15:26 +01:00
< style >
2026-02-11 14:09:40 +01:00
* { box-sizing : border-box ; margin : 0 ; padding : 0 ; font-family : - apple-system , BlinkMacSystemFont , 'Segoe UI' , Roboto , Oxygen , Ubuntu , Cantarell , 'Open Sans' , 'Helvetica Neue' , sans-serif }
body { background : #f5f7fa ; color : #1e293b ; min-height : 100 vh ; display : flex ; flex-direction : column }
/* ── Scrollbar ── */
:: -webkit-scrollbar { width : 8 px }
:: -webkit-scrollbar-track { background : transparent }
:: -webkit-scrollbar-thumb { background : rgba ( 0 , 0 , 0 , .15 ); border-radius : 4 px }
:: -webkit-scrollbar-thumb : hover { background : rgba ( 0 , 0 , 0 , .25 )}
* { scrollbar-width : thin ; scrollbar-color : rgba ( 0 , 0 , 0 , .15 ) transparent }
/* ── Top Header Bar ── */
. admin-header {
background : linear-gradient ( 135 deg , #2a3042 0 % , #232838 100 % );
padding : 0 32 px ; height : 64 px ; display : flex ; align-items : center ; justify-content : space-between ;
box-shadow : 0 2 px 12 px rgba ( 0 , 0 , 0 , .15 ); position : sticky ; top : 0 ; z-index : 100 ;
}
. admin-header-left { display : flex ; align-items : center ; gap : 14 px }
. admin-logo {
width : 38 px ; height : 38 px ; background : linear-gradient ( 135 deg , #ff5e3a , #ff2d55 ); border-radius : 11 px ;
display : flex ; align-items : center ; justify-content : center ;
box-shadow : 0 3 px 10 px rgba ( 255 , 94 , 58 , .35 );
}
. admin-logo svg { width : 20 px ; height : 20 px ; fill : #fff }
. admin-title { font-size : 17 px ; font-weight : 700 ; color : #fff ; letter-spacing : .3 px }
. admin-title span { color : rgba ( 255 , 255 , 255 , .5 ); font-weight : 400 ; margin-left : 6 px }
. admin-header-right a {
color : rgba ( 255 , 255 , 255 , .65 ); text-decoration : none ; font-size : 13 px ; font-weight : 500 ;
display : flex ; align-items : center ; gap : 6 px ; transition : color .2 s ;
}
. admin-header-right a : hover { color : #fff }
/* ── Container ── */
. admin-container { max-width : 1080 px ; margin : 0 auto ; padding : 28 px 24 px 60 px ; width : 100 % }
/* ── Tabs ── */
. admin-tabs { display : flex ; gap : 6 px ; margin-bottom : 28 px ; background : #fff ; border-radius : 14 px ; padding : 6 px ; box-shadow : 0 1 px 4 px rgba ( 0 , 0 , 0 , .06 )}
. admin-tab {
padding : 10 px 22 px ; cursor : pointer ; font-weight : 600 ; font-size : 13.5 px ; color : #64748b ;
border : none ; background : none ; border-radius : 10 px ; transition : all .2 s ; display : flex ; align-items : center ; gap : 8 px ;
}
. admin-tab : hover { color : #1e293b ; background : #f8fafc }
. admin-tab . active { color : #fff ; background : linear-gradient ( 135 deg , #ff5e3a , #ff2d55 ); box-shadow : 0 3 px 12 px rgba ( 255 , 94 , 58 , .25 )}
. admin-tab . active i { color : #fff }
. admin-tab i { font-size : 14 px ; width : 16 px ; text-align : center }
2026-02-11 01:08:00 +01:00
. tab-content { display : none }
. tab-content . active { display : block }
2026-02-11 14:09:40 +01:00
/* ── Cards ── */
. admin-card { background : #fff ; border-radius : 16 px ; box-shadow : 0 1 px 4 px rgba ( 0 , 0 , 0 , .06 ), 0 0 0 1 px rgba ( 0 , 0 , 0 , .03 ); padding : 28 px ; margin-bottom : 22 px }
. admin-card h2 { font-size : 16 px ; font-weight : 700 ; margin-bottom : 20 px ; color : #1e293b ; display : flex ; align-items : center ; gap : 10 px }
. admin-card h2 i { color : #ff5e3a ; font-size : 17 px }
2026-02-11 01:08:00 +01:00
2026-02-11 14:09:40 +01:00
/* ── Stats Grid ── */
. stats-grid { display : grid ; grid-template-columns : repeat ( auto - fit , minmax ( 180 px , 1 fr )); gap : 14 px ; margin-bottom : 22 px }
. stat-card {
padding : 20 px ; background : #f8fafc ; border-radius : 14 px ; text-align : center ;
border : 1 px solid #e2e8f0 ; transition : transform .15 s , box-shadow .15 s ;
}
. stat-card : hover { transform : translateY ( -2 px ); box-shadow : 0 4 px 12 px rgba ( 0 , 0 , 0 , .06 )}
. stat-value { font-size : 1.75 rem ; font-weight : 800 ; color : #1e293b }
. stat-label { font-size : 11.5 px ; color : #94a3b8 ; text-transform : uppercase ; letter-spacing : .06 em ; margin-top : 4 px ; font-weight : 600 }
2026-02-11 01:08:00 +01:00
. stat-card . warn { border-color : #fbbf24 ; background : #fffbeb }
. stat-card . danger { border-color : #ef4444 ; background : #fef2f2 }
2026-02-11 14:09:40 +01:00
. text-blue { color : #3b82f6 !important }
. text-green { color : #059669 !important }
. text-orange { color : #d97706 !important }
. text-red { color : #dc2626 !important }
/* ── Progress bar ── */
. progress-bar { width : 100 % ; height : 8 px ; background : #e2e8f0 ; border-radius : 4 px ; overflow : hidden }
. progress-fill { height : 100 % ; border-radius : 4 px ; transition : width .5 s ease }
. progress-fill . green { background : linear-gradient ( 90 deg , #059669 , #10b981 )}
. progress-fill . orange { background : linear-gradient ( 90 deg , #d97706 , #f59e0b )}
. progress-fill . red { background : linear-gradient ( 90 deg , #dc2626 , #ef4444 )}
/* ── Table ── */
. table-wrap { overflow-x : auto ; border-radius : 12 px ; border : 1 px solid #e2e8f0 }
table { width : 100 % ; border-collapse : collapse ; font-size : 13.5 px }
th { text-align : left ; padding : 12 px 16 px ; background : #f8fafc ; color : #94a3b8 ; font-size : 11 px ; text-transform : uppercase ; letter-spacing : .06 em ; font-weight : 700 ; white-space : nowrap ; border-bottom : 1 px solid #e2e8f0 }
td { padding : 14 px 16 px ; border-bottom : 1 px solid #f1f5f9 ; vertical-align : middle }
tr : last-child td { border-bottom : none }
tr : hover { background : #fafbfd }
. user-info { display : flex ; flex-direction : column ; gap : 2 px }
. user-name { font-weight : 600 ; color : #1e293b }
. user-email { font-size : 12 px ; color : #94a3b8 }
/* ── Badges ── */
. badge { display : inline-flex ; align-items : center ; gap : 4 px ; font-size : 11 px ; padding : 3 px 10 px ; border-radius : 20 px ; font-weight : 600 }
2026-02-11 01:08:00 +01:00
. badge-admin { background : #dbeafe ; color : #1d4ed8 }
2026-02-11 14:09:40 +01:00
. badge-user { background : #f1f5f9 ; color : #64748b }
2026-02-11 01:08:00 +01:00
. badge-active { background : #d1fae5 ; color : #065f46 }
. badge-inactive { background : #fee2e2 ; color : #991b1b }
. badge-oidc { background : #ede9fe ; color : #6d28d9 }
2026-02-11 14:09:40 +01:00
. badge-env { background : #fef3c7 ; color : #92400e ; font-size : 10 px ; padding : 1 px 6 px ; margin-left : 4 px }
/* ── Buttons ── */
. btn {
padding : 8 px 18 px ; border : none ; border-radius : 10 px ; font-size : 13 px ; font-weight : 600 ;
cursor : pointer ; transition : all .15 s ; white-space : nowrap ; display : inline-flex ; align-items : center ; gap : 6 px ;
}
. btn-sm { padding : 6 px 12 px ; font-size : 12 px ; border-radius : 8 px }
. btn-primary { background : linear-gradient ( 135 deg , #ff5e3a , #ff2d55 ); color : #fff ; box-shadow : 0 2 px 8 px rgba ( 255 , 94 , 58 , .25 )}
. btn-primary : hover { box-shadow : 0 4 px 14 px rgba ( 255 , 94 , 58 , .35 ); transform : translateY ( -1 px )}
. btn-secondary { background : #fff ; color : #334155 ; border : 1 px solid #e2e8f0 }
. btn-secondary : hover { background : #f8fafc ; border-color : #cbd5e1 }
. btn-danger { background : #fef2f2 ; color : #991b1b ; border : 1 px solid #fecaca }
. btn-danger : hover { background : #fee2e2 }
. btn-success { background : #ecfdf5 ; color : #065f46 ; border : 1 px solid #a7f3d0 }
. btn-success : hover { background : #d1fae5 }
. btn : disabled { opacity : .4 ; cursor : not-allowed ; transform : none !important }
2026-02-11 01:08:00 +01:00
. actions-row { display : flex ; gap : 6 px ; flex-wrap : wrap }
2026-02-11 14:09:40 +01:00
/* ── Forms ── */
. form-group { margin-bottom : 16 px }
. form-group label { display : block ; font-size : 13 px ; font-weight : 600 ; margin-bottom : 4 px ; color : #334155 }
2026-02-11 01:08:00 +01:00
. form-group input [ type = "text" ], . form-group input [ type = "password" ], . form-group input [ type = "url" ], . form-group input [ type = "number" ], . form-group select {
2026-02-11 14:09:40 +01:00
width : 100 % ; padding : 10 px 14 px ; border : 2 px solid #e2e8f0 ; border-radius : 10 px ; font-size : 14 px ;
background : #f8fafc ; transition : all .2 s ; font-family : inherit ; color : #1e293b ;
}
. form-group input : focus , . form-group select : focus { outline : none ; border-color : #ff5e3a ; background : #fff ; box-shadow : 0 0 0 3 px rgba ( 255 , 94 , 58 , .1 )}
. form-group small { color : #94a3b8 ; font-size : 12 px ; display : block ; margin-top : 3 px }
. toggle-row { display : flex ; align-items : center ; justify-content : space-between ; padding : 10 px 0 }
. toggle-row label { font-size : 14 px ; font-weight : 500 ; color : #334155 }
. switch { position : relative ; width : 44 px ; height : 24 px ; flex-shrink : 0 }
2026-02-11 01:08:00 +01:00
. switch input { opacity : 0 ; width : 0 ; height : 0 }
2026-02-11 14:09:40 +01:00
. slider { position : absolute ; cursor : pointer ; inset : 0 ; background : #d1d5db ; border-radius : 24 px ; transition : .3 s }
. slider : before { content : "" ; position : absolute ; height : 18 px ; width : 18 px ; left : 3 px ; bottom : 3 px ; background : #fff ; border-radius : 50 % ; transition : .3 s ; box-shadow : 0 1 px 3 px rgba ( 0 , 0 , 0 , .15 )}
. switch input : checked + . slider { background : linear-gradient ( 135 deg , #ff5e3a , #ff2d55 )}
. switch input : checked + . slider : before { transform : translateX ( 20 px )}
. readonly-field {
display : flex ; align-items : center ; gap : 8 px ; background : #f8fafc ; border : 2 px solid #e2e8f0 ; border-radius : 10 px ;
padding : 10 px 14 px ; font-family : 'SF Mono' , Monaco , Consolas , monospace ; font-size : 13 px ; word-break : break-all ; color : #334155 ;
}
. readonly-field button {
flex-shrink : 0 ; padding : 6 px 10 px ; border : 1 px solid #e2e8f0 ; border-radius : 8 px ; background : #fff ; cursor : pointer ;
font-size : 12 px ; transition : all .15 s ; color : #64748b ;
}
. readonly-field button : hover { background : #f1f5f9 ; color : #334155 }
/* ── Alerts ── */
. alert { padding : 12 px 16 px ; border-radius : 10 px ; font-size : 13 px ; margin-top : 14 px ; display : none ; font-weight : 500 }
2026-02-11 00:15:26 +01:00
. alert-success { background : #ecfdf5 ; color : #065f46 ; border : 1 px solid #a7f3d0 ; display : block }
. alert-error { background : #fef2f2 ; color : #991b1b ; border : 1 px solid #fecaca ; display : block }
. alert-info { background : #eff6ff ; color : #1e40af ; border : 1 px solid #bfdbfe ; display : block }
2026-02-11 14:09:40 +01:00
. warning { background : #fffbeb ; border : 1 px solid #fde68a ; border-radius : 10 px ; padding : 10 px 14 px ; font-size : 13 px ; color : #92400e ; margin-top : 8 px ; display : flex ; align-items : center ; gap : 6 px ; font-weight : 500 }
2026-02-11 01:08:00 +01:00
2026-02-11 14:09:40 +01:00
/* ── Discovery ── */
. discovery-result { margin : 12 px 0 ; padding : 14 px ; border-radius : 10 px ; font-size : 13 px }
2026-02-11 00:15:26 +01:00
. discovery-result . ok { background : #ecfdf5 ; border : 1 px solid #a7f3d0 ; color : #065f46 }
. discovery-result . fail { background : #fef2f2 ; border : 1 px solid #fecaca ; color : #991b1b }
2026-02-11 14:09:40 +01:00
. discovery-result dt { font-weight : 700 ; margin-top : 6 px }
2026-02-11 00:15:26 +01:00
. discovery-result dd { margin-left : 0 ; word-break : break-all }
2026-02-11 01:08:00 +01:00
2026-02-11 14:09:40 +01:00
/* ── Details ── */
details { margin-top : 14 px ; border-top : 1 px solid #e2e8f0 ; padding-top : 12 px }
details summary { cursor : pointer ; font-weight : 600 ; font-size : 14 px ; color : #64748b ; padding : 6 px 0 ; user-select : none ; transition : color .2 s }
details summary : hover { color : #ff5e3a }
details [ open ] summary { margin-bottom : 14 px ; color : #ff5e3a }
/* ── Modal ── */
. modal-overlay { position : fixed ; inset : 0 ; background : rgba ( 0 , 0 , 0 , .45 ); backdrop-filter : blur ( 4 px ); display : flex ; align-items : center ; justify-content : center ; z-index : 1000 }
. modal { background : #fff ; border-radius : 20 px ; padding : 28 px ; width : 420 px ; max-width : 90 vw ; box-shadow : 0 20 px 60 px rgba ( 0 , 0 , 0 , .2 ); animation : modalIn .2 s ease-out }
@ keyframes modalIn { from { opacity : 0 ; transform : scale ( .95 ) translateY ( 10 px )} to { opacity : 1 ; transform : scale ( 1 ) translateY ( 0 )}}
. modal h3 { margin-bottom : 18 px ; font-size : 17 px ; color : #1e293b ; display : flex ; align-items : center }
. modal-actions { display : flex ; gap : 8 px ; justify-content : flex-end ; margin-top : 18 px }
/* ── Quota bar inline ── */
. quota-bar { display : flex ; align-items : center ; gap : 10 px }
2026-02-11 01:08:00 +01:00
. quota-bar . progress-bar { flex : 1 ; height : 6 px }
2026-02-11 14:09:40 +01:00
. quota-text { font-size : 12 px ; color : #94a3b8 ; white-space : nowrap }
/* ── Access / Loading ── */
# access-denied { display : none ; text-align : center ; padding : 80 px 20 px }
# access-denied . access-icon { width : 80 px ; height : 80 px ; background : #fef2f2 ; border-radius : 50 % ; display : flex ; align-items : center ; justify-content : center ; margin : 0 auto 20 px }
# access-denied . access-icon i { font-size : 32 px ; color : #ef4444 }
# access-denied h2 { color : #991b1b ; margin-bottom : 8 px ; font-size : 20 px }
# access-denied p { color : #64748b ; margin-bottom : 20 px ; font-size : 14 px }
# access-denied a { display : inline-flex ; align-items : center ; gap : 6 px ; padding : 10 px 24 px ; background : linear-gradient ( 135 deg , #ff5e3a , #ff2d55 ); color : #fff ; text-decoration : none ; border-radius : 10 px ; font-weight : 600 ; font-size : 14 px ; box-shadow : 0 3 px 12 px rgba ( 255 , 94 , 58 , .3 ); transition : all .2 s }
# access-denied a : hover { transform : translateY ( -1 px ); box-shadow : 0 5 px 18 px rgba ( 255 , 94 , 58 , .4 )}
# loading { text-align : center ; padding : 80 px ; color : #94a3b8 ; font-size : 15 px }
# loading i { font-size : 32 px ; color : #ff5e3a ; display : block ; margin-bottom : 12 px ; animation : spin 1 s linear infinite }
@ keyframes spin { to { transform : rotate ( 360 deg )}}
/* ── Pagination ── */
. pagination { display : flex ; align-items : center ; justify-content : space-between ; margin-top : 14 px ; font-size : 13 px ; color : #94a3b8 ; padding : 0 4 px }
. pagination button { padding : 6 px 14 px }
2026-02-11 00:15:26 +01:00
</ style >
</ head >
< body >
2026-02-11 14:09:40 +01:00
<!-- Header -->
< div class = "admin-header" >
< div class = "admin-header-left" >
2026-02-13 22:37:31 +01:00
< a href = "/" class = "admin-logo" style = "text-decoration:none" >
2026-02-11 14:09:40 +01:00
< svg viewBox = "0 0 500 500" >
< path d = "M345 310c32 0 58-26 58-58s-26-58-58-58c-6.2 0-12 0.9-17.5 2.7C318 166 289 143 255 143c-34.3 0-63.1 22.6-73 53.7C176.9 195.7 171 195 165 195c-32 0-58 26-58 58s26 58 58 58h180z" />
</ svg >
2026-02-13 22:37:31 +01:00
</ a >
2026-02-11 14:09:40 +01:00
< div class = "admin-title" > OxiCloud < span > · Admin</ span ></ div >
</ div >
< div class = "admin-header-right" >
< a href = "/" >< i class = "fas fa-arrow-left" ></ i > Back to OxiCloud</ a >
</ div >
</ div >
< div class = "admin-container" >
< div id = "loading" >< i class = "fas fa-circle-notch" ></ i > Loading…</ div >
< div id = "access-denied" >
< div class = "access-icon" >< i class = "fas fa-lock" ></ i ></ div >
< h2 > Access Denied</ h2 >
< p > Administrator privileges required to access this panel.</ p >
2026-02-13 22:44:20 +01:00
< a href = "/login" >< i class = "fas fa-sign-in-alt" ></ i > Sign in</ a >
2026-02-11 14:09:40 +01:00
</ div >
2026-02-11 00:15:26 +01:00
< div id = "main-content" style = "display:none" >
2026-02-11 01:08:00 +01:00
<!-- Tabs -->
2026-02-11 14:09:40 +01:00
< div class = "admin-tabs" >
< button class = "admin-tab active" onclick = "switchTab('dashboard',this)" >< i class = "fas fa-chart-pie" ></ i > Dashboard</ button >
< button class = "admin-tab" onclick = "switchTab('users',this)" >< i class = "fas fa-users" ></ i > Users</ button >
< button class = "admin-tab" onclick = "switchTab('oidc',this)" >< i class = "fas fa-key" ></ i > SSO / OIDC</ button >
2026-02-11 01:08:00 +01:00
</ div >
2026-02-11 00:15:26 +01:00
2026-02-11 01:08:00 +01:00
<!-- ======== DASHBOARD TAB ======== -->
< div id = "tab-dashboard" class = "tab-content active" >
2026-02-11 14:09:40 +01:00
< div class = "stats-grid" >
2026-02-11 01:08:00 +01:00
< div class = "stat-card" >< div class = "stat-value text-blue" id = "ds-total-users" > —</ div >< div class = "stat-label" > Total Users</ div ></ div >
< div class = "stat-card" >< div class = "stat-value text-green" id = "ds-active-users" > —</ div >< div class = "stat-label" > Active Users</ div ></ div >
< div class = "stat-card" >< div class = "stat-value text-blue" id = "ds-admin-users" > —</ div >< div class = "stat-label" > Admins</ div ></ div >
< div class = "stat-card" >< div class = "stat-value" id = "ds-version" > —</ div >< div class = "stat-label" > Version</ div ></ div >
2026-02-11 00:15:26 +01:00
</ div >
2026-02-11 14:09:40 +01:00
< div class = "admin-card" >
< h2 >< i class = "fas fa-hdd" ></ i > Storage Overview</ h2 >
2026-02-11 01:08:00 +01:00
< div class = "stats-grid" >
< div class = "stat-card" >< div class = "stat-value" id = "ds-used" > —</ div >< div class = "stat-label" > Used</ div ></ div >
< div class = "stat-card" >< div class = "stat-value" id = "ds-quota" > —</ div >< div class = "stat-label" > Total Quota</ div ></ div >
< div class = "stat-card" >< div class = "stat-value" id = "ds-usage-pct" > —</ div >< div class = "stat-label" > Usage %</ div ></ div >
2026-02-11 00:15:26 +01:00
</ div >
2026-02-11 01:08:00 +01:00
< div class = "progress-bar" >< div class = "progress-fill green" id = "ds-bar" style = "width:0%" ></ div ></ div >
2026-02-11 14:09:40 +01:00
< div style = "margin-top:14px" >
2026-02-11 01:08:00 +01:00
< div class = "stats-grid" >
2026-02-11 14:09:40 +01:00
< div class = "stat-card warn" id = "ds-warn-card" style = "display:none" >< div class = "stat-value text-orange" id = "ds-over80" > 0</ div >< div class = "stat-label" > Users > 80% quota</ div ></ div >
2026-02-11 01:08:00 +01:00
< div class = "stat-card danger" id = "ds-danger-card" style = "display:none" >< div class = "stat-value text-red" id = "ds-overquota" > 0</ div >< div class = "stat-label" > Users over quota</ div ></ div >
</ div >
2026-02-11 00:15:26 +01:00
</ div >
2026-02-11 01:08:00 +01:00
</ div >
2026-02-11 00:15:26 +01:00
2026-02-11 14:09:40 +01:00
< div class = "admin-card" >
< h2 >< i class = "fas fa-server" ></ i > System</ h2 >
2026-02-11 01:08:00 +01:00
< div class = "stats-grid" >
< div class = "stat-card" >< div class = "stat-value" id = "ds-auth" > —</ div >< div class = "stat-label" > Auth</ div ></ div >
< div class = "stat-card" >< div class = "stat-value" id = "ds-oidc" > —</ div >< div class = "stat-label" > OIDC</ div ></ div >
< div class = "stat-card" >< div class = "stat-value" id = "ds-quotas-flag" > —</ div >< div class = "stat-label" > Quotas</ div ></ div >
2026-02-11 00:15:26 +01:00
</ div >
2026-02-13 16:46:59 +01:00
< div class = "toggle-row" style = "margin-top:10px;padding:12px 0;border-top:1px solid #e2e8f0" >
< label >< i class = "fas fa-user-plus" style = "color:#64748b;margin-right:6px" ></ i > Allow public self-registration</ label >
< label class = "switch" >< input type = "checkbox" id = "ds-registration" checked onchange = "toggleRegistration(this.checked)" >< span class = "slider" ></ span ></ label >
</ div >
< div class = "warning" id = "registration-warning" style = "display:none" >< i class = "fas fa-exclamation-triangle" ></ i > Public registration is disabled. Only admins can create new users.</ div >
2026-02-11 01:08:00 +01:00
</ div >
</ div >
2026-02-11 00:15:26 +01:00
2026-02-11 01:08:00 +01:00
<!-- ======== USERS TAB ======== -->
< div id = "tab-users" class = "tab-content" >
2026-02-11 14:09:40 +01:00
< div class = "admin-card" >
2026-02-13 16:46:59 +01:00
< h2 style = "justify-content:space-between" >< span >< i class = "fas fa-users-cog" ></ i > User Management</ span >< button class = "btn btn-primary" onclick = "openCreateUserModal()" >< i class = "fas fa-user-plus" ></ i > Create User</ button ></ h2 >
2026-02-11 01:08:00 +01:00
< div class = "table-wrap" >
< table >
< thead >
< tr >
< th > User</ th >
< th > Role</ th >
< th > Status</ th >
< th > Storage</ th >
< th > Last Login</ th >
< th > Actions</ th >
</ tr >
</ thead >
2026-02-11 14:09:40 +01:00
< tbody id = "users-tbody" >< tr >< td colspan = "6" style = "text-align:center;padding:28px;color:#94a3b8" >< i class = "fas fa-spinner fa-spin" ></ i > Loading users…</ td ></ tr ></ tbody >
2026-02-11 01:08:00 +01:00
</ table >
2026-02-11 00:15:26 +01:00
</ div >
2026-02-11 01:08:00 +01:00
< div class = "pagination" >
< span id = "users-info" > —</ span >
2026-02-11 14:09:40 +01:00
< div style = "display:flex;gap:6px" >
< button class = "btn btn-sm btn-secondary" id = "prev-btn" onclick = "prevPage()" disabled >< i class = "fas fa-chevron-left" ></ i > Prev</ button >
< button class = "btn btn-sm btn-secondary" id = "next-btn" onclick = "nextPage()" > Next < i class = "fas fa-chevron-right" ></ i ></ button >
2026-02-11 00:15:26 +01:00
</ div >
</ div >
2026-02-11 01:08:00 +01:00
</ div >
</ div >
2026-02-11 00:15:26 +01:00
2026-02-11 01:08:00 +01:00
<!-- ======== OIDC TAB ======== -->
< div id = "tab-oidc" class = "tab-content" >
2026-02-11 14:09:40 +01:00
< div class = "admin-card" >
< h2 >< i class = "fas fa-shield-alt" ></ i > Single Sign-On (OIDC / SSO)</ h2 >
2026-02-11 01:08:00 +01:00
< div class = "toggle-row" >
< label > Enable SSO Authentication</ label >
< label class = "switch" >< input type = "checkbox" id = "oidc-enabled" >< span class = "slider" ></ span ></ label >
</ div >
< div id = "oidc-form" style = "display:none" >
2026-02-11 00:15:26 +01:00
< div class = "form-group" >
2026-02-11 01:08:00 +01:00
< label > Provider Name < span id = "badge-provider_name" ></ span ></ label >
< input type = "text" id = "provider-name" placeholder = "e.g., Authentik, Keycloak" >
2026-02-11 00:15:26 +01:00
</ div >
2026-02-11 01:08:00 +01:00
< div class = "form-group" >
< label > Issuer URL < span id = "badge-issuer_url" ></ span ></ label >
< input type = "url" id = "issuer-url" placeholder = "https://auth.example.com/application/o/oxicloud/" >
< small > OpenID Connect issuer URL of your identity provider</ small >
2026-02-11 00:15:26 +01:00
</ div >
2026-02-11 14:09:40 +01:00
< div style = "margin-bottom:12px" >
< button class = "btn btn-secondary btn-sm" id = "discover-btn" onclick = "testConnection()" >< i class = "fas fa-search" ></ i > Auto-discover</ button >
2026-02-11 01:08:00 +01:00
</ div >
< div id = "discovery-result" ></ div >
2026-02-11 00:15:26 +01:00
< div class = "form-group" >
2026-02-11 01:08:00 +01:00
< label > Client ID < span id = "badge-client_id" ></ span ></ label >
< input type = "text" id = "client-id" placeholder = "oxicloud" >
2026-02-11 00:15:26 +01:00
</ div >
2026-02-11 01:08:00 +01:00
< div class = "form-group" >
< label > Client Secret < span id = "badge-client_secret" ></ span ></ label >
< input type = "password" id = "client-secret" placeholder = "Leave empty to keep current value" >
2026-02-11 14:09:40 +01:00
< small id = "secret-hint" style = "display:none" >< i class = "fas fa-check-circle" style = "color:#059669" ></ i > A client secret is already configured</ small >
2026-02-11 00:15:26 +01:00
</ div >
2026-02-11 01:08:00 +01:00
< div class = "form-group" >
2026-02-11 14:09:40 +01:00
< label > Callback URL < small style = "font-weight:400;color:#94a3b8" > (register in your IdP)</ small ></ label >
< div class = "readonly-field" >< span id = "callback-url" > —</ span >< button onclick = "copyCallback()" title = "Copy" >< i class = "fas fa-copy" ></ i ></ button ></ div >
2026-02-11 00:15:26 +01:00
</ div >
2026-02-11 01:08:00 +01:00
< details >
2026-02-11 14:09:40 +01:00
< summary >< i class = "fas fa-sliders-h" style = "margin-right:6px" ></ i > Advanced Settings</ summary >
2026-02-11 01:08:00 +01:00
< div class = "form-group" >
< label > Scopes < span id = "badge-scopes" ></ span ></ label >
< input type = "text" id = "scopes" placeholder = "openid profile email" >
</ div >
< div class = "toggle-row" >
< label > Auto-provision users on first login</ label >
< label class = "switch" >< input type = "checkbox" id = "auto-provision" checked >< span class = "slider" ></ span ></ label >
</ div >
< div class = "form-group" >
< label > Admin Groups < span id = "badge-admin_groups" ></ span ></ label >
< input type = "text" id = "admin-groups" placeholder = "e.g., oxicloud-admins" >
< small > Comma-separated OIDC group names that map to admin role</ small >
</ div >
< div class = "toggle-row" >
< label > Disable password login (OIDC only)</ label >
< label class = "switch" >< input type = "checkbox" id = "disable-password" >< span class = "slider" ></ span ></ label >
</ div >
2026-02-11 14:09:40 +01:00
< div class = "warning" id = "password-warning" style = "display:none" >< i class = "fas fa-exclamation-triangle" ></ i > This will prevent ALL password-based logins!</ div >
2026-02-11 01:08:00 +01:00
</ details >
2026-02-11 14:09:40 +01:00
< div style = "display:flex;gap:10px;margin-top:24px;justify-content:flex-end" >
< button class = "btn btn-secondary" onclick = "testConnection()" >< i class = "fas fa-vial" ></ i > Test</ button >
< button class = "btn btn-primary" id = "save-btn" onclick = "saveOidcSettings()" >< i class = "fas fa-save" ></ i > Save</ button >
2026-02-11 01:08:00 +01:00
</ div >
< div id = "oidc-status" class = "alert" ></ div >
2026-02-11 00:15:26 +01:00
</ div >
</ div >
</ div >
2026-02-11 01:08:00 +01:00
</ div >
</ div >
2026-02-11 00:15:26 +01:00
2026-02-11 01:08:00 +01:00
<!-- Quota Modal -->
< div id = "quota-modal" class = "modal-overlay" style = "display:none" >
< div class = "modal" >
2026-02-11 14:09:40 +01:00
< h3 >< i class = "fas fa-box" style = "color:#ff5e3a;margin-right:8px" ></ i > Update Storage Quota</ h3 >
2026-02-11 01:08:00 +01:00
< div class = "form-group" >
< label > User: < strong id = "qm-username" ></ strong ></ label >
</ div >
< div class = "form-group" >
< label > New Quota</ label >
2026-02-11 14:09:40 +01:00
< div style = "display:flex;gap:10px;align-items:center" >
2026-02-11 01:08:00 +01:00
< input type = "number" id = "qm-value" min = "0" step = "1" style = "flex:1" >
2026-02-11 14:09:40 +01:00
< select id = "qm-unit" style = "width:90px;padding:10px 14px;border:2px solid #e2e8f0;border-radius:10px;font-size:14px;background:#f8fafc" >< option value = "1073741824" > GB</ option >< option value = "1048576" > MB</ option >< option value = "1099511627776" > TB</ option ></ select >
2026-02-11 00:15:26 +01:00
</ div >
2026-02-11 01:08:00 +01:00
< small > Set to 0 for unlimited</ small >
</ div >
< div class = "modal-actions" >
< button class = "btn btn-secondary" onclick = "closeQuotaModal()" > Cancel</ button >
2026-02-11 14:09:40 +01:00
< button class = "btn btn-primary" onclick = "saveQuota()" >< i class = "fas fa-save" ></ i > Save</ button >
2026-02-11 00:15:26 +01:00
</ div >
</ div >
</ div >
2026-02-13 16:46:59 +01:00
<!-- Create User Modal -->
< div id = "create-user-modal" class = "modal-overlay" style = "display:none" >
< div class = "modal" >
< h3 >< i class = "fas fa-user-plus" style = "color:#ff5e3a;margin-right:8px" ></ i > Create New User</ h3 >
< div class = "form-group" >
< label > Username *</ label >
< input type = "text" id = "cu-username" placeholder = "johndoe" minlength = "3" maxlength = "32" >
< small > 3– 32 characters</ small >
</ div >
< div class = "form-group" >
< label > Password *</ label >
< input type = "password" id = "cu-password" placeholder = "Min 8 characters" minlength = "8" >
</ div >
< div class = "form-group" >
< label > Email < small style = "font-weight:400;color:#94a3b8" > (optional)</ small ></ label >
< input type = "text" id = "cu-email" placeholder = "user@example.com (auto-generated if empty)" >
</ div >
< div style = "display:flex;gap:14px" >
< div class = "form-group" style = "flex:1" >
< label > Role</ label >
< select id = "cu-role" style = "width:100%;padding:10px 14px;border:2px solid #e2e8f0;border-radius:10px;font-size:14px;background:#f8fafc" >
< option value = "user" > User</ option >
< option value = "admin" > Admin</ option >
</ select >
</ div >
< div class = "form-group" style = "flex:1" >
< label > Quota</ label >
< div style = "display:flex;gap:6px" >
< input type = "number" id = "cu-quota-value" min = "0" step = "1" value = "1" style = "flex:1" >
< select id = "cu-quota-unit" style = "width:70px;padding:10px 8px;border:2px solid #e2e8f0;border-radius:10px;font-size:13px;background:#f8fafc" >< option value = "1073741824" > GB</ option >< option value = "1048576" > MB</ option >< option value = "1099511627776" > TB</ option ></ select >
</ div >
</ div >
</ div >
< div id = "cu-error" class = "alert" style = "margin-top:0" ></ div >
< div class = "modal-actions" >
< button class = "btn btn-secondary" onclick = "closeCreateUserModal()" > Cancel</ button >
< button class = "btn btn-primary" id = "cu-submit" onclick = "submitCreateUser()" >< i class = "fas fa-user-plus" ></ i > Create</ button >
</ div >
</ div >
</ div >
<!-- Reset Password Modal -->
< div id = "reset-pw-modal" class = "modal-overlay" style = "display:none" >
< div class = "modal" >
< h3 >< i class = "fas fa-key" style = "color:#ff5e3a;margin-right:8px" ></ i > Reset Password</ h3 >
< div class = "form-group" >
< label > User: < strong id = "rp-username" ></ strong ></ label >
</ div >
< div class = "form-group" >
< label > New Password</ label >
< input type = "password" id = "rp-password" placeholder = "Min 8 characters" minlength = "8" >
</ div >
< div id = "rp-error" class = "alert" style = "margin-top:0" ></ div >
< div class = "modal-actions" >
< button class = "btn btn-secondary" onclick = "closeResetPasswordModal()" > Cancel</ button >
< button class = "btn btn-primary" id = "rp-submit" onclick = "submitResetPassword()" >< i class = "fas fa-save" ></ i > Reset</ button >
</ div >
</ div >
</ div >
2026-02-11 00:15:26 +01:00
< script >
const API = '/api' ;
2026-02-11 14:09:40 +01:00
const token = localStorage . getItem ( 'oxicloud_token' ) || localStorage . getItem ( 'token' ) || localStorage . getItem ( 'access_token' );
2026-02-11 01:08:00 +01:00
let currentAdminId = '' ;
let usersPage = 0 ;
const PAGE_SIZE = 50 ;
let totalUsers = 0 ;
2026-02-11 00:15:26 +01:00
2026-02-11 01:08:00 +01:00
function headers () {
return { 'Authorization' : 'Bearer ' + token , 'Content-Type' : 'application/json' };
}
function formatBytes ( bytes ) {
if ( bytes === 0 ) return '0 B' ;
const k = 1024 , sizes = [ 'B' , 'KB' , 'MB' , 'GB' , 'TB' ];
const i = Math . floor ( Math . log ( bytes ) / Math . log ( k ));
return parseFloat (( bytes / Math . pow ( k , i )). toFixed ( 1 )) + ' ' + sizes [ i ];
}
function timeAgo ( dateStr ) {
if ( ! dateStr ) return 'Never' ;
const d = new Date ( dateStr );
const now = new Date ();
const secs = Math . floor (( now - d ) / 1000 );
if ( secs < 60 ) return 'Just now' ;
if ( secs < 3600 ) return Math . floor ( secs / 60 ) + 'm ago' ;
if ( secs < 86400 ) return Math . floor ( secs / 3600 ) + 'h ago' ;
if ( secs < 2592000 ) return Math . floor ( secs / 86400 ) + 'd ago' ;
return d . toLocaleDateString ();
}
// ── Tab switching ──
2026-02-11 14:09:40 +01:00
function switchTab ( name , el ) {
document . querySelectorAll ( '.admin-tab' ). forEach ( t => t . classList . remove ( 'active' ));
2026-02-11 01:08:00 +01:00
document . querySelectorAll ( '.tab-content' ). forEach ( t => t . classList . remove ( 'active' ));
document . getElementById ( 'tab-' + name ). classList . add ( 'active' );
2026-02-11 14:09:40 +01:00
if ( el ) el . classList . add ( 'active' );
2026-02-11 01:08:00 +01:00
if ( name === 'users' ) loadUsers ();
if ( name === 'dashboard' ) loadDashboard ();
}
// ── Dashboard ──
async function loadDashboard () {
try {
const resp = await fetch ( API + '/admin/dashboard' , { headers : headers () });
if ( ! resp . ok ) return ;
const d = await resp . json ();
document . getElementById ( 'ds-total-users' ). textContent = d . total_users ;
document . getElementById ( 'ds-active-users' ). textContent = d . active_users ;
document . getElementById ( 'ds-admin-users' ). textContent = d . admin_users ;
document . getElementById ( 'ds-version' ). textContent = 'v' + d . server_version ;
document . getElementById ( 'ds-used' ). textContent = formatBytes ( d . total_used_bytes );
document . getElementById ( 'ds-quota' ). textContent = formatBytes ( d . total_quota_bytes );
document . getElementById ( 'ds-usage-pct' ). textContent = d . storage_usage_percent . toFixed ( 1 ) + '%' ;
const bar = document . getElementById ( 'ds-bar' );
bar . style . width = Math . min ( d . storage_usage_percent , 100 ) + '%' ;
bar . className = 'progress-fill ' + ( d . storage_usage_percent > 90 ? 'red' : d . storage_usage_percent > 70 ? 'orange' : 'green' );
document . getElementById ( 'ds-auth' ). textContent = d . auth_enabled ? 'Enabled' : 'Disabled' ;
document . getElementById ( 'ds-oidc' ). textContent = d . oidc_configured ? 'Active' : 'Off' ;
document . getElementById ( 'ds-quotas-flag' ). textContent = d . quotas_enabled ? 'Enabled' : 'Disabled' ;
2026-02-13 16:46:59 +01:00
// Registration toggle
if ( typeof d . registration_enabled !== 'undefined' ) {
document . getElementById ( 'ds-registration' ). checked = d . registration_enabled ;
document . getElementById ( 'registration-warning' ). style . display = d . registration_enabled ? 'none' : 'flex' ;
}
2026-02-11 01:08:00 +01:00
if ( d . users_over_80_percent > 0 ) {
document . getElementById ( 'ds-warn-card' ). style . display = '' ;
document . getElementById ( 'ds-over80' ). textContent = d . users_over_80_percent ;
}
if ( d . users_over_quota > 0 ) {
document . getElementById ( 'ds-danger-card' ). style . display = '' ;
document . getElementById ( 'ds-overquota' ). textContent = d . users_over_quota ;
}
} catch ( e ) { console . error ( 'Dashboard error' , e ); }
}
// ── Users ──
async function loadUsers () {
const tbody = document . getElementById ( 'users-tbody' );
2026-02-11 14:09:40 +01:00
tbody . innerHTML = '<tr><td colspan="6" style="text-align:center;padding:28px;color:#94a3b8"><i class="fas fa-spinner fa-spin"></i> Loading…</td></tr>' ;
2026-02-11 01:08:00 +01:00
try {
const resp = await fetch ( API + '/admin/users?limit=' + PAGE_SIZE + '&offset=' + ( usersPage * PAGE_SIZE ), { headers : headers () });
2026-02-11 14:09:40 +01:00
if ( ! resp . ok ) { tbody . innerHTML = '<tr><td colspan="6" style="color:#991b1b;padding:20px"><i class="fas fa-exclamation-circle"></i> Failed to load users</td></tr>' ; return ; }
2026-02-11 01:08:00 +01:00
const data = await resp . json ();
totalUsers = data . total ;
const users = data . users ;
2026-02-11 14:09:40 +01:00
if ( users . length === 0 ) { tbody . innerHTML = '<tr><td colspan="6" style="text-align:center;padding:28px;color:#94a3b8">No users found</td></tr>' ; return ; }
2026-02-11 01:08:00 +01:00
tbody . innerHTML = users . map ( u => {
const quotaPct = u . storage_quota_bytes > 0 ? (( u . storage_used_bytes / u . storage_quota_bytes ) * 100 ) : 0 ;
const quotaColor = quotaPct > 90 ? 'red' : quotaPct > 70 ? 'orange' : 'green' ;
const quotaText = u . storage_quota_bytes > 0 ? formatBytes ( u . storage_used_bytes ) + ' / ' + formatBytes ( u . storage_quota_bytes ) : formatBytes ( u . storage_used_bytes ) + ' / ∞' ;
const isSelf = u . id === currentAdminId ;
return '<tr>' +
2026-02-11 14:09:40 +01:00
'<td><div class="user-info"><span class="user-name">' + u . username + ( isSelf ? ' <span style="color:#94a3b8;font-weight:400">(you)</span>' : '' ) + '</span><span class="user-email">' + u . email + '</span></div></td>' +
'<td><span class="badge badge-' + u . role + '">' + ( u . role === 'admin' ? '<i class="fas fa-shield-alt" style="font-size:10px"></i> ' : '' ) + u . role + '</span></td>' +
2026-02-11 01:08:00 +01:00
'<td><span class="badge badge-' + ( u . active ? 'active' : 'inactive' ) + '">' + ( u . active ? 'Active' : 'Inactive' ) + '</span></td>' +
'<td><div class="quota-bar"><div class="progress-bar" style="width:80px"><div class="progress-fill ' + quotaColor + '" style="width:' + Math . min ( quotaPct , 100 ) + '%"></div></div><span class="quota-text">' + quotaText + '</span></div></td>' +
2026-02-11 14:09:40 +01:00
'<td style="font-size:12px;color:#94a3b8">' + timeAgo ( u . last_login_at ) + '</td>' +
2026-02-11 01:08:00 +01:00
'<td><div class="actions-row">' +
2026-02-11 14:09:40 +01:00
'<button class="btn btn-sm btn-secondary" onclick="openQuotaModal(\'' + u . id + '\',\'' + u . username + '\',' + u . storage_quota_bytes + ')" title="Edit quota"><i class="fas fa-box"></i></button>' +
2026-02-13 16:46:59 +01:00
'<button class="btn btn-sm btn-secondary" onclick="openResetPasswordModal(\'' + u . id + '\',\'' + u . username + '\')" title="Reset password"><i class="fas fa-key"></i></button>' +
2026-02-11 14:09:40 +01:00
'<button class="btn btn-sm btn-secondary" onclick="toggleRole(\'' + u . id + '\',\'' + u . role + '\')" title="Toggle role"' + ( isSelf ? ' disabled' : '' ) + '><i class="fas fa-' + ( u . role === 'admin' ? 'user' : 'crown' ) + '"></i></button>' +
'<button class="btn btn-sm ' + ( u . active ? 'btn-danger' : 'btn-success' ) + '" onclick="toggleActive(\'' + u . id + '\',' + u . active + ')" title="' + ( u . active ? 'Deactivate' : 'Activate' ) + '"' + ( isSelf && u . active ? ' disabled' : '' ) + '><i class="fas fa-' + ( u . active ? 'ban' : 'check' ) + '"></i></button>' +
'<button class="btn btn-sm btn-danger" onclick="deleteUser(\'' + u . id + '\',\'' + u . username + '\')" title="Delete"' + ( isSelf ? ' disabled' : '' ) + '><i class="fas fa-trash-alt"></i></button>' +
2026-02-11 01:08:00 +01:00
'</div></td></tr>' ;
}). join ( '' );
document . getElementById ( 'users-info' ). textContent = 'Showing ' + ( usersPage * PAGE_SIZE + 1 ) + '-' + Math . min (( usersPage + 1 ) * PAGE_SIZE , totalUsers ) + ' of ' + totalUsers ;
document . getElementById ( 'prev-btn' ). disabled = usersPage === 0 ;
document . getElementById ( 'next-btn' ). disabled = ( usersPage + 1 ) * PAGE_SIZE >= totalUsers ;
} catch ( e ) {
2026-02-11 14:09:40 +01:00
tbody . innerHTML = '<tr><td colspan="6" style="color:#991b1b;padding:20px"><i class="fas fa-exclamation-circle"></i> Error: ' + e . message + '</td></tr>' ;
2026-02-11 01:08:00 +01:00
}
}
function prevPage () { if ( usersPage > 0 ) { usersPage -- ; loadUsers (); } }
function nextPage () { if (( usersPage + 1 ) * PAGE_SIZE < totalUsers ) { usersPage ++ ; loadUsers (); } }
async function toggleRole ( userId , currentRole ) {
const newRole = currentRole === 'admin' ? 'user' : 'admin' ;
if ( ! confirm ( 'Change role to ' + newRole + '?' )) return ;
try {
const resp = await fetch ( API + '/admin/users/' + userId + '/role' , {
method : 'PUT' , headers : headers (), body : JSON . stringify ({ role : newRole })
});
if ( resp . ok ) loadUsers (); else { const e = await resp . json (); alert ( e . message || 'Failed' ); }
} catch ( e ) { alert ( 'Error: ' + e . message ); }
}
async function toggleActive ( userId , currentActive ) {
const action = currentActive ? 'deactivate' : 'activate' ;
if ( ! confirm ( 'Are you sure you want to ' + action + ' this user?' )) return ;
try {
const resp = await fetch ( API + '/admin/users/' + userId + '/active' , {
method : 'PUT' , headers : headers (), body : JSON . stringify ({ active : ! currentActive })
});
if ( resp . ok ) loadUsers (); else { const e = await resp . json (); alert ( e . message || 'Failed' ); }
} catch ( e ) { alert ( 'Error: ' + e . message ); }
}
async function deleteUser ( userId , username ) {
if ( ! confirm ( 'DELETE user "' + username + '"? This cannot be undone!' )) return ;
try {
const resp = await fetch ( API + '/admin/users/' + userId , { method : 'DELETE' , headers : headers () });
if ( resp . ok ) { loadUsers (); loadDashboard (); } else { const e = await resp . json (); alert ( e . message || 'Failed' ); }
} catch ( e ) { alert ( 'Error: ' + e . message ); }
}
// ── Quota Modal ──
let quotaUserId = '' ;
function openQuotaModal ( userId , username , currentQuota ) {
quotaUserId = userId ;
document . getElementById ( 'qm-username' ). textContent = username ;
const gb = currentQuota / 1073741824 ;
document . getElementById ( 'qm-unit' ). value = '1073741824' ;
document . getElementById ( 'qm-value' ). value = gb > 0 ? Math . round ( gb * 10 ) / 10 : 0 ;
document . getElementById ( 'quota-modal' ). style . display = 'flex' ;
}
function closeQuotaModal () { document . getElementById ( 'quota-modal' ). style . display = 'none' ; }
async function saveQuota () {
const val = parseFloat ( document . getElementById ( 'qm-value' ). value ) || 0 ;
const unit = parseInt ( document . getElementById ( 'qm-unit' ). value );
const bytes = Math . round ( val * unit );
try {
const resp = await fetch ( API + '/admin/users/' + quotaUserId + '/quota' , {
method : 'PUT' , headers : headers (), body : JSON . stringify ({ quota_bytes : bytes })
});
if ( resp . ok ) { closeQuotaModal (); loadUsers (); loadDashboard (); }
else { const e = await resp . json (); alert ( e . message || 'Failed' ); }
} catch ( e ) { alert ( 'Error: ' + e . message ); }
}
2026-02-13 16:46:59 +01:00
// ── Create User Modal ──
function openCreateUserModal () {
document . getElementById ( 'cu-username' ). value = '' ;
document . getElementById ( 'cu-password' ). value = '' ;
document . getElementById ( 'cu-email' ). value = '' ;
document . getElementById ( 'cu-role' ). value = 'user' ;
document . getElementById ( 'cu-quota-value' ). value = '1' ;
document . getElementById ( 'cu-quota-unit' ). value = '1073741824' ;
document . getElementById ( 'cu-error' ). className = 'alert' ;
document . getElementById ( 'cu-error' ). textContent = '' ;
document . getElementById ( 'create-user-modal' ). style . display = 'flex' ;
setTimeout (() => document . getElementById ( 'cu-username' ). focus (), 100 );
}
function closeCreateUserModal () { document . getElementById ( 'create-user-modal' ). style . display = 'none' ; }
async function submitCreateUser () {
const username = document . getElementById ( 'cu-username' ). value . trim ();
const password = document . getElementById ( 'cu-password' ). value ;
const email = document . getElementById ( 'cu-email' ). value . trim () || null ;
const role = document . getElementById ( 'cu-role' ). value ;
const quotaVal = parseFloat ( document . getElementById ( 'cu-quota-value' ). value ) || 0 ;
const quotaUnit = parseInt ( document . getElementById ( 'cu-quota-unit' ). value );
const quotaBytes = Math . round ( quotaVal * quotaUnit );
const errorEl = document . getElementById ( 'cu-error' );
if ( username . length < 3 ) { errorEl . textContent = 'Username must be at least 3 characters' ; errorEl . className = 'alert alert-error' ; return ; }
if ( password . length < 8 ) { errorEl . textContent = 'Password must be at least 8 characters' ; errorEl . className = 'alert alert-error' ; return ; }
const btn = document . getElementById ( 'cu-submit' );
btn . disabled = true ; btn . innerHTML = '<i class="fas fa-spinner fa-spin"></i> Creating…' ;
try {
const resp = await fetch ( API + '/admin/users' , {
method : 'POST' , headers : headers (),
body : JSON . stringify ({ username , password , email , role , quota_bytes : quotaBytes })
});
if ( resp . ok ) {
closeCreateUserModal ();
loadUsers ();
loadDashboard ();
} else {
const e = await resp . json (). catch (() => ({}));
errorEl . textContent = e . message || 'Failed to create user' ;
errorEl . className = 'alert alert-error' ;
}
} catch ( e ) {
errorEl . textContent = 'Network error: ' + e . message ;
errorEl . className = 'alert alert-error' ;
}
btn . disabled = false ; btn . innerHTML = '<i class="fas fa-user-plus"></i> Create' ;
}
// ── Reset Password Modal ──
let resetPwUserId = '' ;
function openResetPasswordModal ( userId , username ) {
resetPwUserId = userId ;
document . getElementById ( 'rp-username' ). textContent = username ;
document . getElementById ( 'rp-password' ). value = '' ;
document . getElementById ( 'rp-error' ). className = 'alert' ;
document . getElementById ( 'rp-error' ). textContent = '' ;
document . getElementById ( 'reset-pw-modal' ). style . display = 'flex' ;
setTimeout (() => document . getElementById ( 'rp-password' ). focus (), 100 );
}
function closeResetPasswordModal () { document . getElementById ( 'reset-pw-modal' ). style . display = 'none' ; }
async function submitResetPassword () {
const password = document . getElementById ( 'rp-password' ). value ;
const errorEl = document . getElementById ( 'rp-error' );
if ( password . length < 8 ) { errorEl . textContent = 'Password must be at least 8 characters' ; errorEl . className = 'alert alert-error' ; return ; }
const btn = document . getElementById ( 'rp-submit' );
btn . disabled = true ; btn . innerHTML = '<i class="fas fa-spinner fa-spin"></i> Resetting…' ;
try {
const resp = await fetch ( API + '/admin/users/' + resetPwUserId + '/password' , {
method : 'PUT' , headers : headers (),
body : JSON . stringify ({ new_password : password })
});
if ( resp . ok ) { closeResetPasswordModal (); }
else { const e = await resp . json (). catch (() => ({})); errorEl . textContent = e . message || 'Failed' ; errorEl . className = 'alert alert-error' ; }
} catch ( e ) { errorEl . textContent = 'Error: ' + e . message ; errorEl . className = 'alert alert-error' ; }
btn . disabled = false ; btn . innerHTML = '<i class="fas fa-save"></i> Reset' ;
}
// ── Registration Toggle ──
async function toggleRegistration ( enabled ) {
document . getElementById ( 'registration-warning' ). style . display = enabled ? 'none' : 'flex' ;
try {
const resp = await fetch ( API + '/admin/settings/registration' , {
method : 'PUT' , headers : headers (),
body : JSON . stringify ({ registration_enabled : enabled })
});
if ( ! resp . ok ) {
// Revert toggle on failure
document . getElementById ( 'ds-registration' ). checked = ! enabled ;
document . getElementById ( 'registration-warning' ). style . display = ! enabled ? 'flex' : 'none' ;
const e = await resp . json (). catch (() => ({}));
alert ( e . message || 'Failed to update registration setting' );
}
} catch ( e ) {
document . getElementById ( 'ds-registration' ). checked = ! enabled ;
document . getElementById ( 'registration-warning' ). style . display = ! enabled ? 'flex' : 'none' ;
alert ( 'Error: ' + e . message );
}
}
2026-02-11 01:08:00 +01:00
// ── OIDC settings ──
2026-02-11 00:15:26 +01:00
document . getElementById ( 'oidc-enabled' ). addEventListener ( 'change' , function () {
document . getElementById ( 'oidc-form' ). style . display = this . checked ? 'block' : 'none' ;
});
document . getElementById ( 'disable-password' ). addEventListener ( 'change' , function () {
document . getElementById ( 'password-warning' ). style . display = this . checked ? 'flex' : 'none' ;
});
2026-02-11 01:08:00 +01:00
function showOidcStatus ( msg , type ) {
const el = document . getElementById ( 'oidc-status' );
2026-02-11 00:15:26 +01:00
el . textContent = msg ;
el . className = 'alert alert-' + type ;
}
function copyCallback () {
const text = document . getElementById ( 'callback-url' ). textContent ;
2026-02-11 01:08:00 +01:00
navigator . clipboard . writeText ( text );
}
async function testConnection () {
const url = document . getElementById ( 'issuer-url' ). value . trim ();
if ( ! url ) { showOidcStatus ( 'Enter an Issuer URL first' , 'error' ); return ; }
const btn = document . getElementById ( 'discover-btn' );
2026-02-11 14:09:40 +01:00
btn . disabled = true ; btn . innerHTML = '<i class="fas fa-spinner fa-spin"></i> Discovering…' ;
2026-02-11 01:08:00 +01:00
const resultDiv = document . getElementById ( 'discovery-result' );
try {
const resp = await fetch ( API + '/admin/settings/oidc/test' , { method : 'POST' , headers : headers (), body : JSON . stringify ({ issuer_url : url }) });
const r = await resp . json ();
if ( r . success ) {
2026-02-11 14:09:40 +01:00
resultDiv . innerHTML = '<div class="discovery-result ok"><strong><i class="fas fa-check-circle"></i> ' + r . message + '</strong><dl><dt>Issuer</dt><dd>' + ( r . issuer || '—' ) + '</dd><dt>Auth Endpoint</dt><dd>' + ( r . authorization_endpoint || '—' ) + '</dd></dl></div>' ;
2026-02-11 01:08:00 +01:00
if ( ! document . getElementById ( 'provider-name' ). value && r . provider_name_suggestion ) document . getElementById ( 'provider-name' ). value = r . provider_name_suggestion ;
} else {
2026-02-11 14:09:40 +01:00
resultDiv . innerHTML = '<div class="discovery-result fail"><strong><i class="fas fa-times-circle"></i> ' + r . message + '</strong></div>' ;
2026-02-11 01:08:00 +01:00
}
2026-02-11 14:09:40 +01:00
} catch ( e ) { resultDiv . innerHTML = '<div class="discovery-result fail"><i class="fas fa-times-circle"></i> Error: ' + e . message + '</div>' ; }
btn . disabled = false ; btn . innerHTML = '<i class="fas fa-search"></i> Auto-discover' ;
2026-02-11 00:15:26 +01:00
}
2026-02-11 01:08:00 +01:00
async function saveOidcSettings () {
const btn = document . getElementById ( 'save-btn' );
2026-02-11 14:09:40 +01:00
btn . disabled = true ; btn . innerHTML = '<i class="fas fa-spinner fa-spin"></i> Saving…' ;
2026-02-11 01:08:00 +01:00
const body = {
enabled : document . getElementById ( 'oidc-enabled' ). checked ,
issuer_url : document . getElementById ( 'issuer-url' ). value . trim (),
client_id : document . getElementById ( 'client-id' ). value . trim (),
client_secret : document . getElementById ( 'client-secret' ). value || null ,
scopes : document . getElementById ( 'scopes' ). value . trim () || null ,
auto_provision : document . getElementById ( 'auto-provision' ). checked ,
admin_groups : document . getElementById ( 'admin-groups' ). value . trim () || null ,
disable_password_login : document . getElementById ( 'disable-password' ). checked ,
provider_name : document . getElementById ( 'provider-name' ). value . trim () || null ,
};
try {
const resp = await fetch ( API + '/admin/settings/oidc' , { method : 'PUT' , headers : headers (), body : JSON . stringify ( body ) });
if ( resp . ok ) { showOidcStatus ( 'Settings saved — OIDC is now ' + ( body . enabled ? 'active' : 'disabled' ), 'success' ); loadDashboard (); }
else { const e = await resp . json (). catch (()=>({})); showOidcStatus ( 'Error: ' + ( e . message || resp . statusText ), 'error' ); }
} catch ( e ) { showOidcStatus ( 'Network error: ' + e . message , 'error' ); }
2026-02-11 14:09:40 +01:00
btn . disabled = false ; btn . innerHTML = '<i class="fas fa-save"></i> Save' ;
2026-02-11 01:08:00 +01:00
}
// ── Init ──
2026-02-11 00:15:26 +01:00
async function init () {
if ( ! token ) { showAccessDenied (); return ; }
try {
const me = await fetch ( API + '/auth/me' , { headers : headers () });
if ( ! me . ok ) { showAccessDenied (); return ; }
const user = await me . json ();
if ( user . role !== 'admin' ) { showAccessDenied (); return ; }
2026-02-11 01:08:00 +01:00
currentAdminId = user . id ;
2026-02-11 00:15:26 +01:00
// Load OIDC settings
const oidcResp = await fetch ( API + '/admin/settings/oidc' , { headers : headers () });
if ( oidcResp . ok ) {
const s = await oidcResp . json ();
document . getElementById ( 'oidc-enabled' ). checked = s . enabled ;
document . getElementById ( 'oidc-form' ). style . display = s . enabled ? 'block' : 'none' ;
document . getElementById ( 'provider-name' ). value = s . provider_name || '' ;
document . getElementById ( 'issuer-url' ). value = s . issuer_url || '' ;
document . getElementById ( 'client-id' ). value = s . client_id || '' ;
document . getElementById ( 'scopes' ). value = s . scopes || 'openid profile email' ;
document . getElementById ( 'auto-provision' ). checked = s . auto_provision ;
document . getElementById ( 'admin-groups' ). value = s . admin_groups || '' ;
document . getElementById ( 'disable-password' ). checked = s . disable_password_login ;
document . getElementById ( 'password-warning' ). style . display = s . disable_password_login ? 'flex' : 'none' ;
document . getElementById ( 'callback-url' ). textContent = s . callback_url ;
if ( s . client_secret_set ) document . getElementById ( 'secret-hint' ). style . display = 'block' ;
( s . env_overrides || []). forEach ( field => {
const badge = document . getElementById ( 'badge-' + field );
2026-02-11 01:08:00 +01:00
if ( badge ) badge . innerHTML = '<span class="badge badge-env">ENV</span>' ;
2026-02-11 00:15:26 +01:00
});
}
2026-02-11 01:08:00 +01:00
await loadDashboard ();
2026-02-11 00:15:26 +01:00
document . getElementById ( 'loading' ). style . display = 'none' ;
document . getElementById ( 'main-content' ). style . display = 'block' ;
2026-02-11 01:08:00 +01:00
} catch ( e ) { console . error ( e ); showAccessDenied (); }
2026-02-11 00:15:26 +01:00
}
function showAccessDenied () {
document . getElementById ( 'loading' ). style . display = 'none' ;
document . getElementById ( 'access-denied' ). style . display = 'block' ;
}
init ();
</ script >
</ body >
2026-02-11 14:09:40 +01:00
</ html >