diff --git a/backend/chainlit/translations/en-US.json b/backend/chainlit/translations/en-US.json index 5a62b3c254..3be562035f 100644 --- a/backend/chainlit/translations/en-US.json +++ b/backend/chainlit/translations/en-US.json @@ -21,9 +21,9 @@ "title": "Login to access the app", "form": { "email": { - "label": "Email address", - "required": "email is a required field", - "placeholder": "me@example.com" + "label": "Username or email", + "required": "username or email is a required field", + "placeholder": "your_username" }, "password": { "label": "Password", diff --git a/cypress/e2e/chat_profiles/spec.cy.ts b/cypress/e2e/chat_profiles/spec.cy.ts index 0972c1baba..7669e57247 100644 --- a/cypress/e2e/chat_profiles/spec.cy.ts +++ b/cypress/e2e/chat_profiles/spec.cy.ts @@ -3,7 +3,7 @@ import { submitMessage } from '../../support/testUtils'; describe('Chat profiles', () => { it('should be able to select a chat profile', () => { cy.visit('/'); - cy.get("input[name='email']").type('admin'); + cy.get("input[name='username']").type('admin'); cy.get("input[name='password']").type('admin'); cy.get("button[type='submit']").click(); cy.get('#chat-input').should('exist'); @@ -67,7 +67,7 @@ describe('Chat profiles', () => { it('should keep chat profile description visible when hovering over a link', () => { cy.visit('/'); - cy.get("input[name='email']").type('admin'); + cy.get("input[name='username']").type('admin'); cy.get("input[name='password']").type('admin'); cy.get("button[type='submit']").click(); cy.get('#chat-input').should('exist'); diff --git a/cypress/e2e/config_overrides/spec.cy.ts b/cypress/e2e/config_overrides/spec.cy.ts index 92085fa7bf..8b3a33efff 100644 --- a/cypress/e2e/config_overrides/spec.cy.ts +++ b/cypress/e2e/config_overrides/spec.cy.ts @@ -3,7 +3,7 @@ import { submitMessage } from '../../support/testUtils'; describe('Config overrides with chat profiles', () => { it('should be able to select a chat profile and test MCP button visibility', () => { cy.visit('/'); - cy.get("input[name='email']").type('admin'); + cy.get("input[name='username']").type('admin'); cy.get("input[name='password']").type('admin'); cy.get("button[type='submit']").click(); @@ -86,7 +86,7 @@ describe('Config overrides with chat profiles', () => { it('should keep chat profile description visible when hovering over a link', () => { cy.visit('/'); - cy.get("input[name='email']").type('admin'); + cy.get("input[name='username']").type('admin'); cy.get("input[name='password']").type('admin'); cy.get("button[type='submit']").click(); diff --git a/cypress/e2e/custom_element_auth/spec.cy.ts b/cypress/e2e/custom_element_auth/spec.cy.ts index f8c50cf660..26d2202fb4 100644 --- a/cypress/e2e/custom_element_auth/spec.cy.ts +++ b/cypress/e2e/custom_element_auth/spec.cy.ts @@ -12,7 +12,7 @@ describe('Custom Element Auth', () => { cy.intercept('POST', '/login').as('login'); cy.intercept('POST', '/set-session-cookie').as('setSession'); - cy.get('input[name="email"]').type('admin'); + cy.get('input[name="username"]').type('admin'); cy.get('input[name="password"]').type('admin'); cy.get('button[type="submit"]').click(); diff --git a/cypress/e2e/data_layer/spec.cy.ts b/cypress/e2e/data_layer/spec.cy.ts index 8dc5638e13..cccf7dfeed 100644 --- a/cypress/e2e/data_layer/spec.cy.ts +++ b/cypress/e2e/data_layer/spec.cy.ts @@ -5,7 +5,7 @@ import { setupWebSocketListener, submitMessage } from '../../support/testUtils'; // Constants const SELECTORS = { - EMAIL_INPUT: '#email', + USERNAME_INPUT: '#username', PASSWORD_INPUT: '#password', AI_MESSAGE: "[data-step-type='assistant_message']", CHAT_SUBMIT: '#chat-submit', @@ -32,7 +32,7 @@ const login = (username: string = 'user1', password: string = 'user1') => { cy.location('pathname').should('eq', '/login'); - cy.get(SELECTORS.EMAIL_INPUT).should('be.visible').type(username); + cy.get(SELECTORS.USERNAME_INPUT).should('be.visible').type(username); cy.get(SELECTORS.PASSWORD_INPUT) .should('be.visible') .type(`${password}{enter}`); diff --git a/cypress/e2e/password_auth/spec.cy.ts b/cypress/e2e/password_auth/spec.cy.ts index 9728293fd0..cb1e3e9b3e 100644 --- a/cypress/e2e/password_auth/spec.cy.ts +++ b/cypress/e2e/password_auth/spec.cy.ts @@ -14,7 +14,7 @@ describe('Password Auth', () => { it('should redirect to login dialog', () => { cy.location('pathname').should('eq', '/login'); - cy.get("input[name='email']").should('exist'); + cy.get("input[name='username']").should('exist'); cy.get("input[name='password']").should('exist'); }); }); @@ -26,7 +26,7 @@ describe('Password Auth', () => { describe('submitting incorrect credentials', () => { it('should fail to login with wrong credentials', () => { - cy.get("input[name='email']").type('user'); + cy.get("input[name='username']").type('user'); cy.get("input[name='password']").type('user'); cy.get("button[type='submit']").click(); cy.get('body').should('contain', 'Unauthorized'); @@ -35,7 +35,7 @@ describe('Password Auth', () => { describe('submitting correct credentials', () => { beforeEach(() => { - cy.get("input[name='email']").type('admin'); + cy.get("input[name='username']").type('admin'); cy.get("input[name='password']").type('admin'); cy.intercept('POST', '/login').as('login'); @@ -68,7 +68,7 @@ describe('Password Auth', () => { }); it('should not contain a login form', () => { - cy.get("input[name='email']").should('not.exist'); + cy.get("input[name='username']").should('not.exist'); cy.get("input[name='password']").should('not.exist'); }); diff --git a/cypress/e2e/thread_resume/spec.cy.ts b/cypress/e2e/thread_resume/spec.cy.ts index d8e63b613a..b2ff58e9e3 100644 --- a/cypress/e2e/thread_resume/spec.cy.ts +++ b/cypress/e2e/thread_resume/spec.cy.ts @@ -4,8 +4,8 @@ const login = (user: 'alice' | 'bob') => { cy.visit('/'); cy.location('pathname').should('eq', '/login'); - cy.get("input[name='email']").clear(); - cy.get("input[name='email']").type(user); + cy.get("input[name='username']").clear(); + cy.get("input[name='username']").type(user); cy.get("input[name='password']").clear(); cy.get("input[name='password']").type(user === 'alice' ? 'a' : 'b'); diff --git a/frontend/src/components/LoginForm.tsx b/frontend/src/components/LoginForm.tsx index 4d3c9bfc08..6bebdc7e74 100644 --- a/frontend/src/components/LoginForm.tsx +++ b/frontend/src/components/LoginForm.tsx @@ -18,7 +18,7 @@ interface Props { providers: string[]; callbackUrl: string; onPasswordSignIn?: ( - email: string, + username: string, password: string, callbackUrl: string ) => Promise; @@ -26,7 +26,7 @@ interface Props { } interface FormValues { - email: string; + username: string; password: string; } @@ -46,10 +46,10 @@ export function LoginForm({ const { register, handleSubmit, - formState: { errors, touchedFields } + formState: { errors, isSubmitted, touchedFields } } = useForm({ defaultValues: { - email: '', + username: '', password: '' } }); @@ -63,7 +63,7 @@ export function LoginForm({ setLoading(true); try { - await onPasswordSignIn(data.email, data.password, callbackUrl); + await onPasswordSignIn(data.username, data.password, callbackUrl); } catch (err) { if (err instanceof ClientError && err.detail) { setErrorState(err.detail); @@ -101,24 +101,27 @@ export function LoginForm({ {onPasswordSignIn && ( <>
-
@@ -138,7 +141,7 @@ export function LoginForm({ required: t('auth.login.form.password.required') })} className={cn( - touchedFields.password && + (touchedFields.password || isSubmitted) && errors.password && 'border-destructive' )} @@ -157,7 +160,7 @@ export function LoginForm({ )} - {touchedFields.password && errors.password && ( + {(touchedFields.password || isSubmitted) && errors.password && (

{errors.password.message}

diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 9bfae649f2..c39db0e607 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -53,9 +53,9 @@ export default function Login() { await handleAuth(jsonPromise, '/'); }; - const handlePasswordLogin = async (email: string, password: string) => { + const handlePasswordLogin = async (username: string, password: string) => { const formData = new FormData(); - formData.append('username', email); + formData.append('username', username); formData.append('password', password); const jsonPromise = apiClient.passwordAuth(formData); diff --git a/frontend/tests/LoginForm.spec.tsx b/frontend/tests/LoginForm.spec.tsx new file mode 100644 index 0000000000..d76e0f6b63 --- /dev/null +++ b/frontend/tests/LoginForm.spec.tsx @@ -0,0 +1,131 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { LoginForm } from '@/components/LoginForm'; + +vi.mock('@chainlit/react-client', () => ({ + ClientError: class ClientError extends Error { + detail?: string; + + constructor(detail?: string) { + super(detail); + this.detail = detail; + } + } +})); + +vi.mock('@/components/i18n/Translator', () => ({ + __esModule: true, + default: ({ path }: { path: string }) => { + const translations: Record = { + 'auth.login.title': 'Sign in', + 'auth.login.form.email.label': 'Username or email', + 'auth.login.form.email.placeholder': 'your_username', + 'auth.login.form.email.required': 'username or email is a required field', + 'auth.login.form.password.label': 'Password', + 'auth.login.form.password.required': 'password is a required field', + 'auth.login.form.actions.signin': 'Sign In', + 'auth.login.form.alternativeText.or': 'OR' + }; + + return {translations[path] || path}; + }, + useTranslation: () => ({ + t: (key: string | string[]) => { + const translations: Record = { + 'auth.login.form.email.placeholder': 'your_username', + 'auth.login.form.email.required': + 'username or email is a required field', + 'auth.login.form.password.required': 'password is a required field', + 'auth.login.errors.default': 'Something went wrong' + }; + + if (Array.isArray(key)) { + return translations[key[0]] || translations[key[1]] || key[0]; + } + + return translations[key] || key; + } + }) +})); + +describe('LoginForm', () => { + it('renders a username-or-email field', () => { + render( + + ); + + const input = screen.getByLabelText('Username or email'); + + expect(input).toHaveAttribute('id', 'username'); + expect(input).toHaveAttribute('autocomplete', 'username'); + expect(input).toHaveAttribute('placeholder', 'your_username'); + }); + + it('submits the username value to password sign-in', async () => { + const onPasswordSignIn = vi.fn().mockResolvedValue(undefined); + + render( + + ); + + fireEvent.change(screen.getByLabelText('Username or email'), { + target: { value: 'plain-username' } + }); + fireEvent.change(screen.getByLabelText('Password'), { + target: { value: 'secret' } + }); + fireEvent.click(screen.getByRole('button', { name: 'Sign In' })); + + await waitFor(() => { + expect(onPasswordSignIn).toHaveBeenCalledWith( + 'plain-username', + 'secret', + '/callback' + ); + }); + }); + + it('shows required-field errors when submitted empty', async () => { + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Sign In' })); + + expect( + await screen.findByText('username or email is a required field') + ).toBeInTheDocument(); + expect( + await screen.findByText('password is a required field') + ).toBeInTheDocument(); + }); + + it('shows an error when password sign-in fails', async () => { + const onPasswordSignIn = vi + .fn() + .mockRejectedValue(new Error('Sign-in failed')); + + render( + + ); + + fireEvent.change(screen.getByLabelText('Username or email'), { + target: { value: 'plain-username' } + }); + fireEvent.change(screen.getByLabelText('Password'), { + target: { value: 'secret' } + }); + fireEvent.click(screen.getByRole('button', { name: 'Sign In' })); + + expect(await screen.findByText('Something went wrong')).toBeInTheDocument(); + }); +});