From 4d72b63547b9a1cba86406e2a611610b3c0d2300 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Wed, 20 May 2026 18:09:04 -0400 Subject: [PATCH] Add frontend role guards, fix dashboard data exposure, and harden UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RoleGuard: New component wrapping admin routes — dispatchers navigating to /admin/* by URL now redirect to / instead of seeing error states. Dashboard: Add mine=true filter so dispatchers only see their own proposals and stats, not all users' data. AdminWorkspace: Auto-save dirty changes before approving so edits to refined scope and line items aren't silently discarded. ProposalFormPage: Add onError toast and 300ms debounce on customer search (was firing an API call per keystroke). admin.ts: Stop swallowing errors in getPdf — let them propagate to the mutation's onError handler. Fix AuditEntry.details type to string|null. LoginPage: Fix pre-existing TS error with noUncheckedIndexedAccess. --- web/src/App.tsx | 10 +++++----- web/src/components/ProtectedRoute.tsx | 10 ++++++++++ web/src/lib/api/admin.ts | 11 ++++------ .../pages/admin/workspace/AdminWorkspace.tsx | 18 ++++++++--------- web/src/pages/auth/LoginPage.tsx | 4 ++-- web/src/pages/dashboard/Dashboard.tsx | 20 +++---------------- .../pages/proposals/form/ProposalFormPage.tsx | 9 +++++++-- 7 files changed, 40 insertions(+), 42 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 99a77e4..ca5169a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,7 +3,7 @@ import { useSelector } from 'react-redux'; import { Box, Toolbar, Typography } from '@mui/material'; import { selectSidebarOpen } from './app/slices/uiSlice'; import type { RootState } from './app/store'; -import ProtectedRoute from './components/ProtectedRoute'; +import ProtectedRoute, { RoleGuard } from './components/ProtectedRoute'; import Topbar from './components/Topbar'; import Sidebar from './components/Sidebar'; import LoginPage from './pages/auth/LoginPage'; @@ -50,10 +50,10 @@ export default function App() { } /> {/* Admin Routes */} - } /> - } /> - } /> - User Management — Coming Soon} /> + } /> + } /> + } /> + User Management — Coming Soon} /> } /> diff --git a/web/src/components/ProtectedRoute.tsx b/web/src/components/ProtectedRoute.tsx index f0a5824..8e77202 100644 --- a/web/src/components/ProtectedRoute.tsx +++ b/web/src/components/ProtectedRoute.tsx @@ -10,3 +10,13 @@ export default function ProtectedRoute({ children }: { children: React.ReactNode return <>{children}; } + +export function RoleGuard({ roles, children }: { roles: string[]; children: React.ReactNode }) { + const { user } = useAuth(); + + if (!user || !roles.includes(user.role)) { + return ; + } + + return <>{children}; +} diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index f7dfc06..c0eb2e3 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -19,7 +19,7 @@ export interface AuditEntry { userId: string; userName: string; action: string; - details: Record; + details: string | null; timestamp: string; ipAddress: string | null; } @@ -66,11 +66,8 @@ export const adminApi = { }, getPdf: async (id: string): Promise<{ downloadUrl: string; expiresAt: string } | null> => { - try { - const res = await apiClient.get(`/proposals/${id}/pdf`); - return res.data; - } catch { - return null; - } + const res = await apiClient.get(`/proposals/${id}/pdf`); + if (res.status === 202) return null; + return res.data; }, }; diff --git a/web/src/pages/admin/workspace/AdminWorkspace.tsx b/web/src/pages/admin/workspace/AdminWorkspace.tsx index 680d916..fc6e4eb 100644 --- a/web/src/pages/admin/workspace/AdminWorkspace.tsx +++ b/web/src/pages/admin/workspace/AdminWorkspace.tsx @@ -34,14 +34,7 @@ import { QUERY_KEYS } from '../../../constants/queryKeys'; import LineItemEditor, { type EditableLineItem } from '../../../components/admin/LineItemEditor'; import VendorDataPanel from '../../../components/admin/VendorDataPanel'; import SimilarProposalsPanel from '../../../components/admin/SimilarProposalsPanel'; - -const STATUS_COLORS: Record = { - Draft: 'default', - InReview: 'info', - Approved: 'success', - Sent: 'success', - Revised: 'warning', -}; +import { STATUS_COLORS } from '../../../constants'; export default function AdminWorkspace() { const { id } = useParams<{ id: string }>(); @@ -115,10 +108,17 @@ export default function AdminWorkspace() { }); const approveMutation = useMutation({ - mutationFn: () => adminApi.approveProposal(id!), + mutationFn: async () => { + if (dirty) { + await saveMutation.mutateAsync(); + } + await adminApi.approveProposal(id!); + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] }); + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposalLineItems, id] }); setApproveDialogOpen(false); + setDirty(false); toast.success('Proposal approved'); }, }); diff --git a/web/src/pages/auth/LoginPage.tsx b/web/src/pages/auth/LoginPage.tsx index 017cddf..6866e2e 100644 --- a/web/src/pages/auth/LoginPage.tsx +++ b/web/src/pages/auth/LoginPage.tsx @@ -48,8 +48,8 @@ export default function LoginPage() { const handleDevLogin = async (role: string) => { setLoading(true); try { - const { email, name } = devUsers[role] ?? devUsers.SysAdmin; - const user = await authApi.devLogin(email, name, role); + const dev = devUsers[role] ?? devUsers['SysAdmin']!; + const user = await authApi.devLogin(dev.email, dev.name, role); dispatch(setUser(user)); navigate('/', { replace: true }); } catch (err) { diff --git a/web/src/pages/dashboard/Dashboard.tsx b/web/src/pages/dashboard/Dashboard.tsx index 31a46dd..10be34c 100644 --- a/web/src/pages/dashboard/Dashboard.tsx +++ b/web/src/pages/dashboard/Dashboard.tsx @@ -23,22 +23,8 @@ import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import SendIcon from '@mui/icons-material/Send'; import { proposalsApi, type ProposalListItem, type ProposalStats } from '../../lib/api/proposals'; import { QUERY_KEYS } from '../../constants/queryKeys'; - -const STATUS_COLORS: Record = { - Draft: 'default', - InReview: 'info', - Approved: 'success', - Sent: 'success', - Revised: 'warning', -}; - -function formatCurrency(amount: number): string { - return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount); -} - -function formatDate(iso: string): string { - return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); -} +import { STATUS_COLORS } from '../../constants'; +import { formatCurrency, formatDate } from '../../lib/format'; function KpiCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { return ( @@ -61,7 +47,7 @@ export default function Dashboard() { const { data: recentData, isLoading: recentLoading } = useQuery<{ items: ProposalListItem[]; totalCount: number }>({ queryKey: [QUERY_KEYS.proposals, 'dashboard-recent'], - queryFn: () => proposalsApi.getAll({ page: 1, pageSize: 5 }), + queryFn: () => proposalsApi.getAll({ page: 1, pageSize: 5, mine: true }), }); const { data: stats, isLoading: statsLoading } = useQuery({ diff --git a/web/src/pages/proposals/form/ProposalFormPage.tsx b/web/src/pages/proposals/form/ProposalFormPage.tsx index 5e8a3d5..8337342 100644 --- a/web/src/pages/proposals/form/ProposalFormPage.tsx +++ b/web/src/pages/proposals/form/ProposalFormPage.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { useMutation } from '@tanstack/react-query'; import { @@ -37,6 +37,7 @@ export default function ProposalFormPage() { const [customerLoading, setCustomerLoading] = useState(false); const [addresses, setAddresses] = useState([]); const [vendorFile, setVendorFile] = useState(null); + const searchDebounceRef = useRef | null>(null); const handleChange = (field: keyof CreateProposalRequest, value: string) => { setForm((prev) => ({ ...prev, [field]: value })); @@ -90,6 +91,9 @@ export default function ProposalFormPage() { toast.success(`Proposal ${proposal.proposalNumber} submitted`); navigate(`/proposals/${proposal.id}`); }, + onError: (error: Error) => { + toast.error(error.message || 'Failed to submit proposal'); + }, }); const handleSubmit = (e: React.FormEvent) => { @@ -134,7 +138,8 @@ export default function ProposalFormPage() { if (reason === 'input') { handleChange('customerName', value); } - searchCustomers(value); + if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); + searchDebounceRef.current = setTimeout(() => searchCustomers(value), 300); }} onChange={handleCustomerSelect} renderInput={(params) => (