From a8debcfb4b7ea3538b2c24731d58e9de92abace9 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Sun, 1 Mar 2026 14:48:35 +0800 Subject: [PATCH 1/6] feat: access control UI in Settings + sidebar drag-and-drop reordering - Add Access Control card in Settings (admin-only): role defaults display, user overrides CRUD - Add sidebar drag-and-drop reordering with per-user persistence in rbac.json - Backend: GET/PUT /api/auth/preferences endpoints, sidebar order helpers in rbac.py - Frontend: HTML5 drag API with grip handles, smart order merge, debounced save - Preserve sidebar_order when updating page overrides --- dashboard/backend/rbac.py | 12 ++ dashboard/backend/routers/auth.py | 20 ++- dashboard/frontend/src/App.svelte | 19 +- .../frontend/src/components/Sidebar.svelte | 123 +++++++++++-- dashboard/frontend/src/lib/api.js | 12 ++ dashboard/frontend/src/routes/Settings.svelte | 168 +++++++++++++++++- 6 files changed, 334 insertions(+), 20 deletions(-) diff --git a/dashboard/backend/rbac.py b/dashboard/backend/rbac.py index 20da62e..a385ee7 100644 --- a/dashboard/backend/rbac.py +++ b/dashboard/backend/rbac.py @@ -61,6 +61,18 @@ def get_pages(username: str, role: str) -> list | str: return rbac.get("role_defaults", {}).get(role, {}).get("pages", []) +def get_sidebar_order(username: str) -> dict | None: + rbac = load_rbac() + overrides = rbac.get("user_overrides", {}) + return overrides.get(username, {}).get("sidebar_order") + + +def save_sidebar_order(username: str, order: dict): + rbac = load_rbac() + rbac.setdefault("user_overrides", {}).setdefault(username, {})["sidebar_order"] = order + save_rbac(rbac) + + def has_page_access(user: User, page: str) -> bool: if user.pages == "*": return True diff --git a/dashboard/backend/routers/auth.py b/dashboard/backend/routers/auth.py index 9246002..0e3775f 100644 --- a/dashboard/backend/routers/auth.py +++ b/dashboard/backend/routers/auth.py @@ -364,10 +364,28 @@ async def rbac_set_override(username: str, request: Request, current_user = Depe if pages is None: raise HTTPException(status_code=400, detail="Missing pages field") rbac = load_rbac() - rbac.setdefault("user_overrides", {})[username] = {"pages": pages} + existing = rbac.setdefault("user_overrides", {}).get(username, {}) + existing["pages"] = pages + rbac["user_overrides"][username] = existing save_rbac(rbac) return {"ok": True} +@router.get("/preferences") +async def get_preferences(current_user = Depends(auth.get_current_user)): + from rbac import get_sidebar_order + order = get_sidebar_order(current_user.username) + return {"sidebar_order": order} + +@router.put("/preferences") +async def save_preferences(request: Request, current_user = Depends(auth.get_current_user)): + from rbac import save_sidebar_order + body = await request.json() + order = body.get("sidebar_order") + if order is None or not isinstance(order, dict): + raise HTTPException(status_code=400, detail="Missing sidebar_order dict") + save_sidebar_order(current_user.username, order) + return {"ok": True} + @router.delete("/rbac/overrides/{username}") async def rbac_delete_override(username: str, current_user = Depends(auth.get_current_user)): if current_user.role != "admin": diff --git a/dashboard/frontend/src/App.svelte b/dashboard/frontend/src/App.svelte index eeb0f2b..7262c2e 100644 --- a/dashboard/frontend/src/App.svelte +++ b/dashboard/frontend/src/App.svelte @@ -11,7 +11,7 @@ import Security from "./routes/Security.svelte"; import Login from "./routes/Login.svelte"; import { onMount } from "svelte"; - import { getToken, checkAuth, setToken, setRefreshToken, currentUser, setCurrentUser } from "./lib/api.js"; + import { getToken, checkAuth, setToken, setRefreshToken, currentUser, setCurrentUser, getPreferences, savePreferences } from "./lib/api.js"; let page = $state("dashboard"); let dark = $state(false); @@ -20,6 +20,8 @@ let loading = $state(true); let userRole = $state(""); let allowedPages = $state([]); + let sidebarOrder = $state(null); + let orderSaveTimer = null; async function fetchUserInfo() { try { @@ -27,11 +29,24 @@ setCurrentUser(data); userRole = data.role || "admin"; allowedPages = data.pages || "*"; + // Fetch sidebar preferences + try { + const prefs = await getPreferences(); + sidebarOrder = prefs.sidebar_order || null; + } catch {} } catch (e) { console.error("Failed to fetch user info:", e); } } + function handleOrderChange(newOrder) { + sidebarOrder = newOrder; + clearTimeout(orderSaveTimer); + orderSaveTimer = setTimeout(() => { + savePreferences({ sidebar_order: newOrder }).catch(() => {}); + }, 500); + } + function hasPageAccess(pageId) { if (allowedPages === "*") return true; return Array.isArray(allowedPages) && allowedPages.includes(pageId); @@ -109,7 +124,7 @@ {:else}
- +
diff --git a/dashboard/frontend/src/components/Sidebar.svelte b/dashboard/frontend/src/components/Sidebar.svelte index 2a4d968..95cc5ea 100644 --- a/dashboard/frontend/src/components/Sidebar.svelte +++ b/dashboard/frontend/src/components/Sidebar.svelte @@ -1,13 +1,13 @@ @@ -80,16 +151,22 @@
+ {#if currentUser.role === "admin" && rbacConfig} +
+
+

Access Control

+ +
+ + {#if rbacMsg} +
{rbacMsg}
+ {/if} + {#if rbacErr} +
{rbacErr}
+ {/if} + + +
+

Role Defaults

+
+ {#each Object.entries(rbacConfig.role_defaults || {}) as [role, cfg]} +
+ {role} + {cfg.pages === "*" ? "All pages" : (cfg.pages || []).join(", ")} +
+ {/each} +
+
+ + +
+
+

User Overrides

+ {#if editingUser !== "__new__"} + + {/if} +
+ + {#if Object.keys(rbacConfig.user_overrides || {}).length > 0} +
+ {#each Object.entries(rbacConfig.user_overrides) as [username, cfg]} + {#if editingUser === username} +
+

{username}

+
+ {#each allPages as p} + + {/each} +
+
+ + +
+
+ {:else} +
+
+ {username} + {(cfg.pages || []).join(", ")} +
+
+ + +
+
+ {/if} + {/each} +
+ {:else if editingUser !== "__new__"} +

No user overrides configured

+ {/if} + + {#if editingUser === "__new__"} +
+ +
+ {#each allPages as p} + + {/each} +
+
+ + +
+
+ {/if} +
+
+ {/if} +

Audit Log

From 62856c9343c47cb6c8b73a29a333827f08c90d44 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Sun, 1 Mar 2026 14:59:23 +0800 Subject: [PATCH 2/6] fix: comprehensive dark mode support across all pages - Fix body/html dark background to surface-950 - Add dark scrollbar styling - Fix App.svelte wrapper to use Tailwind dark: classes instead of conditional strings - Add dark:bg-surface-800 and dark:border-surface-700 to all cards in Dashboard, Docker, Files, Gitea - Add dark text variants for headings, labels, and content text - Add dark hover states for interactive elements - Enable cross-category sidebar drag-and-drop --- dashboard/frontend/src/App.svelte | 4 +- dashboard/frontend/src/app.css | 9 ++-- .../frontend/src/components/Sidebar.svelte | 47 ++++++++++++++----- .../frontend/src/routes/Dashboard.svelte | 32 ++++++------- dashboard/frontend/src/routes/Docker.svelte | 14 +++--- dashboard/frontend/src/routes/Files.svelte | 16 +++---- dashboard/frontend/src/routes/Gitea.svelte | 18 +++---- immich/docker-compose.yml | 2 +- 8 files changed, 84 insertions(+), 58 deletions(-) diff --git a/dashboard/frontend/src/App.svelte b/dashboard/frontend/src/App.svelte index 7262c2e..e0dd134 100644 --- a/dashboard/frontend/src/App.svelte +++ b/dashboard/frontend/src/App.svelte @@ -123,12 +123,12 @@ {:else if !authorized} {:else} -
+
- {#if page === "dashboard" && hasPageAccess("dashboard")} diff --git a/dashboard/frontend/src/app.css b/dashboard/frontend/src/app.css index 28e114e..2b967ac 100644 --- a/dashboard/frontend/src/app.css +++ b/dashboard/frontend/src/app.css @@ -22,6 +22,7 @@ --color-surface-700: #334155; --color-surface-800: #1e293b; --color-surface-900: #0f172a; + --color-surface-950: #020617; --color-emerald-400: #34d399; --color-emerald-500: #10b981; @@ -57,8 +58,10 @@ body { ::-webkit-scrollbar-thumb:hover { background: var(--color-surface-400); } /* Dark mode overrides */ -.dark body, -.dark { - background: var(--color-surface-900); +html.dark body { + background: var(--color-surface-950); color: var(--color-surface-200); } + +html.dark ::-webkit-scrollbar-thumb { background: var(--color-surface-600); } +html.dark ::-webkit-scrollbar-thumb:hover { background: var(--color-surface-500); } diff --git a/dashboard/frontend/src/components/Sidebar.svelte b/dashboard/frontend/src/components/Sidebar.svelte index 95cc5ea..e932b35 100644 --- a/dashboard/frontend/src/components/Sidebar.svelte +++ b/dashboard/frontend/src/components/Sidebar.svelte @@ -69,6 +69,7 @@ // Drag-and-drop state let dragCategory = $state(null); let dragIndex = $state(-1); + let dropCategory = $state(null); let dropIndex = $state(-1); function handleDragStart(category, index, e) { @@ -79,26 +80,49 @@ } function handleDragOver(category, index, e) { - if (category !== dragCategory) return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; + dropCategory = category; dropIndex = index; } function handleDragEnd() { dragCategory = null; dragIndex = -1; + dropCategory = null; dropIndex = -1; } function handleDrop(category, index, e) { e.preventDefault(); - if (category !== dragCategory || dragIndex === index) { handleDragEnd(); return; } + if (dragCategory === null) { handleDragEnd(); return; } + if (dragCategory === category && dragIndex === index) { handleDragEnd(); return; } const lists = { main: [...links], media: [...media], tools: [...tools] }; - const list = lists[category]; - const [moved] = list.splice(dragIndex, 1); - list.splice(index, 0, moved); - lists[category] = list; + const [moved] = lists[dragCategory].splice(dragIndex, 1); + lists[category].splice(index, 0, moved); + const newOrder = { + main: lists.main.map(itemKey), + media: lists.media.map(itemKey), + tools: lists.tools.map(itemKey), + }; + onOrderChange(newOrder); + handleDragEnd(); + } + + // Allow dropping at end of a category (on the section divider/header area) + function handleCategoryDragOver(category, e) { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + dropCategory = category; + dropIndex = -1; + } + + function handleCategoryDrop(category, e) { + e.preventDefault(); + if (dragCategory === null) { handleDragEnd(); return; } + const lists = { main: [...links], media: [...media], tools: [...tools] }; + const [moved] = lists[dragCategory].splice(dragIndex, 1); + lists[category].push(moved); const newOrder = { main: lists.main.map(itemKey), media: lists.media.map(itemKey), @@ -124,9 +148,8 @@ } function dragClasses(category, index) { - if (dragCategory !== category) return ""; - if (index === dragIndex) return "opacity-30"; - if (index === dropIndex) return "border-t-2 border-primary-500"; + if (dragCategory === category && index === dragIndex) return "opacity-30"; + if (dropCategory === category && index === dropIndex) return "border-t-2 border-primary-500"; return ""; } @@ -150,7 +173,7 @@
-

Media

+

handleCategoryDragOver("media", e)} ondrop={(e) => handleCategoryDrop("media", e)}>Media

{#each media as svc, i}
-

Tools

+

handleCategoryDragOver("tools", e)} ondrop={(e) => handleCategoryDrop("tools", e)}>Tools

{#each tools as item, i} {#if canAccess(item.id)} {#if item.external || item.port} diff --git a/dashboard/frontend/src/routes/Dashboard.svelte b/dashboard/frontend/src/routes/Dashboard.svelte index 9fc7d66..ab712af 100644 --- a/dashboard/frontend/src/routes/Dashboard.svelte +++ b/dashboard/frontend/src/routes/Dashboard.svelte @@ -40,21 +40,21 @@
-

Overview

+

Overview

System status at a glance

{#if loading}
{#each Array(4) as _} -
+
{/each}
{:else}
-
+

CPU

@@ -66,7 +66,7 @@
-
+

Memory

@@ -78,7 +78,7 @@
-
+

Disk

@@ -90,14 +90,14 @@
-
+

Uptime

-

{stats?.uptime || "—"}

+

{stats?.uptime || "—"}

{stats?.platform || "—"}

@@ -105,9 +105,9 @@
-
+
-

Docker Containers

+

Docker Containers

{containers.filter(c => c.status === "running").length}/{containers.length} running
@@ -115,7 +115,7 @@
- {c.name} + {c.name}
{c.image}
@@ -127,9 +127,9 @@
-
+
-

Git Repositories

+

Git Repositories

{repos.length} repos
@@ -137,7 +137,7 @@
- {r.name} + {r.name}
{r.language || "—"}
@@ -151,8 +151,8 @@ {#if stats?.volumes?.length} -
-

Storage Usage

+
+

Storage Usage

{#each stats.volumes as vol}
@@ -160,7 +160,7 @@ {vol.mount} {vol.percent}%
-
+
-

Docker

+

Docker

{containers.length} containers · {containers.filter(c => c.status === "running").length} running

{:else} - + {e.name} diff --git a/dashboard/frontend/src/routes/Gitea.svelte b/dashboard/frontend/src/routes/Gitea.svelte index c97a5c5..85b7103 100644 --- a/dashboard/frontend/src/routes/Gitea.svelte +++ b/dashboard/frontend/src/routes/Gitea.svelte @@ -41,26 +41,26 @@
-

Repositories

+

Repositories

{repos.length} repositories on Gitea

{#if loading}
{#each Array(4) as _} -
+
{/each}
{:else}
{#each repos as r} {/if} -