From 5e19b7fdf6d745057637f00671a678129b4b9f66 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 09:18:41 +0000 Subject: [PATCH] fix(auth): forgot-password says a reset link can't be emailed when the deployment sends no mail (#30366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read features.emailDelivery from /auth/config; on an explicit false the success screen says no reset link can be emailed here and to ask an administrator (Setup → Users). Absent key or failed fetch keeps today's text. The text names no address, so known and unknown addresses read the same. New labels in all ten locale packs, wired through both forgot-password pages. Claude-Session: https://claude.ai/code/session_01Utp6CYv3DdXJ81i2yQpGKm Co-authored-by: Claude --- .changeset/30366-forgot-password-no-mail.md | 17 ++++ .../src/pages/auth/ForgotPasswordPage.tsx | 7 ++ .../src/console/auth/ForgotPasswordPage.tsx | 2 + ui/packages/auth/src/ForgotPasswordForm.tsx | 50 +++++++++-- .../forgot-password-success-message.test.tsx | 86 ++++++++++++++++++- ui/packages/auth/src/types.ts | 10 +++ ui/packages/i18n/src/locales/ar.ts | 2 + ui/packages/i18n/src/locales/de.ts | 2 + ui/packages/i18n/src/locales/en.ts | 2 + ui/packages/i18n/src/locales/es.ts | 2 + ui/packages/i18n/src/locales/fr.ts | 2 + ui/packages/i18n/src/locales/ja.ts | 2 + ui/packages/i18n/src/locales/ko.ts | 2 + ui/packages/i18n/src/locales/pt.ts | 2 + ui/packages/i18n/src/locales/ru.ts | 2 + ui/packages/i18n/src/locales/zh.ts | 2 + 16 files changed, 184 insertions(+), 8 deletions(-) create mode 100644 .changeset/30366-forgot-password-no-mail.md diff --git a/.changeset/30366-forgot-password-no-mail.md b/.changeset/30366-forgot-password-no-mail.md new file mode 100644 index 00000000000..b21858adf29 --- /dev/null +++ b/.changeset/30366-forgot-password-no-mail.md @@ -0,0 +1,17 @@ +--- +'@object-ui/auth': patch +'@object-ui/i18n': patch +'@object-ui/app-shell': patch +'@object-ui/console': patch +--- + +fix(auth): the forgot-password page no longer says a reset link was sent on a deployment that sends no mail (#30366) + +Clause-②: yes (widening) +Metadata-protocol: none + +`GET /api/v1/auth/config` reports `features.emailDelivery`: `false` when no email service is wired or the stock `log` transport only writes mail to the server console. On such a deployment the forgot-password form used to answer every request with "Check your email — we've sent a password reset link", although no mail leaves. When the server reports `emailDelivery: false`, the success screen now says a reset link can't be emailed here and to ask an administrator, who can set a new password from Setup → Users. + +The text describes the deployment, never the address: it carries no address, and a known and an unknown address read the same, so the page adds no account-existence oracle. Only an explicit `false` changes it; a server that predates the flag (no `emailDelivery` key) or a failed config fetch keeps the "check your email" screen, since those deployments may well send mail. + +`ForgotPasswordFormLabels` gains `noDeliveryTitle` and `noDeliveryDescription`, and `AuthPublicConfig.features` gains `emailDelivery`. New strings `auth.forgotPassword.noDeliveryTitle` and `auth.forgotPassword.noDeliveryDescription` in all ten locales, passed by the Console's forgot-password page and `DefaultForgotPasswordPage`. diff --git a/ui/apps/console/src/pages/auth/ForgotPasswordPage.tsx b/ui/apps/console/src/pages/auth/ForgotPasswordPage.tsx index ed91049c7e3..d17ad43e20e 100644 --- a/ui/apps/console/src/pages/auth/ForgotPasswordPage.tsx +++ b/ui/apps/console/src/pages/auth/ForgotPasswordPage.tsx @@ -76,6 +76,13 @@ export function ForgotPasswordPage() { phoneSuccessDescription: t('auth.forgotPassword.phoneSuccessDescription', { defaultValue: 'Your password has been reset. You can now sign in with your new password.', }), + noDeliveryTitle: t('auth.forgotPassword.noDeliveryTitle', { + defaultValue: "A reset link can't be emailed here", + }), + noDeliveryDescription: t('auth.forgotPassword.noDeliveryDescription', { + defaultValue: + "This deployment doesn't send email, so no password reset link can reach you. Ask an administrator: they can set a new password for you from Setup → Users.", + }), rememberPasswordText: t('auth.forgotPassword.rememberPasswordText', { defaultValue: 'Remember your password?', }), diff --git a/ui/packages/app-shell/src/console/auth/ForgotPasswordPage.tsx b/ui/packages/app-shell/src/console/auth/ForgotPasswordPage.tsx index 6cb70464c88..48e93873617 100644 --- a/ui/packages/app-shell/src/console/auth/ForgotPasswordPage.tsx +++ b/ui/packages/app-shell/src/console/auth/ForgotPasswordPage.tsx @@ -28,6 +28,8 @@ export function ForgotPasswordPage() { submittingButton: t('auth.forgotPassword.submittingButton'), successTitle: t('auth.forgotPassword.successTitle'), successDescription: t('auth.forgotPassword.successDescription'), + noDeliveryTitle: t('auth.forgotPassword.noDeliveryTitle'), + noDeliveryDescription: t('auth.forgotPassword.noDeliveryDescription'), backToSignInText: t('auth.forgotPassword.backToSignInText'), rememberPasswordText: t('auth.forgotPassword.rememberPasswordText'), signInText: t('auth.forgotPassword.signInText'), diff --git a/ui/packages/auth/src/ForgotPasswordForm.tsx b/ui/packages/auth/src/ForgotPasswordForm.tsx index e91b7077190..884d14f7dfe 100644 --- a/ui/packages/auth/src/ForgotPasswordForm.tsx +++ b/ui/packages/auth/src/ForgotPasswordForm.tsx @@ -58,6 +58,17 @@ export interface ForgotPasswordFormLabels { phoneSuccessTitle?: string; /** Phone reset — success screen description. */ phoneSuccessDescription?: string; + /** + * Success screen title when the server reports `features.emailDelivery: false` + * (defaults to "A reset link can't be emailed here"). + */ + noDeliveryTitle?: string; + /** + * Success screen description when the server reports + * `features.emailDelivery: false`. It describes the deployment, never the + * address, so it carries no `{email}` hole. + */ + noDeliveryDescription?: string; } export interface ForgotPasswordFormProps { @@ -139,6 +150,12 @@ export function ForgotPasswordForm({ // deliverable SMS service — so the link never leads to codes that can't // arrive. const [phoneOtpEnabled, setPhoneOtpEnabled] = useState(false); + // Whether mail leaves this deployment (`features.emailDelivery`). Only an + // explicit `false` swaps the success screen: an absent key (a server that + // predates the flag) or a failed config fetch is unknown, and keeps the + // "check your email" text rather than telling people on a mail-sending + // deployment to go find an administrator. + const [emailDeliveryOff, setEmailDeliveryOff] = useState(false); const [mode, setMode] = useState<'email' | 'phone'>('email'); const [phone, setPhone] = useState(''); const [otpCode, setOtpCode] = useState(''); @@ -154,10 +171,13 @@ export function ForgotPasswordForm({ Promise.resolve() .then(() => getAuthConfig()) .then((config) => { - if (!cancelled) setPhoneOtpEnabled(config?.features?.phoneNumberOtp === true); + if (cancelled) return; + setPhoneOtpEnabled(config?.features?.phoneNumberOtp === true); + setEmailDeliveryOff(config?.features?.emailDelivery === false); }) .catch(() => { - // Phone reset is an enhancement — a failed config fetch just hides it. + // Phone reset is an enhancement — a failed config fetch just hides it, + // and leaves the email success text as it is (delivery unknown). }); return () => { cancelled = true; @@ -198,6 +218,10 @@ export function ForgotPasswordForm({ phoneSuccessDescription: labels.phoneSuccessDescription ?? 'Your password has been reset. You can now sign in with your new password.', + noDeliveryTitle: labels.noDeliveryTitle ?? "A reset link can't be emailed here", + noDeliveryDescription: + labels.noDeliveryDescription ?? + "This deployment doesn't send email, so no password reset link can reach you. Ask an administrator: they can set a new password for you from Setup → Users.", }; const handleSubmit = async (e: React.FormEvent) => { @@ -284,18 +308,30 @@ export function ForgotPasswordForm({ } if (submitted) { - const successMsg = l.successDescription.includes('{email}') - ? l.successDescription.replace('{email}', email) - : `${l.successDescription} ${email}`; + // No account-existence oracle: the reset request answers a known and an + // unknown address alike, and which text shows here is decided by the + // deployment's `features.emailDelivery` alone, never by that answer. + // The no-delivery text names no address at all. + const successMsg = emailDeliveryOff + ? l.noDeliveryDescription + : l.successDescription.includes('{email}') + ? l.successDescription.replace('{email}', email) + : `${l.successDescription} ${email}`; return (
+ } - title={l.successTitle} + title={emailDeliveryOff ? l.noDeliveryTitle : l.successTitle} description={successMsg} /> {loginUrl && ( diff --git a/ui/packages/auth/src/__tests__/forgot-password-success-message.test.tsx b/ui/packages/auth/src/__tests__/forgot-password-success-message.test.tsx index 62ae47e5cd0..47317c9c70d 100644 --- a/ui/packages/auth/src/__tests__/forgot-password-success-message.test.tsx +++ b/ui/packages/auth/src/__tests__/forgot-password-success-message.test.tsx @@ -23,7 +23,7 @@ import React from 'react'; import { describe, it, expect, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { AuthProvider } from '../AuthProvider'; import { ForgotPasswordForm } from '../ForgotPasswordForm'; import type { AuthClient, AuthPublicConfig } from '../types'; @@ -110,3 +110,87 @@ describe('ForgotPasswordForm — success message substitution (objectui#4135)', expect(occurrences(description.textContent ?? '', 'control@example.com')).toBe(1); }); }); + +/** + * objectstack#30366 — on a deployment that sends no mail the success screen + * must not say a reset link was sent. The server reports this as + * `features.emailDelivery` on `/auth/config`; only an explicit `false` swaps + * the text, and the swap depends on the deployment alone, so a known and an + * unknown address read the same (no account-existence oracle). + */ +describe('ForgotPasswordForm — success screen on a deployment that sends no mail (objectstack#30366)', () => { + function renderWithConfig( + getConfig: AuthClient['getConfig'], + props: React.ComponentProps = {}, + ) { + const client = createMockClient({ getConfig }); + const view = render( + + + , + ); + return { client, view }; + } + + const noMail = () => vi.fn().mockResolvedValue({ features: { emailDelivery: false } } as AuthPublicConfig); + + it('says a reset link cannot be emailed and to ask an administrator when emailDelivery is false', async () => { + const getConfig = noMail(); + renderWithConfig(getConfig); + await waitFor(() => expect(getConfig).toHaveBeenCalled()); + await submit('someone@example.com'); + + expect(await screen.findByText("A reset link can't be emailed here")).toBeTruthy(); + const description = screen.getByText(/doesn't send email/); + expect(description.textContent).toMatch(/administrator/); + expect(description.textContent).toMatch(/Setup → Users/); + expect(description.textContent).not.toContain('someone@example.com'); + expect(screen.queryByText('Check your email')).toBeNull(); + expect(screen.queryByText(/We've sent a password reset link/)).toBeNull(); + }); + + it('reads a known and an unknown address the same: the text follows the deployment, not the address', async () => { + // The reset request answers both alike (the server's no-oracle rule); the + // screen must add no difference of its own, address substitution included. + const screens: string[] = []; + for (const address of ['known@example.com', 'nobody-here@example.com']) { + const getConfig = noMail(); + const { client, view } = renderWithConfig(getConfig); + await waitFor(() => expect(getConfig).toHaveBeenCalled()); + await submit(address); + await screen.findByText("A reset link can't be emailed here"); + expect(client.forgotPassword).toHaveBeenCalledWith(address); + screens.push(view.container.textContent ?? ''); + view.unmount(); + } + expect(screens[0]).toBe(screens[1]); + expect(screens[0]).not.toContain('@example.com'); + }); + + it('uses caller-supplied translated labels for the no-delivery screen', async () => { + const getConfig = noMail(); + renderWithConfig(getConfig, { + labels: { noDeliveryTitle: 'NO-MAIL TITLE', noDeliveryDescription: 'NO-MAIL BODY' }, + }); + await waitFor(() => expect(getConfig).toHaveBeenCalled()); + await submit('someone@example.com'); + + expect(await screen.findByText('NO-MAIL TITLE')).toBeTruthy(); + expect(screen.getByText('NO-MAIL BODY')).toBeTruthy(); + }); + + it.each([ + ['emailDelivery is true', () => vi.fn().mockResolvedValue({ features: { emailDelivery: true } } as AuthPublicConfig)], + ['the server sends no emailDelivery key (older server)', () => vi.fn().mockResolvedValue({ features: {} } as AuthPublicConfig)], + ['the config fetch fails', () => vi.fn().mockRejectedValue(new Error('offline'))], + ])('keeps the "check your email" screen when %s', async (_label, makeGetConfig) => { + const getConfig = makeGetConfig(); + renderWithConfig(getConfig); + await waitFor(() => expect(getConfig).toHaveBeenCalled()); + await submit('someone@example.com'); + + expect(await screen.findByText('Check your email')).toBeTruthy(); + expect(screen.getByText(/We've sent a password reset link to someone@example.com/)).toBeTruthy(); + expect(screen.queryByText("A reset link can't be emailed here")).toBeNull(); + }); +}); diff --git a/ui/packages/auth/src/types.ts b/ui/packages/auth/src/types.ts index 760f8c3da86..6804d267693 100644 --- a/ui/packages/auth/src/types.ts +++ b/ui/packages/auth/src/types.ts @@ -333,6 +333,16 @@ export interface AuthPublicConfig { * entry point whose code can never arrive. */ phoneNumberOtp?: boolean; + /** + * Whether mail leaves this deployment (`AuthFeaturesConfigSchema.emailDelivery` + * in `@objectstack/spec`): `true` only when the wired email service reports + * itself configured; `false` with no email service or the stock `log` + * transport. The forgot-password form swaps its success screen to "a reset + * link can't be emailed here, ask an administrator" on an explicit `false` + * only. It describes the deployment, never an address. Absent (older + * server) ⇒ the form keeps its "check your email" text. + */ + emailDelivery?: boolean; /** * better-auth admin plugin mounted server-side (`auth.plugins.admin`; * SCIM forces it on). Gates the admin user-management surfaces — the diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index f7f66efbae5..d16de7c3a5f 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -3093,6 +3093,8 @@ const ar = { useEmailResetText: "إعادة التعيين بالبريد الإلكتروني بدلاً من ذلك", phoneSuccessTitle: "تمت إعادة تعيين كلمة المرور", phoneSuccessDescription: "تمت إعادة تعيين كلمة المرور. يمكنك الآن تسجيل الدخول بكلمة المرور الجديدة.", + noDeliveryTitle: "لا يمكن إرسال رابط إعادة التعيين بالبريد الإلكتروني هنا", + noDeliveryDescription: "هذا النشر لا يرسل رسائل بريد إلكتروني، لذلك لن يصلك أي رابط لإعادة تعيين كلمة المرور. اطلب من المسؤول: يمكنه تعيين كلمة مرور جديدة لك من الإعداد → المستخدمون.", }, resetPassword: { title: "تعيين كلمة مرور جديدة", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index decad288cde..7a2e5d12946 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -2839,6 +2839,8 @@ const de = { useEmailResetText: "Stattdessen per E-Mail zurücksetzen", phoneSuccessTitle: "Passwort zurückgesetzt", phoneSuccessDescription: "Ihr Passwort wurde zurückgesetzt. Sie können sich jetzt mit dem neuen Passwort anmelden.", + noDeliveryTitle: "Hier kann kein Link zum Zurücksetzen per E-Mail gesendet werden", + noDeliveryDescription: "Diese Bereitstellung versendet keine E-Mails, daher kann Sie kein Link zum Zurücksetzen des Passworts erreichen. Wenden Sie sich an einen Administrator: Er kann unter Einrichtung → Benutzer ein neues Passwort für Sie festlegen.", }, resetPassword: { title: "Neues Passwort festlegen", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 9ba9c6ae612..f38bb4cd07b 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -3387,6 +3387,8 @@ const en = { useEmailResetText: 'Reset via email instead', phoneSuccessTitle: 'Password reset', phoneSuccessDescription: 'Your password has been reset. You can now sign in with your new password.', + noDeliveryTitle: "A reset link can't be emailed here", + noDeliveryDescription: "This deployment doesn't send email, so no password reset link can reach you. Ask an administrator: they can set a new password for you from Setup → Users.", }, resetPassword: { title: 'Set a new password', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index f22af5ce0c2..3a4431c48d9 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -2904,6 +2904,8 @@ const es = { useEmailResetText: "Restablecer por correo electrónico en su lugar", phoneSuccessTitle: "Contraseña restablecida", phoneSuccessDescription: "Tu contraseña ha sido restablecida. Ahora puedes iniciar sesión con tu nueva contraseña.", + noDeliveryTitle: "Aquí no se puede enviar un enlace de restablecimiento por correo", + noDeliveryDescription: "Esta implementación no envía correos, así que ningún enlace para restablecer la contraseña puede llegarle. Pida ayuda a un administrador: puede establecerle una nueva contraseña en Configuración → Usuarios.", }, resetPassword: { title: "Establecer nueva contraseña", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index c216cdab8b9..28d2b85c465 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -2900,6 +2900,8 @@ const fr = { useEmailResetText: "Réinitialiser par e-mail à la place", phoneSuccessTitle: "Mot de passe réinitialisé", phoneSuccessDescription: "Votre mot de passe a été réinitialisé. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe.", + noDeliveryTitle: "Aucun lien de réinitialisation ne peut être envoyé par e-mail ici", + noDeliveryDescription: "Ce déploiement n'envoie pas d'e-mails, aucun lien de réinitialisation du mot de passe ne peut donc vous parvenir. Demandez à un administrateur : il peut vous définir un nouveau mot de passe dans Configuration → Utilisateurs.", }, resetPassword: { title: "Définir un nouveau mot de passe", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 2c2f9ba76a8..a64100ece4f 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -2841,6 +2841,8 @@ const ja = { useEmailResetText: "メールでリセットする", phoneSuccessTitle: "パスワードをリセットしました", phoneSuccessDescription: "パスワードをリセットしました。新しいパスワードでサインインできます。", + noDeliveryTitle: "ここではリセットリンクをメールで送信できません", + noDeliveryDescription: "このデプロイはメールを送信しないため、パスワードリセットリンクは届きません。管理者にお問い合わせください。管理者が「設定 → ユーザー」で新しいパスワードを設定できます。", }, resetPassword: { title: "新しいパスワードを設定", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 530c8bb2d76..1540073d147 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -2839,6 +2839,8 @@ const ko = { useEmailResetText: "이메일로 재설정하기", phoneSuccessTitle: "비밀번호가 재설정되었습니다", phoneSuccessDescription: "비밀번호가 재설정되었습니다. 이제 새 비밀번호로 로그인할 수 있습니다.", + noDeliveryTitle: "여기서는 재설정 링크를 이메일로 보낼 수 없습니다", + noDeliveryDescription: "이 배포는 이메일을 보내지 않으므로 비밀번호 재설정 링크가 전달되지 않습니다. 관리자에게 문의하세요. 관리자가 '설정 → 사용자'에서 새 비밀번호를 설정해 줄 수 있습니다.", }, resetPassword: { title: "새 비밀번호 설정", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index c9c734753f3..e31733687e6 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -2899,6 +2899,8 @@ const pt = { useEmailResetText: "Redefinir por e-mail em vez disso", phoneSuccessTitle: "Senha redefinida", phoneSuccessDescription: "Sua senha foi redefinida. Agora você pode entrar com a nova senha.", + noDeliveryTitle: "Não é possível enviar um link de redefinição por e-mail aqui", + noDeliveryDescription: "Esta implantação não envia e-mails, então nenhum link de redefinição de senha pode chegar até você. Peça a um administrador: ele pode definir uma nova senha para você em Configuração → Usuários.", }, resetPassword: { title: "Definir nova senha", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index f251e5e8654..45da40b588e 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -2974,6 +2974,8 @@ const ru = { useEmailResetText: "Сбросить по электронной почте", phoneSuccessTitle: "Пароль сброшен", phoneSuccessDescription: "Ваш пароль сброшен. Теперь вы можете войти с новым паролем.", + noDeliveryTitle: "Здесь нельзя отправить ссылку для сброса по почте", + noDeliveryDescription: "Это развёртывание не отправляет письма, поэтому ссылка для сброса пароля до вас не дойдёт. Обратитесь к администратору: он может задать вам новый пароль в разделе «Настройка → Пользователи».", }, resetPassword: { title: "Установить новый пароль", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index c7f54ab82ad..2b5529b3ddc 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -2903,6 +2903,8 @@ const zh = { useEmailResetText: '改用邮箱重置', phoneSuccessTitle: '密码已重置', phoneSuccessDescription: '您的密码已重置,现在可以使用新密码登录了。', + noDeliveryTitle: '此处无法通过邮件发送重置链接', + noDeliveryDescription: '此部署不发送邮件,因此密码重置链接无法送达。请联系管理员:管理员可以在“设置 → 用户”中为你设置新密码。', }, setPassword: { title: '设置一个备用密码',