fix: show a translated 404 to a signed-in user who mistypes a URL instead of the login page

This commit is contained in:
Linus Rath
2026-10-28 03:52:40 +01:00
parent 6994dc2ce6
commit 9ee767f576
35 changed files with 269 additions and 38 deletions
+8
View File
@@ -0,0 +1,8 @@
import { notFound } from 'next/navigation';
// Unknown paths below a locale (/de/typo) end here, so the 404 renders
// inside the locale layout and is translated ([locale]/not-found.tsx).
// Removed from the static Lite export, like (main)/[...rest].
export default function LocaleCatchAll() {
notFound();
}
+10
View File
@@ -0,0 +1,10 @@
"use client";
import { useTranslations } from "next-intl";
import { NotFoundView } from "@/components/error/not-found-view";
export default function LocaleNotFound() {
const t = useTranslations("not_found");
const tErrors = useTranslations("errors");
return <NotFoundView title={t("title")} description={t("description")} homeLabel={tErrors("go_home")} />;
}
+9 -38
View File
@@ -1,14 +1,14 @@
"use client";
import { useEffect } from "react";
import { useAuthStore } from "@/stores/auth-store";
import { getPathPrefix } from "@/lib/browser-navigation";
import { NotFoundView } from "@/components/error/not-found-view";
import { IS_LITE } from "@/lib/lite";
import { liteSurfaceRootFor, stashPendingLitePath } from "@/lib/lite-link-segments";
// Outside the locale tree there are no translations: paths below a locale
// get the translated page in [locale]/not-found.tsx.
export default function NotFound() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
useEffect(() => {
// Static Lite build on a host without rewrite rules (GitHub Pages): a deep
// link such as /en/mail/thread/abc lands here as 404.html. Park the link
@@ -21,42 +21,13 @@ export default function NotFound() {
return;
}
}
if (!isAuthenticated) {
const prefix = getPathPrefix();
// Don't redirect admin routes to the webmail login page. Admin paths
// are mounted relative to the deployment prefix, so account for it.
const adminBase = `${prefix}/admin`;
const isAdminRoute = window.location.pathname === adminBase || window.location.pathname.startsWith(`${adminBase}/`);
if (!isAdminRoute) {
window.location.href = `${prefix}/login`;
}
}
}, [isAuthenticated]);
if (!isAuthenticated) {
let isAdmin = false;
if (typeof window !== 'undefined') {
const prefix = getPathPrefix();
const adminBase = `${prefix}/admin`;
isAdmin = window.location.pathname === adminBase || window.location.pathname.startsWith(`${adminBase}/`);
}
if (!isAdmin) return null;
}
const prefix = typeof window !== 'undefined' ? getPathPrefix() : '';
}, []);
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center max-w-md px-4">
<h1 className="text-4xl font-bold text-foreground mb-2">404</h1>
<p className="text-muted-foreground mb-6">This page could not be found.</p>
<a
href={`${prefix}/`}
className="inline-flex items-center px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity"
>
Go home
</a>
</div>
</div>
<NotFoundView
title="Page not found"
description="This page doesn't exist or has moved. Check the address, or go back to your inbox."
homeLabel="Go to inbox"
/>
);
}
@@ -0,0 +1,54 @@
import { act, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// A mistyped URL with a still valid session: the 404 waits for the session
// to be restored instead of sending the user to the login page.
const { default: LocaleNotFound } = await import('@/app/(main)/[locale]/not-found');
const { useAuthStore } = await import('@/stores/auth-store');
const realLocation = window.location;
let href = '';
beforeEach(() => {
href = 'http://localhost/de/gibtsnicht';
Object.defineProperty(window, 'location', {
configurable: true,
value: {
...realLocation,
pathname: '/de/gibtsnicht',
get href() { return href; },
set href(v: string) { href = v; },
},
});
});
afterEach(() => {
Object.defineProperty(window, 'location', { configurable: true, value: realLocation });
});
describe('404 page', () => {
it('restores the session before deciding, and shows the translated page to a signed-in user', async () => {
let finish: () => void = () => {};
const checkAuth = vi.fn(() => new Promise<void>((resolve) => {
finish = () => { useAuthStore.setState({ isAuthenticated: true } as never); resolve(); };
}));
useAuthStore.setState({ isAuthenticated: false, checkAuth } as never);
render(<LocaleNotFound />);
expect(checkAuth).toHaveBeenCalled();
expect(href).not.toContain('/login');
await act(async () => { finish(); });
expect(href).not.toContain('/login');
expect(screen.getByRole('heading', { name: 'title' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'go_home' })).toBeInTheDocument();
});
it('sends a visitor without a session to the login page once that is known', async () => {
const checkAuth = vi.fn(() => Promise.resolve());
useAuthStore.setState({ isAuthenticated: false, checkAuth } as never);
render(<LocaleNotFound />);
await act(async () => {});
expect(href).toBe('/login');
});
});
+71
View File
@@ -0,0 +1,71 @@
"use client";
import { useEffect, useState } from "react";
import { SearchX } from "@/components/icons";
import { useAuthStore } from "@/stores/auth-store";
import { getPathPrefix } from "@/lib/browser-navigation";
/** Whether the current URL is an admin page, which has its own sign-in. */
function isAdminPath(): boolean {
if (typeof window === "undefined") return false;
const adminBase = `${getPathPrefix()}/admin`;
const path = window.location.pathname;
return path === adminBase || path.startsWith(`${adminBase}/`);
}
/**
* Who is looking at a missing page: "checking" until the stored session has
* been restored, then signed in or not. Deciding before that sent a signed-in
* user with a mistyped URL to the login page.
*/
export function useNotFoundAuth(): "checking" | "signed-in" | "signed-out" {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const [checked, setChecked] = useState(() => useAuthStore.getState().isAuthenticated);
useEffect(() => {
if (useAuthStore.getState().isAuthenticated) {
setChecked(true);
return;
}
let cancelled = false;
void useAuthStore.getState().checkAuth().finally(() => { if (!cancelled) setChecked(true); });
return () => { cancelled = true; };
}, []);
useEffect(() => {
if (!checked || isAuthenticated || isAdminPath()) return;
window.location.href = `${getPathPrefix()}/login`;
}, [checked, isAuthenticated]);
if (isAuthenticated) return "signed-in";
return checked ? "signed-out" : "checking";
}
export interface NotFoundViewProps {
title: string;
description: string;
homeLabel: string;
}
/** The 404 page: what happened and the way back. Signed out, it shows only on admin paths. */
export function NotFoundView({ title, description, homeLabel }: NotFoundViewProps) {
const auth = useNotFoundAuth();
if (auth !== "signed-in" && !isAdminPath()) return null;
const prefix = typeof window !== "undefined" ? getPathPrefix() : "";
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center max-w-md px-4">
<SearchX className="w-12 h-12 mx-auto mb-4 text-muted-foreground" aria-hidden="true" />
<h1 className="text-xl font-semibold text-foreground mb-2">{title}</h1>
<p className="text-muted-foreground mb-6">{description}</p>
<a
href={`${prefix}/`}
className="inline-flex items-center justify-center h-9 px-3 text-sm font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
{homeLabel}
</a>
</div>
</div>
);
}
+4
View File
@@ -2625,6 +2625,10 @@
"retry": "إعادة المحاولة",
"go_home": "الانتقال إلى البريد الوارد"
},
"not_found": {
"title": "الصفحة غير موجودة",
"description": "هذه الصفحة غير موجودة أو نُقلت. تحقّق من العنوان أو عُد إلى صندوق الوارد."
},
"context_menu": {
"reply": "رد",
"reply_all": "الرد على الجميع",
+4
View File
@@ -2625,6 +2625,10 @@
"retry": "Reintenta",
"go_home": "Vés a la safata d'entrada"
},
"not_found": {
"title": "No s'ha trobat la pàgina",
"description": "Aquesta pàgina no existeix o s'ha mogut. Comproveu l'adreça o torneu a la safata d'entrada."
},
"context_menu": {
"reply": "Respon",
"reply_all": "Respon a tots",
+4
View File
@@ -2625,6 +2625,10 @@
"retry": "Opakovat",
"go_home": "Přejít do doručené pošty"
},
"not_found": {
"title": "Stránka nenalezena",
"description": "Tato stránka neexistuje nebo byla přesunuta. Zkontrolujte adresu nebo se vraťte do doručené pošty."
},
"context_menu": {
"reply": "Odpovědět",
"reply_all": "Odpovědět všem",
+4
View File
@@ -2625,6 +2625,10 @@
"retry": "Prøv igen",
"go_home": "Gå til indbakke"
},
"not_found": {
"title": "Siden blev ikke fundet",
"description": "Siden findes ikke eller er flyttet. Tjek adressen, eller gå tilbage til din indbakke."
},
"context_menu": {
"reply": "Svar",
"reply_all": "Svar alle",
+4
View File
@@ -2625,6 +2625,10 @@
"retry": "Wiederholen",
"go_home": "Zum Posteingang"
},
"not_found": {
"title": "Seite nicht gefunden",
"description": "Diese Seite gibt es nicht oder sie wurde verschoben. Prüfen Sie die Adresse oder kehren Sie zu Ihrem Posteingang zurück."
},
"context_menu": {
"reply": "Antworten",
"reply_all": "Allen antworten",
+4
View File
@@ -2625,6 +2625,10 @@
"retry": "Retry",
"go_home": "Go to inbox"
},
"not_found": {
"title": "Page not found",
"description": "This page doesn't exist or has moved. Check the address, or go back to your inbox."
},
"context_menu": {
"reply": "Reply",
"reply_all": "Reply All",
+4
View File
@@ -2625,6 +2625,10 @@
"retry": "Reintentar",
"go_home": "Ir a bandeja de entrada"
},
"not_found": {
"title": "No se ha encontrado la página",
"description": "Esta página no existe o se ha movido. Comprueba la dirección o vuelve a tu bandeja de entrada."
},
"context_menu": {
"reply": "Responder",
"reply_all": "Responder a Todos",
+4
View File
@@ -2625,6 +2625,10 @@
"retry": "Proovi uuesti",
"go_home": "Mine postkasti"
},
"not_found": {
"title": "Lehte ei leitud",
"description": "Seda lehte pole olemas või see on teisaldatud. Kontrollige aadressi või minge tagasi postkasti."
},
"context_menu": {
"reply": "Vasta",
"reply_all": "Vasta kõigile",
+4
View File
@@ -2625,6 +2625,10 @@
"retry": "تلاش مجدد",
"go_home": "رفتن به صندوق ورودی"
},
"not_found": {
"title": "صفحه پیدا نشد",
"description": "این صفحه وجود ندارد یا جابه‌جا شده است. نشانی را بررسی کنید یا به صندوق ورودی برگردید."
},
"context_menu": {
"reply": "پاسخ",
"reply_all": "پاسخ به همه",
+4
View File
@@ -2625,6 +2625,10 @@
"retry": "Réessayer",
"go_home": "Aller à la boîte de réception"
},
"not_found": {
"title": "Page introuvable",
"description": "Cette page n'existe pas ou a été déplacée. Vérifiez l'adresse ou revenez à votre boîte de réception."
},
"context_menu": {
"reply": "Répondre",
"reply_all": "Répondre à tous",
+4
View File
@@ -2591,6 +2591,10 @@
"retry": "נסה שוב",
"go_home": "עבור לתיבת הדואר הנכנס"
},
"not_found": {
"title": "הדף לא נמצא",
"description": "הדף הזה לא קיים או שהועבר. בדקו את הכתובת או חזרו לתיבת הדואר הנכנס."
},
"context_menu": {
"reply": "השב",
"reply_all": "השב לכולם",
+4
View File
@@ -2625,6 +2625,10 @@
"retry": "Újra",
"go_home": "Ugrás a beérkezőhöz"
},
"not_found": {
"title": "Az oldal nem található",
"description": "Ez az oldal nem létezik, vagy áthelyezték. Ellenőrizze a címet, vagy térjen vissza a beérkezett üzenetekhez."
},
"context_menu": {
"reply": "Válasz",
"reply_all": "Válasz mindenkinek",
+4
View File
@@ -2625,6 +2625,10 @@
"retry": "Riprova",
"go_home": "Vai alla posta in arrivo"
},
"not_found": {
"title": "Pagina non trovata",
"description": "Questa pagina non esiste o è stata spostata. Controlla l'indirizzo o torna alla posta in arrivo."
},
"context_menu": {
"reply": "Rispondi",
"reply_all": "Rispondi a tutti",
+4
View File
@@ -2625,6 +2625,10 @@
"retry": "リトライ",
"go_home": "受信トレイに移動"
},
"not_found": {
"title": "ページが見つかりません",
"description": "このページは存在しないか、移動されました。アドレスを確認するか、受信トレイに戻ってください。"
},
"context_menu": {
"reply": "返信",
"reply_all": "全員に返信",
+4
View File
@@ -2625,6 +2625,10 @@
"retry": "재시도",
"go_home": "받은편지함으로 이동"
},
"not_found": {
"title": "페이지를 찾을 수 없습니다",
"description": "이 페이지가 없거나 이동되었습니다. 주소를 확인하거나 받은편지함으로 돌아가세요."
},
"context_menu": {
"reply": "답장",
"reply_all": "전체 답장",
+4
View File
@@ -2625,6 +2625,10 @@
"retry": "Mēģināt vēlreiz",
"go_home": "Uz iesūtni"
},
"not_found": {
"title": "Lapa nav atrasta",
"description": "Šī lapa nepastāv vai ir pārvietota. Pārbaudiet adresi vai atgriezieties iesūtnē."
},
"context_menu": {
"reply": "Atbildēt",
"reply_all": "Atbildēt visiem",
+4
View File
@@ -2625,6 +2625,10 @@
"retry": "Дахин оролдох",
"go_home": "Ирсэн имэйл рүү очих"
},
"not_found": {
"title": "Хуудас олдсонгүй",
"description": "Энэ хуудас байхгүй эсвэл зөөгдсөн байна. Хаягаа шалгах эсвэл ирсэн захидал руу буцна уу."
},
"context_menu": {
"reply": "Хариулах",
"reply_all": "Бүгдэд нь хариулах",
+4
View File
@@ -2625,6 +2625,10 @@
"retry": "Prøv på nytt",
"go_home": "Gå til innboksen"
},
"not_found": {
"title": "Fant ikke siden",
"description": "Denne siden finnes ikke eller er flyttet. Sjekk adressen, eller gå tilbake til innboksen."
},
"context_menu": {
"reply": "Svar",
"reply_all": "Svar alle",
+4
View File
@@ -2625,6 +2625,10 @@
"retry": "Opnieuw proberen",
"go_home": "Ga naar postvak IN"
},
"not_found": {
"title": "Pagina niet gevonden",
"description": "Deze pagina bestaat niet of is verplaatst. Controleer het adres of ga terug naar je inbox."
},
"context_menu": {
"reply": "Beantwoorden",
"reply_all": "Allen beantwoorden",
+4
View File
@@ -2625,6 +2625,10 @@
"retry": "Ponów",
"go_home": "Przejdź do skrzynki odbiorczej"
},
"not_found": {
"title": "Nie znaleziono strony",
"description": "Ta strona nie istnieje lub została przeniesiona. Sprawdź adres albo wróć do skrzynki odbiorczej."
},
"context_menu": {
"reply": "Odpowiedz",
"reply_all": "Odpowiedz wszystkim",
+4
View File
@@ -2625,6 +2625,10 @@
"retry": "Tentar novamente",
"go_home": "Ir para caixa de entrada"
},
"not_found": {
"title": "Página não encontrada",
"description": "Esta página não existe ou foi movida. Verifique o endereço ou volte à caixa de entrada."
},
"context_menu": {
"reply": "Responder",
"reply_all": "Responder a Todos",
+4
View File
@@ -2625,6 +2625,10 @@
"retry": "Reîncearcă",
"go_home": "Accesați căsuța de intrare"
},
"not_found": {
"title": "Pagina nu a fost găsită",
"description": "Această pagină nu există sau a fost mutată. Verificați adresa sau reveniți la Inbox."
},
"context_menu": {
"reply": "Răspunde",
"reply_all": "Răspunde tuturor",
+4
View File
@@ -2625,6 +2625,10 @@
"retry": "Повторить",
"go_home": "В папку «Входящие»"
},
"not_found": {
"title": "Страница не найдена",
"description": "Этой страницы нет или она перемещена. Проверьте адрес или вернитесь во «Входящие»."
},
"context_menu": {
"reply": "Ответить",
"reply_all": "Ответить всем",
+4
View File
@@ -2625,6 +2625,10 @@
"retry": "Opakovať",
"go_home": "Prejsť do doručenej pošty"
},
"not_found": {
"title": "Stránka sa nenašla",
"description": "Táto stránka neexistuje alebo bola presunutá. Skontrolujte adresu alebo sa vráťte do doručenej pošty."
},
"context_menu": {
"reply": "Odpovedať",
"reply_all": "Odpovedať všetkým",
+4
View File
@@ -101,6 +101,10 @@
"retry": "ลองใหม่",
"go_home": "ไปยังกล่องข้อความ"
},
"not_found": {
"title": "ไม่พบหน้านี้",
"description": "ไม่มีหน้านี้หรือหน้านี้ถูกย้ายแล้ว ตรวจสอบที่อยู่ หรือกลับไปที่กล่องจดหมายเข้า"
},
"login": {
"title": "เข้าสู่ระบบจดหมายอิเล็กทรอนิกส์",
"username_label": "อีเมล",
+4
View File
@@ -2625,6 +2625,10 @@
"retry": "Yeniden dene",
"go_home": "Gelen kutusuna git"
},
"not_found": {
"title": "Sayfa bulunamadı",
"description": "Bu sayfa yok veya taşınmış. Adresi kontrol edin ya da gelen kutunuza dönün."
},
"context_menu": {
"reply": "Yanıtla",
"reply_all": "Tümünü Yanıtla",
+4
View File
@@ -2625,6 +2625,10 @@
"retry": "Повторіть спробу",
"go_home": "Перейти до папки \"Вхідні\"."
},
"not_found": {
"title": "Сторінку не знайдено",
"description": "Цієї сторінки немає або її переміщено. Перевірте адресу або поверніться до вхідних."
},
"context_menu": {
"reply": "Відповісти",
"reply_all": "Відповісти всім",
+4
View File
@@ -2625,6 +2625,10 @@
"retry": "重試",
"go_home": "前往收件匣"
},
"not_found": {
"title": "找不到頁面",
"description": "此頁面不存在或已移動。請檢查網址,或返回收件匣。"
},
"context_menu": {
"reply": "回覆",
"reply_all": "全部回覆",
+4
View File
@@ -2625,6 +2625,10 @@
"retry": "重试",
"go_home": "前往收件箱"
},
"not_found": {
"title": "找不到页面",
"description": "此页面不存在或已移动。请检查地址,或返回收件箱。"
},
"context_menu": {
"reply": "回复",
"reply_all": "全部回复",
+1
View File
@@ -39,6 +39,7 @@ export const LITE_REMOVED_PATHS = [
"instrumentation.ts",
"instrumentation.node.ts",
"app/(main)/[...rest]",
"app/(main)/[locale]/[...rest]",
"app/(main)/protocol",
"e2e",
"integration",