#21: guard the web surfaces and connect them to the auth service

Three separate gaps, all of which made the F4 interface non-functional or
unprotected:

The admin console rendered for anyone. There was no middleware and no
guard in the layout, so /admin/dashboard returned 200 unauthenticated -
only its data calls failed. middleware.ts now redirects both surfaces to
their login when the session cookie is missing, expired, carries the wrong
audience, or is a pre-MFA challenge token. It reads the claims without
verifying the signature on purpose: the signing key belongs to the auth
service and must not reach the browser bundle. That is sound because this
is not the boundary - every /api/v1 request is verified for real upstream.

Nothing served /api/v1/*. next.config.ts had no rewrites and there were no
route handlers, so every call the pages made 404'd against the Next
server, while AUTH_SERVICE_URL sat unused. A catch-all route now proxies
to the auth service, forwarding the client address and preserving multiple
Set-Cookie headers, which logout needs to clear both surfaces. Session
cookies also move to Path=/, since a cookie pinned to /user or /admin was
never sent with the /api/v1 requests that needed it.

i18n existed only as files. The three bundles had perfect key parity and
real translations, but no library, no imports and no switcher: the UI was
hardcoded English and i18n.test.ts compared keys while the feature was
absent. There is now a provider, a hook, a language switcher, and
server-side Accept-Language negotiation that honours q-values and matches
a bare "pt" to pt-BR. Logins, both layouts and the settings screen consume
it; the admin pages still have English titles.

The settings screen invented app passwords in the browser with Math.random
and never stored them. It now calls the API, which returns the password
once and keeps only its hash.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-02 09:33:49 -03:00
1 parent 833dc76c2e
commit 132cecd41c
16 files changed
+535 -43

No files matched your search

@@ -0,0 +1,27 @@
"use client";
import { LOCALES, LOCALE_LABELS, type Locale } from "./config";
import { useI18n } from "./provider";
/** Language selector, rendered in both surface layouts (PLAN.md section 21.2). */
export function LanguageSwitcher({ className = "" }: { className?: string }) {
const { locale, setLocale, t } = useI18n();
return (
<label className={`flex items-center gap-2 text-xs ${className}`}>
<span className="sr-only">{t("settings.language")}</span>
<select
aria-label={t("settings.language")}
value={locale}
onChange={(e) => setLocale(e.target.value as Locale)}
className="rounded-md border border-slate-700 bg-slate-900 px-2 py-1 text-xs text-slate-200"
>
{LOCALES.map((l) => (
<option key={l} value={l}>
{LOCALE_LABELS[l]}
</option>
))}
</select>
</label>
);
}
+71
View File
@@ -0,0 +1,71 @@
import en from "../../messages/en.json";
import ptBR from "../../messages/pt-BR.json";
import es from "../../messages/es.json";
export const LOCALES = ["en", "pt-BR", "es"] as const;
export type Locale = (typeof LOCALES)[number];
export const DEFAULT_LOCALE: Locale = "en";
export const LOCALE_COOKIE = "lm_locale";
export const LOCALE_LABELS: Record<Locale, string> = {
en: "English",
"pt-BR": "Português (Brasil)",
es: "Español",
};
// Bundled at build time rather than fetched: these are small, needed on first
// paint, and a network round trip would flash untranslated text.
export const MESSAGES: Record<Locale, Messages> = {
en: en as Messages,
"pt-BR": ptBR as Messages,
es: es as Messages,
};
export type Messages = Record<string, Record<string, string>>;
export function isLocale(value: string | undefined | null): value is Locale {
return !!value && (LOCALES as readonly string[]).includes(value);
}
/**
* Picks the best supported locale from an Accept-Language header.
* Quality values are honoured, and a bare "pt" matches "pt-BR" so a Brazilian
* browser that only advertises "pt" is not silently served English.
*/
export function negotiateLocale(acceptLanguage: string | null): Locale {
if (!acceptLanguage) return DEFAULT_LOCALE;
const ranked = acceptLanguage
.split(",")
.map((part) => {
const [tag, ...params] = part.trim().split(";");
const q = params.find((p) => p.trim().startsWith("q="));
return { tag: tag.trim().toLowerCase(), q: q ? Number(q.split("=")[1]) || 0 : 1 };
})
.sort((a, b) => b.q - a.q);
for (const { tag } of ranked) {
const exact = LOCALES.find((l) => l.toLowerCase() === tag);
if (exact) return exact;
const base = tag.split("-")[0];
const prefixed = LOCALES.find((l) => l.toLowerCase().split("-")[0] === base);
if (prefixed) return prefixed;
}
return DEFAULT_LOCALE;
}
/**
* Resolves a dotted key such as "auth.signInButton".
*
* A missing key falls back to English and, failing that, returns the key
* itself: a screen showing "settings.title" is a visible bug report, whereas
* throwing would blank the page over a typo in a label.
*/
export function translate(messages: Messages, key: string): string {
const [namespace, name] = key.split(".");
const value = messages?.[namespace]?.[name];
if (typeof value === "string") return value;
const fallback = MESSAGES[DEFAULT_LOCALE]?.[namespace]?.[name];
return typeof fallback === "string" ? fallback : key;
}
+52
View File
@@ -0,0 +1,52 @@
"use client";
import React, { createContext, useCallback, useContext, useMemo, useState } from "react";
import { LOCALE_COOKIE, MESSAGES, DEFAULT_LOCALE, translate, type Locale } from "./config";
interface I18nContextValue {
locale: Locale;
setLocale: (locale: Locale) => void;
t: (key: string) => string;
}
const I18nContext = createContext<I18nContextValue | null>(null);
export function I18nProvider({ locale: initialLocale, children }: { locale: Locale; children: React.ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(initialLocale);
const setLocale = useCallback((next: Locale) => {
setLocaleState(next);
// Persisted for the next server render, so the page does not come back in
// the previous language on reload. Not HttpOnly: this is a display
// preference the client itself sets, carrying nothing sensitive.
document.cookie = `${LOCALE_COOKIE}=${next}; Path=/; Max-Age=31536000; SameSite=Lax`;
// Best effort: keep the account's stored locale in step for anything the
// server sends by mail. A failure here must not break the UI switch.
void fetch("/api/v1/user/locale", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ locale: next }),
}).catch(() => undefined);
document.documentElement.lang = next;
}, []);
const value = useMemo<I18nContextValue>(() => {
const messages = MESSAGES[locale] ?? MESSAGES[DEFAULT_LOCALE];
return { locale, setLocale, t: (key: string) => translate(messages, key) };
}, [locale, setLocale]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n(): I18nContextValue {
const ctx = useContext(I18nContext);
if (!ctx) {
throw new Error("useI18n must be used inside <I18nProvider>");
}
return ctx;
}
/** Shorthand for components that only need to translate. */
export function useTranslations(): (key: string) => string {
return useI18n().t;
}