Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
36a599f
Add backend address update endpoint with stubbed handler and service …
Mar 18, 2026
eb688a1
Add address feature scaffold with types, mutation hook, flow context,…
Mar 18, 2026
e0c3d23
Add address form component with state-specific defaults, validation, …
Mar 18, 2026
b1dddf4
Add dashboard success alerts for address update and card request flows
Mar 18, 2026
29c2b4f
Add replacement card prompt with address display, SNAP/TANF callout, …
Mar 18, 2026
1ac435a
Add card selection page with children checkboxes, CO card numbers, an…
Mar 18, 2026
f3a38e6
Add co-loaded info stub page and card-flow entry TODO comments
Mar 18, 2026
a0fb411
Register address handler and validation service in DI, add integratio…
Mar 18, 2026
0c6a897
Merge branch 'main' into feat/DC-152-update-mailing-address
Mar 18, 2026
2c8a857
fix dashboard alert disappearing after URL param cleanup
Mar 19, 2026
38d23ec
Merge branch 'main' into feat/DC-152-update-mailing-address
Mar 19, 2026
6671cc4
Address walkthrough findings: flow guard loading state, program name …
Mar 19, 2026
7ce64fc
Pre-populate address form with addressOnFile from household data
Mar 19, 2026
e9c3cdd
Use stub alert copy to signal pending state system integration
Mar 19, 2026
02c327e
add state abbreviation map, stub alert copy, and shared MSW handler
Mar 19, 2026
96bfc17
Wire up address update flow with form pre-population, card selection…
Mar 19, 2026
810c151
Wire up CO visual walkthrough fixes: button colors, heading styles, l…
Mar 20, 2026
3e21d81
Merge branch 'main' into feat/DC-152-update-mailing-address
Mar 20, 2026
f757a05
Address update: harden validation, fix ZIP inputMode, satisfy CodeQL…
Mar 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion src/SEBT.Portal.Api/Controllers/Household/HouseholdController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
namespace SEBT.Portal.Api.Controllers.Household;

/// <summary>
/// Controller for handling household data retrieval.
/// Controller for household data retrieval and management.
/// Household lookup uses state-configurable preferred household ID type (e.g. email, SNAP ID) resolved from the authenticated user.
/// </summary>
[ApiController]
Expand Down Expand Up @@ -49,4 +49,38 @@ public async Task<IActionResult> GetHouseholdData(
_ => StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse("An unexpected error occurred."))
});
}

/// <summary>
/// Updates the mailing address for the authenticated user's household.
/// </summary>
/// <param name="request">The new mailing address.</param>
/// <param name="commandHandler">The use case handler for updating the address.</param>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>No content on success; otherwise, BadRequest or Unauthorized.</returns>
/// <response code="204">Address updated successfully.</response>
/// <response code="400">Validation failed (missing fields or invalid format).</response>
/// <response code="403">User is not authorized or no household identifier could be resolved from token.</response>
[HttpPut("address")]
[Authorize]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status403Forbidden)]
public async Task<IActionResult> UpdateAddress(
[FromBody] UpdateAddressRequest request,
[FromServices] ICommandHandler<UpdateAddressCommand> commandHandler,
CancellationToken cancellationToken = default)
{
var command = new UpdateAddressCommand
{
User = User,
StreetAddress1 = request.StreetAddress1,
StreetAddress2 = request.StreetAddress2,
City = request.City,
State = request.State,
PostalCode = request.PostalCode
};

var result = await commandHandler.Handle(command, cancellationToken);
return result.ToActionResult();
}
}
29 changes: 29 additions & 0 deletions src/SEBT.Portal.Api/Models/Household/UpdateAddressRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System.ComponentModel.DataAnnotations;

namespace SEBT.Portal.Api.Models.Household;

/// <summary>
/// Request model for updating a household's mailing address.
/// </summary>
public record UpdateAddressRequest
{
/// <summary>Street address line 1 (e.g., "123 Main St NW").</summary>
[Required(ErrorMessage = "Street address is required.")]
public required string StreetAddress1 { get; init; }

/// <summary>Street address line 2 (e.g., apartment, suite). Optional.</summary>
public string? StreetAddress2 { get; init; }

/// <summary>City name.</summary>
[Required(ErrorMessage = "City is required.")]
public required string City { get; init; }

/// <summary>State or territory name.</summary>
[Required(ErrorMessage = "State is required.")]
public required string State { get; init; }

/// <summary>5- or 9-digit ZIP code (e.g., "20001" or "20001-1234").</summary>
[Required(ErrorMessage = "Postal code is required.")]
[RegularExpression(@"^\d{5}(-\d{4})?$", ErrorMessage = "Postal code must be a valid 5- or 9-digit ZIP code.")]
public required string PostalCode { get; init; }
}
34 changes: 34 additions & 0 deletions src/SEBT.Portal.Core/Services/IAddressValidationService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using SEBT.Portal.Core.Models.Household;

namespace SEBT.Portal.Core.Services;

