fix: CORS, JWT auth, useBlocker crash, and auto-migration for production deploy
Some checks failed
Deploy / Deploy to AWS (push) Has been cancelled

- Add CloudFront origin to API Gateway CORS preflight and .NET CORS policy
- Replace HttpMethod.ANY with explicit methods so OPTIONS preflight doesn't
  hit the JWT authorizer (was causing 403 on all API calls)
- Return Cognito ID token instead of access token from auth callback
  (access tokens lack the aud claim required by API Gateway JWT authorizer)
- Add CloudFront callback URI to allowed redirect list
- Remove identity_provider=Google from login URL to show Cognito hosted UI
- Replace useBlocker (requires data router) with state-based navigation guard
  to fix crash on AdminWorkspace with BrowserRouter
- Add auto-migration on Lambda cold start
- Enable Swagger in production
This commit is contained in:
Adam Moussa 2026-05-27 19:20:29 -04:00
parent da783d48cd
commit ae3ad9d823
5 changed files with 56 additions and 25 deletions

View file

@ -37,6 +37,7 @@ public class AuthController : ControllerBase
var allowedRedirectUris = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"https://proposals.seahaven.com/callback",
"https://d2yevct5e5uuz5.cloudfront.net/callback",
};
if (_config.GetValue<bool>("Auth:DevMode"))
allowedRedirectUris.Add("http://localhost:5173/callback");
@ -136,7 +137,7 @@ public class AuthController : ControllerBase
user.Email,
user.DisplayName,
user.Role.ToString(),
tokenResponse.AccessToken
tokenResponse.IdToken
));
}

View file

@ -205,7 +205,7 @@ builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
var origins = new List<string> { "https://proposals.seahaven.com" };
var origins = new List<string> { "https://proposals.seahaven.com", "https://d2yevct5e5uuz5.cloudfront.net" };
if (builder.Environment.IsDevelopment())
origins.Add("http://localhost:5173");
policy.WithOrigins(origins.ToArray())
@ -221,11 +221,8 @@ var app = builder.Build();
app.UseMiddleware<GlobalExceptionHandler>();
if (!app.Environment.IsProduction())
{
app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Proposal System API v1"));
}
app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Proposal System API v1"));
app.UseCors();
app.UseMiddleware<InternalApiKeyMiddleware>();
@ -243,4 +240,10 @@ app.Use(async (context, next) =>
app.MapControllers();
app.MapHealthChecks("/api/health");
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ProposalDbContext>();
db.Database.Migrate();
}
app.Run();

View file

@ -307,6 +307,7 @@ export class ComputeStack extends cdk.Stack {
corsPreflight: {
allowOrigins: [
'https://proposals.seahaven.com',
'https://d2yevct5e5uuz5.cloudfront.net',
'http://localhost:5173',
],
allowMethods: [
@ -364,13 +365,31 @@ export class ComputeStack extends cdk.Stack {
httpApi.addRoutes({
path: '/api/auth/{proxy+}',
methods: [apigatewayv2.HttpMethod.POST],
methods: [apigatewayv2.HttpMethod.POST, apigatewayv2.HttpMethod.OPTIONS],
integration: apiIntegration,
});
httpApi.addRoutes({
path: '/swagger/{proxy+}',
methods: [apigatewayv2.HttpMethod.GET],
integration: apiIntegration,
});
httpApi.addRoutes({
path: '/swagger',
methods: [apigatewayv2.HttpMethod.GET],
integration: apiIntegration,
});
httpApi.addRoutes({
path: '/{proxy+}',
methods: [apigatewayv2.HttpMethod.ANY],
methods: [
apigatewayv2.HttpMethod.GET,
apigatewayv2.HttpMethod.POST,
apigatewayv2.HttpMethod.PUT,
apigatewayv2.HttpMethod.DELETE,
apigatewayv2.HttpMethod.PATCH,
],
integration: apiIntegration,
authorizer: jwtAuthorizer,
});

View file

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate, useBlocker } from 'react-router-dom';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation } from '@tanstack/react-query';
import {
Box,
@ -109,13 +109,17 @@ export default function AdminWorkspace() {
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 [navBlocked, setNavBlocked] = useState(false);
const pendingNavRef = useRef<string | null>(null);
const guardedNavigate = useCallback((to: string) => {
if (dirty) {
pendingNavRef.current = to;
setNavBlocked(true);
} else {
navigate(to);
}
}, [dirty, navigate]);
const saveMutation = useMutation({
mutationFn: async () => {
@ -258,7 +262,7 @@ export default function AdminWorkspace() {
>
Failed to load proposal{proposalError instanceof Error ? `: ${proposalError.message}` : '.'}
</Alert>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/admin')} sx={{ mt: 2 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => guardedNavigate('/admin')} sx={{ mt: 2 }}>
Back to Dashboard
</Button>
</Box>
@ -269,7 +273,7 @@ export default function AdminWorkspace() {
return (
<Box>
<Typography color="error">Proposal not found</Typography>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/admin')} sx={{ mt: 2 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => guardedNavigate('/admin')} sx={{ mt: 2 }}>
Back to Dashboard
</Button>
</Box>
@ -305,7 +309,7 @@ export default function AdminWorkspace() {
<Link
component="button"
underline="hover"
onClick={() => navigate('/admin')}
onClick={() => guardedNavigate('/admin')}
sx={{ fontSize: 14, color: '#0B5A73', cursor: 'pointer' }}
>
Admin Queue
@ -550,14 +554,19 @@ export default function AdminWorkspace() {
</Card>
{/* Unsaved Changes Navigation Guard */}
<Dialog open={blocker.state === 'blocked'} onClose={() => blocker.reset?.()}>
<Dialog open={navBlocked} onClose={() => setNavBlocked(false)}>
<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?.()}>
<Button onClick={() => setNavBlocked(false)}>Stay</Button>
<Button variant="contained" color="error" onClick={() => {
setNavBlocked(false);
const dest = pendingNavRef.current ?? '/admin';
pendingNavRef.current = null;
navigate(dest);
}}>
Discard & Leave
</Button>
</DialogActions>

View file

@ -20,7 +20,6 @@ function buildLoginUrl(): string {
response_type: 'code',
scope: 'openid email profile',
redirect_uri: REDIRECT_URI,
identity_provider: 'Google',
});
return `https://${COGNITO_DOMAIN}/oauth2/authorize?${params.toString()}`;
}