feat: add role-based sidebar link visibility control #14

Merged
jimmy merged 1 commits from dev into main 2026-03-01 18:19:11 +08:00
7 changed files with 74 additions and 17 deletions
+6 -4
View File
@@ -31,7 +31,7 @@ def create_refresh_token(data: dict):
return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM) return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
async def get_current_user(token: str = Depends(oauth2_scheme)): async def get_current_user(token: str = Depends(oauth2_scheme)):
from rbac import User, get_pages from rbac import User, get_pages, get_sidebar_links
credentials_exception = HTTPException( credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials", detail="Could not validate credentials",
@@ -48,10 +48,11 @@ async def get_current_user(token: str = Depends(oauth2_scheme)):
except jwt.PyJWTError: except jwt.PyJWTError:
raise credentials_exception raise credentials_exception
pages = get_pages(username, role) pages = get_pages(username, role)
return User(username=username, role=role, pages=pages) sidebar_links = get_sidebar_links(username, role)
return User(username=username, role=role, pages=pages, sidebar_links=sidebar_links)
async def get_current_user_ws(token: str): async def get_current_user_ws(token: str):
from rbac import User, get_pages from rbac import User, get_pages, get_sidebar_links
credentials_exception = HTTPException( credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials", detail="Could not validate credentials",
@@ -67,7 +68,8 @@ async def get_current_user_ws(token: str):
except jwt.PyJWTError: except jwt.PyJWTError:
raise credentials_exception raise credentials_exception
pages = get_pages(username, role) pages = get_pages(username, role)
return User(username=username, role=role, pages=pages) sidebar_links = get_sidebar_links(username, role)
return User(username=username, role=role, pages=pages, sidebar_links=sidebar_links)
# TOTP Persistence # TOTP Persistence
AUTH_FILE = config.VOLUME_ROOT + "/docker/nas-dashboard/auth.json" AUTH_FILE = config.VOLUME_ROOT + "/docker/nas-dashboard/auth.json"
+12 -3
View File
@@ -16,9 +16,9 @@ ROLE_GROUP_MAP = {
DEFAULT_RBAC = { DEFAULT_RBAC = {
"role_defaults": { "role_defaults": {
"admin": {"pages": "*"}, "admin": {"pages": "*", "sidebar_links": "*"},
"member": {"pages": ["dashboard", "docker", "files", "openclaw", "chat-digest"]}, "member": {"pages": ["dashboard", "docker", "files", "openclaw", "chat-digest"], "sidebar_links": "*"},
"viewer": {"pages": ["dashboard"]}, "viewer": {"pages": ["dashboard"], "sidebar_links": ["dashboard"]},
}, },
"user_overrides": {}, "user_overrides": {},
} }
@@ -28,6 +28,7 @@ class User(BaseModel):
username: str username: str
role: str role: str
pages: list | str # "*" or list of page ids pages: list | str # "*" or list of page ids
sidebar_links: list | str = "*" # "*" or list of sidebar link labels/ids
def load_rbac() -> dict: def load_rbac() -> dict:
@@ -61,6 +62,14 @@ def get_pages(username: str, role: str) -> list | str:
return rbac.get("role_defaults", {}).get(role, {}).get("pages", []) return rbac.get("role_defaults", {}).get(role, {}).get("pages", [])
def get_sidebar_links(username: str, role: str) -> list | str:
rbac = load_rbac()
overrides = rbac.get("user_overrides", {})
if username in overrides and "sidebar_links" in overrides[username]:
return overrides[username]["sidebar_links"]
return rbac.get("role_defaults", {}).get(role, {}).get("sidebar_links", "*")
def get_sidebar_order(username: str) -> dict | None: def get_sidebar_order(username: str) -> dict | None:
rbac = load_rbac() rbac = load_rbac()
overrides = rbac.get("user_overrides", {}) overrides = rbac.get("user_overrides", {})
+8 -1
View File
@@ -156,6 +156,7 @@ async def read_users_me(current_user = Depends(auth.get_current_user)):
"username": current_user.username, "username": current_user.username,
"role": current_user.role, "role": current_user.role,
"pages": current_user.pages, "pages": current_user.pages,
"sidebar_links": current_user.sidebar_links,
"has_2fa": bool(totp_secret), "has_2fa": bool(totp_secret),
} }
@@ -411,11 +412,17 @@ async def rbac_update_role(role: str, request: Request, current_user = Depends(a
from rbac import load_rbac, save_rbac from rbac import load_rbac, save_rbac
body = await request.json() body = await request.json()
pages = body.get("pages") pages = body.get("pages")
sidebar_links = body.get("sidebar_links")
if pages is None: if pages is None:
raise HTTPException(status_code=400, detail="Missing pages field") raise HTTPException(status_code=400, detail="Missing pages field")
if pages != "*" and not isinstance(pages, list): if pages != "*" and not isinstance(pages, list):
raise HTTPException(status_code=400, detail="pages must be '*' or a list") raise HTTPException(status_code=400, detail="pages must be '*' or a list")
if sidebar_links is not None and sidebar_links != "*" and not isinstance(sidebar_links, list):
raise HTTPException(status_code=400, detail="sidebar_links must be '*' or a list")
rbac = load_rbac() rbac = load_rbac()
rbac.setdefault("role_defaults", {})[role] = {"pages": pages} role_config = {"pages": pages}
if sidebar_links is not None:
role_config["sidebar_links"] = sidebar_links
rbac.setdefault("role_defaults", {})[role] = role_config
save_rbac(rbac) save_rbac(rbac)
return {"ok": True} return {"ok": True}
+3 -1
View File
@@ -20,6 +20,7 @@
let loading = $state(true); let loading = $state(true);
let userRole = $state(""); let userRole = $state("");
let allowedPages = $state([]); let allowedPages = $state([]);
let allowedSidebarLinks = $state("*");
let sidebarOrder = $state(null); let sidebarOrder = $state(null);
let orderSaveTimer = null; let orderSaveTimer = null;
@@ -29,6 +30,7 @@
setCurrentUser(data); setCurrentUser(data);
userRole = data.role || "admin"; userRole = data.role || "admin";
allowedPages = data.pages || "*"; allowedPages = data.pages || "*";
allowedSidebarLinks = data.sidebar_links || "*";
// Fetch sidebar preferences // Fetch sidebar preferences
try { try {
const prefs = await getPreferences(); const prefs = await getPreferences();
@@ -126,7 +128,7 @@
<Login /> <Login />
{:else} {:else}
<div class="flex h-screen overflow-hidden bg-surface-50 dark:bg-surface-900"> <div class="flex h-screen overflow-hidden bg-surface-50 dark:bg-surface-900">
<Sidebar bind:page {dark} {toggleTheme} {logout} bind:sidebarOpen {allowedPages} {userRole} {sidebarOrder} onOrderChange={handleOrderChange} /> <Sidebar bind:page {dark} {toggleTheme} {logout} bind:sidebarOpen {allowedPages} {allowedSidebarLinks} {userRole} {sidebarOrder} onOrderChange={handleOrderChange} />
<main class="flex-1 overflow-auto"> <main class="flex-1 overflow-auto">
<div class="max-w-[1400px] mx-auto p-6 lg:p-8"> <div class="max-w-[1400px] mx-auto p-6 lg:p-8">
<!-- Mobile hamburger --> <!-- Mobile hamburger -->
@@ -1,5 +1,5 @@
<script> <script>
let { page = $bindable("dashboard"), dark = false, toggleTheme = () => {}, logout = () => {}, sidebarOpen = $bindable(false), allowedPages = "*", userRole = "admin", sidebarOrder = null, onOrderChange = () => {} } = $props(); let { page = $bindable("dashboard"), dark = false, toggleTheme = () => {}, logout = () => {}, sidebarOpen = $bindable(false), allowedPages = "*", allowedSidebarLinks = "*", userRole = "admin", sidebarOrder = null, onOrderChange = () => {} } = $props();
function canAccess(id) { function canAccess(id) {
if (!id) return true; if (!id) return true;
@@ -7,6 +7,13 @@
return Array.isArray(allowedPages) && allowedPages.includes(id); return Array.isArray(allowedPages) && allowedPages.includes(id);
} }
function canSeeSidebarLink(item) {
if (allowedSidebarLinks === "*") return true;
// Check by label or id
const key = item.label || item.id;
return Array.isArray(allowedSidebarLinks) && allowedSidebarLinks.includes(key);
}
const defaultLinks = [ const defaultLinks = [
{ id: "dashboard", label: "Overview", icon: "grid" }, { id: "dashboard", label: "Overview", icon: "grid" },
{ id: "docker", label: "Docker", icon: "box" }, { id: "docker", label: "Docker", icon: "box" },
@@ -283,7 +290,7 @@
</div> </div>
{/if} {/if}
{#each items as item, i} {#each items as item, i}
{#if canAccess(item.id)} {#if canAccess(item.id) && canSeeSidebarLink(item)}
{#if item.external || item.remoteHref || item.port} {#if item.external || item.remoteHref || item.port}
<a <a
href={extHref(item)} href={extHref(item)}
+36 -6
View File
@@ -133,6 +133,7 @@
// Access Control // Access Control
const allPages = ["dashboard", "docker", "files", "terminal", "security", "openclaw", "chat-digest", "gitea", "settings"]; const allPages = ["dashboard", "docker", "files", "terminal", "security", "openclaw", "chat-digest", "gitea", "settings"];
const allSidebarLinks = ["Overview", "Docker", "Files", "Terminal", "Security", "Navidrome", "Jellyfin", "Audiobookshelf", "Immich", "OpenClaw", "Chat Digest", "Repos", "Gitea Web", "Stirling PDF", "Vaultwarden", "n8n", "Speedtest"];
let rbacConfig = $state(null); let rbacConfig = $state(null);
let rbacLoading = $state(false); let rbacLoading = $state(false);
let rbacMsg = $state(""); let rbacMsg = $state("");
@@ -142,6 +143,7 @@
let newUsername = $state(""); let newUsername = $state("");
let editingRole = $state(""); let editingRole = $state("");
let editRolePages = $state([]); let editRolePages = $state([]);
let editRoleSidebarLinks = $state([]);
async function loadRbac() { async function loadRbac() {
rbacLoading = true; rbacLoading = true;
@@ -171,11 +173,13 @@
newUsername = ""; newUsername = "";
editingRole = ""; editingRole = "";
editRolePages = []; editRolePages = [];
editRoleSidebarLinks = [];
} }
function startEditRole(role, pages) { function startEditRole(role, pages, sidebarLinks) {
editingRole = role; editingRole = role;
editRolePages = pages === "*" ? ["*"] : [...pages]; editRolePages = pages === "*" ? ["*"] : [...pages];
editRoleSidebarLinks = sidebarLinks === "*" ? ["*"] : (sidebarLinks ? [...sidebarLinks] : ["*"]);
editingUser = ""; editingUser = "";
} }
@@ -189,6 +193,16 @@
} }
} }
function toggleRoleSidebarLink(link) {
if (link === "*") {
editRoleSidebarLinks = ["*"];
} else {
if (editRoleSidebarLinks.includes("*")) editRoleSidebarLinks = [];
if (editRoleSidebarLinks.includes(link)) editRoleSidebarLinks = editRoleSidebarLinks.filter(x => x !== link);
else editRoleSidebarLinks = [...editRoleSidebarLinks, link];
}
}
function togglePage(p) { function togglePage(p) {
if (editPages.includes(p)) editPages = editPages.filter(x => x !== p); if (editPages.includes(p)) editPages = editPages.filter(x => x !== p);
else editPages = [...editPages, p]; else editPages = [...editPages, p];
@@ -225,9 +239,11 @@
rbacMsg = ""; rbacErr = ""; rbacMsg = ""; rbacErr = "";
if (!editingRole) return; if (!editingRole) return;
if (!editRolePages.length) { rbacErr = "Select at least one page or '*' for all"; return; } if (!editRolePages.length) { rbacErr = "Select at least one page or '*' for all"; return; }
if (!editRoleSidebarLinks.length) { rbacErr = "Select at least one sidebar link or '*' for all"; return; }
try { try {
const pages = editRolePages.includes("*") ? "*" : editRolePages; const pages = editRolePages.includes("*") ? "*" : editRolePages;
await put(`/auth/rbac/roles/${encodeURIComponent(editingRole)}`, { pages }); const sidebar_links = editRoleSidebarLinks.includes("*") ? "*" : editRoleSidebarLinks;
await put(`/auth/rbac/roles/${encodeURIComponent(editingRole)}`, { pages, sidebar_links });
rbacMsg = `Updated role default for ${editingRole}`; rbacMsg = `Updated role default for ${editingRole}`;
cancelEdit(); cancelEdit();
await loadRbac(); await loadRbac();
@@ -341,12 +357,20 @@
{#if editingRole === role} {#if editingRole === role}
<div class="p-3 bg-surface-50 dark:bg-surface-700 rounded-lg space-y-2"> <div class="p-3 bg-surface-50 dark:bg-surface-700 rounded-lg space-y-2">
<p class="text-xs font-medium text-surface-700 dark:text-surface-200">{role}</p> <p class="text-xs font-medium text-surface-700 dark:text-surface-200">{role}</p>
<p class="text-[10px] text-surface-500 mt-1">Pages (internal dashboard pages)</p>
<div class="flex flex-wrap gap-1.5"> <div class="flex flex-wrap gap-1.5">
<button onclick={() => toggleRolePage("*")} class="px-2 py-0.5 text-xs rounded-md transition-colors {editRolePages.includes('*') ? 'bg-primary-600 text-white' : 'bg-surface-200 dark:bg-surface-600 text-surface-500'}">* (all)</button> <button onclick={() => toggleRolePage("*")} class="px-2 py-0.5 text-xs rounded-md transition-colors {editRolePages.includes('*') ? 'bg-primary-600 text-white' : 'bg-surface-200 dark:bg-surface-600 text-surface-500'}">* (all)</button>
{#each allPages as p} {#each allPages as p}
<button onclick={() => toggleRolePage(p)} class="px-2 py-0.5 text-xs rounded-md transition-colors {editRolePages.includes(p) ? 'bg-primary-600 text-white' : 'bg-surface-200 dark:bg-surface-600 text-surface-500'}">{p}</button> <button onclick={() => toggleRolePage(p)} class="px-2 py-0.5 text-xs rounded-md transition-colors {editRolePages.includes(p) ? 'bg-primary-600 text-white' : 'bg-surface-200 dark:bg-surface-600 text-surface-500'}">{p}</button>
{/each} {/each}
</div> </div>
<p class="text-[10px] text-surface-500 mt-2">Sidebar Links (all services)</p>
<div class="flex flex-wrap gap-1.5">
<button onclick={() => toggleRoleSidebarLink("*")} class="px-2 py-0.5 text-xs rounded-md transition-colors {editRoleSidebarLinks.includes('*') ? 'bg-primary-600 text-white' : 'bg-surface-200 dark:bg-surface-600 text-surface-500'}">* (all)</button>
{#each allSidebarLinks as link}
<button onclick={() => toggleRoleSidebarLink(link)} class="px-2 py-0.5 text-xs rounded-md transition-colors {editRoleSidebarLinks.includes(link) ? 'bg-primary-600 text-white' : 'bg-surface-200 dark:bg-surface-600 text-surface-500'}">{link}</button>
{/each}
</div>
<div class="flex gap-2"> <div class="flex gap-2">
<button onclick={saveRoleDefault} class="px-3 py-1 text-xs font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-md transition-colors">Save</button> <button onclick={saveRoleDefault} class="px-3 py-1 text-xs font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-md transition-colors">Save</button>
<button onclick={cancelEdit} class="px-3 py-1 text-xs font-medium text-surface-600 hover:text-surface-800 dark:text-surface-400 dark:hover:text-surface-200">Cancel</button> <button onclick={cancelEdit} class="px-3 py-1 text-xs font-medium text-surface-600 hover:text-surface-800 dark:text-surface-400 dark:hover:text-surface-200">Cancel</button>
@@ -354,11 +378,17 @@
</div> </div>
{:else} {:else}
<div class="flex items-center justify-between p-2 bg-surface-50 dark:bg-surface-700 rounded-lg"> <div class="flex items-center justify-between p-2 bg-surface-50 dark:bg-surface-700 rounded-lg">
<div class="flex items-center gap-2 text-xs"> <div class="flex flex-col gap-1 text-xs">
<span class="font-medium text-surface-700 dark:text-surface-300 w-16">{role}</span> <div class="flex items-center gap-2">
<span class="text-surface-400">{cfg.pages === "*" ? "All pages" : (cfg.pages || []).join(", ")}</span> <span class="font-medium text-surface-700 dark:text-surface-300 w-16">{role}</span>
<span class="text-surface-400">Pages: {cfg.pages === "*" ? "All" : (cfg.pages || []).join(", ")}</span>
</div>
<div class="flex items-center gap-2">
<span class="w-16"></span>
<span class="text-surface-400">Links: {cfg.sidebar_links === "*" ? "All" : (cfg.sidebar_links || ["*"]).join(", ")}</span>
</div>
</div> </div>
<button onclick={() => startEditRole(role, cfg.pages)} class="text-xs text-primary-500 hover:text-primary-700">Edit</button> <button onclick={() => startEditRole(role, cfg.pages, cfg.sidebar_links)} class="text-xs text-primary-500 hover:text-primary-700">Edit</button>
</div> </div>
{/if} {/if}
{/each} {/each}