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: '设置一个备用密码',