40234-vm/frontend/src/layouts/Authenticated.tsx
2026-06-09 17:13:18 +00:00

291 lines
8.2 KiB
TypeScript

import {
mdiAccountCircle,
mdiAccountGroup,
mdiBookOpenVariant,
mdiClose,
mdiFileDocumentEditOutline,
mdiFormTextbox,
mdiLogout,
mdiMenu,
mdiShieldAccountOutline,
mdiViewDashboardOutline,
} from '@mdi/js';
import jwt from 'jsonwebtoken';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, { ReactNode, useEffect, useState } from 'react';
import BaseIcon from '../components/BaseIcon';
import { hasPermission } from '../helpers/userPermissions';
import { findMe, logoutUser } from '../stores/authSlice';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
type Props = {
children: ReactNode;
permission?: string;
};
const navItems = [
{
href: '/dashboard',
icon: mdiViewDashboardOutline,
label: 'Dashboard',
},
{
href: '/clients',
icon: mdiAccountGroup,
label: 'Clients',
},
{
href: '/session-memory',
icon: mdiFileDocumentEditOutline,
label: 'Session Memory',
},
{
href: '/intake-leads',
icon: mdiFormTextbox,
label: 'Intake Leads',
},
{
href: '/client-portal',
icon: mdiBookOpenVariant,
label: 'Client Portal',
},
{
href: '/users/users-list',
icon: mdiShieldAccountOutline,
label: 'Team',
permission: 'READ_USERS',
},
{
href: '/profile',
icon: mdiAccountCircle,
label: 'Profile',
},
];
export default function LayoutAuthenticated({ children, permission }: Props) {
const dispatch = useAppDispatch();
const router = useRouter();
const { token, currentUser } = useAppSelector((state) => state.auth);
const [isAsideOpen, setIsAsideOpen] = useState(false);
const isClientUser = currentUser?.app_role?.name === 'Client';
function getLocalToken() {
if (typeof window === 'undefined') {
return null;
}
return localStorage.getItem('token');
}
function isTokenValid() {
const localToken = getLocalToken();
if (!localToken) {
return false;
}
const decodedToken = jwt.decode(localToken);
if (!decodedToken || typeof decodedToken === 'string') {
return false;
}
if (!decodedToken.exp) {
return false;
}
const now = new Date().getTime() / 1000;
return now < decodedToken.exp;
}
useEffect(() => {
dispatch(findMe());
if (!isTokenValid()) {
dispatch(logoutUser());
router.push('/login');
}
}, [token]);
useEffect(() => {
if (!permission || !currentUser) {
return;
}
if (!hasPermission(currentUser, permission)) {
router.push('/error');
}
}, [currentUser, permission]);
useEffect(() => {
if (!isClientUser) {
return;
}
if (
router.pathname === '/client-portal' ||
router.pathname === '/profile'
) {
return;
}
router.push('/client-portal');
}, [isClientUser, router.pathname]);
useEffect(() => {
function closeAside() {
setIsAsideOpen(false);
}
router.events.on('routeChangeStart', closeAside);
return () => {
router.events.off('routeChangeStart', closeAside);
};
}, [router.events]);
const visibleNavItems = navItems.filter((item) => {
if (isClientUser) {
return item.href === '/client-portal' || item.href === '/profile';
}
if (!item.permission) {
return true;
}
if (!currentUser) {
return false;
}
return hasPermission(currentUser, item.permission);
});
const activePath = router.pathname.split('/')[1];
return (
<div className='min-h-screen bg-[#fffdf9] text-[#19192d]'>
<aside
className={`fixed inset-y-0 left-0 z-40 w-64 border-r border-[#19192d]/10 bg-[#fffdf9] px-3 py-4 transition-transform duration-300 lg:translate-x-0 ${
isAsideOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className='flex items-center justify-between'>
<Link href='/dashboard' className='flex items-center gap-3'>
<span className='grid h-8 w-8 place-items-center rounded-none border border-[#35b7a5]/50 bg-[#35b7a5] text-sm font-black text-white'>
CW
</span>
<span>
<span className='block text-sm font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
AppWizzy
</span>
<span className='block text-lg font-semibold'>
Coaching Workspace
</span>
</span>
</Link>
<button
type='button'
className='grid h-8 w-8 place-items-center rounded-none border border-[#19192d]/10 text-[#72798a] lg:hidden'
onClick={() => setIsAsideOpen(false)}
>
<BaseIcon path={mdiClose} size={18} />
</button>
</div>
<div className='mt-5 rounded-none border border-[#19192d]/10 bg-white p-6'>
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-[#35b7a5]'>
Today
</p>
<p className='mt-2 text-sm leading-6 text-[#72798a]'>
{isClientUser
? 'Review shared notes, commitments, and resources from your coach.'
: 'Prepare, follow up, and keep every coaching relationship warm between sessions.'}
</p>
</div>
<nav className='mt-4 space-y-1'>
{visibleNavItems.map((item) => {
const itemPath = item.href.split('/')[1];
const isActive = itemPath === activePath;
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 rounded-none px-4 py-3 text-sm font-semibold transition ${
isActive
? 'bg-[#35b7a5] text-white'
: 'text-[#72798a] hover:bg-[#fffdf9] hover:text-[#19192d]'
}`}
>
<BaseIcon path={item.icon} size={18} />
<span>{item.label}</span>
</Link>
);
})}
</nav>
<div className='absolute inset-x-4 bottom-5 rounded-none border border-[#19192d]/10 bg-[#fffdf9] p-6'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
Signed in
</p>
<p className='mt-2 truncate text-sm font-semibold'>
{currentUser?.email || 'Workspace user'}
</p>
<button
type='button'
className='mt-4 flex w-full items-center justify-center gap-2 rounded-none bg-[#19192d] px-4 py-2 text-sm font-semibold text-white'
onClick={() => {
dispatch(logoutUser());
router.push('/login');
}}
>
<BaseIcon path={mdiLogout} size={18} />
Log out
</button>
</div>
</aside>
{isAsideOpen && (
<button
type='button'
aria-label='Close menu'
className='fixed inset-0 z-30 bg-[#19192d]/40 lg:hidden'
onClick={() => setIsAsideOpen(false)}
/>
)}
<div className='lg:pl-64'>
<header className='sticky top-0 z-20 border-b border-[#19192d]/10/80 bg-[#fffdf9]/92 px-4 py-3 backdrop-blur lg:px-8'>
<div className='flex items-center justify-between'>
<button
type='button'
className='grid h-8 w-8 place-items-center rounded-none border border-[#19192d]/10 bg-white text-[#72798a] lg:hidden'
onClick={() => setIsAsideOpen(true)}
>
<BaseIcon path={mdiMenu} size={18} />
</button>
<div className='hidden lg:block'>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
Workspace status
</p>
<p className='mt-1 text-sm text-[#72798a]'>
{isClientUser
? 'Review your commitments, notes, and shared resources.'
: 'Review sessions, clients, tasks, and shared client materials.'}
</p>
</div>
<Link
href='/'
className='rounded-none border border-[#19192d]/10 bg-white px-4 py-2 text-sm font-semibold text-[#19192d]'
>
Public site
</Link>
</div>
</header>
<main>{children}</main>
</div>
</div>
);
}