feat: access control UI in Settings + sidebar drag-and-drop reordering
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 1m13s
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:
@@ -1,13 +1,13 @@
|
||||
<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) {
|
||||
if (!id) return true; // external links always shown
|
||||
if (!id) return true;
|
||||
if (allowedPages === "*") return true;
|
||||
return Array.isArray(allowedPages) && allowedPages.includes(id);
|
||||
}
|
||||
|
||||
const links = [
|
||||
const defaultLinks = [
|
||||
{ id: "dashboard", label: "Overview", icon: "grid" },
|
||||
{ id: "docker", label: "Docker", icon: "box" },
|
||||
{ id: "files", label: "Files", icon: "folder" },
|
||||
@@ -25,14 +25,14 @@
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
const media = [
|
||||
const defaultMedia = [
|
||||
{ label: "Navidrome", remoteHref: "https://music.jimmygan.com:8443", icon: "music" },
|
||||
{ label: "Jellyfin", remoteHref: "https://media.jimmygan.com:8443", icon: "play" },
|
||||
{ label: "Audiobookshelf", remoteHref: "https://books.jimmygan.com:8443", icon: "headphones" },
|
||||
{ label: "Immich", remoteHref: "https://photos.jimmygan.com:8443", icon: "image" },
|
||||
];
|
||||
|
||||
const tools = [
|
||||
const defaultTools = [
|
||||
{ id: "openclaw", label: "OpenClaw", icon: "openclaw" },
|
||||
{ id: "chat-digest", label: "Chat Digest", icon: "chat" },
|
||||
{ id: "gitea", label: "Repos", icon: "git" },
|
||||
@@ -44,6 +44,70 @@
|
||||
{ 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) {
|
||||
if (lanReachable && svc.port) return `http://${LAN_IP}:${svc.port}`;
|
||||
if (svc.remoteHref) return svc.remoteHref;
|
||||
@@ -58,6 +122,13 @@
|
||||
page = id;
|
||||
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>
|
||||
|
||||
<!-- Mobile overlay -->
|
||||
@@ -80,16 +151,22 @@
|
||||
<!-- Nav Links -->
|
||||
<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>
|
||||
{#each links as link}
|
||||
{#each links as link, i}
|
||||
{#if canAccess(link.id)}
|
||||
<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
|
||||
? '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' : '')
|
||||
}"
|
||||
} {dragClasses('main', i)}"
|
||||
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">
|
||||
{#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>
|
||||
@@ -110,13 +187,19 @@
|
||||
|
||||
<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>
|
||||
{#each media as svc}
|
||||
{#each media as svc, i}
|
||||
<a
|
||||
href={extHref(svc)}
|
||||
target="_blank"
|
||||
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">
|
||||
{#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>
|
||||
@@ -135,15 +218,21 @@
|
||||
|
||||
<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>
|
||||
{#each tools as item}
|
||||
{#each tools as item, i}
|
||||
{#if canAccess(item.id)}
|
||||
{#if item.external || item.port}
|
||||
<a
|
||||
href={extHref(item)}
|
||||
target="_blank"
|
||||
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">
|
||||
{#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>
|
||||
@@ -168,13 +257,19 @@
|
||||
</a>
|
||||
{:else}
|
||||
<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
|
||||
? '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' : '')
|
||||
}"
|
||||
} {dragClasses('tools', i)}"
|
||||
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">
|
||||
{#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>
|
||||
|
||||
Reference in New Issue
Block a user