Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/30366-forgot-password-no-mail.md
Original file line number Diff line number Diff line change
@@ -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`.
7 changes: 7 additions & 0 deletions ui/apps/console/src/pages/auth/ForgotPasswordPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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?',
}),
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/app-shell/src/console/auth/ForgotPasswordPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
50 changes: 43 additions & 7 deletions ui/packages/auth/src/ForgotPasswordForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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('');
Expand All @@ -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;
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 (
<div className="mx-auto flex w-full flex-col justify-center space-y-7 sm:w-[400px]">
<AuthFormHeader
icon={
<span className="flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 ring-1 ring-emerald-500/15 dark:text-emerald-400">
<span
className={
emailDeliveryOff
? 'flex h-12 w-12 items-center justify-center rounded-2xl bg-amber-500/10 text-amber-600 ring-1 ring-amber-500/15 dark:text-amber-400'
: 'flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 ring-1 ring-emerald-500/15 dark:text-emerald-400'
}
>
<AuthMailIcon />
</span>
}
title={l.successTitle}
title={emailDeliveryOff ? l.noDeliveryTitle : l.successTitle}
description={successMsg}
/>
{loginUrl && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<typeof ForgotPasswordForm> = {},
) {
const client = createMockClient({ getConfig });
const view = render(
<AuthProvider authUrl="/api/auth" client={client}>
<ForgotPasswordForm {...props} />
</AuthProvider>,
);
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();
});
});
10 changes: 10 additions & 0 deletions ui/packages/auth/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3098,6 +3098,8 @@ const ar = {
useEmailResetText: "إعادة التعيين بالبريد الإلكتروني بدلاً من ذلك",
phoneSuccessTitle: "تمت إعادة تعيين كلمة المرور",
phoneSuccessDescription: "تمت إعادة تعيين كلمة المرور. يمكنك الآن تسجيل الدخول بكلمة المرور الجديدة.",
noDeliveryTitle: "لا يمكن إرسال رابط إعادة التعيين بالبريد الإلكتروني هنا",
noDeliveryDescription: "هذا النشر لا يرسل رسائل بريد إلكتروني، لذلك لن يصلك أي رابط لإعادة تعيين كلمة المرور. اطلب من المسؤول: يمكنه تعيين كلمة مرور جديدة لك من الإعداد → المستخدمون.",
},
resetPassword: {
title: "تعيين كلمة مرور جديدة",
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2846,6 +2846,8 @@ const ja = {
useEmailResetText: "メールでリセットする",
phoneSuccessTitle: "パスワードをリセットしました",
phoneSuccessDescription: "パスワードをリセットしました。新しいパスワードでサインインできます。",
noDeliveryTitle: "ここではリセットリンクをメールで送信できません",
noDeliveryDescription: "このデプロイはメールを送信しないため、パスワードリセットリンクは届きません。管理者にお問い合わせください。管理者が「設定 → ユーザー」で新しいパスワードを設定できます。",
},
resetPassword: {
title: "新しいパスワードを設定",
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2844,6 +2844,8 @@ const ko = {
useEmailResetText: "이메일로 재설정하기",
phoneSuccessTitle: "비밀번호가 재설정되었습니다",
phoneSuccessDescription: "비밀번호가 재설정되었습니다. 이제 새 비밀번호로 로그인할 수 있습니다.",
noDeliveryTitle: "여기서는 재설정 링크를 이메일로 보낼 수 없습니다",
noDeliveryDescription: "이 배포는 이메일을 보내지 않으므로 비밀번호 재설정 링크가 전달되지 않습니다. 관리자에게 문의하세요. 관리자가 '설정 → 사용자'에서 새 비밀번호를 설정해 줄 수 있습니다.",
},
resetPassword: {
title: "새 비밀번호 설정",
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2979,6 +2979,8 @@ const ru = {
useEmailResetText: "Сбросить по электронной почте",
phoneSuccessTitle: "Пароль сброшен",
phoneSuccessDescription: "Ваш пароль сброшен. Теперь вы можете войти с новым паролем.",
noDeliveryTitle: "Здесь нельзя отправить ссылку для сброса по почте",
noDeliveryDescription: "Это развёртывание не отправляет письма, поэтому ссылка для сброса пароля до вас не дойдёт. Обратитесь к администратору: он может задать вам новый пароль в разделе «Настройка → Пользователи».",
},
resetPassword: {
title: "Установить новый пароль",
Expand Down
Loading
Loading