291 lines
8.2 KiB
TypeScript
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>
|
|
);
|
|
}
|