sprint9: app visibility — sidebar permission filter + TopBar + ProtectedRoute + route guards
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
permission: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ permission, children }: ProtectedRouteProps) {
|
||||
const { hasPermission } = usePermission();
|
||||
|
||||
if (!hasPermission(permission)) {
|
||||
return <Navigate to="/kein-zugriff" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { ChevronRight, FileText, Home, Settings, Users } from 'lucide-react';
|
||||
import { usePluginStore } from '@/store/pluginStore';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { useMenuOrder } from '@/api/users';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
|
||||
interface NavSingleItem {
|
||||
to: string;
|
||||
@@ -40,6 +41,7 @@ export function Sidebar() {
|
||||
const location = useLocation();
|
||||
const manifests = usePluginStore(s => s.manifests);
|
||||
const { data: menuOrderData } = useMenuOrder();
|
||||
const { hasPermission } = usePermission();
|
||||
|
||||
const allMenuItems = useMemo(() => {
|
||||
const staticItems = singleItems.map(item => ({
|
||||
@@ -50,10 +52,12 @@ export function Sidebar() {
|
||||
order: item.order,
|
||||
group: undefined as string | undefined,
|
||||
isStatic: true as const,
|
||||
permission: item.to === '/dashboard' ? 'dashboard:read' : item.to === '/contacts' ? 'contacts:read' : undefined,
|
||||
}));
|
||||
|
||||
const pluginItems = manifests
|
||||
.flatMap((m) => m.menu_items)
|
||||
.filter(item => !item.permission || hasPermission(item.permission))
|
||||
.map(item => ({
|
||||
path: item.path,
|
||||
labelKey: item.label_key,
|
||||
@@ -62,6 +66,7 @@ export function Sidebar() {
|
||||
order: item.order,
|
||||
group: item.group,
|
||||
isStatic: false as const,
|
||||
permission: item.permission,
|
||||
}));
|
||||
|
||||
const allItems = [...staticItems, ...pluginItems];
|
||||
@@ -152,6 +157,8 @@ export function Sidebar() {
|
||||
}
|
||||
const elements: React.ReactNode[] = [];
|
||||
for (const item of singles) {
|
||||
// Skip if user lacks permission
|
||||
if (item.permission && !hasPermission(item.permission)) continue;
|
||||
elements.push(
|
||||
<li key={item.path}>
|
||||
<NavLink
|
||||
@@ -175,6 +182,9 @@ export function Sidebar() {
|
||||
);
|
||||
}
|
||||
for (const [group, items] of groups) {
|
||||
// Filter group items by permission
|
||||
const visibleItems = items.filter(item => !item.permission || hasPermission(item.permission));
|
||||
if (visibleItems.length === 0) continue; // Hide empty groups
|
||||
const groupKey = `plugin-group-${group}`;
|
||||
const expanded = expandedItems.has(groupKey);
|
||||
elements.push(
|
||||
@@ -196,13 +206,13 @@ export function Sidebar() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{getIcon(items[0].icon as string)}
|
||||
{getIcon(visibleItems[0].icon as string)}
|
||||
<span className="flex-1 truncate">{group}</span>
|
||||
{chevronIcon(expanded)}
|
||||
</div>
|
||||
{expanded && (
|
||||
<ul className="mt-1 ml-4 space-y-1 border-l border-secondary-700 pl-2" role="group">
|
||||
{items.map((child) => (
|
||||
{visibleItems.map((child) => (
|
||||
<li key={child.path}>
|
||||
<NavLink
|
||||
to={child.path}
|
||||
|
||||
@@ -8,9 +8,10 @@ import { useLogout } from '@/api/hooks';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { SearchDropdown } from '@/components/shared/SearchDropdown';
|
||||
import { SuggestionBadge } from '@/components/ai/SuggestionBadge';
|
||||
import { Building, ChevronDown, Menu, Zap, Bot, Layers, Code } from 'lucide-react';
|
||||
import { Building, ChevronDown, Menu, Zap, Bot, Layers, Code, Plus } from 'lucide-react';
|
||||
import { NotificationBell } from '@/components/layout/NotificationBell';
|
||||
import { useWindowStore } from '@/store/windowStore';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
|
||||
export function TopBar() {
|
||||
const { t } = useTranslation();
|
||||
@@ -20,6 +21,7 @@ export function TopBar() {
|
||||
const { toggleSidebar, toggleMessageSidebar } = useUIStore();
|
||||
const logoutMutation = useLogout();
|
||||
const minimizedWindows = useWindowStore((s) => s.windows.filter((w) => w.state === 'minimized'));
|
||||
const { hasPermission } = usePermission();
|
||||
const restoreWindow = useWindowStore((s) => s.restoreWindow);
|
||||
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
@@ -82,6 +84,18 @@ export function TopBar() {
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationBell />
|
||||
{/* Quick Create */}
|
||||
{hasPermission('contacts:write') && (
|
||||
<button
|
||||
onClick={() => navigate('/contacts/new')}
|
||||
className="flex items-center gap-1.5 bg-primary-600 text-white px-3 py-1.5 rounded-md text-sm font-medium hover:bg-primary-700 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
aria-label={t('topbar.quickCreate')}
|
||||
title={t('topbar.quickCreate', 'Neuer Kontakt')}
|
||||
>
|
||||
<Plus className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
<span className="hidden md:inline">{t('topbar.quickCreate', 'Neu')}</span>
|
||||
</button>
|
||||
)}
|
||||
{/* Minimized windows */}
|
||||
{minimizedWindows.length > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -130,13 +144,15 @@ export function TopBar() {
|
||||
>
|
||||
{t('topbar.profile')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setUserMenuOpen(false); navigate('/settings'); }}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-secondary-50 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
role="menuitem"
|
||||
>
|
||||
{t('topbar.settings')}
|
||||
</button>
|
||||
{hasPermission('settings:read') && (
|
||||
<button
|
||||
onClick={() => { setUserMenuOpen(false); navigate('/settings'); }}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-secondary-50 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
role="menuitem"
|
||||
>
|
||||
{t('topbar.settings')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setUserMenuOpen(false); navigate('/automation'); }}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-secondary-50 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 flex items-center gap-2"
|
||||
@@ -153,13 +169,15 @@ export function TopBar() {
|
||||
<Bot className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
{t('nav.agents', 'Agenten')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setUserMenuOpen(false); navigate('/audit-log'); }}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-secondary-50 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
role="menuitem"
|
||||
>
|
||||
{t('nav.auditLog')}
|
||||
</button>
|
||||
{hasPermission('audit:read') && (
|
||||
<button
|
||||
onClick={() => { setUserMenuOpen(false); navigate('/audit-log'); }}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-secondary-50 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
role="menuitem"
|
||||
>
|
||||
{t('nav.auditLog')}
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href="/docs"
|
||||
target="_blank"
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { Suspense } from 'react';
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
||||
import { AppShell } from '@/components/layout/AppShell';
|
||||
import { ProtectedRoute } from './ProtectedRoute';
|
||||
import { ProtectedRoute as PermissionRoute } from '@/components/common/ProtectedRoute';
|
||||
import { LoginPage } from '@/pages/Login';
|
||||
import { PasswordResetRequestPage } from '@/pages/PasswordResetRequest';
|
||||
import { PasswordResetConfirmPage } from '@/pages/PasswordResetConfirm';
|
||||
@@ -117,31 +118,31 @@ const router = createBrowserRouter([
|
||||
children: [
|
||||
{ path: '/', element: withSuspense(<DashboardPage />) },
|
||||
{ path: '/dashboard', element: withSuspense(<DashboardPage />) },
|
||||
{ path: '/contacts', element: withSuspense(<ContactsListPage />) },
|
||||
{ path: '/contacts/:id', element: withSuspense(<ContactDetailPage />) },
|
||||
{ path: '/audit-log', element: withSuspense(<AuditLogPage />) },
|
||||
{ path: '/contacts', element: <PermissionRoute permission="contacts:read">{withSuspense(<ContactsListPage />)}</PermissionRoute> },
|
||||
{ path: '/contacts/:id', element: <PermissionRoute permission="contacts:read">{withSuspense(<ContactDetailPage />)}</PermissionRoute> },
|
||||
{ path: '/audit-log', element: <PermissionRoute permission="audit:read">{withSuspense(<AuditLogPage />)}</PermissionRoute> },
|
||||
{ path: '/search', element: withSuspense(<GlobalSearchResultsPage />) },
|
||||
{ path: '/calendar', element: withSuspense(<CalendarPage />) },
|
||||
{ path: '/calendar/kanban', element: withSuspense(<CalendarKanbanPage />) },
|
||||
{ path: '/dms', element: withSuspense(<DmsPage />) },
|
||||
{ path: '/dms/trash', element: withSuspense(<DmsTrashPage />) },
|
||||
{ path: '/mail', element: withSuspense(<MailPage />) },
|
||||
{ path: '/mail/settings', element: withSuspense(<MailSettingsPage />) },
|
||||
{ path: '/ai-assistant', element: withSuspense(<AIAssistantPage />) },
|
||||
{ path: '/automation', element: withSuspense(<AutomationDashboardPage />) },
|
||||
{ path: '/agents', element: withSuspense(<AgentDashboardPage />) },
|
||||
{ path: '/reports', element: withSuspense(<ReportsPage />) },
|
||||
{ path: '/tasks', element: withSuspense(<TasksPage />) },
|
||||
{ path: '/communication', element: withSuspense(<CommunicationPage />) },
|
||||
{ path: '/workflows', element: withSuspense(<WorkflowsPage />) },
|
||||
{ path: '/contacts/dedup', element: withSuspense(<DedupMergePage />) },
|
||||
{ path: '/import-export', element: withSuspense(<ImportExportPage />) },
|
||||
{ path: '/tags', element: withSuspense(<TagsPage />) },
|
||||
{ path: '/activity', element: withSuspense(<ActivityTimelinePage />) },
|
||||
{ path: '/calendar', element: <PermissionRoute permission="calendar:read">{withSuspense(<CalendarPage />)}</PermissionRoute> },
|
||||
{ path: '/calendar/kanban', element: <PermissionRoute permission="calendar:read">{withSuspense(<CalendarKanbanPage />)}</PermissionRoute> },
|
||||
{ path: '/dms', element: <PermissionRoute permission="dms:read">{withSuspense(<DmsPage />)}</PermissionRoute> },
|
||||
{ path: '/dms/trash', element: <PermissionRoute permission="dms:read">{withSuspense(<DmsTrashPage />)}</PermissionRoute> },
|
||||
{ path: '/mail', element: <PermissionRoute permission="mail:read">{withSuspense(<MailPage />)}</PermissionRoute> },
|
||||
{ path: '/mail/settings', element: <PermissionRoute permission="mail:read">{withSuspense(<MailSettingsPage />)}</PermissionRoute> },
|
||||
{ path: '/ai-assistant', element: <PermissionRoute permission="ai:read">{withSuspense(<AIAssistantPage />)}</PermissionRoute> },
|
||||
{ path: '/automation', element: <PermissionRoute permission="automation:read">{withSuspense(<AutomationDashboardPage />)}</PermissionRoute> },
|
||||
{ path: '/agents', element: <PermissionRoute permission="automation:read">{withSuspense(<AgentDashboardPage />)}</PermissionRoute> },
|
||||
{ path: '/reports', element: <PermissionRoute permission="reports:read">{withSuspense(<ReportsPage />)}</PermissionRoute> },
|
||||
{ path: '/tasks', element: <PermissionRoute permission="tasks:read">{withSuspense(<TasksPage />)}</PermissionRoute> },
|
||||
{ path: '/communication', element: <PermissionRoute permission="communication:read">{withSuspense(<CommunicationPage />)}</PermissionRoute> },
|
||||
{ path: '/workflows', element: <PermissionRoute permission="workflows:read">{withSuspense(<WorkflowsPage />)}</PermissionRoute> },
|
||||
{ path: '/contacts/dedup', element: <PermissionRoute permission="contacts:read">{withSuspense(<DedupMergePage />)}</PermissionRoute> },
|
||||
{ path: '/import-export', element: <PermissionRoute permission="contacts:read">{withSuspense(<ImportExportPage />)}</PermissionRoute> },
|
||||
{ path: '/tags', element: <PermissionRoute permission="tags:read">{withSuspense(<TagsPage />)}</PermissionRoute> },
|
||||
{ path: '/activity', element: <PermissionRoute permission="activity:read">{withSuspense(<ActivityTimelinePage />)}</PermissionRoute> },
|
||||
{ path: '/profile', element: withSuspense(<SettingsProfilePage />) },
|
||||
{
|
||||
path: '/settings',
|
||||
element: withSuspense(<SettingsPage />),
|
||||
element: <PermissionRoute permission="settings:read">{withSuspense(<SettingsPage />)}</PermissionRoute>,
|
||||
children: [
|
||||
{ path: 'stammdaten', element: withSuspense(<SettingsStammdatenPage />) },
|
||||
{ path: 'user-management', element: withSuspense(<SettingsUserManagementPage />) },
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface PluginMenuItem {
|
||||
group: string;
|
||||
order: number;
|
||||
badge_key: string;
|
||||
permission?: string;
|
||||
}
|
||||
|
||||
export interface PluginPageRoute {
|
||||
|
||||
Reference in New Issue
Block a user