Skip to content

Commit e0cb39b

Browse files
Merge branch 'main' into dependabot/npm_and_yarn/npm_and_yarn-a75b47bd17
2 parents 7a8470f + 7d9d9ca commit e0cb39b

19 files changed

Lines changed: 258 additions & 138 deletions

File tree

packages/design-system/design/sass/components/_footer.scss

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,27 @@
22
----------------------------------------
33
Footer Styles
44
----------------------------------------
5-
Custom footer background colors per Figma design.
6-
TODO: Replace hardcoded color with design token when available.
5+
State-specific footer backgrounds via CSS custom properties.
6+
Each state defines --footer-section-bg on a BEM modifier class
7+
(.usa-footer--dc, .usa-footer--co). Colors are bespoke values
8+
from each state's Figma design that don't map to USWDS tokens.
79
----------------------------------------
810
*/
911

1012
@use 'uswds-core' as *;
1113

12-
// Footer sections - #00A4A4
13-
// TODO: This should use a token like color('footer-bg') when defined
14+
// DC: teal background per DC Figma global footer (node I2035:1118;6002:23451)
15+
.usa-footer--dc {
16+
--footer-section-bg: #00a4a4;
17+
}
18+
19+
// CO: neutral gray per CO Figma CDHS global footer (nodes 8007:29245, 8007:29246)
20+
// Computed from Figma's rgba(0,0,0,0.1) overlay on #f5f5f5
21+
.usa-footer--co {
22+
--footer-section-bg: #dddddd;
23+
}
24+
1425
.usa-footer__primary-section,
1526
.usa-footer__secondary-section {
16-
background-color: #00a4a4;
27+
background-color: var(--footer-section-bg);
1728
}

