mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-05 17:41:59 +00:00
Add frontend role guards, fix dashboard data exposure, and harden UX
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.
This commit is contained in:
parent
866601025d
commit
4d72b63547
7 changed files with 40 additions and 42 deletions
|
|
@ -3,7 +3,7 @@ import { useSelector } from 'react-redux';
|
||||||
import { Box, Toolbar, Typography } from '@mui/material';
|
import { Box, Toolbar, Typography } from '@mui/material';
|
||||||
import { selectSidebarOpen } from './app/slices/uiSlice';
|
import { selectSidebarOpen } from './app/slices/uiSlice';
|
||||||
import type { RootState } from './app/store';
|
import type { RootState } from './app/store';
|
||||||
import ProtectedRoute from './components/ProtectedRoute';
|
import ProtectedRoute, { RoleGuard } from './components/ProtectedRoute';
|
||||||
import Topbar from './components/Topbar';
|
import Topbar from './components/Topbar';
|
||||||
import Sidebar from './components/Sidebar';
|
import Sidebar from './components/Sidebar';
|
||||||
import LoginPage from './pages/auth/LoginPage';
|
import LoginPage from './pages/auth/LoginPage';
|
||||||
|
|
@ -50,10 +50,10 @@ export default function App() {
|
||||||
<Route path="/proposals/:id" element={<ProposalDetailPage />} />
|
<Route path="/proposals/:id" element={<ProposalDetailPage />} />
|
||||||
|
|
||||||
{/* Admin Routes */}
|
{/* Admin Routes */}
|
||||||
<Route path="/admin" element={<AdminDashboard />} />
|
<Route path="/admin" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminDashboard /></RoleGuard>} />
|
||||||
<Route path="/admin/proposals" element={<AdminDashboard />} />
|
<Route path="/admin/proposals" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminDashboard /></RoleGuard>} />
|
||||||
<Route path="/admin/proposals/:id" element={<AdminWorkspace />} />
|
<Route path="/admin/proposals/:id" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminWorkspace /></RoleGuard>} />
|
||||||
<Route path="/admin/users" element={<Typography variant="h5" sx={{ p: 2 }}>User Management — Coming Soon</Typography>} />
|
<Route path="/admin/users" element={<RoleGuard roles={['SysAdmin']}><Typography variant="h5" sx={{ p: 2 }}>User Management — Coming Soon</Typography></RoleGuard>} />
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
|
||||||
|
|
@ -10,3 +10,13 @@ export default function ProtectedRoute({ children }: { children: React.ReactNode
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RoleGuard({ roles, children }: { roles: string[]; children: React.ReactNode }) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
if (!user || !roles.includes(user.role)) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ export interface AuditEntry {
|
||||||
userId: string;
|
userId: string;
|
||||||
userName: string;
|
userName: string;
|
||||||
action: string;
|
action: string;
|
||||||
details: Record<string, unknown>;
|
details: string | null;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
ipAddress: string | null;
|
ipAddress: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -66,11 +66,8 @@ export const adminApi = {
|
||||||
},
|
},
|
||||||
|
|
||||||
getPdf: async (id: string): Promise<{ downloadUrl: string; expiresAt: string } | null> => {
|
getPdf: async (id: string): Promise<{ downloadUrl: string; expiresAt: string } | null> => {
|
||||||
try {
|
const res = await apiClient.get(`/proposals/${id}/pdf`);
|
||||||
const res = await apiClient.get(`/proposals/${id}/pdf`);
|
if (res.status === 202) return null;
|
||||||
return res.data;
|
return res.data;
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -34,14 +34,7 @@ import { QUERY_KEYS } from '../../../constants/queryKeys';
|
||||||
import LineItemEditor, { type EditableLineItem } from '../../../components/admin/LineItemEditor';
|
import LineItemEditor, { type EditableLineItem } from '../../../components/admin/LineItemEditor';
|
||||||
import VendorDataPanel from '../../../components/admin/VendorDataPanel';
|
import VendorDataPanel from '../../../components/admin/VendorDataPanel';
|
||||||
import SimilarProposalsPanel from '../../../components/admin/SimilarProposalsPanel';
|
import SimilarProposalsPanel from '../../../components/admin/SimilarProposalsPanel';
|
||||||
|
import { STATUS_COLORS } from '../../../constants';
|
||||||
const STATUS_COLORS: Record<string, 'default' | 'info' | 'warning' | 'success'> = {
|
|
||||||
Draft: 'default',
|
|
||||||
InReview: 'info',
|
|
||||||
Approved: 'success',
|
|
||||||
Sent: 'success',
|
|
||||||
Revised: 'warning',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AdminWorkspace() {
|
export default function AdminWorkspace() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
|
|
@ -115,10 +108,17 @@ export default function AdminWorkspace() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const approveMutation = useMutation({
|
const approveMutation = useMutation({
|
||||||
mutationFn: () => adminApi.approveProposal(id!),
|
mutationFn: async () => {
|
||||||
|
if (dirty) {
|
||||||
|
await saveMutation.mutateAsync();
|
||||||
|
}
|
||||||
|
await adminApi.approveProposal(id!);
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] });
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposalLineItems, id] });
|
||||||
setApproveDialogOpen(false);
|
setApproveDialogOpen(false);
|
||||||
|
setDirty(false);
|
||||||
toast.success('Proposal approved');
|
toast.success('Proposal approved');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,8 @@ export default function LoginPage() {
|
||||||
const handleDevLogin = async (role: string) => {
|
const handleDevLogin = async (role: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { email, name } = devUsers[role] ?? devUsers.SysAdmin;
|
const dev = devUsers[role] ?? devUsers['SysAdmin']!;
|
||||||
const user = await authApi.devLogin(email, name, role);
|
const user = await authApi.devLogin(dev.email, dev.name, role);
|
||||||
dispatch(setUser(user));
|
dispatch(setUser(user));
|
||||||
navigate('/', { replace: true });
|
navigate('/', { replace: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -23,22 +23,8 @@ import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||||
import SendIcon from '@mui/icons-material/Send';
|
import SendIcon from '@mui/icons-material/Send';
|
||||||
import { proposalsApi, type ProposalListItem, type ProposalStats } from '../../lib/api/proposals';
|
import { proposalsApi, type ProposalListItem, type ProposalStats } from '../../lib/api/proposals';
|
||||||
import { QUERY_KEYS } from '../../constants/queryKeys';
|
import { QUERY_KEYS } from '../../constants/queryKeys';
|
||||||
|
import { STATUS_COLORS } from '../../constants';
|
||||||
const STATUS_COLORS: Record<string, 'default' | 'info' | 'warning' | 'success' | 'error'> = {
|
import { formatCurrency, formatDate } from '../../lib/format';
|
||||||
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' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function KpiCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
function KpiCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -61,7 +47,7 @@ export default function Dashboard() {
|
||||||
|
|
||||||
const { data: recentData, isLoading: recentLoading } = useQuery<{ items: ProposalListItem[]; totalCount: number }>({
|
const { data: recentData, isLoading: recentLoading } = useQuery<{ items: ProposalListItem[]; totalCount: number }>({
|
||||||
queryKey: [QUERY_KEYS.proposals, 'dashboard-recent'],
|
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<ProposalStats>({
|
const { data: stats, isLoading: statsLoading } = useQuery<ProposalStats>({
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
|
|
@ -37,6 +37,7 @@ export default function ProposalFormPage() {
|
||||||
const [customerLoading, setCustomerLoading] = useState(false);
|
const [customerLoading, setCustomerLoading] = useState(false);
|
||||||
const [addresses, setAddresses] = useState<string[]>([]);
|
const [addresses, setAddresses] = useState<string[]>([]);
|
||||||
const [vendorFile, setVendorFile] = useState<File | null>(null);
|
const [vendorFile, setVendorFile] = useState<File | null>(null);
|
||||||
|
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const handleChange = (field: keyof CreateProposalRequest, value: string) => {
|
const handleChange = (field: keyof CreateProposalRequest, value: string) => {
|
||||||
setForm((prev) => ({ ...prev, [field]: value }));
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
|
@ -90,6 +91,9 @@ export default function ProposalFormPage() {
|
||||||
toast.success(`Proposal ${proposal.proposalNumber} submitted`);
|
toast.success(`Proposal ${proposal.proposalNumber} submitted`);
|
||||||
navigate(`/proposals/${proposal.id}`);
|
navigate(`/proposals/${proposal.id}`);
|
||||||
},
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message || 'Failed to submit proposal');
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
|
@ -134,7 +138,8 @@ export default function ProposalFormPage() {
|
||||||
if (reason === 'input') {
|
if (reason === 'input') {
|
||||||
handleChange('customerName', value);
|
handleChange('customerName', value);
|
||||||
}
|
}
|
||||||
searchCustomers(value);
|
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
|
||||||
|
searchDebounceRef.current = setTimeout(() => searchCustomers(value), 300);
|
||||||
}}
|
}}
|
||||||
onChange={handleCustomerSelect}
|
onChange={handleCustomerSelect}
|
||||||
renderInput={(params) => (
|
renderInput={(params) => (
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue