diff --git a/docs/prs/PR-i18n-localised-server-redirects-report.md b/docs/prs/PR-i18n-localised-server-redirects-report.md
new file mode 100644
index 0000000..6698f56
--- /dev/null
+++ b/docs/prs/PR-i18n-localised-server-redirects-report.md
@@ -0,0 +1,138 @@
+# PR — les redirections serveur portent la langue de l'utilisateur
+
+**Date** : 25 juillet 2026
+**Auteur** : @cc-ankora
+**Branche** : `fix/i18n-localised-server-redirects`
+**Revue de plan** : `plan-reviewer` — 🟡 APPROVED WITH CHANGES (4 corrections, toutes intégrées)
+**Ferme** : le P0 laissé ouvert par #258, arbitré par @thierry le 25/07
+
+---
+
+## 1. La régression fermée
+
+`localePrefix: 'as-needed'` met le français sur les URLs **non préfixées** : le préfixe du
+français est `/fr-BE`, jamais la chaîne vide. Un chemin comme `/login` ne matche donc aucun
+préfixe de locale.
+
+Jusqu'à #258, next-intl rattrapait le coup : la branche cookie de `localeDetection` lisait
+`NEXT_LOCALE` et émettait un 307 vers `/en/login`. #258 a désactivé cette détection — c'était
+la même garde qui laissait un prefetch d'arrière-plan réécrire silencieusement la langue de
+l'utilisateur. Le filet a disparu avec elle, et chaque `redirect()` nu renvoyait dès lors un
+utilisateur anglophone sur une page française.
+
+Concerné : session expirée, connexion, déconnexion, fin d'onboarding, refus d'accès admin.
+
+## 2. Le correctif
+
+Le helper que j'avais prévu d'écrire **existait déjà** — `plan-reviewer` l'a relevé, et c'est
+strictement meilleur que ma proposition. `src/i18n/navigation.ts` expose un `redirect` issu de
+`createNavigation(routing)` :
+
+```ts
+redirect({ href: '/login', locale: await getLocale() });
+```
+
+Il retourne `never`, lève immédiatement comme celui de `next/navigation`, et **son type impose
+la locale**. Aucun module à écrire, et le risque décrit ci-dessous disparaît par construction.
+
+### Le piège que ce choix évite
+
+J'avais envisagé un helper `async` du type `await redirectLocalised('/login')`. Vérification
+faite : `eslint.config.mjs` n'active pas `@typescript-eslint/no-floating-promises` (pas de lint
+typé — ni `project` ni `projectService`). Un `await` oublié sur une garde d'authentification
+n'aurait donc levé **ni erreur TypeScript ni erreur de lint** : `redirect` ne lèverait plus,
+l'exécution continuerait, et l'utilisateur passerait **non authentifié**. Le `redirect`
+synchrone de next-intl rend cette classe d'erreur impossible.
+
+### Terminaison explicite
+
+TypeScript ne propage pas le narrowing `never` depuis un identifiant issu d'un destructuring
+(`export const { redirect } = createNavigation(...)`), contrairement au `redirect` de
+`next/navigation`. Le code après l'appel était donc considéré atteignable, ce qui a fait
+remonter 8 erreurs `'x' is possibly null`. Corrigé par un `return redirect(...)` explicite à
+chaque site : la terminaison est visible à la lecture, et TypeScript la comprend.
+
+## 3. Sites modifiés (14 redirections, 8 fichiers)
+
+| Fichier | Cibles localisées |
+| -------------------------------------------------------- | ---------------------------------------------------------------------- |
+| `src/lib/auth/require-user.ts` | `/login`, `/onboarding` |
+| `src/lib/auth/require-admin.ts` | `/app` |
+| `src/lib/data/workspace-snapshot.ts` | `/login`, `/onboarding` (×3) |
+| `src/lib/actions/auth.ts` | `/signup/check-email`, `/app`\|`/onboarding`, `/`, `/login?reset=done` |
+| `src/lib/actions/onboarding.ts` | `/app` |
+| `src/app/[locale]/onboarding/page.tsx` | `/app` |
+| `src/app/[locale]/app/settings/deletion-status/page.tsx` | `/app/settings` |
+
+### Ce qui n'est délibérément PAS localisé
+
+- **`src/lib/actions/auth.ts` — l'URL OAuth de Google.** Elle est absolue et externe ; la
+ passer au `redirect` locale-aware la préfixerait et la transformerait en chemin
+ same-origin cassé. Elle garde le `redirect` de `next/navigation`, importé sous l'alias
+ explicite `redirectToExternalUrl` — la distinction est porteuse de sens, pas cosmétique.
+- **`src/app/auth/callback/route.ts`** garde sa résolution par cookie. Cette route est exclue
+ du matcher du proxy, donc le middleware n'y tourne pas et l'en-tête `X-NEXT-INTL-LOCALE`
+ dont dépend `getLocale()` est absent : il retomberait sur `resolveLocaleFromUserOrCookie()`,
+ qui coûte un `auth.getUser()` **plus** un select `users` sur le chemin chaud de l'OAuth, pour
+ la valeur que le cookie porte déjà. Commentaire ajouté pour que personne ne « simplifie ».
+- **`src/proxy.ts` et `src/i18n/routing.ts`** ne sont pas touchés. Un 307 basé sur la lecture
+ du cookie dans le proxy serait l'alternative tentante en une ligne ; elle est interdite par
+ la note de `routing.ts` (« trois correctifs middleware construits et mesurés — do not retry
+ that layer »). Localiser aux call-sites est la bonne couche.
+- **`src/app/not-found.tsx`** lit `NEXT_LOCALE` mais ne contient aucun `redirect()` — hors
+ périmètre, vérifié.
+
+### Paramètre mort supprimé
+
+`requireUser(redirectTo = '/login')` : les 6 appelants du repo ne passent **aucun** argument.
+Localiser un chemin fourni par un appelant inexistant reviendrait à inventer une surface
+d'open redirect. Le paramètre est supprimé plutôt que validé.
+
+### Le middleware n'est pas en cause
+
+`updateSession` (`src/lib/supabase/middleware.ts`) n'émet aucune redirection — il ne fait que
+rafraîchir les cookies de session. Le `307 → /login` mesuré provient donc bien d'un
+`redirect()` RSC, et le relevé `grep -rn "redirect("` couvre tout le rayon d'explosion.
+
+## 4. Preuve
+
+Mesure A/B sur le comportement réel, build de production local contre la production actuelle :
+
+| Requête | Production (avant) | Cette branche |
+| ---------------------------- | -------------------------------- | -------------------------- |
+| `/en/app` — visiteur anglais | `307 → /login` ❌ page française | `307 → /en/login` ✅ |
+| `/app` — visiteur français | `307 → /login` | `307 → /login` ✅ inchangé |
+
+### Tests
+
+`src/lib/auth/__tests__/require-user-redirects.test.ts` — **nouveau**. Ces branches avaient
+**zéro couverture** : la suite voisine `require-user.test.ts` n'exerce que `getOptionalUser`,
+n'importe jamais `requireUser` et ne mocke pas `next/navigation`.
+
+Le mock `redirect` **lève**, sur le modèle de `require-admin.test.ts`. Un mock inerte laisserait
+`requireUser` continuer et retourner `undefined as User` : le test resterait vert au-dessus
+d'une garde qui ne garde plus. Un test dédié verrouille précisément ça.
+
+Deux suites existantes ont dû être alignées : elles mockaient `next/navigation`, alors que les
+modules importent désormais `@/i18n/navigation`. Mocker le barrel garde aussi le build ESM de
+next-intl hors du graphe Vitest, où il échoue à résoudre `next/navigation`.
+
+**Angle mort assumé** : les specs Playwright authentifiées sont skippées en CI (secrets `E2E_*`
+absents — un seul projet Supabase, la clé `service_role` ne doit pas atteindre la CI). Ces
+tests unitaires sont donc le **seul** filet automatisé pour cette classe de régression, pas un
+complément à une couverture E2E.
+
+### Quality gates
+
+`npm run typecheck` 0 erreur · `npm run lint` 0 erreur · `npm run lint:use-server` OK ·
+`npm run test` **1652 / 1652**
+
+## 5. Definition of DONE
+
+| # | Critère | Preuve |
+| --- | ----------------------------------- | -------------------------------------- |
+| 1 | CI verte | cf. checks de la PR |
+| 2 | Sourcery muet sur le dernier commit | `gh api …/comments` → sortie vide |
+| 3 | Threads de review résolus | GraphQL `reviewThreads` → 0 non résolu |
+| 4 | Pas de conflit avec `main` | `mergeStateStatus: CLEAN` |
+| 5 | Rapport livré | ce fichier |
diff --git a/src/app/[locale]/app/settings/deletion-status/page.tsx b/src/app/[locale]/app/settings/deletion-status/page.tsx
index 218c136..61463ec 100644
--- a/src/app/[locale]/app/settings/deletion-status/page.tsx
+++ b/src/app/[locale]/app/settings/deletion-status/page.tsx
@@ -1,5 +1,5 @@
import type { Metadata } from 'next';
-import { redirect } from 'next/navigation';
+import { redirect } from '@/i18n/navigation';
import { getLocale, getTranslations } from 'next-intl/server';
import { Link } from '@/i18n/navigation';
@@ -31,7 +31,7 @@ export default async function DeletionStatusPage() {
.limit(1)
.maybeSingle();
- if (!data) redirect('/app/settings');
+ if (!data) return redirect({ href: '/app/settings', locale: await getLocale() });
const scheduled = new Date(data.scheduled_for);
const now = new Date();
diff --git a/src/app/[locale]/onboarding/page.tsx b/src/app/[locale]/onboarding/page.tsx
index 1052a6e..6aaa950 100644
--- a/src/app/[locale]/onboarding/page.tsx
+++ b/src/app/[locale]/onboarding/page.tsx
@@ -1,6 +1,8 @@
import type { Metadata } from 'next';
-import { redirect } from 'next/navigation';
+import { getLocale } from 'next-intl/server';
+
+import { redirect } from '@/i18n/navigation';
import { requireUser } from '@/lib/auth/require-user';
import { createClient } from '@/lib/supabase/server';
@@ -17,7 +19,7 @@ export default async function OnboardingPage() {
.eq('id', user.id)
.maybeSingle();
- if (profile?.onboarded_at) redirect('/app');
+ if (profile?.onboarded_at) return redirect({ href: '/app', locale: await getLocale() });
return (
diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts
index 17dd064..3055486 100644
--- a/src/app/auth/callback/route.ts
+++ b/src/app/auth/callback/route.ts
@@ -19,6 +19,15 @@ import { routing, type Locale } from '@/i18n/routing';
* Falls back to `routing.defaultLocale` (fr-BE) if the cookie is missing or
* carries an unknown value. The TS type guard rules out a spoofed cookie.
*/
+/**
+ * Reads the cookie DELIBERATELY, and must keep doing so — do not "simplify"
+ * this to `getLocale()` from `next-intl/server`. This route is excluded from
+ * the proxy matcher (`src/proxy.ts`), so the middleware never runs here and the
+ * `X-NEXT-INTL-LOCALE` header `getLocale()` relies on is absent. It would fall
+ * back to `resolveLocaleFromUserOrCookie()` in `src/i18n/request.ts`, which
+ * costs an extra `auth.getUser()` plus a `users` select on the OAuth hot path,
+ * for the same value the cookie already carries.
+ */
async function resolveLocale(): Promise
{
const cookieStore = await cookies();
const raw = cookieStore.get('NEXT_LOCALE')?.value;
diff --git a/src/lib/actions/auth.ts b/src/lib/actions/auth.ts
index 9231f5e..e6cd533 100644
--- a/src/lib/actions/auth.ts
+++ b/src/lib/actions/auth.ts
@@ -1,7 +1,10 @@
'use server';
import { headers } from 'next/headers';
-import { redirect } from 'next/navigation';
+import { redirect as redirectToExternalUrl } from 'next/navigation';
+import { getLocale } from 'next-intl/server';
+
+import { redirect } from '@/i18n/navigation';
import { env } from '@/lib/env';
import { createClient } from '@/lib/supabase/server';
@@ -73,7 +76,7 @@ export async function signupAction(formData: FormData): Promise {
userAgent,
});
- redirect('/signup/check-email');
+ return redirect({ href: '/signup/check-email', locale: await getLocale() });
}
export async function signInWithGoogleAction(): Promise {
@@ -103,7 +106,11 @@ export async function signInWithGoogleAction(): Promise {
return { ok: false, errorCode: 'errors.auth.googleFailed' };
}
- redirect(data.url);
+ // EXTERNAL URL — Google's consent screen. It must never go through the
+ // locale-aware `redirect`: that one prefixes its `href`, which would turn an
+ // absolute third-party URL into a broken same-origin path. Hence the explicit
+ // alias — the distinction is load-bearing, not stylistic.
+ redirectToExternalUrl(data.url);
}
export async function loginAction(formData: FormData): Promise {
@@ -151,7 +158,10 @@ export async function loginAction(formData: FormData): Promise {
.eq('id', data.user.id)
.maybeSingle();
- redirect(profile?.onboarded_at ? '/app' : '/onboarding');
+ return redirect({
+ href: profile?.onboarded_at ? '/app' : '/onboarding',
+ locale: await getLocale(),
+ });
}
export async function logoutAction(): Promise {
@@ -167,7 +177,12 @@ export async function logoutAction(): Promise {
await logAuditEvent(AuditEvent.AUTH_LOGOUT, { userId: user.id, ipAddress: ip, userAgent });
}
- redirect('/');
+ // Prefixed on purpose. The @thierry arbitration of 2026-07-25 ("`/` always
+ // renders French") is about Accept-Language detection on an unprefixed URL,
+ // not about an explicit redirect from a context where the locale is known.
+ // Dropping an English user on the French landing right after logout is the
+ // very bug this PR closes.
+ return redirect({ href: '/', locale: await getLocale() });
}
export async function requestPasswordResetAction(formData: FormData): Promise {
@@ -240,5 +255,5 @@ export async function confirmPasswordResetAction(formData: FormData): Promise {
+const redirectMock = vi.fn((_args?: unknown): never => {
throw new Error('NEXT_REDIRECT');
});
const notFoundMock = vi.fn(() => {
@@ -17,10 +17,21 @@ vi.mock('next/headers', () => ({
}));
vi.mock('next/navigation', () => ({
- redirect: (path: string) => redirectMock(path),
notFound: () => notFoundMock(),
}));
+// `redirect` now comes from the locale-aware barrel, so the guard can send an
+// English admin to `/en/app` instead of the French cockpit. Mocking the barrel
+// (rather than `next/navigation`) also keeps next-intl's ESM build out of the
+// Vitest module graph — it fails to resolve `next/navigation` there.
+vi.mock('@/i18n/navigation', () => ({
+ redirect: (args: unknown) => redirectMock(args),
+}));
+
+vi.mock('next-intl/server', () => ({
+ getLocale: async () => 'fr-BE',
+}));
+
vi.mock('@/lib/auth/require-user', () => ({
requireUser: async () => requireUserMock(),
}));
@@ -103,7 +114,7 @@ describe('requireAdmin() — rate-limited + audit-logged guard', () => {
await expect(requireAdmin()).rejects.toThrow('NEXT_REDIRECT');
- expect(redirectMock).toHaveBeenCalledWith('/app');
+ expect(redirectMock).toHaveBeenCalledWith({ href: '/app', locale: 'fr-BE' });
// Per security-auditor P1-B + gdpr P1, attempted_user_id is no longer
// duplicated into metadata — canonical `userId` column carries it.
expect(logAuditEventMock).toHaveBeenCalledWith(
diff --git a/src/lib/auth/__tests__/require-user-redirects.test.ts b/src/lib/auth/__tests__/require-user-redirects.test.ts
new file mode 100644
index 0000000..fcf13a1
--- /dev/null
+++ b/src/lib/auth/__tests__/require-user-redirects.test.ts
@@ -0,0 +1,159 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+/**
+ * Locale-aware server redirects — `requireUser` / `requireUserWithWorkspace`.
+ *
+ * Why this file exists. `localePrefix: 'as-needed'` puts French on unprefixed
+ * URLs, so `/login` IS the French login page. Until #258, a bare
+ * `redirect('/login')` was rescued downstream: next-intl's `localeDetection`
+ * read the NEXT_LOCALE cookie and 307'd the request to `/en/login`. #258 turned
+ * that detection off (it was the same gate that let a background prefetch
+ * silently rewrite the user's language), so the rescue is gone and every bare
+ * redirect now lands an English user on a French page.
+ *
+ * These branches had ZERO coverage before: the neighbouring
+ * `require-user.test.ts` exercises `getOptionalUser` only — it never imports
+ * `requireUser`, and never mocks `next/navigation`.
+ *
+ * The `redirect` mock THROWS on purpose, mirroring `require-admin.test.ts`. A
+ * no-op mock would let `requireUser` fall through and return `undefined as
+ * User`, so the test would stay green over a guard that no longer guards —
+ * the exact failure mode this suite is meant to catch.
+ *
+ * Note on coverage honesty: the authenticated Playwright specs are skipped in
+ * CI (no `E2E_*` secrets — one Supabase project, service_role key must not
+ * reach CI). These unit tests are therefore the only automated net for this
+ * regression class, not a complement to an E2E one.
+ */
+
+const redirectMock = vi.hoisted(() =>
+ vi.fn((_args?: unknown): never => {
+ throw new Error('NEXT_REDIRECT');
+ }),
+);
+const getLocaleMock = vi.hoisted(() => vi.fn(async () => 'fr-BE'));
+const getUserMock = vi.hoisted(() => vi.fn());
+const fromMock = vi.hoisted(() => vi.fn());
+
+vi.mock('@/i18n/navigation', () => ({
+ redirect: (args: unknown) => redirectMock(args),
+}));
+
+vi.mock('next-intl/server', () => ({
+ getLocale: () => getLocaleMock(),
+}));
+
+vi.mock('@/lib/supabase/server', () => ({
+ createClient: async () => ({
+ auth: { getUser: () => getUserMock() },
+ from: (table: string) => fromMock(table),
+ }),
+}));
+
+vi.mock('@/lib/log', () => ({
+ log: {
+ trace: vi.fn(),
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ fatal: vi.fn(),
+ child: vi.fn(),
+ },
+}));
+
+import { requireUser, requireUserWithWorkspace } from '../require-user';
+
+const fakeUser = {
+ id: 'user-123',
+ email: 'thierry@example.test',
+ app_metadata: {},
+ user_metadata: {},
+ aud: 'authenticated',
+ created_at: '2026-01-01T00:00:00.000Z',
+};
+
+/** Minimal chainable stub for `.from(...).select(...)…maybeSingle()`. */
+function membershipQuery(result: { data: unknown; error: unknown }) {
+ const chain = {
+ select: () => chain,
+ eq: () => chain,
+ order: () => chain,
+ limit: () => chain,
+ maybeSingle: async () => result,
+ };
+ return chain;
+}
+
+beforeEach(() => {
+ redirectMock.mockClear();
+ getLocaleMock.mockClear();
+ getLocaleMock.mockResolvedValue('fr-BE');
+ getUserMock.mockReset();
+ fromMock.mockReset();
+});
+
+describe('requireUser — the login redirect carries the locale', () => {
+ it('sends an English visitor to the English login page', async () => {
+ getLocaleMock.mockResolvedValue('en');
+ getUserMock.mockResolvedValue({ data: { user: null }, error: null });
+
+ await expect(requireUser()).rejects.toThrow('NEXT_REDIRECT');
+ expect(redirectMock).toHaveBeenCalledWith({ href: '/login', locale: 'en' });
+ });
+
+ it('sends a French visitor to the French login page', async () => {
+ getUserMock.mockResolvedValue({ data: { user: null }, error: null });
+
+ await expect(requireUser()).rejects.toThrow('NEXT_REDIRECT');
+ expect(redirectMock).toHaveBeenCalledWith({ href: '/login', locale: 'fr-BE' });
+ });
+
+ it('does not redirect when a session exists', async () => {
+ getUserMock.mockResolvedValue({ data: { user: fakeUser }, error: null });
+
+ await expect(requireUser()).resolves.toMatchObject({ id: 'user-123' });
+ expect(redirectMock).not.toHaveBeenCalled();
+ });
+});
+
+describe('requireUserWithWorkspace — the onboarding redirect carries the locale', () => {
+ it('sends an English visitor without a workspace to the English onboarding', async () => {
+ getLocaleMock.mockResolvedValue('en');
+ getUserMock.mockResolvedValue({ data: { user: fakeUser }, error: null });
+ fromMock.mockReturnValue(membershipQuery({ data: null, error: null }));
+
+ await expect(requireUserWithWorkspace()).rejects.toThrow('NEXT_REDIRECT');
+ expect(redirectMock).toHaveBeenCalledWith({ href: '/onboarding', locale: 'en' });
+ });
+
+ it('returns the membership when there is one, without redirecting', async () => {
+ getUserMock.mockResolvedValue({ data: { user: fakeUser }, error: null });
+ fromMock.mockReturnValue(
+ membershipQuery({ data: { workspace_id: 'ws-1', role: 'owner' }, error: null }),
+ );
+
+ await expect(requireUserWithWorkspace()).resolves.toMatchObject({
+ workspaceId: 'ws-1',
+ role: 'owner',
+ });
+ expect(redirectMock).not.toHaveBeenCalled();
+ });
+});
+
+describe('the guard cannot be silently defeated', () => {
+ it('never returns a user when the session is missing', async () => {
+ // Guards against the failure mode a non-throwing redirect mock would hide:
+ // if `redirect` stopped terminating, `requireUser` would fall through and
+ // hand back `undefined` typed as `User`, and every caller would treat the
+ // visitor as authenticated.
+ getUserMock.mockResolvedValue({ data: { user: null }, error: null });
+
+ const outcome = await requireUser().then(
+ (value) => ({ returned: value }),
+ (error: Error) => ({ threw: error.message }),
+ );
+
+ expect(outcome).toEqual({ threw: 'NEXT_REDIRECT' });
+ });
+});
diff --git a/src/lib/auth/__tests__/require-user.test.ts b/src/lib/auth/__tests__/require-user.test.ts
index 0e12f49..6a977cd 100644
--- a/src/lib/auth/__tests__/require-user.test.ts
+++ b/src/lib/auth/__tests__/require-user.test.ts
@@ -10,6 +10,20 @@ const createClientMock = vi.fn(async () => ({
const logWarnMock = vi.hoisted(() => vi.fn());
const logErrorMock = vi.hoisted(() => vi.fn());
+// This suite covers `getOptionalUser` only, but the module under test now
+// imports the locale-aware `redirect`. Stub the barrel so next-intl's ESM build
+// stays out of the Vitest module graph (it fails to resolve `next/navigation`
+// there). The redirecting branches are covered in `require-user-redirects.test.ts`.
+vi.mock('@/i18n/navigation', () => ({
+ redirect: () => {
+ throw new Error('NEXT_REDIRECT');
+ },
+}));
+
+vi.mock('next-intl/server', () => ({
+ getLocale: async () => 'fr-BE',
+}));
+
vi.mock('@/lib/supabase/server', () => ({
createClient: () => createClientMock(),
}));
diff --git a/src/lib/auth/require-admin.ts b/src/lib/auth/require-admin.ts
index 12d7a1a..d2f0f46 100644
--- a/src/lib/auth/require-admin.ts
+++ b/src/lib/auth/require-admin.ts
@@ -1,5 +1,8 @@
import { headers } from 'next/headers';
-import { notFound, redirect } from 'next/navigation';
+import { notFound } from 'next/navigation';
+import { getLocale } from 'next-intl/server';
+
+import { redirect } from '@/i18n/navigation';
import type { User } from '@supabase/supabase-js';
import { env } from '@/lib/env';
@@ -75,7 +78,7 @@ export async function requireAdmin(): Promise {
{ userId: user.id, ipAddress, userAgent },
{ path },
);
- redirect('/app');
+ return redirect({ href: '/app', locale: await getLocale() });
}
// 4. Granted
diff --git a/src/lib/auth/require-user.ts b/src/lib/auth/require-user.ts
index 648d20b..491a8b4 100644
--- a/src/lib/auth/require-user.ts
+++ b/src/lib/auth/require-user.ts
@@ -1,6 +1,7 @@
-import { redirect } from 'next/navigation';
import type { User } from '@supabase/supabase-js';
+import { getLocale } from 'next-intl/server';
+import { redirect } from '@/i18n/navigation';
import { createClient } from '@/lib/supabase/server';
import { log } from '@/lib/log';
@@ -73,12 +74,19 @@ export async function getOptionalUser(): Promise {
* Delegates the session lookup to `getOptionalUser` so the Supabase wiring
* (createClient + getUser + error handling) lives in a single place.
*/
-export async function requireUser(redirectTo = '/login'): Promise {
+export async function requireUser(): Promise {
const user = await getOptionalUser();
if (!user) {
- log.warn('[503-diag] require-user requireUser redirect', { redirectTo });
- redirect(redirectTo);
+ log.warn('[503-diag] require-user requireUser redirect', { redirectTo: '/login' });
+ // Locale-aware on purpose: `localePrefix: 'as-needed'` means French lives
+ // on unprefixed URLs, so a bare `redirect('/login')` sends an English user
+ // to the French login page. next-intl stopped covering for this when
+ // `localeDetection` was turned off (#258) — the cookie branch that used to
+ // 307 `/login` to `/en/login` is gone. This `redirect` returns `never` and
+ // throws synchronously like the one from `next/navigation`, and its type
+ // forces the locale to be passed.
+ return redirect({ href: '/login', locale: await getLocale() });
}
return user;
@@ -121,7 +129,7 @@ export async function requireUserWithWorkspace(): Promise<{
userId: user.id,
hadError: !!error,
});
- redirect('/onboarding');
+ return redirect({ href: '/onboarding', locale: await getLocale() });
}
return {
diff --git a/src/lib/data/workspace-snapshot.ts b/src/lib/data/workspace-snapshot.ts
index 7c25e27..00551f4 100644
--- a/src/lib/data/workspace-snapshot.ts
+++ b/src/lib/data/workspace-snapshot.ts
@@ -1,4 +1,6 @@
-import { redirect } from 'next/navigation';
+import { getLocale } from 'next-intl/server';
+
+import { redirect } from '@/i18n/navigation';
import { createClient } from '@/lib/supabase/server';
import { log } from '@/lib/log';
@@ -150,14 +152,14 @@ export async function getWorkspaceSnapshot(): Promise {
const {
data: { user },
} = await supabase.auth.getUser();
- if (!user) redirect('/login');
+ if (!user) return redirect({ href: '/login', locale: await getLocale() });
const { data: profile } = await supabase
.from('users')
.select('onboarded_at')
.eq('id', user.id)
.maybeSingle();
- if (!profile?.onboarded_at) redirect('/onboarding');
+ if (!profile?.onboarded_at) return redirect({ href: '/onboarding', locale: await getLocale() });
const { data: membership } = await supabase
.from('workspace_members')
@@ -166,7 +168,7 @@ export async function getWorkspaceSnapshot(): Promise {
.order('joined_at', { ascending: true })
.limit(1)
.maybeSingle();
- if (!membership) redirect('/onboarding');
+ if (!membership) return redirect({ href: '/onboarding', locale: await getLocale() });
const workspaceId = membership.workspace_id;
@@ -245,7 +247,8 @@ export async function getWorkspaceSnapshot(): Promise {
.eq('period_month', previousMonth),
]);
- if (wsRes.error || !wsRes.data) redirect('/onboarding');
+ if (wsRes.error || !wsRes.data)
+ return redirect({ href: '/onboarding', locale: await getLocale() });
// Never swallow a charges read failure silently again (incident 2026-07-18:
// the `?? []` fallback made a failing SELECT look like an empty workspace).