From 050fc0d5999916a13b65eaa8a3abff29a1152036 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:01:13 +0000 Subject: [PATCH 1/2] fix(auth): LoginForm's registerUrl has no default, so an absent URL renders no sign-up link (objectui#11634) Both console login pages pass `registerUrl={undefined}` when the server reports `emailPassword.disableSignUp`. LoginForm destructured `registerUrl = '/register'`, so that undefined brought the default back and a sign-up-disabled deployment still showed "Don't have an account? Sign up". The default is dropped: the link renders only for a passed URL. The prop's type is unchanged and no second "off" value is added (triage ruling). Tests: LoginForm pins absent/undefined -> no link and a passed URL -> that href; the enforced-mode pin now passes registerUrl so it still measures the enforced guard; a console LoginPage pin covers disableSignUp true/false. Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude --- .../LoginPage.sign-up-gate-11634.test.tsx | 73 +++++++++++++++++++ packages/auth/src/LoginForm.tsx | 9 ++- .../auth/src/__tests__/LoginForm.test.tsx | 41 ++++++++++- 3 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 apps/console/src/pages/auth/__tests__/LoginPage.sign-up-gate-11634.test.tsx diff --git a/apps/console/src/pages/auth/__tests__/LoginPage.sign-up-gate-11634.test.tsx b/apps/console/src/pages/auth/__tests__/LoginPage.sign-up-gate-11634.test.tsx new file mode 100644 index 0000000000..7af96ba552 --- /dev/null +++ b/apps/console/src/pages/auth/__tests__/LoginPage.sign-up-gate-11634.test.tsx @@ -0,0 +1,73 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * LoginPage — a sign-up-disabled deployment offers no "Sign up" link + * (objectui#11634). + * + * The page reads `/auth/config` and, when the server reports + * `emailPassword.disableSignUp: true`, hands `registerUrl={undefined}` to + * ``. The form used to default `registerUrl` to `'/register'`, so + * that `undefined` brought the link straight back. The form no longer has a + * default; this pins the page-level outcome. + * + * The dev-seeded admin hint is set by the SAME config read that sets the + * sign-up gate, so its appearance is the proof that the config was applied + * before the link is judged — the same signal the card's reproduction used. + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { AuthProvider } from '@object-ui/auth'; +import type { AuthClient } from '@object-ui/auth'; +import { LoginPage } from '../LoginPage'; + +afterEach(cleanup); +beforeEach(() => { + window.localStorage.clear(); +}); + +function createMockClient(config: Record): AuthClient { + return { + getSession: vi.fn().mockResolvedValue(null), + getConfig: vi.fn().mockResolvedValue(config), + } as unknown as AuthClient; +} + +function renderLogin(config: Record) { + window.history.replaceState({}, '', '/login'); + return render( + + + + + , + ); +} + +const DEV_SEED = { devSeedAdmin: { email: 'admin@objectos.ai', password: 'admin123' } }; +const SIGN_UP_LINK = { name: 'Sign up' } as const; + +describe('LoginPage — the sign-up link follows emailPassword.disableSignUp (objectui#11634)', () => { + it('renders no sign-up link when the server reports disableSignUp: true', async () => { + renderLogin({ ...DEV_SEED, emailPassword: { enabled: true, disableSignUp: true } }); + + await screen.findByTestId('dev-admin-hint'); + await screen.findByLabelText('Email'); + expect(screen.queryByRole('link', SIGN_UP_LINK)).toBeNull(); + }); + + it('keeps the sign-up link when the server reports disableSignUp: false', async () => { + renderLogin({ ...DEV_SEED, emailPassword: { enabled: true, disableSignUp: false } }); + + await screen.findByTestId('dev-admin-hint'); + await screen.findByLabelText('Email'); + expect(screen.getByRole('link', SIGN_UP_LINK).getAttribute('href')).toBe('/register'); + }); +}); diff --git a/packages/auth/src/LoginForm.tsx b/packages/auth/src/LoginForm.tsx index e80d504162..c62cd03ca3 100644 --- a/packages/auth/src/LoginForm.tsx +++ b/packages/auth/src/LoginForm.tsx @@ -82,7 +82,12 @@ export interface LoginFormProps { onSuccess?: () => void; /** Callback on login error */ onError?: (error: Error) => void; - /** Link to registration page */ + /** + * Link to registration page. The "Sign up" link renders only when this is + * set: there is no default, so leaving it out (or passing `undefined`, as a + * caller does when the server reports `emailPassword.disableSignUp`) renders + * no link. + */ registerUrl?: string; /** Link to forgot password page */ forgotPasswordUrl?: string; @@ -144,7 +149,7 @@ const DefaultLockIcon = () => ( export function LoginForm({ onSuccess, onError, - registerUrl = '/register', + registerUrl, forgotPasswordUrl = '/forgot-password', title = 'Sign in to your account', description = 'Enter your email and password to continue', diff --git a/packages/auth/src/__tests__/LoginForm.test.tsx b/packages/auth/src/__tests__/LoginForm.test.tsx index 247c9518b4..0b438810d0 100644 --- a/packages/auth/src/__tests__/LoginForm.test.tsx +++ b/packages/auth/src/__tests__/LoginForm.test.tsx @@ -11,7 +11,7 @@ import React from 'react'; import { describe, it, expect, vi } from 'vitest'; import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import { AuthProvider } from '../AuthProvider'; -import { LoginForm } from '../LoginForm'; +import { LoginForm, type LoginFormProps } from '../LoginForm'; import type { AuthClient, AuthPublicConfig } from '../types'; const SSO_BUTTON = { name: 'Sign in with SSO' } as const; @@ -36,10 +36,10 @@ function createMockClient( } as unknown as AuthClient; } -function renderLogin(client: AuthClient) { +function renderLogin(client: AuthClient, props: LoginFormProps = {}) { return render( - + , ); } @@ -79,11 +79,44 @@ describe('LoginForm — server-gated SSO button', () => { }); }); +// objectui#11634 — `registerUrl` has no default. Both console login pages pass +// `undefined` when the server reports `emailPassword.disableSignUp`; a +// `'/register'` default brought the link back on exactly that value, so a +// sign-up-disabled deployment still offered "Sign up". +describe('LoginForm — the sign-up link renders only for a passed registerUrl (objectui#11634)', () => { + const SIGN_UP_LINK = { name: 'Sign up' } as const; + + it.each([ + ['left out', {}], + ['passed as undefined', { registerUrl: undefined }], + ] as const)('renders no sign-up link when registerUrl is %s', async (_shape, props) => { + renderLogin(createMockClient({}), props); + + // The config gate has resolved and the form is painted… + await screen.findByLabelText('Email'); + // …and there is no "Don't have an account? Sign up" row. + expect(screen.queryByRole('link', SIGN_UP_LINK)).toBeNull(); + expect(screen.queryByText("Don't have an account?")).toBeNull(); + }); + + it('links to the passed registerUrl', async () => { + renderLogin(createMockClient({}), { registerUrl: '/x' }); + + await screen.findByLabelText('Email'); + expect(screen.getByText("Don't have an account?")).toBeTruthy(); + expect(screen.getByRole('link', SIGN_UP_LINK).getAttribute('href')).toBe('/x'); + }); +}); + describe('LoginForm — SSO-only (enforced) mode', () => { const BREAK_GLASS = { name: 'Use a password instead' } as const; it('hides the password form + sign-up and shows a break-glass link when features.ssoEnforced', async () => { - renderLogin(createMockClient({ features: { sso: true, ssoEnforced: true } })); + // `registerUrl` is passed so the sign-up assertion below measures the + // enforced-mode guard: without it no link renders at all (objectui#11634). + renderLogin(createMockClient({ features: { sso: true, ssoEnforced: true } }), { + registerUrl: '/register', + }); // The break-glass link appears (federated buttons are the path)… await screen.findByRole('button', BREAK_GLASS); From 189504446a57700b96ddd585e223db2cd90e4229 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:22:47 +0000 Subject: [PATCH 2/2] chore(changeset): @object-ui/auth minor for LoginForm's registerUrl losing its '/register' default (objectui#11634) States the behaviour change for a caller that left `registerUrl` out and relied on the default, and what it now passes to keep the link. Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude --- .changeset/11634-loginform-register-default.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/11634-loginform-register-default.md diff --git a/.changeset/11634-loginform-register-default.md b/.changeset/11634-loginform-register-default.md new file mode 100644 index 0000000000..99f9c50bb2 --- /dev/null +++ b/.changeset/11634-loginform-register-default.md @@ -0,0 +1,9 @@ +--- +'@object-ui/auth': minor +--- + +`LoginForm` shows its "Don't have an account? Sign up" row only when the caller passes `registerUrl` (objectui#11634). The prop used to default to `'/register'`, so a caller that passed `undefined` to withhold the link got it back. Both console login pages pass `undefined` when the server's `/auth/config` reports `emailPassword.disableSignUp: true`, so a deployment with sign-up turned off still offered a sign-up link. The server refused the sign-up and `/register` sent the user back to the login page. + +**Behaviour change.** A `LoginForm` rendered without `registerUrl` no longer shows a sign-up link. A caller that left the prop out and relied on the `'/register'` default now passes the URL itself, `registerUrl="/register"`, to keep the link. The console app's login page and `@object-ui/app-shell`'s `DefaultLoginPage` already pass the URL whenever sign-up is on, so neither changes. Leaving the prop out, or passing `undefined`, is the one way to render no link: no second "off" value is added. + +**Clause-②: no.** The prop's type is unchanged (`registerUrl?: string`). No export, prop, type member or i18n key is added or removed.