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() private User GetOrThrow()
{ {
if (_cachedUser != null) return _cachedUser;
ResolveAsync().GetAwaiter().GetResult();
return _cachedUser 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: services:
postgres: postgres:
image: postgres:16-alpine image: postgres:15-alpine
ports: ports:
- "5432:5432" - "5432:5432"
environment: environment:
POSTGRES_DB: proposalsystem POSTGRES_DB: proposals
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: localdev POSTGRES_PASSWORD: localdev
volumes: volumes:

View file

@ -28,6 +28,7 @@ const compute = new ComputeStack(app, 'proposal-system-compute', {
libraryBucket: foundation.libraryBucket, libraryBucket: foundation.libraryBucket,
jobsQueue: foundation.jobsQueue, jobsQueue: foundation.jobsQueue,
userPool: foundation.userPool, userPool: foundation.userPool,
alarmTopic: foundation.alarmTopic,
}); });
new FrontendStack(app, 'proposal-system-frontend', { 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 iam from 'aws-cdk-lib/aws-iam';
import * as s3 from 'aws-cdk-lib/aws-s3'; import * as s3 from 'aws-cdk-lib/aws-s3';
import * as sqs from 'aws-cdk-lib/aws-sqs'; 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 cognito from 'aws-cdk-lib/aws-cognito';
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
import * as lambdaEventSources from 'aws-cdk-lib/aws-lambda-event-sources'; import * as lambdaEventSources from 'aws-cdk-lib/aws-lambda-event-sources';
import * as bedrock from 'aws-cdk-lib/aws-bedrock'; import * as bedrock from 'aws-cdk-lib/aws-bedrock';
import * as opensearchserverless from 'aws-cdk-lib/aws-opensearchserverless'; import * as opensearchserverless from 'aws-cdk-lib/aws-opensearchserverless';
import * as logs from 'aws-cdk-lib/aws-logs'; 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 * as cr from 'aws-cdk-lib/custom-resources';
import { Construct } from 'constructs'; import { Construct } from 'constructs';
@ -24,6 +27,7 @@ export interface ComputeStackProps extends cdk.StackProps {
libraryBucket: s3.IBucket; libraryBucket: s3.IBucket;
jobsQueue: sqs.IQueue; jobsQueue: sqs.IQueue;
userPool: cognito.IUserPool; userPool: cognito.IUserPool;
alarmTopic: sns.ITopic;
} }
export class ComputeStack extends cdk.Stack { 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 // Outputs
new cdk.CfnOutput(this, 'ApiEndpoint', { value: httpApi.apiEndpoint }); new cdk.CfnOutput(this, 'ApiEndpoint', { value: httpApi.apiEndpoint });
new cdk.CfnOutput(this, 'ApiFunctionArn', { value: apiFunction.functionArn }); 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 cognito from 'aws-cdk-lib/aws-cognito';
import * as logs from 'aws-cdk-lib/aws-logs'; import * as logs from 'aws-cdk-lib/aws-logs';
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; 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'; import { Construct } from 'constructs';
export class FoundationStack extends cdk.Stack { export class FoundationStack extends cdk.Stack {
@ -17,6 +21,7 @@ export class FoundationStack extends cdk.Stack {
public readonly libraryBucket: s3.IBucket; public readonly libraryBucket: s3.IBucket;
public readonly jobsQueue: sqs.IQueue; public readonly jobsQueue: sqs.IQueue;
public readonly userPool: cognito.IUserPool; public readonly userPool: cognito.IUserPool;
public readonly alarmTopic: sns.ITopic;
constructor(scope: Construct, id: string, props?: cdk.StackProps) { constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props); 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 // CloudWatch Log Groups
const logGroupNames = [ const logGroupNames = [
'proposal-system-api', '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, 'DbSecretArn', { value: this.dbSecret.secretArn });
new cdk.CfnOutput(this, 'WebClientId', { value: webClient.userPoolClientId }); new cdk.CfnOutput(this, 'WebClientId', { value: webClient.userPoolClientId });
new cdk.CfnOutput(this, 'MobileClientId', { value: mobileClient.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 json
import logging import logging
import os import os
import time
from datetime import datetime from datetime import datetime
import boto3 import boto3
@ -67,10 +68,10 @@ def process_ingestion(proposal_id: str):
def fetch_proposal(proposal_id: str) -> dict | None: def fetch_proposal(proposal_id: str) -> dict | None:
try: try:
resp = httpx.get( resp = _retry_request(
"GET",
f"{API_BASE_URL}/api/proposals/{proposal_id}", f"{API_BASE_URL}/api/proposals/{proposal_id}",
headers=_api_headers(), headers=_api_headers(),
timeout=10,
) )
if resp.status_code == 200: if resp.status_code == 200:
return resp.json() return resp.json()
@ -81,10 +82,10 @@ def fetch_proposal(proposal_id: str) -> dict | None:
def fetch_line_items(proposal_id: str) -> list[dict]: def fetch_line_items(proposal_id: str) -> list[dict]:
try: try:
resp = httpx.get( resp = _retry_request(
"GET",
f"{API_BASE_URL}/api/proposals/{proposal_id}/line-items", f"{API_BASE_URL}/api/proposals/{proposal_id}/line-items",
headers=_api_headers(), headers=_api_headers(),
timeout=10,
) )
if resp.status_code == 200: if resp.status_code == 200:
return resp.json() return resp.json()
@ -198,3 +199,22 @@ def _api_headers() -> dict:
if api_key: if api_key:
headers["X-Internal-Api-Key"] = api_key headers["X-Internal-Api-Key"] = api_key
return headers 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 logging
import os import os
import tempfile import tempfile
import time
import boto3 import boto3
import httpx import httpx
@ -299,7 +300,8 @@ def save_extraction(vendor_proposal_id: str, extracted: dict):
} }
try: try:
resp = httpx.put( resp = _retry_request(
"PUT",
f"{API_BASE_URL}/api/vendor-proposals/{vendor_proposal_id}", f"{API_BASE_URL}/api/vendor-proposals/{vendor_proposal_id}",
json={ json={
"vendorName": extracted["vendorName"], "vendorName": extracted["vendorName"],
@ -308,7 +310,6 @@ def save_extraction(vendor_proposal_id: str, extracted: dict):
"processingStatus": "Complete", "processingStatus": "Complete",
}, },
headers=_api_headers(), headers=_api_headers(),
timeout=10,
) )
if resp.status_code not in (200, 204): if resp.status_code not in (200, 204):
logger.error( logger.error(
@ -322,11 +323,11 @@ def update_processing_status(vendor_proposal_id: str, status: str):
if not vendor_proposal_id: if not vendor_proposal_id:
return return
try: try:
httpx.put( _retry_request(
"PUT",
f"{API_BASE_URL}/api/vendor-proposals/{vendor_proposal_id}/status", f"{API_BASE_URL}/api/vendor-proposals/{vendor_proposal_id}/status",
json={"processingStatus": status}, json={"processingStatus": status},
headers=_api_headers(), headers=_api_headers(),
timeout=10,
) )
except Exception as e: except Exception as e:
logger.error("Error updating status: %s", e) logger.error("Error updating status: %s", e)
@ -338,3 +339,22 @@ def _api_headers() -> dict:
if api_key: if api_key:
headers["X-Internal-Api-Key"] = api_key headers["X-Internal-Api-Key"] = api_key
return headers 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 json
import logging import logging
import os import os
import time
from datetime import datetime from datetime import datetime
from io import BytesIO from io import BytesIO
@ -96,10 +97,10 @@ def generate_pdf(proposal_id: str):
def fetch_proposal(proposal_id: str) -> dict | None: def fetch_proposal(proposal_id: str) -> dict | None:
try: try:
resp = httpx.get( resp = _retry_request(
"GET",
f"{API_BASE_URL}/api/proposals/{proposal_id}", f"{API_BASE_URL}/api/proposals/{proposal_id}",
headers=_api_headers(), headers=_api_headers(),
timeout=10,
) )
if resp.status_code == 200: if resp.status_code == 200:
return resp.json() return resp.json()
@ -110,10 +111,10 @@ def fetch_proposal(proposal_id: str) -> dict | None:
def fetch_line_items(proposal_id: str) -> list[dict]: def fetch_line_items(proposal_id: str) -> list[dict]:
try: try:
resp = httpx.get( resp = _retry_request(
"GET",
f"{API_BASE_URL}/api/proposals/{proposal_id}/line-items", f"{API_BASE_URL}/api/proposals/{proposal_id}/line-items",
headers=_api_headers(), headers=_api_headers(),
timeout=10,
) )
if resp.status_code == 200: if resp.status_code == 200:
return resp.json() 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): def register_pdf(proposal_id: str, s3_key: str):
try: try:
resp = httpx.post( resp = _retry_request(
"POST",
f"{API_BASE_URL}/api/generated-pdfs", f"{API_BASE_URL}/api/generated-pdfs",
json={"proposalId": proposal_id, "s3Key": s3_key}, json={"proposalId": proposal_id, "s3Key": s3_key},
headers=_api_headers(), headers=_api_headers(),
timeout=10,
) )
if resp.status_code not in (200, 201): if resp.status_code not in (200, 201):
logger.error("Failed to register PDF: %s %s", resp.status_code, resp.text) logger.error("Failed to register PDF: %s %s", resp.status_code, resp.text)
@ -542,3 +543,22 @@ def _api_headers() -> dict:
if api_key: if api_key:
headers["X-Internal-Api-Key"] = api_key headers["X-Internal-Api-Key"] = api_key
return headers 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 json
import logging import logging
import os import os
import time
import boto3 import boto3
import httpx import httpx
@ -72,10 +73,10 @@ def process_suggestion(proposal_id: str, trigger: str):
def fetch_line_items(proposal_id: str) -> list[dict]: def fetch_line_items(proposal_id: str) -> list[dict]:
try: try:
resp = httpx.get( resp = _retry_request(
"GET",
f"{API_BASE_URL}/api/proposals/{proposal_id}/line-items", f"{API_BASE_URL}/api/proposals/{proposal_id}/line-items",
headers=_api_headers(), headers=_api_headers(),
timeout=10,
) )
if resp.status_code == 200: if resp.status_code == 200:
return resp.json() return resp.json()
@ -86,10 +87,10 @@ def fetch_line_items(proposal_id: str) -> list[dict]:
def fetch_proposal(proposal_id: str) -> dict | None: def fetch_proposal(proposal_id: str) -> dict | None:
try: try:
resp = httpx.get( resp = _retry_request(
"GET",
f"{API_BASE_URL}/api/proposals/{proposal_id}", f"{API_BASE_URL}/api/proposals/{proposal_id}",
headers=_api_headers(), headers=_api_headers(),
timeout=10,
) )
if resp.status_code == 200: if resp.status_code == 200:
return resp.json() return resp.json()
@ -263,7 +264,8 @@ def post_line_items(proposal_id: str, items: list[dict], existing_items: list[di
) )
try: try:
resp = httpx.put( resp = _retry_request(
"PUT",
f"{API_BASE_URL}/api/proposals/{proposal_id}/line-items", f"{API_BASE_URL}/api/proposals/{proposal_id}/line-items",
json={"lineItems": line_items_payload}, json={"lineItems": line_items_payload},
headers=_api_headers(), headers=_api_headers(),
@ -288,14 +290,14 @@ def store_similar_references(proposal_id: str, similar_proposals: list[dict]):
continue continue
try: try:
httpx.post( _retry_request(
"POST",
f"{API_BASE_URL}/api/proposals/{proposal_id}/similar-references", f"{API_BASE_URL}/api/proposals/{proposal_id}/similar-references",
json={ json={
"referencedLibraryItemId": library_item_id, "referencedLibraryItemId": library_item_id,
"similarityScore": sp["score"], "similarityScore": sp["score"],
}, },
headers=_api_headers(), headers=_api_headers(),
timeout=10,
) )
except Exception as e: except Exception as e:
logger.error("Error storing similar reference: %s", 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): def update_status_to_in_review(proposal_id: str):
try: try:
httpx.put( _retry_request(
"PUT",
f"{API_BASE_URL}/api/proposals/{proposal_id}", f"{API_BASE_URL}/api/proposals/{proposal_id}",
json={"status": "InReview"}, json={"status": "InReview"},
headers=_api_headers(), headers=_api_headers(),
timeout=10,
) )
except Exception as e: except Exception as e:
logger.error("Error updating status: %s", e) logger.error("Error updating status: %s", e)
@ -319,3 +321,22 @@ def _api_headers() -> dict:
if api_key: if api_key:
headers["X-Internal-Api-Key"] = api_key headers["X-Internal-Api-Key"] = api_key
return headers 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
import React_RCTAppDelegate import React_RCTAppDelegate
import ReactAppDependencyProvider import ReactAppDependencyProvider
import RCTLinking
@main @main
class AppDelegate: UIResponder, UIApplicationDelegate { class AppDelegate: UIResponder, UIApplicationDelegate {
@ -31,6 +32,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
return true 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 { class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {

View file

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

View file

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

View file

@ -2,6 +2,41 @@ import axios from 'axios';
import Config from '../../config'; import Config from '../../config';
import { tokenStorage } from '../storage'; 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({ const apiClient = axios.create({
baseURL: Config.API_URL, baseURL: Config.API_URL,
headers: { headers: {
@ -23,15 +58,40 @@ apiClient.interceptors.request.use(
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(response) => response, (response) => response,
(error) => { async (error) => {
if (error.response) { if (error.response) {
const { status, data } = error.response; const { status, data, config: originalRequest } = error.response;
if (status === 401) { if (status === 401 && !originalRequest._retry) {
tokenStorage.clear(); originalRequest._retry = true;
return Promise.reject(
new Error('Session expired. Please log in again.'), 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) { if (status === 403) {

View file

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

View file

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate, useBlocker } from 'react-router-dom';
import { useQuery, useMutation } from '@tanstack/react-query'; import { useQuery, useMutation } from '@tanstack/react-query';
import { import {
Box, Box,
@ -81,6 +81,23 @@ export default function AdminWorkspace() {
} }
}, [proposal]); }, [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({ const saveMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
await adminApi.updateProposal(id!, { refinedScope }); await adminApi.updateProposal(id!, { refinedScope });
@ -387,6 +404,20 @@ export default function AdminWorkspace() {
</CardContent> </CardContent>
</Card> </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 */} {/* Approve Confirmation Dialog */}
<Dialog open={approveDialogOpen} onClose={() => setApproveDialogOpen(false)}> <Dialog open={approveDialogOpen} onClose={() => setApproveDialogOpen(false)}>
<DialogTitle>Approve Proposal</DialogTitle> <DialogTitle>Approve Proposal</DialogTitle>

View file

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