39998-vm/frontend/src/layouts/Authenticated.tsx
Flatlogic Bot eb455b41dd 6
2026-05-15 09:08:49 +00:00

286 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
)
}