diff --git a/framework/SimpleModule.Core/Authorization/ICurrentUser.cs b/framework/SimpleModule.Core/Authorization/ICurrentUser.cs new file mode 100644 index 00000000..c9e9e751 --- /dev/null +++ b/framework/SimpleModule.Core/Authorization/ICurrentUser.cs @@ -0,0 +1,32 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using SimpleModule.Core.Extensions; + +namespace SimpleModule.Core.Authorization; + +/// +/// Ambient access to the current request's principal, without forcing every +/// caller to inject IHttpContextAccessor and walk claims by hand. +/// +public interface ICurrentUser +{ + string? Id { get; } + bool IsAuthenticated { get; } + bool IsInRole(string role); + bool HasPermission(string permission); + ClaimsPrincipal? Principal { get; } +} + +public sealed class HttpContextCurrentUser(IHttpContextAccessor httpContextAccessor) : ICurrentUser +{ + public ClaimsPrincipal? Principal => httpContextAccessor.HttpContext?.User; + + public string? Id => Principal?.GetUserId(); + + public bool IsAuthenticated => Principal?.Identity?.IsAuthenticated == true; + + public bool IsInRole(string role) => Principal?.IsInRole(role) == true; + + public bool HasPermission(string permission) => + Principal is not null && Principal.HasPermission(permission); +} diff --git a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs index 7150accd..09f6ed36 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Http; @@ -8,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; +using SimpleModule.Core.Authorization; using SimpleModule.Core.Constants; using SimpleModule.Core.Exceptions; using SimpleModule.Core.Health; @@ -85,6 +87,7 @@ public static WebApplicationBuilder AddSimpleModuleInfrastructure( // Required by EntityInterceptor to access the current HTTP context builder.Services.AddHttpContextAccessor(); + builder.Services.AddScoped(); // Entity framework interceptors for automatic entity field population builder.Services.AddScoped(); @@ -95,7 +98,16 @@ public static WebApplicationBuilder AddSimpleModuleInfrastructure( // (e.g., OpenIddict registers SmartAuth policy scheme). // Register a baseline so the middleware pipeline works even without an auth module. builder.Services.AddAuthentication(); - builder.Services.AddAuthorization(); + // Authenticated-by-default. Endpoints that genuinely need to be public + // (login, health probes, error/404 fallbacks) opt out with .AllowAnonymous(). + // Without a fallback policy, plain app.MapGet(...) outside a module group + // is silently public — the wrong default for a business app. + builder.Services.AddAuthorization(options => + { + options.FallbackPolicy = new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .Build(); + }); builder.Services.AddAntiforgery(); // Register default IPublicMenuProvider if no module provides one @@ -252,7 +264,11 @@ public static async Task UseSimpleModuleInfrastructure(this WebApplication app) app.UseInertia(); UseStaticFileCaching(app); - app.MapStaticAssets(); + // MapStaticAssets registers endpoints, which would otherwise inherit the + // RequireAuthenticatedUser fallback policy — that breaks JS bundle / CSS / + // favicon loads on anonymous pages like /Identity/Account/Login. Static + // files are intentionally public. + app.MapStaticAssets().AllowAnonymous(); app.UseAuthentication(); app.UseAuthorization(); @@ -272,6 +288,10 @@ public static async Task UseSimpleModuleInfrastructure(this WebApplication app) if (options.EnableHealthChecks) { + // Health probes are intentionally anonymous so kubelet / load balancers + // can hit them without credentials. The framework owns these — do not + // remove the .AllowAnonymous() calls when sweeping anonymous routes + // out of an application. app.MapHealthChecks( RouteConstants.HealthLive, new HealthCheckOptions { Predicate = _ => false } diff --git a/modules/Admin/src/SimpleModule.Admin/AdminModule.cs b/modules/Admin/src/SimpleModule.Admin/AdminModule.cs index 7e7bab7b..10c2f9dd 100644 --- a/modules/Admin/src/SimpleModule.Admin/AdminModule.cs +++ b/modules/Admin/src/SimpleModule.Admin/AdminModule.cs @@ -1,8 +1,10 @@ -using Microsoft.Extensions.Configuration; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using SimpleModule.Admin.Contracts; using SimpleModule.Core; using SimpleModule.Core.Menu; +using SimpleModule.OpenIddict.Contracts; namespace SimpleModule.Admin; @@ -14,6 +16,27 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped(); } + public void ConfigureMiddleware(IApplicationBuilder app) + { + // Admin's session-management endpoints inject IOpenIddictSessionContracts, whose + // implementation lives in SimpleModule.OpenIddict (not its Contracts assembly). + // Without that module installed, minimal-API parameter binding falls through to + // body-binding and the host crashes at MapModuleEndpoints with the misleading + // "Body was inferred but the method does not allow inferred body parameters." + // Fail fast here with a directive a human can act on. + // Use IServiceProviderIsService so we don't actually instantiate the (scoped) + // service from the root provider. + var probe = app.ApplicationServices.GetRequiredService(); + if (!probe.IsService(typeof(IOpenIddictSessionContracts))) + { + throw new InvalidOperationException( + "SimpleModule.Admin requires SimpleModule.OpenIddict to be installed. " + + "Add a reference to the SimpleModule.OpenIddict package (or project) " + + "so IOpenIddictSessionContracts can be resolved by Admin's session endpoints." + ); + } + } + public void ConfigureMenu(IMenuBuilder menus) { menus.Add( diff --git a/modules/Email/src/SimpleModule.Email/EmailModule.cs b/modules/Email/src/SimpleModule.Email/EmailModule.cs index ecfbc525..1bd05674 100644 --- a/modules/Email/src/SimpleModule.Email/EmailModule.cs +++ b/modules/Email/src/SimpleModule.Email/EmailModule.cs @@ -1,4 +1,5 @@ using FluentValidation; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -21,6 +22,26 @@ namespace SimpleModule.Email; )] public class EmailModule : IModule, IModuleServices { + public void ConfigureMiddleware(IApplicationBuilder app) + { + // EmailService and the job-registration hosted service inject IBackgroundJobs, + // whose implementation lives in SimpleModule.BackgroundJobs (not its Contracts + // assembly). Without that module installed, the host crashes at first resolution + // with "Unable to resolve service for type 'IBackgroundJobs'" — fail fast here + // with a directive message instead. + // Use IServiceProviderIsService so we don't actually instantiate the (scoped) + // service from the root provider. + var probe = app.ApplicationServices.GetRequiredService(); + if (!probe.IsService(typeof(IBackgroundJobs))) + { + throw new InvalidOperationException( + "SimpleModule.Email requires SimpleModule.BackgroundJobs to be installed. " + + "Add a reference to the SimpleModule.BackgroundJobs package (or project) " + + "so IBackgroundJobs can be resolved by Email's send/retry jobs." + ); + } + } + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) { services.AddModuleDbContext(configuration, EmailConstants.ModuleName); diff --git a/modules/Users/src/SimpleModule.Users/UsersModule.cs b/modules/Users/src/SimpleModule.Users/UsersModule.cs index 8af07284..670aec99 100644 --- a/modules/Users/src/SimpleModule.Users/UsersModule.cs +++ b/modules/Users/src/SimpleModule.Users/UsersModule.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -37,6 +38,41 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config options.LoginPath = "/Identity/Account/Login"; options.LogoutPath = "/Identity/Account/Logout"; options.AccessDeniedPath = "/Identity/Account/AccessDenied"; + + // /api/* clients (JS, CLI, integration tests) want a bare 401 — not a + // 302 to /Identity/Account/Login. The default cookie handler sniffs the + // Accept header but inconsistently, leading to 401 for some routes and + // 302 for others. Force 401 for any unauthenticated /api request. + options.Events.OnRedirectToLogin = context => + { + if ( + context.Request.Path.StartsWithSegments( + "/api", + StringComparison.OrdinalIgnoreCase + ) + ) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return Task.CompletedTask; + } + context.Response.Redirect(context.RedirectUri); + return Task.CompletedTask; + }; + options.Events.OnRedirectToAccessDenied = context => + { + if ( + context.Request.Path.StartsWithSegments( + "/api", + StringComparison.OrdinalIgnoreCase + ) + ) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return Task.CompletedTask; + } + context.Response.Redirect(context.RedirectUri); + return Task.CompletedTask; + }; }); // Bridge UsersModuleOptions into ASP.NET Identity options diff --git a/packages/SimpleModule.Client/src/resolve-page.ts b/packages/SimpleModule.Client/src/resolve-page.ts index a3c48523..c6d5862d 100644 --- a/packages/SimpleModule.Client/src/resolve-page.ts +++ b/packages/SimpleModule.Client/src/resolve-page.ts @@ -1,14 +1,37 @@ export async function resolvePage(name: string) { const moduleName = name.split('/')[0]; - const assemblyName = `SimpleModule.${moduleName}`; const cacheBuster = (document.querySelector('meta[name="cache-buster"]') as HTMLMetaElement) ?.content; const suffix = cacheBuster ? `?v=${cacheBuster}` : ''; - const mod = await import( - /* @vite-ignore */ - `/_content/${assemblyName}/${assemblyName}.pages.js${suffix}` - ); + // Downstream apps frequently ship modules under a bare assembly name + // (e.g. "Customers", "Invoices") rather than the framework's "SimpleModule.X" + // convention. Try the bare name first, then fall back to the prefixed form + // so framework modules continue to resolve. + const candidates = [moduleName, `SimpleModule.${moduleName}`]; + // biome-ignore lint/suspicious/noExplicitAny: matches existing dynamic-import shape + let mod: any; + let assemblyName = candidates[0]; + let lastError: unknown; + for (const candidate of candidates) { + try { + mod = await import( + /* @vite-ignore */ + `/_content/${candidate}/${candidate}.pages.js${suffix}` + ); + assemblyName = candidate; + break; + } catch (err) { + lastError = err; + } + } + + if (!mod) { + throw new Error( + `Could not load pages bundle for module "${moduleName}". ` + + `Tried ${candidates.join(', ')}. Last error: ${String(lastError)}`, + ); + } if (!mod.pages) { throw new Error( diff --git a/packages/SimpleModule.Client/src/use-translation.ts b/packages/SimpleModule.Client/src/use-translation.ts index a5d8917c..dec6dc13 100644 --- a/packages/SimpleModule.Client/src/use-translation.ts +++ b/packages/SimpleModule.Client/src/use-translation.ts @@ -27,11 +27,30 @@ interface SharedProps { *

