From 088233d6e985ad9907144bc59a997caa95f0119a Mon Sep 17 00:00:00 2001 From: Danielkombou Date: Fri, 6 Feb 2026 15:13:23 +0100 Subject: [PATCH 1/6] responsivess addjusted --- components/Dashboard/RepositoryView.tsx | 12 +-- components/FileExplorer/FileDetailModal.tsx | 51 ++++++------ components/FileExplorer/FileExplorer.tsx | 12 +-- components/FileExplorer/TableView.tsx | 91 ++++++++++++--------- components/Forms/AuthForm.tsx | 50 +++++------ components/Landing/LandingPage.tsx | 31 +++---- components/Layout/DashboardLayout.tsx | 36 ++++---- components/Layout/Sidebar.tsx | 36 +++++++- components/Settings/SettingsPage.tsx | 67 +++++++-------- package-lock.json | 7 -- 10 files changed, 215 insertions(+), 178 deletions(-) diff --git a/components/Dashboard/RepositoryView.tsx b/components/Dashboard/RepositoryView.tsx index 4956f3f..c205986 100644 --- a/components/Dashboard/RepositoryView.tsx +++ b/components/Dashboard/RepositoryView.tsx @@ -32,19 +32,19 @@ const RepositoryView: React.FC = () => { }, [files, activeTab, searchQuery]); return ( -
-
-
-

+
+
+
+

{activeTab === 'ALL' ? 'Repository Overview' : `${activeTab} Management`}

-

+

Indexing {filteredFiles.length} enterprise documents

diff --git a/components/FileExplorer/FileDetailModal.tsx b/components/FileExplorer/FileDetailModal.tsx index c25eca1..042244e 100644 --- a/components/FileExplorer/FileDetailModal.tsx +++ b/components/FileExplorer/FileDetailModal.tsx @@ -111,51 +111,52 @@ const FileDetailModal: React.FC = ({ const docClassification = file.isClassifying ? 'Classifying...' : file.docType; return ( -
+
-
+
{/* Header */} -
-
-
- +
+
+
+
-
-

{file.name}

-
- +
+

{file.name}

+
+ {docClassification} - - {(file.size / 1024 / 1024).toFixed(2)} MB + + {(file.size / 1024 / 1024).toFixed(2)} MB
-
+
{/* Content Area */} -
+
{/* Left: Document Viewer */} -
+
{isPdf && rawFile ? ( @@ -255,22 +256,22 @@ const FileDetailModal: React.FC = ({
{/* Chat Input */} -
-
+
+ setChatMessage(e.target.value)} />
diff --git a/components/FileExplorer/FileExplorer.tsx b/components/FileExplorer/FileExplorer.tsx index d9974c8..0448194 100644 --- a/components/FileExplorer/FileExplorer.tsx +++ b/components/FileExplorer/FileExplorer.tsx @@ -62,18 +62,18 @@ const FileExplorer: React.FC = ({ return (
{/* Navigation Header */} -
+
{currentPath !== 'Root' && ( )} -
+
{breadcrumbs.map((part, i) => ( - {i < breadcrumbs.length - 1 && } + {i < breadcrumbs.length - 1 && } ))}
-
+
{/* Go Back Trigger Card */} {currentPath !== 'Root' && ( diff --git a/components/FileExplorer/TableView.tsx b/components/FileExplorer/TableView.tsx index 9f768fe..f42630d 100644 --- a/components/FileExplorer/TableView.tsx +++ b/components/FileExplorer/TableView.tsx @@ -20,25 +20,27 @@ const TableView: React.FC = ({ files, onAnalyze, onDelete, setSe }; return ( -
-
- - - - - - - - - - +
+
+
+
+
Document IdentifierAI IntelligenceWorkflowTimestampActions
+ + + + + + + + + {files.length === 0 ? ( - @@ -49,26 +51,35 @@ const TableView: React.FC = ({ files, onAnalyze, onDelete, setSe className="hover:bg-slate-50 dark:hover:bg-slate-800/40 group cursor-pointer transition-colors" onClick={() => setSelectedFile(file)} > - - - - - @@ -109,7 +120,9 @@ const TableView: React.FC = ({ files, onAnalyze, onDelete, setSe )) )} -
DocumentAI IntelligenceWorkflowTimestampActions
+
- -

Database Empty

+ +

Database Empty

-
-
- +
+
+
+
-
-

{file.name}

-

{file.path}

+
+

{file.name}

+

{file.path}

+ {/* Mobile: Show doc type and status inline */} +
+ + {file.isClassifying ? 'Processing' : file.docType} + + + {file.status} + +
+ {file.isClassifying ? ( -
-
- Processing +
+
+ Processing
) : (
- + {file.docType} {file.summary && ( @@ -77,31 +88,31 @@ const TableView: React.FC = ({ files, onAnalyze, onDelete, setSe
)}
- + + {file.status} -

{new Date(file.createdAt).toLocaleDateString()}

-

{formatSize(file.size)}

+
+

{new Date(file.createdAt).toLocaleDateString()}

+

{formatSize(file.size)}

-
+
+
+ +
+
); diff --git a/components/Forms/AuthForm.tsx b/components/Forms/AuthForm.tsx index 6947030..d09f95a 100644 --- a/components/Forms/AuthForm.tsx +++ b/components/Forms/AuthForm.tsx @@ -35,47 +35,47 @@ const AuthForm: React.FC = ({ onLogin }) => { }; return ( -
-
+
+
-
-

- Eqorascale - , Intelligent document indexing for procurement +
+

+ Eqorascale + , Intelligent document indexing for procurement

-

+

Migrate folder trees, index every file, and generate quotes & invoices using AI. Secure, enterprise-focused.

-
+
-
-
+
+
-

+

Log In to Eqorascale

-
+
- +
-
- +
+
setUsername(e.target.value)} @@ -83,34 +83,34 @@ const AuthForm: React.FC = ({ onLogin }) => {
- +
-
- +
+
setPassword(e.target.value)} />
- +
- {error &&

{error}

} + {error &&

{error}

} -
If you need an account, contact your system admin.
+
If you need an account, contact your system admin.
diff --git a/components/Landing/LandingPage.tsx b/components/Landing/LandingPage.tsx index 1f89deb..cd6bffa 100644 --- a/components/Landing/LandingPage.tsx +++ b/components/Landing/LandingPage.tsx @@ -13,12 +13,12 @@ const LandingPage: React.FC = () => { return (
-
+
-
Supply Chain Intelligence
-

Master your Supply Chain with AI Precision.

-

Enterprise-grade document management for industrial sectors. Automate RFQs, POs, and Invoices with AI intelligence.

-
- - +
Supply Chain Intelligence
+

Master your Supply Chain with AI Precision.

+

Enterprise-grade document management for industrial sectors. Automate RFQs, POs, and Invoices with AI intelligence.

+
+ +
{/* Other sections removed for brevity but they remain functional links */} -
-

© 2026 Eqorascale Enterprise. V1.0.0 MVP PLATFORM

+
+

© 2026 Eqorascale Enterprise. V1.0.0 MVP PLATFORM

); diff --git a/components/Layout/DashboardLayout.tsx b/components/Layout/DashboardLayout.tsx index 7c2e1e6..4276e8c 100644 --- a/components/Layout/DashboardLayout.tsx +++ b/components/Layout/DashboardLayout.tsx @@ -189,39 +189,39 @@ const DashboardLayout: React.FC = ({ user, onLogout, isDar
-
-
-
-
- +
+
+
+
+ setSearchQuery(e.target.value)} />
-
-
- - + +
-
-
+
= ({ onLogout, user }) => { const navigate = useNavigate(); + const [isMobileOpen, setIsMobileOpen] = useState(false); const menuItems = [ { id: 'ALL', path: '/app/repository/ALL', label: 'Dashboard', icon: Icons.LayoutGrid }, @@ -21,12 +22,38 @@ const Sidebar: React.FC = ({ onLogout, user }) => { ]; return ( - + ); }; diff --git a/components/Settings/SettingsPage.tsx b/components/Settings/SettingsPage.tsx index b6b5319..bb01259 100644 --- a/components/Settings/SettingsPage.tsx +++ b/components/Settings/SettingsPage.tsx @@ -41,69 +41,70 @@ const SettingsPage: React.FC = () => { if (isError) return ; return ( -
-
+
+
{/* Header Section */} -
-
-

+
+
+

Settings

-

+

Manage your personal details and security preferences.

-
- - +
+ + - + - System Operational + System Operational + Operational
{data && ( -
+
{/* 1. Hero Profile Card (Spans 8 columns) */} -
+
{/* Decorative Gradient Background */} -
+
-
-
+
+
{/* Avatar */} -
+
{getInitials(data.username)}
{/* Name & Badge */} -
-
-

+
+
+

{data.username}

{data.is_active ? ( - + Active ) : ( - + Inactive )}
- - {data.email || 'No email linked'} + + {data.email || 'No email linked'}

-
+
{
{/* 2. Status & Security Card (Spans 4 columns) */} -
-
+
+

@@ -213,13 +214,13 @@ const InfoItem = ({ highlight?: boolean, copyable?: boolean }) => ( -
-
- +
+
+
-
-

{label}

-

+

+

{label}

+

{value || '—'}

diff --git a/package-lock.json b/package-lock.json index 1159ac1..59bdab9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,7 +70,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1807,7 +1806,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2797,7 +2795,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2825,7 +2822,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2871,7 +2867,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -2881,7 +2876,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3273,7 +3267,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", From e9f8ec7958119cf55a4cb82c80c129fcbca5f8c1 Mon Sep 17 00:00:00 2001 From: Danielkombou Date: Fri, 6 Feb 2026 15:33:11 +0100 Subject: [PATCH 2/6] resolved the flash on project mount, so it directly laods the default background color --- .gitignore | 10 ++++++++++ App.tsx | 16 +++++++++++++--- components/Landing/LandingPage.tsx | 2 +- index.css | 10 ++++++++++ index.html | 28 ++++++++++++++++++++++++++++ 5 files changed, 62 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index a547bf3..fc98fc5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,16 @@ dist dist-ssr *.local +# dotenv environment variable files +.env +.env.local +.env.development +.env.production +.env.test +.env.test.local +.env.production.local +.env.development.local + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/App.tsx b/App.tsx index 8e2076b..94d31b2 100644 --- a/App.tsx +++ b/App.tsx @@ -32,7 +32,7 @@ const ProtectedRoute: React.FC = ({ children }) => { }; const PublicLayout = () => ( -
+
); @@ -44,15 +44,25 @@ const App: React.FC = () => { const [authLoading, setAuthLoading] = useState(true); const [isDarkMode, setIsDarkMode] = useState(() => { + // Check if dark class was already applied by inline script + const hasDarkClass = document.documentElement.classList.contains('dark'); + if (hasDarkClass) { + const saved = localStorage.getItem('theme'); + return saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches); + } const saved = localStorage.getItem('theme'); if (saved) return saved === 'dark'; return window.matchMedia('(prefers-color-scheme: dark)').matches; }); useEffect(() => { + // Sync dark mode class immediately on mount (already set by inline script, but ensure consistency) const root = window.document.documentElement; - if (isDarkMode) root.classList.add('dark'); - else root.classList.remove('dark'); + if (isDarkMode) { + root.classList.add('dark'); + } else { + root.classList.remove('dark'); + } localStorage.setItem('theme', isDarkMode ? 'dark' : 'light'); }, [isDarkMode]); diff --git a/components/Landing/LandingPage.tsx b/components/Landing/LandingPage.tsx index cd6bffa..ae3bbdd 100644 --- a/components/Landing/LandingPage.tsx +++ b/components/Landing/LandingPage.tsx @@ -11,7 +11,7 @@ const LandingPage: React.FC = () => { const navigate = useNavigate(); return ( -
+

- fileInputRef.current?.click(), - onReset: () => setIsResetModalOpen(true) - }} /> + {filesLoading ? ( +
+
+
+ ) : ( + fileInputRef.current?.click(), + onReset: () => setIsResetModalOpen(true) + }} /> + )}
{selectedFile && ( setSelectedFile(null)} onAnalyze={handleManualAnalyze} onAsk={(q) => askDocumentQuestion(selectedFile.name, selectedFile.content || '', q)} - isProcessing={selectedFile.isClassifying || false} + isProcessing={false} /> )} diff --git a/components/Layout/Sidebar.tsx b/components/Layout/Sidebar.tsx index a72072c..3ff003a 100644 --- a/components/Layout/Sidebar.tsx +++ b/components/Layout/Sidebar.tsx @@ -3,6 +3,7 @@ import React, { useState } from 'react'; import { NavLink, useNavigate } from 'react-router-dom'; import { DocumentType, User } from '../../types'; import { Icons } from '../../constants'; +import { useIsAdmin, useCanAccess } from '../../hooks/usePermissions'; interface SidebarProps { onLogout: () => void; @@ -12,6 +13,8 @@ interface SidebarProps { const Sidebar: React.FC = ({ onLogout, user }) => { const navigate = useNavigate(); const [isMobileOpen, setIsMobileOpen] = useState(false); + const isAdmin = useIsAdmin(); + const canViewAnalytics = useCanAccess('analytics'); const menuItems = [ { id: 'ALL', path: '/app/repository/ALL', label: 'Dashboard', icon: Icons.LayoutGrid }, @@ -78,12 +81,26 @@ const Sidebar: React.FC = ({ onLogout, user }) => { `w-full flex items-center px-4 py-2.5 rounded-xl text-sm font-medium transition-all group ${isActive ? 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-400' : 'text-slate-400 dark:text-slate-500 hover:bg-slate-50 dark:hover:bg-slate-800'}`}> Collections - `w-full flex items-center px-4 py-2.5 rounded-xl text-sm font-medium transition-all group ${isActive ? 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-400' : 'text-slate-400 dark:text-slate-500 hover:bg-slate-50 dark:hover:bg-slate-800'}`}> - Analytics - + {canViewAnalytics && ( + `w-full flex items-center px-4 py-2.5 rounded-xl text-sm font-medium transition-all group ${isActive ? 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-400' : 'text-slate-400 dark:text-slate-500 hover:bg-slate-50 dark:hover:bg-slate-800'}`}> + Analytics + + )} `w-full flex items-center px-4 py-2.5 rounded-xl text-sm font-medium transition-all group ${isActive ? 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-400' : 'text-slate-400 dark:text-slate-500 hover:bg-slate-50 dark:hover:bg-slate-800'}`}> Settings + + {/* Admin Section */} + {isAdmin && ( + <> +
+

Admin

+ `w-full flex items-center px-4 py-2.5 rounded-xl text-sm font-medium transition-all group ${isActive ? 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-400' : 'text-slate-400 dark:text-slate-500 hover:bg-slate-50 dark:hover:bg-slate-800'}`}> + Admin Panel + +
+ + )}
diff --git a/components/Settings/SettingsPage.tsx b/components/Settings/SettingsPage.tsx index 364bf4e..c854581 100644 --- a/components/Settings/SettingsPage.tsx +++ b/components/Settings/SettingsPage.tsx @@ -178,7 +178,7 @@ const SettingsPage: React.FC = () => {

- Your session is secured via JWT. Role-based access control is currently enforced. + Your session is secured via JWT. Role-based access control is actively enforced.

diff --git a/constants.tsx b/constants.tsx index cc00954..2b632a3 100644 --- a/constants.tsx +++ b/constants.tsx @@ -20,7 +20,18 @@ import { Trash2, Upload, LockIcon, - Monitor + Monitor, + Users, + UserPlus, + Edit, + Shield, + Activity, + Database, + Clock, + BarChart3, + TrendingUp, + Key, + Filter } from 'lucide-react'; export const STATUS_COLORS = { @@ -59,5 +70,16 @@ export const Icons = { ArrowRight, Globe, Trash: Trash2, - Lock:LockIcon + Lock: LockIcon, + Users, + UserPlus, + Edit, + Shield, + Activity, + Database, + Clock, + BarChart3, + TrendingUp, + Key, + Filter }; diff --git a/hooks/usePermissions.ts b/hooks/usePermissions.ts new file mode 100644 index 0000000..59092f6 --- /dev/null +++ b/hooks/usePermissions.ts @@ -0,0 +1,81 @@ +/** + * Permission Hooks for RBAC + * React hooks for checking user permissions and roles + */ + +import { useContext } from 'react'; +import { AuthContext } from '../App'; +import { + Permission, + hasPermission, + hasAnyPermission, + hasAllPermissions, + hasRole, + isAdmin, + getUserPermissions, + canAccess, +} from '../utils/permissions'; + +/** + * Hook to check if current user has a specific permission + */ +export const useHasPermission = (permission: Permission): boolean => { + const { user } = useContext(AuthContext); + return hasPermission(user, permission); +}; + +/** + * Hook to check if current user has any of the specified permissions + */ +export const useHasAnyPermission = (permissions: Permission[]): boolean => { + const { user } = useContext(AuthContext); + return hasAnyPermission(user, permissions); +}; + +/** + * Hook to check if current user has all of the specified permissions + */ +export const useHasAllPermissions = (permissions: Permission[]): boolean => { + const { user } = useContext(AuthContext); + return hasAllPermissions(user, permissions); +}; + +/** + * Hook to check if current user has a specific role + */ +export const useHasRole = (role: string): boolean => { + const { user } = useContext(AuthContext); + return hasRole(user, role); +}; + +/** + * Hook to check if current user is an admin + */ +export const useIsAdmin = (): boolean => { + const { user } = useContext(AuthContext); + return isAdmin(user); +}; + +/** + * Hook to get all permissions for current user + */ +export const useUserPermissions = (): Permission[] => { + const { user } = useContext(AuthContext); + return getUserPermissions(user); +}; + +/** + * Hook to check if current user can access a feature + */ +export const useCanAccess = (feature: string): boolean => { + const { user } = useContext(AuthContext); + return canAccess(user, feature); +}; + +/** + * Hook to get current user + */ +export const useUser = () => { + const { user } = useContext(AuthContext); + return user; +}; diff --git a/services/api.ts b/services/api.ts index f7f2196..95fa84b 100644 --- a/services/api.ts +++ b/services/api.ts @@ -5,7 +5,7 @@ type ApiError = { }; const API_BASE_URL = - (import.meta as any).env?.VITE_API_BASE_URL || 'http://localhost:3000'; + import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'; const getAuthToken = () => localStorage.getItem('eqorascale_token'); diff --git a/services/auth.ts b/services/auth.ts index a1eb602..df6c482 100644 --- a/services/auth.ts +++ b/services/auth.ts @@ -1,5 +1,6 @@ import { User } from '../types'; import { apiFetch, setAuthToken } from './api'; +import { isAdmin, hasRole, hasPermission, Permission } from '../utils/permissions'; type LoginResponse = { message: string; @@ -63,3 +64,62 @@ export const getProfileDetails = async (): Promise => { export const logoutUser = () => { setAuthToken(null); }; + +export type RegisterUserDto = { + username: string; + email: string; + password: string; + metadata?: Record; + role?: 'admin' | 'user'; + secretKey: string; +}; + +type RegisterResponse = { + message: string; + user: any; +}; + +export const registerUser = async (registerDto: RegisterUserDto) => { + const data = await apiFetch('/auth/register', { + method: 'POST', + body: registerDto, + }); + + return mapUser(data.user); +}; + +/** + * Role validation helpers + */ +export const validateUserRole = (user: User | null, requiredRole: string): boolean => { + return hasRole(user, requiredRole); +}; + +export const validateUserIsAdmin = (user: User | null): boolean => { + return isAdmin(user); +}; + +export const validateUserPermission = (user: User | null, permission: Permission): boolean => { + return hasPermission(user, permission); +}; + +/** + * Check if user can perform an action + */ +export const canUserPerformAction = (user: User | null, action: string): boolean => { + if (!user) return false; + + const actionPermissions: Record = { + 'delete-document': Permission.DELETE_DOCUMENTS, + 'upload-document': Permission.UPLOAD_DOCUMENTS, + 'edit-document': Permission.EDIT_DOCUMENTS, + 'manage-users': Permission.MANAGE_USER_ROLES, + 'view-analytics': Permission.VIEW_ANALYTICS, + 'reset-database': Permission.RESET_DATABASE, + }; + + const requiredPermission = actionPermissions[action]; + if (!requiredPermission) return false; + + return hasPermission(user, requiredPermission); +}; diff --git a/services/files.ts b/services/files.ts new file mode 100644 index 0000000..dfb83fa --- /dev/null +++ b/services/files.ts @@ -0,0 +1,293 @@ +import { apiFetch } from './api'; +import { FileRecord, DocumentType, FileStatus } from '../types'; + +// Backend file response types +export interface BackendFile { + id: number; + originalName: string; + fileName: string; + mimeType: string; + size: number; + path: string; + ocrText?: string | null; + folder?: { + id: number; + name: string; + } | null; + createdAt?: string; + updatedAt?: string; +} + +export interface BackendFolder { + id: number; + name: string; + type: 'folder'; + createdAt?: string; + updatedAt?: string; + children?: (BackendFile | BackendFolder)[]; +} + +export interface FileListResponse { + type: 'root' | 'folder'; + data: { + folders?: BackendFolder[]; + files?: BackendFile[]; + id?: number; + name?: string; + children?: (BackendFile | BackendFolder)[]; + }; +} + +export interface UploadResponse { + message: string; + file: BackendFile; + textExtracted: boolean; + extractedTextLength: number; +} + +export interface UploadMultipleResponse { + message: string; + successful: UploadResponse[]; + failed: Array<{ + fileName: string; + error: string; + }>; + totalFiles: number; + successfulCount: number; + failedCount: number; +} + +export interface UploadFolderResponse { + message: string; + jobId?: string; + totalFiles?: number; + status?: string; + progressUrl?: string; + successful?: UploadResponse[]; + failed?: Array<{ fileName: string; error: string }>; + foldersCreated?: number; + successfulCount?: number; + failedCount?: number; +} + +export interface JobStatusResponse { + jobId: string; + state: 'waiting' | 'active' | 'completed' | 'failed' | 'delayed'; + progress: { + processed: number; + total: number; + percentage: number; + }; + data?: { + files?: BackendFile[]; + folderPath?: string; + }; + result?: { + message: string; + successful: UploadResponse[]; + failed: Array<{ fileName: string; error: string }>; + }; + failedReason?: string | null; + attemptsMade: number; + timestamp: number; +} + +export interface ExtractTextResponse { + message: string; + extractedText: string; + fileName: string; + hasText: boolean; +} + +// Convert backend file to frontend FileRecord +const mapBackendFileToFileRecord = (file: BackendFile, folderPath: string = 'Root'): FileRecord => { + // Determine document type from filename or content (basic heuristic) + let docType = DocumentType.GENERAL; + const name = file.originalName.toLowerCase(); + if (name.includes('rfq') || name.includes('request for quotation')) { + docType = DocumentType.RFQ; + } else if (name.includes('po') || name.includes('purchase order')) { + docType = DocumentType.PO; + } else if (name.includes('quotation') || name.includes('quote')) { + docType = DocumentType.QUOTATION; + } else if (name.includes('invoice')) { + docType = DocumentType.INVOICE; + } + + return { + id: String(file.id), + name: file.originalName, + type: file.mimeType, + size: file.size, + path: folderPath, + createdAt: file.createdAt || new Date().toISOString(), + status: FileStatus.COMPLETED, + docType, + tags: [], + content: file.ocrText || undefined, + blobUrl: file.path, // Use the Supabase URL as blob URL + }; +}; + +// List files and folders +export const listFiles = async (folderId?: number): Promise => { + const query = folderId ? `?folderId=${folderId}` : ''; + return apiFetch(`/files/list${query}`, { + method: 'GET', + }); +}; + +// Convert backend structure to flat FileRecord array +export const flattenFileStructure = ( + structure: FileListResponse, + parentPath: string = 'Root' +): FileRecord[] => { + const files: FileRecord[] = []; + + const processFolder = (folder: BackendFolder | BackendFile, currentPath: string) => { + if ('type' in folder && folder.type === 'folder') { + const folderPath = `${currentPath}/${folder.name}`; + if (folder.children) { + folder.children.forEach((item) => { + if ('type' in item && item.type === 'folder') { + processFolder(item as BackendFolder, folderPath); + } else { + files.push(mapBackendFileToFileRecord(item as BackendFile, folderPath)); + } + }); + } + } else { + files.push(mapBackendFileToFileRecord(folder as BackendFile, currentPath)); + } + }; + + if (structure.type === 'root' && structure.data.folders) { + structure.data.folders.forEach((folder) => { + processFolder(folder, parentPath); + }); + } + + if (structure.data.files) { + structure.data.files.forEach((file) => { + files.push(mapBackendFileToFileRecord(file, parentPath)); + }); + } + + if (structure.type === 'folder' && structure.data.children) { + structure.data.children.forEach((item) => { + if ('type' in item && item.type === 'folder') { + processFolder(item as BackendFolder, `${parentPath}/${structure.data.name || ''}`); + } else { + files.push(mapBackendFileToFileRecord(item as BackendFile, `${parentPath}/${structure.data.name || ''}`)); + } + }); + } + + return files; +}; + +// Get single file by ID +export const getFile = async (id: number): Promise => { + return apiFetch(`/files/${id}`, { + method: 'GET', + }); +}; + +// Upload single file +export const uploadFile = async (file: File): Promise => { + const formData = new FormData(); + formData.append('file', file); + + return apiFetch('/files/upload', { + method: 'POST', + body: formData, + }); +}; + +// Upload multiple files +export const uploadMultipleFiles = async (files: File[]): Promise => { + const formData = new FormData(); + files.forEach((file) => { + formData.append('files', file); + }); + + return apiFetch('/files/upload-multiple', { + method: 'POST', + body: formData, + }); +}; + +// Upload folder structure +export const uploadFolder = async ( + files: File[], + folderPath?: string +): Promise => { + const formData = new FormData(); + files.forEach((file) => { + formData.append('files', file); + }); + if (folderPath) { + formData.append('folderPath', folderPath); + } + + return apiFetch('/files/upload-folder', { + method: 'POST', + body: formData, + }); +}; + +// Extract text from file +export const extractText = async (file: File): Promise => { + const formData = new FormData(); + formData.append('file', file); + + return apiFetch('/files/extract-text', { + method: 'POST', + body: formData, + }); +}; + +// Delete file +export const deleteFile = async (id: number): Promise => { + await apiFetch(`/files/${id}`, { + method: 'DELETE', + }); +}; + +// Rename file +export const renameFile = async (id: number, newName: string): Promise => { + return apiFetch(`/files/${id}/rename`, { + method: 'PATCH', + body: { name: newName }, + }); +}; + +// Get job status +export const getJobStatus = async (jobId: string): Promise => { + return apiFetch(`/files/job/${jobId}`, { + method: 'GET', + }); +}; + +// Upload chunk (experimental) +export const uploadChunk = async ( + chunk: File, + chunkIndex: number, + totalChunks: number, + uploadId: string, + folderPath?: string +): Promise => { + const formData = new FormData(); + formData.append('chunk', chunk); + formData.append('chunkIndex', String(chunkIndex)); + formData.append('totalChunks', String(totalChunks)); + formData.append('uploadId', uploadId); + if (folderPath) { + formData.append('folderPath', folderPath); + } + + return apiFetch('/files/upload-chunk', { + method: 'POST', + body: formData, + }); +}; diff --git a/services/users.ts b/services/users.ts new file mode 100644 index 0000000..924a50f --- /dev/null +++ b/services/users.ts @@ -0,0 +1,21 @@ +import { apiFetch } from './api'; +import { UserProfile } from './auth'; + +export interface UserListResponse { + users: UserProfile[]; + total: number; +} + +// Get all users (admin only) +export const getAllUsers = async (): Promise => { + return apiFetch('/users/all', { + method: 'GET', + }); +}; + +// Delete user (admin only) +export const deleteUser = async (id: number): Promise<{ message: string }> => { + return apiFetch<{ message: string }>(`/users/${id}`, { + method: 'DELETE', + }); +}; diff --git a/store/mockData.ts b/store/mockData.ts deleted file mode 100644 index 8e3c735..0000000 --- a/store/mockData.ts +++ /dev/null @@ -1,9 +0,0 @@ - -import { FileRecord } from '../types'; - -/** - * Starting database for the MVP. - * Initialized as an empty array to allow users to populate the system - * exclusively with their own documents and folder structures. - */ -export const INITIAL_FILES: FileRecord[] = []; \ No newline at end of file diff --git a/utils/permissions.ts b/utils/permissions.ts new file mode 100644 index 0000000..ecc9e03 --- /dev/null +++ b/utils/permissions.ts @@ -0,0 +1,140 @@ +/** + * Permission System for EquoraScale RBAC + * Defines permissions and role-based access control + */ + +import { User } from '../types'; + +export enum Permission { + // Document Permissions + VIEW_DOCUMENTS = 'view:documents', + UPLOAD_DOCUMENTS = 'upload:documents', + DELETE_DOCUMENTS = 'delete:documents', + EDIT_DOCUMENTS = 'edit:documents', + ANALYZE_DOCUMENTS = 'analyze:documents', + + // User Management Permissions + VIEW_USERS = 'view:users', + CREATE_USERS = 'create:users', + EDIT_USERS = 'edit:users', + DELETE_USERS = 'delete:users', + MANAGE_USER_ROLES = 'manage:user:roles', + + // System Permissions + VIEW_ANALYTICS = 'view:analytics', + MANAGE_SETTINGS = 'manage:settings', + VIEW_SYSTEM_LOGS = 'view:system:logs', + MANAGE_API_KEYS = 'manage:api:keys', + RESET_DATABASE = 'reset:database', + + // Collection Permissions + VIEW_COLLECTIONS = 'view:collections', + MANAGE_COLLECTIONS = 'manage:collections', +} + +// Role to Permissions mapping +export const ROLE_PERMISSIONS: Record = { + admin: [ + // Documents - Full access + Permission.VIEW_DOCUMENTS, + Permission.UPLOAD_DOCUMENTS, + Permission.DELETE_DOCUMENTS, + Permission.EDIT_DOCUMENTS, + Permission.ANALYZE_DOCUMENTS, + + // User Management - Full access + Permission.VIEW_USERS, + Permission.CREATE_USERS, + Permission.EDIT_USERS, + Permission.DELETE_USERS, + Permission.MANAGE_USER_ROLES, + + // System - Full access + Permission.VIEW_ANALYTICS, + Permission.MANAGE_SETTINGS, + Permission.VIEW_SYSTEM_LOGS, + Permission.MANAGE_API_KEYS, + Permission.RESET_DATABASE, + + // Collections - Full access + Permission.VIEW_COLLECTIONS, + Permission.MANAGE_COLLECTIONS, + ], + user: [ + // Documents - Limited access + Permission.VIEW_DOCUMENTS, + Permission.UPLOAD_DOCUMENTS, + Permission.ANALYZE_DOCUMENTS, + + // Collections - View only + Permission.VIEW_COLLECTIONS, + ], +}; + +/** + * Check if a user has a specific permission + */ +export const hasPermission = (user: User | null, permission: Permission): boolean => { + if (!user) return false; + const userPermissions = ROLE_PERMISSIONS[user.role] || []; + return userPermissions.includes(permission); +}; + +/** + * Check if a user has any of the specified permissions + */ +export const hasAnyPermission = (user: User | null, permissions: Permission[]): boolean => { + if (!user) return false; + return permissions.some(permission => hasPermission(user, permission)); +}; + +/** + * Check if a user has all of the specified permissions + */ +export const hasAllPermissions = (user: User | null, permissions: Permission[]): boolean => { + if (!user) return false; + return permissions.every(permission => hasPermission(user, permission)); +}; + +/** + * Check if user has a specific role + */ +export const hasRole = (user: User | null, role: string): boolean => { + return user?.role === role; +}; + +/** + * Check if user is admin + */ +export const isAdmin = (user: User | null): boolean => { + return hasRole(user, 'admin'); +}; + +/** + * Get all permissions for a user + */ +export const getUserPermissions = (user: User | null): Permission[] => { + if (!user) return []; + return ROLE_PERMISSIONS[user.role] || []; +}; + +/** + * Check if user can access a feature + */ +export const canAccess = (user: User | null, feature: string): boolean => { + if (!user) return false; + + // Feature to permission mapping + const featurePermissions: Record = { + 'analytics': [Permission.VIEW_ANALYTICS], + 'user-management': [Permission.VIEW_USERS], + 'settings': [Permission.MANAGE_SETTINGS], + 'collections': [Permission.VIEW_COLLECTIONS], + 'admin-panel': [Permission.MANAGE_SETTINGS], + }; + + const requiredPermissions = featurePermissions[feature] || []; + if (requiredPermissions.length === 0) return true; // No restrictions + + return hasAnyPermission(user, requiredPermissions); +}; diff --git a/vite-env.d.ts b/vite-env.d.ts new file mode 100644 index 0000000..d5d5008 --- /dev/null +++ b/vite-env.d.ts @@ -0,0 +1,11 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL: string; + readonly VITE_API_KEY: string; + readonly VITE_GEMINI_API_KEY?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} From 932453fb66bf2c14548172c47855c0370ebc4565 Mon Sep 17 00:00:00 2001 From: Danielkombou Date: Fri, 6 Feb 2026 17:45:38 +0100 Subject: [PATCH 5/6] added RBAC with graceful error management --- components/Admin/UserManagement.tsx | 4 ++-- components/Layout/DashboardLayout.tsx | 4 ++-- services/files.ts | 9 ++++++--- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/components/Admin/UserManagement.tsx b/components/Admin/UserManagement.tsx index c73f540..a25b6a5 100644 --- a/components/Admin/UserManagement.tsx +++ b/components/Admin/UserManagement.tsx @@ -4,11 +4,11 @@ */ import React, { useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Icons } from '../../constants'; import { UserProfile } from '../../services/auth'; import { useToast } from '../UI/Toast'; -import { apiFetch } from '../../services/api'; +import { getAllUsers, deleteUser } from '../../services/users'; interface UserListResponse { users: UserProfile[]; diff --git a/components/Layout/DashboardLayout.tsx b/components/Layout/DashboardLayout.tsx index d969e81..a0f3187 100644 --- a/components/Layout/DashboardLayout.tsx +++ b/components/Layout/DashboardLayout.tsx @@ -184,13 +184,13 @@ const DashboardLayout: React.FC = ({ user, onLogout, isDar
diff --git a/services/files.ts b/services/files.ts index dfb83fa..297dc12 100644 --- a/services/files.ts +++ b/services/files.ts @@ -101,9 +101,12 @@ export interface ExtractTextResponse { // Convert backend file to frontend FileRecord const mapBackendFileToFileRecord = (file: BackendFile, folderPath: string = 'Root'): FileRecord => { + // Use originalName if available, fallback to fileName + const fileName = file.originalName || file.fileName || 'Untitled'; + // Determine document type from filename or content (basic heuristic) let docType = DocumentType.GENERAL; - const name = file.originalName.toLowerCase(); + const name = fileName.toLowerCase(); if (name.includes('rfq') || name.includes('request for quotation')) { docType = DocumentType.RFQ; } else if (name.includes('po') || name.includes('purchase order')) { @@ -116,7 +119,7 @@ const mapBackendFileToFileRecord = (file: BackendFile, folderPath: string = 'Roo return { id: String(file.id), - name: file.originalName, + name: fileName, type: file.mimeType, size: file.size, path: folderPath, @@ -258,7 +261,7 @@ export const deleteFile = async (id: number): Promise => { export const renameFile = async (id: number, newName: string): Promise => { return apiFetch(`/files/${id}/rename`, { method: 'PATCH', - body: { name: newName }, + body: JSON.stringify({ name: newName }), }); }; From 571623db7721694257567bd8d198ed7daa3eb9f1 Mon Sep 17 00:00:00 2001 From: Danielkombou Date: Sat, 7 Feb 2026 09:20:54 +0100 Subject: [PATCH 6/6] early commit --- App.tsx | 11 +++++ components/Dashboard/RepositoryView.tsx | 22 +++++---- components/Layout/DashboardLayout.tsx | 62 +++++++++++++++++++++---- components/Layout/Sidebar.tsx | 33 ++----------- components/UI/DesktopRequired.tsx | 51 ++++++++++++++++++++ 5 files changed, 130 insertions(+), 49 deletions(-) create mode 100644 components/UI/DesktopRequired.tsx diff --git a/App.tsx b/App.tsx index f44e02c..4f2e150 100644 --- a/App.tsx +++ b/App.tsx @@ -14,6 +14,7 @@ import RoleRoute from './components/Admin/RoleRoute'; import AdminDashboard from './components/Admin/AdminDashboard'; import UserManagement from './components/Admin/UserManagement'; import AdminAnalytics from './components/Admin/AdminAnalytics'; +import DesktopRequired from './components/UI/DesktopRequired'; export type ThemeMode = 'light' | 'dark' | 'system'; @@ -58,6 +59,11 @@ const PublicLayout = () => ( ); const App: React.FC = () => { + const [isDesktop, setIsDesktop] = useState(() => { + if (typeof window === 'undefined') return true; + return window.innerWidth >= 1024; + }); + const [user, setUser] = useState(() => { return JSON.parse(localStorage.getItem('eqorascale_user') || 'null'); }); @@ -162,6 +168,11 @@ const App: React.FC = () => { localStorage.removeItem('eqorascale_user'); }; + // Show desktop required message for mobile/tablet + if (!isDesktop) { + return ; + } + return ( diff --git a/components/Dashboard/RepositoryView.tsx b/components/Dashboard/RepositoryView.tsx index 6326c69..4a31486 100644 --- a/components/Dashboard/RepositoryView.tsx +++ b/components/Dashboard/RepositoryView.tsx @@ -37,22 +37,24 @@ const RepositoryView: React.FC = () => { }, [files, activeTab, searchQuery]); return ( -
-
+
+
-

+

{activeTab === 'ALL' ? 'Repository Overview' : `${activeTab} Management`}

-

+

Indexing {filteredFiles.length} enterprise documents

- + {canReset && ( + + )}
diff --git a/components/Layout/DashboardLayout.tsx b/components/Layout/DashboardLayout.tsx index a0f3187..8e7873f 100644 --- a/components/Layout/DashboardLayout.tsx +++ b/components/Layout/DashboardLayout.tsx @@ -54,6 +54,52 @@ const DashboardLayout: React.FC = ({ user, onLogout, isDar // Convert file structure to flat array const files: FileRecord[] = fileStructure ? flattenFileStructure(fileStructure, currentFolderPath) : []; + // Upload mutations + const uploadFileMutation = useMutation({ + mutationFn: uploadFile, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['files'] }); + toast.success('File uploaded successfully'); + }, + onError: (error: any) => { + toast.error(error?.message || 'Failed to upload file'); + }, + }); + + const uploadMultipleMutation = useMutation({ + mutationFn: uploadMultipleFiles, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['files'] }); + toast.success('Files uploaded successfully'); + }, + onError: (error: any) => { + toast.error(error?.message || 'Failed to upload files'); + }, + }); + + const uploadFolderMutation = useMutation({ + mutationFn: ({ files, folderPath }: { files: File[]; folderPath?: string }) => + uploadFolder(files, folderPath), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['files'] }); + toast.success('Folder uploaded successfully'); + }, + onError: (error: any) => { + toast.error(error?.message || 'Failed to upload folder'); + }, + }); + + const deleteFileMutation = useMutation({ + mutationFn: deleteFile, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['files'] }); + toast.success('File deleted successfully'); + }, + onError: (error: any) => { + toast.error(error?.message || 'Failed to delete file'); + }, + }); + const extractText = async (file: File): Promise => { const fileName = file.name.toLowerCase(); try { @@ -85,15 +131,11 @@ const DashboardLayout: React.FC = ({ user, onLogout, isDar const triggerAnalysis = async (fileId: string, name: string, content: string) => { try { const result = await analyzeDocument(name, content); - setFiles(prev => prev.map(f => f.id === fileId ? { - ...f, - docType: result?.documentType as DocumentType || DocumentType.GENERAL, - tags: [...new Set([...f.tags, ...(result?.suggestedTags || [])])], - summary: result?.summary, - isClassifying: false - } : f)); + // Invalidate queries to refresh file data after analysis + queryClient.invalidateQueries({ queryKey: ['files'] }); + toast.success(`Analysis completed for ${name}`); } catch (err) { - setFiles(prev => prev.map(f => f.id === fileId ? { ...f, isClassifying: false } : f)); + toast.error(`Failed to analyze ${name}`); } }; @@ -158,7 +200,7 @@ const DashboardLayout: React.FC = ({ user, onLogout, isDar
-
+
@@ -196,7 +238,7 @@ const DashboardLayout: React.FC = ({ user, onLogout, isDar
-
+
{filesLoading ? (
diff --git a/components/Layout/Sidebar.tsx b/components/Layout/Sidebar.tsx index 3ff003a..7973e9c 100644 --- a/components/Layout/Sidebar.tsx +++ b/components/Layout/Sidebar.tsx @@ -1,5 +1,5 @@ -import React, { useState } from 'react'; +import React from 'react'; import { NavLink, useNavigate } from 'react-router-dom'; import { DocumentType, User } from '../../types'; import { Icons } from '../../constants'; @@ -12,7 +12,6 @@ interface SidebarProps { const Sidebar: React.FC = ({ onLogout, user }) => { const navigate = useNavigate(); - const [isMobileOpen, setIsMobileOpen] = useState(false); const isAdmin = useIsAdmin(); const canViewAnalytics = useCanAccess('analytics'); @@ -26,37 +25,13 @@ const Sidebar: React.FC = ({ onLogout, user }) => { return ( <> - {/* Mobile Menu Button */} - - - {/* Mobile Overlay */} - {isMobileOpen && ( -
setIsMobileOpen(false)} - /> - )} - {/* Sidebar */} -