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) => (