diff --git a/db/schema.sql b/db/schema.sql
index 29b13ae9..d0ebd486 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -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';
diff --git a/src/infrastructure/repositories/pg/folder_db_repository.rs b/src/infrastructure/repositories/pg/folder_db_repository.rs
index eea07a31..b6855d04 100644
--- a/src/infrastructure/repositories/pg/folder_db_repository.rs
+++ b/src/infrastructure/repositories/pg/folder_db_repository.rs
@@ -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())
diff --git a/static/admin.html b/static/admin.html
index dc637a83..cb0d918b 100644
--- a/static/admin.html
+++ b/static/admin.html
@@ -1,910 +1,260 @@
-
-
-
-
-
-OxiCloud — Admin Panel
-
-
-
-
-
-
-
-
-
-
-
-
Loading…
-
-
-
Access Denied
-
Administrator privileges required to access this panel.
-
Sign in
-
-
-
-
-
- Dashboard
- Users
- SSO / OIDC
-
-
-
-
-
-
-
-
Storage Overview
-
-
-
-
-
-
-
System
-
-
- Allow public self-registration
-
-
-
Public registration is disabled. Only admins can create new users.
-
-
-
-
-
-
-
User Management Create User
-
-
-
-
- User
- Role
- Status
- Storage
- Last Login
- Actions
-
-
- Loading users…
-
-
-
-
-
-
-
-
-
-
Single Sign-On (OIDC / SSO)
-
- Enable SSO Authentication
-
-
-
-
-
-
-
-
-
-
-
-
Update Storage Quota
-
- User:
-
-
-
- Cancel
- Save
-
-
-
-
-
-
-
-
Create New User
-
- Username *
-
- 3–32 characters
-
-
- Password *
-
-
-
- Email (optional)
-
-
-
-
- Role
-
- User
- Admin
-
-
-
-
-
-
- Cancel
- Create
-
-
-
-
-
-
-
-
Reset Password
-
- User:
-
-
- New Password
-
-
-
-
- Cancel
- Reset
-
-
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+
+OxiCloud — Admin Panel
+
+
+
+
+
+
+
+
+
+
Loading…
+
+
+
Access Denied
+
Administrator privileges required to access this panel.
+
Sign in
+
+
+
+
+ Dashboard
+ Users
+ SSO / OIDC
+
+
+
+
+
+
+
Storage Overview
+
+
+
+
+
+
+
System
+
+
+ Allow public self-registration
+
+
+
Public registration is disabled. Only admins can create new users.
+
+
+
+
+
+
User Management Create User
+
+
+
+
+ User
+ Role
+ Status
+ Storage
+ Last Login
+ Actions
+
+
+ Loading users…
+
+
+
+
+
+
+
+
+
Single Sign-On (OIDC / SSO)
+
+ Enable SSO Authentication
+
+
+
+
+
+
+
+
+
+
+
Update Storage Quota
+
+ User:
+
+
+
+ Cancel
+ Save
+
+
+
+
+
+
+
Create New User
+
+ Username *
+
+ 3–32 characters
+
+
+ Password *
+
+
+
+ Email (optional)
+
+
+
+
+
+ Cancel
+ Create
+
+
+
+
+
+
+
Reset Password
+
+ User:
+
+
+ New Password
+
+
+
+
+ Cancel
+ Reset
+
+
+
+
+
+
+
diff --git a/static/css/admin.css b/static/css/admin.css
new file mode 100644
index 00000000..a444da70
--- /dev/null
+++ b/static/css/admin.css
@@ -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}
diff --git a/static/css/profile.css b/static/css/profile.css
new file mode 100644
index 00000000..a9966e17
--- /dev/null
+++ b/static/css/profile.css
@@ -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}
diff --git a/static/identifier.sh b/static/identifier.sh
deleted file mode 100644
index eaec9d05..00000000
--- a/static/identifier.sh
+++ /dev/null
@@ -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)
-
diff --git a/static/index.html b/static/index.html
index 75250b3a..feb71bd1 100644
--- a/static/index.html
+++ b/static/index.html
@@ -14,22 +14,33 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
diff --git a/static/profile.html b/static/profile.html
index 7d12affb..1d831a00 100644
--- a/static/profile.html
+++ b/static/profile.html
@@ -1,416 +1,120 @@
-
-
-
-
-
-
OxiCloud — My Profile
-
-
-
-
-
-
-
-
-
-
-
-
Loading…
-
-
-
Not Authenticated
-
Please sign in to view your profile.
-
Sign in
-
-
-
-
-
-
-
-
+
+
+
+
+
+
OxiCloud — My Profile
+
+
+
+
+
+
+
+
+
+
Loading…
+
+
+
Not Authenticated
+
Please sign in to view your profile.
+
Sign in
+
+
+
+
+
+
+
+
diff --git a/static/shared.html b/static/shared.html
index 6f41d14f..348d939d 100644
--- a/static/shared.html
+++ b/static/shared.html
@@ -8,7 +8,7 @@
-
+
@@ -247,9 +247,10 @@
×
-
-
-
+
+
+
+
-
+