packages/design-system/design/scripts/generate-sass-tokens.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,11 @@ function generateSettingsContent(state, variables, timestamp) {
265265
settingsLines.push(' $theme-alert-icon-size: 3,')
266266
settingsLines.push('')
267267

268+
// Suppress USWDS release notes printed on every compile
269+
settingsLines.push(' // Suppress verbose USWDS compile notifications')
270+
settingsLines.push(' $theme-show-notifications: false,')
271+
settingsLines.push('')
272+
268273
// Add utility settings - ensures utility classes override component styles
269274
settingsLines.push(' // Utility settings')
270275
settingsLines.push(' $utilities-use-important: true')

packages/design-system/src/components/layout/Footer.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export function Footer({ state = 'dc' }: FooterProps) {
2828

2929
return (
3030
<footer
31-
className="usa-footer usa-footer--slim"
31+
className={`usa-footer usa-footer--slim usa-footer--${state}`}
3232
role="contentinfo"
3333
>
3434
<div className="usa-footer__primary-section padding-top-4">
@@ -96,19 +96,19 @@ function COFooter({ state = 'co' }: FooterProps) {
9696

9797
return (
9898
<footer
99-
className="usa-footer usa-footer--slim"
99+
className={`usa-footer usa-footer--slim usa-footer--${state}`}
100100
role="contentinfo"
101101
>
102102
<div className="usa-footer__primary-section padding-y-2">
103103
<div className="grid-container text-center">
104-
<p className="margin-0 text-white font-sans-xs">
104+
<p className="margin-0 text-ink font-sans-xs">
105105
{t('copyrite', '© 2026 State of Colorado')}
106106
{' | '}
107107
<Link
108108
href={links.footer.transparencyOnline ?? '#'}
109109
target="_blank"
110110
rel="noopener noreferrer"
111-
className="usa-link text-white text-underline"
111+
className="usa-link text-ink text-underline"
112112
>
113113
{t('transparencyOnline', 'Transparency Online')}
114114
</Link>
@@ -117,7 +117,7 @@ function COFooter({ state = 'co' }: FooterProps) {
117117
href={links.footer.generalNotices ?? '#'}
118118
target="_blank"
119119
rel="noopener noreferrer"
120-
className="usa-link text-white text-underline"
120+
className="usa-link text-ink text-underline"
121121
>
122122
{t('generalNotices', 'General Notices')}
123123
</Link>

src/SEBT.Portal.Api/Composition/PluginAssemblyLoader.cs

Lines changed: 81 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Reflection;
22
using System.Runtime.Loader;
3+
using Serilog;
34

45
namespace SEBT.Portal.Api.Composition;
56

@@ -15,15 +16,27 @@ namespace SEBT.Portal.Api.Composition;
1516
// The assembly loading logic here is standalone and does not depend on MEF.
1617
internal static class PluginAssemblyLoader
1718
{
19+
/// <param name="paths">Entries from configuration (relative to the API output or content root, or absolute).</param>
20+
/// <param name="searchOption">Whether to scan subdirectories when counting and loading DLLs.</param>
21+
/// <param name="contentRootPath">Host content root (project directory during <c>dotnet run</c> / watch). Used when plugin DLLs exist under <c>plugins-*</c> there but were not copied into <c>AppContext.BaseDirectory</c>.</param>
1822
public static List<Assembly> LoadAssembliesFromPaths(
1923
string[] paths,
20-
SearchOption searchOption = SearchOption.TopDirectoryOnly)
24+
SearchOption searchOption = SearchOption.TopDirectoryOnly,
25+
string? contentRootPath = null)
2126
{
2227
var baseDir = AppContext.BaseDirectory;
23-
var existingPaths = paths
24-
.Select(p => Path.GetFullPath(Path.Combine(baseDir, p)))
25-
.Where(Directory.Exists)
26-
.ToArray();
28+
var existingPaths = ResolvePluginDirectoriesWithDlls(paths, baseDir, contentRootPath, searchOption);
29+
30+
if (paths.Length > 0 && existingPaths.Length == 0)
31+
{
32+
Log.Warning(
33+
"No plugin directories containing DLLs were found. PluginAssemblyPaths: {ConfiguredPaths}. " +
34+
"BaseDirectory: {BaseDirectory}. ContentRoot: {ContentRoot}. " +
35+
"For local dev, build the state connector (e.g. pnpm api:build-co) so DLLs exist under src/SEBT.Portal.Api/plugins-* or under the API output folder.",
36+
paths,
37+
baseDir,
38+
contentRootPath ?? "(not provided)");
39+
}
2740

2841
if (existingPaths.Length == 0)
2942
return [];
@@ -48,23 +61,18 @@ public static List<Assembly> LoadAssembliesFromPaths(
4861

4962
var loadedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
5063

51-
// Connector post-builds often copy transitive dependencies (contracts, Kiota, etc.) into
52-
// plugins-* alongside the implementation. Those DLLs are already in the app base or default
53-
// context — loading them again from the plugin path causes duplicate loads and type/MEF issues.
64+
// Skip only assemblies already loaded in the default context. Do NOT treat every *.dll file
65+
// under BaseDirectory as loaded: connector outputs are copied next to the host EXE via
66+
// CopyToOutputDirectory, so SEBT.Portal.StatePlugins.*.dll may exist on disk without being
67+
// loaded — those must still load from the plugins-* folder or plugin types never register.
68+
// (Loading the same assembly twice from different paths still trips the catch below.)
5469
var hostAssemblySimpleNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
5570
foreach (var assemblyName in AssemblyLoadContext.Default.Assemblies.Select(a => a.GetName().Name))
5671
{
5772
if (!string.IsNullOrEmpty(assemblyName))
5873
hostAssemblySimpleNames.Add(assemblyName);
5974
}
6075

61-
foreach (var dllPath in Directory.GetFiles(baseDir, "*.dll"))
62-
{
63-
var simpleName = Path.GetFileNameWithoutExtension(dllPath);
64-
if (!string.IsNullOrEmpty(simpleName))
65-
hostAssemblySimpleNames.Add(simpleName);
66-
}
67-
6876
var allAssemblies = new List<Assembly>();
6977

7078
foreach (var combinedPath in existingPaths)
@@ -95,4 +103,62 @@ public static List<Assembly> LoadAssembliesFromPaths(
95103

96104
return allAssemblies;
97105
}
106+
107+
/// <summary>
108+
/// Picks one directory per configured path: first tries the app output (<paramref name="baseDir"/>),
109+
/// then host content root, skipping folders that do not exist or contain no DLLs.
110+
/// Absolute <paramref name="paths"/> entries are used as-is.
111+
/// </summary>
112+
private static string[] ResolvePluginDirectoriesWithDlls(
113+
string[] paths,
114+
string baseDir,
115+
string? contentRootPath,
116+
SearchOption searchOption)
117+
{
118+
var chosen = new List<string>();
119+
120+
foreach (var raw in paths)
121+
{
122+
if (string.IsNullOrWhiteSpace(raw))
123+
continue;
124+
125+
var trimmed = raw.Trim();
126+
var candidates = new List<string>();
127+
if (Path.IsPathRooted(trimmed))
128+
{
129+
candidates.Add(Path.GetFullPath(trimmed));
130+
}
131+
else
132+
{
133+
candidates.Add(Path.GetFullPath(Path.Combine(baseDir, trimmed)));
134+
if (!string.IsNullOrEmpty(contentRootPath))
135+
candidates.Add(Path.GetFullPath(Path.Combine(contentRootPath, trimmed)));
136+
}
137+
138+
string? existing = null;
139+
foreach (var dir in candidates.Distinct(StringComparer.OrdinalIgnoreCase))
140+
{
141+
if (!Directory.Exists(dir))
142+
continue;
143+
try
144+
{
145+
if (Directory.GetFiles(dir, "*.dll", searchOption).Length == 0)
146+
continue;
147+
}
148+
catch (IOException ex)
149+
{
150+
Log.Warning(ex, "Could not enumerate plugin DLLs in directory {PluginDirectory}", dir);
151+
continue;
152+
}
153+
154+
existing = dir;
155+
break;
156+
}
157+
158+
if (existing != null)
159+
chosen.Add(existing);
160+
}
161+
162+
return chosen.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
163+
}
98164
}

src/SEBT.Portal.Api/Composition/ServiceCollectionPluginExtensions.cs

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,22 +25,31 @@ namespace SEBT.Portal.Api.Composition;
2525
internal static class ServiceCollectionPluginExtensions
2626
{
2727
public static IServiceCollection AddPlugins(this IServiceCollection services, IConfiguration configuration)
28-
{
29-
services.TryAddSingleton<IStateAuthenticationService, Defaults.DefaultStateAuthenticationService>();
30-
services.TryAddSingleton<IStateHealthCheckService, Defaults.DefaultStateHealthCheckService>();
31-
services.TryAddSingleton<ISummerEbtCaseService, Defaults.DefaultSummerEbtCaseService>();
32-
services.TryAddSingleton<IEnrollmentCheckService, Defaults.DefaultEnrollmentCheckService>();
33-
services.TryAddSingleton<IAddressUpdateService, Defaults.DefaultAddressUpdateService>();
28+
=> services.AddPlugins(configuration, contentRootPath: null);
3429

30+
/// <param name="services">The service collection.</param>
31+
/// <param name="configuration">Application configuration (must include PluginAssemblyPaths).</param>
32+
/// <param name="contentRootPath">
33+
/// <see cref="Microsoft.Extensions.Hosting.IHostEnvironment.ContentRootPath"/> so plugin folders under
34+
/// the API project directory are found when DLLs were not copied into <c>AppContext.BaseDirectory</c>.
35+
/// </param>
36+
public static IServiceCollection AddPlugins(
37+
this IServiceCollection services,
38+
IConfiguration configuration,
39+
string? contentRootPath)
40+
{
3541
var healthChecksBuilder = services.AddHealthChecks();
3642

3743
var pluginAssemblyPaths = configuration
3844
.GetSection("PluginAssemblyPaths")
3945
.Get<string[]>()
4046
?? throw new InvalidOperationException("PluginAssemblyPaths missing from configuration.");
41-
Log.Information("Loading plugins from: {PluginAssemblyPaths}", pluginAssemblyPaths);
47+
Log.Debug("Loading plugins from: {PluginAssemblyPaths}", pluginAssemblyPaths);
4248

43-
var loadedAssemblies = PluginAssemblyLoader.LoadAssembliesFromPaths(pluginAssemblyPaths);
49+
var loadedAssemblies = PluginAssemblyLoader.LoadAssembliesFromPaths(
50+
pluginAssemblyPaths,
51+
SearchOption.TopDirectoryOnly,
52+
contentRootPath);
4453

4554
var pluginTypes = loadedAssemblies
4655
.SelectMany(a =>
@@ -57,10 +66,12 @@ public static IServiceCollection AddPlugins(this IServiceCollection services, IC
5766

5867
foreach (var pluginType in pluginTypes)
5968
{
60-
Log.Information("Discovered plugin type: {PluginType}", pluginType.FullName);
69+
Log.Debug("Discovered plugin type: {PluginType}", pluginType.FullName);
6170

71+
// Only service interfaces that extend IStatePlugin (excludes IDisposable and other
72+
// non-state contracts that appear on GetInterfaces()).
6273
var pluginInterfaces = pluginType.GetInterfaces()
63-
.Where(i => i != typeof(IStatePlugin))
74+
.Where(i => i != typeof(IStatePlugin) && typeof(IStatePlugin).IsAssignableFrom(i))
6475
.ToList();
6576

6677
switch (pluginInterfaces.Count)
@@ -98,7 +109,7 @@ public static IServiceCollection AddPlugins(this IServiceCollection services, IC
98109
// with a lazy resolve adapter.
99110
using var tempProvider = services.BuildServiceProvider();
100111
var instance = ActivatorUtilities.CreateInstance(tempProvider, pluginType);
101-
Log.Information("Constructed health check plugin: {PluginType}", pluginType.FullName);
112+
Log.Debug("Constructed health check plugin: {PluginType}", pluginType.FullName);
102113

103114
((IStateHealthCheckService)instance).ConfigureHealthChecks(healthChecksBuilder);
104115
services.AddSingleton(pluginInterface, instance);
@@ -113,12 +124,27 @@ public static IServiceCollection AddPlugins(this IServiceCollection services, IC
113124
{
114125
var logger = sp.GetRequiredService<ILoggerFactory>()
115126
.CreateLogger("SEBT.Portal.Api.Composition");
116-
logger.LogInformation("Constructing plugin: {PluginType}", capturedType.FullName);
127+
logger.LogDebug("Constructing plugin: {PluginType}", capturedType.FullName);
117128
return ActivatorUtilities.CreateInstance(sp, capturedType);
118129
});
119130
}
120131
}
121132

133+
if (pluginTypes.Count == 0 && loadedAssemblies.Count > 0)
134+
{
135+
Log.Warning(
136+
"Loaded {AssemblyCount} plugin assemblies but discovered 0 IStatePlugin implementations. " +
137+
"See earlier warnings for TypeLoadException from GetExportedTypes.",
138+
loadedAssemblies.Count);
139+
}
140+
141+
// Register in-process defaults only for services no connector plugin provided.
142+
services.TryAddSingleton<IStateAuthenticationService, Defaults.DefaultStateAuthenticationService>();
143+
services.TryAddSingleton<IStateHealthCheckService, Defaults.DefaultStateHealthCheckService>();
144+
services.TryAddSingleton<ISummerEbtCaseService, Defaults.DefaultSummerEbtCaseService>();
145+
services.TryAddSingleton<IEnrollmentCheckService, Defaults.DefaultEnrollmentCheckService>();
146+
services.TryAddSingleton<IAddressUpdateService, Defaults.DefaultAddressUpdateService>();
147+
122148
return services;
123149
}
124150
}

src/SEBT.Portal.Api/Program.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@
122122
builder.Services.AddCaching(builder.Configuration);
123123

124124
// Registers plugins and allows them to be constructor injected into ASP.NET controllers
125-
builder.Services.AddPlugins(builder.Configuration);
125+
builder.Services.AddPlugins(builder.Configuration, builder.Environment.ContentRootPath);
126126

127127
// Add services to the container.
128128
builder.Services.AddControllers();

src/SEBT.Portal.Api/SEBT.Portal.Api.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@
127127

128128
<ItemGroup>
129129
<Folder Include="plugins-dc\" />
130+
<Folder Include="plugins-co\" />
130131
</ItemGroup>
131132

132133
</Project>

src/SEBT.Portal.Infrastructure/Repositories/DatabaseDocVerificationChallengeRepository.cs

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,41 @@ public async Task CreateAsync(
8686
throw new ArgumentNullException(nameof(challenge));
8787
}
8888

89-
var entity = MapToEntity(challenge);
90-
dbContext.DocVerificationChallenges.Add(entity);
91-
await dbContext.SaveChangesAsync(cancellationToken);
89+
var now = DateTime.UtcNow;
90+
91+
// Single transaction: bulk expire + insert commit together. Without this, a failure after
92+
// ExecuteUpdateAsync could leave stale rows expired with no new challenge (recoverable on
93+
// retry but inconsistent until then).
94+
await using var transaction =
95+
await dbContext.Database.BeginTransactionAsync(cancellationToken);
96+
try
97+
{
98+
// Bulk expire via SQL: does not use RowVersion optimistic concurrency or
99+
// DocVerificationChallenge.TransitionTo (same Created→Expired transition as the domain).
100+
// The predicate limits rows to stale Created/Pending; revisit if concurrent writers race here.
101+
await dbContext.DocVerificationChallenges
102+
.Where(c => c.UserId == challenge.UserId
103+
&& (c.Status == (int)DocVerificationStatus.Created
104+
|| c.Status == (int)DocVerificationStatus.Pending)
105+
&& c.ExpiresAt != null
106+
&& c.ExpiresAt <= now)
107+
.ExecuteUpdateAsync(
108+
setters => setters
109+
.SetProperty(e => e.Status, (int)DocVerificationStatus.Expired)
110+
.SetProperty(e => e.UpdatedAt, now),
111+
cancellationToken);
112+
113+
var entity = MapToEntity(challenge);
114+
dbContext.DocVerificationChallenges.Add(entity);
115+
await dbContext.SaveChangesAsync(cancellationToken);
116+
117+
await transaction.CommitAsync(cancellationToken);
118+
}
119+
catch
120+
{
121+
await transaction.RollbackAsync(cancellationToken);
122+
throw;
123+
}
92124
}
93125

94126
public async Task UpdateAsync(

src/SEBT.Portal.Infrastructure/Services/HouseholdIdentifierResolver.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ public HouseholdIdentifierResolver(
7979
var overridePhone = _phoneOverrideProvider.GetOverridePhone();
8080
if (!string.IsNullOrWhiteSpace(overridePhone))
8181
{
82-
_logger?.LogInformation("Using development phone override for household lookup ");
82+
_logger?.LogInformation("Using development phone override for household lookup");
8383
return new HouseholdIdentifier(PreferredHouseholdIdType.Phone, overridePhone);
8484
}
8585

src/SEBT.Portal.Web/e2e/card-replacement/address-flow.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ import { currentState } from '../fixtures/state'
1212

1313
const ADDRESS_FORM_DATA =
1414
currentState === 'co'
15-
? { street: '200 E Colfax Ave', city: 'Denver', state: 'Colorado', zip: '80203' }
15+
? { street: '200 E Colfax Ave', city: 'Denver', state: 'CO', zip: '80203' }
1616
: {
1717
street: '456 Oak Avenue NW',
1818
city: 'Washington',
19-
state: 'District of Columbia',
19+
state: 'DC',
2020
zip: '20002'
2121
}
2222

0 commit comments

Comments
 (0)