Fix Phase 2 audit findings: reliability, UX, and operational monitoring

BLOCK-10: Add CloudWatch alarms (DLQ, Lambda errors, RDS, API 5xx) with SNS email
BLOCK-11: Remove sync-over-async deadlock in CurrentUserService
BLOCK-12: Add AppDelegate OAuth URL callback handler for mobile
BLOCK-13: Wire mobile 401 interceptor to dispatch Redux logout
BLOCK-14: Fix JWT base64 padding crash and SysAdmin role detection
BLOCK-15: Reset pagination to page 1 on filter change
BLOCK-16: Add unsaved-changes guard (beforeunload + useBlocker) to AdminWorkspace
FIX-08: Add BulkUpdateLineItems FluentValidation validator
FIX-13: Display auth errors on LoginPage
FIX-25: Add token refresh with retry queue to mobile API client
FIX-44: Add httpx retry logic to all Lambda handlers
FIX-42/43: Align docker-compose PG version (15) and DB name (proposals) with RDS
This commit is contained in:
Adam Moussa 2026-05-20 19:07:49 -04:00
parent dfbd0562ee
commit 184bc1da7e
17 changed files with 396 additions and 47 deletions

View file

@ -94,11 +94,8 @@ public class CurrentUserService : ICurrentUserService
private User GetOrThrow()
{
if (_cachedUser != null) return _cachedUser;
ResolveAsync().GetAwaiter().GetResult();
return _cachedUser
?? throw new UnauthorizedAccessException("Could not resolve current user");
?? throw new InvalidOperationException(
"CurrentUserService.ResolveAsync() was not called. Ensure the authentication middleware runs before accessing user properties.");
}
}

View file

@ -0,0 +1,38 @@
using FluentValidation;
using ProposalSystem.Application.DTOs;
namespace ProposalSystem.Application.Validators;
public class BulkUpdateLineItemsValidator : AbstractValidator<BulkUpdateLineItemsRequest>
{
public BulkUpdateLineItemsValidator()
{
RuleFor(x => x.LineItems).NotNull();
RuleForEach(x => x.LineItems).ChildRules(entry =>
{
entry.RuleFor(x => x.Description)
.NotEmpty().WithMessage("Description is required")
.MaximumLength(1000);
entry.RuleFor(x => x.Quantity)
.GreaterThan(0).WithMessage("Quantity must be positive");
entry.RuleFor(x => x.Unit)
.NotEmpty().WithMessage("Unit is required")
.MaximumLength(50);
entry.RuleFor(x => x.TotalPrice)
.GreaterThanOrEqualTo(0).WithMessage("Total price cannot be negative");
entry.RuleFor(x => x.UnitPrice)
.GreaterThanOrEqualTo(0)
.When(x => x.UnitPrice.HasValue)
.WithMessage("Unit price cannot be negative");
entry.RuleFor(x => x.PricingMode).IsInEnum();
entry.RuleFor(x => x.Source).IsInEnum();
entry.RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
});
}
}

View file

@ -1,10 +1,10 @@
services:
postgres:
image: postgres:16-alpine
image: postgres:15-alpine
ports:
- "5432:5432"
environment:
POSTGRES_DB: proposalsystem
POSTGRES_DB: proposals
POSTGRES_USER: postgres
POSTGRES_PASSWORD: localdev
volumes:

View file

