Skip to content
Open
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
6 changes: 3 additions & 3 deletions backend/chainlit/translations/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions cypress/e2e/chat_profiles/spec.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand Down
4 changes: 2 additions & 2 deletions cypress/e2e/config_overrides/spec.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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();

Expand Down
2 changes: 1 addition & 1 deletion cypress/e2e/custom_element_auth/spec.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
4 changes: 2 additions & 2 deletions cypress/e2e/data_layer/spec.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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}`);
Expand Down
8 changes: 4 additions & 4 deletions cypress/e2e/password_auth/spec.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
Expand All @@ -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');
Expand All @@ -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');
Expand Down Expand Up @@ -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');
});

Expand Down
4 changes: 2 additions & 2 deletions cypress/e2e/thread_resume/spec.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
29 changes: 16 additions & 13 deletions frontend/src/components/LoginForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@ interface Props {
providers: string[];
callbackUrl: string;
onPasswordSignIn?: (
email: string,
username: string,
password: string,
callbackUrl: string
) => Promise<any>;
onOAuthSignIn?: (provider: string, callbackUrl: string) => Promise<any>;
}

interface FormValues {
email: string;
username: string;
password: string;
}

Expand All @@ -46,10 +46,10 @@ export function LoginForm({
const {
register,
handleSubmit,
formState: { errors, touchedFields }
formState: { errors, isSubmitted, touchedFields }
} = useForm<FormValues>({
defaultValues: {
email: '',
username: '',
password: ''
}
});
Expand All @@ -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);
Expand Down Expand Up @@ -101,24 +101,27 @@ export function LoginForm({
{onPasswordSignIn && (
<>
<div className="grid gap-2">
<Label htmlFor="email">
<Label htmlFor="username">
<Translator path="auth.login.form.email.label" />
</Label>
<Input
id="email"
id="username"
disabled={loading}
autoFocus
placeholder={t('auth.login.form.email.placeholder')}
{...register('email', {
autoComplete="username"
{...register('username', {
required: t('auth.login.form.email.required')
})}
className={cn(
touchedFields.email && errors.email && 'border-destructive'
(touchedFields.username || isSubmitted) &&
errors.username &&
'border-destructive'
)}
/>
{touchedFields.email && errors.email && (
{(touchedFields.username || isSubmitted) && errors.username && (
<p className="text-sm text-destructive">
{errors.email.message}
{errors.username.message}
</p>
)}
</div>
Expand All @@ -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'
)}
Expand All @@ -157,7 +160,7 @@ export function LoginForm({
)}
</Button>
</div>
{touchedFields.password && errors.password && (
{(touchedFields.password || isSubmitted) && errors.password && (
<p className="text-sm text-destructive">
{errors.password.message}
</p>
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/pages/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
131 changes: 131 additions & 0 deletions frontend/tests/LoginForm.spec.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
'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 <span>{translations[path] || path}</span>;
},
useTranslation: () => ({
t: (key: string | string[]) => {
const translations: Record<string, string> = {
'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', () => {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
render(
<LoginForm callbackUrl="/" providers={[]} onPasswordSignIn={vi.fn()} />
);

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 () => {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
const onPasswordSignIn = vi.fn().mockResolvedValue(undefined);

render(
<LoginForm
callbackUrl="/callback"
providers={[]}
onPasswordSignIn={onPasswordSignIn}
/>
);

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(
<LoginForm callbackUrl="/" providers={[]} onPasswordSignIn={vi.fn()} />
);

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(
<LoginForm
callbackUrl="/"
providers={[]}
onPasswordSignIn={onPasswordSignIn}
/>
);

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();
});
});