├── .eslintrc.json ├── src ├── app │ ├── auth │ │ ├── page.tsx │ │ ├── sign-in │ │ │ └── loading.tsx │ │ ├── sign-up │ │ │ └── loading.tsx │ │ ├── account-created │ │ │ └── loading.tsx │ │ ├── forgot-password │ │ │ └── loading.tsx │ │ ├── reset-password │ │ │ └── loading.tsx │ │ ├── password-reset-confirm │ │ │ ├── loading.tsx │ │ │ └── page.tsx │ │ ├── components │ │ │ └── AuthFormWrapper.tsx │ │ ├── check-email │ │ │ └── page.tsx │ │ └── email-sent │ │ │ └── page.tsx │ ├── not-authorized.tsx │ ├── page.tsx │ ├── loading.tsx │ ├── team │ │ ├── loading.tsx │ │ ├── page.tsx │ │ └── components │ │ │ ├── FilterToggle.tsx │ │ │ ├── TeamClient.tsx │ │ │ ├── UserTable.tsx │ │ │ └── OrganizationName.tsx │ ├── contacts │ │ ├── loading.tsx │ │ ├── page.tsx │ │ └── components │ │ │ └── ContactsTableRow.tsx │ ├── documents │ │ ├── loading.tsx │ │ ├── [documentId] │ │ │ ├── loading.tsx │ │ │ ├── page.tsx │ │ │ └── components │ │ │ │ ├── FilterToggle.tsx │ │ │ │ ├── CustomBarChart.tsx │ │ │ │ └── InfoTableHeader.tsx │ │ └── components │ │ │ ├── CustomAccordion.tsx │ │ │ ├── DocumentsTableHeader.tsx │ │ │ ├── LinkDetailsAccordion.tsx │ │ │ └── NewLinkDialog.tsx │ ├── profile │ │ ├── loading.tsx │ │ └── page.tsx │ ├── settings │ │ ├── loading.tsx │ │ ├── page.tsx │ │ └── components │ │ │ ├── SettingsTabs.tsx │ │ │ └── ColorPickerBox.tsx │ ├── documentAccess │ │ └── [linkId] │ │ │ ├── loading.tsx │ │ │ ├── layout.tsx │ │ │ ├── components │ │ │ ├── AccessError.tsx │ │ │ └── FileDisplay.tsx │ │ │ └── page.tsx │ ├── api │ │ ├── _services │ │ │ ├── index.ts │ │ │ └── errorService.ts │ │ ├── auth │ │ │ ├── [...nextauth] │ │ │ │ └── route.ts │ │ │ ├── verify │ │ │ │ └── route.ts │ │ │ ├── password │ │ │ │ ├── reset │ │ │ │ │ └── route.ts │ │ │ │ └── forgot │ │ │ │ │ └── route.ts │ │ │ └── register │ │ │ │ └── route.ts │ │ ├── documents │ │ │ └── [documentId] │ │ │ │ ├── links │ │ │ │ ├── [linkId] │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ │ ├── visitors │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ ├── profile │ │ │ ├── route.ts │ │ │ ├── changeName │ │ │ │ └── route.ts │ │ │ └── changePassword │ │ │ │ └── route.ts │ │ ├── public_links │ │ │ └── [linkId] │ │ │ │ ├── route.ts │ │ │ │ └── access │ │ │ │ └── route.ts │ │ └── contacts │ │ │ └── route.ts │ ├── layout.tsx │ ├── not-found.tsx │ └── providers.tsx ├── shared │ ├── types │ │ ├── global.d.ts │ │ └── next-auth.d.ts │ ├── models │ │ ├── authModels.ts │ │ ├── index.ts │ │ ├── userModels.ts │ │ ├── documentModels.ts │ │ └── linkModels.ts │ ├── config │ │ ├── visitorFieldsConfig.ts │ │ ├── fileIcons.ts │ │ └── routesConfig.ts │ └── utils │ │ └── index.ts ├── providers │ ├── toast │ │ ├── toastTypes.ts │ │ └── ToastProvider.tsx │ ├── query │ │ └── QueryProvider.tsx │ └── auth │ │ └── AuthWrapper.tsx ├── hooks │ ├── useModal.ts │ ├── useToast.ts │ ├── documents │ │ ├── useFetchDocuments.ts │ │ ├── useDeleteDocument.ts │ │ ├── useUploadDocument.ts │ │ └── useCreateLink.ts │ ├── documentAccess │ │ ├── useDocumentAccess.ts │ │ └── useVisitorSubmission.ts │ ├── contacts │ │ └── useFetchContacts.ts │ ├── index.ts │ ├── useDocumentDetail.ts │ ├── useDocumentData.ts │ ├── useDocumentAnalytics.ts │ └── useSort.ts ├── lib │ └── prisma.ts ├── components │ ├── loaders │ │ ├── LoadingSpinner.tsx │ │ ├── LoadingButton.tsx │ │ ├── CustomCircularProgress.tsx │ │ └── EmptyState.tsx │ ├── input │ │ ├── CustomCheckbox.tsx │ │ ├── Dropdown.tsx │ │ ├── PasswordValidation.tsx │ │ └── FormInput.tsx │ ├── common │ │ ├── EnvironmentBadge.tsx │ │ └── Toast.tsx │ ├── navigation │ │ ├── NavLink.tsx │ │ └── Paginator.tsx │ └── index.ts ├── theme │ └── customTypesTheme.d.ts └── icons │ ├── charts │ └── BarChartIcon.tsx │ ├── shapes │ └── SquareIcon.tsx │ ├── arrows │ ├── ArrowNarrowLeftIcon.tsx │ ├── ArrowNarrowRightIcon.tsx │ ├── ChevronUpIcon.tsx │ ├── ChevronDownIcon.tsx │ ├── ChevronRightIcon.tsx │ └── ChevronSelectorVerticalIcon.tsx │ ├── general │ ├── CheckIcon.tsx │ ├── XCloseIcon.tsx │ ├── CheckSquareIcon.tsx │ ├── LogOutIcon.tsx │ ├── MenuIcon.tsx │ ├── UploadCloudIcon.tsx │ ├── LinkBrokenIcon.tsx │ ├── LinkIcon.tsx │ ├── TrashIcon.tsx │ ├── XCircleIcon.tsx │ ├── EyeIcon.tsx │ ├── CopyIcon.tsx │ ├── SettingsIcon.tsx │ ├── EyeOffIcon.tsx │ ├── HomeIcon.tsx │ ├── CheckCircleIcon.tsx │ └── SaveIcon.tsx │ ├── alerts │ └── AlertCircleIcon.tsx │ ├── files │ ├── FileIcon.tsx │ ├── VideoIcon.tsx │ ├── AudioIcon.tsx │ ├── GeneralIcon.tsx │ ├── ImageIcon.tsx │ ├── ZIPIcon.tsx │ ├── TextIcon.tsx │ ├── FileDownloadIcon.tsx │ ├── ExcelIcon.tsx │ └── PPTIcon.tsx │ ├── users │ ├── UserIcon.tsx │ └── UsersIcon.tsx │ ├── security │ ├── LockIcon.tsx │ └── KeyIcon.tsx │ ├── communication │ └── MailIcon.tsx │ └── editor │ └── PencilIcon.tsx ├── .prettierignore ├── middleware.ts ├── .prettierrc ├── .gitignore ├── tsconfig.json ├── next.config.mjs ├── .vscode └── settings.json ├── prisma └── seed.ts ├── scripts └── prepare-env.js └── package.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals"] 3 | } 4 | -------------------------------------------------------------------------------- /src/app/auth/page.tsx: -------------------------------------------------------------------------------- 1 | export default function Home() { 2 | return
Auth
; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/not-authorized.tsx: -------------------------------------------------------------------------------- 1 | export default function Home() { 2 | return
Not Authorized
; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@mui/material'; 2 | 3 | export default function Home() { 4 | return Home page; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/loading.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingSpinner } from '@/components'; 2 | 3 | export default function PageLoader() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/team/loading.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingSpinner } from '@/components'; 2 | 3 | export default function PageLoader() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/contacts/loading.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingSpinner } from '@/components'; 2 | 3 | export default function PageLoader() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/documents/loading.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingSpinner } from '@/components'; 2 | 3 | export default function PageLoader() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/profile/loading.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingSpinner } from '@/components'; 2 | 3 | export default function PageLoader() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/settings/loading.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingSpinner } from '@/components'; 2 | 3 | export default function PageLoader() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/auth/sign-in/loading.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingSpinner } from '@/components'; 2 | 3 | export default function PageLoader() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/auth/sign-up/loading.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingSpinner } from '@/components'; 2 | 3 | export default function PageLoader() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/auth/account-created/loading.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingSpinner } from '@/components'; 2 | 3 | export default function PageLoader() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/auth/forgot-password/loading.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingSpinner } from '@/components'; 2 | 3 | export default function PageLoader() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/auth/reset-password/loading.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingSpinner } from '@/components'; 2 | 3 | export default function PageLoader() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/shared/types/global.d.ts: -------------------------------------------------------------------------------- 1 | // global.d.ts 2 | import { PrismaClient } from '@prisma/client'; 3 | 4 | declare global { 5 | var prisma: PrismaClient | undefined; 6 | } 7 | 8 | export {}; 9 | -------------------------------------------------------------------------------- /src/app/documentAccess/[linkId]/loading.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingSpinner } from '@/components'; 2 | 3 | export default function PageLoader() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/documents/[documentId]/loading.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingSpinner } from '@/components'; 2 | 3 | export default function PageLoader() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/auth/password-reset-confirm/loading.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingSpinner } from '@/components'; 2 | 3 | export default function PageLoader() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/profile/page.tsx: -------------------------------------------------------------------------------- 1 | import { Container } from '@mui/material'; 2 | import ProfileForm from './components/ProfileForm'; 3 | 4 | export default function ProfilePage() { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/providers/toast/toastTypes.ts: -------------------------------------------------------------------------------- 1 | export type ToastVariant = 'success' | 'error' | 'warning' | 'info'; 2 | 3 | export interface ToastMessage { 4 | id: string; // Unique identifier for each toast 5 | message: string; 6 | variant?: ToastVariant; 7 | autoHide?: boolean; 8 | } 9 | -------------------------------------------------------------------------------- /src/app/api/_services/index.ts: -------------------------------------------------------------------------------- 1 | export { DocumentService } from './documentService'; 2 | export { authService } from './authService'; 3 | export { createErrorResponse } from './errorService'; 4 | export { LinkService } from './linkService'; 5 | export { emailService } from './emailService'; 6 | -------------------------------------------------------------------------------- /src/hooks/useModal.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | export function useModal() { 4 | const [isOpen, setIsOpen] = useState(false); 5 | 6 | const openModal = () => setIsOpen(true); 7 | const closeModal = () => setIsOpen(false); 8 | 9 | return { isOpen, openModal, closeModal }; 10 | } 11 | -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from 'next-auth'; 2 | import { authOptions } from '@/lib/authOptions'; 3 | 4 | const nextAuthHandler = NextAuth(authOptions) as unknown as (req: Request) => Promise; 5 | 6 | export const GET = nextAuthHandler; 7 | export const POST = nextAuthHandler; 8 | -------------------------------------------------------------------------------- /src/hooks/useToast.ts: -------------------------------------------------------------------------------- 1 | import { useToastContext } from '@/providers/toast/ToastProvider'; 2 | import { ToastMessage } from '@/providers/toast/toastTypes'; 3 | 4 | export const useToast = () => { 5 | const { showToast } = useToastContext(); 6 | return { showToast: (toast: Omit) => showToast(toast) }; 7 | }; 8 | -------------------------------------------------------------------------------- /src/app/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import { Container, Divider } from '@mui/material'; 2 | import BrandingSetting from './components/BrandingSetting'; 3 | import SettingsTabs from './components/SettingsTabs'; 4 | 5 | export default function SettingsPage() { 6 | return ( 7 | 8 | 9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/app/contacts/page.tsx: -------------------------------------------------------------------------------- 1 | import { Container, Typography } from '@mui/material'; 2 | import ContactsTable from './components/ContactsTable'; 3 | 4 | export default function ContactsPage() { 5 | return ( 6 | 7 | 10 | All contacts 11 | 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore dependencies and builds 2 | node_modules 3 | dist 4 | .next 5 | out 6 | build 7 | coverage 8 | 9 | # Ignore specific files 10 | package-lock.json 11 | yarn.lock 12 | README.md 13 | 14 | # Ignore public assets 15 | public 16 | public/**/*.* 17 | static 18 | static/**/* 19 | 20 | # Ignore environment files 21 | .env 22 | .env.local 23 | .env.*.local 24 | 25 | # Ignore logs 26 | *.log 27 | -------------------------------------------------------------------------------- /src/app/documents/[documentId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Container } from '@mui/material'; 2 | import DocumentView from './components/DocumentView'; 3 | 4 | export default async function page(props: { params: Promise<{ documentId: string }> }) { 5 | const { documentId } = await props.params; 6 | 7 | return ( 8 | 9 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | const globalForPrisma = global as unknown as { prisma: PrismaClient }; 4 | 5 | const prisma = 6 | globalForPrisma.prisma || 7 | new PrismaClient({ 8 | log: process.env.ENABLE_DEBUG_LOGS === 'true' ? ['query', 'info', 'warn', 'error'] : [], 9 | }); 10 | 11 | if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma; 12 | 13 | export default prisma; 14 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { withAuth } from 'next-auth/middleware'; 2 | 3 | export default withAuth({ 4 | pages: { 5 | signIn: '/auth/sign-in', // Redirect to /sign-in for all unauthenticated routes 6 | }, 7 | }); 8 | 9 | export const config = { 10 | matcher: [ 11 | '/((?!register|auth/sign-up|auth/forgot-password|auth/account-created|auth/password-reset-confirm|auth/check-email|auth/reset-password|documentAccess/[a-f0-9-]{36})/.*.*|auth/reset-password/.*)', 12 | ], 13 | }; 14 | -------------------------------------------------------------------------------- /src/app/api/_services/errorService.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | 3 | /** 4 | * Creates a consistent JSON error response. 5 | * @param message The error message 6 | * @param status HTTP status code 7 | * @param details Optional error details 8 | */ 9 | export function createErrorResponse(message: string, status: number, details?: any) { 10 | console.error(`[${new Date().toISOString()}] ${message}`, details); 11 | return NextResponse.json({ message }, { status }); 12 | } 13 | -------------------------------------------------------------------------------- /src/shared/types/next-auth.d.ts: -------------------------------------------------------------------------------- 1 | // next-auth.d.ts 2 | import NextAuth from 'next-auth'; 3 | 4 | declare module 'next-auth' { 5 | interface Session { 6 | user: { 7 | id: string; 8 | userId: string; 9 | role: string; 10 | firstName: string; 11 | lastName: string; 12 | email: string; 13 | image?: string; 14 | }; 15 | } 16 | 17 | interface User { 18 | id: string; 19 | userId: string; 20 | role: string; 21 | firstName: string; 22 | lastName: string; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/components/loaders/LoadingSpinner.tsx: -------------------------------------------------------------------------------- 1 | import { Box, CircularProgress } from '@mui/material'; 2 | 3 | export default function LoadingSpinner() { 4 | return ( 5 | 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/shared/models/authModels.ts: -------------------------------------------------------------------------------- 1 | import { UserRole } from '@prisma/client'; 2 | 3 | // =========== REGISTER PAYLOAD =========== 4 | 5 | export interface RegisterPayload { 6 | email: string; 7 | password: string; 8 | firstName: string; 9 | lastName: string; 10 | role: UserRole; 11 | } 12 | 13 | // =========== REGISTER RESULT =========== 14 | 15 | export interface RegisterResult { 16 | success: boolean; 17 | message: string; 18 | userId?: string; 19 | verificationToken?: string; 20 | emailFail?: boolean; // partial success scenario 21 | } 22 | -------------------------------------------------------------------------------- /src/app/settings/components/SettingsTabs.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Tab, Tabs } from '@mui/material'; 4 | import { useState } from 'react'; 5 | 6 | export default function SettingsTabs() { 7 | const [tabValue, setTabValue] = useState(0); 8 | 9 | const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { 10 | setTabValue(newValue); 11 | }; 12 | return ( 13 | <> 14 | 18 | 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/hooks/documents/useFetchDocuments.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { useQuery } from '@tanstack/react-query'; 3 | import { DocumentType } from '@/shared/models'; 4 | 5 | interface DocumentResponse { 6 | documents: DocumentType[]; 7 | } 8 | 9 | const fetchDocuments = async (): Promise => { 10 | const response = await axios.get('/api/documents'); 11 | 12 | return response.data; 13 | }; 14 | 15 | const useFetchDocuments = () => { 16 | return useQuery({ 17 | queryKey: ['documents'], 18 | queryFn: fetchDocuments, 19 | }); 20 | }; 21 | 22 | export default useFetchDocuments; 23 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSameLine": true, 4 | "bracketSpacing": true, 5 | "endOfLine": "lf", 6 | "printWidth": 100, 7 | "semi": true, 8 | "singleQuote": true, 9 | "tabWidth": 2, 10 | "useTabs": true, 11 | "trailingComma": "all", 12 | "jsxSingleQuote": true, 13 | "singleAttributePerLine": true, 14 | "htmlWhitespaceSensitivity": "css", 15 | "proseWrap": "preserve", 16 | "quoteProps": "as-needed", 17 | "embeddedLanguageFormatting": "auto", 18 | "overrides": [ 19 | { 20 | "files": "*.json", 21 | "options": { 22 | "printWidth": 80, 23 | "tabWidth": 2 24 | } 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /src/hooks/documents/useDeleteDocument.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { useMutation, useQueryClient } from '@tanstack/react-query'; 3 | 4 | const deleteDocumentById = async (documentId: string): Promise => { 5 | const response = await axios.delete(`/api/documents/${documentId}`); 6 | 7 | return response.data; 8 | }; 9 | 10 | const useDeleteDocument = () => { 11 | const queryClient = useQueryClient(); 12 | 13 | return useMutation({ 14 | mutationFn: deleteDocumentById, 15 | onSuccess: () => { 16 | queryClient.invalidateQueries({ queryKey: ['documents'] }); 17 | }, 18 | }); 19 | }; 20 | 21 | export default useDeleteDocument; 22 | -------------------------------------------------------------------------------- /src/providers/query/QueryProvider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | 5 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; 6 | import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; 7 | 8 | interface Props { 9 | children: React.ReactNode; 10 | } 11 | 12 | const QueryProvider = ({ children }: Props) => { 13 | const [queryClient] = React.useState(() => new QueryClient()); 14 | 15 | return ( 16 | 17 | 18 | {children} 19 | 20 | ); 21 | }; 22 | 23 | export default QueryProvider; 24 | -------------------------------------------------------------------------------- /src/hooks/documentAccess/useDocumentAccess.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { useQuery } from '@tanstack/react-query'; 3 | import { QueryFunctionContext } from '@tanstack/react-query'; 4 | 5 | const fetchDocumentDetails = async ({ queryKey }: QueryFunctionContext) => { 6 | const [_, linkId] = queryKey as [string, string]; 7 | const response = await axios.get(`/api/public_links/${linkId}`); 8 | 9 | return response.data; 10 | }; 11 | 12 | const useDocumentAccess = (linkId: string) => { 13 | return useQuery({ 14 | queryKey: ['documentAccess', linkId], 15 | queryFn: fetchDocumentDetails, 16 | retry: false, 17 | }); 18 | }; 19 | 20 | export default useDocumentAccess; 21 | -------------------------------------------------------------------------------- /src/app/auth/components/AuthFormWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Container } from '@mui/material'; 2 | import { ReactNode } from 'react'; 3 | import { BackgroundIcon } from '@/icons'; 4 | 5 | const AuthFormWrapper = ({ children }: { children: ReactNode }) => { 6 | return ( 7 | <> 8 | 9 | 12 | 18 | {children} 19 | 20 | 21 | 22 | ); 23 | }; 24 | 25 | export default AuthFormWrapper; 26 | -------------------------------------------------------------------------------- /src/shared/models/index.ts: -------------------------------------------------------------------------------- 1 | export type { RegisterPayload } from './authModels'; 2 | export type { RegisterResult } from './authModels'; 3 | 4 | export type { DocumentType } from './documentModels'; 5 | export type { BarDataItem } from './documentModels'; 6 | 7 | export type { LinkType } from './linkModels'; 8 | export type { LinkFormValues } from './linkModels'; 9 | export type { CreateDocumentLinkPayload } from './linkModels'; 10 | export type { InviteRecipientsPayload } from './linkModels'; 11 | export type { LinkData } from './linkModels'; 12 | export type { LinkDetail } from './linkModels'; 13 | 14 | export type { User } from './userModels'; 15 | export type { Contact } from './userModels'; 16 | -------------------------------------------------------------------------------- /src/shared/config/visitorFieldsConfig.ts: -------------------------------------------------------------------------------- 1 | const visitorFieldKeys = ['name', 'email'] as const; 2 | 3 | type VisitorFieldKey = (typeof visitorFieldKeys)[number]; 4 | type VisitorFieldsConfigByKey = Record; 5 | 6 | export interface VisitorField { 7 | key: VisitorFieldKey; 8 | label: string; 9 | placeholder: string; 10 | } 11 | 12 | export const visitorFieldsConfig: VisitorField[] = [ 13 | { key: 'name', label: 'Name', placeholder: 'Your Name' }, 14 | { key: 'email', label: 'Email', placeholder: 'your_email@bluewave.com' }, 15 | ]; 16 | 17 | export const visitorFieldsConfigByKey: VisitorFieldsConfigByKey = Object.groupBy( 18 | visitorFieldsConfig, 19 | (item) => item.key, 20 | ); 21 | -------------------------------------------------------------------------------- /src/app/team/page.tsx: -------------------------------------------------------------------------------- 1 | import { Container, Divider, Typography } from '@mui/material'; 2 | import OrganizationName from './components/OrganizationName'; 3 | import TeamClient from './components/TeamClient'; 4 | 5 | export default function TeamPage() { 6 | return ( 7 | 8 | Team 9 | 10 | 13 | Set up your team here. 14 | 15 | 16 | 17 | 18 | 19 | 22 | Team Members 23 | 24 | 25 | 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # next.js 10 | /.next/ 11 | /out/ 12 | 13 | # Ignore settings.json files 14 | /.vscode 15 | .vscode/settings.json 16 | 17 | # debug 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | 22 | # Ignore all real .env files 23 | env/ 24 | .env 25 | .env.local 26 | .env.production 27 | .env.development 28 | 29 | # vercel 30 | .vercel 31 | 32 | md/ 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | # Seed Data 39 | /src/seed 40 | 41 | # Ignore the entire migrations folder temporarily 42 | prisma/migrations/ 43 | 44 | -------------------------------------------------------------------------------- /src/hooks/documents/useUploadDocument.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { useMutation, useQueryClient } from '@tanstack/react-query'; 3 | 4 | const uploadDocument = async (formData: FormData) => { 5 | const response = await axios.post('/api/documents', formData); 6 | 7 | return response.data; 8 | }; 9 | 10 | const useUploadDocument = () => { 11 | const queryClient = useQueryClient(); 12 | 13 | return useMutation({ 14 | mutationFn: async (formData: FormData) => uploadDocument(formData), 15 | onSuccess: () => { 16 | queryClient.invalidateQueries({ queryKey: ['documents'] }); 17 | }, 18 | onError: (error) => { 19 | console.error('Error adding document: ', error); 20 | }, 21 | }); 22 | }; 23 | 24 | export default useUploadDocument; 25 | -------------------------------------------------------------------------------- /src/hooks/documentAccess/useVisitorSubmission.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { useMutation } from '@tanstack/react-query'; 3 | 4 | const submitVisitorDetails = async ({ linkId, payload }: { linkId: string; payload: any }) => { 5 | const response = await axios.post(`/api/public_links/${linkId}/access`, payload); 6 | 7 | return response.data; 8 | }; 9 | 10 | const useVisitorSubmission = () => { 11 | const mutation = useMutation({ 12 | mutationFn: submitVisitorDetails, 13 | onError: (error) => { 14 | console.error('Error submitting visitor details: ', error); 15 | }, 16 | }); 17 | 18 | return { 19 | mutateAsync: mutation.mutateAsync, 20 | isPending: mutation.isPending, 21 | error: mutation.error, 22 | }; 23 | }; 24 | 25 | export default useVisitorSubmission; 26 | -------------------------------------------------------------------------------- /src/app/api/auth/verify/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { authService } from '@/app/api/_services/authService'; 3 | 4 | /** 5 | * GET /api/auth/verify?token=... or userId=... 6 | */ 7 | export async function GET(req: NextRequest) { 8 | try { 9 | const { searchParams } = new URL(req.url); 10 | const token = searchParams.get('token') || undefined; 11 | const userId = searchParams.get('userId') || undefined; 12 | 13 | const result = await authService.verifyUser(token, userId); 14 | 15 | return NextResponse.json({ message: result.message }, { status: result.statusCode }); 16 | } catch (err) { 17 | console.error('[verify] Error verifying email:', err); 18 | return NextResponse.json({ message: 'Internal server error' }, { status: 500 }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/shared/models/userModels.ts: -------------------------------------------------------------------------------- 1 | // =========== USER TYPE =========== 2 | 3 | export interface User { 4 | user_id: number; 5 | name: string; 6 | email: string; 7 | role: 'Administrator' | 'Member'; 8 | createdAt: string; 9 | // ... etc 10 | } 11 | 12 | // =========== VISITOR DETAIL =========== 13 | 14 | export interface Contact { 15 | id: number; 16 | name: string; // Combined first + last name 17 | email: string; // If LinkVisitors has an email field 18 | document_id: string; // The document_id from DB 19 | lastActivity: Date; //The date/time of their last activity 20 | lastViewedLink: string; //The last link or friendly name they viewed 21 | totalVisits: number; //Total visits for that email across the user's links 22 | downloads: number; 23 | duration: string; 24 | completion: string; 25 | } 26 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | import { Inter } from 'next/font/google'; 3 | import React from 'react'; 4 | import Providers from './providers'; 5 | import EnvironmentBadge from '@/components/common/EnvironmentBadge'; 6 | 7 | const inter = Inter({ subsets: ['latin'] }); 8 | 9 | export const metadata: Metadata = { 10 | title: 'Blw-Datahall', 11 | description: 'Share documents safely with your team and customers', 12 | }; 13 | 14 | interface RootLayoutProps { 15 | children: React.ReactNode; 16 | } 17 | 18 | export default function RootLayout({ children }: RootLayoutProps) { 19 | return ( 20 | 21 | 22 | 23 | {children} 24 | 25 | 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/app/team/components/FilterToggle.tsx: -------------------------------------------------------------------------------- 1 | import { ToggleButton, ToggleButtonGroup } from '@mui/material'; 2 | 3 | interface Props { 4 | currentFilter: 'All' | 'Administrator' | 'Member'; 5 | onFilterChange: (role: 'All' | 'Administrator' | 'Member') => void; 6 | } 7 | 8 | const FilterToggle = ({ currentFilter, onFilterChange }: Props) => ( 9 | { 13 | if (newRole !== null) { 14 | onFilterChange(newRole); 15 | } 16 | }} 17 | aria-label='Filter by role' 18 | size='small'> 19 | All 20 | Administrator 21 | Member 22 | 23 | ); 24 | 25 | export default FilterToggle; 26 | -------------------------------------------------------------------------------- /src/components/input/CustomCheckbox.tsx: -------------------------------------------------------------------------------- 1 | import { Checkbox, CheckboxProps, FormControlLabel } from '@mui/material'; 2 | import { SquareIcon, CheckSquareIcon } from '@/icons'; 3 | 4 | import { ChangeEvent } from 'react'; 5 | 6 | interface CustomCheckboxProps extends CheckboxProps { 7 | checked: boolean; 8 | onChange: (event: ChangeEvent) => void; 9 | label: string; 10 | name: string; 11 | } 12 | 13 | const CustomCheckbox = ({ checked, onChange, label, name, ...props }: CustomCheckboxProps) => ( 14 | } 18 | checkedIcon={} 19 | checked={checked} 20 | name={name} 21 | onChange={onChange} 22 | {...props} 23 | /> 24 | } 25 | label={label} 26 | /> 27 | ); 28 | 29 | export default CustomCheckbox; 30 | -------------------------------------------------------------------------------- /src/hooks/contacts/useFetchContacts.ts: -------------------------------------------------------------------------------- 1 | import { Contact } from '@/shared/models'; 2 | import { useQuery } from '@tanstack/react-query'; 3 | import axios from 'axios'; 4 | 5 | export default function useFetchContacts() { 6 | const fetchContacts = async (): Promise => { 7 | const response = await axios.get('/api/contacts'); 8 | return response.data.data; 9 | }; 10 | 11 | return useQuery({ 12 | queryKey: ['contacts'], // Caching key 13 | queryFn: fetchContacts, // Function to fetch data 14 | staleTime: 1000 * 30, // Data stays fresh for 30 seconds before being marked stale 15 | refetchInterval: 1000 * 60, // Background refetch every 60 seconds 16 | refetchOnWindowFocus: true, // Refetch when user focuses the window 17 | refetchOnReconnect: true, // Refetch when the user reconnects to the internet 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./src/*"], 22 | "@lib/*": ["./lib/*"] 23 | }, 24 | "target": "ES2017" 25 | }, 26 | "include": [ 27 | "next-env.d.ts", 28 | "**/*.ts", 29 | "**/*.tsx", 30 | ".next/types/**/*.ts", 31 | "customTypesTheme.d.ts", 32 | "src/app/register/page.jsx", 33 | "src/app/register/page.jsx" 34 | ], 35 | "exclude": ["node_modules"] 36 | } 37 | -------------------------------------------------------------------------------- /src/app/documents/[documentId]/components/FilterToggle.tsx: -------------------------------------------------------------------------------- 1 | import { ToggleButton, ToggleButtonGroup } from '@mui/material'; 2 | 3 | interface FilterToggleProps { 4 | currentFilter: 'fromStart' | 'last30Days' | 'last7Days'; 5 | onFilterChange: (period: 'fromStart' | 'last30Days' | 'last7Days') => void; 6 | } 7 | 8 | const FilterToggle = ({ currentFilter, onFilterChange }: FilterToggleProps) => ( 9 | { 13 | if (newPeriod !== null) { 14 | onFilterChange(newPeriod); 15 | } 16 | }} 17 | aria-label='Filter by period'> 18 | From start 19 | Last 30 days 20 | Last 7 days 21 | 22 | ); 23 | 24 | export default FilterToggle; 25 | -------------------------------------------------------------------------------- /src/app/contacts/components/ContactsTableRow.tsx: -------------------------------------------------------------------------------- 1 | import { TableCell, TableRow, Typography } from '@mui/material'; 2 | 3 | import { Contact } from '@/shared/models'; 4 | import { formatDateTime } from '@/shared/utils'; 5 | 6 | interface Props { 7 | contact: Contact; 8 | } 9 | 10 | export default function ContactsTableRow({ contact }: Props) { 11 | return ( 12 | 13 | 14 | {contact.name ? contact.name : 'N/A'} 15 |
16 | {contact.email ? contact.email : 'N/A'} 17 |
18 | {contact.lastViewedLink} 19 | 20 | {formatDateTime(contact.lastActivity, { includeTime: true })} 21 | 22 | {contact.totalVisits} 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/shared/models/documentModels.ts: -------------------------------------------------------------------------------- 1 | import { FileType } from '@/shared/config/fileIcons'; 2 | import { LinkDetail } from './linkModels'; 3 | 4 | // =========== DOCUMENT TYPE =========== 5 | 6 | export interface DocumentType { 7 | document_id: string; // The unique DB identifier (cuid) 8 | fileName: string; 9 | filePath: string; 10 | fileType: FileType; 11 | size: number; 12 | createdAt: string; // ISO string 13 | updatedAt: string; // ISO string 14 | uploader: { 15 | name: string; 16 | avatar: string | null; 17 | }; 18 | links: number; // The count of Link[] 19 | viewers: number; // The sum of all LinkVisitors for all links 20 | views: number; // Potential total doc views (0 if not tracked) 21 | createdLinks?: LinkDetail[]; // If you want to store link details 22 | } 23 | 24 | // ====== CHART TYPE ====== 25 | 26 | export interface BarDataItem { 27 | month: string; 28 | Views: number; 29 | Downloads: number; 30 | date: Date; 31 | } 32 | -------------------------------------------------------------------------------- /src/app/documentAccess/[linkId]/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Typography, Box, Link } from '@mui/material'; 3 | import { BlueWaveLogo } from '@/components'; 4 | 5 | interface RootLayoutProps { 6 | children: React.ReactNode; 7 | } 8 | 9 | export default function RootLayout({ children }: RootLayoutProps) { 10 | return ( 11 | 18 | 19 | 23 | 24 | {children} 25 | 29 | Need help? 30 | 33 | Contact Support 34 | 35 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/app/api/documents/[documentId]/links/[linkId]/route.ts: -------------------------------------------------------------------------------- 1 | import { authService, createErrorResponse, LinkService } from '@/app/api/_services'; 2 | import { NextRequest, NextResponse } from 'next/server'; 3 | 4 | /** 5 | * DELETE /api/documents/[documentId]/links/[documentLinkId] 6 | * Removes a link if the user owns it. 7 | */ 8 | export async function DELETE( 9 | req: NextRequest, 10 | props: { params: Promise<{ documentLinkId: string }> }, 11 | ) { 12 | try { 13 | const userId = await authService.authenticate(); 14 | const { documentLinkId } = await props.params; 15 | const deleted = await LinkService.deleteLink(userId, documentLinkId); 16 | 17 | if (!deleted) { 18 | return createErrorResponse('Link not found or access denied.', 404); 19 | } 20 | 21 | return NextResponse.json({ message: 'Link deleted successfully.' }, { status: 200 }); 22 | } catch (error) { 23 | return createErrorResponse('Server error while deleting link.', 500, error); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | // Ensures these server-only packages are included in the server bundle. 4 | serverExternalPackages: ['@prisma/client', 'bcrypt'], 5 | // Enables additional checks and warnings in development mode. 6 | reactStrictMode: false, 7 | 8 | webpack: (config) => { 9 | // Prevents Webpack from resolving the 'canvas' module. 10 | // This is useful when deploying to platforms like Vercel 11 | config.resolve.alias.canvas = false; 12 | 13 | return config; 14 | }, 15 | 16 | typescript: { 17 | // Ensures the build fails if there are TypeScript errors. 18 | ignoreBuildErrors: false, 19 | }, 20 | // Prepares the Next.js app for production with a self-contained output. 21 | // Useful for Docker deployments or when running the app outside of Vercel. 22 | // It bundles all necessary dependencies into a 'standalone' folder. 23 | output: 'standalone', 24 | }; 25 | 26 | export default nextConfig; 27 | -------------------------------------------------------------------------------- /src/components/common/EnvironmentBadge.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Box, Chip, Typography } from '@mui/material'; 4 | import { ChipProps } from '@mui/material'; 5 | 6 | const actualEnv = process.env.NEXT_PUBLIC_DEPLOYMENT_ENVIRONMENT ?? 'unknown'; 7 | 8 | const envMap: Record = { 9 | local: { label: 'Dev', color: 'warning' }, // green 10 | development: { label: 'Preview', color: 'success' }, // yellow 11 | production: { label: 'BETA', color: 'primary' }, // red 12 | }; 13 | 14 | const EnvironmentBadge = () => { 15 | const { label, color } = envMap[actualEnv] ?? { label: actualEnv, color: '#616161' }; 16 | 17 | return ( 18 | 31 | ); 32 | }; 33 | 34 | export default EnvironmentBadge; 35 | -------------------------------------------------------------------------------- /src/app/api/auth/password/reset/route.ts: -------------------------------------------------------------------------------- 1 | // src/app/api/auth/password/reset/route.ts 2 | import { authService } from '@/app/api/_services/authService'; 3 | import { NextRequest, NextResponse } from 'next/server'; 4 | 5 | /** 6 | * POST /api/auth/password/reset 7 | * Expects: { token, password } 8 | */ 9 | export async function POST(req: NextRequest) { 10 | try { 11 | const { token, password } = await req.json(); 12 | if (!token || !password) { 13 | return NextResponse.json( 14 | { message: 'Token and new password are required.' }, 15 | { status: 400 }, 16 | ); 17 | } 18 | 19 | const result = await authService.resetUserPassword(token, password); 20 | if (!result.success) { 21 | return NextResponse.json({ message: result.message }, { status: 400 }); 22 | } 23 | 24 | return NextResponse.json({ message: result.message }, { status: 200 }); 25 | } catch (error) { 26 | console.error('[reset] Error updating password:', error); 27 | return NextResponse.json({ message: 'Internal server error' }, { status: 500 }); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/app/documents/[documentId]/components/CustomBarChart.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { BarDataItem } from '@/shared/models'; 4 | 5 | import { BarChart } from '@mui/x-charts/BarChart'; 6 | 7 | interface CustomBarChartProps { 8 | data: { month: string; Views: number; Downloads: number; date: Date }[]; 9 | } 10 | 11 | export default function CustomBarChart({ data }: CustomBarChartProps) { 12 | const chartData = data; 13 | 14 | return ( 15 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/shared/utils/index.ts: -------------------------------------------------------------------------------- 1 | export type { FormatDateTimeOptions } from './dateUtils'; 2 | export { formatDateTime } from './dateUtils'; 3 | export { computeExpirationDays } from './dateUtils'; 4 | 5 | export type { FormatFileSizeOptions } from './fileUtils'; 6 | export { formatFileSize } from './fileUtils'; 7 | export { parseFileSize } from './fileUtils'; 8 | 9 | export { splitName } from './stringUtils'; 10 | export { convertTransparencyToHex } from './stringUtils'; 11 | export { sortFields } from './stringUtils'; 12 | 13 | export type { ValidationRule } from './validators'; 14 | export { requiredFieldRule } from './validators'; 15 | export { validEmailRule } from './validators'; 16 | export { validateEmails } from './validators'; 17 | export { validateEmailsRule } from './validators'; 18 | export { minLengthRule } from './validators'; 19 | export { hasSpecialCharRule } from './validators'; 20 | export { passwordValidationRule } from './validators'; 21 | export { confirmPasswordRule } from './validators'; 22 | export { getPasswordChecks } from './validators'; 23 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useFetchContacts } from './contacts/useFetchContacts'; 2 | 3 | export { default as useFetchDocuments } from './documents/useFetchDocuments'; 4 | export { default as useUploadDocument } from './documents/useUploadDocument'; 5 | export { default as useDeleteDocument } from './documents/useDeleteDocument'; 6 | 7 | export { default as useCreateLink } from './documents/useCreateLink'; 8 | 9 | export { default as useDocumentAnalytics } from './useDocumentAnalytics'; 10 | export { default as useDocumentData } from './useDocumentData'; 11 | export { default as useDocumentDetail } from './useDocumentDetail'; 12 | export { useFormSubmission } from './useFormSubmission'; 13 | export { useModal } from './useModal'; 14 | export { useSort } from './useSort'; 15 | export { useToast } from './useToast'; 16 | export { useValidatedFormData } from './useValidatedFormData'; 17 | export { default as useDocumentAccess } from './documentAccess/useDocumentAccess'; 18 | export { default as useVisitorSubmission } from './documentAccess/useVisitorSubmission'; 19 | -------------------------------------------------------------------------------- /src/components/navigation/NavLink.tsx: -------------------------------------------------------------------------------- 1 | import { Typography, TypographyProps } from '@mui/material'; 2 | import NextLink from 'next/link'; 3 | 4 | interface NavLinkProps extends TypographyProps { 5 | href: string; 6 | linkText: string; 7 | color?: string; 8 | prefetch?: boolean; 9 | } 10 | 11 | const NavLink = ({ 12 | href, 13 | linkText, 14 | color = 'text.brand', 15 | prefetch = false, 16 | ...props 17 | }: NavLinkProps) => { 18 | return ( 19 | 25 | 33 | 41 | {linkText} 42 | 43 | 44 | 45 | ); 46 | }; 47 | 48 | export default NavLink; 49 | -------------------------------------------------------------------------------- /src/app/api/profile/route.ts: -------------------------------------------------------------------------------- 1 | import { authService } from '../_services/authService'; 2 | import prisma from '@/lib/prisma'; 3 | import { NextRequest, NextResponse } from 'next/server'; 4 | 5 | export async function GET(req: NextRequest) { 6 | try { 7 | // Authenticate the user 8 | const userId = await authService.authenticate(); 9 | 10 | // Get the user’s info from the database 11 | const user = await prisma.user.findUnique({ 12 | where: { user_id: userId }, 13 | select: { 14 | email: true, 15 | first_name: true, 16 | last_name: true, 17 | }, 18 | }); 19 | 20 | if (!user) { 21 | return NextResponse.json({ error: 'User not found' }, { status: 404 }); 22 | } 23 | 24 | // Return the user’s info 25 | return NextResponse.json( 26 | { 27 | email: user.email, 28 | firstName: user.first_name, 29 | lastName: user.last_name, 30 | }, 31 | { status: 200 }, 32 | ); 33 | } catch (error) { 34 | console.error('Error fetching user info:', error); 35 | return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/shared/config/fileIcons.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PDFIcon, 3 | WordIcon, 4 | ExcelIcon, 5 | PPTIcon, 6 | ZIPIcon, 7 | TextIcon, 8 | ImageIcon, 9 | AudioIcon, 10 | VideoIcon, 11 | GeneralIcon, 12 | } from '@/icons'; 13 | 14 | // =========== ENUMS & CONFIGS =========== 15 | 16 | export const FileTypeConfig = { 17 | 'application/pdf': PDFIcon, 18 | 'application/msword': WordIcon, 19 | 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': WordIcon, 20 | 'application/vnd.ms-excel': ExcelIcon, 21 | 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ExcelIcon, 22 | 'application/vnd.ms-powerpoint': PPTIcon, 23 | 'application/vnd.openxmlformats-officedocument.presentationml.presentation': PPTIcon, 24 | 'application/zip': ZIPIcon, 25 | 'text/plain': TextIcon, 26 | 'image/png': ImageIcon, 27 | 'image/jpeg': ImageIcon, 28 | 'image/gif': ImageIcon, 29 | 'audio/mpeg': AudioIcon, 30 | 'audio/wav': AudioIcon, 31 | 'video/mp4': VideoIcon, 32 | 'video/x-msvideo': VideoIcon, 33 | General: GeneralIcon, 34 | } as const; 35 | 36 | export type FileType = keyof typeof FileTypeConfig; 37 | -------------------------------------------------------------------------------- /src/app/documentAccess/[linkId]/components/AccessError.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Box, Typography } from '@mui/material'; 4 | 5 | import { LinkBrokenIcon } from '@/icons'; 6 | 7 | interface AccessErrorProps { 8 | message: string; 9 | description?: string; 10 | } 11 | 12 | const AccessError: React.FC = (props) => { 13 | return ( 14 | 19 | 23 | 29 | 32 | {props.message} 33 | 34 | 35 | 36 | 37 | The link you used is no longer active. If you believe this is an error, please contact the 38 | document owner. 39 | 40 | 41 | 42 | ); 43 | }; 44 | 45 | export default AccessError; 46 | -------------------------------------------------------------------------------- /src/hooks/documents/useCreateLink.ts: -------------------------------------------------------------------------------- 1 | import { CreateDocumentLinkPayload, LinkType } from '@/shared/models'; 2 | import { useMutation, useQueryClient } from '@tanstack/react-query'; 3 | import axios from 'axios'; 4 | 5 | interface CreateLinkParams { 6 | documentId: string; 7 | payload: CreateDocumentLinkPayload; 8 | } 9 | 10 | interface DocumentLinkResponse { 11 | message: string; 12 | link: LinkType; 13 | } 14 | 15 | export default function useCreateLink() { 16 | const queryClient = useQueryClient(); 17 | 18 | const mutation = useMutation({ 19 | // Returns a promise that resolves to the response data, thus fixing the useFormSubmission issue 20 | mutationFn: async ({ 21 | documentId, 22 | payload, 23 | }: CreateLinkParams): Promise => { 24 | const response = await axios.post(`/api/documents/${documentId}/links`, payload); 25 | return response.data; 26 | }, 27 | onSuccess: () => { 28 | queryClient.invalidateQueries({ queryKey: ['links'] }); 29 | }, 30 | }); 31 | 32 | return { 33 | mutateAsync: mutation.mutateAsync, 34 | isPending: mutation.isPending, 35 | error: mutation.error, 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // General Editor Settings 3 | "editor.formatOnSave": true, 4 | "prettier.requireConfig": true, 5 | "prettier.configPath": "./.prettierrc", 6 | // Default Formatters for Supported Languages 7 | "[typescript]": { 8 | "editor.defaultFormatter": "esbenp.prettier-vscode" 9 | }, 10 | "[typescriptreact]": { 11 | "editor.defaultFormatter": "esbenp.prettier-vscode" 12 | }, 13 | "[javascript]": { 14 | "editor.defaultFormatter": "esbenp.prettier-vscode" 15 | }, 16 | "[html]": { 17 | "editor.defaultFormatter": "esbenp.prettier-vscode" 18 | }, 19 | "[scss]": { 20 | "editor.defaultFormatter": "esbenp.prettier-vscode" 21 | }, 22 | "[css]": { 23 | "editor.defaultFormatter": "esbenp.prettier-vscode" 24 | }, 25 | "search.exclude": { 26 | "node_modules": true, 27 | "dist": true, 28 | "build": true, 29 | ".next": true 30 | }, 31 | // Disable Automatic Formatting in Large Files 32 | "editor.largeFileOptimizations": true, 33 | // Trim Trailing Whitespace on Save 34 | "files.trimTrailingWhitespace": true, 35 | } -------------------------------------------------------------------------------- /src/app/api/profile/changeName/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse, NextRequest } from 'next/server'; 2 | import prisma from '@/lib/prisma'; 3 | 4 | export async function POST(req: NextRequest) { 5 | try { 6 | const { email, firstName, lastName } = await req.json(); 7 | 8 | // Check if the user exists 9 | const user = await prisma.user.findUnique({ where: { email } }); 10 | 11 | if (!user) { 12 | return NextResponse.json({ error: 'User not found' }, { status: 404 }); 13 | } 14 | 15 | const updateData: { first_name?: string; last_name?: string } = {}; 16 | if (firstName) { 17 | updateData.first_name = firstName; 18 | updateData.last_name = lastName; 19 | } 20 | 21 | if (Object.keys(updateData).length > 0) { 22 | await prisma.user.update({ 23 | where: { email }, 24 | data: { first_name: firstName, last_name: lastName }, 25 | }); 26 | } 27 | 28 | return NextResponse.json({ message: 'Name changed successfully' }, { status: 200 }); 29 | } catch (error) { 30 | console.error('Error updating name', error); 31 | return NextResponse.json( 32 | { error: 'An error occurred while updating the name' }, 33 | { status: 500 }, 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/app/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Typography } from '@mui/material'; 2 | import Link from 'next/link'; 3 | 4 | import { AlertCircleIcon } from '@/icons'; 5 | 6 | export default function NotFound() { 7 | return ( 8 | 22 | 23 | 27 | 28 | 29 | 32 | Oops! Page Not Found 33 | 34 | 37 | The page you’re looking for doesn’t exist. It might have been removed, or the URL might be 38 | incorrect. 39 | 40 | 43 | 48 | 49 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/app/auth/check-email/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { Box, Typography, Button } from '@mui/material'; 3 | import { useRouter } from 'next/navigation'; 4 | import AuthFormWrapper from '../components/AuthFormWrapper'; 5 | import { BlueWaveLogo } from '@/components'; 6 | 7 | export default function VerificationSent() { 8 | const router = useRouter(); 9 | 10 | const handleGoToSignIn = () => { 11 | router.push('/auth/sign-in'); // Navigate to sign-in page 12 | }; 13 | 14 | return ( 15 | 16 | 17 | 21 | 22 | 23 | 26 | 📬 Check Your Inbox! 27 | 28 | 29 | 33 | We've sent you an email with a verification link. Click the link to complete your 34 | registration. If you don't see it in your inbox, please check your spam or junk folder. 35 | 36 | 37 | 44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/app/api/profile/changePassword/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse, NextRequest } from 'next/server'; 2 | import prisma from '@/lib/prisma'; 3 | import bcryptjs from 'bcryptjs'; 4 | 5 | export async function POST(req: NextRequest) { 6 | try { 7 | const { email, currentPassword, newPassword } = await req.json(); 8 | const user = await prisma.user.findUnique({ 9 | where: { email }, 10 | }); 11 | 12 | // Check if the user exists 13 | if (!user) { 14 | return NextResponse.json({ error: 'User not found' }, { status: 404 }); 15 | } 16 | 17 | const isPasswordValid = await bcryptjs.compare(currentPassword, user.password!); 18 | if (!isPasswordValid) { 19 | return NextResponse.json({ error: 'Current password is incorrect' }, { status: 401 }); 20 | } 21 | const hashedPassword = await bcryptjs.hash(newPassword, 10); 22 | 23 | await prisma.user.update({ 24 | where: { email }, 25 | data: { password: hashedPassword }, 26 | }); 27 | 28 | return NextResponse.json({ message: 'Password updated successfully' }, { status: 200 }); 29 | } catch (error) { 30 | console.error('Error updating password:', error); 31 | return NextResponse.json( 32 | { error: 'An error occurred while updating the password' }, 33 | { status: 500 }, 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Breadcrumb } from './common/Breadcrumb'; 2 | export { default as Toast } from './common/Toast'; 3 | 4 | export { default as CustomUploader } from './fileHandling/CustomUploader'; 5 | export { default as PDFViewer } from './fileHandling/PDFViewer'; 6 | 7 | export { default as CustomCheckbox } from './input/CustomCheckbox'; 8 | export { default as Dropdown } from './input/Dropdown'; 9 | export { default as FormInput } from './input/FormInput'; 10 | export { default as PasswordValidation } from './input/PasswordValidation'; 11 | 12 | export { default as BlueWaveLogo } from './layout/BlueWaveLogo'; 13 | export { default as DropdownMenu } from './layout/DropdownMenu'; 14 | export { default as Sidebar } from './layout/Sidebar'; 15 | 16 | export { default as CustomCircularProgress } from './loaders/CustomCircularProgress'; 17 | export { default as EmptyState } from './loaders/EmptyState'; 18 | export { default as LoadingButton } from './loaders/LoadingButton'; 19 | export { default as LoadingSpinner } from './loaders/LoadingSpinner'; 20 | 21 | export { default as ModalWrapper } from './modals/ModalWrapper'; 22 | 23 | export { default as NavLink } from './navigation/NavLink'; 24 | export { default as Paginator } from './navigation/Paginator'; 25 | -------------------------------------------------------------------------------- /src/components/loaders/LoadingButton.tsx: -------------------------------------------------------------------------------- 1 | // components/LoadingButton.tsx 2 | import { Button, CircularProgress, ButtonProps } from '@mui/material'; 3 | import { FormEvent } from 'react'; 4 | 5 | interface LoadingButtonProps extends ButtonProps { 6 | loading: boolean; 7 | buttonText: string; 8 | loadingText?: string; 9 | fullWidth?: boolean; 10 | onClick?: (event: FormEvent) => void; 11 | type?: 'button' | 'submit' | 'reset'; 12 | variant?: 'text' | 'outlined' | 'contained'; 13 | color?: 'primary' | 'secondary' | 'error'; 14 | } 15 | 16 | const LoadingButton = ({ 17 | loading, 18 | buttonText, 19 | loadingText = 'Loading...', 20 | fullWidth = true, 21 | onClick, 22 | type = 'submit', 23 | variant = 'contained', 24 | color = 'primary', 25 | ...props 26 | }: LoadingButtonProps) => { 27 | return ( 28 | 46 | ); 47 | }; 48 | 49 | export default LoadingButton; 50 | -------------------------------------------------------------------------------- /src/app/documents/components/CustomAccordion.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Accordion, 3 | AccordionDetails, 4 | AccordionProps, 5 | AccordionSummary, 6 | Typography, 7 | } from '@mui/material'; 8 | 9 | import { ChevronRightIcon } from '@/icons'; 10 | 11 | interface CustomAccordionProps extends AccordionProps { 12 | title: string; 13 | } 14 | 15 | const CustomAccordion = ({ title, children, ...props }: CustomAccordionProps) => { 16 | return ( 17 | 20 | }> 33 | 38 | {title} 39 | 40 | 41 | {children} 42 | 43 | ); 44 | }; 45 | 46 | export default CustomAccordion; 47 | -------------------------------------------------------------------------------- /src/app/documents/components/DocumentsTableHeader.tsx: -------------------------------------------------------------------------------- 1 | import { TableCell, TableRow, TableSortLabel } from '@mui/material'; 2 | 3 | import { DocumentType } from '@/shared/models'; 4 | 5 | import { ChevronDownIcon, ChevronSelectorVerticalIcon } from '@/icons'; 6 | 7 | interface Props { 8 | orderBy: keyof DocumentType | undefined; 9 | orderDirection: 'asc' | 'desc' | undefined; 10 | onSort: (property: keyof DocumentType) => void; 11 | } 12 | 13 | const DocumentsTableHeader = ({ orderBy, orderDirection, onSort }: Props) => ( 14 | 15 | 16 | DOCUMENT 17 | 18 | onSort('uploader')} 22 | hideSortIcon={false} 23 | IconComponent={ 24 | orderDirection === undefined ? ChevronSelectorVerticalIcon : ChevronDownIcon 25 | }> 26 | UPLOADER 27 | 28 | 29 | ANALYTICS 30 | LINK 31 | ACTION 32 | 33 | ); 34 | 35 | export default DocumentsTableHeader; 36 | -------------------------------------------------------------------------------- /src/app/documentAccess/[linkId]/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { use } from 'react'; 4 | 5 | import { Box, Button, Typography } from '@mui/material'; 6 | import AccessPage from './components/AccessPage'; 7 | 8 | const LinkIdPage = ({ params }: { params: Promise<{ linkId: string }> }) => { 9 | const { linkId } = use(params); 10 | 11 | const [showFileAccess, setShowFileAccess] = React.useState(false); 12 | 13 | const handleConfirmClick = () => { 14 | setShowFileAccess(true); 15 | }; 16 | 17 | return ( 18 | <> 19 | {!showFileAccess ? ( 20 | 27 | 28 | 31 | A secure file has been shared with you 32 | 33 | 34 | Please confirm your identity to access this document 35 | 36 | 37 | 42 | 43 | ) : ( 44 | 45 | )} 46 | 47 | ); 48 | }; 49 | 50 | export default LinkIdPage; 51 | -------------------------------------------------------------------------------- /src/app/providers.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { SessionProvider } from 'next-auth/react'; 3 | import { useEffect, useState } from 'react'; 4 | 5 | import { CssBaseline, ThemeProvider } from '@mui/material'; 6 | import { AppRouterCacheProvider } from '@mui/material-nextjs/v13-appRouter'; 7 | 8 | import { LoadingSpinner } from '@/components'; 9 | import AuthWrapper from '@/providers/auth/AuthWrapper'; 10 | import { ToastProvider } from '@/providers/toast/ToastProvider'; 11 | import QueryProvider from '@/providers/query/QueryProvider'; 12 | 13 | import globalTheme from '@/theme/globalTheme'; 14 | 15 | export default function Providers({ children }: { children: React.ReactNode }) { 16 | const [isHydrated, setIsHydrated] = useState(false); 17 | 18 | useEffect(() => { 19 | setIsHydrated(true); 20 | }, []); 21 | 22 | if (!isHydrated) { 23 | // Show a loading spinner while the client-side is hydrating 24 | return ; 25 | } 26 | 27 | return ( 28 | 29 | 30 | 31 | 32 | 33 | 34 | {children} 35 | 36 | 37 | 38 | 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/theme/customTypesTheme.d.ts: -------------------------------------------------------------------------------- 1 | import '@mui/material/styles'; 2 | 3 | declare module '@mui/material' { 4 | interface Palette { 5 | border: { 6 | light: string; 7 | dark: string; 8 | }; 9 | } 10 | 11 | interface SimplePaletteColorOptions { 12 | text?: string; 13 | } 14 | 15 | interface PaletteOptions { 16 | border?: { 17 | light: string; 18 | dark: string; 19 | }; 20 | } 21 | 22 | interface ThemeOptions { 23 | shape?: { 24 | borderRadius: number; 25 | borderThick?: number; 26 | boxShadow?: string; 27 | }; 28 | customShadows?: { 29 | menu?: string; 30 | }; 31 | } 32 | } 33 | 34 | declare module '@mui/material/styles' { 35 | interface TypeBackground { 36 | content: string; 37 | alt: string; 38 | brand: string; 39 | fill: string; 40 | error: string; 41 | } 42 | 43 | interface Theme { 44 | customShadows: { 45 | menu: string; 46 | }; 47 | } 48 | 49 | // Extend the Variant type 50 | interface TypographyVariants { 51 | subtitle3: React.CSSProperties; 52 | } 53 | 54 | // Extend the Variant type for theme options 55 | interface TypographyVariantsOptions { 56 | subtitle3?: React.CSSProperties; 57 | } 58 | } 59 | 60 | // For components using Typography 61 | declare module '@mui/material/Typography' { 62 | interface TypographyPropsVariantOverrides { 63 | subtitle3: true; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/hooks/useDocumentDetail.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { useState, useEffect, useCallback } from 'react'; 3 | 4 | import { DocumentType } from '@/shared/models'; 5 | 6 | interface UseDocumentDetailReturn { 7 | document: DocumentType | null; 8 | isLoading: boolean; 9 | error: string | null; 10 | refetch: () => void; 11 | } 12 | 13 | export default function useDocumentDetail(documentId: string): UseDocumentDetailReturn { 14 | const [document, setDocument] = useState(null); 15 | const [isLoading, setIsLoading] = useState(true); 16 | const [error, setError] = useState(null); 17 | 18 | const fetchDocumentDetails = useCallback(async () => { 19 | if (!documentId) { 20 | setError('No document ID provided.'); 21 | setIsLoading(false); 22 | return; 23 | } 24 | try { 25 | setIsLoading(true); 26 | setError(null); 27 | 28 | const response = await axios.get(`/api/documents/${documentId}`); 29 | setDocument(response.data.document); 30 | } catch (err: any) { 31 | setError(err.response?.data?.error || 'Error fetching document details'); 32 | } finally { 33 | setIsLoading(false); 34 | } 35 | }, [documentId]); 36 | 37 | useEffect(() => { 38 | fetchDocumentDetails(); 39 | }, [fetchDocumentDetails]); 40 | 41 | return { document, isLoading, error, refetch: fetchDocumentDetails }; 42 | } 43 | -------------------------------------------------------------------------------- /src/app/api/documents/[documentId]/visitors/route.ts: -------------------------------------------------------------------------------- 1 | import { authService, DocumentService, createErrorResponse } from '@/app/api/_services'; 2 | import { NextRequest, NextResponse } from 'next/server'; 3 | 4 | /** 5 | * GET /api/documents/[documentId]/visitors 6 | * Lists visitors across all links for a doc. 7 | */ 8 | export async function GET(req: NextRequest, props: { params: Promise<{ documentId: string }> }) { 9 | try { 10 | const userId = await authService.authenticate(); 11 | const { documentId } = await props.params; 12 | const linkVisitors = await DocumentService.getDocumentVisitors(userId, documentId); 13 | if (linkVisitors === null) { 14 | return createErrorResponse('Document not found or access denied.', 404); 15 | } 16 | 17 | if (linkVisitors.length === 0) { 18 | return NextResponse.json({ visitors: [] }, { status: 200 }); 19 | } 20 | 21 | const visitors = linkVisitors.map((visitor) => ({ 22 | id: visitor.id, 23 | documentId: documentId, 24 | name: `${visitor.firstName} ${visitor.lastName}`.trim(), 25 | email: visitor.email, 26 | lastActivity: visitor.updatedAt, 27 | downloads: 0, 28 | duration: 0, 29 | completion: 0, 30 | })); 31 | 32 | return NextResponse.json({ visitors }, { status: 200 }); 33 | } catch (error) { 34 | return createErrorResponse('Server error while fetching visitors.', 500, error); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/icons/charts/BarChartIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface BarChartIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | } 8 | 9 | /** 10 | * A reusable SVG icon component for rendering an icon. 11 | * 12 | * @param {number} [width=14] - The width of the icon in pixels. Optional. 13 | * @param {number} [height=14] - The height of the icon in pixels. Optional. 14 | * @param {string} [color='#939393'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 15 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 16 | * 17 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 18 | */ 19 | 20 | const BarChartIcon: FC = ({ 21 | width = 14, 22 | height = 14, 23 | color = '#939393', 24 | ...props 25 | }) => { 26 | return ( 27 | 36 | 42 | 43 | ); 44 | }; 45 | 46 | export default BarChartIcon; 47 | -------------------------------------------------------------------------------- /src/icons/shapes/SquareIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface SquareIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | } 8 | 9 | /** 10 | * A reusable SVG icon component for rendering an icon. 11 | * 12 | * @param {number} [width=20] - The width of the icon in pixels. Optional. 13 | * @param {number} [height=20] - The height of the icon in pixels. Optional. 14 | * @param {string} [color='#98A2B3'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 15 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 16 | * 17 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 18 | */ 19 | 20 | const SquareIcon: FC = ({ 21 | width = 20, 22 | height = 20, 23 | color = '#98A2B3', 24 | ...props 25 | }) => { 26 | return ( 27 | 36 | 40 | 41 | ); 42 | }; 43 | 44 | export default SquareIcon; 45 | -------------------------------------------------------------------------------- /src/app/api/public_links/[linkId]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { LinkService, createErrorResponse } from '@/app/api/_services'; 3 | 4 | /** 5 | * GET /api/public_links/[linkId] 6 | */ 7 | export async function GET(req: NextRequest, props: { params: Promise<{ linkId: string }> }) { 8 | try { 9 | const { linkId } = await props.params; 10 | if (!linkId) { 11 | return createErrorResponse('Link ID is required.', 400); 12 | } 13 | 14 | const link = await LinkService.getPublicLink(linkId); 15 | console.log('🚀 ~ GET ~ link:', link); 16 | console.log('Link ID:', linkId); 17 | if (!link) { 18 | console.log(`Link not found: ${linkId}`); 19 | return NextResponse.json({ message: 'Link not found' }, { status: 404 }); 20 | } 21 | 22 | // Check expiration 23 | if (link.expirationTime && new Date(link.expirationTime) <= new Date()) { 24 | return NextResponse.json({ message: 'Link is expired' }, { status: 410 }); 25 | } 26 | 27 | // If link is public, we see if it requires user details or password 28 | const isPasswordProtected = !!link.password; 29 | const visitorFields = link.visitorFields; 30 | 31 | return NextResponse.json( 32 | { 33 | message: 'Link is valid', 34 | data: { 35 | isPasswordProtected, 36 | visitorFields, 37 | }, 38 | }, 39 | { status: 200 }, 40 | ); 41 | } catch (error) { 42 | return createErrorResponse('Server error while fetching link.', 500, error); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/icons/arrows/ArrowNarrowLeftIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface ArrowNarrowLeftIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | } 8 | 9 | /** 10 | * A reusable SVG icon component for rendering an icon. 11 | * 12 | * @param {number} [width=14] - The width of the icon in pixels. Optional. 13 | * @param {number} [height=14] - The height of the icon in pixels. Optional. 14 | * @param {string} [color='#667085'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 15 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 16 | * 17 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 18 | */ 19 | 20 | const ArrowNarrowLeftIcon: FC = ({ 21 | width = 14, 22 | height = 14, 23 | color = '#667085', 24 | ...props 25 | }) => { 26 | return ( 27 | 36 | 42 | 43 | ); 44 | }; 45 | 46 | export default ArrowNarrowLeftIcon; 47 | -------------------------------------------------------------------------------- /src/icons/arrows/ArrowNarrowRightIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface ArrowNarrowRightIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | } 8 | 9 | /** 10 | * A reusable SVG icon component for rendering an icon. 11 | * 12 | * @param {number} [width=14] - The width of the icon in pixels. Optional. 13 | * @param {number} [height=14] - The height of the icon in pixels. Optional. 14 | * @param {string} [color='#667085'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 15 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 16 | * 17 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 18 | */ 19 | 20 | const ArrowNarrowRightIcon: FC = ({ 21 | width = 14, 22 | height = 14, 23 | color = '#667085', 24 | ...props 25 | }) => { 26 | return ( 27 | 36 | 42 | 43 | ); 44 | }; 45 | 46 | export default ArrowNarrowRightIcon; 47 | -------------------------------------------------------------------------------- /src/components/loaders/CustomCircularProgress.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Box, CircularProgress, CircularProgressProps, Typography } from '@mui/material'; 4 | import { useEffect } from 'react'; 5 | 6 | const CircularProgressWithLabel = (props: CircularProgressProps & { value: number }) => { 7 | return ( 8 | 9 | 13 | 24 | {`${Math.round(props.value)}%`} 28 | 29 | 30 | ); 31 | }; 32 | 33 | interface CustomCircularProgressProps { 34 | progress: number; 35 | handleProgress: React.Dispatch>; 36 | } 37 | 38 | const CustomCircularProgress = ({ progress, handleProgress }: CustomCircularProgressProps) => { 39 | useEffect(() => { 40 | const timer = setInterval(() => { 41 | handleProgress((prevProgress) => (prevProgress >= 100 ? 0 : prevProgress + 10)); 42 | }, 800); 43 | return () => { 44 | clearInterval(timer); 45 | }; 46 | }, [handleProgress]); 47 | 48 | return ; 49 | }; 50 | 51 | export default CustomCircularProgress; 52 | -------------------------------------------------------------------------------- /src/icons/general/CheckIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface CheckIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | strokeWidth?: number; 8 | } 9 | 10 | /** 11 | * A reusable SVG icon component for rendering an icon. 12 | * 13 | * @param {number} [width=18] - The width of the icon in pixels. Optional. 14 | * @param {number} [height=15] - The height of the icon in pixels. Optional. 15 | * @param {string} [color='#667085'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 16 | * @param {number} [strokeWidth=1] - The stroke width of the icon's path. Optional. 17 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 18 | * 19 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 20 | */ 21 | 22 | const CheckIcon: FC = ({ 23 | width = 18, 24 | height = 15, 25 | color = '#667085', 26 | strokeWidth = 1, 27 | ...props 28 | }) => { 29 | return ( 30 | 39 | 46 | 47 | ); 48 | }; 49 | 50 | export default CheckIcon; 51 | -------------------------------------------------------------------------------- /src/components/input/Dropdown.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { MenuItem, Select, SelectChangeEvent, SelectProps } from '@mui/material'; 4 | import { useState } from 'react'; 5 | 6 | interface Props { 7 | options: { value: string; label: string }[]; 8 | initialValue: string; 9 | onValueChange?: (newValue: string) => void; 10 | isSelectFullWidth?: boolean; 11 | px?: number; 12 | py?: number; 13 | minWidth?: number; 14 | variant?: SelectProps['variant']; 15 | } 16 | 17 | const Dropdown = ({ 18 | options, 19 | initialValue, 20 | onValueChange, 21 | variant, 22 | isSelectFullWidth = false, 23 | minWidth = 195, 24 | px = 8, 25 | py = 4, 26 | }: Props) => { 27 | const [value, setValue] = useState(initialValue); 28 | 29 | const handleChange = (event: SelectChangeEvent) => { 30 | const newValue = event.target.value as string; 31 | setValue(newValue); 32 | 33 | if (onValueChange) { 34 | onValueChange(newValue); 35 | } 36 | }; 37 | 38 | return ( 39 | 60 | ); 61 | }; 62 | 63 | export default Dropdown; 64 | -------------------------------------------------------------------------------- /src/icons/general/XCloseIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface XCloseIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | strokeWidth?: number; 8 | } 9 | 10 | /** 11 | * A reusable SVG icon component for rendering an icon. 12 | * 13 | * @param {number} [width=36] - The width of the icon in pixels. Optional. 14 | * @param {number} [height=36] - The height of the icon in pixels. Optional. 15 | * @param {string} [color='#98A2B3'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 16 | * @param {number} [strokeWidth=1.66667] - The stroke width of the icon's path. Optional. 17 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 18 | * 19 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 20 | */ 21 | 22 | const XCloseIcon: FC = ({ 23 | width = 36, 24 | height = 36, 25 | color = '#98A2B3', 26 | strokeWidth = 1.66667, 27 | ...props 28 | }) => { 29 | return ( 30 | 39 | 46 | 47 | ); 48 | }; 49 | 50 | export default XCloseIcon; 51 | -------------------------------------------------------------------------------- /prisma/seed.ts: -------------------------------------------------------------------------------- 1 | // import { PrismaClient } from '@prisma/client'; 2 | 3 | // const prisma = new PrismaClient(); 4 | 5 | // import { getLinkData } from '../src/seed/link'; 6 | // import { getLinkVisitorData } from '../src/seed/linkVisitor'; 7 | // import { getDocumentData } from '../src/seed/document'; 8 | // import { getUserData } from '../src/seed/user'; 9 | 10 | // async function main() { 11 | // console.log('Seeding database...'); 12 | 13 | // // 1) Seed USERS 14 | // const usersData = await getUserData(); 15 | // await prisma.user.createMany({ data: usersData }); 16 | // console.log(`Seeded ${usersData.length} users.`); 17 | 18 | // // 2) Seed DOCUMENTS 19 | // const documentsData = getDocumentData(); 20 | // await prisma.document.createMany({ data: documentsData }); 21 | // console.log(`Seeded ${documentsData.length} documents.`); 22 | 23 | // // 3) Seed LINKS 24 | // const linksData = getLinkData(); 25 | // await prisma.link.createMany({ data: linksData }); 26 | // console.log(`Seeded ${linksData.length} links.`); 27 | 28 | // // 4) Seed LINK VISITORS 29 | // const linkVisitorsData = getLinkVisitorData(); 30 | // await prisma.linkVisitors.createMany({ data: linkVisitorsData }); 31 | // console.log(`Seeded ${linkVisitorsData.length} link visitors.`); 32 | 33 | // console.log('Seeding completed successfully.'); 34 | // } 35 | 36 | // main() 37 | // .catch((error) => { 38 | // console.error('Error during seeding:', error); 39 | // process.exit(1); 40 | // }) 41 | // .finally(async () => { 42 | // await prisma.$disconnect(); 43 | // }); 44 | -------------------------------------------------------------------------------- /src/icons/arrows/ChevronUpIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface ChevronUpIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | strokeWidth?: number; 8 | } 9 | 10 | /** 11 | * A reusable SVG icon component for rendering an icon. 12 | * 13 | * @param {number} [width=20] - The width of the icon in pixels. Optional. 14 | * @param {number} [height=20] - The height of the icon in pixels. Optional. 15 | * @param {string} [color='#667085'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 16 | * @param {number} [strokeWidth=1.66667] - The stroke width of the icon's path. Optional. 17 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 18 | * 19 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 20 | */ 21 | 22 | const ChevronUpIcon: FC = ({ 23 | width = 20, 24 | height = 20, 25 | color = '#667085', 26 | strokeWidth = 1.66667, 27 | ...props 28 | }) => { 29 | return ( 30 | 39 | 46 | 47 | ); 48 | }; 49 | 50 | export default ChevronUpIcon; 51 | -------------------------------------------------------------------------------- /src/icons/arrows/ChevronDownIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface ChevronDownIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | strokeWidth?: number; 8 | } 9 | 10 | /** 11 | * A reusable SVG icon component for rendering an icon. 12 | * 13 | * @param {number} [width=15] - The width of the icon in pixels. Optional. 14 | * @param {number} [height=15] - The height of the icon in pixels. Optional. 15 | * @param {string} [color='#9A9B9B'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 16 | * @param {number} [strokeWidth=1.66667] - The stroke width of the icon's path. Optional. 17 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 18 | * 19 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 20 | */ 21 | 22 | const ChevronDownIcon: FC = ({ 23 | width = 15, 24 | height = 15, 25 | color = '#9A9B9B', 26 | strokeWidth = 1.66667, 27 | ...props 28 | }) => { 29 | return ( 30 | 39 | 46 | 47 | ); 48 | }; 49 | 50 | export default ChevronDownIcon; 51 | -------------------------------------------------------------------------------- /src/icons/arrows/ChevronRightIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface ChevronRightIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | strokeWidth?: number; 8 | } 9 | 10 | /** 11 | * A reusable SVG icon component for rendering an icon. 12 | * 13 | * @param {number} [width=20] - The width of the icon in pixels. Optional. 14 | * @param {number} [height=20] - The height of the icon in pixels. Optional. 15 | * @param {string} [color='#667085'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 16 | * @param {number} [strokeWidth=1.66667] - The stroke width of the icon's path. Optional. 17 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 18 | * 19 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 20 | */ 21 | 22 | const ChevronRightIcon: FC = ({ 23 | width = 20, 24 | height = 20, 25 | color = '#667085', 26 | strokeWidth = 1.66667, 27 | ...props 28 | }) => { 29 | return ( 30 | 39 | 46 | 47 | ); 48 | }; 49 | 50 | export default ChevronRightIcon; 51 | -------------------------------------------------------------------------------- /src/hooks/useDocumentData.ts: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import axios from 'axios'; 4 | import { useCallback, useEffect, useState } from 'react'; 5 | 6 | import { Contact, LinkDetail } from '@/shared/models'; 7 | 8 | interface UseDocumentDataReturn { 9 | data: LinkDetail[] | Contact[]; 10 | loading: boolean; 11 | error: string | null; 12 | refetch: () => void; 13 | } 14 | 15 | export default function useDocumentData( 16 | documentId: string, 17 | variant: 'linkTable' | 'visitorTable', 18 | ): UseDocumentDataReturn { 19 | const [data, setData] = useState([]); 20 | const [loading, setLoading] = useState(true); 21 | const [error, setError] = useState(null); 22 | 23 | const fetchData = useCallback(async () => { 24 | if (!documentId) { 25 | setError('No document ID provided'); 26 | setLoading(false); 27 | return; 28 | } 29 | try { 30 | setLoading(true); 31 | setError(null); 32 | 33 | const url = 34 | variant === 'linkTable' 35 | ? `/api/documents/${documentId}/links` 36 | : `/api/documents/${documentId}/visitors`; 37 | 38 | const res = await axios.get(url); 39 | if (variant === 'linkTable') { 40 | setData(res.data.links || []); 41 | } else { 42 | setData(res.data.visitors || []); 43 | } 44 | } catch (err: any) { 45 | setError(err.response?.data?.error || 'Error fetching data'); 46 | } finally { 47 | setLoading(false); 48 | } 49 | }, [documentId, variant]); 50 | 51 | useEffect(() => { 52 | fetchData(); 53 | }, [fetchData]); 54 | 55 | return { data, loading, error, refetch: fetchData }; 56 | } 57 | -------------------------------------------------------------------------------- /src/icons/alerts/AlertCircleIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface AlertCircleIconProps extends SVGProps { 4 | color?: string; 5 | strokeWidth?: number; 6 | } 7 | 8 | /** 9 | * A reusable SVG icon component for rendering an icon. 10 | * 11 | * @param {number} [width=80] - The width of the icon in pixels. Optional. 12 | * @param {number} [height=80] - The height of the icon in pixels. Optional. 13 | * @param {string} [color='#db504a'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 14 | * @param {number} [strokeWidth=2] - The stroke width of the icon's path. Optional. 15 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 16 | * 17 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 18 | */ 19 | 20 | const AlertCircleIcon: FC = ({ 21 | width = 80, 22 | height = 80, 23 | color = '#db504a', 24 | strokeWidth = 2, 25 | ...props 26 | }) => { 27 | return ( 28 | 37 | 44 | 45 | ); 46 | }; 47 | 48 | export default AlertCircleIcon; 49 | -------------------------------------------------------------------------------- /src/app/api/public_links/[linkId]/access/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | 3 | import { createErrorResponse, LinkService } from '@/app/api/_services'; 4 | 5 | /** 6 | * POST /api/public_links/[linkId]/access 7 | */ 8 | export async function POST(req: NextRequest, props: { params: Promise<{ linkId: string }> }) { 9 | try { 10 | const { linkId } = await props.params; 11 | const { firstName, lastName, email, password } = await req.json(); 12 | 13 | // 1) Retrieve link 14 | const link = await LinkService.getPublicLink(linkId); 15 | if (!link) { 16 | return createErrorResponse('Link not found', 404); 17 | } 18 | 19 | // 2) Check expiration 20 | if (link.expirationTime && new Date(link.expirationTime) <= new Date()) { 21 | return createErrorResponse('Link is expired', 410); 22 | } 23 | 24 | // 3) If password is required, verify 25 | const passwordOk = await LinkService.verifyLinkPassword(link, password); 26 | if (!passwordOk) { 27 | return createErrorResponse('Invalid password', 401); 28 | } 29 | 30 | // 4) Log visitor. 31 | await LinkService.logVisitor(linkId, firstName, lastName, email); 32 | 33 | // 5) Get a signed URL for the doc 34 | try { 35 | const { fileName, signedUrl, size } = await LinkService.getSignedFileFromLink(linkId); 36 | return NextResponse.json({ 37 | message: 'File access granted', 38 | data: { signedUrl, fileName, size }, 39 | }); 40 | } catch (err) { 41 | return createErrorResponse('Error retrieving file', 400, err); 42 | } 43 | } catch (error) { 44 | return createErrorResponse('Server error while accessing link', 500, error); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /scripts/prepare-env.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const dotenv = require('dotenv'); 4 | 5 | const cliArg = process.argv.find((arg) => arg.startsWith('--env=')); 6 | const cliEnv = cliArg ? cliArg.split('=')[1] : null; 7 | 8 | const DEPLOYMENT_ENV = cliEnv || process.env.DEPLOYMENT_ENVIRONMENT || 'local'; 9 | 10 | const rootDir = path.resolve(__dirname, '..'); 11 | const envDir = path.join(rootDir, 'env'); 12 | const outputEnvPath = path.join(rootDir, '.env'); 13 | 14 | function loadEnv(filePath) { 15 | if (!fs.existsSync(filePath)) return {}; 16 | const result = dotenv.parse(fs.readFileSync(filePath)); 17 | return result; 18 | } 19 | 20 | function mergeEnvs(...envObjects) { 21 | return Object.assign({}, ...envObjects); 22 | } 23 | 24 | function writeEnvFile(envObj, targetPath) { 25 | const contents = Object.entries(envObj) 26 | .map(([key, val]) => `${key}="${val.replace(/"/g, '\\"')}"`) 27 | .join('\n'); 28 | fs.writeFileSync(targetPath, contents); 29 | console.log(`✅ Generated .env from DEPLOYMENT_ENVIRONMENT=${DEPLOYMENT_ENV}`); 30 | } 31 | 32 | function run() { 33 | let envConfig = {}; 34 | 35 | switch (DEPLOYMENT_ENV) { 36 | case 'production': 37 | envConfig = loadEnv(path.join(envDir, '.env.production')); 38 | break; 39 | case 'development': 40 | envConfig = loadEnv(path.join(envDir, '.env.development')); 41 | break; 42 | case 'local': 43 | default: 44 | const dev = loadEnv(path.join(envDir, '.env.development')); 45 | const local = loadEnv(path.join(envDir, '.env.local')); 46 | envConfig = mergeEnvs(dev, local); // local overrides dev 47 | break; 48 | } 49 | 50 | writeEnvFile(envConfig, outputEnvPath); 51 | } 52 | 53 | run(); 54 | -------------------------------------------------------------------------------- /src/icons/general/CheckSquareIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface CheckSquareIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | strokeWidth?: number; 8 | } 9 | 10 | /** 11 | * A reusable SVG icon component for rendering an icon. 12 | * 13 | * @param {number} [width=20] - The width of the icon in pixels. Optional. 14 | * @param {number} [height=20] - The height of the icon in pixels. Optional. 15 | * @param {string} [color='white'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 16 | * @param {number} [strokeWidth=2] - The stroke width of the icon's path. Optional. 17 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 18 | * 19 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 20 | */ 21 | 22 | const CheckSquareIcon: FC = ({ 23 | width = 20, 24 | height = 20, 25 | color = 'white', 26 | strokeWidth = 2, 27 | ...props 28 | }) => { 29 | return ( 30 | 39 | 43 | 50 | 51 | ); 52 | }; 53 | 54 | export default CheckSquareIcon; 55 | -------------------------------------------------------------------------------- /src/providers/toast/ToastProvider.tsx: -------------------------------------------------------------------------------- 1 | import { Toast } from '@/components'; 2 | import { ToastMessage } from './toastTypes'; 3 | 4 | import React, { createContext, useContext, useState, ReactNode } from 'react'; 5 | 6 | interface ToastContextValue { 7 | showToast: (toast: Omit) => void; 8 | } 9 | 10 | const ToastContext = createContext(undefined); 11 | 12 | export const ToastProvider = ({ children }: { children: ReactNode }) => { 13 | const [toasts, setToasts] = useState([]); 14 | 15 | const showToast = (toast: Omit) => { 16 | const id = Math.random().toString(36).substr(2, 9); // Generate a unique ID 17 | setToasts((prev) => [...prev, { id, ...toast }]); 18 | 19 | // Auto-remove toast after a duration 20 | if (toast.autoHide !== false) { 21 | setTimeout(() => removeToast(id), 6000); 22 | } 23 | }; 24 | 25 | const removeToast = (id: string) => { 26 | setToasts((prev) => prev.filter((toast) => toast.id !== id)); 27 | }; 28 | 29 | return ( 30 | 31 | {children} 32 | {/* Render active toasts */} 33 | {toasts.map(({ id, message, variant, autoHide }, index) => ( 34 | removeToast(id)} 40 | autoHide={autoHide} 41 | index={index} 42 | /> 43 | ))} 44 | 45 | ); 46 | }; 47 | 48 | export const useToastContext = () => { 49 | const context = useContext(ToastContext); 50 | if (!context) { 51 | throw new Error('useToastContext must be used within a ToastProvider'); 52 | } 53 | return context; 54 | }; 55 | -------------------------------------------------------------------------------- /src/app/documents/components/LinkDetailsAccordion.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Typography } from '@mui/material'; 2 | import React from 'react'; 3 | 4 | import { CustomCheckbox, FormInput } from '@/components'; 5 | 6 | interface Props { 7 | formValues: any; 8 | handleInputChange: (event: React.ChangeEvent) => void; 9 | } 10 | 11 | const LinkDetailsAccordion = ({ formValues, handleInputChange }: Props) => { 12 | return ( 13 | 14 | {/* Link URL 15 | 18 | This is an automatically generated link address. 19 | 20 | 21 | 25 | {}} 30 | placeholder='' 31 | /> 32 | 33 | 34 | 35 | */} 36 | 37 | 42 | Link Alias 43 | 51 | 52 | 59 | 60 | 61 | ); 62 | }; 63 | 64 | export default LinkDetailsAccordion; 65 | -------------------------------------------------------------------------------- /src/icons/files/FileIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface FileIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | } 8 | 9 | /** 10 | * A reusable SVG icon component for rendering an icon. 11 | * 12 | * @param {number} [width=24] - The width of the icon in pixels. Optional. 13 | * @param {number} [height=24] - The height of the icon in pixels. Optional. 14 | * @param {string} [color='#667085'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 15 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 16 | * 17 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 18 | */ 19 | 20 | const FileIcon: FC = ({ width = 24, height = 24, color = '#667085', ...props }) => { 21 | return ( 22 | 31 | 37 | 38 | ); 39 | }; 40 | 41 | export default FileIcon; 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blw-datahall", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "predev": "node scripts/prepare-env.js --env=local", 7 | "dev": "next dev", 8 | "build": "next build", 9 | "start": "next start", 10 | "lint": "next lint", 11 | "prepare-env": "node scripts/prepare-env.js", 12 | "prisma:generate": "npm run prepare-env && npx prisma generate", 13 | "prisma:migrate": "npm run prepare-env && npx prisma migrate dev", 14 | "prisma:studio": "npm run prepare-env && npx prisma studio" 15 | }, 16 | "dependencies": { 17 | "@emotion/cache": "^11.14.0", 18 | "@emotion/react": "^11.14.0", 19 | "@emotion/styled": "^11.14.0", 20 | "@mui/icons-material": "^6.4.6", 21 | "@mui/material": "^6.4.6", 22 | "@mui/material-nextjs": "^6.4.3", 23 | "@mui/x-charts": "^7.27.1", 24 | "@prisma/client": "^6.4.1", 25 | "@supabase/supabase-js": "^2.49.1", 26 | "@tanstack/react-query": "^5.66.11", 27 | "@tanstack/react-query-devtools": "^5.66.11", 28 | "axios": "^1.8.1", 29 | "bcryptjs": "^3.0.2", 30 | "canvas": "^3.1.0", 31 | "date-fns": "^4.1.0", 32 | "dotenv": "^16.4.7", 33 | "next": "^15.2.0", 34 | "next-auth": "^4.24.11", 35 | "pdfjs-dist": "^4.10.38", 36 | "react": "^19.0.0", 37 | "react-color": "^2.19.3", 38 | "react-dom": "^19.0.0", 39 | "react-dropzone": "^14.3.8", 40 | "resend": "^4.1.2" 41 | }, 42 | "devDependencies": { 43 | "@types/bcryptjs": "^2.4.6", 44 | "@types/node": "^22.13.5", 45 | "@types/react": "^19.0.10", 46 | "@types/react-color": "^3.0.13", 47 | "@types/react-dom": "^19.0.4", 48 | "eslint": "^9.21.0", 49 | "eslint-config-next": "^15.2.0", 50 | "prettier": "^3.5.2", 51 | "prisma": "^6.4.1", 52 | "ts-node": "^10.9.2", 53 | "tsx": "^4.19.3", 54 | "typescript": "^5.7.3" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/icons/general/LogOutIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface LogOutIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | strokeWidth?: number; 8 | } 9 | 10 | /** 11 | * A reusable SVG icon component for rendering an icon. 12 | * 13 | * @param {number} [width=16] - The width of the icon in pixels. Optional. 14 | * @param {number} [height=16] - The height of the icon in pixels. Optional. 15 | * @param {string} [color='#667085'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 16 | * @param {number} [strokeWidth=1.5] - The stroke width of the icon's path. Optional. 17 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 18 | * 19 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 20 | */ 21 | 22 | const LogOutIcon: FC = ({ 23 | width = 16, 24 | height = 16, 25 | color = '#667085', 26 | strokeWidth = 1.5, 27 | ...props 28 | }) => { 29 | return ( 30 | 39 | 46 | 47 | ); 48 | }; 49 | 50 | export default LogOutIcon; 51 | -------------------------------------------------------------------------------- /src/components/navigation/Paginator.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Pagination, PaginationItem } from '@mui/material'; 2 | 3 | import { ArrowNarrowLeftIcon, ArrowNarrowRightIcon } from '@/icons'; 4 | 5 | interface PaginatorProps { 6 | page: number; 7 | totalPages: number; 8 | onPageChange: (newPage: number) => void; 9 | pageSize: number; 10 | totalItems: number; 11 | } 12 | 13 | const Paginator = ({ page, totalPages, onPageChange, pageSize, totalItems }: PaginatorProps) => { 14 | const handlePageChange = (_event: any, newPage: number) => { 15 | onPageChange(newPage); 16 | }; 17 | 18 | if (totalItems <= pageSize) { 19 | return null; 20 | } 21 | 22 | return ( 23 | 29 | 38 | 39 | ( 49 | 53 | )} 54 | /> 55 | 64 | 65 | ); 66 | }; 67 | 68 | export default Paginator; 69 | -------------------------------------------------------------------------------- /src/icons/files/VideoIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface VideoIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | } 7 | 8 | /** 9 | * A reusable SVG icon component for rendering an icon. 10 | * 11 | * @param {number} [width=25] - The width of the icon in pixels. Optional. 12 | * @param {number} [height=25] - The height of the icon in pixels. Optional. 13 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 14 | * 15 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 16 | */ 17 | 18 | const VideoIcon: FC = ({ width = 25, height = 25, ...props }) => { 19 | return ( 20 | 29 | 35 | 36 | ); 37 | }; 38 | 39 | export default VideoIcon; 40 | -------------------------------------------------------------------------------- /src/icons/general/MenuIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface MenuIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | strokeWidth?: number; 8 | } 9 | 10 | /** 11 | * A reusable SVG icon component for rendering an icon. 12 | * 13 | * @param {number} [width=20] - The width of the icon in pixels. Optional. 14 | * @param {number} [height=20] - The height of the icon in pixels. Optional. 15 | * @param {string} [color='#344054ab'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 16 | * @param {number} [strokeWidth=2] - The stroke width of the icon's path. Optional. 17 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 18 | * 19 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 20 | */ 21 | 22 | const MenuIcon: FC = ({ 23 | width = 20, 24 | height = 20, 25 | color = '#344054ab', 26 | strokeWidth = 2, 27 | ...props 28 | }) => { 29 | return ( 30 | 39 | 43 | 51 | 59 | 67 | 68 | ); 69 | }; 70 | 71 | export default MenuIcon; 72 | -------------------------------------------------------------------------------- /src/icons/general/UploadCloudIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface UploadCloudIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | strokeWidth?: number; 8 | } 9 | 10 | /** 11 | * A reusable SVG icon component for rendering an icon. 12 | * 13 | * @param {number} [width=21] - The width of the icon in pixels. Optional. 14 | * @param {number} [height=20] - The height of the icon in pixels. Optional. 15 | * @param {string} [color='#344054'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 16 | * @param {number} [strokeWidth=1.66667] - The stroke width of the icon's path. Optional. 17 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 18 | * 19 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 20 | */ 21 | 22 | const UploadCloudIcon: FC = ({ 23 | width = 21, 24 | height = 20, 25 | color = '#344054', 26 | strokeWidth = 1.66667, 27 | ...props 28 | }) => ( 29 | 38 | 45 | 46 | ); 47 | 48 | export default UploadCloudIcon; 49 | -------------------------------------------------------------------------------- /src/icons/users/UserIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface UserIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | strokeWidth?: number; 8 | } 9 | 10 | /** 11 | * A reusable SVG icon component for rendering an icon. 12 | * 13 | * @param {number} [width=24] - The width of the icon in pixels. Optional. 14 | * @param {number} [height=24] - The height of the icon in pixels. Optional. 15 | * @param {string} [color='#667085'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 16 | * @param {number} [strokeWidth=1] - The stroke width of the icon's path. Optional. 17 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 18 | * 19 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 20 | */ 21 | 22 | const UserIcon: FC = ({ 23 | width = 24, 24 | height = 24, 25 | color = '#667085', 26 | strokeWidth = 1, 27 | ...props 28 | }) => { 29 | return ( 30 | 39 | 46 | 47 | ); 48 | }; 49 | 50 | export default UserIcon; 51 | -------------------------------------------------------------------------------- /src/app/documents/components/NewLinkDialog.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Box, Chip, Dialog, DialogContent, DialogTitle, IconButton } from '@mui/material'; 4 | 5 | import { CheckIcon, CopyIcon, LinkIcon } from '@/icons'; 6 | 7 | interface NewLinkDialogProps { 8 | linkUrl: string; 9 | onClose: () => void; 10 | } 11 | 12 | export default function NewLinkDialog({ linkUrl, onClose }: NewLinkDialogProps) { 13 | const [isLinkCopied, setIsLinkCopied] = React.useState(false); 14 | 15 | function handleLinkCopy() { 16 | if (linkUrl) { 17 | navigator.clipboard.writeText(linkUrl); 18 | setIsLinkCopied(true); 19 | setTimeout(() => setIsLinkCopied(false), 3000); 20 | } 21 | } 22 | 23 | const open = Boolean(linkUrl); 24 | 25 | return ( 26 | 31 | 36 | New link 37 | 38 | 39 | 47 | } 50 | label={linkUrl} 51 | sx={{ 52 | typography: 'h4', 53 | flexGrow: 1, 54 | justifyContent: 'left', 55 | overflow: 'hidden', 56 | textOverflow: 'ellipsis', 57 | whiteSpace: 'nowrap', 58 | }} 59 | /> 60 | 61 | 64 | {isLinkCopied ? ( 65 | 69 | ) : ( 70 | 71 | )} 72 | 73 | 74 | 75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /src/icons/general/LinkBrokenIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface LinkBrokenIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | strokeWidth?: number; 8 | } 9 | 10 | /** 11 | * A reusable SVG icon component for rendering an icon. 12 | * 13 | * @param {number} [width=24] - The width of the icon in pixels. Optional. 14 | * @param {number} [height=24] - The height of the icon in pixels. Optional. 15 | * @param {string} [color='#FF4747'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 16 | * @param {number} [strokeWidth=2] - The stroke width of the icon's path. Optional. 17 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 18 | * 19 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 20 | */ 21 | 22 | const LinkBrokenIcon: FC = ({ 23 | width = 24, 24 | height = 24, 25 | color = '#FF4747', 26 | strokeWidth = 2, 27 | ...props 28 | }) => { 29 | return ( 30 | 39 | 46 | 47 | ); 48 | }; 49 | 50 | export default LinkBrokenIcon; 51 | -------------------------------------------------------------------------------- /src/app/api/auth/register/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { authService } from '@/app/api/_services/authService'; 3 | // import { emailService } from '../../_services/emailService'; 4 | 5 | export async function POST(req: NextRequest) { 6 | try { 7 | const { email, password, firstName, lastName, role } = await req.json(); 8 | 9 | const result = await authService.registerUser({ 10 | email, 11 | password, 12 | firstName, 13 | lastName, 14 | role, 15 | }); 16 | 17 | if (!result.success) { 18 | return NextResponse.json({ message: result.message }, { status: 409 }); 19 | } 20 | 21 | // Attempt to send verification email 22 | const verificationLink = `${process.env.APP_PROTOCOL}://${process.env.APP_DOMAIN}/auth/account-created/?token=${result.verificationToken}`; 23 | 24 | // const emailResp = await emailService.sendVerificationEmail({ 25 | // toEmail: email, 26 | // username: firstName, 27 | // verificationLink, 28 | // }); 29 | 30 | // if (!emailResp.success) { 31 | // // Partial success: user was created but email sending failed 32 | // console.error('[register] Email sending failed:', emailResp.error); 33 | // return NextResponse.json( 34 | // { 35 | // success: true, 36 | // emailFail: true, 37 | // userId: result.userId, 38 | // message: 'User created, but verification email failed. Please contact admin.', 39 | // }, 40 | // { status: 200 }, 41 | // ); 42 | // } 43 | 44 | // Full success 45 | return NextResponse.json( 46 | { 47 | success: true, 48 | message: 'Verification email sent. Please check your inbox.', 49 | token: result.verificationToken, 50 | }, 51 | { status: 200 }, 52 | ); 53 | } catch (error) { 54 | console.error('[register] Error creating user:', error); 55 | return NextResponse.json({ message: 'Internal server error' }, { status: 500 }); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/shared/config/routesConfig.ts: -------------------------------------------------------------------------------- 1 | // THIS IS NOT FUNCTIONAL YET, JUST A TEMPLATE 2 | 3 | /** 4 | * A list of public route patterns, expressed as regex strings. 5 | * 6 | * Explanation: 7 | * - '^auth/sign-up$' means exactly "/auth/sign-up". 8 | * (We remove the leading slash when matching, see isPublicRoute below.) 9 | * - '^auth/reset-password/.+' means any path that starts with /auth/reset-password/ 10 | * and then has additional segments. 11 | * - '^links/[a-f0-9-]{36}' matches "/links/uuid" where uuid is a 36-char hex string. 12 | */ 13 | const PUBLIC_ROUTE_PATTERNS = [ 14 | '^auth/sign-up$', 15 | '^auth/forgot-password$', 16 | '^auth/reset-password$', 17 | '^auth/account-created$', 18 | '^auth/password-reset-confirm$', 19 | '^auth/check-email$', 20 | '^links/[a-f0-9-]{36}', // dynamic link route 21 | '^auth/reset-password/.+', // dynamic reset-password route 22 | ]; 23 | 24 | /** 25 | * Checks if a given pathname is public by testing against the regex patterns. 26 | * 27 | * @param pathname - the current route path (e.g. "/auth/sign-up" or "/documents/123"). 28 | */ 29 | export function isPublicRoute(pathname: string): boolean { 30 | // Remove leading slash (e.g. "/auth/sign-up" => "auth/sign-up") 31 | const path = pathname.startsWith('/') ? pathname.slice(1) : pathname; 32 | 33 | return PUBLIC_ROUTE_PATTERNS.some((pattern) => { 34 | const regex = new RegExp(pattern, 'i'); // 'i' = case-insensitive 35 | return regex.test(path); 36 | }); 37 | } 38 | 39 | /** 40 | * Generates one big negative-lookahead pattern for NextAuth middleware. 41 | * 42 | * For example, if PUBLIC_ROUTE_PATTERNS = ["^auth/sign-up$", "^links/[a-f0-9-]{36}"], 43 | * we'll produce something like: 44 | * ^/(?!(auth/sign-up$|links/[a-f0-9-]{36})).* 45 | * 46 | * This means: "Match anything that does NOT start with one of these patterns." 47 | */ 48 | export const NEGATIVE_LOOKAHEAD_REGEX = `^/(?!(${PUBLIC_ROUTE_PATTERNS.join('|')})).*`; 49 | -------------------------------------------------------------------------------- /src/icons/users/UsersIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface UsersIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | strokeWidth?: number; 8 | } 9 | 10 | /** 11 | * A reusable SVG icon component for rendering an icon. 12 | * 13 | * @param {number} [width=16] - The width of the icon in pixels. Optional. 14 | * @param {number} [height=16] - The height of the icon in pixels. Optional. 15 | * @param {string} [color='#667085'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 16 | * @param {number} [strokeWidth=2.2] - The stroke width of the icon's path. Optional. 17 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 18 | * 19 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 20 | */ 21 | 22 | const UsersIcon: FC = ({ 23 | width = 16, 24 | height = 16, 25 | color = '#667085', 26 | strokeWidth = 2.2, 27 | ...props 28 | }) => { 29 | return ( 30 | 39 | 46 | 47 | ); 48 | }; 49 | 50 | export default UsersIcon; 51 | -------------------------------------------------------------------------------- /src/components/input/PasswordValidation.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react'; 2 | 3 | import { Box, Typography } from '@mui/material'; 4 | 5 | import { CheckCircleIcon, XCircleIcon } from '@/icons'; 6 | 7 | import { getPasswordChecks } from '@/shared/utils'; 8 | 9 | interface PasswordValidationProps { 10 | passwordValue: string; 11 | isBlur?: boolean; 12 | } 13 | 14 | const PasswordValidation: FC = ({ passwordValue, isBlur }) => { 15 | const { isLengthValid, hasUppercaseLetter, hasSymbol } = getPasswordChecks(passwordValue); 16 | 17 | return ( 18 | 23 | {/* Has at least 8 characters */} 24 | 28 | {passwordValue && !isLengthValid && isBlur ? ( 29 | 30 | ) : ( 31 | 32 | )} 33 | Must be at least 8 characters 34 | 35 | 36 | {/* Has at least one uppercase letter */} 37 | 41 | {passwordValue && !hasUppercaseLetter && isBlur ? ( 42 | 43 | ) : ( 44 | 45 | )} 46 | Must contain at least one uppercase letter. 47 | 48 | 49 | {/* Has at least one symbol */} 50 | 54 | {passwordValue && !hasSymbol && isBlur ? ( 55 | 56 | ) : ( 57 | 58 | )} 59 | Must Include at least one symbol. 60 | 61 | 62 | ); 63 | }; 64 | 65 | export default PasswordValidation; 66 | -------------------------------------------------------------------------------- /src/icons/general/LinkIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface LinkIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | strokeWidth?: number; 8 | disabled?: boolean; 9 | } 10 | 11 | /** 12 | * A reusable SVG icon component for rendering an icon. 13 | * 14 | * @param {number} [width=16] - The width of the icon in pixels. Optional. 15 | * @param {number} [height=16] - The height of the icon in pixels. Optional. 16 | * @param {string} [color='#344054'] - The stroke color of the icon when it is active. Accepts any valid CSS color value. Optional. 17 | * @param {number} [strokeWidth=2] - The stroke width of the icon's path. Optional. 18 | * @param {boolean} [disabled=false] - Indicates whether the icon is in a disabled state. Optional. 19 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 20 | * 21 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 22 | */ 23 | 24 | const LinkIcon: FC = ({ 25 | width = 16, 26 | height = 16, 27 | color = '#344054', 28 | strokeWidth = 2, 29 | disabled = false, 30 | ...props 31 | }) => { 32 | return ( 33 | 42 | 49 | 50 | ); 51 | }; 52 | 53 | export default LinkIcon; 54 | -------------------------------------------------------------------------------- /src/icons/general/TrashIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface TrashIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | strokeWidth?: number; 8 | } 9 | 10 | /** 11 | * A reusable SVG icon component for rendering an icon. 12 | * 13 | * @param {number} [width=19] - The width of the icon in pixels. Optional. 14 | * @param {number} [height=20] - The height of the icon in pixels. Optional. 15 | * @param {string} [color='#667085'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 16 | * @param {number} [strokeWidth=1.5] - The stroke width of the icon's path. Optional. 17 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 18 | * 19 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 20 | */ 21 | 22 | const TrashIcon: FC = ({ 23 | width = 19, 24 | height = 20, 25 | color = '#667085', 26 | strokeWidth = 1.5, 27 | ...props 28 | }) => { 29 | return ( 30 | 39 | 46 | 47 | ); 48 | }; 49 | 50 | export default TrashIcon; 51 | -------------------------------------------------------------------------------- /src/icons/general/XCircleIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | type IconColor = 'error' | 'success' | 'disabled' | 'outline'; 4 | 5 | const colorMap: Record = { 6 | error: '#F04438', 7 | success: '#067647', 8 | disabled: '#D0D5DD', 9 | outline: '#344054', 10 | }; 11 | 12 | interface XCircleIconProps extends SVGProps { 13 | width?: number; 14 | height?: number; 15 | color?: IconColor; 16 | } 17 | 18 | /** 19 | * A reusable SVG icon component for rendering an icon. 20 | * 21 | * @param {number} [width=24] - The width of the icon in pixels. Optional. 22 | * @param {number} [height=24] - The height of the icon in pixels. Optional. 23 | * @param {IconColor} [color='disabled'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 24 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 25 | * 26 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 27 | */ 28 | 29 | const XCircleIcon: FC = ({ 30 | width = 24, 31 | height = 24, 32 | color = 'disabled', 33 | ...props 34 | }) => { 35 | const isOutline = color === 'outline'; 36 | const fillColor = isOutline ? 'none' : colorMap[color]; 37 | const strokeColor = isOutline ? colorMap['outline'] : colorMap[color]; 38 | 39 | return ( 40 | 49 | 57 | 64 | 65 | ); 66 | }; 67 | 68 | export default XCircleIcon; 69 | -------------------------------------------------------------------------------- /src/icons/general/EyeIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface EyeIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | strokeWidth?: number; 8 | } 9 | 10 | /** 11 | * A reusable SVG icon component for rendering an icon. 12 | * 13 | * @param {number} [width=24] - The width of the icon in pixels. Optional. 14 | * @param {number} [height=24] - The height of the icon in pixels. Optional. 15 | * @param {string} [color='#667085'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 16 | * @param {number} [strokeWidth=1] - The stroke width of the icon's path. Optional. 17 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 18 | * 19 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 20 | */ 21 | 22 | const EyeIcon: FC = ({ 23 | width = 24, 24 | height = 24, 25 | color = '#667085', 26 | strokeWidth = 1, 27 | ...props 28 | }) => { 29 | return ( 30 | 39 | 46 | 55 | 56 | ); 57 | }; 58 | 59 | export default EyeIcon; 60 | -------------------------------------------------------------------------------- /src/app/settings/components/ColorPickerBox.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { SketchPicker } from 'react-color'; 3 | 4 | import { Box, Dialog, IconButton, TextField } from '@mui/material'; 5 | 6 | import { convertTransparencyToHex } from '@/shared/utils'; 7 | 8 | export default function ColorPickerBox() { 9 | const [pickerColor, setPickerColor] = useState('#ffffff'); 10 | const [showPicker, setShowPicker] = useState(false); 11 | 12 | const handleColorChange = (newColor: any) => { 13 | //Concat the 2-digit hex as a transparency number to newColor.hex 14 | const transparentColor = newColor.hex.concat(convertTransparencyToHex(newColor.rgb.a)); 15 | setPickerColor(transparentColor); //Push the changed color to color state 16 | }; 17 | 18 | const handleInputChange = (event: React.ChangeEvent) => { 19 | setPickerColor(event.target.value); //Push the hex code 20 | }; 21 | 22 | //Open and close a color picker 23 | const togglePicker = () => { 24 | setShowPicker(!showPicker); 25 | }; 26 | 27 | return ( 28 | 36 | 47 | 59 | 67 | 71 | 72 | 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /src/icons/files/AudioIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface AudioIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | } 7 | 8 | /** 9 | * A reusable SVG icon component for rendering an icon. 10 | * 11 | * @param {number} [width=25] - The width of the icon in pixels. Optional. 12 | * @param {number} [height=25] - The height of the icon in pixels. Optional. 13 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 14 | * 15 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 16 | */ 17 | 18 | const AudioIcon: FC = ({ width = 25, height = 25, ...props }) => { 19 | return ( 20 | 29 | 35 | 36 | ); 37 | }; 38 | 39 | export default AudioIcon; 40 | -------------------------------------------------------------------------------- /src/icons/arrows/ChevronSelectorVerticalIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface ChevronSelectorVerticalIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | strokeWidth?: number; 8 | } 9 | 10 | /** 11 | * A reusable SVG icon component for rendering an icon. 12 | * 13 | * @param {number} [width=16] - The width of the icon in pixels. Optional. 14 | * @param {number} [height=16] - The height of the icon in pixels. Optional. 15 | * @param {string} [color='#9A9B9B'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 16 | * @param {number} [strokeWidth=1.33333] - The stroke width of the icon's path. Optional. 17 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 18 | * 19 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 20 | */ 21 | 22 | const ChevronSelectorVerticalIcon: FC = ({ 23 | width = 16, 24 | height = 16, 25 | color = '#9A9B9B', 26 | strokeWidth = 1.33333, 27 | ...props 28 | }) => { 29 | return ( 30 | 39 | 40 | 47 | 54 | 55 | 56 | 57 | 62 | 63 | 64 | 65 | ); 66 | }; 67 | 68 | export default ChevronSelectorVerticalIcon; 69 | -------------------------------------------------------------------------------- /src/icons/files/GeneralIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface GeneralIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | strokeWidth?: number; 8 | } 9 | 10 | /** 11 | * A reusable SVG icon component for rendering an icon. 12 | * 13 | * @param {number} [width=25] - The width of the icon in pixels. Optional. 14 | * @param {number} [height=25] - The height of the icon in pixels. Optional. 15 | * @param {string} [color='white'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 16 | * @param {number} [strokeWidth=1.90667] - The stroke width of the icon's path. Optional. 17 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 18 | * 19 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 20 | */ 21 | 22 | const GeneralIcon: FC = ({ 23 | width = 25, 24 | height = 25, 25 | color = 'white', 26 | strokeWidth = 1.90667, 27 | ...props 28 | }) => { 29 | return ( 30 | 39 | 43 | 50 | 51 | ); 52 | }; 53 | 54 | export default GeneralIcon; 55 | -------------------------------------------------------------------------------- /src/icons/files/ImageIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface ImageIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | } 7 | 8 | /** 9 | * A reusable SVG icon component for rendering an icon. 10 | * 11 | * @param {number} [width=25] - The width of the icon in pixels. Optional. 12 | * @param {number} [height=25] - The height of the icon in pixels. Optional. 13 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 14 | * 15 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 16 | */ 17 | 18 | const ImageIcon: FC = ({ width = 25, height = 25, ...props }) => { 19 | return ( 20 | 29 | 35 | 36 | ); 37 | }; 38 | 39 | export default ImageIcon; 40 | -------------------------------------------------------------------------------- /src/icons/general/CopyIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | interface CopyIconProps extends SVGProps { 3 | width?: number; 4 | height?: number; 5 | color?: string; 6 | } 7 | 8 | /** 9 | * A reusable SVG icon component for rendering an icon. 10 | * 11 | * @param {number} [width=15] - The width of the icon in pixels. Optional. 12 | * @param {number} [height=15] - The height of the icon in pixels. Optional. 13 | * @param {string} [color='#667085'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 14 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 15 | * 16 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 17 | */ 18 | 19 | const CopyIcon: FC = ({ width = 15, height = 15, color = '#667085', ...props }) => { 20 | return ( 21 | 30 | 36 | 37 | ); 38 | }; 39 | 40 | export default CopyIcon; 41 | -------------------------------------------------------------------------------- /src/app/api/auth/password/forgot/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | 3 | import { authService, emailService } from '@/app/api/_services'; 4 | 5 | /** 6 | * POST /api/auth/password/forgot 7 | * Expects: { email } 8 | */ 9 | export async function POST(req: NextRequest) { 10 | try { 11 | const { email } = await req.json(); 12 | 13 | if (!email || typeof email !== 'string') { 14 | return NextResponse.json({ message: 'Email is required.' }, { status: 400 }); 15 | } 16 | 17 | // 1) Create token 18 | const result = await authService.createPasswordResetToken(email); 19 | if (!result.success) { 20 | return NextResponse.json({ message: result.message }, { status: 400 }); 21 | } 22 | 23 | // 2) Build reset URL 24 | const resetPasswordUrl = `${process.env.APP_PROTOCOL}://${process.env.APP_DOMAIN}/auth/reset-password?token=${result.token}&email=${encodeURIComponent( 25 | result.userEmail!, 26 | )}`; 27 | 28 | // // 3) Send the email (if SEND_EMAILS === 'true') 29 | // const emailResp = await emailService.sendResetPasswordEmail({ 30 | // toEmail: result.userEmail!, 31 | // username: result.userName!, 32 | // resetUrl: resetPasswordUrl, 33 | // }); 34 | 35 | // if (!emailResp.success) { 36 | // console.error('[forgot] Error sending email:', emailResp.error); 37 | // return NextResponse.json({ message: 'Internal server error' }, { status: 500 }); 38 | // } 39 | 40 | // 4) Return different info if not sending emails 41 | if (process.env.SEND_EMAILS !== 'true') { 42 | // In dev, skip actual email sending 43 | return NextResponse.json( 44 | { 45 | success: true, 46 | message: 'Mail sending is disabled; returning reset URL for dev usage.', 47 | url: resetPasswordUrl, 48 | }, 49 | { status: 201 }, 50 | ); 51 | } 52 | 53 | // Production or emailing is enabled 54 | return NextResponse.json( 55 | { success: true, message: 'Mail sent. Please check your inbox.' }, 56 | { status: 201 }, 57 | ); 58 | } catch (err) { 59 | console.error('[forgot] Error in password/forgot route:', err); 60 | return NextResponse.json({ message: 'Internal server error' }, { status: 500 }); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/icons/general/SettingsIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface SettingsIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | } 8 | 9 | /** 10 | * A reusable SVG icon component for rendering an icon. 11 | * 12 | * @param {number} [width=24] - The width of the icon in pixels. Optional. 13 | * @param {number} [height=24] - The height of the icon in pixels. Optional. 14 | * @param {string} [color='#667085'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 15 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 16 | * 17 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 18 | */ 19 | 20 | const SettingsIcon: FC = ({ 21 | width = 24, 22 | height = 24, 23 | color = '#667085', 24 | ...props 25 | }) => { 26 | return ( 27 | 36 | 40 | 44 | 45 | ); 46 | }; 47 | 48 | export default SettingsIcon; 49 | -------------------------------------------------------------------------------- /src/components/loaders/EmptyState.tsx: -------------------------------------------------------------------------------- 1 | import { Container, Box, Button, Typography } from '@mui/material'; 2 | import { ReactNode } from 'react'; 3 | import { EmptyStateIcon } from '@/icons'; 4 | 5 | interface EmptyStateProps { 6 | message: string; 7 | icon?: ReactNode; 8 | buttonText?: string; 9 | buttonAction?: () => void; 10 | children?: ReactNode; 11 | } 12 | 13 | /** 14 | * A reusable empty state component for empty items, such as tables, that are completely out of data. 15 | * For example, in the parent component, we can handle the following code: 16 | * {!data.length && } />} 17 | * 18 | * @param {string} [message] - The message of empty states. Required. 19 | * @param {ReactNode} [icon= ] - The icon of empty states. Optional. 20 | * @param {string} [buttonText] - The button text of empty states. Optional. 21 | * @param {() => void} [buttonAction] - The button action of empty states. Optional. 22 | * @param {ReactNode} [children] - Custom JSX elements for more complex empty states (e.g., multiple buttons or additional text). Optional. 23 | * 24 | * @returns {JSX.Element} A scalable and responsive design including a message, icon, button, or passed children. 25 | */ 26 | 27 | export default function EmptyState({ 28 | message, 29 | icon = , 30 | buttonText, 31 | buttonAction, 32 | children, 33 | }: EmptyStateProps) { 34 | return ( 35 | 42 | {icon && ( 43 | 46 | {icon} 47 | 48 | )} 49 | 50 | {message} 51 | 52 | {buttonText && buttonAction ? ( 53 | 54 | 59 | 60 | ) : ( 61 | children && {children} 62 | )} 63 | 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /src/icons/files/ZIPIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface ZIPIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | } 7 | 8 | /** 9 | * A reusable SVG icon component for rendering an icon. 10 | * 11 | * @param {number} [width=25] - The width of the icon in pixels. Optional. 12 | * @param {number} [height=25] - The height of the icon in pixels. Optional. 13 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 14 | * 15 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 16 | */ 17 | 18 | const ZIPIcon: FC = ({ width = 25, height = 25, ...props }) => { 19 | return ( 20 | 29 | 30 | 34 | 35 | 36 | 37 | 42 | 43 | 44 | 45 | ); 46 | }; 47 | 48 | export default ZIPIcon; 49 | -------------------------------------------------------------------------------- /src/icons/security/LockIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface LockIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | strokeWidth?: number; 8 | } 9 | 10 | /** 11 | * A reusable SVG icon component for rendering an icon. 12 | * 13 | * @param {number} [width=28] - The width of the icon in pixels. Optional. 14 | * @param {number} [height=28] - The height of the icon in pixels. Optional. 15 | * @param {string} [color='#344054'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 16 | * @param {number} [strokeWidth=2] - The stroke width of the icon's path. Optional. 17 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 18 | * 19 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 20 | */ 21 | 22 | const LockIcon: FC = ({ 23 | width = 28, 24 | height = 28, 25 | color = '#344054', 26 | strokeWidth = 2, 27 | ...props 28 | }) => { 29 | return ( 30 | 39 | 46 | 47 | ); 48 | }; 49 | 50 | export default LockIcon; 51 | -------------------------------------------------------------------------------- /src/icons/general/EyeOffIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface EyeOffIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | strokeWidth?: number; 8 | } 9 | 10 | /** 11 | * A reusable SVG icon component for rendering an icon. 12 | * 13 | * @param {number} [width=24] - The width of the icon in pixels. Optional. 14 | * @param {number} [height=24] - The height of the icon in pixels. Optional. 15 | * @param {string} [color='#667085'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 16 | * @param {number} [strokeWidth=1] - The stroke width of the icon's path. Optional. 17 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 18 | * 19 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 20 | */ 21 | 22 | const EyeOffIcon: FC = ({ 23 | width = 24, 24 | height = 24, 25 | color = '#667085', 26 | strokeWidth = 1, 27 | ...props 28 | }) => { 29 | return ( 30 | 39 | 46 | 47 | ); 48 | }; 49 | 50 | export default EyeOffIcon; 51 | -------------------------------------------------------------------------------- /src/icons/files/TextIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface TextIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | } 7 | 8 | /** 9 | * A reusable SVG icon component for rendering an icon. 10 | * 11 | * @param {number} [width=25] - The width of the icon in pixels. Optional. 12 | * @param {number} [height=25] - The height of the icon in pixels. Optional. 13 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 14 | * 15 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 16 | */ 17 | 18 | const TextIcon: FC = ({ width = 25, height = 25, ...props }) => { 19 | return ( 20 | 29 | 35 | 36 | ); 37 | }; 38 | 39 | export default TextIcon; 40 | -------------------------------------------------------------------------------- /src/components/input/FormInput.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * FormInput.tsx 3 | * ---------------------------------------------------------------------------- 4 | * A unified reusable input component built on top of MUI's TextField. 5 | * 6 | * NOTES: 7 | * - Supports all standard TextFieldProps (type, disabled, etc.). 8 | * - "label" is rendered above the TextField as a separate Typography element. 9 | * - "errorMessage" will display inline below the TextField if provided. 10 | * - "minWidth" is applied via sx props for basic styling. 11 | */ 12 | 13 | import React, { FC } from 'react'; 14 | import { Box, TextField, Typography, TextFieldProps } from '@mui/material'; 15 | 16 | interface FormInputProps extends Omit { 17 | /** Optional label rendered above the TextField. */ 18 | label?: string; 19 | 20 | /** The unique identifier for this field (used for id/name). */ 21 | id: string; 22 | 23 | /** An inline error message displayed below the TextField if validation fails. */ 24 | errorMessage?: string; 25 | 26 | /** An optional custom minimum width for the TextField, in pixels. */ 27 | minWidth?: number; 28 | 29 | /** An optional custom minimum height for the TextField helpertext */ 30 | minHeight?: number; 31 | } 32 | 33 | const FormInput: FC = ({ 34 | label, 35 | id, 36 | errorMessage = '', 37 | minWidth, 38 | minHeight = '1.5em', 39 | fullWidth = true, 40 | size = 'small', 41 | // Any other TextField props 42 | ...props 43 | }) => { 44 | const displayError = Boolean(errorMessage); 45 | 46 | return ( 47 | 48 | {/* Render a top label if provided */} 49 | {label && ( 50 | 53 | {label} 54 | 55 | )} 56 | 57 | 77 | 78 | ); 79 | }; 80 | 81 | export default FormInput; 82 | -------------------------------------------------------------------------------- /src/hooks/useDocumentAnalytics.ts: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { BarDataItem } from '@/shared/models'; 4 | 5 | import { useState, useEffect, useMemo } from 'react'; 6 | 7 | // import { mockGraphData } from '@/seed/analytics'; 8 | 9 | export type FilterPeriod = 'fromStart' | 'last30Days' | 'last7Days'; 10 | 11 | interface UseDocumentAnalyticsReturn { 12 | filteredData: BarDataItem[]; 13 | isLoading: boolean; 14 | error: string | null; 15 | filterPeriod: FilterPeriod; 16 | setFilterPeriod: (period: FilterPeriod) => void; 17 | } 18 | 19 | export default function useDocumentAnalytics(documentId: string): UseDocumentAnalyticsReturn { 20 | const [analyticsData, setAnalyticsData] = useState([]); 21 | const [filterPeriod, setFilterPeriod] = useState('fromStart'); 22 | const [isLoading, setIsLoading] = useState(false); 23 | const [error, setError] = useState(null); 24 | 25 | useEffect(() => { 26 | if (!documentId) return; 27 | 28 | const fetchAnalytics = async () => { 29 | try { 30 | setIsLoading(true); 31 | setError(null); 32 | 33 | // In a real scenario, you might do: 34 | // const { data } = await axios.get(`/api/analytics?documentId=${documentId}`); 35 | // setAnalyticsData(data); 36 | 37 | // setAnalyticsData(mockGraphData); 38 | } catch (err: any) { 39 | setError('Error fetching analytics data'); 40 | } finally { 41 | setIsLoading(false); 42 | } 43 | }; 44 | 45 | fetchAnalytics(); 46 | }, [documentId]); 47 | 48 | // Filter logic 49 | const filteredData = useMemo(() => { 50 | if (!analyticsData.length) return []; 51 | const currentDate = new Date(); 52 | 53 | switch (filterPeriod) { 54 | case 'last30Days': 55 | return analyticsData.filter( 56 | (item) => currentDate.getTime() - item.date.getTime() <= 30 * 24 * 60 * 60 * 1000, 57 | ); 58 | case 'last7Days': 59 | return analyticsData.filter( 60 | (item) => currentDate.getTime() - item.date.getTime() <= 7 * 24 * 60 * 60 * 1000, 61 | ); 62 | case 'fromStart': 63 | default: 64 | return analyticsData; 65 | } 66 | }, [filterPeriod, analyticsData]); 67 | 68 | return { 69 | filteredData, 70 | isLoading, 71 | error, 72 | filterPeriod, 73 | setFilterPeriod, 74 | }; 75 | } 76 | -------------------------------------------------------------------------------- /src/icons/general/HomeIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface HomeIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | strokeWidth?: number; 8 | } 9 | 10 | /** 11 | * A reusable SVG icon component for rendering an icon. 12 | * 13 | * @param {number} [width=20] - The width of the icon in pixels. Optional. 14 | * @param {number} [height=20] - The height of the icon in pixels. Optional. 15 | * @param {string} [color='#667085'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 16 | * @param {number} [strokeWidth=1.5] - The stroke width of the icon's path. Optional. 17 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 18 | * 19 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 20 | */ 21 | 22 | const HomeIcon: FC = ({ 23 | width = 20, 24 | height = 20, 25 | color = '#667085', 26 | strokeWidth = 1.5, 27 | ...props 28 | }) => { 29 | return ( 30 | 39 | 46 | 47 | ); 48 | }; 49 | 50 | export default HomeIcon; 51 | -------------------------------------------------------------------------------- /src/shared/models/linkModels.ts: -------------------------------------------------------------------------------- 1 | // =========== LINK TYPE =========== 2 | 3 | export interface LinkType { 4 | id: number; 5 | linkId: string; 6 | documentId: string; 7 | userId: string; 8 | alias?: string; 9 | linkUrl: string; 10 | isPublic: boolean; 11 | visitorFields?: string[]; 12 | expirationTime?: string; 13 | password?: string; 14 | updatedAt: string; 15 | createdAt: string; 16 | } 17 | 18 | // =========== LINK FORM VALUES =========== 19 | 20 | export interface LinkFormValues { 21 | password?: string; 22 | isPublic: boolean; 23 | alias?: string; 24 | expirationTime?: string; 25 | requirePassword: boolean; 26 | expirationEnabled: boolean; 27 | requireUserDetails: boolean; 28 | visitorFields?: string[]; 29 | contactEmails?: { label: string; id: number }[]; 30 | selectFromContact: boolean; 31 | otherEmails?: string; 32 | sendToOthers: boolean; 33 | } 34 | 35 | // =========== LINK PAYLOAD =========== 36 | 37 | export interface CreateDocumentLinkPayload { 38 | documentId: string; 39 | alias?: string; // Alias for the link 40 | isPublic: boolean; 41 | expirationTime?: string; // ISO string format 42 | expirationEnabled?: boolean; 43 | requirePassword?: boolean; 44 | password?: string; 45 | requireUserDetails?: boolean; 46 | visitorFields?: string[]; // Array of required visitor details 47 | contactEmails?: { label: string; id: number }[]; 48 | selectFromContact: boolean; 49 | otherEmails?: string; 50 | sendToOthers: boolean; 51 | } 52 | 53 | // =========== INVITE RECIPIENTS PAYLOAD =========== 54 | 55 | export interface InviteRecipientsPayload { 56 | linkUrl: string; 57 | recipients: string[]; 58 | } 59 | 60 | // =========== LINK DATA =========== 61 | 62 | export interface LinkData { 63 | isPasswordProtected?: boolean; 64 | requiredUserDetailsOption?: number; 65 | signedUrl?: string; 66 | fileName?: string; 67 | size?: number; 68 | } 69 | 70 | // =========== LINK DETAIL =========== 71 | 72 | export interface LinkDetail { 73 | documentLinkId: string; // unique string 74 | alias: string; // The links's friendly name 75 | document_id: string; // The document_id from DB 76 | createdLink: string; // The linkUrl from DB 77 | lastActivity: Date; // The link's updatedAt 78 | linkViews: number; // If you track actual link views, you can use a real value 79 | } 80 | -------------------------------------------------------------------------------- /src/icons/communication/MailIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface MailIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | strokeWidth?: number; 8 | } 9 | 10 | /** 11 | * A reusable SVG icon component for rendering an icon. 12 | * 13 | * @param {number} [width=28] - The width of the icon in pixels. Optional. 14 | * @param {number} [height=28] - The height of the icon in pixels. Optional. 15 | * @param {string} [color='#344054'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 16 | * @param {number} [strokeWidth=2] - The stroke width of the icon's path. Optional. 17 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 18 | * 19 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 20 | */ 21 | 22 | const MailIcon: FC = ({ 23 | width = 28, 24 | height = 28, 25 | color = '#344054', 26 | strokeWidth = 2, 27 | ...props 28 | }) => { 29 | return ( 30 | 39 | 46 | 47 | ); 48 | }; 49 | 50 | export default MailIcon; 51 | -------------------------------------------------------------------------------- /src/icons/files/FileDownloadIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface FileDownloadIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | strokeWidth?: number; 8 | } 9 | 10 | /** 11 | * A reusable SVG icon component for rendering an icon. 12 | * 13 | * @param {number} [width=28] - The width of the icon in pixels. Optional. 14 | * @param {number} [height=28] - The height of the icon in pixels. Optional. 15 | * @param {string} [color='#667085'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 16 | * @param {number} [strokeWidth=2] - The stroke width of the icon's path. Optional. 17 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 18 | * 19 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 20 | */ 21 | 22 | const FileDownloadIcon: FC = ({ 23 | width = 28, 24 | height = 28, 25 | color = '#667085', 26 | strokeWidth = 2, 27 | ...props 28 | }) => { 29 | return ( 30 | 39 | 46 | 47 | ); 48 | }; 49 | 50 | export default FileDownloadIcon; 51 | -------------------------------------------------------------------------------- /src/app/documents/[documentId]/components/InfoTableHeader.tsx: -------------------------------------------------------------------------------- 1 | import { TableCell, TableRow, TableSortLabel } from '@mui/material'; 2 | 3 | import { Contact, LinkDetail } from '@/shared/models'; 4 | 5 | import { ChevronDownIcon, ChevronSelectorVerticalIcon } from '@/icons'; 6 | 7 | type SortableKeys = 'lastActivity'; 8 | 9 | interface InfoTableHeaderProps { 10 | variant?: 'linkTable' | 'visitorTable'; 11 | orderBy?: keyof LinkDetail | Contact | undefined; 12 | orderDirection?: 'asc' | 'desc' | undefined; 13 | onSort?: (property: SortableKeys) => void; 14 | } 15 | 16 | export default function InfoTableHeader({ 17 | variant, 18 | orderBy, 19 | orderDirection, 20 | onSort, 21 | }: InfoTableHeaderProps) { 22 | const handleHeaderClick = () => { 23 | if (onSort) { 24 | onSort('lastActivity'); 25 | } 26 | }; 27 | 28 | const sortIcon = orderDirection === undefined ? ChevronSelectorVerticalIcon : ChevronDownIcon; 29 | 30 | if (variant === 'linkTable') { 31 | return ( 32 | 33 | LINK 34 | 35 | 41 | LAST VIEWED 42 | 43 | 44 | VIEWS 45 | ACTION 46 | 47 | ); 48 | } 49 | 50 | // visitorTable 51 | return ( 52 | 53 | VISITOR 54 | 55 | 61 | LAST VIEWED 62 | 63 | 64 | DOWNLOADS 65 | DURATION 66 | COMPLETION 67 | 68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /src/icons/general/CheckCircleIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | type IconColor = 'error' | 'success' | 'disabled' | 'outline' | 'primaryOutline'; 4 | 5 | const colorMap: Record = { 6 | error: '#F04438', 7 | success: '#067647', 8 | disabled: '#D0D5DD', 9 | outline: '#344054', 10 | primaryOutline: '#1570ef', 11 | }; 12 | 13 | interface CheckCircleIconProps extends SVGProps { 14 | width?: number; 15 | height?: number; 16 | color?: IconColor; 17 | } 18 | 19 | /** 20 | * A reusable SVG icon component for rendering an icon. 21 | * 22 | * @param {number} [width=24] - The width of the icon in pixels. Optional. 23 | * @param {number} [height=24] - The height of the icon in pixels. Optional. 24 | * @param {IconColor} [color='disabled'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 25 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 26 | * 27 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 28 | */ 29 | 30 | const CheckCircleIcon: FC = ({ 31 | width = 24, 32 | height = 24, 33 | color = 'disabled', 34 | ...props 35 | }) => { 36 | const isOutline = color === 'outline'; 37 | const isPrimaryOutline = color === 'primaryOutline'; 38 | const fillColor = isOutline || isPrimaryOutline ? 'none' : colorMap[color]; 39 | const strokeColor = isOutline 40 | ? colorMap['outline'] 41 | : isPrimaryOutline 42 | ? colorMap['primaryOutline'] 43 | : 'none'; 44 | 45 | return ( 46 | 55 | 63 | 64 | 73 | 74 | ); 75 | }; 76 | 77 | export default CheckCircleIcon; 78 | -------------------------------------------------------------------------------- /src/components/common/Toast.tsx: -------------------------------------------------------------------------------- 1 | import { Alert, Box, IconButton, Slide, SlideProps, Snackbar } from '@mui/material'; 2 | import { ReactNode } from 'react'; 3 | import NavLink from '../navigation/NavLink'; 4 | import { ToastVariant } from '@/providers/toast/toastTypes'; 5 | import { XCloseIcon } from '@/icons'; 6 | 7 | interface BaseToastProps { 8 | variant?: ToastVariant; 9 | autoHide?: boolean; 10 | index?: number; 11 | } 12 | 13 | type ToastWithMessage = BaseToastProps & { 14 | message: string; 15 | toastLink?: string; 16 | toastLinkText?: string; 17 | children?: never; 18 | }; 19 | 20 | type ToastWithChildren = BaseToastProps & { 21 | children: ReactNode; 22 | message?: never; 23 | toastLink?: never; 24 | toastLinkText?: never; 25 | }; 26 | 27 | type ToastProps = ToastWithMessage | ToastWithChildren; 28 | 29 | const SlideTransition = (props: SlideProps) => { 30 | return ( 31 | 35 | ); 36 | }; 37 | 38 | export default function Toast({ 39 | variant = 'info', 40 | message, 41 | toastLink, 42 | toastLinkText = 'Learn more', 43 | autoHide = true, 44 | open, 45 | hideToast, 46 | children, 47 | index = 0, 48 | }: ToastProps & { open: boolean; hideToast: () => void }) { 49 | const action = ( 50 | 51 | 52 | 53 | ); 54 | 55 | return ( 56 | 57 | 70 | 75 | {message ? ( 76 | 81 | {message}{' '} 82 | {toastLink && ( 83 | 87 | )} 88 | 89 | ) : ( 90 | children 91 | )} 92 | 93 | 94 | 95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /src/app/auth/password-reset-confirm/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { signIn } from 'next-auth/react'; 4 | import { useRouter, useSearchParams } from 'next/navigation'; 5 | import { useEffect, useState } from 'react'; 6 | 7 | import { Box, Typography } from '@mui/material'; 8 | 9 | import { CheckCircleIcon } from '@/icons'; 10 | 11 | import { LoadingButton } from '@/components'; 12 | import AuthFormWrapper from '../components/AuthFormWrapper'; 13 | 14 | export default function PasswordResetConfirm() { 15 | const [loading, setLoading] = useState(false); 16 | const [signedIn, setSignedIn] = useState(false); 17 | 18 | const router = useRouter(); 19 | const searchParams = useSearchParams(); 20 | 21 | const email = searchParams.get('email'); 22 | const password = searchParams.get('password'); 23 | 24 | // Attempt sign-in 25 | const handleSignIn = async () => { 26 | setLoading(true); 27 | 28 | if (email && password) { 29 | const signInResult = await signIn('credentials', { 30 | redirect: false, 31 | email, 32 | password, 33 | }); 34 | 35 | if (signInResult?.error) { 36 | console.error('Sign-in failed:', signInResult.error); 37 | } else { 38 | setSignedIn(true); 39 | } 40 | } 41 | 42 | setLoading(false); 43 | }; 44 | 45 | // If sign-in is successful, redirect to /documents 46 | useEffect(() => { 47 | if (signedIn) { 48 | router.push('/documents'); 49 | } 50 | }, [signedIn, router]); 51 | 52 | return ( 53 | 54 | 63 | 68 | 69 | 70 | 73 | Password Reset Successfully! 74 | 75 | 76 | 79 | Your password has been successfully reset. Click below to log in magically. 80 | 81 | 82 | 89 | 90 | ); 91 | } 92 | -------------------------------------------------------------------------------- /src/app/documentAccess/[linkId]/components/FileDisplay.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Typography, Box, Button } from '@mui/material'; 4 | 5 | import { useToast } from '@/hooks'; 6 | 7 | import { formatFileSize } from '@/shared/utils'; 8 | 9 | interface FilePageProps { 10 | signedUrl: string; 11 | fileName: string; 12 | size: number; 13 | } 14 | 15 | const FileDisplay: React.FC = ({ signedUrl, fileName, size }) => { 16 | const { showToast } = useToast(); 17 | 18 | const handleDownload = async () => { 19 | try { 20 | const response = await fetch(signedUrl); 21 | const blob = await response.blob(); 22 | const url = window.URL.createObjectURL(blob); 23 | 24 | const link = document.createElement('a'); 25 | link.href = url; 26 | link.download = fileName; 27 | link.click(); 28 | 29 | window.URL.revokeObjectURL(url); 30 | showToast({ message: 'File downloaded successfully', variant: 'success' }); 31 | } catch (error) { 32 | console.error('Error downloading the file:', error); 33 | showToast({ 34 | message: 'Error downloading the file. Please try again.', 35 | variant: 'error', 36 | }); 37 | } 38 | }; 39 | 40 | return ( 41 | 42 | 45 | File is ready for download 46 | 47 | 48 | Thanks for verifying your details. You can now download the document. 49 | 50 | 56 | Document: 57 | 60 | {fileName} ({formatFileSize(size)}) 61 | 62 | 63 | 68 | 75 | 80 | 81 | 82 | ); 83 | }; 84 | 85 | export default FileDisplay; 86 | -------------------------------------------------------------------------------- /src/icons/security/KeyIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface KeyIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | strokeWidth?: number; 8 | } 9 | 10 | /** 11 | * A reusable SVG icon component for rendering an icon. 12 | * 13 | * @param {number} [width=28] - The width of the icon in pixels. Optional. 14 | * @param {number} [height=28] - The height of the icon in pixels. Optional. 15 | * @param {string} [color='#344054'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 16 | * @param {number} [strokeWidth=2] - The stroke width of the icon's path. Optional. 17 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 18 | * 19 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 20 | */ 21 | 22 | const KeyIcon: FC = ({ 23 | width = 28, 24 | height = 28, 25 | color = '#344054', 26 | strokeWidth = 2, 27 | ...props 28 | }) => { 29 | return ( 30 | 39 | 46 | 47 | ); 48 | }; 49 | 50 | export default KeyIcon; 51 | -------------------------------------------------------------------------------- /src/app/api/contacts/route.ts: -------------------------------------------------------------------------------- 1 | import prisma from '@/lib/prisma'; 2 | import { NextRequest, NextResponse } from 'next/server'; 3 | import { authService } from '../_services/authService'; 4 | 5 | export async function GET(req: NextRequest): Promise { 6 | try { 7 | const userId = await authService.authenticate(); 8 | 9 | const userLinks = await prisma.documentLink.findMany({ 10 | where: { createdByUserId: userId }, 11 | select: { documentLinkId: true }, 12 | }); 13 | if (!userLinks.length) { 14 | return NextResponse.json({ data: [] }, { status: 200 }); 15 | } 16 | 17 | const linkIds = userLinks.map((l) => l.documentLinkId); 18 | 19 | const visitors = await prisma.documentLinkVisitor.groupBy({ 20 | by: ['email'], 21 | where: { 22 | documentLinkId: { in: linkIds }, 23 | }, 24 | _count: { 25 | email: true, 26 | }, 27 | _max: { 28 | updatedAt: true, 29 | }, 30 | }); 31 | 32 | const visitorDetails = await Promise.all( 33 | visitors.map(async (visitor) => { 34 | const lastVisit = await prisma.documentLinkVisitor.findFirst({ 35 | where: { 36 | email: visitor.email, 37 | documentLinkId: { in: linkIds }, 38 | }, 39 | orderBy: { updatedAt: 'desc' }, 40 | include: { 41 | documentLink: true, 42 | }, 43 | }); 44 | 45 | if (!lastVisit) { 46 | return null; 47 | } 48 | 49 | const firstName = lastVisit.firstName?.trim() || null; 50 | const lastName = lastVisit.lastName?.trim() || null; 51 | const fullName = 52 | firstName || lastName ? `${firstName || ''} ${lastName || ''}`.trim() : null; 53 | 54 | return { 55 | id: lastVisit.id, 56 | name: fullName, 57 | email: visitor.email || null, 58 | lastViewedLink: lastVisit.documentLink?.alias || lastVisit.documentLink?.linkUrl || null, 59 | lastActivity: lastVisit.updatedAt || null, 60 | totalVisits: visitor._count.email || 0, 61 | }; 62 | }), 63 | ); 64 | 65 | const contacts = visitorDetails.filter(Boolean); 66 | 67 | return NextResponse.json({ data: contacts }, { status: 200 }); 68 | } catch (error) { 69 | return createErrorResponse('Server error.', 500, error); 70 | } 71 | } 72 | 73 | function createErrorResponse(message: string, status: number, details?: any) { 74 | console.error(`[${new Date().toISOString()}] ${message}`, details); 75 | return NextResponse.json({ error: message, details }, { status }); 76 | } 77 | -------------------------------------------------------------------------------- /src/icons/files/ExcelIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface ExcelIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | } 7 | 8 | /** 9 | * A reusable SVG icon component for rendering an icon. 10 | * 11 | * @param {number} [width=25] - The width of the icon in pixels. Optional. 12 | * @param {number} [height=25] - The height of the icon in pixels. Optional. 13 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 14 | * 15 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 16 | */ 17 | 18 | const ExcelIcon: FC = ({ width = 25, height = 25, ...props }) => { 19 | return ( 20 | 29 | 35 | 36 | ); 37 | }; 38 | 39 | export default ExcelIcon; 40 | -------------------------------------------------------------------------------- /src/app/team/components/TeamClient.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useEffect, useState } from 'react'; 3 | 4 | import { Box, Button } from '@mui/material'; 5 | 6 | import { ModalWrapper } from '@/components'; 7 | 8 | import { dummyTeams } from './dummyTeams'; 9 | 10 | import FilterToggle from './FilterToggle'; 11 | import UserTable from './UserTable'; 12 | 13 | import { useModal } from '@/hooks'; 14 | 15 | import { User } from '@/shared/models'; 16 | 17 | export default function TeamClient() { 18 | const inviteModal = useModal(); 19 | 20 | const [filterRole, setFilterRole] = useState<'All' | 'Administrator' | 'Member'>('All'); 21 | const [page, setPage] = useState(1); 22 | const [users, setUsers] = useState([]); 23 | const [totalUsers, setTotalUsers] = useState(0); 24 | const pageSize = 6; 25 | 26 | useEffect(() => { 27 | const fetchUsers = () => { 28 | const filteredUsers = 29 | filterRole === 'All' ? dummyTeams : dummyTeams.filter((user) => user.role === filterRole); 30 | 31 | setTotalUsers(filteredUsers.length); 32 | setUsers(filteredUsers.slice((page - 1) * pageSize, page * pageSize)); 33 | }; 34 | 35 | fetchUsers(); 36 | }, [filterRole, page]); 37 | 38 | const handleFilterChange = (role: 'All' | 'Administrator' | 'Member') => { 39 | setFilterRole(role); 40 | setPage(1); // Reset to page 1 when the filter changes 41 | }; 42 | 43 | return ( 44 | <> 45 | 49 | 53 | 59 | 60 | 61 | 69 | 70 | 71 | 82 | 83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /src/hooks/useSort.ts: -------------------------------------------------------------------------------- 1 | import { useState, useMemo } from 'react'; 2 | 3 | type SortFunction = (a: T, b: T, orderDirection: 'asc' | 'desc' | undefined) => number; 4 | 5 | export function useSort( 6 | data: T[], 7 | initialKey: keyof T | undefined = undefined, 8 | customSort?: SortFunction, 9 | ) { 10 | const [orderDirection, setOrderDirection] = useState<'asc' | 'desc' | undefined>(undefined); 11 | const [orderBy, setOrderBy] = useState(initialKey); 12 | 13 | const sortedData = useMemo(() => { 14 | if (orderDirection && orderBy) { 15 | return [...data].sort((a, b) => { 16 | // 1) If a custom sort function is provided, use it. 17 | if (customSort) { 18 | return customSort(a, b, orderDirection); 19 | } 20 | 21 | let aValue: any = a[orderBy]; 22 | let bValue: any = b[orderBy]; 23 | 24 | // 2) If both values are Date objects, compare by getTime(). 25 | if (aValue instanceof Date && bValue instanceof Date) { 26 | return orderDirection === 'asc' 27 | ? aValue.getTime() - bValue.getTime() 28 | : bValue.getTime() - aValue.getTime(); 29 | } 30 | 31 | // 3) Handle nested objects with a `.name` property, or strings 32 | if (typeof aValue === 'object' && aValue !== null) { 33 | aValue = aValue.name?.toUpperCase() ?? ''; 34 | } else if (typeof aValue === 'string') { 35 | aValue = aValue.toUpperCase(); 36 | } 37 | if (typeof bValue === 'object' && bValue !== null) { 38 | bValue = bValue.name?.toUpperCase() ?? ''; 39 | } else if (typeof bValue === 'string') { 40 | bValue = bValue.toUpperCase(); 41 | } 42 | 43 | // 4) Default string/number comparison 44 | if (orderDirection === 'asc') { 45 | return aValue < bValue ? -1 : 1; 46 | } else if (orderDirection === 'desc') { 47 | return aValue > bValue ? -1 : 1; 48 | } 49 | return 0; 50 | }); 51 | } 52 | return data; 53 | }, [data, orderDirection, orderBy, customSort]); 54 | 55 | const handleSortRequest = (property: keyof T) => { 56 | if (orderBy === property) { 57 | // Toggle direction or reset 58 | if (orderDirection === 'asc') { 59 | setOrderDirection('desc'); 60 | } else if (orderDirection === 'desc') { 61 | setOrderDirection(undefined); 62 | setOrderBy(undefined); 63 | } else { 64 | setOrderDirection('asc'); 65 | } 66 | } else { 67 | setOrderBy(property); 68 | setOrderDirection('asc'); 69 | } 70 | }; 71 | 72 | return { sortedData, orderDirection, orderBy, handleSortRequest }; 73 | } 74 | -------------------------------------------------------------------------------- /src/app/team/components/UserTable.tsx: -------------------------------------------------------------------------------- 1 | import { Dropdown, Paginator } from '@/components'; 2 | 3 | import { TrashIcon } from '@/icons'; 4 | 5 | import { User } from '@/shared/models'; 6 | 7 | import { 8 | Paper, 9 | Table, 10 | TableBody, 11 | TableCell, 12 | TableContainer, 13 | TableHead, 14 | TableRow, 15 | Typography, 16 | Box, 17 | } from '@mui/material'; 18 | 19 | import IconButton from '@mui/material/IconButton'; 20 | 21 | interface Props { 22 | users: User[]; 23 | page: number; 24 | setPage: (page: number) => void; 25 | filterRole: 'All' | 'Administrator' | 'Member'; 26 | pageSize: number; 27 | totalUsers: number; 28 | } 29 | 30 | const UserTable = ({ users, page, setPage, pageSize, totalUsers }: Props) => ( 31 | <> 32 | 33 | 34 | 35 | 36 | Name 37 | Email 38 | Role 39 | Action 40 | 41 | 42 | 43 | {users.map((user, index) => ( 44 | 45 | 46 | {user.name} 47 | Created {user.createdAt} 48 | 49 | {user.email} 50 | 51 | { 59 | console.log(`Role changed to ${newRole} for user ${user.name}`); 60 | }} 61 | /> 62 | 63 | 64 | 65 | 70 | 71 | 72 | 73 | ))} 74 | 75 |
76 |
77 | 84 | 85 | ); 86 | 87 | export default UserTable; 88 | -------------------------------------------------------------------------------- /src/icons/files/PPTIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface PPTIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | } 7 | 8 | /** 9 | * A reusable SVG icon component for rendering an icon. 10 | * 11 | * @param {number} [width=25] - The width of the icon in pixels. Optional. 12 | * @param {number} [height=25] - The height of the icon in pixels. Optional. 13 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 14 | * 15 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 16 | */ 17 | 18 | const PPTIcon: FC = ({ width = 25, height = 25, ...props }) => { 19 | return ( 20 | 29 | 35 | 36 | ); 37 | }; 38 | 39 | export default PPTIcon; 40 | -------------------------------------------------------------------------------- /src/icons/editor/PencilIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface PencilIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | strokeWidth?: number; 8 | } 9 | 10 | /** 11 | * A reusable SVG icon component for rendering an icon. 12 | * 13 | * @param {number} [width=16] - The width of the icon in pixels. Optional. 14 | * @param {number} [height=16] - The height of the icon in pixels. Optional. 15 | * @param {string} [color='#667085'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 16 | * @param {number} [strokeWidth=1.5] - The stroke width of the icon's path. Optional. 17 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 18 | * 19 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 20 | */ 21 | 22 | const PencilIcon: FC = ({ 23 | width = 16, 24 | height = 16, 25 | color = '#667085', 26 | strokeWidth = 1.5, 27 | ...props 28 | }) => { 29 | return ( 30 | 39 | 40 | 47 | 48 | 49 | 50 | 55 | 56 | 57 | 58 | ); 59 | }; 60 | 61 | export default PencilIcon; 62 | -------------------------------------------------------------------------------- /src/app/api/documents/[documentId]/links/route.ts: -------------------------------------------------------------------------------- 1 | import { authService, createErrorResponse, LinkService } from '@/app/api/_services'; 2 | import { NextRequest, NextResponse } from 'next/server'; 3 | 4 | /** 5 | * GET /api/documents/[documentId]/links 6 | * Returns links for a doc owned by the user. 7 | */ 8 | export async function GET(req: NextRequest, props: { params: Promise<{ documentId: string }> }) { 9 | try { 10 | const userId = await authService.authenticate(); 11 | const { documentId } = await props.params; 12 | const links = await LinkService.getDocumentLinks(userId, documentId); 13 | if (links === null) { 14 | return createErrorResponse('Document not found or access denied.', 404); 15 | } 16 | 17 | const result = links.map((link) => ({ 18 | id: link.id, 19 | documentId: link.documentId, 20 | linkId: link.documentLinkId, 21 | alias: link.alias, 22 | createdLink: link.linkUrl, 23 | lastViewed: link.updatedAt, 24 | linkViews: 0, 25 | })); 26 | 27 | return NextResponse.json({ links: result }, { status: 200 }); 28 | } catch (error) { 29 | return createErrorResponse('Server error while fetching links.', 500, error); 30 | } 31 | } 32 | 33 | /** 34 | * POST /api/documents/[documentId]/links 35 | * Creates a new link for the doc. 36 | */ 37 | export async function POST(req: NextRequest, props: { params: Promise<{ documentId: string }> }) { 38 | const params = await props.params; 39 | try { 40 | const userId = await authService.authenticate(); 41 | const body = await req.json(); 42 | 43 | // Attempt creation 44 | try { 45 | const newLink = await LinkService.createLinkForDocument(userId, params.documentId, body); 46 | 47 | if (!newLink) { 48 | return createErrorResponse('Document not found or access denied.', 404); 49 | } 50 | return NextResponse.json( 51 | { message: 'Link created successfully.', link: newLink }, 52 | { status: 201 }, 53 | ); 54 | } catch (createErr) { 55 | // Check if we threw an "EXPIRATION_PAST" error 56 | if (createErr instanceof Error && createErr.message === 'EXPIRATION_PAST') { 57 | return createErrorResponse('Expiration time cannot be in the past.', 400); 58 | } 59 | if (createErr instanceof Error && createErr.message === 'FRIENDLY_NAME_CONFLICT') { 60 | return createErrorResponse( 61 | 'This alias is already in use. Please choose a different link alias.', 62 | 409, 63 | ); 64 | } 65 | throw createErr; // rethrow 66 | } 67 | } catch (error) { 68 | return createErrorResponse('Server error while creating link', 500, error); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/icons/general/SaveIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, SVGProps } from 'react'; 2 | 3 | interface SaveIconProps extends SVGProps { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | strokeWidth?: number; 8 | } 9 | 10 | /** 11 | * A reusable SVG icon component for rendering an icon. 12 | * 13 | * @param {number} [width=20] - The width of the icon in pixels. Optional. 14 | * @param {number} [height=20] - The height of the icon in pixels. Optional. 15 | * @param {string} [color='#667085'] - The stroke color of the icon. Accepts any valid CSS color value. Optional. 16 | * @param {number} [strokeWidth=2] - The stroke width of the icon's path. Optional. 17 | * @param {SVGProps} props - Additional SVG props such as `className`, `style`, or custom attributes. 18 | * 19 | * @returns {JSX.Element} A scalable vector graphic (SVG) element representing the icon. 20 | */ 21 | 22 | const SaveIcon: FC = ({ 23 | width = 20, 24 | height = 20, 25 | color = '#667085', 26 | strokeWidth = 2, 27 | ...props 28 | }) => { 29 | return ( 30 | 39 | 46 | 47 | ); 48 | }; 49 | 50 | export default SaveIcon; 51 | -------------------------------------------------------------------------------- /src/app/team/components/OrganizationName.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { PencilIcon, SaveIcon } from '@/icons'; 4 | import { Box, IconButton, TextField, Typography } from '@mui/material'; 5 | import { useEffect, useRef, useState } from 'react'; 6 | 7 | const OrganizationName = () => { 8 | const [isEditing, setIsEditing] = useState(false); 9 | const [companyName, setCompanyName] = useState('Bluewave Labs'); 10 | const inputRef = useRef(null); 11 | 12 | const handleEditClick = () => { 13 | setIsEditing(true); 14 | }; 15 | 16 | const handleSaveClick = () => { 17 | setIsEditing(false); 18 | // Add code here to save the updated name to the backend if needed 19 | console.log('Saved:', companyName); 20 | }; 21 | 22 | const handleNameChange = (event: React.ChangeEvent) => { 23 | setCompanyName(event.target.value); 24 | }; 25 | 26 | // Moves cursor to the end of Organization Name Textfield (Weird Requirment on figma) 27 | useEffect(() => { 28 | if (isEditing && inputRef.current) { 29 | const input = inputRef.current; 30 | input.focus(); 31 | input.setSelectionRange(input.value.length, input.value.length); 32 | } 33 | }, [isEditing]); 34 | 35 | return ( 36 | 41 | Organization name 42 | 46 | {isEditing ? ( 47 | 69 | ) : ( 70 | 78 | {companyName} 79 | 80 | )} 81 | 84 | {isEditing ? : } 85 | 86 | 87 | 88 | ); 89 | }; 90 | 91 | export default OrganizationName; 92 | -------------------------------------------------------------------------------- /src/app/api/documents/[documentId]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { authService, DocumentService, createErrorResponse } from '../../_services'; 3 | 4 | /** 5 | * GET /api/documents/[documentId] 6 | * Returns metadata for a single doc (ownership enforced). 7 | */ 8 | export async function GET(req: NextRequest, props: { params: Promise<{ documentId: string }> }) { 9 | try { 10 | const userId = await authService.authenticate(); 11 | const { documentId } = await props.params; 12 | const doc = await DocumentService.getDocumentById(userId, documentId); 13 | if (!doc) { 14 | return createErrorResponse('Document not found or access denied.', 404); 15 | } 16 | 17 | const responsePayload = { 18 | ...doc, 19 | uploader: { 20 | name: `${doc.User.first_name} ${doc.User.last_name}`, 21 | avatar: null, 22 | }, 23 | links: 0, 24 | viewers: 0, 25 | views: 0, 26 | }; 27 | 28 | return NextResponse.json({ document: responsePayload }, { status: 200 }); 29 | } catch (error) { 30 | return createErrorResponse('Server error while fetching document.', 500, error); 31 | } 32 | } 33 | 34 | /** 35 | * PATCH /api/documents/[documentId] 36 | */ 37 | export async function PATCH(req: NextRequest, props: { params: Promise<{ documentId: string }> }) { 38 | const { documentId } = await props.params; 39 | try { 40 | const userId = await authService.authenticate(); 41 | const body = await req.json(); 42 | 43 | const updatedDoc = await DocumentService.updateDocument(userId, documentId, { 44 | fileName: body.fileName, 45 | }); 46 | if (!updatedDoc) { 47 | return createErrorResponse('Document not found or access denied.', 404); 48 | } 49 | 50 | return NextResponse.json( 51 | { message: 'Document updated successfully.', document: updatedDoc }, 52 | { status: 200 }, 53 | ); 54 | } catch (error) { 55 | return createErrorResponse('Server error while updating document.', 500, error); 56 | } 57 | } 58 | 59 | /** 60 | * DELETE /api/documents/[documentId] 61 | */ 62 | export async function DELETE(req: NextRequest, props: { params: Promise<{ documentId: string }> }) { 63 | const { documentId } = await props.params; 64 | try { 65 | const userId = await authService.authenticate(); 66 | 67 | const deletedDoc = await DocumentService.deleteDocument(userId, documentId); 68 | if (!deletedDoc) { 69 | return createErrorResponse('Document not found or access denied.', 404); 70 | } 71 | 72 | return NextResponse.json({ message: 'Document deleted successfully.' }, { status: 200 }); 73 | } catch (error) { 74 | return createErrorResponse('Server error while deleting document.', 500, error); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/app/auth/email-sent/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { NavLink } from '@/components'; 4 | 5 | import { Box, Container, Link, Typography } from '@mui/material'; 6 | 7 | import NextLink from 'next/link'; 8 | import { FormEvent, useState } from 'react'; 9 | 10 | import { MailIcon } from '@/icons'; 11 | 12 | export default function EmailSent() { 13 | const [email, setEmail] = useState('your_email@bluewave.ca'); 14 | const [resending, setResending] = useState(false); 15 | const [resendSuccess, setResendSuccess] = useState(false); 16 | 17 | const handleResendEmail = (event: FormEvent) => { 18 | event.preventDefault(); 19 | setResending(true); 20 | // Simulate resending reset email 21 | setTimeout(() => { 22 | setResending(false); 23 | setResendSuccess(true); 24 | }, 5000); // Mock API delay 25 | }; 26 | 27 | return ( 28 | 31 | 37 | 46 | 47 | 48 | 49 | 52 | Check your email 53 | 54 | 55 | 59 | We sent a password reset link to {email} 60 | 61 | 62 | 63 | Didn’t receive the email?{' '} 64 | 70 | {resending ? 'Resending...' : 'Click to resend'} 71 | 72 | 73 | 74 | 79 | 80 | {resendSuccess && ( 81 | 85 | A reset email has been sent again to {email}. 86 | 87 | )} 88 | {/* Temporary Link to reset Password */} 89 | 92 | Password Reset 93 | 94 | 95 | 96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /src/providers/auth/AuthWrapper.tsx: -------------------------------------------------------------------------------- 1 | import SignIn from '@/app/auth/sign-in/page'; 2 | 3 | import { Sidebar } from '@/components'; 4 | 5 | import { Box, CircularProgress } from '@mui/material'; 6 | import { useSession } from 'next-auth/react'; 7 | import { usePathname } from 'next/navigation'; 8 | import { ReactNode, useEffect, useState } from 'react'; 9 | 10 | export default function AuthWrapper({ children }: { children: ReactNode }) { 11 | const { data: session, status } = useSession(); 12 | const pathname = usePathname(); // Get the current route path 13 | 14 | // Define the public routes 15 | const publicRoutes = [ 16 | '/auth/sign-up', 17 | '/auth/forgot-password', 18 | '/auth/reset-password', 19 | '/auth/account-created', 20 | '/auth/password-reset-confirm', 21 | '/auth/check-email', 22 | '/auth/sign-in', 23 | ]; 24 | 25 | // Check if the current path starts with /auth/reset-password, which is dynamic 26 | const isResetPassFormRoute = 27 | pathname.startsWith('/auth/reset-password') && pathname.includes('reset-password'); 28 | 29 | const isLinksUuidRoute = 30 | pathname.startsWith('/documentAccess/') && 31 | /^[a-f0-9-]{36}$/.test(pathname.split('/documentAccess/')[1]); 32 | // Local state to handle loading state 33 | const [isLoading, setIsLoading] = useState(true); 34 | 35 | // useEffect to handle session status changes 36 | useEffect(() => { 37 | if (status === 'loading') { 38 | setIsLoading(true); 39 | } else { 40 | setIsLoading(false); 41 | } 42 | }, [status]); 43 | 44 | // Show a loading state while fetching the session 45 | if (isLoading) { 46 | return ( 47 | 52 | 53 | 54 | ); 55 | } 56 | 57 | if (isLinksUuidRoute) { 58 | return <>{children}; 59 | } 60 | 61 | // If the user is trying to access a restricted route and is not signed in, redirect to the sign-in page 62 | if (!session && !publicRoutes.includes(pathname) && !isResetPassFormRoute) { 63 | // Redirect the user to the sign-in page with a callback URL 64 | return ; 65 | } 66 | 67 | // Render authenticated layout only when session is authenticated 68 | if (publicRoutes.includes(pathname) || isResetPassFormRoute) { 69 | return <>{children}; 70 | } 71 | 72 | return ( 73 | <> 74 | 79 | 80 | 84 | {children} 85 | 86 | 87 | 88 | ); 89 | } 90 | --------------------------------------------------------------------------------