{t(ProductsKeys.Manage.DeleteConfirm, { name: product.name })}

* ``` */ +const warnedNamespaces = new Set(); + export function useTranslation(namespace: string): TranslationResult { const { props } = usePage(); const locale = props.locale ?? 'en'; const translations = props.translations ?? {}; + // If translations were never populated, the SimpleModule.Localization module + // is probably not installed (its middleware is what writes props.translations). + // Without it, every t(...) call falls back to the bare key and the UI renders + // raw identifiers like "Users.Title". Warn loudly in dev once per namespace. + if ( + process.env.NODE_ENV !== 'production' && + props.translations === undefined && + !warnedNamespaces.has(namespace) + ) { + warnedNamespaces.add(namespace); + console.warn( + `[useTranslation] Module "${namespace}" is asking for translations but ` + + 'Inertia shared data has no "translations" key. Is SimpleModule.Localization installed? ' + + 'Without it, translation keys will render as-is.', + ); + } + const t = useMemo(() => { const prefix = `${namespace}.`; diff --git a/tests/e2e/tests/smoke/filestorage.spec.ts b/tests/e2e/tests/smoke/filestorage.spec.ts index 22463bc6..ae5b50f1 100644 --- a/tests/e2e/tests/smoke/filestorage.spec.ts +++ b/tests/e2e/tests/smoke/filestorage.spec.ts @@ -43,11 +43,11 @@ test.describe('FileStorage smoke', () => { expect(response?.url()).toContain('/Account/Login'); }); - test('API endpoint returns 302', async ({ request }) => { + test('API endpoint returns 401', async ({ request }) => { const response = await request.get('/api/files', { maxRedirects: 0, }); - expect(response.status()).toBe(302); + expect(response.status()).toBe(401); }); }); });