Adds profile and settings pages
This commit is contained in:
@@ -1,13 +1,18 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Outlet, useNavigate } from '@tanstack/react-router';
|
||||
import { Outlet, useNavigate, useRouterState } from '@tanstack/react-router';
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
import { useAvailabilityStatus } from '@/api/useAvailability';
|
||||
import { Sidebar } from '@/components/layout/Sidebar';
|
||||
import { MobileBar } from '@/components/layout/MobileBar';
|
||||
import { SidebarOverlay } from '@/components/layout/SidebarOverlay';
|
||||
|
||||
const ALLOWED_WHEN_UNAVAILABLE = ['/dashboard', '/settings'];
|
||||
|
||||
export function AppLayout() {
|
||||
const { isAuthenticated, status } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { location } = useRouterState();
|
||||
const { data: availability } = useAvailabilityStatus();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -16,6 +21,13 @@ export function AppLayout() {
|
||||
}
|
||||
}, [status, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (availability?.status === 'NotAvailable') {
|
||||
const allowed = ALLOWED_WHEN_UNAVAILABLE.some(p => location.pathname.startsWith(p));
|
||||
if (!allowed) void navigate({ to: '/dashboard' });
|
||||
}
|
||||
}, [availability?.status, location.pathname, navigate]);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { LayoutDashboard, Users, FileText, Settings, X } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
import { useAvailabilityStatus } from '@/api/useAvailability';
|
||||
import { LanguageSwitcher } from '@/i18n/LanguageSwitcher';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
import { UserMenu } from './UserMenu';
|
||||
@@ -18,13 +19,18 @@ interface NavItem {
|
||||
roles?: Role[];
|
||||
}
|
||||
|
||||
const ALLOWED_WHEN_UNAVAILABLE = ['/dashboard', '/settings'];
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: LayoutDashboard, testId: 'nav-dashboard' },
|
||||
{ to: '/users', labelKey: 'nav.users', icon: Users, testId: 'nav-users', roles: ['Owner', 'Administrator'] },
|
||||
{ to: '/settings', labelKey: 'nav.settings', icon: Settings, testId: 'nav-settings', roles: ['Owner'] },
|
||||
{ to: '/cms', labelKey: 'nav.cms', icon: FileText, testId: 'nav-cms', roles: ['Owner'] },
|
||||
];
|
||||
|
||||
const SETTINGS_ITEM: NavItem = {
|
||||
to: '/settings', labelKey: 'nav.settings', icon: Settings, testId: 'nav-settings', roles: ['Owner'],
|
||||
};
|
||||
|
||||
interface SidebarProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
@@ -32,11 +38,14 @@ interface SidebarProps {
|
||||
export function Sidebar({ onClose }: SidebarProps = {}) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const { data: availability } = useAvailabilityStatus();
|
||||
|
||||
const role = user?.role as Role | undefined;
|
||||
const systemUnavailable = availability?.status === 'NotAvailable';
|
||||
const visibleItems = NAV_ITEMS.filter(
|
||||
(item) => !item.roles || (role && item.roles.includes(role))
|
||||
);
|
||||
const showSettings = !SETTINGS_ITEM.roles || (role && SETTINGS_ITEM.roles.includes(role));
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -64,6 +73,19 @@ export function Sidebar({ onClose }: SidebarProps = {}) {
|
||||
<nav className="flex-1 space-y-1 p-3">
|
||||
{visibleItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const blocked = systemUnavailable && !ALLOWED_WHEN_UNAVAILABLE.some(p => item.to.startsWith(p));
|
||||
if (blocked) {
|
||||
return (
|
||||
<span
|
||||
key={item.to}
|
||||
data-testid={item.testId}
|
||||
className="flex cursor-not-allowed items-center gap-3 rounded-md px-3 py-2 text-sm font-medium opacity-40 select-none"
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
{t(item.labelKey)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
key={item.to}
|
||||
@@ -82,6 +104,23 @@ export function Sidebar({ onClose }: SidebarProps = {}) {
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{showSettings && (
|
||||
<div className="border-t border-border p-3">
|
||||
<Link
|
||||
to={SETTINGS_ITEM.to}
|
||||
data-testid={SETTINGS_ITEM.testId}
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
activeProps={{
|
||||
className: cn('bg-accent text-accent-foreground'),
|
||||
}}
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
{t(SETTINGS_ITEM.labelKey)}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1 border-t border-border p-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<UserMenu />
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('AvailabilityStatusBadge', () => {
|
||||
});
|
||||
|
||||
it('renders Unavailable status with red styling', () => {
|
||||
renderWithProviders(<AvailabilityStatusBadge status="Unavailable" />);
|
||||
renderWithProviders(<AvailabilityStatusBadge status="NotAvailable" />);
|
||||
|
||||
const label = screen.getByTestId('availability-status-label');
|
||||
expect(label).toHaveTextContent('Unavailable');
|
||||
@@ -70,7 +70,7 @@ describe('AvailabilityStatusBadge', () => {
|
||||
|
||||
it('Unavailable shows a custom reason when provided', () => {
|
||||
renderWithProviders(
|
||||
<AvailabilityStatusBadge status="Unavailable" message="Emergency shutdown in progress" />,
|
||||
<AvailabilityStatusBadge status="NotAvailable" message="Emergency shutdown in progress" />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('availability-message')).toHaveTextContent(
|
||||
@@ -79,7 +79,7 @@ describe('AvailabilityStatusBadge', () => {
|
||||
});
|
||||
|
||||
it('Unavailable falls back to default translated message when no reason is provided', () => {
|
||||
renderWithProviders(<AvailabilityStatusBadge status="Unavailable" />);
|
||||
renderWithProviders(<AvailabilityStatusBadge status="NotAvailable" />);
|
||||
|
||||
expect(screen.getByTestId('availability-message')).toHaveTextContent(
|
||||
'System is currently unavailable.',
|
||||
|
||||
@@ -33,7 +33,7 @@ const STATUS_CONFIG: Record<
|
||||
colorClass: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
|
||||
Icon: AlertTriangle,
|
||||
},
|
||||
Unavailable: {
|
||||
NotAvailable: {
|
||||
labelKey: 'availability.unavailable',
|
||||
defaultMessageKey: 'availability.messageUnavailable',
|
||||
alwaysDefault: false,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { UserRole } from '@/api/types';
|
||||
|
||||
function roleBadgeVariant(role: UserRole): 'default' | 'secondary' | 'outline' {
|
||||
if (role === 'Owner') return 'default';
|
||||
if (role === 'Administrator') return 'secondary';
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
interface RoleBadgeProps {
|
||||
role: UserRole;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
export function RoleBadge({ role, 'data-testid': testId }: RoleBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Badge variant={roleBadgeVariant(role)} data-testid={testId}>
|
||||
{t(`users.roles.${role}`)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user