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 dcc6355bb80..c5eff413700 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -3098,6 +3098,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 68f65768a93..aceefb2cba8 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -2844,6 +2844,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 29f4c42137a..371e2d829a3 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -3396,6 +3396,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 065c8536332..ff8089a1e0e 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -2909,6 +2909,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 0b212d63a83..b683693795d 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -2905,6 +2905,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 18616912242..7c34f2a768f 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -2846,6 +2846,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 946e04c8575..4a69e0b49c0 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -2844,6 +2844,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 d5aa95ee3df..b5dce9b6bfc 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -2904,6 +2904,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 2e07937b6e4..06cefb081a6 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -2979,6 +2979,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 2a236694f41..543857d5fdf 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -2908,6 +2908,8 @@ const zh = { useEmailResetText: '改用邮箱重置', phoneSuccessTitle: '密码已重置', phoneSuccessDescription: '您的密码已重置,现在可以使用新密码登录了。', + noDeliveryTitle: '此处无法通过邮件发送重置链接', + noDeliveryDescription: '此部署不发送邮件,因此密码重置链接无法送达。请联系管理员:管理员可以在“设置 → 用户”中为你设置新密码。', }, setPassword: { title: '设置一个备用密码',