@ -28,6 +28,7 @@ const compute = new ComputeStack(app, 'proposal-system-compute', {
libraryBucket: foundation.libraryBucket,
jobsQueue: foundation.jobsQueue,
userPool: foundation.userPool,
alarmTopic: foundation.alarmTopic,
});
new FrontendStack(app, 'proposal-system-frontend', {

View file

@ -6,12 +6,15 @@ import * as apigatewayv2Integrations from 'aws-cdk-lib/aws-apigatewayv2-integrat
import * as iam from 'aws-cdk-lib/aws-iam';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as sqs from 'aws-cdk-lib/aws-sqs';
import * as sns from 'aws-cdk-lib/aws-sns';
import * as cognito from 'aws-cdk-lib/aws-cognito';
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
import * as lambdaEventSources from 'aws-cdk-lib/aws-lambda-event-sources';
import * as bedrock from 'aws-cdk-lib/aws-bedrock';
import * as opensearchserverless from 'aws-cdk-lib/aws-opensearchserverless';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
import * as cloudwatchActions from 'aws-cdk-lib/aws-cloudwatch-actions';
import * as cr from 'aws-cdk-lib/custom-resources';
import { Construct } from 'constructs';
@ -24,6 +27,7 @@ export interface ComputeStackProps extends cdk.StackProps {
libraryBucket: s3.IBucket;
jobsQueue: sqs.IQueue;
userPool: cognito.IUserPool;
alarmTopic: sns.ITopic;
}
export class ComputeStack extends cdk.Stack {
@ -405,6 +409,45 @@ export class ComputeStack extends cdk.Stack {
],
}));
// CloudWatch Alarms
const alarmAction = new cloudwatchActions.SnsAction(props.alarmTopic);
const lambdaFunctions = [
{ fn: apiFunction, name: 'api' },
{ fn: suggestionsFunction, name: 'suggestions' },
{ fn: pdfExtractFunction, name: 'pdf-extract' },
{ fn: pdfGenerateFunction, name: 'pdf-generate' },
{ fn: libraryIngestFunction, name: 'library-ingest' },
];
for (const { fn, name } of lambdaFunctions) {
const alarm = new cloudwatch.Alarm(this, `LambdaErrors-${name}`, {
alarmName: `proposal-system-${name}-errors`,
alarmDescription: `Lambda errors for ${name}`,
metric: fn.metricErrors({ period: cdk.Duration.minutes(5) }),
threshold: 1,
evaluationPeriods: 1,
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
});
alarm.addAlarmAction(alarmAction);
}
const api5xxAlarm = new cloudwatch.Alarm(this, 'Api5xxAlarm', {
alarmName: 'proposal-system-api-5xx',
alarmDescription: 'API Gateway 5xx errors',
metric: new cloudwatch.Metric({
namespace: 'AWS/ApiGateway',
metricName: '5xx',
dimensionsMap: { ApiId: httpApi.httpApiId },
statistic: 'Sum',
period: cdk.Duration.minutes(5),
}),
threshold: 5,
evaluationPeriods: 1,
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
});
api5xxAlarm.addAlarmAction(alarmAction);
// Outputs
new cdk.CfnOutput(this, 'ApiEndpoint', { value: httpApi.apiEndpoint });
new cdk.CfnOutput(this, 'ApiFunctionArn', { value: apiFunction.functionArn });

View file

@ -6,6 +6,10 @@ import * as sqs from 'aws-cdk-lib/aws-sqs';
import * as cognito from 'aws-cdk-lib/aws-cognito';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
import * as sns from 'aws-cdk-lib/aws-sns';
import * as snsSubscriptions from 'aws-cdk-lib/aws-sns-subscriptions';
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
import * as cloudwatchActions from 'aws-cdk-lib/aws-cloudwatch-actions';
import { Construct } from 'constructs';
export class FoundationStack extends cdk.Stack {
@ -17,6 +21,7 @@ export class FoundationStack extends cdk.Stack {
public readonly libraryBucket: s3.IBucket;
public readonly jobsQueue: sqs.IQueue;
public readonly userPool: cognito.IUserPool;
public readonly alarmTopic: sns.ITopic;
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
@ -253,6 +258,64 @@ export class FoundationStack extends cdk.Stack {
},
});
// SNS Alarm Topic
const alarmTopic = new sns.Topic(this, 'AlarmTopic', {
topicName: 'proposal-system-alarms',
displayName: 'Proposal System Alarms',
});
alarmTopic.addSubscription(
new snsSubscriptions.EmailSubscription('adam@seahavenind.com'),
);
this.alarmTopic = alarmTopic;
const alarmAction = new cloudwatchActions.SnsAction(alarmTopic);
// DLQ Alarm: any message landing in DLQ indicates a processing failure
const dlqAlarm = new cloudwatch.Alarm(this, 'DlqDepthAlarm', {
alarmName: 'proposal-system-dlq-depth',
alarmDescription: 'Messages in DLQ — SQS processing failures',
metric: dlq.metricApproximateNumberOfMessagesVisible({
period: cdk.Duration.minutes(1),
}),
threshold: 0,
comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
evaluationPeriods: 1,
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
});
dlqAlarm.addAlarmAction(alarmAction);
// RDS Alarms
const rdsAlarms = [
new cloudwatch.Alarm(this, 'RdsCpuAlarm', {
alarmName: 'proposal-system-rds-cpu',
alarmDescription: 'RDS CPU utilization above 80%',
metric: dbInstance.metricCPUUtilization({ period: cdk.Duration.minutes(5) }),
threshold: 80,
evaluationPeriods: 3,
treatMissingData: cloudwatch.TreatMissingData.BREACHING,
}),
new cloudwatch.Alarm(this, 'RdsConnectionsAlarm', {
alarmName: 'proposal-system-rds-connections',
alarmDescription: 'RDS database connections above 80',
metric: dbInstance.metricDatabaseConnections({ period: cdk.Duration.minutes(5) }),
threshold: 80,
evaluationPeriods: 2,
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
}),
new cloudwatch.Alarm(this, 'RdsFreeStorageAlarm', {
alarmName: 'proposal-system-rds-free-storage',
alarmDescription: 'RDS free storage below 2 GB',
metric: dbInstance.metricFreeStorageSpace({ period: cdk.Duration.minutes(5) }),
threshold: 2_000_000_000,
comparisonOperator: cloudwatch.ComparisonOperator.LESS_THAN_THRESHOLD,
evaluationPeriods: 1,
treatMissingData: cloudwatch.TreatMissingData.BREACHING,
}),
];
for (const alarm of rdsAlarms) {
alarm.addAlarmAction(alarmAction);
}
// CloudWatch Log Groups
const logGroupNames = [
'proposal-system-api',
@ -280,5 +343,6 @@ export class FoundationStack extends cdk.Stack {
new cdk.CfnOutput(this, 'DbSecretArn', { value: this.dbSecret.secretArn });
new cdk.CfnOutput(this, 'WebClientId', { value: webClient.userPoolClientId });
new cdk.CfnOutput(this, 'MobileClientId', { value: mobileClient.userPoolClientId });
new cdk.CfnOutput(this, 'AlarmTopicArn', { value: alarmTopic.topicArn });
}
}

