fix: folder trash/delete operations & frontend refactoring

- Fix recursive CTE: add missing RECURSIVE keyword in move_to_trash and restore_from_trash SQL queries (relation 'descendants' does not exist)
- Fix folder deletion: delete descendant files before folder to avoid 'duplicate key violates unique constraint idx_files_unique_name_at_root'
- Simplify trash model: only mark the folder as trashed, not child files (implicit trash via parent)
- Update trash_items view: filter to show only top-level trashed items
- Update schema.sql: change files.folder_id FK from ON DELETE SET NULL to ON DELETE CASCADE
- Fix trash view icons: folders and files now show correct visual icons (folder-icon, pdf-icon, etc.) in trash view
- Frontend refactoring: extract inline CSS/JS from admin.html and profile.html into dedicated external files
- Frontend cleanup: replace all inline style attributes with CSS classes
- Frontend cleanup: replace style.display JS
- Fix recursive CTE: add missing RECURSIVE keyword in move_to_trash and restore_from_trash SQL queries (relation 'descendants' d
This commit is contained in:
Diocrafts
2026-02-20 12:27:52 +01:00
parent 27eb7b16e0
commit a1a3bd1b2b
43 changed files with 3304 additions and 3416 deletions
+20 -8
View File
@@ -424,7 +424,7 @@ CREATE OR REPLACE TRIGGER trg_folders_cascade_path
CREATE TABLE IF NOT EXISTS storage.files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
folder_id UUID REFERENCES storage.folders(id) ON DELETE SET NULL,
folder_id UUID REFERENCES storage.folders(id) ON DELETE CASCADE,
user_id VARCHAR(36) NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
blob_hash VARCHAR(64) NOT NULL,
size BIGINT NOT NULL DEFAULT 0,
@@ -448,15 +448,27 @@ CREATE INDEX IF NOT EXISTS idx_files_blob_hash ON storage.files(blob_hash);
CREATE INDEX IF NOT EXISTS idx_files_trashed ON storage.files(user_id, is_trashed);
CREATE INDEX IF NOT EXISTS idx_files_name_search ON storage.files(user_id, name text_pattern_ops);
-- Trash view combining trashed files and folders for the TrashRepository
-- Trash view combining trashed files and folders for the TrashRepository.
-- Only shows top-level trashed items: excludes files/folders whose parent
-- is also trashed (they are implicitly in trash as children of a trashed folder).
CREATE OR REPLACE VIEW storage.trash_items AS
SELECT id, name, 'file' AS item_type, user_id, trashed_at,
original_folder_id AS original_parent_id, created_at
FROM storage.files WHERE is_trashed = TRUE
SELECT f.id, f.name, 'file' AS item_type, f.user_id, f.trashed_at,
f.original_folder_id AS original_parent_id, f.created_at
FROM storage.files f
WHERE f.is_trashed = TRUE
AND (f.folder_id IS NULL
OR NOT EXISTS (
SELECT 1 FROM storage.folders p
WHERE p.id = f.folder_id AND p.is_trashed = TRUE))
UNION ALL
SELECT id, name, 'folder' AS item_type, user_id, trashed_at,
original_parent_id, created_at
FROM storage.folders WHERE is_trashed = TRUE;
SELECT fo.id, fo.name, 'folder' AS item_type, fo.user_id, fo.trashed_at,
fo.original_parent_id, fo.created_at
FROM storage.folders fo
WHERE fo.is_trashed = TRUE
AND (fo.parent_id IS NULL
OR NOT EXISTS (
SELECT 1 FROM storage.folders p
WHERE p.id = fo.parent_id AND p.is_trashed = TRUE));
COMMENT ON TABLE storage.folders IS 'Virtual folder hierarchy with ltree — no physical directories on disk';
COMMENT ON TABLE storage.files IS 'File metadata pointing to content-addressable blobs';
@@ -457,7 +457,24 @@ impl FolderRepository for FolderDbRepository {
}
async fn delete_folder(&self, id: &str) -> Result<(), DomainError> {
// Hard delete folder and all descendants (CASCADE handles children)
// First, delete all files in this folder and descendant folders
// to avoid constraint violations from ON DELETE SET NULL
sqlx::query(
r#"
WITH RECURSIVE descendants AS (
SELECT id FROM storage.folders WHERE id = $1::uuid
UNION ALL
SELECT f.id FROM storage.folders f JOIN descendants d ON f.parent_id = d.id
)
DELETE FROM storage.files WHERE folder_id IN (SELECT id FROM descendants)
"#,
)
.bind(id)
.execute(self.pool())
.await
.map_err(|e| DomainError::internal_error("FolderDb", format!("delete files: {e}")))?;
// Then delete the folder (CASCADE will remove descendant folders)
let result = sqlx::query("DELETE FROM storage.folders WHERE id = $1::uuid")
.bind(id)
.execute(self.pool())
@@ -501,9 +518,10 @@ impl FolderRepository for FolderDbRepository {
// ── Trash operations ──
async fn move_to_trash(&self, folder_id: &str) -> Result<(), DomainError> {
// Atomic CTE: trash folder + all descendant files in a single statement.
// PostgreSQL executes the entire CTE as one atomic operation — no
// intermediate state where the folder is trashed but files are not.
// Only mark the folder itself as trashed.
// Child files and sub-folders are implicitly hidden because their
// ancestor is trashed — list queries already filter NOT is_trashed,
// and folder navigation won't reach a trashed folder's children.
let result = sqlx::query_scalar::<_, i64>(
r#"
WITH trash_folder AS (
@@ -513,17 +531,6 @@ impl FolderRepository for FolderDbRepository {
original_parent_id = parent_id,
updated_at = NOW()
WHERE id = $1::uuid AND NOT is_trashed
RETURNING id
),
descendants AS (
SELECT id FROM trash_folder
UNION ALL
SELECT f.id FROM storage.folders f JOIN descendants d ON f.parent_id = d.id
),
trash_files AS (
UPDATE storage.files
SET is_trashed = TRUE, trashed_at = NOW(), original_folder_id = folder_id
WHERE folder_id IN (SELECT id FROM descendants) AND NOT is_trashed
RETURNING 1
)
SELECT COUNT(*) FROM trash_folder
@@ -546,7 +553,9 @@ impl FolderRepository for FolderDbRepository {
folder_id: &str,
_original_path: &str,
) -> Result<(), DomainError> {
// Atomic CTE: restore folder + all descendant files in a single statement.
// Only restore the folder itself.
// Child files were never marked as trashed — they become visible
// again automatically once their parent folder is un-trashed.
// The BEFORE UPDATE trigger on parent_id will recompute path/lpath
// automatically when original_parent_id is restored.
let result = sqlx::query_scalar::<_, i64>(
@@ -559,20 +568,6 @@ impl FolderRepository for FolderDbRepository {
original_parent_id = NULL,
updated_at = NOW()
WHERE id = $1::uuid AND is_trashed
RETURNING id
),
descendants AS (
SELECT id FROM restore_folder
UNION ALL
SELECT f.id FROM storage.folders f JOIN descendants d ON f.parent_id = d.id
),
restore_files AS (
UPDATE storage.files
SET is_trashed = FALSE,
trashed_at = NULL,
folder_id = COALESCE(original_folder_id, folder_id),
original_folder_id = NULL
WHERE folder_id IN (SELECT id FROM descendants) AND is_trashed
RETURNING 1
)
SELECT COUNT(*) FROM restore_folder
@@ -591,7 +586,23 @@ impl FolderRepository for FolderDbRepository {
}
async fn delete_folder_permanently(&self, folder_id: &str) -> Result<(), DomainError> {
// Permanently delete — CASCADE handles children
// First, delete all files in this folder and descendant folders
sqlx::query(
r#"
WITH RECURSIVE descendants AS (
SELECT id FROM storage.folders WHERE id = $1::uuid
UNION ALL
SELECT f.id FROM storage.folders f JOIN descendants d ON f.parent_id = d.id
)
DELETE FROM storage.files WHERE folder_id IN (SELECT id FROM descendants)
"#,
)
.bind(folder_id)
.execute(self.pool())
.await
.map_err(|e| DomainError::internal_error("FolderDb", format!("perm delete files: {e}")))?;
// Then permanently delete folder — CASCADE handles descendant folders
let result = sqlx::query("DELETE FROM storage.folders WHERE id = $1::uuid")
.bind(folder_id)
.execute(self.pool())
+41 -691
View File
@@ -4,274 +4,15 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OxiCloud — Admin Panel</title>
<!-- Apply saved theme immediately to prevent flash of light mode -->
<script>if(localStorage.getItem('oxicloud_theme')==='dark')document.documentElement.setAttribute('data-theme','dark');</script>
<script src="/js/icons.js" defer></script>
<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-text{font-size:17px;font-weight:700;color:#fff;letter-spacing:.3px}
.admin-title-separator{font-size:17px;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}
/* ── Dark Mode ── */
[data-theme="dark"] body{background:#0f172a;color:#e2e8f0}
[data-theme="dark"] ::-webkit-scrollbar-thumb{background:rgba(255,255,255,.15)}
[data-theme="dark"] *{scrollbar-color:rgba(255,255,255,.15) transparent}
[data-theme="dark"] .admin-tabs{background:#1e293b;box-shadow:0 1px 4px rgba(0,0,0,.2)}
[data-theme="dark"] .admin-tab{color:#94a3b8}
[data-theme="dark"] .admin-tab:hover{color:#f1f5f9;background:#162032}
[data-theme="dark"] .admin-card{background:#1e293b;box-shadow:0 1px 4px rgba(0,0,0,.2),0 0 0 1px rgba(255,255,255,.03)}
[data-theme="dark"] .admin-card h2{color:#f1f5f9}
[data-theme="dark"] .stat-card{background:#162032;border-color:#334155}
[data-theme="dark"] .stat-card:hover{box-shadow:0 4px 12px rgba(0,0,0,.15)}
[data-theme="dark"] .stat-value{color:#f1f5f9}
[data-theme="dark"] .stat-label{color:#64748b}
[data-theme="dark"] .stat-card.warn{border-color:#92400e;background:#422006}
[data-theme="dark"] .stat-card.danger{border-color:#991b1b;background:#3b1111}
[data-theme="dark"] .progress-bar{background:#334155}
[data-theme="dark"] .table-wrap{border-color:#334155}
[data-theme="dark"] th{background:#162032;color:#64748b;border-bottom-color:#334155}
[data-theme="dark"] td{border-bottom-color:#334155;color:#e2e8f0}
[data-theme="dark"] tr:hover{background:#162032}
[data-theme="dark"] .user-name{color:#f1f5f9}
[data-theme="dark"] .user-email{color:#64748b}
[data-theme="dark"] .badge-admin{background:#1e3a5f;color:#60a5fa}
[data-theme="dark"] .badge-user{background:#334155;color:#94a3b8}
[data-theme="dark"] .badge-active{background:#052e16;color:#86efac}
[data-theme="dark"] .badge-inactive{background:#3b1111;color:#fca5a5}
[data-theme="dark"] .badge-oidc{background:#2e1065;color:#c4b5fd}
[data-theme="dark"] .btn-secondary{background:#1e293b;color:#e2e8f0;border-color:#334155}
[data-theme="dark"] .btn-secondary:hover{background:#334155;border-color:#475569}
[data-theme="dark"] .btn-danger{background:#3b1111;color:#fca5a5;border-color:#991b1b}
[data-theme="dark"] .btn-danger:hover{background:#4a1515}
[data-theme="dark"] .btn-success{background:#052e16;color:#86efac;border-color:#065f46}
[data-theme="dark"] .btn-success:hover{background:#064e27}
[data-theme="dark"] .form-group label{color:#94a3b8}
[data-theme="dark"] .form-group input[type="text"],
[data-theme="dark"] .form-group input[type="password"],
[data-theme="dark"] .form-group input[type="url"],
[data-theme="dark"] .form-group input[type="number"],
[data-theme="dark"] .form-group select{background:#0f172a;border-color:#334155;color:#e2e8f0}
[data-theme="dark"] .form-group input:focus,
[data-theme="dark"] .form-group select:focus{border-color:#ff5e3a;background:#0f172a;box-shadow:0 0 0 3px rgba(255,94,58,.15)}
[data-theme="dark"] .form-group small{color:#64748b}
[data-theme="dark"] .toggle-row label{color:#94a3b8}
[data-theme="dark"] .slider{background:#475569}
[data-theme="dark"] .readonly-field{background:#0f172a;border-color:#334155;color:#e2e8f0}
[data-theme="dark"] .readonly-field button{background:#1e293b;border-color:#334155;color:#94a3b8}
[data-theme="dark"] .readonly-field button:hover{background:#334155;color:#f1f5f9}
[data-theme="dark"] .warning{background:#422006;border-color:#92400e;color:#fbbf24}
[data-theme="dark"] .alert-success{background:#052e16;color:#86efac;border-color:#065f46}
[data-theme="dark"] .alert-error{background:#3b1111;color:#fca5a5;border-color:#991b1b}
[data-theme="dark"] .alert-info{background:#0c2d48;color:#93c5fd;border-color:#1d4ed8}
[data-theme="dark"] .discovery-result.ok{background:#052e16;border-color:#065f46;color:#86efac}
[data-theme="dark"] .discovery-result.fail{background:#3b1111;border-color:#991b1b;color:#fca5a5}
[data-theme="dark"] details{border-top-color:#334155}
[data-theme="dark"] details summary{color:#94a3b8}
[data-theme="dark"] details summary:hover{color:#ff5e3a}
[data-theme="dark"] details[open] summary{color:#ff5e3a}
[data-theme="dark"] .modal{background:#1e293b;box-shadow:0 20px 60px rgba(0,0,0,.4)}
[data-theme="dark"] .modal h3{color:#f1f5f9}
[data-theme="dark"] .modal-overlay{background:rgba(0,0,0,.6);backdrop-filter:blur(4px)}
[data-theme="dark"] .quota-text{color:#64748b}
[data-theme="dark"] .pagination{color:#64748b}
[data-theme="dark"] #access-denied h2{color:#fca5a5}
[data-theme="dark"] #access-denied p{color:#94a3b8}
[data-theme="dark"] #access-denied .access-icon{background:#3b1111}
[data-theme="dark"] #loading{color:#64748b}
[data-theme="dark"] .toggle-row{border-top-color:#334155}
</style>
<script src="/js/core/icons.js" defer></script>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body>
<!-- Header -->
<div class="admin-header">
<div class="admin-header-left">
<a href="/" class="admin-logo-link" style="text-decoration:none;color:inherit;display:flex;align-items:center;gap:14px">
<a href="/" class="admin-logo-link link-reset-flex">
<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"/>
@@ -295,15 +36,13 @@ details[open] summary{margin-bottom:14px;color:#ff5e3a}
<a href="/login"><i class="fas fa-sign-in-alt"></i> Sign in</a>
</div>
<div id="main-content" style="display:none">
<!-- Tabs -->
<div id="main-content">
<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>
@@ -319,11 +58,11 @@ details[open] summary{margin-bottom:14px;color:#ff5e3a}
<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="progress-bar"><div class="progress-fill green width-zero" id="ds-bar"></div></div>
<div class="mt-14">
<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 class="stat-card warn" id="ds-warn-card"><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"><div class="stat-value text-red" id="ds-overquota">0</div><div class="stat-label">Users over quota</div></div>
</div>
</div>
</div>
@@ -335,18 +74,17 @@ details[open] summary{margin-bottom:14px;color:#ff5e3a}
<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>
<div class="toggle-row toggle-row-strong">
<label><i class="fas fa-user-plus icon-muted-right"></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 class="warning" id="registration-warning"><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>
<h2 class="h2-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>
@@ -359,12 +97,12 @@ details[open] summary{margin-bottom:14px;color:#ff5e3a}
<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>
<tbody id="users-tbody"><tr><td colspan="6" class="table-loading-cell"><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">
<div class="flex-gap-6">
<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>
@@ -372,7 +110,6 @@ details[open] summary{margin-bottom:14px;color:#ff5e3a}
</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>
@@ -380,7 +117,7 @@ details[open] summary{margin-bottom:14px;color:#ff5e3a}
<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 id="oidc-form">
<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">
@@ -390,7 +127,7 @@ details[open] summary{margin-bottom:14px;color:#ff5e3a}
<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">
<div class="oidc-discover-wrap">
<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>
@@ -401,14 +138,14 @@ details[open] summary{margin-bottom:14px;color:#ff5e3a}
<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>
<small id="secret-hint"><i class="fas fa-check-circle secret-icon"></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>
<label>Callback URL <small class="small-muted">(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>
<summary><i class="fas fa-sliders-h summary-icon-right"></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">
@@ -426,9 +163,9 @@ details[open] summary{margin-bottom:14px;color:#ff5e3a}
<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>
<div class="warning" id="password-warning"><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">
<div class="oidc-actions">
<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>
@@ -439,18 +176,17 @@ details[open] summary{margin-bottom:14px;color:#ff5e3a}
</div>
</div>
<!-- Quota Modal -->
<div id="quota-modal" class="modal-overlay" style="display:none">
<div id="quota-modal" class="modal-overlay">
<div class="modal">
<h3><i class="fas fa-box" style="color:#ff5e3a;margin-right:8px"></i> Update Storage Quota</h3>
<h3><i class="fas fa-box modal-title-icon"></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 class="quota-input-row">
<input type="number" id="qm-value" min="0" step="1" class="flex-1">
<select id="qm-unit" class="select-qm-unit"><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>
@@ -461,10 +197,9 @@ details[open] summary{margin-bottom:14px;color:#ff5e3a}
</div>
</div>
<!-- Create User Modal -->
<div id="create-user-modal" class="modal-overlay" style="display:none">
<div id="create-user-modal" class="modal-overlay">
<div class="modal">
<h3><i class="fas fa-user-plus" style="color:#ff5e3a;margin-right:8px"></i> Create New User</h3>
<h3><i class="fas fa-user-plus modal-title-icon"></i> Create New User</h3>
<div class="form-group">
<label>Username *</label>
<input type="text" id="cu-username" placeholder="johndoe" minlength="3" maxlength="32">
@@ -475,26 +210,26 @@ details[open] summary{margin-bottom:14px;color:#ff5e3a}
<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>
<label>Email <small class="small-muted">(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">
<div class="form-row">
<div class="form-group 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">
<select id="cu-role" class="select-cu-role">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div class="form-group" style="flex:1">
<div class="form-group 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 class="quota-row">
<input type="number" id="cu-quota-value" min="0" step="1" value="1" class="flex-1">
<select id="cu-quota-unit" class="select-cu-quota-unit"><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 id="cu-error" class="alert alert-no-margin"></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>
@@ -502,10 +237,9 @@ details[open] summary{margin-bottom:14px;color:#ff5e3a}
</div>
</div>
<!-- Reset Password Modal -->
<div id="reset-pw-modal" class="modal-overlay" style="display:none">
<div id="reset-pw-modal" class="modal-overlay">
<div class="modal">
<h3><i class="fas fa-key" style="color:#ff5e3a;margin-right:8px"></i> Reset Password</h3>
<h3><i class="fas fa-key modal-title-icon"></i> Reset Password</h3>
<div class="form-group">
<label>User: <strong id="rp-username"></strong></label>
</div>
@@ -513,7 +247,7 @@ details[open] summary{margin-bottom:14px;color:#ff5e3a}
<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 id="rp-error" class="alert alert-no-margin"></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>
@@ -521,390 +255,6 @@ details[open] summary{margin-bottom:14px;color:#ff5e3a}
</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>
<script src="/js/views/admin/admin.js" defer></script>
</body>
</html>
+299
View File
@@ -0,0 +1,299 @@
*{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}
.hidden{display:none !important}
.show-block{display:block !important}
.show-flex{display:flex !important}
.link-reset-flex{text-decoration:none;color:inherit;display:flex;align-items:center;gap:14px}
.width-zero{width:0%}
.mt-14{margin-top:14px}
.toggle-row-strong{margin-top:10px;padding:12px 0;border-top:1px solid #e2e8f0}
.icon-muted-right{color:#64748b;margin-right:6px}
.h2-space-between{justify-content:space-between}
.flex-gap-6{display:flex;gap:6px}
.oidc-discover-wrap{margin-bottom:12px}
.secret-icon{color:#059669}
.small-muted{font-weight:400;color:#94a3b8}
.summary-icon-right{margin-right:6px}
.oidc-actions{display:flex;gap:10px;margin-top:24px;justify-content:flex-end}
.modal-title-icon{color:#ff5e3a;margin-right:8px}
.quota-input-row{display:flex;gap:10px;align-items:center}
.flex-1{flex:1}
.select-qm-unit{width:90px;padding:10px 14px;border:2px solid #e2e8f0;border-radius:10px;font-size:14px;background:#f8fafc}
.form-row{display:flex;gap:14px}
.select-cu-role{width:100%;padding:10px 14px;border:2px solid #e2e8f0;border-radius:10px;font-size:14px;background:#f8fafc}
.quota-row{display:flex;gap:6px}
.select-cu-quota-unit{width:70px;padding:10px 8px;border:2px solid #e2e8f0;border-radius:10px;font-size:13px;background:#f8fafc}
.alert-no-margin{margin-top:0}
.table-loading-cell{text-align:center;padding:28px;color:#94a3b8}
.table-status-error{color:#991b1b;padding:20px}
.table-status-empty{text-align:center;padding:28px;color:#94a3b8}
.user-self-badge{color:#94a3b8;font-weight:400}
.badge-admin-icon-small{font-size:10px}
.quota-progress-fixed{width:80px}
.user-last-login-cell{font-size:12px;color:#94a3b8}
/* ── 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-text{font-size:17px;font-weight:700;color:#fff;letter-spacing:.3px}
.admin-title-separator{font-size:17px;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}
#main-content,
#ds-warn-card,
#ds-danger-card,
#registration-warning,
#oidc-form,
#secret-hint,
#password-warning,
#quota-modal,
#create-user-modal,
#reset-pw-modal{display:none}
/* ── 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}
/* ── Dark Mode ── */
[data-theme="dark"] body{background:#0f172a;color:#e2e8f0}
[data-theme="dark"] ::-webkit-scrollbar-thumb{background:rgba(255,255,255,.15)}
[data-theme="dark"] *{scrollbar-color:rgba(255,255,255,.15) transparent}
[data-theme="dark"] .admin-tabs{background:#1e293b;box-shadow:0 1px 4px rgba(0,0,0,.2)}
[data-theme="dark"] .admin-tab{color:#94a3b8}
[data-theme="dark"] .admin-tab:hover{color:#f1f5f9;background:#162032}
[data-theme="dark"] .admin-card{background:#1e293b;box-shadow:0 1px 4px rgba(0,0,0,.2),0 0 0 1px rgba(255,255,255,.03)}
[data-theme="dark"] .admin-card h2{color:#f1f5f9}
[data-theme="dark"] .stat-card{background:#162032;border-color:#334155}
[data-theme="dark"] .stat-card:hover{box-shadow:0 4px 12px rgba(0,0,0,.15)}
[data-theme="dark"] .stat-value{color:#f1f5f9}
[data-theme="dark"] .stat-label{color:#64748b}
[data-theme="dark"] .stat-card.warn{border-color:#92400e;background:#422006}
[data-theme="dark"] .stat-card.danger{border-color:#991b1b;background:#3b1111}
[data-theme="dark"] .progress-bar{background:#334155}
[data-theme="dark"] .table-wrap{border-color:#334155}
[data-theme="dark"] th{background:#162032;color:#64748b;border-bottom-color:#334155}
[data-theme="dark"] td{border-bottom-color:#334155;color:#e2e8f0}
[data-theme="dark"] tr:hover{background:#162032}
[data-theme="dark"] .user-name{color:#f1f5f9}
[data-theme="dark"] .user-email{color:#64748b}
[data-theme="dark"] .badge-admin{background:#1e3a5f;color:#60a5fa}
[data-theme="dark"] .badge-user{background:#334155;color:#94a3b8}
[data-theme="dark"] .badge-active{background:#052e16;color:#86efac}
[data-theme="dark"] .badge-inactive{background:#3b1111;color:#fca5a5}
[data-theme="dark"] .badge-oidc{background:#2e1065;color:#c4b5fd}
[data-theme="dark"] .btn-secondary{background:#1e293b;color:#e2e8f0;border-color:#334155}
[data-theme="dark"] .btn-secondary:hover{background:#334155;border-color:#475569}
[data-theme="dark"] .btn-danger{background:#3b1111;color:#fca5a5;border-color:#991b1b}
[data-theme="dark"] .btn-danger:hover{background:#4a1515}
[data-theme="dark"] .btn-success{background:#052e16;color:#86efac;border-color:#065f46}
[data-theme="dark"] .btn-success:hover{background:#064e27}
[data-theme="dark"] .form-group label{color:#94a3b8}
[data-theme="dark"] .form-group input[type="text"],
[data-theme="dark"] .form-group input[type="password"],
[data-theme="dark"] .form-group input[type="url"],
[data-theme="dark"] .form-group input[type="number"],
[data-theme="dark"] .form-group select{background:#0f172a;border-color:#334155;color:#e2e8f0}
[data-theme="dark"] .form-group input:focus,
[data-theme="dark"] .form-group select:focus{border-color:#ff5e3a;background:#0f172a;box-shadow:0 0 0 3px rgba(255,94,58,.15)}
[data-theme="dark"] .form-group small{color:#64748b}
[data-theme="dark"] .toggle-row label{color:#94a3b8}
[data-theme="dark"] .slider{background:#475569}
[data-theme="dark"] .readonly-field{background:#0f172a;border-color:#334155;color:#e2e8f0}
[data-theme="dark"] .readonly-field button{background:#1e293b;border-color:#334155;color:#94a3b8}
[data-theme="dark"] .readonly-field button:hover{background:#334155;color:#f1f5f9}
[data-theme="dark"] .warning{background:#422006;border-color:#92400e;color:#fbbf24}
[data-theme="dark"] .alert-success{background:#052e16;color:#86efac;border-color:#065f46}
[data-theme="dark"] .alert-error{background:#3b1111;color:#fca5a5;border-color:#991b1b}
[data-theme="dark"] .alert-info{background:#0c2d48;color:#93c5fd;border-color:#1d4ed8}
[data-theme="dark"] .discovery-result.ok{background:#052e16;border-color:#065f46;color:#86efac}
[data-theme="dark"] .discovery-result.fail{background:#3b1111;border-color:#991b1b;color:#fca5a5}
[data-theme="dark"] details{border-top-color:#334155}
[data-theme="dark"] details summary{color:#94a3b8}
[data-theme="dark"] details summary:hover{color:#ff5e3a}
[data-theme="dark"] details[open] summary{color:#ff5e3a}
[data-theme="dark"] .modal{background:#1e293b;box-shadow:0 20px 60px rgba(0,0,0,.4)}
[data-theme="dark"] .modal h3{color:#f1f5f9}
[data-theme="dark"] .modal-overlay{background:rgba(0,0,0,.6);backdrop-filter:blur(4px)}
[data-theme="dark"] .quota-text{color:#64748b}
[data-theme="dark"] .pagination{color:#64748b}
[data-theme="dark"] #access-denied h2{color:#fca5a5}
[data-theme="dark"] #access-denied p{color:#94a3b8}
[data-theme="dark"] #access-denied .access-icon{background:#3b1111}
[data-theme="dark"] #loading{color:#64748b}
[data-theme="dark"] .toggle-row{border-top-color:#334155}
+148
View File
@@ -0,0 +1,148 @@
*{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}
.link-reset-flex{text-decoration:none;color:inherit;display:flex;align-items:center;gap:14px}
.width-zero{width:0%}
#main-content{display:none}
/* ── Scrollbar ── */
::-webkit-scrollbar{width:8px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px}
*{scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
/* ── Header ── */
.profile-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;
}
.profile-header-left{display:flex;align-items:center;gap:14px}
.profile-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);
}
.profile-logo svg{width:20px;height:20px;fill:#fff}
.profile-title-text{font-size:17px;font-weight:700;color:#fff;letter-spacing:.3px}
.profile-title-separator{font-size:17px;color:rgba(255,255,255,.5);font-weight:400;margin-left:6px}
.profile-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;
}
.profile-header-right a:hover{color:#fff}
/* ── Container ── */
.profile-container{max-width:720px;margin:0 auto;padding:32px 24px 60px;width:100%}
/* ── Card ── */
.profile-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:32px;margin-bottom:22px}
.profile-card h2{font-size:16px;font-weight:700;margin-bottom:20px;color:#1e293b;display:flex;align-items:center;gap:10px}
.profile-card h2 i{color:#ff5e3a;font-size:17px}
/* ── Avatar Section ── */
.avatar-section{display:flex;align-items:center;gap:24px;margin-bottom:8px}
.avatar-large{
width:88px;height:88px;border-radius:50%;
background:linear-gradient(135deg,#ff5e3a,#ff2d55);
display:flex;align-items:center;justify-content:center;
color:#fff;font-size:32px;font-weight:700;letter-spacing:1px;
box-shadow:0 6px 20px rgba(255,94,58,.3);flex-shrink:0;
}
.avatar-info h1{font-size:22px;font-weight:700;color:#1e293b;margin-bottom:4px}
.avatar-info .email{font-size:14px;color:#64748b;margin-bottom:8px}
.avatar-info .role-badge{
display:inline-flex;align-items:center;gap:5px;font-size:12px;font-weight:600;
padding:4px 12px;border-radius:20px;
}
.role-badge-admin{background:#dbeafe;color:#1d4ed8}
.role-badge-user{background:#f1f5f9;color:#64748b}
/* ── Info Grid ── */
.info-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
@media(max-width:560px){.info-grid{grid-template-columns:1fr}}
.info-item{padding:16px;background:#f8fafc;border-radius:12px;border:1px solid #e2e8f0}
.info-item .info-label{font-size:11.5px;color:#94a3b8;text-transform:uppercase;letter-spacing:.06em;font-weight:700;margin-bottom:4px;display:flex;align-items:center;gap:6px}
.info-item .info-label i{font-size:12px;color:#cbd5e1}
.info-item .info-value{font-size:15px;font-weight:600;color:#1e293b}
/* ── Storage ── */
.storage-section{margin-top:4px}
.storage-stats{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px;margin-bottom:16px}
@media(max-width:560px){.storage-stats{grid-template-columns:1fr}}
.storage-stat{text-align:center;padding:16px;background:#f8fafc;border-radius:12px;border:1px solid #e2e8f0}
.storage-stat .stat-value{font-size:1.5rem;font-weight:800;color:#1e293b}
.storage-stat .stat-label{font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.06em;font-weight:600;margin-top:2px}
.storage-bar-wrap{margin-top:4px}
.storage-bar{height:10px;background:#e2e8f0;border-radius:5px;overflow:hidden}
.storage-fill{height:100%;border-radius:5px;transition:width .6s ease}
.storage-fill.green{background:linear-gradient(90deg,#059669,#10b981)}
.storage-fill.orange{background:linear-gradient(90deg,#d97706,#f59e0b)}
.storage-fill.red{background:linear-gradient(90deg,#dc2626,#ef4444)}
.storage-text{font-size:12px;color:#94a3b8;text-align:right;margin-top:6px}
/* ── Change Password ── */
.form-group{margin-bottom:16px}
.form-group label{display:block;font-size:13px;font-weight:600;margin-bottom:4px;color:#334155}
.form-group input{
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{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}
.btn{
padding:10px 22px;border:none;border-radius:10px;font-size:14px;font-weight:600;
cursor:pointer;transition:all .15s;white-space:nowrap;display:inline-flex;align-items:center;gap:6px;
}
.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-primary:disabled{opacity:.4;cursor:not-allowed;transform:none!important}
.alert{padding:12px 16px;border-radius:10px;font-size:13px;margin-top:14px;font-weight:500}
.alert-success{background:#ecfdf5;color:#065f46;border:1px solid #a7f3d0}
.alert-error{background:#fef2f2;color:#991b1b;border:1px solid #fecaca}
/* ── Loading / Error ── */
#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)}}
#auth-error{display:none;text-align:center;padding:80px 20px}
#auth-error .err-icon{width:80px;height:80px;background:#fef2f2;border-radius:50%;display:flex;align-items:center;justify-content:center;margin:0 auto 20px}
#auth-error .err-icon i{font-size:32px;color:#ef4444}
#auth-error h2{color:#991b1b;margin-bottom:8px;font-size:20px}
#auth-error p{color:#64748b;margin-bottom:20px;font-size:14px}
#auth-error 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}
#auth-error a:hover{transform:translateY(-1px);box-shadow:0 5px 18px rgba(255,94,58,.4)}
/* ── Dark Mode ── */
[data-theme="dark"] body{background:#0f172a;color:#e2e8f0}
[data-theme="dark"] ::-webkit-scrollbar-thumb{background:rgba(255,255,255,.15)}
[data-theme="dark"] *{scrollbar-color:rgba(255,255,255,.15) transparent}
[data-theme="dark"] .profile-card{background:#1e293b;box-shadow:0 1px 4px rgba(0,0,0,.2),0 0 0 1px rgba(255,255,255,.03)}
[data-theme="dark"] .profile-card h2{color:#f1f5f9}
[data-theme="dark"] .avatar-info h1{color:#f1f5f9}
[data-theme="dark"] .avatar-info .email{color:#94a3b8}
[data-theme="dark"] .role-badge-admin{background:#1e3a5f;color:#60a5fa}
[data-theme="dark"] .role-badge-user{background:#334155;color:#94a3b8}
[data-theme="dark"] .info-item{background:#162032;border-color:#334155}
[data-theme="dark"] .info-item .info-label{color:#64748b}
[data-theme="dark"] .info-item .info-label i{color:#475569}
[data-theme="dark"] .info-item .info-value{color:#f1f5f9}
[data-theme="dark"] .storage-stat{background:#162032;border-color:#334155}
[data-theme="dark"] .storage-stat .stat-value{color:#f1f5f9}
[data-theme="dark"] .storage-stat .stat-label{color:#64748b}
[data-theme="dark"] .storage-bar{background:#334155}
[data-theme="dark"] .storage-text{color:#64748b}
[data-theme="dark"] .form-group label{color:#94a3b8}
[data-theme="dark"] .form-group input{background:#0f172a;border-color:#334155;color:#e2e8f0}
[data-theme="dark"] .form-group input:focus{border-color:#ff5e3a;background:#0f172a;box-shadow:0 0 0 3px rgba(255,94,58,.15)}
[data-theme="dark"] .form-group small{color:#64748b}
[data-theme="dark"] .alert-success{background:#052e16;color:#86efac;border-color:#065f46}
[data-theme="dark"] .alert-error{background:#3b1111;color:#fca5a5;border-color:#991b1b}
[data-theme="dark"] #auth-error{background:transparent}
[data-theme="dark"] #auth-error .err-icon{background:#3b1111}
[data-theme="dark"] #auth-error h2{color:#fca5a5}
[data-theme="dark"] #auth-error p{color:#94a3b8}
[data-theme="dark"] #loading{color:#64748b}
-8
View File
@@ -1,8 +0,0 @@
while IFS= read -r -d '' file; do
if grep -Iq . "$file"; then
echo "===== $file ====="
cat "$file"
echo -e "\n"
fi
done < <(find . -type f -print0)
+27 -16
View File
@@ -14,22 +14,33 @@
<link rel="stylesheet" href="/css/recent.css">
<!-- Scripts (defer: download in parallel, execute in order, after HTML parsed) -->
<script defer src="/js/i18n.js"></script>
<script defer src="/js/languageSelector.js"></script>
<script defer src="/js/notifications.js"></script>
<script defer src="/js/modal.js"></script>
<script defer src="/js/ui.js"></script>
<script defer src="/js/contextMenus.js"></script>
<script defer src="/js/fileOperations.js"></script>
<script defer src="/js/multiSelect.js"></script>
<script defer src="/js/search.js"></script>
<script defer src="/js/favorites.js"></script>
<script defer src="/js/recent.js"></script>
<script defer src="/js/fileSharing.js"></script>
<script defer src="/js/components/sharedView.js"></script>
<script defer src="/js/inlineViewer.js"></script>
<script defer src="/js/icons.js"></script>
<script defer src="/js/app.js"></script>
<script defer src="/js/core/i18n.js"></script>
<script defer src="/js/core/languageSelector.js"></script>
<script defer src="/js/core/notifications.js"></script>
<script defer src="/js/core/modal.js"></script>
<script defer src="/js/core/formatters.js"></script>
<script defer src="/js/app/state.js"></script>
<script defer src="/js/app/uiFileTypes.js"></script>
<script defer src="/js/app/uiNotifications.js"></script>
<script defer src="/js/app/ui.js"></script>
<script defer src="/js/features/files/contextMenus.js"></script>
<script defer src="/js/features/files/fileOperations.js"></script>
<script defer src="/js/features/files/multiSelect.js"></script>
<script defer src="/js/features/files/search.js"></script>
<script defer src="/js/features/library/favorites.js"></script>
<script defer src="/js/features/library/recent.js"></script>
<script defer src="/js/features/sharing/fileSharing.js"></script>
<script defer src="/js/views/shared/sharedView.js"></script>
<script defer src="/js/features/files/inlineViewer.js"></script>
<script defer src="/js/core/icons.js"></script>
<script defer src="/js/app/navigation.js"></script>
<script defer src="/js/app/authSession.js"></script>
<script defer src="/js/app/userMenu.js"></script>
<script defer src="/js/app/filesView.js"></script>
<script defer src="/js/app/trashView.js"></script>
<script defer src="/js/app/searchView.js"></script>
<script defer src="/js/app/main.js"></script>
<script defer src="/js/app/bootstrap.js"></script>
<!-- Service Worker Registration -->
<script>
-1843
View File
File diff suppressed because it is too large Load Diff
+224
View File
@@ -0,0 +1,224 @@
/**
* Authentication/session bootstrap and home-folder resolution
*/
async function refreshUserData() {
const TOKEN_KEY = 'oxicloud_token';
const USER_DATA_KEY = 'oxicloud_user';
const token = localStorage.getItem(TOKEN_KEY);
console.log('refreshUserData called, token:', token ? token.substring(0, 20) + '...' : 'null');
if (!token) {
console.log('No valid token, skipping user data refresh');
return null;
}
try {
console.log('Fetching /api/auth/me...');
const response = await fetch('/api/auth/me', {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
console.log('/api/auth/me response status:', response.status);
if (!response.ok) {
console.warn('Failed to fetch user data:', response.status);
return null;
}
const userData = await response.json();
console.log('Refreshed user data from server:', userData);
console.log('Storage from server: used=', userData.storage_used_bytes, 'quota=', userData.storage_quota_bytes);
localStorage.setItem(USER_DATA_KEY, JSON.stringify(userData));
window.updateStorageUsageDisplay(userData);
return userData;
} catch (error) {
console.error('Error refreshing user data:', error);
return null;
}
}
async function checkAuthentication() {
try {
const TOKEN_KEY = 'oxicloud_token';
const REFRESH_TOKEN_KEY = 'oxicloud_refresh_token';
const TOKEN_EXPIRY_KEY = 'oxicloud_token_expiry';
const USER_DATA_KEY = 'oxicloud_user';
const urlParams = new URLSearchParams(window.location.search);
const oidcCode = urlParams.get('oidc_code');
if (oidcCode) {
console.log('OIDC exchange code detected, exchanging for tokens...');
try {
const exchangeResponse = await fetch('/api/auth/oidc/exchange', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: oidcCode })
});
if (!exchangeResponse.ok) {
const errText = await exchangeResponse.text();
console.error('OIDC token exchange failed:', exchangeResponse.status, errText);
window.location.href = '/login?source=oidc_error';
return;
}
const data = await exchangeResponse.json();
console.log('OIDC token exchange successful');
const token = data.access_token || data.token;
const refreshToken = data.refresh_token || data.refreshToken;
if (token) {
localStorage.setItem(TOKEN_KEY, token);
if (refreshToken) localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
let parsedExpiry = false;
const tokenParts = token.split('.');
if (tokenParts.length === 3) {
try {
const payload = JSON.parse(atob(tokenParts[1]));
if (payload.exp) {
const expiryDate = new Date(payload.exp * 1000);
if (!isNaN(expiryDate.getTime())) {
localStorage.setItem(TOKEN_EXPIRY_KEY, expiryDate.toISOString());
parsedExpiry = true;
}
}
} catch (e) {
console.error('Error parsing JWT:', e);
}
}
if (!parsedExpiry) {
const expiry = new Date();
expiry.setDate(expiry.getDate() + 30);
localStorage.setItem(TOKEN_EXPIRY_KEY, expiry.toISOString());
}
if (data.user) {
localStorage.setItem(USER_DATA_KEY, JSON.stringify(data.user));
}
window.history.replaceState({}, document.title, '/');
window.location.reload();
return;
}
} catch (err) {
console.error('OIDC exchange error:', err);
window.location.href = '/login?source=oidc_error';
return;
}
}
const token = localStorage.getItem(TOKEN_KEY);
if (!token) {
console.log('No token found, redirecting to login');
window.location.href = '/login?source=app';
return;
}
console.log('Token found, proceeding with app initialization');
const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}');
if (userData.username) {
const userInitials = userData.username.substring(0, 2).toUpperCase();
document.querySelectorAll('.user-avatar, .user-menu-avatar').forEach(el => {
el.textContent = userInitials;
});
const menuName = document.getElementById('user-menu-name');
const menuEmail = document.getElementById('user-menu-email');
if (menuName) menuName.textContent = userData.username;
if (menuEmail) menuEmail.textContent = userData.email || '';
window.updateStorageUsageDisplay(userData);
refreshUserData().then(freshData => {
if (freshData) {
console.log('Storage usage updated from server');
}
}).catch(err => {
console.warn('Could not refresh user data:', err);
});
resolveHomeFolder().then(() => window.loadFiles());
} else {
console.log('No user data, attempting to fetch from server');
try {
const freshData = await refreshUserData();
if (freshData && freshData.username) {
const userInitials = freshData.username.substring(0, 2).toUpperCase();
document.querySelectorAll('.user-avatar, .user-menu-avatar').forEach(el => el.textContent = userInitials);
window.updateStorageUsageDisplay(freshData);
resolveHomeFolder().then(() => window.loadFiles());
} else {
console.warn('Could not retrieve user data, redirecting to login');
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
localStorage.removeItem(TOKEN_EXPIRY_KEY);
localStorage.removeItem(USER_DATA_KEY);
window.location.href = '/login?source=invalid_session';
}
} catch (err) {
console.error('Failed to fetch user data:', err);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
localStorage.removeItem(TOKEN_EXPIRY_KEY);
localStorage.removeItem(USER_DATA_KEY);
window.location.href = '/login?source=session_error';
}
}
} catch (error) {
console.error('Error during authentication check:', error);
localStorage.removeItem('oxicloud_token');
localStorage.removeItem('oxicloud_refresh_token');
localStorage.removeItem('oxicloud_token_expiry');
localStorage.removeItem('oxicloud_user');
window.location.href = '/login?source=auth_error';
}
}
async function resolveHomeFolder() {
const app = window.app;
if (app.userHomeFolderId) return;
try {
const token = localStorage.getItem('oxicloud_token');
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
const response = await fetch('/api/folders', { headers });
if (!response.ok) {
console.warn(`Could not fetch home folder: ${response.status}`);
return;
}
const folders = await response.json();
const folderList = Array.isArray(folders) ? folders : [];
if (folderList.length > 0) {
const home = folderList[0];
app.userHomeFolderId = home.id;
app.userHomeFolderName = home.name;
app.currentPath = home.id;
window.ui.updateBreadcrumb(home.name);
console.log(`Home folder resolved: ${home.name} (${home.id})`);
} else {
console.warn('No root folders found for user');
app.currentPath = '';
window.ui.updateBreadcrumb('');
}
} catch (error) {
console.error('Error resolving home folder:', error);
app.currentPath = '';
window.ui.updateBreadcrumb('');
}
}
window.refreshUserData = refreshUserData;
window.checkAuthentication = checkAuthentication;
window.resolveHomeFolder = resolveHomeFolder;
+14
View File
@@ -0,0 +1,14 @@
/**
* OxiCloud - App bootstrap
* Isolated startup trigger for the main application initializer.
*/
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
if (typeof window.initApp === 'function') {
window.initApp();
}
});
} else if (typeof window.initApp === 'function') {
window.initApp();
}
+127
View File
@@ -0,0 +1,127 @@
/**
* Files view loading logic
*/
async function loadFiles(options = {}) {
const app = window.app;
const elements = window.appElements;
try {
console.log("Starting loadFiles() - loading files...", options);
const forceRefresh = options.forceRefresh || false;
if (window.isLoadingFiles) {
console.log("A file load is already in progress, ignoring request");
return;
}
window.isLoadingFiles = true;
elements.filesGrid.innerHTML = `
<div class="files-loading-spinner">
<div class="spinner"></div>
<span>${window.i18n ? window.i18n.t('files.loading') : 'Loading files…'}</span>
</div>
`;
if (!app.userHomeFolderId) {
await window.resolveHomeFolder();
}
const timestamp = new Date().getTime();
let url;
if (!app.currentPath || app.currentPath === '') {
if (app.userHomeFolderId) {
url = `/api/folders/${app.userHomeFolderId}/listing?t=${timestamp}`;
app.currentPath = app.userHomeFolderId;
window.ui.updateBreadcrumb(app.userHomeFolderName || 'Home');
console.log(`Loading user folder: ${app.userHomeFolderName} (${app.userHomeFolderId})`);
} else {
url = `/api/folders?t=${timestamp}`;
console.warn("Emergency fallback to root folder - this should not normally happen");
}
} else {
url = `/api/folders/${app.currentPath}/listing?t=${timestamp}`;
console.log(`Loading subfolder content: ${app.currentPath}`);
}
const token = localStorage.getItem('oxicloud_token');
const headers = {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache'
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const requestOptions = {
headers,
cache: 'no-store'
};
if (forceRefresh) {
url += `&force_refresh=true`;
requestOptions.headers['X-Force-Refresh'] = 'true';
console.log('Forcing complete refresh ignoring cache');
}
console.log(`Loading listing from ${url}`);
const response = await fetch(url, requestOptions);
if (response.status === 401 || response.status === 403) {
console.warn("Auth error when loading files, showing empty list");
elements.filesGrid.innerHTML = '<div class="empty-state"><p>Could not load files</p></div>';
elements.filesListView.innerHTML = `
<div class="list-header">
<div class="list-header-checkbox"><input type="checkbox" id="select-all-checkbox" title="Select all"></div>
<div>Name</div>
<div>Type</div>
<div>Size</div>
<div>Modified</div>
</div>
`;
return;
}
if (!response.ok) {
throw new Error(`Server responded with status: ${response.status}`);
}
const listing = await response.json();
if (window.multiSelect) window.multiSelect.clear();
window.ui._items.clear();
elements.filesGrid.innerHTML = '';
const _t = (window.i18n && window.i18n.t) ? window.i18n.t : k => k.split('.').pop();
elements.filesListView.innerHTML = `
<div class="list-header">
<div class="list-header-checkbox"><input type="checkbox" id="select-all-checkbox" title="Select all"></div>
<div data-i18n="files.name">${_t('files.name')}</div>
<div data-i18n="files.type">${_t('files.type')}</div>
<div data-i18n="files.size">${_t('files.size')}</div>
<div data-i18n="files.modified">${_t('files.modified')}</div>
</div>
`;
const selectAllCb = document.getElementById('select-all-checkbox');
if (selectAllCb && window.multiSelect) {
selectAllCb.addEventListener('change', () => window.multiSelect.toggleAll());
}
const folderList = Array.isArray(listing.folders) ? listing.folders : [];
const fileList = Array.isArray(listing.files) ? listing.files : [];
window.ui.renderFolders(folderList);
window.ui.renderFiles(fileList);
console.log(`Loaded ${folderList.length} folders and ${fileList.length} files`);
} catch (error) {
console.error('Error loading folders:', error);
window.ui.showNotification('Error', 'Could not load files and folders');
} finally {
window.isLoadingFiles = false;
}
}
window.loadFiles = loadFiles;
+603
View File
@@ -0,0 +1,603 @@
/**
* OxiCloud - Main Application
* This file contains the core functionality, initialization and state management
*/
const app = window.app;
const elements = window.appElements;
// Upload dropdown listener state (prevents accumulated listeners)
let uploadDropdownDocumentClickHandler = null;
let uploadDropdownBindingsController = null;
let actionsBarDelegationBound = false;
const ACTIONS_BAR_TEMPLATES = {
files: `
<div class="action-buttons">
<div class="upload-dropdown" id="upload-dropdown">
<button class="btn btn-primary" id="upload-btn">
<i class="fas fa-cloud-upload-alt" style="margin-right: 5px;"></i>
<span data-i18n="actions.upload">Upload</span>
<i class="fas fa-caret-down" style="margin-left: 4px; font-size: 12px;"></i>
</button>
<div class="upload-dropdown-menu" id="upload-dropdown-menu">
<button class="upload-dropdown-item" id="upload-files-btn">
<i class="fas fa-file"></i>
<span data-i18n="actions.upload_files">Upload files</span>
</button>
<button class="upload-dropdown-item" id="upload-folder-btn">
<i class="fas fa-folder-open"></i>
<span data-i18n="actions.upload_folder">Upload folder</span>
</button>
</div>
</div>
<button class="btn btn-secondary" id="new-folder-btn">
<i class="fas fa-folder-plus" style="margin-right: 5px;"></i>
<span data-i18n="actions.new_folder">New folder</span>
</button>
</div>
<div class="view-toggle">
<button class="toggle-btn active" id="grid-view-btn" title="Grid view">
<i class="fas fa-th"></i>
</button>
<button class="toggle-btn" id="list-view-btn" title="List view">
<i class="fas fa-list"></i>
</button>
</div>
`,
trash: `
<div class="action-buttons">
<button class="btn btn-danger" id="empty-trash-btn">
<i class="fas fa-trash-alt"></i>
<span data-i18n="trash.empty_trash">Empty trash</span>
</button>
</div>
`,
favorites: `
<div class="action-buttons"></div>
<div class="view-toggle">
<button class="toggle-btn active" id="grid-view-btn" title="Grid view">
<i class="fas fa-th"></i>
</button>
<button class="toggle-btn" id="list-view-btn" title="List view">
<i class="fas fa-list"></i>
</button>
</div>
`,
recent: `
<div class="action-buttons">
<button class="btn btn-secondary" id="clear-recent-btn">
<i class="fas fa-broom" style="margin-right: 5px;"></i>
<span data-i18n="actions.clear_recent">Clear recent</span>
</button>
</div>
<div class="view-toggle">
<button class="toggle-btn active" id="grid-view-btn" title="Grid view">
<i class="fas fa-th"></i>
</button>
<button class="toggle-btn" id="list-view-btn" title="List view">
<i class="fas fa-list"></i>
</button>
</div>
`
};
function setActionsBarMode(mode, force = false) {
if (!elements.actionsBar) return;
if (mode === 'hidden') {
elements.actionsBar.style.display = 'none';
elements.actionsBar.dataset.mode = 'hidden';
return;
}
if (!force && elements.actionsBar.dataset.mode === mode) {
return;
}
const html = ACTIONS_BAR_TEMPLATES[mode];
if (!html) return;
elements.actionsBar.innerHTML = html;
elements.actionsBar.style.display = 'flex';
elements.actionsBar.dataset.mode = mode;
// Refresh cached action elements after rebuild
elements.uploadBtn = document.getElementById('upload-btn');
elements.newFolderBtn = document.getElementById('new-folder-btn');
elements.gridViewBtn = document.getElementById('grid-view-btn');
elements.listViewBtn = document.getElementById('list-view-btn');
if (window.i18n && window.i18n.translateElement) {
window.i18n.translateElement(elements.actionsBar);
}
if (mode === 'files') {
setupUploadDropdown();
}
}
function setupActionsBarDelegation() {
if (actionsBarDelegationBound || !elements.actionsBar) return;
actionsBarDelegationBound = true;
elements.actionsBar.addEventListener('click', async (e) => {
const btn = e.target.closest('button');
if (!btn) return;
switch (btn.id) {
case 'upload-files-btn': {
e.stopPropagation();
const menu = document.getElementById('upload-dropdown-menu');
if (menu) menu.classList.remove('show');
if (elements.fileInput) elements.fileInput.click();
break;
}
case 'upload-folder-btn': {
e.stopPropagation();
const menu = document.getElementById('upload-dropdown-menu');
if (menu) menu.classList.remove('show');
const folderInput = document.getElementById('folder-input');
if (folderInput) folderInput.click();
break;
}
case 'new-folder-btn': {
const folderName = await window.Modal.promptNewFolder();
if (folderName) {
fileOps.createFolder(folderName);
}
break;
}
case 'grid-view-btn':
ui.switchToGridView();
break;
case 'list-view-btn':
ui.switchToListView();
break;
case 'empty-trash-btn':
if (await fileOps.emptyTrash()) {
window.loadTrashItems();
}
break;
case 'clear-recent-btn':
if (window.recent) {
window.recent.clearRecentFiles();
window.recent.displayRecentFiles();
window.ui.showNotification('Cleanup completed', 'Recent files history has been cleared');
}
break;
default:
break;
}
});
}
/**
* Initialize the application
*/
function initApp() {
// Cache DOM elements
cacheElements();
// Initialize file sharing module first
if (window.fileSharing && window.fileSharing.init) {
window.fileSharing.init();
} else {
console.warn('fileSharing module not fully initialized');
}
// Then create menus and dialogs after modules have initialized
setTimeout(() => {
ui.initializeContextMenus();
}, 100);
// Setup event listeners
setupEventListeners();
// Ensure inline viewer is initialized
if (!window.inlineViewer && typeof InlineViewer !== 'undefined') {
try {
window.inlineViewer = new InlineViewer();
} catch (e) {
console.error('Error initializing inline viewer:', e);
}
}
// Initialize favorites module if available
if (window.favorites && window.favorites.init) {
console.log('Initializing favorites module');
window.favorites.init();
} else {
console.warn('Favorites module not available or not initializable');
}
// Initialize recent files module if available
if (window.recent && window.recent.init) {
console.log('Initializing recent files module');
window.recent.init();
} else {
console.warn('Recent files module not available or not initializable');
}
// Initialize multi-select / batch actions
if (window.multiSelect && window.multiSelect.init) {
console.log('Initializing multi-select module');
window.multiSelect.init();
}
// Wait for translations to load before checking authentication
if (window.i18n && window.i18n.isLoaded && window.i18n.isLoaded()) {
// Translations already loaded, proceed with authentication
window.checkAuthentication();
} else {
// Wait for translations to be loaded before proceeding
console.log('Waiting for translations to load...');
window.addEventListener('translationsLoaded', () => {
console.log('Translations loaded, proceeding with authentication');
window.checkAuthentication();
});
// Set a timeout as a fallback in case translations take too long
setTimeout(() => {
if (!window.i18n || !window.i18n.isLoaded || !window.i18n.isLoaded()) {
console.warn('Translations loading timeout, proceeding with authentication anyway');
window.checkAuthentication();
}
}, 3000); // 3 second timeout
}
}
/**
* Cache DOM elements for faster access
*/
function cacheElements() {
elements.uploadBtn = document.getElementById('upload-btn');
elements.dropzone = document.getElementById('dropzone');
elements.fileInput = document.getElementById('file-input');
elements.filesGrid = document.getElementById('files-grid');
elements.filesListView = document.getElementById('files-list-view');
elements.newFolderBtn = document.getElementById('new-folder-btn');
elements.gridViewBtn = document.getElementById('grid-view-btn');
elements.listViewBtn = document.getElementById('list-view-btn');
elements.breadcrumb = document.querySelector('.breadcrumb');
elements.pageTitle = document.querySelector('.page-title');
elements.actionsBar = document.querySelector('.actions-bar');
elements.navItems = document.querySelectorAll('.nav-item');
elements.trashBtn = document.querySelector('.nav-item:nth-child(5)'); // The trash nav item
elements.searchInput = document.querySelector('.search-container input');
}
/**
* Setup the upload dropdown button and menu
* Handles opening/closing the dropdown and triggering file/folder inputs
*/
function setupUploadDropdown() {
const uploadBtn = document.getElementById('upload-btn');
const menu = document.getElementById('upload-dropdown-menu');
if (!uploadBtn || !menu) return;
// Abort any previous local bindings (safe across repeated/rebuilt UI)
if (uploadDropdownBindingsController) {
uploadDropdownBindingsController.abort();
}
uploadDropdownBindingsController = new AbortController();
const signal = uploadDropdownBindingsController.signal;
// Toggle dropdown on button click
uploadBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = menu.classList.contains('show');
// Close any other open dropdowns
document.querySelectorAll('.upload-dropdown-menu.show').forEach(m => m.classList.remove('show'));
if (!isOpen) {
menu.classList.add('show');
}
}, { signal });
// Close dropdown when clicking outside
// remove+add stable handler: guarantees exactly one global listener
if (uploadDropdownDocumentClickHandler) {
document.removeEventListener('click', uploadDropdownDocumentClickHandler);
}
uploadDropdownDocumentClickHandler = (e) => {
if (e.target.closest('#upload-dropdown')) return;
document.querySelectorAll('.upload-dropdown-menu.show').forEach(m => m.classList.remove('show'));
};
document.addEventListener('click', uploadDropdownDocumentClickHandler);
}
/**
* Setup event listeners for main UI elements
*/
function setupEventListeners() {
// Set up drag and drop
ui.setupDragAndDrop();
// Debounce timer for live search
let searchDebounceTimer = null;
const SEARCH_DEBOUNCE_MS = 300;
const SEARCH_MIN_CHARS = 3;
// Search input — Enter key
elements.searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
// Cancel any pending debounce
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
const query = elements.searchInput.value.trim();
if (query) {
window.performSearch(query);
} else if (app.isSearchMode) {
// If search is empty and we're in search mode, return to normal view
app.isSearchMode = false;
app.currentPath = '';
ui.updateBreadcrumb('');
window.loadFiles();
}
}
});
// Search input — Live search (debounced, after 3+ chars)
elements.searchInput.addEventListener('input', () => {
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
const query = elements.searchInput.value.trim();
if (query.length >= SEARCH_MIN_CHARS) {
searchDebounceTimer = setTimeout(() => {
window.performSearch(query);
}, SEARCH_DEBOUNCE_MS);
} else if (query.length === 0 && app.isSearchMode) {
// User cleared the search input — return to normal view
searchDebounceTimer = setTimeout(() => {
app.isSearchMode = false;
app.currentPath = '';
ui.updateBreadcrumb('');
window.loadFiles();
}, SEARCH_DEBOUNCE_MS);
}
});
// Search button
document.getElementById('search-button').addEventListener('click', () => {
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
const query = elements.searchInput.value.trim();
if (query) {
window.performSearch(query);
}
});
// Upload dropdown
setupUploadDropdown();
setupActionsBarDelegation();
if (elements.actionsBar) {
elements.actionsBar.dataset.mode = 'files';
}
// File input
elements.fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
fileOps.uploadFiles(e.target.files);
e.target.value = ''; // reset so same file can be re-uploaded
}
});
// Folder input
const folderInput = document.getElementById('folder-input');
if (folderInput) {
folderInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
fileOps.uploadFolderFiles(e.target.files);
e.target.value = '';
}
});
}
// Sidebar navigation
elements.navItems.forEach(item => {
item.addEventListener('click', () => {
// Remove active class from all nav items
elements.navItems.forEach(navItem => navItem.classList.remove('active'));
// Add active class to clicked item
item.classList.add('active');
// Check if this is the shared item
if (item.querySelector('span').getAttribute('data-i18n') === 'nav.shared') {
// Switch to shared view
switchToSharedView();
return;
}
// Check if this is the favorites item
if (item.querySelector('span').getAttribute('data-i18n') === 'nav.favorites') {
// Switch to favorites view
switchToFavoritesView();
return;
}
// Check if this is the recent files item
if (item.querySelector('span').getAttribute('data-i18n') === 'nav.recent') {
// Switch to recent files view
switchToRecentFilesView();
return;
}
// Check if this is the trash item
if (item === elements.trashBtn) {
// Hide shared view if active
if (app.isSharedView) {
// Hide shared view
if (window.sharedView) {
window.sharedView.hide();
}
// Reset shared view flag
app.isSharedView = false;
// Clean up shared containers if they exist
const sharedContainer = document.getElementById('shared-container');
if (sharedContainer) {
sharedContainer.style.display = 'none';
}
}
// Show trash view
app.isTrashView = true;
app.currentSection = 'trash';
// Show files containers (to be filled with trash)
const filesGrid = document.getElementById('files-grid');
const filesListView = document.getElementById('files-list-view');
if (filesGrid) filesGrid.style.display = app.currentView === 'grid' ? 'grid' : 'none';
if (filesListView) filesListView.style.display = app.currentView === 'list' ? 'block' : 'none';
// Update UI
elements.pageTitle.textContent = window.i18n ? window.i18n.t('nav.trash') : 'Trash';
elements.pageTitle.setAttribute('data-i18n', 'nav.trash');
setActionsBarMode('trash');
// Load trash items
window.loadTrashItems();
} else {
// Check if we need to reset shared view
if (app.isSharedView) {
// Hide shared view
if (window.sharedView) {
window.sharedView.hide();
}
// Reset shared view flag
app.isSharedView = false;
// Clean up shared containers if they exist
const sharedContainer = document.getElementById('shared-container');
if (sharedContainer) {
sharedContainer.style.display = 'none';
}
}
// Show regular files view
app.isTrashView = false;
app.currentSection = 'files';
// Reset UI
elements.pageTitle.textContent = window.i18n ? window.i18n.t('nav.files') : 'Files';
setActionsBarMode('files');
// Show files containers
const filesGrid = document.getElementById('files-grid');
const filesListView = document.getElementById('files-list-view');
if (filesGrid) filesGrid.style.display = app.currentView === 'grid' ? 'grid' : 'none';
if (filesListView) filesListView.style.display = app.currentView === 'list' ? 'block' : 'none';
// Load regular files
app.currentPath = '';
ui.updateBreadcrumb('');
window.loadFiles();
}
});
});
// Load saved view preference
const savedView = localStorage.getItem('oxicloud-view');
if (savedView === 'list') {
ui.switchToListView();
}
// User menu
window.setupUserMenu();
// Global events to close context menus and deselect cards
document.addEventListener('click', (e) => {
const folderMenu = document.getElementById('folder-context-menu');
const fileMenu = document.getElementById('file-context-menu');
if (folderMenu && folderMenu.style.display === 'block' &&
!folderMenu.contains(e.target)) {
ui.closeContextMenu();
}
if (fileMenu && fileMenu.style.display === 'block' &&
!fileMenu.contains(e.target)) {
ui.closeFileContextMenu();
}
// Deselect all cards when clicking empty area (not on a card, menu, or modal)
// Note: multiSelect._hookGlobalDeselect() handles clearing the internal
// selection state; this handler only covers the legacy CSS class removal.
if (!e.target.closest('.file-card') && !e.target.closest('.file-item') && !e.target.closest('.context-menu') && !e.target.closest('.about-modal') && !e.target.closest('.batch-action-bar') && !e.target.closest('.list-header.selection-mode')) {
document.querySelectorAll('.file-card.selected').forEach(c => c.classList.remove('selected'));
document.querySelectorAll('.file-item.selected').forEach(c => c.classList.remove('selected'));
}
});
}
// Expose needed functions to global scope
window.setActionsBarMode = setActionsBarMode;
// Set up global selectFolder function for navigation
window.selectFolder = (id, name) => {
app.currentPath = id;
ui.updateBreadcrumb(name);
window.loadFiles();
};
// View-switching actions moved to app/navigation.js
/**
* Update the storage usage display with the user's actual storage usage
* @param {Object} userData - The user data object
*/
function updateStorageUsageDisplay(userData) {
// Default values
const DEFAULT_QUOTA = 10 * 1024 * 1024 * 1024; // 10 GB
let usedBytes = 0;
let quotaBytes = DEFAULT_QUOTA;
let usagePercentage = 0;
// Get values from user data if available
if (userData) {
usedBytes = userData.storage_used_bytes || 0;
quotaBytes = userData.storage_quota_bytes || DEFAULT_QUOTA;
// Calculate percentage (avoid division by zero)
if (quotaBytes > 0) {
usagePercentage = Math.min(Math.round((usedBytes / quotaBytes) * 100), 100);
}
}
// Format the numbers for display
const usedFormatted = formatFileSize(usedBytes);
const quotaFormatted = formatFileSize(quotaBytes);
// Update the storage display elements
const storageFill = document.querySelector('.storage-fill');
const storageInfo = document.querySelector('.storage-info');
if (storageFill) {
storageFill.style.width = `${usagePercentage}%`;
}
if (storageInfo) {
// Remove data-i18n attribute to prevent i18n from overwriting our value
storageInfo.removeAttribute('data-i18n');
// Use i18n if available
if (window.i18n && window.i18n.t) {
storageInfo.textContent = window.i18n.t('storage.used', {
percentage: usagePercentage,
used: usedFormatted,
total: quotaFormatted
});
} else {
storageInfo.textContent = `${usagePercentage}% used (${usedFormatted} / ${quotaFormatted})`;
}
}
console.log(`Updated storage display: ${usagePercentage}% (${usedFormatted} / ${quotaFormatted})`);
}
window.updateStorageUsageDisplay = updateStorageUsageDisplay;
// Initialize app when DOM is ready
window.initApp = initApp;
+196
View File
@@ -0,0 +1,196 @@
/**
* OxiCloud - View navigation actions
* Extracted from main.js to keep navigation concerns isolated.
*/
function switchToSharedView() {
window.app.isTrashView = false;
window.app.isSharedView = true;
window.app.currentSection = 'shared';
window.appElements.navItems.forEach(navItem => navItem.classList.remove('active'));
const sharedNavItem = document.querySelector('.nav-item:nth-child(2)');
if (sharedNavItem) {
sharedNavItem.classList.add('active');
}
window.appElements.pageTitle.textContent = window.i18n ? window.i18n.t('nav.shared') : 'Shared';
window.appElements.pageTitle.setAttribute('data-i18n', 'nav.shared');
window.ui.updateBreadcrumb('');
const breadcrumb = document.querySelector('.breadcrumb');
if (breadcrumb) breadcrumb.style.display = 'none';
if (window.appElements.actionsBar) {
window.appElements.actionsBar.style.display = 'none';
}
const filesGrid = document.getElementById('files-grid');
const filesListView = document.getElementById('files-list-view');
if (filesGrid) filesGrid.style.display = 'none';
if (filesListView) filesListView.style.display = 'none';
if (window.sharedView) {
window.sharedView.init();
window.sharedView.show();
}
}
function switchToFilesView() {
window.app.isTrashView = false;
window.app.isSharedView = false;
window.app.isFavoritesView = false;
window.app.isRecentView = false;
window.app.currentSection = 'files';
window.appElements.pageTitle.textContent = window.i18n ? window.i18n.t('nav.files') : 'Files';
window.appElements.pageTitle.setAttribute('data-i18n', 'nav.files');
const breadcrumb = document.querySelector('.breadcrumb');
if (breadcrumb) breadcrumb.style.display = '';
window.appElements.navItems.forEach(navItem => navItem.classList.remove('active'));
const filesNavItem = document.querySelector('.nav-item:first-child');
if (filesNavItem) {
filesNavItem.classList.add('active');
}
window.setActionsBarMode('files');
if (window.sharedView) {
window.sharedView.hide();
}
const filesGrid = document.getElementById('files-grid');
if (filesGrid) {
filesGrid.style.display = window.app.currentView === 'grid' ? 'grid' : 'none';
}
const filesListView = document.getElementById('files-list-view');
if (filesListView) {
filesListView.style.display = window.app.currentView === 'list' ? 'block' : 'none';
}
if (window.app.userHomeFolderId) {
window.app.currentPath = window.app.userHomeFolderId;
window.ui.updateBreadcrumb(window.app.userHomeFolderName || 'Home');
} else {
window.app.currentPath = '';
}
window.loadFiles();
}
function switchToFavoritesView() {
window.app.isTrashView = false;
window.app.isSharedView = false;
window.app.isFavoritesView = true;
window.app.currentSection = 'favorites';
window.appElements.navItems.forEach(navItem => navItem.classList.remove('active'));
const favoritesNavItem = document.querySelector('.nav-item:nth-child(4)');
if (favoritesNavItem) {
favoritesNavItem.classList.add('active');
}
window.appElements.pageTitle.textContent = window.i18n ? window.i18n.t('nav.favorites') : 'Favorites';
window.appElements.pageTitle.setAttribute('data-i18n', 'nav.favorites');
window.ui.updateBreadcrumb('');
if (window.sharedView) {
window.sharedView.hide();
}
window.setActionsBarMode('favorites');
const filesGrid = document.getElementById('files-grid');
const filesListView = document.getElementById('files-list-view');
if (filesGrid) {
filesGrid.style.display = window.app.currentView === 'grid' ? 'grid' : 'none';
}
if (filesListView) {
filesListView.style.display = window.app.currentView === 'list' ? 'block' : 'none';
}
if (window.favorites) {
window.favorites.displayFavorites();
} else {
console.error('Favorites module not loaded or initialized');
const filesGridError = document.getElementById('files-grid');
if (filesGridError) {
filesGridError.innerHTML = `
<div class="empty-state">
<i class="fas fa-exclamation-circle" style="font-size: 48px; color: #f44336; margin-bottom: 16px;"></i>
<p>Error loading the favorites module</p>
</div>
`;
}
}
}
function switchToRecentFilesView() {
window.app.isTrashView = false;
window.app.isSharedView = false;
window.app.isFavoritesView = false;
window.app.isRecentView = true;
window.app.currentSection = 'recent';
window.appElements.navItems.forEach(navItem => navItem.classList.remove('active'));
const recentNavItem = document.querySelector('.nav-item:nth-child(3)');
if (recentNavItem) {
recentNavItem.classList.add('active');
}
window.appElements.pageTitle.textContent = window.i18n ? window.i18n.t('nav.recent') : 'Recent';
window.appElements.pageTitle.setAttribute('data-i18n', 'nav.recent');
window.ui.updateBreadcrumb('');
if (window.sharedView) {
window.sharedView.hide();
}
window.setActionsBarMode('recent');
const filesGrid = document.getElementById('files-grid');
const filesListView = document.getElementById('files-list-view');
if (filesGrid) {
filesGrid.style.display = window.app.currentView === 'grid' ? 'grid' : 'none';
}
if (filesListView) {
filesListView.style.display = window.app.currentView === 'list' ? 'block' : 'none';
}
if (window.recent) {
window.recent.displayRecentFiles();
} else {
console.error('Recent files module not loaded or initialized');
const filesGridError = document.getElementById('files-grid');
if (filesGridError) {
filesGridError.innerHTML = `
<div class="empty-state">
<i class="fas fa-exclamation-circle" style="font-size: 48px; color: #f44336; margin-bottom: 16px;"></i>
<p>Error loading the recent files module</p>
</div>
`;
}
}
}
window.switchToFilesView = switchToFilesView;
window.switchToSharedView = switchToSharedView;
window.switchToFavoritesView = switchToFavoritesView;
window.switchToRecentFilesView = switchToRecentFilesView;
+54
View File
@@ -0,0 +1,54 @@
/**
* Search view orchestration logic
*/
async function performSearch(query, sortBy) {
const app = window.app;
console.log(`Performing search for: "${query}" (sort: ${sortBy || 'relevance'})`);
try {
app.isSearchMode = true;
window.ui.updateBreadcrumb(`Search: "${query}"`);
const filesGrid = document.getElementById('files-grid');
if (filesGrid) {
filesGrid.innerHTML = `
<div class="search-results-header">
<h3><i class="fas fa-spinner fa-spin" style="margin-right:8px;"></i> Searching for "${query}"...</h3>
</div>
`;
}
const options = {
recursive: true,
limit: 100,
sort_by: sortBy || 'relevance'
};
if (!app.isTrashView) {
options.folder_id = app.currentPath;
if (!options.folder_id || options.folder_id === '') {
await window.resolveHomeFolder();
options.folder_id = app.currentPath;
}
}
const searchResults = await window.search.searchFiles(query, options);
window.search.displaySearchResults(searchResults);
} catch (error) {
console.error('Search error:', error);
window.ui.showNotification('Error', 'Error performing search');
}
}
document.addEventListener('search-resort', (e) => {
const searchInput = document.querySelector('.search-container input');
if (searchInput && searchInput.value.trim()) {
performSearch(searchInput.value.trim(), e.detail.sort_by);
}
});
window.performSearch = performSearch;
+26
View File
@@ -0,0 +1,26 @@
/**
* OxiCloud - App state container
* Centralized mutable state for app and cached DOM references.
*/
window.app = {
currentView: 'grid',
currentPath: '',
currentFolder: null,
contextMenuTargetFolder: null,
contextMenuTargetFile: null,
selectedTargetFolderId: '',
moveDialogMode: 'file',
isTrashView: false,
isSharedView: false,
isFavoritesView: false,
isRecentView: false,
currentSection: 'files',
isSearchMode: false,
shareDialogItem: null,
shareDialogItemType: null,
notificationShareUrl: null
};
window.appElements = {
};
+157
View File
@@ -0,0 +1,157 @@
/**
* Trash view loading and rendering logic
*/
async function loadTrashItems() {
const elements = window.appElements;
try {
if (window.multiSelect) window.multiSelect.clear();
elements.filesGrid.innerHTML = '';
const _tt = (window.i18n && window.i18n.t) ? window.i18n.t : k => k.split('.').pop();
elements.filesListView.innerHTML = `
<div class="list-header trash-header">
<div data-i18n="files.name">${_tt('files.name')}</div>
<div data-i18n="files.type">${_tt('files.type')}</div>
<div data-i18n="trash.original_location">${_tt('trash.original_location')}</div>
<div data-i18n="trash.deleted_date">${_tt('trash.deleted_date')}</div>
<div data-i18n="trash.actions">${_tt('trash.actions')}</div>
</div>
`;
window.ui.updateBreadcrumb('');
const trashItems = await window.fileOps.getTrashItems();
if (trashItems.length === 0) {
const emptyState = document.createElement('div');
emptyState.className = 'empty-state';
emptyState.innerHTML = `
<i class="fas fa-trash" style="font-size: 48px; color: #ddd; margin-bottom: 16px;"></i>
<p>${window.i18n ? window.i18n.t('trash.empty_state') : 'The trash is empty'}</p>
`;
elements.filesGrid.appendChild(emptyState);
return;
}
trashItems.forEach(item => {
addTrashItemToView(item);
});
} catch (error) {
console.error('Error loading trash items:', error);
window.ui.showNotification('Error', 'Error loading trash items');
}
}
function addTrashItemToView(item) {
const elements = window.appElements;
const isFile = item.item_type === 'file';
const formattedDate = window.formatDateTime(item.trashed_at);
let iconClass;
let typeLabel;
let iconSpecialClass = '';
if (!isFile) {
iconClass = item.icon_class || 'fas fa-folder';
typeLabel = window.i18n ? window.i18n.t('files.file_types.folder') : 'Folder';
} else {
iconClass = item.icon_class || (window.ui && window.ui.getIconClass
? window.ui.getIconClass(item.name)
: 'fas fa-file');
iconSpecialClass = (window.ui && window.ui.getIconSpecialClass)
? window.ui.getIconSpecialClass(item.name)
: '';
const cat = item.category || '';
typeLabel = cat
? (window.i18n ? window.i18n.t(`files.file_types.${cat.toLowerCase()}`) || cat : cat)
: (window.i18n ? window.i18n.t('files.file_types.document') : 'Document');
}
const isFolder = !isFile;
const iconWrapClass = isFolder
? 'file-icon folder-icon'
: `file-icon ${iconSpecialClass}`.trim();
const gridElement = document.createElement('div');
gridElement.className = 'file-card trash-item';
gridElement.dataset.trashId = item.id;
gridElement.dataset.originalId = item.original_id;
gridElement.dataset.itemType = item.item_type;
gridElement.innerHTML = `
<div class="${iconWrapClass}">
<i class="${iconClass}"></i>
</div>
<div class="file-name">${escapeHtml(item.name)}</div>
<div class="file-info">${escapeHtml(typeLabel)} - ${escapeHtml(formattedDate)}</div>
<div class="trash-actions">
<button class="btn-restore" title="${window.i18n ? window.i18n.t('trash.restore') : 'Restore'}">
<i class="fas fa-undo"></i>
</button>
<button class="btn-delete" title="${window.i18n ? window.i18n.t('trash.delete_permanently') : 'Delete permanently'}">
<i class="fas fa-trash"></i>
</button>
</div>
`;
gridElement.querySelector('.btn-restore').addEventListener('click', async (e) => {
e.stopPropagation();
if (await window.fileOps.restoreFromTrash(item.id)) {
window.loadTrashItems();
}
});
gridElement.querySelector('.btn-delete').addEventListener('click', async (e) => {
e.stopPropagation();
if (await window.fileOps.deletePermanently(item.id)) {
window.loadTrashItems();
}
});
elements.filesGrid.appendChild(gridElement);
const listElement = document.createElement('div');
listElement.className = 'file-item trash-item';
listElement.dataset.trashId = item.id;
listElement.dataset.originalId = item.original_id;
listElement.dataset.itemType = item.item_type;
listElement.innerHTML = `
<div class="name-cell">
<div class="${iconWrapClass}">
<i class="${iconClass}"></i>
</div>
<span>${escapeHtml(item.name)}</span>
</div>
<div class="type-cell">${escapeHtml(typeLabel)}</div>
<div class="path-cell">${escapeHtml(item.original_path || '--')}</div>
<div class="date-cell">${escapeHtml(formattedDate)}</div>
<div class="actions-cell">
<button class="btn-restore" title="${window.i18n ? window.i18n.t('trash.restore') : 'Restore'}">
<i class="fas fa-undo"></i>
</button>
<button class="btn-delete" title="${window.i18n ? window.i18n.t('trash.delete_permanently') : 'Delete permanently'}">
<i class="fas fa-trash"></i>
</button>
</div>
`;
listElement.querySelector('.btn-restore').addEventListener('click', async (e) => {
e.stopPropagation();
if (await window.fileOps.restoreFromTrash(item.id)) {
window.loadTrashItems();
}
});
listElement.querySelector('.btn-delete').addEventListener('click', async (e) => {
e.stopPropagation();
if (await window.fileOps.deletePermanently(item.id)) {
window.loadTrashItems();
}
});
elements.filesListView.appendChild(listElement);
}
window.loadTrashItems = loadTrashItems;
+4 -108
View File
@@ -549,11 +549,7 @@ const ui = {
* @returns {boolean}
*/
isViewableFile(file) {
if (!file || !file.mime_type) return false;
if (file.mime_type.startsWith('image/')) return true;
if (file.mime_type === 'application/pdf') return true;
// Delegate text-viewability to the single global definition
return window.isTextViewable ? window.isTextViewable(file.mime_type) : false;
return window.uiFileTypes.isViewableFile(file);
},
/**
@@ -562,29 +558,7 @@ const ui = {
* (e.g. trash items).
*/
getIconClass(fileName) {
if (!fileName) return 'fas fa-file';
const ext = (fileName.split('.').pop() || '').toLowerCase();
const map = {
pdf:'fas fa-file-pdf', doc:'fas fa-file-word', docx:'fas fa-file-word',
txt:'fas fa-file-alt', rtf:'fas fa-file-alt', odt:'fas fa-file-alt',
xls:'fas fa-file-excel', xlsx:'fas fa-file-excel', csv:'fas fa-file-excel', ods:'fas fa-file-excel',
ppt:'fas fa-file-powerpoint', pptx:'fas fa-file-powerpoint', odp:'fas fa-file-powerpoint',
jpg:'fas fa-file-image', jpeg:'fas fa-file-image', png:'fas fa-file-image',
gif:'fas fa-file-image', svg:'fas fa-file-image', webp:'fas fa-file-image',
bmp:'fas fa-file-image', ico:'fas fa-file-image',
mp4:'fas fa-file-video', avi:'fas fa-file-video', mov:'fas fa-file-video',
mkv:'fas fa-file-video', webm:'fas fa-file-video', flv:'fas fa-file-video',
mp3:'fas fa-file-audio', wav:'fas fa-file-audio', ogg:'fas fa-file-audio',
flac:'fas fa-file-audio', aac:'fas fa-file-audio', m4a:'fas fa-file-audio',
zip:'fas fa-file-archive', rar:'fas fa-file-archive', '7z':'fas fa-file-archive',
tar:'fas fa-file-archive', gz:'fas fa-file-archive',
js:'fas fa-file-code', ts:'fas fa-file-code', py:'fas fa-file-code',
rs:'fas fa-file-code', java:'fas fa-file-code', html:'fas fa-file-code',
css:'fas fa-file-code', json:'fas fa-file-code', xml:'fas fa-file-code',
sh:'fas fa-terminal', bash:'fas fa-terminal', bat:'fas fa-terminal',
md:'fas fa-file-alt',
};
return map[ext] || 'fas fa-file';
return window.uiFileTypes.getIconClass(fileName);
},
/**
@@ -592,40 +566,7 @@ const ui = {
* Used as fallback when the backend DTO doesn't include icon_special_class.
*/
getIconSpecialClass(fileName) {
if (!fileName) return '';
const ext = (fileName.split('.').pop() || '').toLowerCase();
const map = {
pdf:'pdf-icon',
doc:'doc-icon', docx:'doc-icon', odt:'doc-icon', rtf:'doc-icon',
xls:'spreadsheet-icon', xlsx:'spreadsheet-icon', ods:'spreadsheet-icon', csv:'spreadsheet-icon',
ppt:'presentation-icon', pptx:'presentation-icon', odp:'presentation-icon', key:'presentation-icon',
jpg:'image-icon', jpeg:'image-icon', png:'image-icon', gif:'image-icon',
svg:'image-icon', webp:'image-icon', bmp:'image-icon', ico:'image-icon',
heic:'image-icon', heif:'image-icon', avif:'image-icon', tiff:'image-icon',
mp4:'video-icon', avi:'video-icon', mkv:'video-icon', mov:'video-icon',
wmv:'video-icon', flv:'video-icon', webm:'video-icon', m4v:'video-icon',
mp3:'audio-icon', wav:'audio-icon', ogg:'audio-icon', flac:'audio-icon',
aac:'audio-icon', wma:'audio-icon', m4a:'audio-icon', opus:'audio-icon',
zip:'archive-icon', rar:'archive-icon', '7z':'archive-icon',
tar:'archive-icon', gz:'archive-icon', bz2:'archive-icon', xz:'archive-icon',
exe:'installer-icon', msi:'installer-icon', dmg:'installer-icon',
deb:'installer-icon', rpm:'installer-icon', appimage:'installer-icon',
py:'code-icon py-icon', rs:'code-icon rust-icon', go:'code-icon go-icon',
js:'code-icon js-icon', jsx:'code-icon js-icon', mjs:'code-icon js-icon',
ts:'code-icon ts-icon', tsx:'code-icon ts-icon',
java:'code-icon java-icon', c:'code-icon c-icon', cpp:'code-icon c-icon',
cs:'code-icon cs-icon', rb:'code-icon ruby-icon', php:'code-icon php-icon',
swift:'code-icon swift-icon',
html:'code-icon html-icon', htm:'code-icon html-icon',
css:'code-icon css-icon', scss:'code-icon css-icon',
json:'code-icon json-icon', xml:'code-icon html-icon',
yaml:'code-icon config-icon', yml:'code-icon config-icon',
toml:'code-icon config-icon', ini:'code-icon config-icon',
sql:'code-icon sql-icon', vue:'code-icon js-icon', svelte:'code-icon js-icon',
sh:'script-icon', bash:'script-icon', zsh:'script-icon', bat:'script-icon',
md:'code-icon md-icon', txt:'doc-icon',
};
return map[ext] || '';
return window.uiFileTypes.getIconSpecialClass(fileName);
},
/**
@@ -634,52 +575,7 @@ const ui = {
* @param {string} message - Notification message
*/
showNotification(title, message) {
// Prefer the bell notification center
if (window.notifications && typeof window.notifications.addNotification === 'function') {
const t = String(title || '').toLowerCase();
let icon = 'fa-info-circle';
let iconClass = 'upload';
if (t.includes('error') || t.includes('failed') || t.includes('fail')) {
icon = 'fa-exclamation-circle';
iconClass = 'error';
} else if (t.includes('favorite') || t.includes('favorit') || t.includes('fav')) {
icon = 'fa-star';
iconClass = 'success';
} else if (t.includes('delete') || t.includes('removed') || t.includes('trash') || t.includes('rename') || t.includes('complete')) {
icon = 'fa-check-circle';
iconClass = 'success';
}
window.notifications.addNotification({
icon,
iconClass,
title: title || '',
text: message || ''
});
return;
}
// Legacy floating toast fallback (pages without bell)
let notification = document.querySelector('.notification');
if (!notification) {
notification = document.createElement('div');
notification.className = 'notification';
notification.innerHTML = `
<div class="notification-title">${title}</div>
<div class="notification-message">${message}</div>
`;
document.body.appendChild(notification);
} else {
notification.querySelector('.notification-title').textContent = title;
notification.querySelector('.notification-message').textContent = message;
}
notification.style.display = 'block';
setTimeout(() => {
notification.style.display = 'none';
}, 5000);
window.uiNotifications.show(title, message);
},
/**
+78
View File
@@ -0,0 +1,78 @@
/**
* OxiCloud - UI file type helpers
* Isolated icon and preview classification helpers used by ui.js.
*/
const uiFileTypes = {
isViewableFile(file) {
if (!file || !file.mime_type) return false;
if (file.mime_type.startsWith('image/')) return true;
if (file.mime_type === 'application/pdf') return true;
return window.isTextViewable ? window.isTextViewable(file.mime_type) : false;
},
getIconClass(fileName) {
if (!fileName) return 'fas fa-file';
const ext = (fileName.split('.').pop() || '').toLowerCase();
const map = {
pdf:'fas fa-file-pdf', doc:'fas fa-file-word', docx:'fas fa-file-word',
txt:'fas fa-file-alt', rtf:'fas fa-file-alt', odt:'fas fa-file-alt',
xls:'fas fa-file-excel', xlsx:'fas fa-file-excel', csv:'fas fa-file-excel', ods:'fas fa-file-excel',
ppt:'fas fa-file-powerpoint', pptx:'fas fa-file-powerpoint', odp:'fas fa-file-powerpoint',
jpg:'fas fa-file-image', jpeg:'fas fa-file-image', png:'fas fa-file-image',
gif:'fas fa-file-image', svg:'fas fa-file-image', webp:'fas fa-file-image',
bmp:'fas fa-file-image', ico:'fas fa-file-image',
mp4:'fas fa-file-video', avi:'fas fa-file-video', mov:'fas fa-file-video',
mkv:'fas fa-file-video', webm:'fas fa-file-video', flv:'fas fa-file-video',
mp3:'fas fa-file-audio', wav:'fas fa-file-audio', ogg:'fas fa-file-audio',
flac:'fas fa-file-audio', aac:'fas fa-file-audio', m4a:'fas fa-file-audio',
zip:'fas fa-file-archive', rar:'fas fa-file-archive', '7z':'fas fa-file-archive',
tar:'fas fa-file-archive', gz:'fas fa-file-archive',
js:'fas fa-file-code', ts:'fas fa-file-code', py:'fas fa-file-code',
rs:'fas fa-file-code', java:'fas fa-file-code', html:'fas fa-file-code',
css:'fas fa-file-code', json:'fas fa-file-code', xml:'fas fa-file-code',
sh:'fas fa-terminal', bash:'fas fa-terminal', bat:'fas fa-terminal',
md:'fas fa-file-alt',
};
return map[ext] || 'fas fa-file';
},
getIconSpecialClass(fileName) {
if (!fileName) return '';
const ext = (fileName.split('.').pop() || '').toLowerCase();
const map = {
pdf:'pdf-icon',
doc:'doc-icon', docx:'doc-icon', odt:'doc-icon', rtf:'doc-icon',
xls:'spreadsheet-icon', xlsx:'spreadsheet-icon', ods:'spreadsheet-icon', csv:'spreadsheet-icon',
ppt:'presentation-icon', pptx:'presentation-icon', odp:'presentation-icon', key:'presentation-icon',
jpg:'image-icon', jpeg:'image-icon', png:'image-icon', gif:'image-icon',
svg:'image-icon', webp:'image-icon', bmp:'image-icon', ico:'image-icon',
heic:'image-icon', heif:'image-icon', avif:'image-icon', tiff:'image-icon',
mp4:'video-icon', avi:'video-icon', mkv:'video-icon', mov:'video-icon',
wmv:'video-icon', flv:'video-icon', webm:'video-icon', m4v:'video-icon',
mp3:'audio-icon', wav:'audio-icon', ogg:'audio-icon', flac:'audio-icon',
aac:'audio-icon', wma:'audio-icon', m4a:'audio-icon', opus:'audio-icon',
zip:'archive-icon', rar:'archive-icon', '7z':'archive-icon',
tar:'archive-icon', gz:'archive-icon', bz2:'archive-icon', xz:'archive-icon',
exe:'installer-icon', msi:'installer-icon', dmg:'installer-icon',
deb:'installer-icon', rpm:'installer-icon', appimage:'installer-icon',
py:'code-icon py-icon', rs:'code-icon rust-icon', go:'code-icon go-icon',
js:'code-icon js-icon', jsx:'code-icon js-icon', mjs:'code-icon js-icon',
ts:'code-icon ts-icon', tsx:'code-icon ts-icon',
java:'code-icon java-icon', c:'code-icon c-icon', cpp:'code-icon c-icon',
cs:'code-icon cs-icon', rb:'code-icon ruby-icon', php:'code-icon php-icon',
swift:'code-icon swift-icon',
html:'code-icon html-icon', htm:'code-icon html-icon',
css:'code-icon css-icon', scss:'code-icon css-icon',
json:'code-icon json-icon', xml:'code-icon html-icon',
yaml:'code-icon config-icon', yml:'code-icon config-icon',
toml:'code-icon config-icon', ini:'code-icon config-icon',
sql:'code-icon sql-icon', vue:'code-icon js-icon', svelte:'code-icon js-icon',
sh:'script-icon', bash:'script-icon', zsh:'script-icon', bat:'script-icon',
md:'code-icon md-icon', txt:'doc-icon',
};
return map[ext] || '';
}
};
window.uiFileTypes = uiFileTypes;
+55
View File
@@ -0,0 +1,55 @@
/**
* OxiCloud - UI notifications adapter
* Isolates notification rendering policy from ui.js.
*/
const uiNotifications = {
show(title, message) {
if (window.notifications && typeof window.notifications.addNotification === 'function') {
const normalizedTitle = String(title || '').toLowerCase();
let icon = 'fa-info-circle';
let iconClass = 'upload';
if (normalizedTitle.includes('error') || normalizedTitle.includes('failed') || normalizedTitle.includes('fail')) {
icon = 'fa-exclamation-circle';
iconClass = 'error';
} else if (normalizedTitle.includes('favorite') || normalizedTitle.includes('favorit') || normalizedTitle.includes('fav')) {
icon = 'fa-star';
iconClass = 'success';
} else if (normalizedTitle.includes('delete') || normalizedTitle.includes('removed') || normalizedTitle.includes('trash') || normalizedTitle.includes('rename') || normalizedTitle.includes('complete')) {
icon = 'fa-check-circle';
iconClass = 'success';
}
window.notifications.addNotification({
icon,
iconClass,
title: title || '',
text: message || ''
});
return;
}
let notification = document.querySelector('.notification');
if (!notification) {
notification = document.createElement('div');
notification.className = 'notification';
notification.innerHTML = `
<div class="notification-title">${title}</div>
<div class="notification-message">${message}</div>
`;
document.body.appendChild(notification);
} else {
notification.querySelector('.notification-title').textContent = title;
notification.querySelector('.notification-message').textContent = message;
}
notification.style.display = 'block';
setTimeout(() => {
notification.style.display = 'none';
}, 5000);
}
};
window.uiNotifications = uiNotifications;
+243
View File
@@ -0,0 +1,243 @@
/**
* User menu, profile modal and logout logic
*/
function setupUserMenu() {
const wrapper = document.getElementById('user-menu-wrapper');
const avatarBtn = document.getElementById('user-avatar-btn');
const menu = document.getElementById('user-menu');
const logoutBtn = document.getElementById('user-menu-logout');
const themeBtn = document.getElementById('user-menu-theme');
const aboutBtn = document.getElementById('user-menu-about');
const adminBtn = document.getElementById('user-menu-admin');
const adminDivider = document.getElementById('user-menu-admin-divider');
const profileBtn = document.getElementById('user-menu-profile');
const roleBadge = document.getElementById('user-menu-role-badge');
if (!wrapper || !avatarBtn || !menu) return;
avatarBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = wrapper.classList.contains('open');
wrapper.classList.toggle('open');
const notifWrapper = document.getElementById('notif-wrapper');
const notifBtn = document.getElementById('notif-bell-btn');
if (notifWrapper) notifWrapper.classList.remove('open');
if (notifBtn) notifBtn.classList.remove('active');
if (!isOpen) {
updateUserMenuData();
const USER_DATA_KEY = 'oxicloud_user';
const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}');
const isAdmin = userData.role === 'admin';
if (adminBtn) adminBtn.style.display = isAdmin ? 'flex' : 'none';
if (adminDivider) adminDivider.style.display = isAdmin ? 'block' : 'none';
if (roleBadge) roleBadge.style.display = isAdmin ? 'block' : 'none';
}
});
document.addEventListener('click', (e) => {
if (wrapper.classList.contains('open') && !wrapper.contains(e.target)) {
wrapper.classList.remove('open');
}
});
if (logoutBtn) {
logoutBtn.addEventListener('click', () => {
wrapper.classList.remove('open');
logout();
});
}
if (themeBtn) {
const pill = document.getElementById('theme-toggle-pill');
const isDark = localStorage.getItem('oxicloud_theme') === 'dark';
if (isDark) {
if (pill) pill.classList.add('active');
document.documentElement.setAttribute('data-theme', 'dark');
}
themeBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (pill) {
pill.classList.toggle('active');
const dark = pill.classList.contains('active');
localStorage.setItem('oxicloud_theme', dark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
window.ui.showNotification(
dark ? '🌙' : '☀️',
dark ? 'Dark mode enabled' : 'Light mode enabled'
);
}
});
}
if (adminBtn) {
adminBtn.addEventListener('click', () => {
wrapper.classList.remove('open');
window.location.href = '/admin';
});
}
if (profileBtn) {
profileBtn.addEventListener('click', () => {
wrapper.classList.remove('open');
window.location.href = '/profile';
});
}
if (aboutBtn) {
aboutBtn.addEventListener('click', () => {
wrapper.classList.remove('open');
const overlay = document.getElementById('about-modal-overlay');
if (overlay) overlay.classList.add('show');
});
}
const aboutCloseBtn = document.getElementById('about-close-btn');
const aboutOverlay = document.getElementById('about-modal-overlay');
if (aboutCloseBtn) {
aboutCloseBtn.addEventListener('click', () => {
aboutOverlay.classList.remove('show');
});
}
if (aboutOverlay) {
aboutOverlay.addEventListener('click', (e) => {
if (e.target === aboutOverlay) {
aboutOverlay.classList.remove('show');
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && aboutOverlay.classList.contains('show')) {
aboutOverlay.classList.remove('show');
}
});
}
fetchAppVersion();
}
function updateUserMenuData() {
const USER_DATA_KEY = 'oxicloud_user';
const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}');
const nameEl = document.getElementById('user-menu-name');
const emailEl = document.getElementById('user-menu-email');
const avatarEl = document.getElementById('user-menu-avatar');
const storageFill = document.getElementById('user-menu-storage-fill');
const storageText = document.getElementById('user-menu-storage-text');
if (userData.username) {
if (nameEl) nameEl.textContent = userData.username;
if (emailEl) emailEl.textContent = userData.email || '';
if (avatarEl) avatarEl.textContent = userData.username.substring(0, 2).toUpperCase();
}
const usedBytes = userData.storage_used_bytes || 0;
const quotaBytes = userData.storage_quota_bytes || (10 * 1024 * 1024 * 1024);
const percentage = quotaBytes > 0 ? Math.min(Math.round((usedBytes / quotaBytes) * 100), 100) : 0;
if (storageFill) storageFill.style.width = percentage + '%';
if (storageText) {
const used = window.formatFileSize(usedBytes);
const total = window.formatFileSize(quotaBytes);
storageText.textContent = `${percentage}% · ${used} / ${total}`;
}
}
async function fetchAppVersion() {
try {
const response = await fetch('/api/version');
if (response.ok) {
const data = await response.json();
const versionEl = document.getElementById('about-version');
if (versionEl && data.version) {
versionEl.textContent = `v${data.version}`;
}
}
} catch (err) {
console.warn('Could not fetch app version:', err);
}
}
function showUserProfileModal() {
const USER_DATA_KEY = 'oxicloud_user';
const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}');
const username = userData.username || 'User';
const email = userData.email || '';
const role = userData.role || 'user';
const initials = username.substring(0, 2).toUpperCase();
const usedBytes = userData.storage_used_bytes || 0;
const quotaBytes = userData.storage_quota_bytes || (10 * 1024 * 1024 * 1024);
const percentage = quotaBytes > 0 ? Math.min(Math.round((usedBytes / quotaBytes) * 100), 100) : 0;
const barColor = percentage > 90 ? '#ef4444' : percentage > 70 ? '#f59e0b' : '#22c55e';
const t = (key, fallback) => (window.i18n && window.i18n.t) ? window.i18n.t(key) || fallback : fallback;
const existing = document.getElementById('profile-modal-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.id = 'profile-modal-overlay';
overlay.className = 'about-modal-overlay';
overlay.innerHTML = `
<div class="about-modal" style="max-width:380px">
<div style="text-align:center;padding:20px 20px 0">
<div style="width:64px;height:64px;border-radius:50%;background:linear-gradient(135deg,#3b82f6,#6366f1);color:#fff;display:inline-flex;align-items:center;justify-content:center;font-size:24px;font-weight:700;margin-bottom:12px">${initials}</div>
<h3 style="margin:0;font-size:18px;color:#1a1a2e">${username}</h3>
<p style="margin:4px 0 0;font-size:13px;color:#64748b">${email}</p>
<span style="display:inline-block;margin-top:8px;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:600;${
role === 'admin'
? 'background:#dbeafe;color:#1d4ed8'
: 'background:#f1f5f9;color:#64748b'
}">${role === 'admin' ? '🛡️ Admin' : '👤 ' + t('user_menu.role_user', 'User')}</span>
</div>
<div style="padding:16px 20px">
<div style="font-size:12px;color:#64748b;text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px">
<i class="fas fa-database" style="margin-right:4px"></i>${t('storage.title', 'Storage')}
</div>
<div style="background:#f1f5f9;border-radius:6px;height:8px;overflow:hidden;margin-bottom:4px">
<div style="height:100%;width:${percentage}%;background:${barColor};border-radius:6px;transition:width .3s"></div>
</div>
<div style="font-size:12px;color:#64748b;text-align:right">${percentage}% · ${window.formatFileSize(usedBytes)} / ${quotaBytes > 0 ? window.formatFileSize(quotaBytes) : '∞'}</div>
</div>
<div style="padding:0 20px 16px;display:flex;justify-content:center">
<button id="profile-modal-close" style="padding:8px 24px;border:1px solid #e2e8f0;border-radius:8px;background:#fff;color:#334155;font-size:13px;font-weight:600;cursor:pointer;transition:background .15s">${t('actions.close', 'Close')}</button>
</div>
</div>
`;
document.body.appendChild(overlay);
requestAnimationFrame(() => overlay.classList.add('show'));
overlay.querySelector('#profile-modal-close').addEventListener('click', () => {
overlay.classList.remove('show');
setTimeout(() => overlay.remove(), 200);
});
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
overlay.classList.remove('show');
setTimeout(() => overlay.remove(), 200);
}
});
}
function logout() {
const TOKEN_KEY = 'oxicloud_token';
const REFRESH_TOKEN_KEY = 'oxicloud_refresh_token';
const TOKEN_EXPIRY_KEY = 'oxicloud_token_expiry';
const USER_DATA_KEY = 'oxicloud_user';
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
localStorage.removeItem(TOKEN_EXPIRY_KEY);
localStorage.removeItem(USER_DATA_KEY);
sessionStorage.removeItem('redirect_count');
window.location.href = '/login';
}
window.setupUserMenu = setupUserMenu;
window.showUserProfileModal = showUserProfileModal;
window.logout = logout;
+63
View File
@@ -0,0 +1,63 @@
/**
* OxiCloud - Shared format and escaping utilities
* Centralized global helpers for date/size/text formatting and XSS-safe escaping.
*/
function escapeHtml(str) {
if (typeof str !== 'string') return '';
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function formatDateTime(value) {
if (!value) return '';
let dateValue;
if (value instanceof Date) {
dateValue = value;
} else if (typeof value === 'number') {
dateValue = new Date(value < 1e12 ? value * 1000 : value);
} else {
dateValue = new Date(value);
}
if (isNaN(dateValue.getTime())) return String(value);
return dateValue.toLocaleDateString() + ' ' +
dateValue.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function formatDateShort(value) {
if (!value) return 'N/A';
const dateValue = typeof value === 'number' ? new Date(value * 1000) : new Date(value);
if (isNaN(dateValue.getTime())) return String(value);
return dateValue.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
function isTextViewable(mimeType) {
if (!mimeType) return false;
if (mimeType.startsWith('text/')) return true;
const textTypes = [
'application/json', 'application/xml', 'application/javascript',
'application/x-sh', 'application/x-yaml', 'application/toml',
'application/x-toml', 'application/sql',
];
return textTypes.includes(mimeType);
}
window.escapeHtml = escapeHtml;
window.formatFileSize = formatFileSize;
window.formatDateTime = formatDateTime;
window.formatDateShort = formatDateShort;
window.isTextViewable = isTextViewable;
+397
View File
@@ -0,0 +1,397 @@
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 hideElement(id) {
const element = document.getElementById(id);
if (!element) return;
element.classList.remove('show-block', 'show-flex');
element.classList.add('hidden');
}
function showElement(id, mode = 'block') {
const element = document.getElementById(id);
if (!element) return;
element.classList.remove('hidden', 'show-block', 'show-flex');
if (mode === 'flex') {
element.classList.add('show-flex');
} else {
element.classList.add('show-block');
}
}
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();
}
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();
}
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';
if (typeof d.registration_enabled !== 'undefined') {
document.getElementById('ds-registration').checked = d.registration_enabled;
if (d.registration_enabled) hideElement('registration-warning');
else showElement('registration-warning', 'flex');
}
if (d.users_over_80_percent > 0) {
showElement('ds-warn-card');
document.getElementById('ds-over80').textContent = d.users_over_80_percent;
}
if (d.users_over_quota > 0) {
showElement('ds-danger-card');
document.getElementById('ds-overquota').textContent = d.users_over_quota;
}
} catch (e) { console.error('Dashboard error', e); }
}
async function loadUsers() {
const tbody = document.getElementById('users-tbody');
tbody.innerHTML = '<tr><td colspan="6" class="table-loading-cell"><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" class="table-status-error"><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" class="table-status-empty">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 class="user-self-badge">(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 badge-admin-icon-small"></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 quota-progress-fixed"><div class="progress-fill ' + quotaColor + '" style="width:' + Math.min(quotaPct, 100) + '%"></div></div><span class="quota-text">' + quotaText + '</span></div></td>' +
'<td class="user-last-login-cell">' + 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" class="table-status-error"><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); }
}
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;
showElement('quota-modal', 'flex');
}
function closeQuotaModal() { hideElement('quota-modal'); }
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); }
}
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 = '';
showElement('create-user-modal', 'flex');
setTimeout(() => document.getElementById('cu-username').focus(), 100);
}
function closeCreateUserModal() { hideElement('create-user-modal'); }
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';
}
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 = '';
showElement('reset-pw-modal', 'flex');
setTimeout(() => document.getElementById('rp-password').focus(), 100);
}
function closeResetPasswordModal() { hideElement('reset-pw-modal'); }
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';
}
async function toggleRegistration(enabled) {
if (enabled) hideElement('registration-warning');
else showElement('registration-warning', 'flex');
try {
const resp = await fetch(API + '/admin/settings/registration', {
method: 'PUT', headers: headers(),
body: JSON.stringify({ registration_enabled: enabled })
});
if (!resp.ok) {
document.getElementById('ds-registration').checked = !enabled;
if (!enabled) showElement('registration-warning', 'flex');
else hideElement('registration-warning');
const e = await resp.json().catch(() => ({}));
alert(e.message || 'Failed to update registration setting');
}
} catch (e) {
document.getElementById('ds-registration').checked = !enabled;
if (!enabled) showElement('registration-warning', 'flex');
else hideElement('registration-warning');
alert('Error: ' + e.message);
}
}
document.getElementById('oidc-enabled').addEventListener('change', function() {
if (this.checked) showElement('oidc-form');
else hideElement('oidc-form');
});
document.getElementById('disable-password').addEventListener('change', function() {
if (this.checked) showElement('password-warning', 'flex');
else hideElement('password-warning');
});
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';
}
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;
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;
if (s.enabled) showElement('oidc-form');
else hideElement('oidc-form');
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;
if (s.disable_password_login) showElement('password-warning', 'flex');
else hideElement('password-warning');
document.getElementById('callback-url').textContent = s.callback_url;
if (s.client_secret_set) showElement('secret-hint');
(s.env_overrides || []).forEach(field => {
const badge = document.getElementById('badge-' + field);
if (badge) badge.innerHTML = '<span class="badge badge-env">ENV</span>';
});
}
await loadDashboard();
hideElement('loading');
showElement('main-content');
} catch (e) { console.error(e); showAccessDenied(); }
}
function showAccessDenied() {
hideElement('loading');
showElement('access-denied');
}
init();
+138
View File
@@ -0,0 +1,138 @@
const API = '/api';
const token = localStorage.getItem('oxicloud_token') || localStorage.getItem('token') || localStorage.getItem('access_token');
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) + ' min ago';
if (secs < 86400) return Math.floor(secs/3600) + 'h ago';
if (secs < 2592000) return Math.floor(secs/86400) + ' days ago';
return d.toLocaleDateString();
}
async function init() {
if (!token) { showError(); return; }
try {
const resp = await fetch(API + '/auth/me', { headers: headers() });
if (!resp.ok) { showError(); return; }
const user = await resp.json();
const initials = (user.username || '?').substring(0, 2).toUpperCase();
document.getElementById('p-avatar').textContent = initials;
document.getElementById('p-username').textContent = user.username;
document.getElementById('p-email').textContent = user.email || '';
const badge = document.getElementById('p-role-badge');
if (user.role === 'admin') {
badge.className = 'role-badge role-badge-admin';
badge.innerHTML = '<i class="fas fa-shield-alt"></i> Administrator';
} else {
badge.className = 'role-badge role-badge-user';
badge.innerHTML = '<i class="fas fa-user"></i> User';
}
document.getElementById('p-detail-username').textContent = user.username;
document.getElementById('p-detail-email').textContent = user.email || '—';
document.getElementById('p-detail-role').textContent = user.role === 'admin' ? 'Administrator' : 'User';
document.getElementById('p-detail-login').textContent = timeAgo(user.last_login_at);
const used = user.storage_used_bytes || 0;
const quota = user.storage_quota_bytes || 0;
const pct = quota > 0 ? Math.min(Math.round((used / quota) * 100), 100) : 0;
document.getElementById('p-storage-used').textContent = formatBytes(used);
document.getElementById('p-storage-quota').textContent = quota > 0 ? formatBytes(quota) : '∞';
document.getElementById('p-storage-pct').textContent = quota > 0 ? pct + '%' : '—';
const bar = document.getElementById('p-storage-bar');
bar.style.width = pct + '%';
bar.className = 'storage-fill ' + (pct > 90 ? 'red' : pct > 70 ? 'orange' : 'green');
document.getElementById('p-storage-text').textContent = formatBytes(used) + ' / ' + (quota > 0 ? formatBytes(quota) : 'Unlimited');
if (user.auth_provider && user.auth_provider !== 'local') {
document.getElementById('password-section').style.display = 'none';
}
try {
const oidcResp = await fetch(API + '/auth/oidc/providers');
if (oidcResp.ok) {
const oidcInfo = await oidcResp.json();
if (!oidcInfo.password_login_enabled) {
document.getElementById('password-section').style.display = 'none';
}
}
} catch (oidcErr) {
}
document.getElementById('loading').style.display = 'none';
document.getElementById('main-content').style.display = 'block';
} catch (e) {
console.error(e);
showError();
}
}
function showError() {
document.getElementById('loading').style.display = 'none';
document.getElementById('auth-error').style.display = 'block';
}
async function changePassword(e) {
e.preventDefault();
const currentPw = document.getElementById('current-password').value;
const newPw = document.getElementById('new-password').value;
const confirmPw = document.getElementById('confirm-password').value;
const statusEl = document.getElementById('pw-status');
if (newPw !== confirmPw) {
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> Passwords do not match</div>';
return false;
}
if (newPw.length < 8) {
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> Password must be at least 8 characters</div>';
return false;
}
const btn = document.getElementById('pw-submit');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Updating…';
try {
const resp = await fetch(API + '/auth/change-password', {
method: 'PUT',
headers: headers(),
body: JSON.stringify({ current_password: currentPw, new_password: newPw })
});
if (resp.ok) {
statusEl.innerHTML = '<div class="alert alert-success"><i class="fas fa-check-circle"></i> Password updated successfully</div>';
document.getElementById('password-form').reset();
} else {
const err = await resp.json().catch(() => ({}));
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ' + (err.message || 'Failed to change password') + '</div>';
}
} catch (err) {
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> Network error: ' + err.message + '</div>';
}
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-save"></i> Update Password';
return false;
}
init();
+3 -3
View File
@@ -12,9 +12,9 @@
<link rel="stylesheet" href="/css/auth.css">
<!-- Scripts -->
<script src="/js/i18n.js"></script>
<script src="/js/icons.js" defer></script>
<script src="/js/auth.js" defer></script>
<script src="/js/core/i18n.js"></script>
<script src="/js/core/icons.js" defer></script>
<script src="/js/features/auth/auth.js" defer></script>
</head>
<body>
<div class="auth-container">
+6 -302
View File
@@ -4,161 +4,15 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OxiCloud — My Profile</title>
<!-- Apply saved theme immediately to prevent flash of light mode -->
<script>if(localStorage.getItem('oxicloud_theme')==='dark')document.documentElement.setAttribute('data-theme','dark');</script>
<script src="/js/icons.js" defer></script>
<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}
*{scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
/* ── Header ── */
.profile-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;
}
.profile-header-left{display:flex;align-items:center;gap:14px}
.profile-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);
}
.profile-logo svg{width:20px;height:20px;fill:#fff}
.profile-title-text{font-size:17px;font-weight:700;color:#fff;letter-spacing:.3px}
.profile-title-separator{font-size:17px;color:rgba(255,255,255,.5);font-weight:400;margin-left:6px}
.profile-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;
}
.profile-header-right a:hover{color:#fff}
/* ── Container ── */
.profile-container{max-width:720px;margin:0 auto;padding:32px 24px 60px;width:100%}
/* ── Card ── */
.profile-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:32px;margin-bottom:22px}
.profile-card h2{font-size:16px;font-weight:700;margin-bottom:20px;color:#1e293b;display:flex;align-items:center;gap:10px}
.profile-card h2 i{color:#ff5e3a;font-size:17px}
/* ── Avatar Section ── */
.avatar-section{display:flex;align-items:center;gap:24px;margin-bottom:8px}
.avatar-large{
width:88px;height:88px;border-radius:50%;
background:linear-gradient(135deg,#ff5e3a,#ff2d55);
display:flex;align-items:center;justify-content:center;
color:#fff;font-size:32px;font-weight:700;letter-spacing:1px;
box-shadow:0 6px 20px rgba(255,94,58,.3);flex-shrink:0;
}
.avatar-info h1{font-size:22px;font-weight:700;color:#1e293b;margin-bottom:4px}
.avatar-info .email{font-size:14px;color:#64748b;margin-bottom:8px}
.avatar-info .role-badge{
display:inline-flex;align-items:center;gap:5px;font-size:12px;font-weight:600;
padding:4px 12px;border-radius:20px;
}
.role-badge-admin{background:#dbeafe;color:#1d4ed8}
.role-badge-user{background:#f1f5f9;color:#64748b}
/* ── Info Grid ── */
.info-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
@media(max-width:560px){.info-grid{grid-template-columns:1fr}}
.info-item{padding:16px;background:#f8fafc;border-radius:12px;border:1px solid #e2e8f0}
.info-item .info-label{font-size:11.5px;color:#94a3b8;text-transform:uppercase;letter-spacing:.06em;font-weight:700;margin-bottom:4px;display:flex;align-items:center;gap:6px}
.info-item .info-label i{font-size:12px;color:#cbd5e1}
.info-item .info-value{font-size:15px;font-weight:600;color:#1e293b}
/* ── Storage ── */
.storage-section{margin-top:4px}
.storage-stats{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px;margin-bottom:16px}
@media(max-width:560px){.storage-stats{grid-template-columns:1fr}}
.storage-stat{text-align:center;padding:16px;background:#f8fafc;border-radius:12px;border:1px solid #e2e8f0}
.storage-stat .stat-value{font-size:1.5rem;font-weight:800;color:#1e293b}
.storage-stat .stat-label{font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.06em;font-weight:600;margin-top:2px}
.storage-bar-wrap{margin-top:4px}
.storage-bar{height:10px;background:#e2e8f0;border-radius:5px;overflow:hidden}
.storage-fill{height:100%;border-radius:5px;transition:width .6s ease}
.storage-fill.green{background:linear-gradient(90deg,#059669,#10b981)}
.storage-fill.orange{background:linear-gradient(90deg,#d97706,#f59e0b)}
.storage-fill.red{background:linear-gradient(90deg,#dc2626,#ef4444)}
.storage-text{font-size:12px;color:#94a3b8;text-align:right;margin-top:6px}
/* ── Change Password ── */
.form-group{margin-bottom:16px}
.form-group label{display:block;font-size:13px;font-weight:600;margin-bottom:4px;color:#334155}
.form-group input{
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{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}
.btn{
padding:10px 22px;border:none;border-radius:10px;font-size:14px;font-weight:600;
cursor:pointer;transition:all .15s;white-space:nowrap;display:inline-flex;align-items:center;gap:6px;
}
.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-primary:disabled{opacity:.4;cursor:not-allowed;transform:none!important}
.alert{padding:12px 16px;border-radius:10px;font-size:13px;margin-top:14px;font-weight:500}
.alert-success{background:#ecfdf5;color:#065f46;border:1px solid #a7f3d0}
.alert-error{background:#fef2f2;color:#991b1b;border:1px solid #fecaca}
/* ── Loading / Error ── */
#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)}}
#auth-error{display:none;text-align:center;padding:80px 20px}
#auth-error .err-icon{width:80px;height:80px;background:#fef2f2;border-radius:50%;display:flex;align-items:center;justify-content:center;margin:0 auto 20px}
#auth-error .err-icon i{font-size:32px;color:#ef4444}
#auth-error h2{color:#991b1b;margin-bottom:8px;font-size:20px}
#auth-error p{color:#64748b;margin-bottom:20px;font-size:14px}
#auth-error 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}
#auth-error a:hover{transform:translateY(-1px);box-shadow:0 5px 18px rgba(255,94,58,.4)}
/* ── Dark Mode ── */
[data-theme="dark"] body{background:#0f172a;color:#e2e8f0}
[data-theme="dark"] ::-webkit-scrollbar-thumb{background:rgba(255,255,255,.15)}
[data-theme="dark"] *{scrollbar-color:rgba(255,255,255,.15) transparent}
[data-theme="dark"] .profile-card{background:#1e293b;box-shadow:0 1px 4px rgba(0,0,0,.2),0 0 0 1px rgba(255,255,255,.03)}
[data-theme="dark"] .profile-card h2{color:#f1f5f9}
[data-theme="dark"] .avatar-info h1{color:#f1f5f9}
[data-theme="dark"] .avatar-info .email{color:#94a3b8}
[data-theme="dark"] .role-badge-admin{background:#1e3a5f;color:#60a5fa}
[data-theme="dark"] .role-badge-user{background:#334155;color:#94a3b8}
[data-theme="dark"] .info-item{background:#162032;border-color:#334155}
[data-theme="dark"] .info-item .info-label{color:#64748b}
[data-theme="dark"] .info-item .info-label i{color:#475569}
[data-theme="dark"] .info-item .info-value{color:#f1f5f9}
[data-theme="dark"] .storage-stat{background:#162032;border-color:#334155}
[data-theme="dark"] .storage-stat .stat-value{color:#f1f5f9}
[data-theme="dark"] .storage-stat .stat-label{color:#64748b}
[data-theme="dark"] .storage-bar{background:#334155}
[data-theme="dark"] .storage-text{color:#64748b}
[data-theme="dark"] .form-group label{color:#94a3b8}
[data-theme="dark"] .form-group input{background:#0f172a;border-color:#334155;color:#e2e8f0}
[data-theme="dark"] .form-group input:focus{border-color:#ff5e3a;background:#0f172a;box-shadow:0 0 0 3px rgba(255,94,58,.15)}
[data-theme="dark"] .form-group small{color:#64748b}
[data-theme="dark"] .alert-success{background:#052e16;color:#86efac;border-color:#065f46}
[data-theme="dark"] .alert-error{background:#3b1111;color:#fca5a5;border-color:#991b1b}
[data-theme="dark"] #auth-error{background:transparent}
[data-theme="dark"] #auth-error .err-icon{background:#3b1111}
[data-theme="dark"] #auth-error h2{color:#fca5a5}
[data-theme="dark"] #auth-error p{color:#94a3b8}
[data-theme="dark"] #loading{color:#64748b}
</style>
<script src="/js/core/icons.js" defer></script>
<link rel="stylesheet" href="/css/profile.css">
</head>
<body>
<!-- Header -->
<div class="profile-header">
<div class="profile-header-left">
<a href="/" class="profile-logo-link" style="text-decoration:none;color:inherit;display:flex;align-items:center;gap:14px">
<a href="/" class="profile-logo-link link-reset-flex">
<div class="profile-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"/>
@@ -182,8 +36,7 @@ body{background:#f5f7fa;color:#1e293b;min-height:100vh;display:flex;flex-directi
<a href="/login"><i class="fas fa-sign-in-alt"></i> Sign in</a>
</div>
<div id="main-content" style="display:none">
<!-- Profile Header Card -->
<div id="main-content">
<div class="profile-card">
<div class="avatar-section">
<div class="avatar-large" id="p-avatar">—</div>
@@ -195,7 +48,6 @@ body{background:#f5f7fa;color:#1e293b;min-height:100vh;display:flex;flex-directi
</div>
</div>
<!-- Account Details -->
<div class="profile-card">
<h2><i class="fas fa-id-card"></i> Account Details</h2>
<div class="info-grid">
@@ -218,7 +70,6 @@ body{background:#f5f7fa;color:#1e293b;min-height:100vh;display:flex;flex-directi
</div>
</div>
<!-- Storage -->
<div class="profile-card">
<h2><i class="fas fa-hdd"></i> Storage</h2>
<div class="storage-stats">
@@ -236,12 +87,11 @@ body{background:#f5f7fa;color:#1e293b;min-height:100vh;display:flex;flex-directi
</div>
</div>
<div class="storage-bar-wrap">
<div class="storage-bar"><div class="storage-fill green" id="p-storage-bar" style="width:0%"></div></div>
<div class="storage-bar"><div class="storage-fill green width-zero" id="p-storage-bar"></div></div>
<div class="storage-text" id="p-storage-text">—</div>
</div>
</div>
<!-- Change Password -->
<div class="profile-card" id="password-section">
<h2><i class="fas fa-key"></i> Change Password</h2>
<form id="password-form" onsubmit="return changePassword(event)">
@@ -265,152 +115,6 @@ body{background:#f5f7fa;color:#1e293b;min-height:100vh;display:flex;flex-directi
</div>
</div>
<script>
const API = '/api';
const token = localStorage.getItem('oxicloud_token') || localStorage.getItem('token') || localStorage.getItem('access_token');
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) + ' min ago';
if (secs < 86400) return Math.floor(secs/3600) + 'h ago';
if (secs < 2592000) return Math.floor(secs/86400) + ' days ago';
return d.toLocaleDateString();
}
async function init() {
if (!token) { showError(); return; }
try {
const resp = await fetch(API + '/auth/me', { headers: headers() });
if (!resp.ok) { showError(); return; }
const user = await resp.json();
// Avatar
const initials = (user.username || '?').substring(0, 2).toUpperCase();
document.getElementById('p-avatar').textContent = initials;
document.getElementById('p-username').textContent = user.username;
document.getElementById('p-email').textContent = user.email || '';
// Role badge
const badge = document.getElementById('p-role-badge');
if (user.role === 'admin') {
badge.className = 'role-badge role-badge-admin';
badge.innerHTML = '<i class="fas fa-shield-alt"></i> Administrator';
} else {
badge.className = 'role-badge role-badge-user';
badge.innerHTML = '<i class="fas fa-user"></i> User';
}
// Details
document.getElementById('p-detail-username').textContent = user.username;
document.getElementById('p-detail-email').textContent = user.email || '—';
document.getElementById('p-detail-role').textContent = user.role === 'admin' ? 'Administrator' : 'User';
document.getElementById('p-detail-login').textContent = timeAgo(user.last_login_at);
// Storage
const used = user.storage_used_bytes || 0;
const quota = user.storage_quota_bytes || 0;
const pct = quota > 0 ? Math.min(Math.round((used / quota) * 100), 100) : 0;
document.getElementById('p-storage-used').textContent = formatBytes(used);
document.getElementById('p-storage-quota').textContent = quota > 0 ? formatBytes(quota) : '∞';
document.getElementById('p-storage-pct').textContent = quota > 0 ? pct + '%' : '—';
const bar = document.getElementById('p-storage-bar');
bar.style.width = pct + '%';
bar.className = 'storage-fill ' + (pct > 90 ? 'red' : pct > 70 ? 'orange' : 'green');
document.getElementById('p-storage-text').textContent = formatBytes(used) + ' / ' + (quota > 0 ? formatBytes(quota) : 'Unlimited');
// Hide change password for OIDC-only users
if (user.auth_provider && user.auth_provider !== 'local') {
document.getElementById('password-section').style.display = 'none';
}
// Also check OIDC config: hide password form when password login is disabled
try {
const oidcResp = await fetch(API + '/auth/oidc/providers');
if (oidcResp.ok) {
const oidcInfo = await oidcResp.json();
if (!oidcInfo.password_login_enabled) {
document.getElementById('password-section').style.display = 'none';
}
}
} catch (oidcErr) {
// Ignore — if OIDC endpoint is unavailable, password login is implicitly enabled
}
document.getElementById('loading').style.display = 'none';
document.getElementById('main-content').style.display = 'block';
} catch (e) {
console.error(e);
showError();
}
}
function showError() {
document.getElementById('loading').style.display = 'none';
document.getElementById('auth-error').style.display = 'block';
}
async function changePassword(e) {
e.preventDefault();
const currentPw = document.getElementById('current-password').value;
const newPw = document.getElementById('new-password').value;
const confirmPw = document.getElementById('confirm-password').value;
const statusEl = document.getElementById('pw-status');
if (newPw !== confirmPw) {
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> Passwords do not match</div>';
return false;
}
if (newPw.length < 8) {
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> Password must be at least 8 characters</div>';
return false;
}
const btn = document.getElementById('pw-submit');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Updating…';
try {
const resp = await fetch(API + '/auth/change-password', {
method: 'PUT',
headers: headers(),
body: JSON.stringify({ current_password: currentPw, new_password: newPw })
});
if (resp.ok) {
statusEl.innerHTML = '<div class="alert alert-success"><i class="fas fa-check-circle"></i> Password updated successfully</div>';
document.getElementById('password-form').reset();
} else {
const err = await resp.json().catch(() => ({}));
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ' + (err.message || 'Failed to change password') + '</div>';
}
} catch (err) {
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> Network error: ' + err.message + '</div>';
}
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-save"></i> Update Password';
return false;
}
init();
</script>
<script src="/js/views/profile/profile.js" defer></script>
</body>
</html>
+6 -5
View File
@@ -8,7 +8,7 @@
<script>if(localStorage.getItem('oxicloud_theme')==='dark')document.documentElement.setAttribute('data-theme','dark');</script>
<link rel="stylesheet" href="/css/style.css">
<link rel="icon" href="/favicon.ico" type="image/x-icon">
<script src="/js/icons.js" defer></script>
<script src="/js/core/icons.js" defer></script>
</head>
<body>
<!-- Main layout similar to index.html -->
@@ -247,9 +247,10 @@
<button id="close-notification" class="close-notification-btn">×</button>
</div>
<script src="/js/i18n.js"></script>
<script src="/js/languageSelector.js"></script>
<script src="/js/fileSharing.js"></script>
<script src="/js/core/i18n.js"></script>
<script src="/js/core/languageSelector.js"></script>
<script src="/js/core/formatters.js"></script>
<script src="/js/features/sharing/fileSharing.js"></script>
<script>
// Auth check — redirect to login if no token
(function() {
@@ -306,6 +307,6 @@
}
})();
</script>
<script src="/js/shared.js"></script>
<script src="/js/views/shared/shared.js"></script>
</body>
</html>
-68
View File
@@ -1,68 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test OxiCloud</title>
</head>
<body>
<h1>OxiCloud Test Page</h1>
<div id="result"></div>
<button id="createBtn">Create new folder</button>
<button id="listBtn">List folders</button>
<script>
// Function to show results
function showResult(data) {
document.getElementById('result').innerHTML =
'<pre>' + JSON.stringify(data, null, 2) + '</pre>';
}
// Function to create folder
async function createFolder() {
try {
const response = await fetch('/api/folders', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: 'test_folder_' + Date.now(),
parent_id: null
})
});
const data = await response.json();
console.log('Folder creation response:', data);
showResult(data);
return data;
} catch (error) {
console.error('Error creating folder:', error);
showResult({error: error.message});
return null;
}
}
// Function to list folders
async function listFolders() {
try {
const response = await fetch('/api/folders');
const data = await response.json();
console.log('Folder listing:', data);
showResult(data);
return data;
} catch (error) {
console.error('Error listing folders:', error);
showResult({error: error.message});
return [];
}
}
// Assign events to buttons
document.getElementById('createBtn').addEventListener('click', createFolder);
document.getElementById('listBtn').addEventListener('click', listFolders);
</script>
</body>
</html>