Files
Oxicloud/static/admin.html
T
Dionisio 1be3e4230a feat: admin can create users manually + disable registration (#85)
Backend:
- POST /api/admin/users — admin-only user creation endpoint
  - username & password required, email optional (auto-generated placeholder)
  - role, quota_bytes, active all configurable
  - creates personal folder automatically
- PUT /api/admin/users/{id}/password — admin password reset
- GET/PUT /api/admin/settings/registration — toggle public registration
  - Supports env var OXICLOUD_DISABLE_REGISTRATION override
  - Blocks POST /api/auth/register when disabled
- AdminCreateUserDto, AdminResetPasswordDto added to settings DTOs
- registration_enabled field added to DashboardStatsDto

Frontend (admin.html):
- 'Create User' button in Users tab with full modal form
  (username, password, email, role, quota)
- 'Reset Password' button per user in actions column
- 'Allow public self-registration' toggle in Dashboard > System
  with warning banner when disabled

Closes #85
2026-02-13 16:46:59 +01:00

837 lines
46 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OxiCloud — Admin Panel</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<style>
*{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:100vh;display:flex;flex-direction:column}
/* ── Scrollbar ── */
::-webkit-scrollbar{width:8px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px}
::-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(135deg,#2a3042 0%,#232838 100%);
padding:0 32px;height:64px;display:flex;align-items:center;justify-content:space-between;
box-shadow:0 2px 12px rgba(0,0,0,.15);position:sticky;top:0;z-index:100;
}
.admin-header-left{display:flex;align-items:center;gap:14px}
.admin-logo{
width:38px;height:38px;background:linear-gradient(135deg,#ff5e3a,#ff2d55);border-radius:11px;
display:flex;align-items:center;justify-content:center;
box-shadow:0 3px 10px rgba(255,94,58,.35);
}
.admin-logo svg{width:20px;height:20px;fill:#fff}
.admin-title{font-size:17px;font-weight:700;color:#fff;letter-spacing:.3px}
.admin-title span{color:rgba(255,255,255,.5);font-weight:400;margin-left:6px}
.admin-header-right a{
color:rgba(255,255,255,.65);text-decoration:none;font-size:13px;font-weight:500;
display:flex;align-items:center;gap:6px;transition:color .2s;
}
.admin-header-right a:hover{color:#fff}
/* ── Container ── */
.admin-container{max-width:1080px;margin:0 auto;padding:28px 24px 60px;width:100%}
/* ── Tabs ── */
.admin-tabs{display:flex;gap:6px;margin-bottom:28px;background:#fff;border-radius:14px;padding:6px;box-shadow:0 1px 4px rgba(0,0,0,.06)}
.admin-tab{
padding:10px 22px;cursor:pointer;font-weight:600;font-size:13.5px;color:#64748b;
border:none;background:none;border-radius:10px;transition:all .2s;display:flex;align-items:center;gap:8px;
}
.admin-tab:hover{color:#1e293b;background:#f8fafc}
.admin-tab.active{color:#fff;background:linear-gradient(135deg,#ff5e3a,#ff2d55);box-shadow:0 3px 12px rgba(255,94,58,.25)}
.admin-tab.active i{color:#fff}
.admin-tab i{font-size:14px;width:16px;text-align:center}
.tab-content{display:none}
.tab-content.active{display:block}
/* ── Cards ── */
.admin-card{background:#fff;border-radius:16px;box-shadow:0 1px 4px rgba(0,0,0,.06),0 0 0 1px rgba(0,0,0,.03);padding:28px;margin-bottom:22px}
.admin-card h2{font-size:16px;font-weight:700;margin-bottom:20px;color:#1e293b;display:flex;align-items:center;gap:10px}
.admin-card h2 i{color:#ff5e3a;font-size:17px}
/* ── Stats Grid ── */
.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px;margin-bottom:22px}
.stat-card{
padding:20px;background:#f8fafc;border-radius:14px;text-align:center;
border:1px solid #e2e8f0;transition:transform .15s,box-shadow .15s;
}
.stat-card:hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,.06)}
.stat-value{font-size:1.75rem;font-weight:800;color:#1e293b}
.stat-label{font-size:11.5px;color:#94a3b8;text-transform:uppercase;letter-spacing:.06em;margin-top:4px;font-weight:600}
.stat-card.warn{border-color:#fbbf24;background:#fffbeb}
.stat-card.danger{border-color:#ef4444;background:#fef2f2}
.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:8px;background:#e2e8f0;border-radius:4px;overflow:hidden}
.progress-fill{height:100%;border-radius:4px;transition:width .5s ease}
.progress-fill.green{background:linear-gradient(90deg,#059669,#10b981)}
.progress-fill.orange{background:linear-gradient(90deg,#d97706,#f59e0b)}
.progress-fill.red{background:linear-gradient(90deg,#dc2626,#ef4444)}
/* ── Table ── */
.table-wrap{overflow-x:auto;border-radius:12px;border:1px solid #e2e8f0}
table{width:100%;border-collapse:collapse;font-size:13.5px}
th{text-align:left;padding:12px 16px;background:#f8fafc;color:#94a3b8;font-size:11px;text-transform:uppercase;letter-spacing:.06em;font-weight:700;white-space:nowrap;border-bottom:1px solid #e2e8f0}
td{padding:14px 16px;border-bottom:1px solid #f1f5f9;vertical-align:middle}
tr:last-child td{border-bottom:none}
tr:hover{background:#fafbfd}
.user-info{display:flex;flex-direction:column;gap:2px}
.user-name{font-weight:600;color:#1e293b}
.user-email{font-size:12px;color:#94a3b8}
/* ── Badges ── */
.badge{display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:3px 10px;border-radius:20px;font-weight:600}
.badge-admin{background:#dbeafe;color:#1d4ed8}
.badge-user{background:#f1f5f9;color:#64748b}
.badge-active{background:#d1fae5;color:#065f46}
.badge-inactive{background:#fee2e2;color:#991b1b}
.badge-oidc{background:#ede9fe;color:#6d28d9}
.badge-env{background:#fef3c7;color:#92400e;font-size:10px;padding:1px 6px;margin-left:4px}
/* ── Buttons ── */
.btn{
padding:8px 18px;border:none;border-radius:10px;font-size:13px;font-weight:600;
cursor:pointer;transition:all .15s;white-space:nowrap;display:inline-flex;align-items:center;gap:6px;
}
.btn-sm{padding:6px 12px;font-size:12px;border-radius:8px}
.btn-primary{background:linear-gradient(135deg,#ff5e3a,#ff2d55);color:#fff;box-shadow:0 2px 8px rgba(255,94,58,.25)}
.btn-primary:hover{box-shadow:0 4px 14px rgba(255,94,58,.35);transform:translateY(-1px)}
.btn-secondary{background:#fff;color:#334155;border:1px solid #e2e8f0}
.btn-secondary:hover{background:#f8fafc;border-color:#cbd5e1}
.btn-danger{background:#fef2f2;color:#991b1b;border:1px solid #fecaca}
.btn-danger:hover{background:#fee2e2}
.btn-success{background:#ecfdf5;color:#065f46;border:1px solid #a7f3d0}
.btn-success:hover{background:#d1fae5}
.btn:disabled{opacity:.4;cursor:not-allowed;transform:none!important}
.actions-row{display:flex;gap:6px;flex-wrap:wrap}
/* ── Forms ── */
.form-group{margin-bottom:16px}
.form-group label{display:block;font-size:13px;font-weight:600;margin-bottom:4px;color:#334155}
.form-group input[type="text"],.form-group input[type="password"],.form-group input[type="url"],.form-group input[type="number"],.form-group select{
width:100%;padding:10px 14px;border:2px solid #e2e8f0;border-radius:10px;font-size:14px;
background:#f8fafc;transition:all .2s;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 3px rgba(255,94,58,.1)}
.form-group small{color:#94a3b8;font-size:12px;display:block;margin-top:3px}
.toggle-row{display:flex;align-items:center;justify-content:space-between;padding:10px 0}
.toggle-row label{font-size:14px;font-weight:500;color:#334155}
.switch{position:relative;width:44px;height:24px;flex-shrink:0}
.switch input{opacity:0;width:0;height:0}
.slider{position:absolute;cursor:pointer;inset:0;background:#d1d5db;border-radius:24px;transition:.3s}
.slider:before{content:"";position:absolute;height:18px;width:18px;left:3px;bottom:3px;background:#fff;border-radius:50%;transition:.3s;box-shadow:0 1px 3px rgba(0,0,0,.15)}
.switch input:checked+.slider{background:linear-gradient(135deg,#ff5e3a,#ff2d55)}
.switch input:checked+.slider:before{transform:translateX(20px)}
.readonly-field{
display:flex;align-items:center;gap:8px;background:#f8fafc;border:2px solid #e2e8f0;border-radius:10px;
padding:10px 14px;font-family:'SF Mono',Monaco,Consolas,monospace;font-size:13px;word-break:break-all;color:#334155;
}
.readonly-field button{
flex-shrink:0;padding:6px 10px;border:1px solid #e2e8f0;border-radius:8px;background:#fff;cursor:pointer;
font-size:12px;transition:all .15s;color:#64748b;
}
.readonly-field button:hover{background:#f1f5f9;color:#334155}
/* ── Alerts ── */
.alert{padding:12px 16px;border-radius:10px;font-size:13px;margin-top:14px;display:none;font-weight:500}
.alert-success{background:#ecfdf5;color:#065f46;border:1px solid #a7f3d0;display:block}
.alert-error{background:#fef2f2;color:#991b1b;border:1px solid #fecaca;display:block}
.alert-info{background:#eff6ff;color:#1e40af;border:1px solid #bfdbfe;display:block}
.warning{background:#fffbeb;border:1px solid #fde68a;border-radius:10px;padding:10px 14px;font-size:13px;color:#92400e;margin-top:8px;display:flex;align-items:center;gap:6px;font-weight:500}
/* ── Discovery ── */
.discovery-result{margin:12px 0;padding:14px;border-radius:10px;font-size:13px}
.discovery-result.ok{background:#ecfdf5;border:1px solid #a7f3d0;color:#065f46}
.discovery-result.fail{background:#fef2f2;border:1px solid #fecaca;color:#991b1b}
.discovery-result dt{font-weight:700;margin-top:6px}
.discovery-result dd{margin-left:0;word-break:break-all}
/* ── Details ── */
details{margin-top:14px;border-top:1px solid #e2e8f0;padding-top:12px}
details summary{cursor:pointer;font-weight:600;font-size:14px;color:#64748b;padding:6px 0;user-select:none;transition:color .2s}
details summary:hover{color:#ff5e3a}
details[open] summary{margin-bottom:14px;color:#ff5e3a}
/* ── Modal ── */
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.45);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;z-index:1000}
.modal{background:#fff;border-radius:20px;padding:28px;width:420px;max-width:90vw;box-shadow:0 20px 60px rgba(0,0,0,.2);animation:modalIn .2s ease-out}
@keyframes modalIn{from{opacity:0;transform:scale(.95) translateY(10px)}to{opacity:1;transform:scale(1) translateY(0)}}
.modal h3{margin-bottom:18px;font-size:17px;color:#1e293b;display:flex;align-items:center}
.modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:18px}
/* ── Quota bar inline ── */
.quota-bar{display:flex;align-items:center;gap:10px}
.quota-bar .progress-bar{flex:1;height:6px}
.quota-text{font-size:12px;color:#94a3b8;white-space:nowrap}
/* ── Access / Loading ── */
#access-denied{display:none;text-align:center;padding:80px 20px}
#access-denied .access-icon{width:80px;height:80px;background:#fef2f2;border-radius:50%;display:flex;align-items:center;justify-content:center;margin:0 auto 20px}
#access-denied .access-icon i{font-size:32px;color:#ef4444}
#access-denied h2{color:#991b1b;margin-bottom:8px;font-size:20px}
#access-denied p{color:#64748b;margin-bottom:20px;font-size:14px}
#access-denied a{display:inline-flex;align-items:center;gap:6px;padding:10px 24px;background:linear-gradient(135deg,#ff5e3a,#ff2d55);color:#fff;text-decoration:none;border-radius:10px;font-weight:600;font-size:14px;box-shadow:0 3px 12px rgba(255,94,58,.3);transition:all .2s}
#access-denied a:hover{transform:translateY(-1px);box-shadow:0 5px 18px rgba(255,94,58,.4)}
#loading{text-align:center;padding:80px;color:#94a3b8;font-size:15px}
#loading i{font-size:32px;color:#ff5e3a;display:block;margin-bottom:12px;animation:spin 1s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
/* ── Pagination ── */
.pagination{display:flex;align-items:center;justify-content:space-between;margin-top:14px;font-size:13px;color:#94a3b8;padding:0 4px}
.pagination button{padding:6px 14px}
</style>
</head>
<body>
<!-- Header -->
<div class="admin-header">
<div class="admin-header-left">
<div class="admin-logo">
<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>
</div>
<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>
<a href="/login.html"><i class="fas fa-sign-in-alt"></i> Sign in</a>
</div>
<div id="main-content" style="display:none">
<!-- Tabs -->
<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>
</div>
<!-- ======== DASHBOARD TAB ======== -->
<div id="tab-dashboard" class="tab-content active">
<div class="stats-grid">
<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>
</div>
<div class="admin-card">
<h2><i class="fas fa-hdd"></i> Storage Overview</h2>
<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>
</div>
<div class="progress-bar"><div class="progress-fill green" id="ds-bar" style="width:0%"></div></div>
<div style="margin-top:14px">
<div class="stats-grid">
<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 &gt;80% quota</div></div>
<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>
</div>
</div>
<div class="admin-card">
<h2><i class="fas fa-server"></i> System</h2>
<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>
</div>
<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>
</div>
</div>
<!-- ======== USERS TAB ======== -->
<div id="tab-users" class="tab-content">
<div class="admin-card">
<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>
<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>
<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>
</table>
</div>
<div class="pagination">
<span id="users-info">—</span>
<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>
</div>
</div>
</div>
</div>
<!-- ======== OIDC TAB ======== -->
<div id="tab-oidc" class="tab-content">
<div class="admin-card">
<h2><i class="fas fa-shield-alt"></i> Single Sign-On (OIDC / SSO)</h2>
<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">
<div class="form-group">
<label>Provider Name <span id="badge-provider_name"></span></label>
<input type="text" id="provider-name" placeholder="e.g., Authentik, Keycloak">
</div>
<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>
</div>
<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>
</div>
<div id="discovery-result"></div>
<div class="form-group">
<label>Client ID <span id="badge-client_id"></span></label>
<input type="text" id="client-id" placeholder="oxicloud">
</div>
<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">
<small id="secret-hint" style="display:none"><i class="fas fa-check-circle" style="color:#059669"></i> A client secret is already configured</small>
</div>
<div class="form-group">
<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>
</div>
<details>
<summary><i class="fas fa-sliders-h" style="margin-right:6px"></i> Advanced Settings</summary>
<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>
<div class="warning" id="password-warning" style="display:none"><i class="fas fa-exclamation-triangle"></i> This will prevent ALL password-based logins!</div>
</details>
<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>
</div>
<div id="oidc-status" class="alert"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Quota Modal -->
<div id="quota-modal" class="modal-overlay" style="display:none">
<div class="modal">
<h3><i class="fas fa-box" style="color:#ff5e3a;margin-right:8px"></i> Update Storage Quota</h3>
<div class="form-group">
<label>User: <strong id="qm-username"></strong></label>
</div>
<div class="form-group">
<label>New Quota</label>
<div style="display:flex;gap:10px;align-items:center">
<input type="number" id="qm-value" min="0" step="1" style="flex:1">
<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>
</div>
<small>Set to 0 for unlimited</small>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeQuotaModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveQuota()"><i class="fas fa-save"></i> Save</button>
</div>
</div>
</div>
<!-- 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>
<script>
const API = '/api';
const token = localStorage.getItem('oxicloud_token') || localStorage.getItem('token') || localStorage.getItem('access_token');
let currentAdminId = '';
let usersPage = 0;
const PAGE_SIZE = 50;
let totalUsers = 0;
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 ──
function switchTab(name, el) {
document.querySelectorAll('.admin-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
document.getElementById('tab-' + name).classList.add('active');
if (el) el.classList.add('active');
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';
// 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';
}
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');
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>';
try {
const resp = await fetch(API + '/admin/users?limit=' + PAGE_SIZE + '&offset=' + (usersPage * PAGE_SIZE), { headers: headers() });
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; }
const data = await resp.json();
totalUsers = data.total;
const users = data.users;
if (users.length === 0) { tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:28px;color:#94a3b8">No users found</td></tr>'; return; }
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>' +
'<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>' +
'<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>' +
'<td style="font-size:12px;color:#94a3b8">' + timeAgo(u.last_login_at) + '</td>' +
'<td><div class="actions-row">' +
'<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>' +
'<button class="btn btn-sm btn-secondary" onclick="openResetPasswordModal(\'' + u.id + '\',\'' + u.username + '\')" title="Reset password"><i class="fas fa-key"></i></button>' +
'<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>' +
'</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) {
tbody.innerHTML = '<tr><td colspan="6" style="color:#991b1b;padding:20px"><i class="fas fa-exclamation-circle"></i> Error: ' + e.message + '</td></tr>';
}
}
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); }
}
// ── 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);
}
}
// ── OIDC settings ──
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';
});
function showOidcStatus(msg, type) {
const el = document.getElementById('oidc-status');
el.textContent = msg;
el.className = 'alert alert-' + type;
}
function copyCallback() {
const text = document.getElementById('callback-url').textContent;
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');
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Discovering…';
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) {
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>';
if (!document.getElementById('provider-name').value && r.provider_name_suggestion) document.getElementById('provider-name').value = r.provider_name_suggestion;
} else {
resultDiv.innerHTML = '<div class="discovery-result fail"><strong><i class="fas fa-times-circle"></i> ' + r.message + '</strong></div>';
}
} 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';
}
async function saveOidcSettings() {
const btn = document.getElementById('save-btn');
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Saving…';
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'); }
btn.disabled = false; btn.innerHTML = '<i class="fas fa-save"></i> Save';
}
// ── Init ──
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; }
currentAdminId = user.id;
// 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);
if (badge) badge.innerHTML = '<span class="badge badge-env">ENV</span>';
});
}
await loadDashboard();
document.getElementById('loading').style.display = 'none';
document.getElementById('main-content').style.display = 'block';
} catch (e) { console.error(e); showAccessDenied(); }
}
function showAccessDenied() {
document.getElementById('loading').style.display = 'none';
document.getElementById('access-denied').style.display = 'block';
}
init();
</script>
</body>
</html>