Skip to content

Commit 8a83814

Browse files
fix(auth): draw the phone way in on first paint when email+password is off and no single sign-on exists (#31533)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbWNorSGyVMaofehjghex5nx)_ Fixes #31492 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:邮箱+密码登录关闭、未提供也未强制单点登录、但开了手机登录时,登录卡片首屏直接给出手机号+密码与验证码登录,不再折叠成单点登录视图,也不再出现「返回单点登录」按钮;账号框此时写「手机号」。 - **为什么改**:只靠手机登录的部署,用户打开登录页只看到「改用密码登录」,点进去还有一个指向并不存在的单点登录的返回按钮,首屏没有入口。 - **风险与回滚**:风险低,只改 `@object-ui/auth` 的 `LoginForm` 一个组件的显示判定;有单点登录、强制单点登录、无任何登录方式、配置读取失败这几种情况保持原样(有对照测试)。回滚即 revert 本 PR。 - **先看**:`ui/packages/auth/src/LoginForm.tsx` 的 `passwordFormCollapsed` 与 `formMode` 两处;`ui/packages/auth/src/__tests__/noSignInMethod-29159.test.tsx` 新增的 describe。 - **两分钟验证**:`cd ui && pnpm exec vitest run packages/auth/src/__tests__/noSignInMethod-29159.test.tsx`,预期 16 个用例全绿。 - **需要你做的**:无,席位落地。 ## What changes `ui/packages/auth/src/LoginForm.tsx`, the card's Mechanism quoted: "`passwordFormCollapsed = ssoEnforced || passwordSignInOff` (line 342), and the `backToSsoText` button renders on `passwordFormCollapsed && showPasswordFallback` (line 788). #29159 gated only the copy (`ssoOnlyCopy`, line 510, which checks `ssoEnforced || ssoEnabled`); the collapse and the back button are not gated on SSO being present." - **Collapse.** `passwordFormCollapsed = ssoEnforced || (passwordSignInOff && (ssoEnabled || !phoneSignInOffered))`, where `phoneSignInOffered = phonePasswordEnabled || phoneOtpEnabled`. The one cell that changes is the password door shut, `features.sso` false, `features.ssoEnforced` false and a phone way in advertised: the form is drawn on first paint. Every other cell keeps today's collapse. Decided from the advertised config only; a failed config fetch leaves every flag at its default and keeps the password form (break-glass). - **Back to single sign-on.** No new condition: the button still renders on `passwordFormCollapsed && showPasswordFallback`. After the change the form collapses only where SSO is enforced or offered, or where the door is shut with no phone way in, and in that last cell the break-glass link is never offered (`breakGlassOffered = !passwordSignInOff || phonePasswordEnabled`), so the button can only be reached where there is single sign-on to go back to. The comment above it says so. - **OTP-only server.** With the password door shut and phone OTP the only way in, the form opens on the verification-code step (`formMode`) and does not offer "Sign in with password instead", a switch to a form the server refuses. Without this, the uncollapsed card would have drawn an email+password form on first paint. - **Identifier label.** The brief asked me to report whether the identifier keeps reading "Email or phone number" while email sign-in is refused. I changed it: with the password door shut and phone+password on, the field reads "Phone number", with the phone placeholder and `type="tel"`. It reuses the existing `phoneLabel` / `phonePlaceholder` labels, which the Console already fills from `auth.login.phoneLabel` / `auth.login.phonePlaceholder`. No locale key is added, so `Clause-②: no` holds. Reason: the server refuses an email address there (`EMAIL_PASSWORD_DISABLED`), so offering one is a way in that does not exist. Submit routing is unchanged: a phone-shaped entry goes to phone+password. - **Doors the server refuses stay hidden.** The sign-up link stays hidden while `emailPassword.enabled` is false, as before (it used to ride on the collapse). The host's password-form description ("Enter your email and password to continue") is not drawn either, matching the no-method state. The Console renders with `hideHeader`, so its card is unaffected by the description change. ## Tests `ui/packages/auth/src/__tests__/noSignInMethod-29159.test.tsx` holds the password-off config matrix, so the new cases go there as one top-level `describe` (no new file). Its client helper gained an `overrides` argument for the sign-in spies. - Password off + phone (both ways) + no SSO, the card's repro config: not collapsed; "Phone number", Password and "Sign in with verification code" on first paint; no "Use a password instead", no "Back to single sign-on", no SSO copy, no sign-up, no password-form description. - The same in the verification-code form (under `hideHeader`, as the Console renders it): no back button, and the switch back to phone+password is offered. - A phone number and password entered on first paint call `signInWithPhonePassword` and not `signIn`. - Password off + phone OTP only: the verification-code form on first paint, with no password field and no switch to a password form. - Password off + phone+password only: the form with no verification-code link and no back button. - CONTROL, password off + phone + SSO advertised: still collapsed with the SSO button; break-glass opens the form and "Back to single sign-on" is there. - CONTROL, declared enforcement + password off + phone: still collapsed; break-glass and back return to the collapsed view. - Unchanged cells already pinned in the same file and in `ssoEnforcedFirstPaint-29274.test.tsx`, `ssoEnforcedLoginCopy-12682.test.tsx` and `LoginForm.test.tsx`: password off + SSO on, SSO enforced, no method at all, failed config fetch. - One existing case changed: "with password off and phone sign-in on, is not the no-method state" clicked "Use a password instead" to reach the OTP link. That click path was the defect. It now asserts the OTP link on first paint. <details><summary>Readings (fix commit f0a0c9c on base d02c99e; head 3a40e3e after merging main f4b1f9e)</summary> - `pnpm exec vitest run packages/auth/src/__tests__/` from `ui/`, locked: base d02c99e 45 files / 398 tests passed; f0a0c9c 45 files / 405 tests passed; head 3a40e3e (after rebuilding the auth closure) 45 files / 405 tests passed. - Ablation, run through `scripts/ablation-replace.mjs` on the committed head: `passwordFormCollapsed` restored to `ssoEnforced || passwordSignInOff`, anchor 1 to 0, blob af50e01 to 7530ce6b. `noSignInMethod-29159.test.tsx` went to 6 failed / 10 passed: the five new phone-only cases plus the edited existing case. Both CONTROLs stayed green. Restored: blob equal to HEAD (af50e01) and `git diff HEAD` empty. - `pnpm exec turbo run type-check --filter=@object-ui/auth`: exit 0 (`tsc --noEmit && tsc -p tsconfig.test.json`), on f0a0c9c and again on 3a40e3e. - `pnpm exec eslint` on both touched source files, from `ui/`: exit 0. </details> ## Acceptance notes - With the password door shut and SSO offered, a server that offers phone OTP but not phone+password still hides the OTP way in behind the collapse: there is no password door, so no break-glass link is offered. The card leaves that cell unchanged by design ("password off + SSO on (unchanged collapse)"). Noted here, not fixed. - With the password door shut, no SSO, a phone way in and a social provider, the provider divider still reads the host's `orText` ("or continue with email" by default) above a phone form. Copy only. Noted, not fixed. - Live dogfood shot: not taken (optional per the brief). ## Verification The derived gate list (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, 36 commands, the same list on both heads) was run locally on f0a0c9c and again on head 3a40e3e: 36 of 36 exit 0 each time, and `--ran` reconciles 36 derived, 36 run, 0 NOT-MEASURED. That includes `node scripts/check-sdui-manifest.mjs` (exit 0) and `pnpm --dir ui exec vitest run scripts/` (170 files passed, 2 skipped). Per-command exit codes are in the os-dev report on the card. CI runs the rest. --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jTpDZ3S2UNafH1sonG)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 46ec2ec commit 8a83814

3 files changed

Lines changed: 217 additions & 25 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/auth': patch
3+
---
4+
5+
fix(auth): with email+password sign-in off, phone sign-in on and no single sign-on, the login card draws the phone way in on first paint instead of a single sign-on view
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`LoginForm` collapsed into the single sign-on view whenever the server turned email+password sign-in off (`emailPassword.enabled: false`), whether or not single sign-on existed. A deployment that signs in by phone only showed its users "Use a password instead" and, behind it, a "Back to single sign-on" button with no single sign-on anywhere. Now, with the password door shut, no single sign-on offered (`features.sso`) or enforced (`features.ssoEnforced`) and a phone way in advertised (`features.phoneNumber` or `features.phoneNumberOtp`), the form is drawn on first paint:
11+
12+
- the identifier field reads "Phone number" (the existing `phoneLabel` / `phonePlaceholder` labels), since the server refuses an email address;
13+
- with phone OTP the only way in, the form opens on the verification-code step and offers no switch to a password form the server refuses;
14+
- no "Back to single sign-on" button, no sign-up link and no password-form description, all of which name a door the server refuses.
15+
16+
A shut password door with single sign-on offered, declared enforcement, the no-method state and a failed config fetch (the password form, break-glass) are unchanged.

‎ui/packages/auth/src/LoginForm.tsx‎

Lines changed: 56 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -336,16 +336,30 @@ export function LoginForm({
336336
};
337337
}, [getAuthConfig]);
338338