View file

@ -8,6 +8,7 @@ then triggers a KB sync.
import json
import logging
import os
import time
from datetime import datetime
import boto3
@ -67,10 +68,10 @@ def process_ingestion(proposal_id: str):
def fetch_proposal(proposal_id: str) -> dict | None:
try:
resp = httpx.get(
resp = _retry_request(
"GET",
f"{API_BASE_URL}/api/proposals/{proposal_id}",
headers=_api_headers(),
timeout=10,
)
if resp.status_code == 200:
return resp.json()
@ -81,10 +82,10 @@ def fetch_proposal(proposal_id: str) -> dict | None:
def fetch_line_items(proposal_id: str) -> list[dict]:
try:
resp = httpx.get(
resp = _retry_request(
"GET",
f"{API_BASE_URL}/api/proposals/{proposal_id}/line-items",
headers=_api_headers(),
timeout=10,
)
if resp.status_code == 200:
return resp.json()
@ -198,3 +199,22 @@ def _api_headers() -> dict:
if api_key:
headers["X-Internal-Api-Key"] = api_key
return headers
def _retry_request(
method: str, url: str, *, max_retries: int = 3, **kwargs
) -> httpx.Response:
kwargs.setdefault("timeout", 10)
for attempt in range(max_retries):
try:
resp = httpx.request(method, url, **kwargs)
if resp.status_code < 500:
return resp
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as exc:
if attempt == max_retries - 1:
raise
logger.warning(
"Retryable error (attempt %d/%d): %s", attempt + 1, max_retries, exc
)
time.sleep(min(2**attempt, 4))
return resp # type: ignore[possibly-undefined]

View file

@ -9,6 +9,7 @@ import json
import logging
import os
import tempfile
import time
import boto3
import httpx
@ -299,7 +300,8 @@ def save_extraction(vendor_proposal_id: str, extracted: dict):
}
try:
resp = httpx.put(
resp = _retry_request(
"PUT",
f"{API_BASE_URL}/api/vendor-proposals/{vendor_proposal_id}",
json={
"vendorName": extracted["vendorName"],
@ -308,7 +310,6 @@ def save_extraction(vendor_proposal_id: str, extracted: dict):
"processingStatus": "Complete",
},
headers=_api_headers(),
timeout=10,
)
if resp.status_code not in (200, 204):
logger.error(
@ -322,11 +323,11 @@ def update_processing_status(vendor_proposal_id: str, status: str):
if not vendor_proposal_id:
return
try:
httpx.put(
_retry_request(
"PUT",
f"{API_BASE_URL}/api/vendor-proposals/{vendor_proposal_id}/status",
json={"processingStatus": status},
headers=_api_headers(),
timeout=10,
)
except Exception as e:
logger.error("Error updating status: %s", e)
@ -338,3 +339,22 @@ def _api_headers() -> dict:
if api_key:
headers["X-Internal-Api-Key"] = api_key
return headers
def _retry_request(
method: str, url: str, *, max_retries: int = 3, **kwargs
) -> httpx.Response:
kwargs.setdefault("timeout", 10)
for attempt in range(max_retries):
try:
resp = httpx.request(method, url, **kwargs)
if resp.status_code < 500:
return resp
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as exc:
if attempt == max_retries - 1:
raise
logger.warning(
"Retryable error (attempt %d/%d): %s", attempt + 1, max_retries, exc
)
time.sleep(min(2**attempt, 4))
return resp # type: ignore[possibly-undefined]

View file

@ -7,6 +7,7 @@ Triggered via SQS when an admin requests PDF generation.
import json
import logging
import os
import time
from datetime import datetime
from io import BytesIO
@ -96,10 +97,10 @@ def generate_pdf(proposal_id: str):
def fetch_proposal(proposal_id: str) -> dict | None:
try:
resp = httpx.get(
resp = _retry_request(
"GET",
f"{API_BASE_URL}/api/proposals/{proposal_id}",
headers=_api_headers(),
timeout=10,
)
if resp.status_code == 200:
return resp.json()
@ -110,10 +111,10 @@ def fetch_proposal(proposal_id: str) -> dict | None:
def fetch_line_items(proposal_id: str) -> list[dict]:
try:
resp = httpx.get(
resp = _retry_request(
"GET",
f"{API_BASE_URL}/api/proposals/{proposal_id}/line-items",
headers=_api_headers(),
timeout=10,
)
if resp.status_code == 200:
return resp.json()
@ -524,11 +525,11 @@ def upload_pdf(s3_key: str, pdf_bytes: bytes):
def register_pdf(proposal_id: str, s3_key: str):
try:
resp = httpx.post(
resp = _retry_request(
"POST",
f"{API_BASE_URL}/api/generated-pdfs",
json={"proposalId": proposal_id, "s3Key": s3_key},
headers=_api_headers(),
timeout=10,
)
if resp.status_code not in (200, 201):
logger.error("Failed to register PDF: %s %s", resp.status_code, resp.text)
@ -542,3 +543,22 @@ def _api_headers() -> dict:
if api_key:
headers["X-Internal-Api-Key"] = api_key
return headers
def _retry_request(
method: str, url: str, *, max_retries: int = 3, **kwargs
) -> httpx.Response:
kwargs.setdefault("timeout", 10)
for attempt in range(max_retries):
try:
resp = httpx.request(method, url, **kwargs)
if resp.status_code < 500:
return resp
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as exc:
if attempt == max_retries - 1:
raise
logger.warning(
"Retryable error (attempt %d/%d): %s", attempt + 1, max_retries, exc
)
time.sleep(min(2**attempt, 4))
return resp # type: ignore[possibly-undefined]

View file

@ -7,6 +7,7 @@ to generate line item suggestions for new proposals.
import json
import logging
import os
import time
import boto3
import httpx
@ -72,10 +73,10 @@ def process_suggestion(proposal_id: str, trigger: str):
def fetch_line_items(proposal_id: str) -> list[dict]:
try:
resp = httpx.get(
resp = _retry_request(
"GET",
f"{API_BASE_URL}/api/proposals/{proposal_id}/line-items",
headers=_api_headers(),
timeout=10,
)
if resp.status_code == 200:
return resp.json()
@ -86,10 +87,10 @@ def fetch_line_items(proposal_id: str) -> list[dict]:
def fetch_proposal(proposal_id: str) -> dict | None:
try:
resp = httpx.get(
resp = _retry_request(
"GET",
f"{API_BASE_URL}/api/proposals/{proposal_id}",
headers=_api_headers(),
timeout=10,
)
if resp.status_code == 200:
return resp.json()
@ -263,7 +264,8 @@ def post_line_items(proposal_id: str, items: list[dict], existing_items: list[di
)
try:
resp = httpx.put(
resp = _retry_request(
"PUT",
f"{API_BASE_URL}/api/proposals/{proposal_id}/line-items",
json={"lineItems": line_items_payload},
headers=_api_headers(),
@ -288,14 +290,14 @@ def store_similar_references(proposal_id: str, similar_proposals: list[dict]):
continue
try:
httpx.post(
_retry_request(
"POST",
f"{API_BASE_URL}/api/proposals/{proposal_id}/similar-references",
json={
"referencedLibraryItemId": library_item_id,
"similarityScore": sp["score"],
},
headers=_api_headers(),
timeout=10,
)
except Exception as e:
logger.error("Error storing similar reference: %s", e)
@ -303,11 +305,11 @@ def store_similar_references(proposal_id: str, similar_proposals: list[dict]):
def update_status_to_in_review(proposal_id: str):
try:
httpx.put(
_retry_request(
"PUT",
f"{API_BASE_URL}/api/proposals/{proposal_id}",
json={"status": "InReview"},
headers=_api_headers(),
timeout=10,
)
except Exception as e:
logger.error("Error updating status: %s", e)
@ -319,3 +321,22 @@ def _api_headers() -> dict:
if api_key:
headers["X-Internal-Api-Key"] = api_key
return headers
def _retry_request(
method: str, url: str, *, max_retries: int = 3, **kwargs
) -> httpx.Response:
kwargs.setdefault("timeout", 10)
for attempt in range(max_retries):
try:
resp = httpx.request(method, url, **kwargs)
if resp.status_code < 500:
return resp
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as exc:
if attempt == max_retries - 1:
raise
logger.warning(
"Retryable error (attempt %d/%d): %s", attempt + 1, max_retries, exc
)
time.sleep(min(2**attempt, 4))
return resp # type: ignore[possibly-undefined]

View file

@ -2,6 +2,7 @@ import UIKit
import React
import React_RCTAppDelegate
import ReactAppDependencyProvider
import RCTLinking
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
@ -31,6 +32,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
return true
}
func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
return RCTLinkingManager.application(app, open: url, options: options)
}
}
class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {

View file

@ -11,9 +11,10 @@ import { theme } from './theme';
import { queryClient } from './lib/queryClient';
import { RootNavigator } from './navigation/RootNavigator';
import { tokenStorage, userStorage } from './lib/storage';
import { setUser, setLoading } from './store/slices/authSlice';
import { setUser, setLoading, logout as logoutAction } from './store/slices/authSlice';
import { processOfflineQueue } from './hooks/useOfflineDraft';
import { proposalsApi, CreateProposalRequest } from './lib/api/proposals';
import { onSessionExpired } from './lib/api/client';
class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
state = { error: null as Error | null };
@ -56,6 +57,12 @@ function AuthBootstrap({ children }: { children: React.ReactNode }) {
})();
}, [dispatch]);
useEffect(() => {
return onSessionExpired(() => {
dispatch(logoutAction());
});
}, [dispatch]);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
if (state.isConnected) {

View file

@ -1,6 +1,6 @@
import { authorize, refresh, revoke } from 'react-native-app-auth';
import Config from '../../config';
import apiClient from './client';
import apiClient, { registerTokenRefresh } from './client';
import { authenticateWithCredentials } from './cognito-auth';
import {
tokenStorage,
@ -12,11 +12,14 @@ import {
function parseUserFromIdToken(idToken: string): StoredUser {
const base64Url = idToken.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const payload = JSON.parse(atob(base64));
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=');
const payload = JSON.parse(atob(padded));
const groups: string[] = payload['cognito:groups'] || [];
const role = groups.includes('admins') || groups.includes('sysadmins')
? 'Admin'
: 'Dispatcher';
const role = groups.includes('sysadmins')
? 'SysAdmin'
: groups.includes('admins')
? 'Admin'
: 'Dispatcher';
return {
id: payload.sub,
email: payload.email,
@ -117,3 +120,5 @@ export const authApi = {
return res.data;
},
};
registerTokenRefresh(() => authApi.refreshTokens());

View file

@ -2,6 +2,41 @@ import axios from 'axios';
import Config from '../../config';
import { tokenStorage } from '../storage';
type RefreshFn = () => Promise<void>;
let _refreshTokens: RefreshFn | null = null;
export function registerTokenRefresh(fn: RefreshFn) {
_refreshTokens = fn;
}
type SessionExpiredListener = () => void;
const sessionExpiredListeners: SessionExpiredListener[] = [];
export function onSessionExpired(listener: SessionExpiredListener): () => void {
sessionExpiredListeners.push(listener);
return () => {
const idx = sessionExpiredListeners.indexOf(listener);
if (idx >= 0) sessionExpiredListeners.splice(idx, 1);
};
}
function emitSessionExpired() {
sessionExpiredListeners.forEach((fn) => fn());
}
let isRefreshing = false;
let refreshSubscribers: Array<(token: string) => void> = [];
function subscribeToRefresh(cb: (token: string) => void) {
refreshSubscribers.push(cb);
}
function onRefreshComplete(token: string) {
refreshSubscribers.forEach((cb) => cb(token));
refreshSubscribers = [];
}
const apiClient = axios.create({
baseURL: Config.API_URL,
headers: {
@ -23,15 +58,40 @@ apiClient.interceptors.request.use(
apiClient.interceptors.response.use(
(response) => response,
(error) => {
async (error) => {
if (error.response) {
const { status, data } = error.response;
const { status, data, config: originalRequest } = error.response;
if (status === 401) {
tokenStorage.clear();
return Promise.reject(
new Error('Session expired. Please log in again.'),
);
if (status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
if (isRefreshing) {
return new Promise((resolve) => {
subscribeToRefresh((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
resolve(apiClient(originalRequest));
});
});
}
isRefreshing = true;
try {
if (!_refreshTokens) throw new Error('No refresh handler');
await _refreshTokens();
const tokens = await tokenStorage.get();
const newToken = tokens?.accessToken ?? '';
onRefreshComplete(newToken);
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return apiClient(originalRequest);
} catch {
await tokenStorage.clear();
emitSessionExpired();
return Promise.reject(
new Error('Session expired. Please log in again.'),
);
} finally {
isRefreshing = false;
}
}
if (status === 403) {

View file

@ -32,6 +32,14 @@ export function usePaginatedList<T>(fetchFn: FetchFn<T>, extraParams: Record<str
};
}, [search]);
const prevExtraKey = useRef(extraKey);
useEffect(() => {
if (prevExtraKey.current !== extraKey) {
prevExtraKey.current = extraKey;
setPage(1);
}
}, [extraKey]);
const reload = useCallback(() => {
setLoading(true);
setErr('');

View file

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate, useBlocker } from 'react-router-dom';
import { useQuery, useMutation } from '@tanstack/react-query';
import {
Box,
@ -81,6 +81,23 @@ export default function AdminWorkspace() {
}
}, [proposal]);
useEffect(() => {
if (!dirty) return;
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
};
window.addEventListener('beforeunload', handler);
return () => window.removeEventListener('beforeunload', handler);
}, [dirty]);
const blocker = useBlocker(
useCallback(
({ currentLocation, nextLocation }: { currentLocation: { pathname: string }; nextLocation: { pathname: string } }) =>
dirty && currentLocation.pathname !== nextLocation.pathname,
[dirty],
),
);
const saveMutation = useMutation({
mutationFn: async () => {
await adminApi.updateProposal(id!, { refinedScope });
@ -387,6 +404,20 @@ export default function AdminWorkspace() {
</CardContent>
</Card>
{/* Unsaved Changes Navigation Guard */}
<Dialog open={blocker.state === 'blocked'} onClose={() => blocker.reset?.()}>
<DialogTitle>Unsaved Changes</DialogTitle>
<DialogContent>
<Typography>You have unsaved changes. Are you sure you want to leave?</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => blocker.reset?.()}>Stay</Button>
<Button variant="contained" color="error" onClick={() => blocker.proceed?.()}>
Discard & Leave
</Button>
</DialogActions>
</Dialog>
{/* Approve Confirmation Dialog */}
<Dialog open={approveDialogOpen} onClose={() => setApproveDialogOpen(false)}>
<DialogTitle>Approve Proposal</DialogTitle>

View file

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import { Box, Button, Card, CardContent, Typography, Divider, Stack } from '@mui/material';
import { Alert, Box, Button, Card, CardContent, Typography, Divider, Stack } from '@mui/material';
import GoogleIcon from '@mui/icons-material/Google';
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
import EngineeringIcon from '@mui/icons-material/Engineering';
@ -28,7 +28,7 @@ function buildLoginUrl(): string {
export default function LoginPage() {
const navigate = useNavigate();
const dispatch = useDispatch();
const { isAuthenticated } = useAuth();
const { isAuthenticated, error } = useAuth();
const [loading, setLoading] = useState(false);
const isDevMode = !CLIENT_ID;
@ -103,6 +103,11 @@ export default function LoginPage() {
</Typography>
</Box>
<CardContent sx={{ p: '30px', textAlign: 'center' }}>
{error && (
<Alert severity="error" sx={{ mb: 2, textAlign: 'left' }}>
{error}
</Alert>
)}
{isDevMode ? (
<>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>