Files
goodgo-platform/apps/web/app/[locale]/(dashboard)/layout.tsx
Ho Ngoc Hai 1fbe2f4e73 feat: add MFA/TOTP auth, PII encryption, agents/leads/inquiries modules, and comprehensive tests
- Add TOTP-based MFA with setup, verify, disable, backup codes, and challenge flow
- Add PII field encryption middleware with AES-256-GCM and deterministic search hashes
- Add agents, inquiries, and leads domain modules with entities, events, value objects
- Add web dashboard pages for inquiries and leads with detail dialogs
- Add 30+ component tests (valuation, charts, listings, search, providers, UI)
- Add Prisma migrations for encryption hash columns and MFA TOTP support
- Fix all ESLint errors (unused imports, duplicate imports, lint auto-fixes)
- Update dependencies and lock file
- Clean up obsolete exploration/QA docs, add audit documentation

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-11 23:43:20 +07:00

176 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { LogOut, Menu, X } from 'lucide-react';
import { usePathname } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { useTheme } from '@/components/providers/theme-provider';
import { Button } from '@/components/ui/button';
import { LanguageSwitcher } from '@/components/ui/language-switcher';
import { Link } from '@/i18n/navigation';
import { useAuthStore } from '@/lib/auth-store';
import { cn } from '@/lib/utils';
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const { user, logout } = useAuthStore();
const { theme, toggleTheme } = useTheme();
const t = useTranslations();
const [sidebarOpen, setSidebarOpen] = useState(false);
const navItems = [
{ href: '/dashboard' as const, label: t('dashboard.title'), icon: '🏠' },
{ href: '/listings' as const, label: t('dashboard.listings'), icon: '📋' },
{ href: '/listings/new' as const, label: t('dashboard.createListing'), icon: '' },
{ href: '/inquiries' as const, label: t('dashboard.inquiries'), icon: '💬' },
{ href: '/leads' as const, label: t('dashboard.leads'), icon: '🎯' },
{ href: '/analytics' as const, label: t('dashboard.analytics'), icon: '📊' },
{ href: '/dashboard/saved-searches' as const, label: t('dashboard.savedSearches'), icon: '🔖' },
{ href: '/dashboard/valuation' as const, label: t('dashboard.aiValuation'), icon: '🤖' },
{ href: '/dashboard/profile' as const, label: t('dashboard.profile'), icon: '👤' },
{ href: '/dashboard/subscription' as const, label: t('dashboard.subscription'), icon: '💎' },
{ href: '/dashboard/payments' as const, label: t('dashboard.payments'), icon: '💳' },
];
return (
<div className="min-h-screen bg-background">
{/* Mobile overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 md:hidden"
onClick={() => setSidebarOpen(false)}
aria-hidden="true"
/>
)}
{/* Mobile sidebar */}
<aside
role="navigation"
aria-label={t('nav.dashboardNav')}
className={cn(
'fixed inset-y-0 left-0 z-50 w-64 border-r bg-card transition-transform md:hidden',
sidebarOpen ? 'translate-x-0' : '-translate-x-full',
)}
>
<div className="flex h-14 items-center border-b px-4">
<Link href="/" className="flex items-center space-x-2">
<span className="text-lg font-bold text-primary">{t('common.goodgo')}</span>
</Link>
<button
aria-label={t('nav.closeMenu')}
className="ml-auto"
onClick={() => setSidebarOpen(false)}
>
<X className="h-5 w-5" />
</button>
</div>
<nav className="flex flex-col gap-1 p-3">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
onClick={() => setSidebarOpen(false)}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors',
pathname === item.href || (item.href !== '/dashboard' && pathname.startsWith(item.href))
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
)}
>
<span aria-hidden="true">{item.icon}</span>
{item.label}
</Link>
))}
</nav>
<div className="mt-auto border-t p-3">
{user && (
<p className="mb-2 truncate px-3 text-xs text-muted-foreground">{user.fullName}</p>
)}
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2"
onClick={() => logout()}
>
<LogOut className="h-4 w-4" aria-hidden="true" />
{t('common.logout')}
</Button>
</div>
</aside>
<header
role="banner"
className="sticky top-0 z-50 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
>
<div className="mx-auto flex h-14 max-w-7xl items-center px-4">
{/* Mobile hamburger */}
<button
aria-label={t('nav.openMenu')}
className="mr-3 inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-accent-foreground md:hidden"
onClick={() => setSidebarOpen(true)}
>
<Menu className="h-5 w-5" />
</button>
<Link href="/" className="mr-6 flex items-center space-x-2">
<span className="text-lg font-bold text-primary">{t('common.goodgo')}</span>
</Link>
{/* Desktop nav */}
<nav aria-label={t('nav.dashboardNav')} className="hidden items-center space-x-1 md:flex">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
aria-label={item.label}
className={cn(
'rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground',
pathname === item.href || (item.href !== '/dashboard' && pathname.startsWith(item.href))
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground',
)}
>
<span className="mr-1.5" aria-hidden="true">{item.icon}</span>
<span className="hidden lg:inline">{item.label}</span>
</Link>
))}
</nav>
<div className="ml-auto flex items-center space-x-2">
{user && (
<span className="hidden text-sm text-muted-foreground sm:inline">
{user.fullName}
</span>
)}
<LanguageSwitcher />
<Button
variant="ghost"
size="sm"
onClick={toggleTheme}
aria-label={theme === 'light' ? t('dashboard.darkMode') : t('dashboard.lightMode')}
className="h-9 w-9 p-0"
>
{theme === 'light' ? (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
) : (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
)}
</Button>
<Button variant="ghost" size="sm" className="hidden md:inline-flex" onClick={() => logout()}>
{t('common.logout')}
</Button>
</div>
</div>
</header>
<main id="main-content" role="main" className="mx-auto max-w-7xl px-4 py-6">{children}</main>
</div>
);
}