feat: access control UI in Settings + sidebar drag-and-drop reordering
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 1m13s

- 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
This commit is contained in:
Gan, Jimmy
2026-03-01 14:48:35 +08:00
parent 9992105b49
commit a8debcfb4b
6 changed files with 334 additions and 20 deletions
+12
View File
@@ -61,6 +61,18 @@ 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_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: def has_page_access(user: User, page: str) -> bool:
if user.pages == "*": if user.pages == "*":
return True return True
+19 -1
View File
@@ -364,10 +364,28 @@ async def rbac_set_override(username: str, request: Request, current_user = Depe
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")
rbac = load_rbac() 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) save_rbac(rbac)
return {"ok": True} 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}") @router.delete("/rbac/overrides/{username}")
async def rbac_delete_override(username: str, current_user = Depends(auth.get_current_user)): async def rbac_delete_override(username: str, current_user = Depends(auth.get_current_user)):
if current_user.role != "admin": if current_user.role != "admin":
+17 -2
View File
@@ -11,7 +11,7 @@
import Security from "./routes/Security.svelte"; import Security from "./routes/Security.svelte";
import Login from "./routes/Login.svelte"; import Login from "./routes/Login.svelte";
import { onMount } from "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 page = $state("dashboard");
let dark = $state(false); let dark = $state(false);
@@ -20,6 +20,8 @@
let loading = $state(true); let loading = $state(true);
let userRole = $state(""); let userRole = $state("");
let allowedPages = $state([]); let allowedPages = $state([]);
let sidebarOrder = $state(null);
let orderSaveTimer = null;
async function fetchUserInfo() { async function fetchUserInfo() {
try { try {
@@ -27,11 +29,24 @@
setCurrentUser(data); setCurrentUser(data);
userRole = data.role || "admin"; userRole = data.role || "admin";
allowedPages = data.pages || "*"; allowedPages = data.pages || "*";
// Fetch sidebar preferences
try {
const prefs = await getPreferences();
sidebarOrder = prefs.sidebar_order || null;
} catch {}
} catch (e) { } catch (e) {
console.error("Failed to fetch user info:", 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) { function hasPageAccess(pageId) {
if (allowedPages === "*") return true; if (allowedPages === "*") return true;
return Array.isArray(allowedPages) && allowedPages.includes(pageId); return Array.isArray(allowedPages) && allowedPages.includes(pageId);
@@ -109,7 +124,7 @@
<Login /> <Login />
{:else} {:else}
<div class="flex h-screen overflow-hidden bg-surface-50 {dark ? 'dark bg-surface-900' : ''}"> <div class="flex h-screen overflow-hidden bg-surface-50 {dark ? 'dark bg-surface-900' : ''}">
<Sidebar bind:page {dark} {toggleTheme} {logout} bind:sidebarOpen {allowedPages} {userRole} /> <Sidebar bind:page {dark} {toggleTheme} {logout} bind:sidebarOpen {allowedPages} {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 -->
+109 -14
View File
@@ -1,13 +1,13 @@
<script> <script>
let { page = $bindable("dashboard"), dark = false, toggleTheme = () => {}, logout = () => {}, sidebarOpen = $bindable(false), allowedPages = "*", userRole = "admin" } = $props(); let { page = $bindable("dashboard"), dark = false, toggleTheme = () => {}, logout = () => {}, sidebarOpen = $bindable(false), allowedPages = "*", userRole = "admin", sidebarOrder = null, onOrderChange = () => {} } = $props();
function canAccess(id) { function canAccess(id) {
if (!id) return true; // external links always shown if (!id) return true;
if (allowedPages === "*") return true; if (allowedPages === "*") return true;
return Array.isArray(allowedPages) && allowedPages.includes(id); return Array.isArray(allowedPages) && allowedPages.includes(id);
} }
const links = [ 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" },
{ id: "files", label: "Files", icon: "folder" }, { id: "files", label: "Files", icon: "folder" },
@@ -25,14 +25,14 @@
}).catch(() => {}); }).catch(() => {});
} }
const media = [ const defaultMedia = [
{ label: "Navidrome", remoteHref: "https://music.jimmygan.com:8443", icon: "music" }, { label: "Navidrome", remoteHref: "https://music.jimmygan.com:8443", icon: "music" },
{ label: "Jellyfin", remoteHref: "https://media.jimmygan.com:8443", icon: "play" }, { label: "Jellyfin", remoteHref: "https://media.jimmygan.com:8443", icon: "play" },
{ label: "Audiobookshelf", remoteHref: "https://books.jimmygan.com:8443", icon: "headphones" }, { label: "Audiobookshelf", remoteHref: "https://books.jimmygan.com:8443", icon: "headphones" },
{ label: "Immich", remoteHref: "https://photos.jimmygan.com:8443", icon: "image" }, { label: "Immich", remoteHref: "https://photos.jimmygan.com:8443", icon: "image" },
]; ];
const tools = [ const defaultTools = [
{ id: "openclaw", label: "OpenClaw", icon: "openclaw" }, { id: "openclaw", label: "OpenClaw", icon: "openclaw" },
{ id: "chat-digest", label: "Chat Digest", icon: "chat" }, { id: "chat-digest", label: "Chat Digest", icon: "chat" },
{ id: "gitea", label: "Repos", icon: "git" }, { id: "gitea", label: "Repos", icon: "git" },
@@ -44,6 +44,70 @@
{ label: "Speedtest", remoteHref: "https://speed.jimmygan.com:8443", icon: "speedtest", external: true }, { label: "Speedtest", remoteHref: "https://speed.jimmygan.com:8443", icon: "speedtest", external: true },
]; ];
// Item key: use id if present, otherwise label
function itemKey(item) { return item.id || item.label; }
// Apply saved order: saved items first (in order), then new items appended
function applyOrder(items, savedKeys) {
if (!savedKeys || !savedKeys.length) return [...items];
const byKey = new Map(items.map(i => [itemKey(i), i]));
const ordered = [];
for (const k of savedKeys) {
if (byKey.has(k)) { ordered.push(byKey.get(k)); byKey.delete(k); }
}
// Append items not in saved order
for (const i of items) {
if (byKey.has(itemKey(i))) ordered.push(i);
}
return ordered;
}
let links = $derived(applyOrder(defaultLinks, sidebarOrder?.main));
let media = $derived(applyOrder(defaultMedia, sidebarOrder?.media));
let tools = $derived(applyOrder(defaultTools, sidebarOrder?.tools));
// Drag-and-drop state
let dragCategory = $state(null);
let dragIndex = $state(-1);
let dropIndex = $state(-1);
function handleDragStart(category, index, e) {
dragCategory = category;
dragIndex = index;
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", "");
}
function handleDragOver(category, index, e) {
if (category !== dragCategory) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
dropIndex = index;
}
function handleDragEnd() {
dragCategory = null;
dragIndex = -1;
dropIndex = -1;
}
function handleDrop(category, index, e) {
e.preventDefault();
if (category !== dragCategory || 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 newOrder = {
main: lists.main.map(itemKey),
media: lists.media.map(itemKey),
tools: lists.tools.map(itemKey),
};
onOrderChange(newOrder);
handleDragEnd();
}
function extHref(svc) { function extHref(svc) {
if (lanReachable && svc.port) return `http://${LAN_IP}:${svc.port}`; if (lanReachable && svc.port) return `http://${LAN_IP}:${svc.port}`;
if (svc.remoteHref) return svc.remoteHref; if (svc.remoteHref) return svc.remoteHref;
@@ -58,6 +122,13 @@
page = id; page = id;
sidebarOpen = false; sidebarOpen = false;
} }
function dragClasses(category, index) {
if (dragCategory !== category) return "";
if (index === dragIndex) return "opacity-30";
if (index === dropIndex) return "border-t-2 border-primary-500";
return "";
}
</script> </script>
<!-- Mobile overlay --> <!-- Mobile overlay -->
@@ -80,16 +151,22 @@
<!-- Nav Links --> <!-- Nav Links -->
<nav class="flex-1 px-3 mt-2 overflow-y-auto"> <nav class="flex-1 px-3 mt-2 overflow-y-auto">
<p class="text-[10px] font-semibold uppercase tracking-wider text-surface-400 px-3 mb-2">Main</p> <p class="text-[10px] font-semibold uppercase tracking-wider text-surface-400 px-3 mb-2">Main</p>
{#each links as link} {#each links as link, i}
{#if canAccess(link.id)} {#if canAccess(link.id)}
<button <button
class="w-full text-left px-3 py-2.5 rounded-lg text-[13px] font-medium flex items-center gap-2.5 mb-0.5 transition-all duration-150 draggable="true"
ondragstart={(e) => handleDragStart("main", i, e)}
ondragover={(e) => handleDragOver("main", i, e)}
ondrop={(e) => handleDrop("main", i, e)}
ondragend={handleDragEnd}
class="w-full text-left px-3 py-2.5 rounded-lg text-[13px] font-medium flex items-center gap-2.5 mb-0.5 transition-all duration-150 group
{page === link.id {page === link.id
? 'bg-primary-50 text-primary-700 shadow-sm ' + (dark ? 'bg-primary-900/30 text-primary-300' : '') ? 'bg-primary-50 text-primary-700 shadow-sm ' + (dark ? 'bg-primary-900/30 text-primary-300' : '')
: 'text-surface-500 hover:bg-surface-100 hover:text-surface-700 ' + (dark ? 'hover:bg-surface-700 hover:text-surface-200 text-surface-400' : '') : 'text-surface-500 hover:bg-surface-100 hover:text-surface-700 ' + (dark ? 'hover:bg-surface-700 hover:text-surface-200 text-surface-400' : '')
}" } {dragClasses('main', i)}"
onclick={() => navigate(link.id)} onclick={() => navigate(link.id)}
> >
<svg class="w-3 h-3 opacity-0 group-hover:opacity-30 -ml-1 mr-0.5 shrink-0 cursor-grab" fill="currentColor" viewBox="0 0 24 24"><circle cx="9" cy="6" r="2"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="15" cy="12" r="2"/><circle cx="9" cy="18" r="2"/><circle cx="15" cy="18" r="2"/></svg>
<span class="w-5 h-5 flex items-center justify-center"> <span class="w-5 h-5 flex items-center justify-center">
{#if link.icon === "grid"} {#if link.icon === "grid"}
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" /></svg> <svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" /></svg>
@@ -110,13 +187,19 @@
<div class="my-4 border-t border-surface-200 {dark ? 'border-surface-700' : ''}"></div> <div class="my-4 border-t border-surface-200 {dark ? 'border-surface-700' : ''}"></div>
<p class="text-[10px] font-semibold uppercase tracking-wider text-surface-400 px-3 mb-2">Media</p> <p class="text-[10px] font-semibold uppercase tracking-wider text-surface-400 px-3 mb-2">Media</p>
{#each media as svc} {#each media as svc, i}
<a <a
href={extHref(svc)} href={extHref(svc)}
target="_blank" target="_blank"
rel="noopener" rel="noopener"
class="w-full text-left px-3 py-2 rounded-lg text-[13px] font-medium flex items-center gap-2.5 mb-0.5 text-surface-500 hover:bg-surface-100 hover:text-surface-700 transition-all duration-150 {dark ? 'hover:bg-surface-700 hover:text-surface-200 text-surface-400' : ''}" draggable="true"
ondragstart={(e) => handleDragStart("media", i, e)}
ondragover={(e) => handleDragOver("media", i, e)}
ondrop={(e) => handleDrop("media", i, e)}
ondragend={handleDragEnd}
class="w-full text-left px-3 py-2 rounded-lg text-[13px] font-medium flex items-center gap-2.5 mb-0.5 text-surface-500 hover:bg-surface-100 hover:text-surface-700 transition-all duration-150 group {dark ? 'hover:bg-surface-700 hover:text-surface-200 text-surface-400' : ''} {dragClasses('media', i)}"
> >
<svg class="w-3 h-3 opacity-0 group-hover:opacity-30 -ml-1 mr-0.5 shrink-0 cursor-grab" fill="currentColor" viewBox="0 0 24 24"><circle cx="9" cy="6" r="2"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="15" cy="12" r="2"/><circle cx="9" cy="18" r="2"/><circle cx="15" cy="18" r="2"/></svg>
<span class="w-5 h-5 flex items-center justify-center"> <span class="w-5 h-5 flex items-center justify-center">
{#if svc.icon === "music"} {#if svc.icon === "music"}
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z" /></svg> <svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z" /></svg>
@@ -135,15 +218,21 @@
<div class="my-4 border-t border-surface-200 {dark ? 'border-surface-700' : ''}"></div> <div class="my-4 border-t border-surface-200 {dark ? 'border-surface-700' : ''}"></div>
<p class="text-[10px] font-semibold uppercase tracking-wider text-surface-400 px-3 mb-2">Tools</p> <p class="text-[10px] font-semibold uppercase tracking-wider text-surface-400 px-3 mb-2">Tools</p>
{#each tools as item} {#each tools as item, i}
{#if canAccess(item.id)} {#if canAccess(item.id)}
{#if item.external || item.port} {#if item.external || item.port}
<a <a
href={extHref(item)} href={extHref(item)}
target="_blank" target="_blank"
rel="noopener" rel="noopener"
class="w-full text-left px-3 py-2 rounded-lg text-[13px] font-medium flex items-center gap-2.5 mb-0.5 text-surface-500 hover:bg-surface-100 hover:text-surface-700 transition-all duration-150 {dark ? 'hover:bg-surface-700 hover:text-surface-200 text-surface-400' : ''}" draggable="true"
ondragstart={(e) => handleDragStart("tools", i, e)}
ondragover={(e) => handleDragOver("tools", i, e)}
ondrop={(e) => handleDrop("tools", i, e)}
ondragend={handleDragEnd}
class="w-full text-left px-3 py-2 rounded-lg text-[13px] font-medium flex items-center gap-2.5 mb-0.5 text-surface-500 hover:bg-surface-100 hover:text-surface-700 transition-all duration-150 group {dark ? 'hover:bg-surface-700 hover:text-surface-200 text-surface-400' : ''} {dragClasses('tools', i)}"
> >
<svg class="w-3 h-3 opacity-0 group-hover:opacity-30 -ml-1 mr-0.5 shrink-0 cursor-grab" fill="currentColor" viewBox="0 0 24 24"><circle cx="9" cy="6" r="2"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="15" cy="12" r="2"/><circle cx="9" cy="18" r="2"/><circle cx="15" cy="18" r="2"/></svg>
<span class="w-5 h-5 flex items-center justify-center"> <span class="w-5 h-5 flex items-center justify-center">
{#if item.icon === "openclaw"} {#if item.icon === "openclaw"}
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg> <svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg>
@@ -168,13 +257,19 @@
</a> </a>
{:else} {:else}
<button <button
class="w-full text-left px-3 py-2.5 rounded-lg text-[13px] font-medium flex items-center gap-2.5 mb-0.5 transition-all duration-150 draggable="true"
ondragstart={(e) => handleDragStart("tools", i, e)}
ondragover={(e) => handleDragOver("tools", i, e)}
ondrop={(e) => handleDrop("tools", i, e)}
ondragend={handleDragEnd}
class="w-full text-left px-3 py-2.5 rounded-lg text-[13px] font-medium flex items-center gap-2.5 mb-0.5 transition-all duration-150 group
{page === item.id {page === item.id
? 'bg-primary-50 text-primary-700 shadow-sm ' + (dark ? 'bg-primary-900/30 text-primary-300' : '') ? 'bg-primary-50 text-primary-700 shadow-sm ' + (dark ? 'bg-primary-900/30 text-primary-300' : '')
: 'text-surface-500 hover:bg-surface-100 hover:text-surface-700 ' + (dark ? 'hover:bg-surface-700 hover:text-surface-200 text-surface-400' : '') : 'text-surface-500 hover:bg-surface-100 hover:text-surface-700 ' + (dark ? 'hover:bg-surface-700 hover:text-surface-200 text-surface-400' : '')
}" } {dragClasses('tools', i)}"
onclick={() => navigate(item.id)} onclick={() => navigate(item.id)}
> >
<svg class="w-3 h-3 opacity-0 group-hover:opacity-30 -ml-1 mr-0.5 shrink-0 cursor-grab" fill="currentColor" viewBox="0 0 24 24"><circle cx="9" cy="6" r="2"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="15" cy="12" r="2"/><circle cx="9" cy="18" r="2"/><circle cx="15" cy="18" r="2"/></svg>
<span class="w-5 h-5 flex items-center justify-center"> <span class="w-5 h-5 flex items-center justify-center">
{#if item.icon === "openclaw"} {#if item.icon === "openclaw"}
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg> <svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg>
+12
View File
@@ -130,3 +130,15 @@ export function login(creds) {
export function checkAuth() { export function checkAuth() {
return request("/auth/me"); return request("/auth/me");
} }
export function getPreferences() {
return get("/auth/preferences");
}
export function savePreferences(data) {
return request("/auth/preferences", { method: "PUT", json: data });
}
export function put(path, data) {
return request(path, { method: "PUT", json: data });
}
+165 -3
View File
@@ -1,6 +1,7 @@
<script> <script>
import { post, get } from "../lib/api.js"; import { post, get, del, put } from "../lib/api.js";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { currentUser } from "../lib/api.js";
let currentPassword = $state(""); let currentPassword = $state("");
let newPassword = $state(""); let newPassword = $state("");
@@ -14,8 +15,6 @@
let passkeyErr = $state(""); let passkeyErr = $state("");
let passkeyLoading = $state(false); let passkeyLoading = $state(false);
onMount(loadPasskeys);
async function loadPasskeys() { async function loadPasskeys() {
try { try {
const res = await get("/auth/passkey/list"); const res = await get("/auth/passkey/list");
@@ -131,6 +130,79 @@
} catch {} } catch {}
auditLoading = false; auditLoading = false;
} }
// Access Control
const allPages = ["dashboard", "docker", "files", "terminal", "security", "openclaw", "chat-digest", "gitea"];
let rbacConfig = $state(null);
let rbacLoading = $state(false);
let rbacMsg = $state("");
let rbacErr = $state("");
let editingUser = $state("");
let editPages = $state([]);
let newUsername = $state("");
async function loadRbac() {
rbacLoading = true;
try {
rbacConfig = await get("/auth/rbac/config");
} catch {}
rbacLoading = false;
}
function startEdit(username, pages) {
editingUser = username;
editPages = [...pages];
newUsername = "";
}
function startAdd() {
editingUser = "__new__";
editPages = ["dashboard"];
newUsername = "";
}
function cancelEdit() {
editingUser = "";
editPages = [];
newUsername = "";
}
function togglePage(p) {
if (editPages.includes(p)) editPages = editPages.filter(x => x !== p);
else editPages = [...editPages, p];
}
async function saveOverride() {
rbacMsg = ""; rbacErr = "";
const uname = editingUser === "__new__" ? newUsername.trim() : editingUser;
if (!uname) { rbacErr = "Username required"; return; }
if (!editPages.length) { rbacErr = "Select at least one page"; return; }
try {
await put(`/auth/rbac/overrides/${encodeURIComponent(uname)}`, { pages: editPages });
rbacMsg = `Saved override for ${uname}`;
cancelEdit();
await loadRbac();
} catch (e) {
rbacErr = e.body?.detail || "Failed to save";
}
}
async function deleteOverride(username) {
if (!confirm(`Remove override for "${username}"?`)) return;
rbacMsg = ""; rbacErr = "";
try {
await del(`/auth/rbac/overrides/${encodeURIComponent(username)}`);
rbacMsg = `Removed override for ${username}`;
await loadRbac();
} catch (e) {
rbacErr = e.body?.detail || "Failed to delete";
}
}
onMount(() => {
loadPasskeys();
if (currentUser.role === "admin") loadRbac();
});
</script> </script>
<div class="space-y-6"> <div class="space-y-6">
@@ -208,6 +280,96 @@
</div> </div>
</div> </div>
{#if currentUser.role === "admin" && rbacConfig}
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-6 shadow-sm">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Access Control</h3>
<button onclick={loadRbac} disabled={rbacLoading} class="px-3 py-1.5 text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-lg transition-colors disabled:opacity-50">
{rbacLoading ? "Loading..." : "Refresh"}
</button>
</div>
{#if rbacMsg}
<div class="text-sm text-emerald-600 bg-emerald-50 px-3 py-2 rounded-lg mb-4">{rbacMsg}</div>
{/if}
{#if rbacErr}
<div class="text-sm text-rose-600 bg-rose-50 px-3 py-2 rounded-lg mb-4">{rbacErr}</div>
{/if}
<!-- Role Defaults -->
<div class="mb-4">
<p class="text-xs font-medium text-surface-500 mb-2">Role Defaults</p>
<div class="space-y-1.5">
{#each Object.entries(rbacConfig.role_defaults || {}) as [role, cfg]}
<div class="flex items-center gap-2 text-xs">
<span class="font-medium text-surface-700 dark:text-surface-300 w-16">{role}</span>
<span class="text-surface-400">{cfg.pages === "*" ? "All pages" : (cfg.pages || []).join(", ")}</span>
</div>
{/each}
</div>
</div>
<!-- User Overrides -->
<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<p class="text-xs font-medium text-surface-500">User Overrides</p>
{#if editingUser !== "__new__"}
<button onclick={startAdd} class="px-2 py-1 text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-md transition-colors">Add Override</button>
{/if}
</div>
{#if Object.keys(rbacConfig.user_overrides || {}).length > 0}
<div class="space-y-2">
{#each Object.entries(rbacConfig.user_overrides) as [username, cfg]}
{#if editingUser === username}
<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">{username}</p>
<div class="flex flex-wrap gap-1.5">
{#each allPages as p}
<button onclick={() => togglePage(p)} class="px-2 py-0.5 text-xs rounded-md transition-colors {editPages.includes(p) ? 'bg-primary-600 text-white' : 'bg-surface-200 dark:bg-surface-600 text-surface-500'}">{p}</button>
{/each}
</div>
<div class="flex gap-2">
<button onclick={saveOverride} class="px-3 py-1 text-xs font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-md">Save</button>
<button onclick={cancelEdit} class="px-3 py-1 text-xs font-medium text-surface-500 bg-surface-200 hover:bg-surface-300 rounded-md">Cancel</button>
</div>
</div>
{:else}
<div class="flex items-center justify-between px-3 py-2 bg-surface-50 dark:bg-surface-700 rounded-lg">
<div>
<span class="text-sm font-medium text-surface-700 dark:text-surface-200">{username}</span>
<span class="text-xs text-surface-400 ml-2">{(cfg.pages || []).join(", ")}</span>
</div>
<div class="flex gap-1.5">
<button onclick={() => startEdit(username, cfg.pages || [])} class="text-xs text-primary-500 hover:text-primary-700">Edit</button>
<button onclick={() => deleteOverride(username)} class="text-xs text-rose-500 hover:text-rose-700">Delete</button>
</div>
</div>
{/if}
{/each}
</div>
{:else if editingUser !== "__new__"}
<p class="text-sm text-surface-400">No user overrides configured</p>
{/if}
{#if editingUser === "__new__"}
<div class="p-3 bg-surface-50 dark:bg-surface-700 rounded-lg space-y-2 mt-2">
<input type="text" bind:value={newUsername} placeholder="Username" class="w-full px-3 py-1.5 text-sm border border-surface-200 dark:border-surface-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-surface-800 dark:text-white" />
<div class="flex flex-wrap gap-1.5">
{#each allPages as p}
<button onclick={() => togglePage(p)} class="px-2 py-0.5 text-xs rounded-md transition-colors {editPages.includes(p) ? 'bg-primary-600 text-white' : 'bg-surface-200 dark:bg-surface-600 text-surface-500'}">{p}</button>
{/each}
</div>
<div class="flex gap-2">
<button onclick={saveOverride} class="px-3 py-1 text-xs font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-md">Save</button>
<button onclick={cancelEdit} class="px-3 py-1 text-xs font-medium text-surface-500 bg-surface-200 hover:bg-surface-300 rounded-md">Cancel</button>
</div>
</div>
{/if}
</div>
</div>
{/if}
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-6 shadow-sm"> <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-6 shadow-sm">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Audit Log</h3> <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Audit Log</h3>