mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-02 21:19:05 +00:00
196 lines
7.7 KiB
Svelte
196 lines
7.7 KiB
Svelte
<script lang="ts">
|
|
import { page } from '$app/stores';
|
|
import { goto } from '$app/navigation';
|
|
import * as Sidebar from '$lib/components/ui/sidebar';
|
|
import { useSidebar } from '$lib/components/ui/sidebar';
|
|
import {
|
|
LayoutDashboard,
|
|
Box,
|
|
Layers,
|
|
Images,
|
|
ScrollText,
|
|
HardDrive,
|
|
Network,
|
|
PanelLeftClose,
|
|
PanelLeft,
|
|
Download,
|
|
Settings,
|
|
Terminal,
|
|
Info,
|
|
Crown,
|
|
LogOut,
|
|
User,
|
|
ClipboardList,
|
|
Activity,
|
|
Timer
|
|
} from 'lucide-svelte';
|
|
import { licenseStore } from '$lib/stores/license';
|
|
import { authStore, hasAnyAccess } from '$lib/stores/auth';
|
|
import * as Avatar from '$lib/components/ui/avatar';
|
|
|
|
import type { Permissions } from '$lib/stores/auth';
|
|
|
|
// TypeScript interface for menu items
|
|
interface MenuItem {
|
|
href: string;
|
|
Icon: typeof LayoutDashboard;
|
|
label: string;
|
|
// Permission resource required to see this menu item (enterprise only)
|
|
// Show menu if user has ANY permission for this resource, or 'always' (no check)
|
|
permission?: keyof Permissions | 'always';
|
|
// If true, item is only visible with enterprise license
|
|
enterpriseOnly?: boolean;
|
|
}
|
|
|
|
const currentPath = $derived($page.url.pathname);
|
|
const sidebar = useSidebar();
|
|
|
|
function isActive(path: string): boolean {
|
|
if (path === '/') return currentPath === '/';
|
|
return currentPath === path || currentPath.startsWith(`${path}/`);
|
|
}
|
|
|
|
async function handleLogout() {
|
|
sidebar.setOpenMobile(false);
|
|
await authStore.logout();
|
|
goto('/login');
|
|
}
|
|
|
|
/**
|
|
* Check if a menu item should be visible based on permissions
|
|
* - Enterprise-only items require enterprise license
|
|
* - FREE edition: all non-enterprise items visible (no permission checks)
|
|
* - ENTERPRISE edition: check if user has ANY permission for the resource
|
|
*/
|
|
function canSeeMenuItem(item: MenuItem): boolean {
|
|
// Enterprise-only items are hidden without enterprise license
|
|
if (item.enterpriseOnly && !$licenseStore.isEnterprise) {
|
|
return false;
|
|
}
|
|
|
|
// FREE edition or auth disabled = all items visible (except enterprise-only)
|
|
if (!$licenseStore.isEnterprise || !$authStore.authEnabled) {
|
|
return true;
|
|
}
|
|
|
|
// ENTERPRISE edition: check permissions
|
|
// Admins see everything
|
|
if ($authStore.user?.isAdmin) {
|
|
return true;
|
|
}
|
|
|
|
// No permission specified = always visible
|
|
if (!item.permission || item.permission === 'always') {
|
|
return true;
|
|
}
|
|
|
|
// Check if user has ANY permission for this resource
|
|
return $hasAnyAccess(item.permission);
|
|
}
|
|
|
|
const menuItems: readonly MenuItem[] = [
|
|
{ href: '/', Icon: LayoutDashboard, label: 'Dashboard', permission: 'always' },
|
|
{ href: '/containers', Icon: Box, label: 'Containers', permission: 'containers' },
|
|
{ href: '/logs', Icon: ScrollText, label: 'Logs', permission: 'containers' },
|
|
{ href: '/terminal', Icon: Terminal, label: 'Shell', permission: 'containers' },
|
|
{ href: '/stacks', Icon: Layers, label: 'Stacks', permission: 'stacks' },
|
|
{ href: '/images', Icon: Images, label: 'Images', permission: 'images' },
|
|
{ href: '/volumes', Icon: HardDrive, label: 'Volumes', permission: 'volumes' },
|
|
{ href: '/networks', Icon: Network, label: 'Networks', permission: 'networks' },
|
|
{ href: '/registry', Icon: Download, label: 'Registry', permission: 'registries' },
|
|
{ href: '/activity', Icon: Activity, label: 'Activity', permission: 'activity' },
|
|
{ href: '/schedules', Icon: Timer, label: 'Schedules', permission: 'schedules' },
|
|
{ href: '/audit', Icon: ClipboardList, label: 'Audit log', permission: 'audit_logs', enterpriseOnly: true },
|
|
{ href: '/settings', Icon: Settings, label: 'Settings', permission: 'settings' }
|
|
] as const;
|
|
</script>
|
|
|
|
<Sidebar.Root collapsible="icon">
|
|
<Sidebar.Header class="overflow-visible flex items-center justify-center p-0">
|
|
<!-- Expanded state: logo + collapse button -->
|
|
<div class="relative flex items-center justify-center w-full group-data-[state=collapsed]:hidden">
|
|
<a href="/" class="flex justify-center relative">
|
|
<img src="/logo-light.webp" alt="Dockhand Logo" class="h-[52px] w-auto object-contain mt-2 mb-1 dark:hidden" style="filter: drop-shadow(1px 1px 2px rgba(0,0,0,0.3)) drop-shadow(-1px -1px 1px rgba(255,255,255,0.9));" />
|
|
<img src="/logo-dark.webp" alt="Dockhand Logo" class="h-[52px] w-auto object-contain mt-2 mb-1 hidden dark:block" style="filter: drop-shadow(2px 2px 3px rgba(0,0,0,0.6)) drop-shadow(-1px -1px 1px rgba(255,255,255,0.2));" />
|
|
{#if $licenseStore.isEnterprise}
|
|
<Crown class="w-4 h-4 absolute top-0 -right-[6px] text-amber-500 fill-amber-400 drop-shadow-sm rotate-[20deg]" />
|
|
{/if}
|
|
</a>
|
|
<button
|
|
type="button"
|
|
onclick={() => sidebar.toggle()}
|
|
class="absolute right-1 p-1.5 rounded-md hover:bg-sidebar-accent text-gray-300 hover:text-gray-400 transition-colors"
|
|
title="Collapse sidebar"
|
|
aria-label="Collapse sidebar"
|
|
>
|
|
<PanelLeftClose class="w-4 h-4" aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
<!-- Collapsed state: expand button only -->
|
|
<button
|
|
type="button"
|
|
onclick={() => sidebar.toggle()}
|
|
class="hidden group-data-[state=collapsed]:flex p-1.5 rounded-md hover:bg-sidebar-accent text-muted-foreground hover:text-foreground transition-colors"
|
|
title="Expand sidebar"
|
|
aria-label="Expand sidebar"
|
|
>
|
|
<PanelLeft class="w-4 h-4" aria-hidden="true" />
|
|
</button>
|
|
</Sidebar.Header>
|
|
|
|
<Sidebar.Content>
|
|
<Sidebar.Group>
|
|
<Sidebar.Menu>
|
|
{#each menuItems as item}
|
|
{#if canSeeMenuItem(item)}
|
|
<Sidebar.MenuItem>
|
|
<Sidebar.MenuButton href={item.href} isActive={isActive(item.href)} tooltipContent={item.label} onclick={() => sidebar.setOpenMobile(false)}>
|
|
<item.Icon aria-hidden="true" />
|
|
<span class="group-data-[state=collapsed]:hidden">{item.label}</span>
|
|
</Sidebar.MenuButton>
|
|
</Sidebar.MenuItem>
|
|
{/if}
|
|
{/each}
|
|
</Sidebar.Menu>
|
|
</Sidebar.Group>
|
|
</Sidebar.Content>
|
|
|
|
<!-- User info footer (only when auth is enabled) -->
|
|
{#if $authStore.authEnabled && $authStore.authenticated && $authStore.user}
|
|
<Sidebar.Footer class="border-t">
|
|
<Sidebar.Menu>
|
|
<Sidebar.MenuItem>
|
|
<a
|
|
href="/profile"
|
|
onclick={() => sidebar.setOpenMobile(false)}
|
|
class="flex items-center gap-2 px-2 py-1.5 group-data-[state=collapsed]:px-1 group-data-[state=collapsed]:py-1 rounded-md hover:bg-sidebar-accent transition-colors group-data-[state=collapsed]:justify-center"
|
|
title="View profile"
|
|
>
|
|
<Avatar.Root class="w-8 h-8 group-data-[state=collapsed]:w-6 group-data-[state=collapsed]:h-6 shrink-0 transition-all">
|
|
<Avatar.Image src={$authStore.user.avatar} alt={$authStore.user.username} />
|
|
<Avatar.Fallback class="bg-primary/10 text-primary text-xs">
|
|
{($authStore.user.displayName || $authStore.user.username)?.slice(0, 2).toUpperCase()}
|
|
</Avatar.Fallback>
|
|
</Avatar.Root>
|
|
<div class="flex flex-col min-w-0 group-data-[state=collapsed]:hidden">
|
|
<span class="text-sm font-medium truncate">{$authStore.user.displayName || $authStore.user.username}</span>
|
|
<span class="text-xs text-muted-foreground truncate">{$authStore.user.isAdmin ? 'Admin' : 'User'}</span>
|
|
</div>
|
|
</a>
|
|
</Sidebar.MenuItem>
|
|
<Sidebar.MenuItem>
|
|
<button
|
|
type="button"
|
|
onclick={handleLogout}
|
|
class="flex items-center gap-2 w-full px-2 py-1.5 group-data-[state=collapsed]:px-1 group-data-[state=collapsed]:py-1 text-sm text-muted-foreground hover:text-foreground hover:bg-sidebar-accent rounded-md transition-colors group-data-[state=collapsed]:justify-center"
|
|
title="Sign out"
|
|
>
|
|
<LogOut class="w-4 h-4 shrink-0 group-data-[state=collapsed]:w-3.5 group-data-[state=collapsed]:h-3.5" />
|
|
<span class="group-data-[state=collapsed]:hidden">Sign out</span>
|
|
</button>
|
|
</Sidebar.MenuItem>
|
|
</Sidebar.Menu>
|
|
</Sidebar.Footer>
|
|
{/if}
|
|
</Sidebar.Root>
|