286 lines
9.9 KiB
TypeScript
286 lines
9.9 KiB
TypeScript
import React, { ReactNode, useEffect, useRef, useState } from 'react'
|
||
import jwt from 'jsonwebtoken';
|
||
import {
|
||
mdiChevronDown,
|
||
mdiCogOutline,
|
||
mdiLogout,
|
||
} from '@mdi/js'
|
||
import menuAside from '../menuAside'
|
||
import BaseIcon from '../components/BaseIcon'
|
||
import NavBar from '../components/NavBar'
|
||
import AsideMenu from '../components/AsideMenu'
|
||
import FooterBar from '../components/FooterBar'
|
||
import { useAppDispatch, useAppSelector } from '../stores/hooks'
|
||
import { useRouter } from 'next/router'
|
||
import {findMe, logoutUser} from "../stores/authSlice";
|
||
import Link from 'next/link';
|
||
import ClickOutside from '../components/ClickOutside';
|
||
|
||
import {hasPermission} from "../helpers/userPermissions";
|
||
|
||
|
||
type Props = {
|
||
children: ReactNode
|
||
|
||
permission?: string | string[]
|
||
|
||
}
|
||
|
||
function getAvatarUrl(avatar: any) {
|
||
if (!Array.isArray(avatar) || !avatar.length) {
|
||
return '';
|
||
}
|
||
|
||
return avatar[0]?.publicUrl || '';
|
||
}
|
||
|
||
function getUserInitial(currentUser: any) {
|
||
const firstName = currentUser?.firstName || '';
|
||
const lastName = currentUser?.lastName || '';
|
||
const initials = `${firstName.slice(0, 1)}${lastName.slice(0, 1)}`.trim();
|
||
|
||
if (initials) {
|
||
return initials.toUpperCase();
|
||
}
|
||
|
||
if (currentUser?.email) {
|
||
return currentUser.email.slice(0, 1).toUpperCase();
|
||
}
|
||
|
||
return 'U';
|
||
}
|
||
|
||
export default function LayoutAuthenticated({
|
||
children,
|
||
|
||
permission
|
||
|
||
}: Props) {
|
||
const asideWidth = 256
|
||
const dispatch = useAppDispatch()
|
||
const router = useRouter()
|
||
const { token, currentUser } = useAppSelector((state) => state.auth)
|
||
const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
|
||
const isWorkspaceRoute = router.pathname.startsWith('/workspace');
|
||
const [localToken, setLocalToken] = useState<string | null>(null)
|
||
const effectiveToken = token || localToken || ''
|
||
const isAuthBootstrapped = Boolean(token) || localToken !== null
|
||
|
||
const isTokenValid = (value: string) => {
|
||
if (!value) {
|
||
return false;
|
||
}
|
||
|
||
const date = new Date().getTime() / 1000;
|
||
const data = jwt.decode(value);
|
||
if (!data) {
|
||
return false;
|
||
}
|
||
|
||
return date < data.exp;
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (typeof window === 'undefined') {
|
||
return;
|
||
}
|
||
|
||
setLocalToken(localStorage.getItem('token') || '')
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (!effectiveToken) {
|
||
return;
|
||
}
|
||
|
||
if (!isTokenValid(effectiveToken)) {
|
||
dispatch(logoutUser());
|
||
return;
|
||
}
|
||
|
||
dispatch(findMe());
|
||
}, [dispatch, effectiveToken]);
|
||
|
||
|
||
useEffect(() => {
|
||
if (!permission || !currentUser) return;
|
||
|
||
if (!hasPermission(currentUser, permission)) router.push('/error');
|
||
}, [currentUser, permission]);
|
||
|
||
|
||
const darkMode = useAppSelector((state) => state.style.darkMode)
|
||
|
||
const [isWorkspaceAccountMenuOpen, setIsWorkspaceAccountMenuOpen] = useState(false)
|
||
const workspaceAccountMenuButtonRef = useRef(null)
|
||
const currentUserAvatarUrl = getAvatarUrl(currentUser?.avatar)
|
||
const currentUserInitial = getUserInitial(currentUser)
|
||
|
||
useEffect(() => {
|
||
const handleRouteChangeStart = () => {
|
||
setIsWorkspaceAccountMenuOpen(false)
|
||
}
|
||
|
||
router.events.on('routeChangeStart', handleRouteChangeStart)
|
||
|
||
// If the component is unmounted, unsubscribe
|
||
// from the event with the `off` method:
|
||
return () => {
|
||
router.events.off('routeChangeStart', handleRouteChangeStart)
|
||
}
|
||
}, [router.events, dispatch])
|
||
|
||
const contentStyle = isWorkspaceRoute
|
||
? undefined
|
||
: { paddingLeft: `${asideWidth}px` }
|
||
const navStyle = isWorkspaceRoute
|
||
? undefined
|
||
: { left: `${asideWidth}px`, width: `calc(100% - ${asideWidth}px)` }
|
||
|
||
if (!isAuthBootstrapped) {
|
||
return (
|
||
<div className={`${darkMode ? 'dark' : ''}`}>
|
||
<div className={`min-h-screen ${bgColor} dark:bg-dark-800 dark:text-slate-100`}>
|
||
<div className="flex min-h-screen items-center justify-center px-6 py-10">
|
||
<div className="w-full max-w-md rounded-[12px] border border-slate-200 bg-white p-6 shadow-[0_24px_60px_-40px_rgba(15,23,42,0.25)] dark:border-slate-800 dark:bg-dark-900">
|
||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">
|
||
Loading
|
||
</p>
|
||
<h1 className="mt-3 text-[28px] font-semibold tracking-[-0.03em] text-slate-900 dark:text-slate-50">
|
||
Restoring your workspace.
|
||
</h1>
|
||
<p className="mt-3 text-[14px] leading-6 text-slate-500 dark:text-slate-400">
|
||
We are checking your session before opening the app.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
|
||
if (!effectiveToken) {
|
||
return (
|
||
<div className={`${darkMode ? 'dark' : ''}`}>
|
||
<div className={`min-h-screen ${bgColor} px-6 py-10 dark:bg-dark-800 dark:text-slate-100`}>
|
||
<div className="mx-auto flex min-h-[calc(100vh-5rem)] max-w-md items-center justify-center">
|
||
<div className="w-full rounded-[12px] border border-slate-200 bg-white p-6 shadow-[0_24px_60px_-40px_rgba(15,23,42,0.25)] dark:border-slate-800 dark:bg-dark-900">
|
||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">
|
||
Session expired
|
||
</p>
|
||
<h1 className="mt-3 text-[28px] font-semibold tracking-[-0.03em] text-slate-900 dark:text-slate-50">
|
||
Sign in again to continue.
|
||
</h1>
|
||
<p className="mt-3 text-[14px] leading-6 text-slate-500 dark:text-slate-400">
|
||
Your session has ended. Open the sign-in screen to continue working in the workspace.
|
||
</p>
|
||
<div className="mt-6">
|
||
<Link
|
||
className="inline-flex h-11 items-center rounded-[8px] bg-slate-900 px-4 text-[14px] font-medium text-white dark:bg-slate-100 dark:text-slate-900"
|
||
href="/login"
|
||
>
|
||
Open login
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className={`${darkMode ? 'dark' : ''} overflow-hidden lg:overflow-visible`}>
|
||
<div
|
||
className={`pt-12 min-h-screen ${bgColor} dark:bg-dark-800 dark:text-slate-100`}
|
||
style={contentStyle}
|
||
>
|
||
<NavBar
|
||
menu={[]}
|
||
className=""
|
||
contentClassName="w-full"
|
||
style={navStyle}
|
||
>
|
||
<div className="flex min-w-0 flex-1 items-center justify-between px-3 sm:px-5">
|
||
<div className="flex min-w-0 items-center">
|
||
{isWorkspaceRoute && (
|
||
<Link
|
||
className="truncate text-[14px] font-semibold tracking-[-0.02em] text-slate-700 dark:text-slate-100"
|
||
href="/workspace"
|
||
>
|
||
AI Chat Workspace
|
||
</Link>
|
||
)}
|
||
</div>
|
||
<div className="relative">
|
||
<button
|
||
className="inline-flex items-center gap-2 rounded-[8px] border border-slate-200 bg-white px-3 py-1.5 text-[12px] font-medium text-slate-700"
|
||
onClick={() => setIsWorkspaceAccountMenuOpen((previous) => !previous)}
|
||
ref={workspaceAccountMenuButtonRef}
|
||
type="button"
|
||
>
|
||
{currentUserAvatarUrl ? (
|
||
<span className="flex h-6 w-6 shrink-0 overflow-hidden rounded-full border border-slate-200 bg-slate-100">
|
||
<img
|
||
alt={currentUser?.email || 'Workspace user'}
|
||
className="h-full w-full object-cover"
|
||
src={currentUserAvatarUrl}
|
||
/>
|
||
</span>
|
||
) : (
|
||
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-slate-100 text-[10px] font-semibold text-slate-600">
|
||
{currentUserInitial}
|
||
</span>
|
||
)}
|
||
<span className="hidden max-w-[220px] truncate md:inline">
|
||
{currentUser?.email || 'Workspace user'}
|
||
</span>
|
||
<BaseIcon path={mdiChevronDown} size="16" />
|
||
</button>
|
||
{isWorkspaceAccountMenuOpen && (
|
||
<div className="absolute right-0 top-[calc(100%+0.5rem)] z-40 min-w-[220px]">
|
||
<ClickOutside
|
||
excludedElements={[workspaceAccountMenuButtonRef]}
|
||
onClickOutside={() => setIsWorkspaceAccountMenuOpen(false)}
|
||
>
|
||
<div className="overflow-hidden rounded-[10px] border border-slate-200 bg-white p-1 shadow-[0_16px_48px_-32px_rgba(15,23,42,0.25)]">
|
||
<Link
|
||
className="flex items-center gap-2 rounded-[8px] px-3 py-2 text-[13px] text-slate-700"
|
||
href="/settings"
|
||
onClick={() => setIsWorkspaceAccountMenuOpen(false)}
|
||
>
|
||
<BaseIcon path={mdiCogOutline} size="16" />
|
||
Settings
|
||
</Link>
|
||
<button
|
||
className="flex w-full items-center gap-2 rounded-[8px] px-3 py-2 text-left text-[13px] text-slate-700"
|
||
onClick={() => {
|
||
setIsWorkspaceAccountMenuOpen(false)
|
||
dispatch(logoutUser())
|
||
router.push('/login')
|
||
}}
|
||
type="button"
|
||
>
|
||
<BaseIcon path={mdiLogout} size="16" />
|
||
Log out
|
||
</button>
|
||
</div>
|
||
</ClickOutside>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</NavBar>
|
||
{!isWorkspaceRoute && (
|
||
<AsideMenu
|
||
menu={menuAside}
|
||
/>
|
||
)}
|
||
{children}
|
||
{!isWorkspaceRoute && <FooterBar>Hand-crafted & Made with ❤️</FooterBar>}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|