(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);
});
});
});