339+
// A phone way in the server advertises: phone+password, or phone OTP.
340+
const phoneSignInOffered = phonePasswordEnabled || phoneOtpEnabled;
339341
// Under enforced mode, and when the server has turned the password door off,
340342
// the password form is collapsed until the user opts into the break-glass
341-
// path; otherwise it's always shown.
342-
const passwordFormCollapsed = ssoEnforced || passwordSignInOff;
343+
// path; otherwise it's always shown. One exception: the password door shut
344+
// with no single sign-on offered but a phone way in. The form then holds the
345+
// way in (phone+password, phone OTP), so it is drawn on first paint; a
346+
// collapsed view there would be a single sign-on view with no single
347+
// sign-on in it.
348+
const passwordFormCollapsed =
349+
ssoEnforced || (passwordSignInOff && (ssoEnabled || !phoneSignInOffered));
343350
const passwordFormVisible = !passwordFormCollapsed || showPasswordFallback;
344351
// The break-glass link reveals the password form, so it is offered only
345352
// while that form holds a password door the server accepts: email+password
346353
// itself, or phone+password sign-in. The SSO button needs no link: the
347354
// SSO-only view draws it on first paint (`ssoEmailFormVisible` below).
348355
const breakGlassOffered = !passwordSignInOff || phonePasswordEnabled;
356+
// The form's mode as drawn: with no password door the server accepts, the
357+
// only way the form can hold is phone OTP, so it opens there and offers no
358+
// switch back to a password form the server refuses.
359+
const formMode = breakGlassOffered ? mode : 'phone-otp';
360+
// The identifier field names only what the server accepts: with email
361+
// sign-in refused, a phone number is the one identifier left.
362+
const phoneOnlyIdentifier = passwordSignInOff && phonePasswordEnabled;
349363
// Nothing the server advertises lets anyone in: say so, instead of SSO copy
350364
// with no SSO affordance and a password link the server refuses.
351365
const noSignInMethod =
@@ -407,7 +421,7 @@ export function LoginForm({
407421
// that the server rejects as "Invalid email" (#3238) — trim before use.
408422
const identifier = email.trim();
409423
try {
410-
if (mode === 'phone-otp') {
424+
if (formMode === 'phone-otp') {
411425
await signInWithPhoneOtp(phone.trim(), otpCode.trim());
412426
} else if (phonePasswordEnabled && looksLikePhoneIdentifier(identifier)) {
413427
// Unified identifier: a phone-shaped entry routes to phone+password.
@@ -570,7 +584,10 @@ export function LoginForm({
570584
? ssoOnlyCopy
571585
? l.ssoOnlyDescription
572586
: undefined
573-
: description
587+
: passwordSignInOff
588+
? // The password-form copy names a door the server refuses.
589+
undefined
590+
: description
574591
}
575592
/>
576593
)}
@@ -607,7 +624,7 @@ export function LoginForm({
607624
/>
608625

609626
{passwordFormVisible ? (
610-
mode === 'phone-otp' ? (
627+
formMode === 'phone-otp' ? (
611628
/* Phone-OTP sign-in (framework#2780): request a code, then verify.
612629
Server-side the number is guarded by a per-number cooldown +
613630
hourly cap — the resend button mirrors it with a countdown. */
@@ -674,13 +691,15 @@ export function LoginForm({
674691
{isLoading ? l.submittingButton : l.submitButton}
675692
</button>
676693

677-
<button
678-
type="button"
679-
onClick={() => switchMode('password')}
680-
className={`w-full text-center text-xs text-muted-foreground underline-offset-4 transition-colors hover:text-primary hover:underline ${AUTH_TOUCH_TARGET_CLASS}`}
681-
>
682-
{l.usePasswordSignInText}
683-
</button>
694+
{breakGlassOffered && (
695+
<button
696+
type="button"
697+
onClick={() => switchMode('password')}
698+
className={`w-full text-center text-xs text-muted-foreground underline-offset-4 transition-colors hover:text-primary hover:underline ${AUTH_TOUCH_TARGET_CLASS}`}
699+
>
700+
{l.usePasswordSignInText}
701+
</button>
702+
)}
684703
</form>
685704
) : (
686705
<form onSubmit={handleSubmit} className="space-y-4">
@@ -691,15 +710,26 @@ export function LoginForm({
691710

692711
<div className="space-y-2">
693712
<label htmlFor="login-email" className={AUTH_FIELD_LABEL_CLASS}>
694-
{phonePasswordEnabled ? l.emailOrPhoneLabel : l.emailLabel}
713+
{phoneOnlyIdentifier
714+
? l.phoneLabel
715+
: phonePasswordEnabled
716+
? l.emailOrPhoneLabel
717+
: l.emailLabel}
695718
</label>
696719
<input
697720
id="login-email"
698721
// `text` (not `email`) when a phone number is also accepted, so the
699-
// browser doesn't reject a phone-shaped value on native validation.
700-
type={phonePasswordEnabled ? 'text' : 'email'}
701-
inputMode={phonePasswordEnabled ? 'text' : 'email'}
702-
placeholder={phonePasswordEnabled ? l.emailOrPhonePlaceholder : l.emailPlaceholder}
722+
// browser doesn't reject a phone-shaped value on native validation;
723+
// `tel` when it is the only identifier the server accepts.
724+
type={phoneOnlyIdentifier ? 'tel' : phonePasswordEnabled ? 'text' : 'email'}
725+
inputMode={phoneOnlyIdentifier ? 'tel' : phonePasswordEnabled ? 'text' : 'email'}
726+
placeholder={
727+
phoneOnlyIdentifier
728+
? l.phonePlaceholder
729+
: phonePasswordEnabled
730+
? l.emailOrPhonePlaceholder
731+
: l.emailPlaceholder
732+
}
703733
value={email}
704734
onChange={(e) => setEmail(e.target.value)}
705735
required
@@ -783,8 +813,12 @@ export function LoginForm({
783813
</button>
784814
)}
785815

786-
{/* Break-glass form was opened under enforced mode — let the user
787-
collapse back to the SSO-only view. */}
816+
{/* Break-glass form was opened from the collapsed view — let the
817+
user collapse back to it. The form is collapsed only where single
818+
sign-on is enforced or offered (a shut password door with a phone
819+
way in and no SSO draws the form on first paint), and the link
820+
that opens it is offered only with a password door, so there is
821+
always single sign-on to go back to. */}
788822
{passwordFormCollapsed && showPasswordFallback && (
789823
<button
790824
type="button"
@@ -854,7 +888,9 @@ export function LoginForm({
854888
)}
855889
</div>
856890

857-
{registerUrl && !passwordFormCollapsed && (
891+
{/* Sign-up is an email+password door too: not offered while the server
892+
refuses that door, whether or not the form is collapsed. */}
893+
{registerUrl && !passwordFormCollapsed && !passwordSignInOff && (
858894
<p className="px-8 text-center text-[13px] text-muted-foreground">
859895
{l.noAccountText}{' '}
860896
<LinkComp href={registerUrl} className={AUTH_LINK_CLASS}>

‎ui/packages/auth/src/__tests__/noSignInMethod-29159.test.tsx‎

Lines changed: 145 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ const SSO_ONLY_COPY = "Sign in with your organization's single sign-on";
3737
const BREAK_GLASS = { name: 'Use a password instead' } as const;
3838
const SSO_BUTTON = { name: 'Sign in with SSO' } as const;
3939
const PROVIDER_BUTTON = { name: /Continue with Acme IdP$/ } as const;
40+
const OTP_LINK = { name: 'Sign in with verification code' } as const;
41+
const PASSWORD_INSTEAD = { name: 'Sign in with password instead' } as const;
42+
const BACK_TO_SSO = { name: 'Back to single sign-on' } as const;
4043
const OIDC: AuthPublicConfig['socialProviders'] = [
4144
{ id: 'acme-idp', name: 'Acme IdP', enabled: true, type: 'oidc' },
4245
];
@@ -48,20 +51,25 @@ const PASSWORD_OFF_NOTHING_ELSE: AuthPublicConfig = {
4851
features: { sso: false, ssoEnforced: false },
4952
};
5053

51-
function client(getConfig: AuthClient['getConfig']): AuthClient {
54+
function client(getConfig: AuthClient['getConfig'], overrides: Partial<AuthClient> = {}): AuthClient {
5255
return {
5356
signIn: vi.fn(),
5457
getSession: vi.fn().mockResolvedValue(null),
5558
getConfig,
5659
signInWithProvider: vi.fn().mockResolvedValue(undefined),
60+
...overrides,
5761
} as unknown as AuthClient;
5862
}
5963

60-
function renderLogin(config: AuthPublicConfig | Error, props: LoginFormProps = {}) {
64+
function renderLogin(
65+
config: AuthPublicConfig | Error,
66+
props: LoginFormProps = {},
67+
overrides: Partial<AuthClient> = {},
68+
) {
6169
const getConfig =
6270
config instanceof Error ? vi.fn().mockRejectedValue(config) : vi.fn().mockResolvedValue(config);
6371
return render(
64-
<AuthProvider authUrl="/api/auth" client={client(getConfig)}>
72+
<AuthProvider authUrl="/api/auth" client={client(getConfig, overrides)}>
6573
<LoginForm registerUrl="/register" {...props} />
6674
</AuthProvider>,
6775
);
@@ -172,8 +180,8 @@ describe('LoginForm — doors the server does advertise stay usable', () => {
172180

173181
expect(screen.queryByTestId('login-no-sign-in-method')).toBeNull();
174182
expect(screen.queryByText(SSO_ONLY_COPY)).toBeNull();
175-
fireEvent.click(screen.getByRole('button', BREAK_GLASS));
176-
expect(await screen.findByRole('button', { name: 'Sign in with verification code' })).toBeTruthy();
183+
// The phone ways in are on the first paint; the full matrix is below.
184+
expect(screen.getByRole('button', OTP_LINK)).toBeTruthy();
177185
});
178186

179187
it('falls back to the password form when the config fetch fails (break-glass beats lock-out)', async () => {
@@ -195,3 +203,135 @@ describe('LoginForm — doors the server does advertise stay usable', () => {
195203
expect(screen.queryByTestId('login-no-sign-in-method')).toBeNull();
196204
});
197205
});
206+
207+
/**
208+
* The password door shut, no single sign-on offered or enforced, and a phone
209+
* way in advertised: the card is not collapsed into the single sign-on view.
210+
* The form holding the phone way in is the first paint, and no "Back to single
211+
* sign-on" button is drawn, since there is no single sign-on to go back to.
212+
* Cells with single sign-on keep their collapse and their back button.
213+
*/
214+
describe('LoginForm — password off, phone sign-in on, no single sign-on', () => {
215+
/** The QA repro: `email_password_enabled` false on a boot with phone sign-in. */
216+
const PHONE_ONLY: AuthPublicConfig = {
217+
socialProviders: [],
218+
emailPassword: { enabled: false },
219+
features: { sso: false, ssoEnforced: false, phoneNumber: true, phoneNumberOtp: true },
220+
};
221+
222+
it('draws the phone+password form and the verification-code way in on first paint, with no SSO view', async () => {
223+
renderLogin(PHONE_ONLY);
224+
await configResolved();
225+
226+
// The identifier the server still accepts is a phone number; email is refused.
227+
expect(screen.getByLabelText('Phone number')).toBeTruthy();
228+
expect(screen.queryByLabelText('Email or phone number')).toBeNull();
229+
expect(screen.getByLabelText('Password')).toBeTruthy();
230+
expect(screen.getByRole('button', OTP_LINK)).toBeTruthy();
231+
232+
expect(screen.queryByRole('button', BREAK_GLASS)).toBeNull();
233+
expect(screen.queryByRole('button', BACK_TO_SSO)).toBeNull();
234+
expect(screen.queryByText(SSO_ONLY_COPY)).toBeNull();
235+
expect(screen.queryByTestId('login-no-sign-in-method')).toBeNull();
236+
// Sign-up and the password-form copy are email+password doors the server refuses.
237+
expect(screen.queryByText('Sign up')).toBeNull();
238+
expect(screen.queryByText(/Enter your email and password/)).toBeNull();
239+
});
240+
241+
it('draws no back-to-SSO button in the verification-code form either, and switches back', async () => {
242+
renderLogin(PHONE_ONLY, { hideHeader: true });
243+
await configResolved();
244+
245+
fireEvent.click(screen.getByRole('button', OTP_LINK));
246+
expect(await screen.findByLabelText('Verification code')).toBeTruthy();
247+
expect(screen.queryByRole('button', BACK_TO_SSO)).toBeNull();
248+
expect(screen.queryByText(SSO_ONLY_COPY)).toBeNull();
249+
250+
// Phone+password is a door the server accepts, so the switch back is offered.
251+
fireEvent.click(screen.getByRole('button', PASSWORD_INSTEAD));
252+
expect(await screen.findByLabelText('Password')).toBeTruthy();
253+
expect(screen.queryByRole('button', BACK_TO_SSO)).toBeNull();
254+
});
255+
256+
it('signs a phone number in with its password from the first paint', async () => {
257+
const signIn = vi.fn();
258+
const signInWithPhonePassword = vi
259+
.fn()
260+
.mockResolvedValue({ user: { id: 'u2' }, session: { token: 'pw-tok' } });
261+
renderLogin(PHONE_ONLY, {}, { signIn, signInWithPhonePassword });
262+
await configResolved();
263+
264+
fireEvent.change(screen.getByLabelText('Phone number'), { target: { value: '13800138000' } });
265+
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'S3cret!' } });
266+
fireEvent.click(screen.getByRole('button', { name: 'Sign In' }));
267+
268+
await waitFor(() => expect(signInWithPhonePassword).toHaveBeenCalledWith('13800138000', 'S3cret!'));
269+
expect(signIn).not.toHaveBeenCalled();
270+
});
271+
272+
it('with phone OTP the only way in, opens on the verification-code form with no switch to a refused password form', async () => {
273+
renderLogin({
274+
socialProviders: [],
275+
emailPassword: { enabled: false },
276+
features: { sso: false, ssoEnforced: false, phoneNumber: false, phoneNumberOtp: true },
277+
});
278+
await configResolved();
279+
280+
expect(screen.getByLabelText('Phone number')).toBeTruthy();
281+
expect(screen.getByLabelText('Verification code')).toBeTruthy();
282+
expect(screen.getByRole('button', { name: 'Get code' })).toBeTruthy();
283+
expect(screen.queryByLabelText('Password')).toBeNull();
284+
expect(screen.queryByRole('button', PASSWORD_INSTEAD)).toBeNull();
285+
expect(screen.queryByRole('button', BREAK_GLASS)).toBeNull();
286+
expect(screen.queryByRole('button', BACK_TO_SSO)).toBeNull();
287+
});
288+
289+
it('with phone+password the only way in, draws it with no verification-code link', async () => {
290+
renderLogin({
291+
socialProviders: [],
292+
emailPassword: { enabled: false },
293+
features: { sso: false, ssoEnforced: false, phoneNumber: true, phoneNumberOtp: false },
294+
});
295+
await configResolved();
296+
297+
expect(screen.getByLabelText('Phone number')).toBeTruthy();
298+
expect(screen.getByLabelText('Password')).toBeTruthy();
299+
expect(screen.queryByRole('button', OTP_LINK)).toBeNull();
300+
expect(screen.queryByRole('button', BREAK_GLASS)).toBeNull();
301+
expect(screen.queryByRole('button', BACK_TO_SSO)).toBeNull();
302+
});
303+
304+
it('CONTROL: with SSO advertised as well, the card stays collapsed and the back button returns', async () => {
305+
renderLogin({ ...PHONE_ONLY, features: { ...PHONE_ONLY.features, sso: true } });
306+
await configResolved();
307+
308+
expect(screen.getByText(SSO_ONLY_COPY)).toBeTruthy();
309+
expect(screen.getByRole('button', SSO_BUTTON)).toBeTruthy();
310+
expect(screen.queryByLabelText('Password')).toBeNull();
311+
312+
fireEvent.click(screen.getByRole('button', BREAK_GLASS));
313+
expect(await screen.findByLabelText('Password')).toBeTruthy();
314+
expect(screen.getByRole('button', BACK_TO_SSO)).toBeTruthy();
315+
});
316+
317+
it('CONTROL: under declared enforcement, the card stays collapsed and the back button returns', async () => {
318+
renderLogin(
319+
{
320+
...PHONE_ONLY,
321+
socialProviders: OIDC,
322+
features: { ...PHONE_ONLY.features, ssoEnforced: true },
323+
},
324+
{ hideHeader: true },
325+
);
326+
await configResolved();
327+
328+
expect(screen.getByText(SSO_ONLY_COPY)).toBeTruthy();
329+
expect(await screen.findByRole('button', PROVIDER_BUTTON)).toBeTruthy();
330+
expect(screen.queryByLabelText('Password')).toBeNull();
331+
332+
fireEvent.click(screen.getByRole('button', BREAK_GLASS));
333+
expect(await screen.findByLabelText('Password')).toBeTruthy();
334+
fireEvent.click(screen.getByRole('button', BACK_TO_SSO));
335+
await waitFor(() => expect(screen.queryByLabelText('Password')).toBeNull());
336+
});
337+
});

0 commit comments

Comments
 (0)