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
9 changes: 9 additions & 0 deletions .changeset/11634-loginform-register-default.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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
* `<LoginForm>`. 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<string, unknown>): AuthClient {
return {
getSession: vi.fn().mockResolvedValue(null),
getConfig: vi.fn().mockResolvedValue(config),
} as unknown as AuthClient;
}

function renderLogin(config: Record<string, unknown>) {
window.history.replaceState({}, '', '/login');
return render(
<AuthProvider authUrl="/api/v1/auth" client={createMockClient(config)}>
<MemoryRouter initialEntries={['/login']}>
<LoginPage />
</MemoryRouter>
</AuthProvider>,
);
}

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');
});
});
9 changes: 7 additions & 2 deletions packages/auth/src/LoginForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand Down
41 changes: 37 additions & 4 deletions packages/auth/src/__tests__/LoginForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -36,10 +36,10 @@ function createMockClient(
} as unknown as AuthClient;
}

function renderLogin(client: AuthClient) {
function renderLogin(client: AuthClient, props: LoginFormProps = {}) {
return render(
<AuthProvider authUrl="/api/auth" client={client}>
<LoginForm />
<LoginForm {...props} />
</AuthProvider>,
);
}
Expand Down Expand Up @@ -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);
Expand Down
Loading