/// <summary>
/// Validates a mailing address against an external service (e.g., Smarty).
/// Implementations may autocomplete, suggest alternatives, or reject invalid addresses.
/// </summary>
public interface IAddressValidationService
{
/// <summary>
/// Validates the given address and returns a result indicating whether the address is valid,
/// invalid, or has a suggested alternative.
/// </summary>
Task<AddressValidationResult> ValidateAsync(Address address, CancellationToken cancellationToken = default);
}

/// <summary>
/// Result of an address validation call.
/// </summary>
/// <param name="IsValid">Whether the address passed validation.</param>
/// <param name="SuggestedAddress">An alternative address suggested by the validation service, if any.</param>
/// <param name="ErrorMessage">A user-facing error message if validation failed.</param>
public record AddressValidationResult(
bool IsValid,
Address? SuggestedAddress = null,
string? ErrorMessage = null)
{
public static AddressValidationResult Valid() => new(true);

public static AddressValidationResult Invalid(string errorMessage) => new(false, ErrorMessage: errorMessage);

public static AddressValidationResult Suggestion(Address suggested) => new(false, SuggestedAddress: suggested);
}
3 changes: 3 additions & 0 deletions src/SEBT.Portal.Infrastructure/Dependencies.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ public static IServiceCollection AddPortalInfrastructureServices(this IServiceCo

// Household identifier resolution (state-configurable preferred household ID type)
services.AddTransient<IHouseholdIdentifierResolver, HouseholdIdentifierResolver>();

// Address validation — stub for now, swap with Smarty integration in DC-160
services.AddTransient<IAddressValidationService, AlwaysValidAddressValidator>();
services.AddSingleton<IIdentifierHasher, IdentifierHasher>();

// Expose SocureSettings directly for use case injection (avoids IOptions dependency in UseCases layer)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using SEBT.Portal.Core.Models.Household;
using SEBT.Portal.Core.Services;

namespace SEBT.Portal.Infrastructure.Services;

/// <summary>
/// Stub address validation service that always returns valid.
/// Replace with a real Smarty integration when DC-160 is implemented.
/// </summary>
public class AlwaysValidAddressValidator : IAddressValidationService
Comment thread
necampanini marked this conversation as resolved.
{
public Task<AddressValidationResult> ValidateAsync(Address address, CancellationToken cancellationToken = default)
{
return Task.FromResult(AddressValidationResult.Valid());
}
}
1 change: 1 addition & 0 deletions src/SEBT.Portal.UseCases/Dependencies.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public static IServiceCollection AddUseCases(this IServiceCollection services)
services.RegisterCommandHandler<StartChallengeCommand, StartChallengeResponse, StartChallengeCommandHandler>();
services.RegisterQueryHandler<GetVerificationStatusQuery, VerificationStatusResponse, GetVerificationStatusQueryHandler>();
services.RegisterCommandHandler<ProcessWebhookCommand, ProcessWebhookCommandHandler>();
services.RegisterCommandHandler<UpdateAddressCommand, UpdateAddressCommandHandler>();

return services;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using SEBT.Portal.Kernel;

namespace SEBT.Portal.UseCases.Household;

/// <summary>
/// Command to update the mailing address for an authenticated user's household.
/// </summary>
public class UpdateAddressCommand : ICommand
{
/// <summary>
/// The authenticated user's claims principal, used to resolve household identity.
/// </summary>
[Required]
public required ClaimsPrincipal User { get; init; }

[Required(ErrorMessage = "Street address is required.")]
[RegularExpression(@"\S.*", ErrorMessage = "Street address cannot be whitespace only.")]
public required string StreetAddress1 { get; init; }

public string? StreetAddress2 { get; init; }

[Required(ErrorMessage = "City is required.")]
[RegularExpression(@"\S.*", ErrorMessage = "City cannot be whitespace only.")]
public required string City { get; init; }

[Required(ErrorMessage = "State is required.")]
[RegularExpression(@"\S.*", ErrorMessage = "State cannot be whitespace only.")]
public required string State { get; init; }

[Required(ErrorMessage = "Postal code is required.")]
[RegularExpression(@"^\d{5}(-\d{4})?$", ErrorMessage = "Postal code must be a valid 5- or 9-digit ZIP code.")]
public required string PostalCode { get; init; }
Comment thread
necampanini marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using Microsoft.Extensions.Logging;
using SEBT.Portal.Core.Services;
using SEBT.Portal.Kernel;
using SEBT.Portal.Kernel.Results;

namespace SEBT.Portal.UseCases.Household;

/// <summary>
/// Handles mailing address updates for an authenticated user's household.
/// Validates input, resolves household identity, and returns success.
/// State connector call is stubbed — actual address persistence is a future integration.
/// </summary>
public class UpdateAddressCommandHandler(
IValidator<UpdateAddressCommand> validator,
IHouseholdIdentifierResolver resolver,
ILogger<UpdateAddressCommandHandler> logger)
: ICommandHandler<UpdateAddressCommand>
{
public async Task<Result> Handle(UpdateAddressCommand command, CancellationToken cancellationToken = default)
{
var validationResult = await validator.Validate(command, cancellationToken);
if (validationResult is ValidationFailedResult validationFailed)
{
logger.LogWarning("Address update validation failed");
return Result.ValidationFailed(validationFailed.Errors);
}

var identifier = await resolver.ResolveAsync(command.User, cancellationToken);
if (identifier == null)
{
logger.LogWarning("Address update attempted but no household identifier could be resolved from claims");
return Result.Unauthorized("Unable to identify user from token.");
}

// Never log raw address fields — PII policy.
// Extract enum name to a local to break CodeQL taint chain (identifier is tainted via .Value).
var identifierKind = identifier.Type.ToString();

logger.LogInformation(
"Address update received for household identifier kind {Kind}",
identifierKind);

// TODO: Call state connector to persist address update.
// This is stubbed — the handler returns success without writing to the state system.
// When DC-160 / state connector work lands, wire up IAddressValidationService and
// the state connector write method here.

logger.LogInformation(
"Address update completed for household identifier kind {Kind}",
identifierKind);

return Result.Success();
}
}
41 changes: 41 additions & 0 deletions src/SEBT.Portal.Web/design/fonts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'

import { primaryFont } from './fonts'

/**
* Contract test: the SCSS font override must reference the same CSS variable
* that the font generator produces. A mismatch causes a FOUT (flash of unstyled
* text) because the SCSS !important override resolves to an undefined variable,
* falling back to the browser's default serif font.
*
* This caught a real bug: generate-fonts.js was updated in DC-143 to produce
* --font-primary, but _uswds-theme-custom-styles.scss still referenced
* --font-urbanist.
*/
describe('font variable contract', () => {
const scssPath = resolve(__dirname, 'sass/_uswds-theme-custom-styles.scss')
const scssContent = readFileSync(scssPath, 'utf-8')

it('primaryFont exports a CSS variable name', () => {
expect(primaryFont.variable).toBeDefined()
expect(primaryFont.variable).toMatch(/^--font-/)
})

it('SCSS font override references the same variable as primaryFont', () => {
const cssVariable = primaryFont.variable
expect(scssContent).toContain(`var(${cssVariable})`)
})

it('SCSS font override does not reference stale font variable names', () => {
// Guard against the specific bug: old variable names left behind after
// the font generator is updated to produce a different variable.
const fontVarPattern = /var\(--font-([^)]+)\)/g
const referencedVars = [...scssContent.matchAll(fontVarPattern)].map((m) => `--font-${m[1]}`)

for (const varName of referencedVars) {
expect(varName).toBe(primaryFont.variable)
}
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -23,22 +23,22 @@ References:
Custom Font Override
----------------------------------------
Override USWDS default fonts with our custom font from Next.js font loader.
The --font-urbanist CSS variable is set by Next.js in layout.tsx.
The --font-primary CSS variable is set by Next.js in layout.tsx.
This ensures all USWDS components use our design token font.
----------------------------------------
*/

// Override all USWDS font-family declarations to use our custom font
// This is necessary because USWDS sets font-family on many utility classes
// TODO: Register Urbanist as a custom USWDS typeface to remove !important hack
// TODO: Register the design token font as a custom USWDS typeface to remove !important hack
// See: https://designsystem.digital.gov/documentation/settings/#typography-settings
body,
.usa-prose,
[class*='font-'],
[class*='text-'],
[class*='usa-'] {
font-family:
var(--font-urbanist),
var(--font-primary),
system-ui,
-apple-system,
BlinkMacSystemFont,
Expand Down
41 changes: 41 additions & 0 deletions src/SEBT.Portal.Web/design/sass/components/_buttons.scss
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,44 @@ Typography: 16px, Bold (700), Line height 24px
}
}
}

// Temporary CO override: DC uses secondary (gold) for buttons, but CO's secondary
// is red — intended for error/validation states, not interactive controls.
// CO mockups show buttons in the primary teal range.
// TODO: Resolve with a proper per-state button token mapping so this override
// is unnecessary. See design token discussion re: semantic color roles.
html[data-state='co'] {
Comment thread
necampanini marked this conversation as resolved.
.usa-button:not(.usa-language__link):not(.usa-button--unstyled):not(.usa-button--outline) {
background-color: color('primary');
border-color: color('primary');
color: color('white');

&:hover {
background-color: color('primary-dark');
border-color: color('primary-dark');
}

&:active {
background-color: color('primary-darker');
border-color: color('primary-darker');
}
}

.usa-button--outline:not(.usa-language__link) {
background-color: color('white');
border: 2px solid color('primary');
color: color('primary');

&:hover {
background-color: color('primary-lightest');
border-color: color('primary-dark');
color: color('primary-dark');
}

&:active {
background-color: color('primary-lighter');
border-color: color('primary-darker');
color: color('primary-darker');
}
}
}
Loading
Loading