Run the auth and settings screens through the translator

use-translation.ts states the rule: every user-facing string in a
component must go through t(). Five screens never called it at all --
forgot-password, reset-password, confirm-password, verify-email and
settings/password had zero occurrences of useTranslation -- so a client
who had chosen Spanish reset their password in English, from the browser
tab down to the submit button. settings/profile had the hook but used it
for two strings, leaving its heading, labels and the whole
email-verification notice hardcoded around them.

The password page also carried a second, smaller mistake the miss was
hiding: its <Head> title said "Profile settings", copied from the
profile page, so the tab named the wrong screen in every language.
It says "Password settings" now, the wording its own breadcrumb and
the sibling "Notification settings" title already use.

Every string on the six screens goes through t() now. The two
module-level breadcrumb arrays moved inside their components to reach
the hook -- the shape two-factor, notifications and the other settings
pages already have. Where a key already exists in the catalogs (Email
address, Password, Confirm password, New password, Log out and friends,
shared with the login screen) the existing translations light up
immediately; the keys new to the catalogs fall back to their English
text, exactly what those lines rendered before, until the locales pick
them up.

TranslationUsageTest is the guard, a source scan like
DateFormattingUsageTest and for the same reason: no JavaScript test
runner gates this class of miss. It fails on any page under pages/auth
or pages/settings that never uses the hook -- those screens always carry
copy of their own, so a page there without it is a page somebody forgot
-- and on any literal <Head title="..."> anywhere, which is both a
user-facing string and where the copy-paste title above lived. Both
scans go red on the tree without this change: five pages and six
literal titles.
This commit is contained in:
denkfabrik-li
2026-08-29 02:03:26 +02:00
parent 4556ccf691
commit ed82d748ea
7 changed files with 160 additions and 57 deletions
+9 -6
View File
@@ -7,9 +7,12 @@ import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useTranslation } from '@/hooks/use-translation';
import AuthLayout from '@/layouts/auth-layout';
export default function ConfirmPassword() {
const { t } = useTranslation();
const { data, setData, post, processing, errors, reset } = useForm({
password: '',
});
@@ -24,20 +27,20 @@ export default function ConfirmPassword() {
return (
<AuthLayout
title="Confirm your password"
description="This is a secure area of the application. Please confirm your password before continuing."
title={t('Confirm your password')}
description={t('This is a secure area of the application. Please confirm your password before continuing.')}
>
<Head title="Confirm password" />
<Head title={t('Confirm password')} />
<form onSubmit={submit}>
<div className="space-y-6">
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Label htmlFor="password">{t('Password')}</Label>
<Input
id="password"
type="password"
name="password"
placeholder="Password"
placeholder={t('Password')}
autoComplete="current-password"
value={data.password}
autoFocus
@@ -50,7 +53,7 @@ export default function ConfirmPassword() {
<div className="flex items-center">
<Button className="w-full" disabled={processing}>
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
Confirm password
{t('Confirm password')}
</Button>
</div>
</div>
+8 -6
View File
@@ -9,6 +9,7 @@ import TextLink from '@/components/text-link';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useTranslation } from '@/hooks/use-translation';
import AuthLayout from '@/layouts/auth-layout';
interface ForgotPasswordForm {
@@ -19,6 +20,7 @@ interface ForgotPasswordForm {
}
export default function ForgotPassword({ status }: { status?: string }) {
const { t } = useTranslation();
const captcha = useRef<CaptchaHandle>(null);
const captchaToken = useRef<string | null>(null);
@@ -39,15 +41,15 @@ export default function ForgotPassword({ status }: { status?: string }) {
};
return (
<AuthLayout title="Forgot password" description="Enter your email to receive a password reset link">
<Head title="Forgot password" />
<AuthLayout title={t('Forgot password')} description={t('Enter your email to receive a password reset link')}>
<Head title={t('Forgot password')} />
{status && <div className="mb-4 text-center text-sm font-medium text-green-600">{status}</div>}
<div className="space-y-6">
<form onSubmit={submit}>
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<Label htmlFor="email">{t('Email address')}</Label>
<Input
id="email"
type="email"
@@ -70,14 +72,14 @@ export default function ForgotPassword({ status }: { status?: string }) {
<div className="my-6 flex items-center justify-start">
<Button className="w-full" disabled={processing}>
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
Email password reset link
{t('Email password reset link')}
</Button>
</div>
</form>
<div className="text-muted-foreground space-x-1 text-center text-sm">
<span>Or, return to</span>
<TextLink href={route('login')}>log in</TextLink>
<span>{t('Or, return to')}</span>
<TextLink href={route('login')}>{t('log in')}</TextLink>
</div>
</div>
</AuthLayout>
+11 -8
View File
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { PasswordRequirements } from '@/components/password-requirements';
import { useTranslation } from '@/hooks/use-translation';
import AuthLayout from '@/layouts/auth-layout';
interface ResetPasswordProps {
@@ -23,6 +24,8 @@ interface ResetPasswordForm {
}
export default function ResetPassword({ token, email }: ResetPasswordProps) {
const { t } = useTranslation();
const { data, setData, post, processing, errors, reset } = useForm<ResetPasswordForm>({
token: token,
email: email,
@@ -38,13 +41,13 @@ export default function ResetPassword({ token, email }: ResetPasswordProps) {
};
return (
<AuthLayout title="Reset password" description="Please enter your new password below">
<Head title="Reset password" />
<AuthLayout title={t('Reset password')} description={t('Please enter your new password below')}>
<Head title={t('Reset password')} />
<form onSubmit={submit}>
<div className="grid gap-6">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Label htmlFor="email">{t('Email')}</Label>
<Input
id="email"
type="email"
@@ -59,7 +62,7 @@ export default function ResetPassword({ token, email }: ResetPasswordProps) {
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Label htmlFor="password">{t('Password')}</Label>
<Input
id="password"
type="password"
@@ -69,14 +72,14 @@ export default function ResetPassword({ token, email }: ResetPasswordProps) {
className="mt-1 block w-full"
autoFocus
onChange={(e) => setData('password', e.target.value)}
placeholder="Password"
placeholder={t('Password')}
/>
<PasswordRequirements />
<InputError message={errors.password} />
</div>
<div className="grid gap-2">
<Label htmlFor="password_confirmation">Confirm password</Label>
<Label htmlFor="password_confirmation">{t('Confirm password')}</Label>
<Input
id="password_confirmation"
type="password"
@@ -85,14 +88,14 @@ export default function ResetPassword({ token, email }: ResetPasswordProps) {
value={data.password_confirmation}
className="mt-1 block w-full"
onChange={(e) => setData('password_confirmation', e.target.value)}
placeholder="Confirm password"
placeholder={t('Confirm password')}
/>
<InputError message={errors.password_confirmation} className="mt-2" />
</div>
<Button type="submit" className="mt-4 w-full" disabled={processing}>
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
Reset password
{t('Reset password')}
</Button>
</div>
</form>
+7 -5
View File
@@ -5,9 +5,11 @@ import { FormEventHandler } from 'react';
import TextLink from '@/components/text-link';
import { Button } from '@/components/ui/button';
import { useTranslation } from '@/hooks/use-translation';
import AuthLayout from '@/layouts/auth-layout';
export default function VerifyEmail({ status }: { status?: string }) {
const { t } = useTranslation();
const { post, processing } = useForm({});
const submit: FormEventHandler = (e) => {
@@ -17,23 +19,23 @@ export default function VerifyEmail({ status }: { status?: string }) {
};
return (
<AuthLayout title="Verify email" description="Please verify your email address by clicking on the link we just emailed to you.">
<Head title="Email verification" />
<AuthLayout title={t('Verify email')} description={t('Please verify your email address by clicking on the link we just emailed to you.')}>
<Head title={t('Email verification')} />
{status === 'verification-link-sent' && (
<div className="mb-4 text-center text-sm font-medium text-green-600">
A new verification link has been sent to the email address you provided during registration.
{t('A new verification link has been sent to the email address you provided during registration.')}
</div>
)}
<form onSubmit={submit} className="space-y-6 text-center">
<Button disabled={processing} variant="secondary">
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
Resend verification email
{t('Resend verification email')}
</Button>
<TextLink href={route('logout')} method="post" className="mx-auto block text-sm">
Log out
{t('Log out')}
</TextLink>
</form>
</AuthLayout>
+22 -16
View File
@@ -10,15 +10,18 @@ import { SaveButton } from '@/components/save-button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { PasswordRequirements } from '@/components/password-requirements';
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Password settings',
href: '/settings/password',
},
];
import { useTranslation } from '@/hooks/use-translation';
export default function Password() {
const { t } = useTranslation();
const breadcrumbs: BreadcrumbItem[] = [
{
title: t('Password settings'),
href: '/settings/password',
},
];
const passwordInput = useRef<HTMLInputElement>(null);
const currentPasswordInput = useRef<HTMLInputElement>(null);
@@ -50,15 +53,18 @@ export default function Password() {
return (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title="Profile settings" />
<Head title={t('Password settings')} />
<SettingsLayout>
<div className="space-y-6">
<HeadingSmall title="Update password" description="Ensure your account is using a long, random password to stay secure" />
<HeadingSmall
title={t('Update password')}
description={t('Ensure your account is using a long, random password to stay secure')}
/>
<form onSubmit={updatePassword} className="space-y-6">
<div className="grid gap-2">
<Label htmlFor="current_password">Current password</Label>
<Label htmlFor="current_password">{t('Current password')}</Label>
<Input
id="current_password"
@@ -68,14 +74,14 @@ export default function Password() {
type="password"
className="mt-1 block w-full"
autoComplete="current-password"
placeholder="Current password"
placeholder={t('Current password')}
/>
<InputError message={errors.current_password} />
</div>
<div className="grid gap-2">
<Label htmlFor="password">New password</Label>
<Label htmlFor="password">{t('New password')}</Label>
<Input
id="password"
@@ -85,7 +91,7 @@ export default function Password() {
type="password"
className="mt-1 block w-full"
autoComplete="new-password"
placeholder="New password"
placeholder={t('New password')}
/>
<PasswordRequirements />
@@ -93,7 +99,7 @@ export default function Password() {
</div>
<div className="grid gap-2">
<Label htmlFor="password_confirmation">Confirm password</Label>
<Label htmlFor="password_confirmation">{t('Confirm password')}</Label>
<Input
id="password_confirmation"
@@ -102,14 +108,14 @@ export default function Password() {
type="password"
className="mt-1 block w-full"
autoComplete="new-password"
placeholder="Confirm password"
placeholder={t('Confirm password')}
/>
<InputError message={errors.password_confirmation} />
</div>
<SaveButton processing={processing} recentlySuccessful={recentlySuccessful}>
Save password
{t('Save password')}
</SaveButton>
</form>
</div>
+16 -16
View File
@@ -13,13 +13,6 @@ import { useTranslation } from '@/hooks/use-translation';
import AppLayout from '@/layouts/app-layout';
import SettingsLayout from '@/layouts/settings/layout';
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Profile settings',
href: '/settings/profile',
},
];
export default function Profile({
mustVerifyEmail,
status,
@@ -38,6 +31,13 @@ export default function Profile({
const { t } = useTranslation();
const { auth } = usePage<SharedData>().props;
const breadcrumbs: BreadcrumbItem[] = [
{
title: t('Profile settings'),
href: '/settings/profile',
},
];
const { data, setData, patch, errors, processing, recentlySuccessful } = useForm({
name: auth.user.name,
email: auth.user.email,
@@ -53,15 +53,15 @@ export default function Profile({
return (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title="Profile settings" />
<Head title={t('Profile settings')} />
<SettingsLayout>
<div className="space-y-6">
<HeadingSmall title="Profile information" description="Update your name and email address" />
<HeadingSmall title={t('Profile information')} description={t('Update your name and email address')} />
<form onSubmit={submit} className="space-y-6">
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<Label htmlFor="name">{t('Name')}</Label>
<Input
id="name"
@@ -70,14 +70,14 @@ export default function Profile({
onChange={(e) => setData('name', e.target.value)}
required
autoComplete="name"
placeholder="Full name"
placeholder={t('Full name')}
/>
<InputError className="mt-2" message={errors.name} />
</div>
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<Label htmlFor="email">{t('Email address')}</Label>
<Input
id="email"
@@ -87,7 +87,7 @@ export default function Profile({
onChange={(e) => setData('email', e.target.value)}
required
autoComplete="username"
placeholder="Email address"
placeholder={t('Email address')}
/>
<InputError className="mt-2" message={errors.email} />
@@ -115,20 +115,20 @@ export default function Profile({
{mustVerifyEmail && auth.user.email_verified_at === null && (
<div>
<p className="mt-2 text-sm text-neutral-800">
Your email address is unverified.
{t('Your email address is unverified.')}
<Link
href={route('verification.send')}
method="post"
as="button"
className="rounded-md text-sm text-neutral-600 underline hover:text-neutral-900 focus:ring-2 focus:ring-offset-2 focus:outline-hidden"
>
Click here to re-send the verification email.
{t('Click here to re-send the verification email.')}
</Link>
</p>
{status === 'verification-link-sent' && (
<div className="mt-2 text-sm font-medium text-green-600">
A new verification link has been sent to your email address.
{t('A new verification link has been sent to your email address.')}
</div>
)}
</div>
+87
View File
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
/**
* A source scan, like DateFormattingUsageTest and for the same reason: there
* is no JavaScript test runner here, and none of the checks that gate CI can
* tell a translated screen from a hardcoded one. The types are fine, the
* lint is fine, and the PHP suite never renders a component.
*
* What it protects: use-translation.ts promises that "every user-facing
* string in a component must go through t()". The way that promise broke was
* never one stray string on a busy page it was whole pages that skipped
* the hook entirely: five auth and settings screens shipped with zero calls,
* so a client who had chosen Spanish reset their password in English. One of
* them had copied its <Head> title from the profile page too, so the browser
* tab said "Profile settings" over the password form.
*
* Two scans, matching the two shapes of that miss:
*
* - Every page under pages/auth and pages/settings must use the hook. These
* screens always carry copy of their own (a title at minimum), so a page
* here with no useTranslation is a page somebody forgot, not a page with
* nothing to say. Pages elsewhere are not scanned a public theme page
* can legitimately render nothing but data.
* - No literal <Head title="..."> anywhere. A browser-tab title is user-
* facing copy like any other, and the literal form is also where the
* copy-paste mistake above lived.
*/
// dirname() rather than base_path(): this runs at file scope, where the
// application container is not booted yet.
$root = dirname(__DIR__, 2);
$untranslatedPages = [];
foreach (['auth', 'settings'] as $section) {
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($root.'/resources/js/pages/'.$section, FilesystemIterator::SKIP_DOTS)
);
foreach ($files as $file) {
if ($file->getExtension() !== 'tsx') {
continue;
}
if (! str_contains((string) file_get_contents($file->getPathname()), 'useTranslation')) {
$untranslatedPages[] = str_replace($root.'/', '', $file->getPathname());
}
}
}
$literalTitles = [];
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($root.'/resources/js', FilesystemIterator::SKIP_DOTS)
);
foreach ($files as $file) {
if ($file->getExtension() !== 'tsx') {
continue;
}
$relative = str_replace($root.'/', '', $file->getPathname());
foreach (file($file->getPathname()) as $number => $line) {
if (str_contains($line, '<Head title="')) {
$literalTitles[] = $relative.':'.($number + 1);
}
}
}
test('every auth and settings page goes through the translator', function () use ($untranslatedPages) {
expect($untranslatedPages)->toBe([], implode("\n", array_merge(
['These pages never call useTranslation(), so everything they say is English in every language.'],
['Wrap each user-facing string: t(\'...\') — see resources/js/hooks/use-translation.ts.'],
$untranslatedPages,
)));
});
test('no page hardcodes its browser-tab title', function () use ($literalTitles) {
expect($literalTitles)->toBe([], implode("\n", array_merge(
['These <Head> titles are string literals, so the tab reads English in every language.'],
['Pass the title through t() — <Head title={t(\'...\')} />.'],
$literalTitles,
)));
});