mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-23 20:06:20 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
)));
|
||||
});
|
||||
Reference in New Issue
Block a user