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
2 changes: 1 addition & 1 deletion e2e/auth.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ setup('authenticate as admin', async ({ page }) => {

await page.locator('input:not([type="password"])').first().fill(email);
await page.locator('input[type="password"]').first().fill(password);
await page.getByRole('button', { name: 'Sign In', exact: true }).click();
await page.getByRole('button', { name: 'Sign in', exact: true }).click();

const errorBanner = page.locator('[role="alert"]');
await expect(errorBanner).not.toBeVisible({ timeout: 5_000 });
Expand Down
34 changes: 23 additions & 11 deletions e2e/login.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ test.describe('Login page accessibility', () => {
});

test('has zero violations after showing validation errors', async ({ page }) => {
const signInButton = page.getByRole('button', { name: 'Sign In', exact: true });
const signInButton = page.getByRole('button', { name: 'Sign in', exact: true });
await signInButton.click();

await expect(page.getByText(/required/i).first()).toBeVisible({ timeout: 5000 });
Expand All @@ -34,7 +34,7 @@ test.describe('Login page accessibility', () => {
});

test('validation errors are announced via live region', async ({ page }) => {
const signInButton = page.getByRole('button', { name: 'Sign In', exact: true });
const signInButton = page.getByRole('button', { name: 'Sign in', exact: true });
await signInButton.click();

const liveRegion = page.locator('.auth-card [role="status"][aria-live="polite"]');
Expand Down Expand Up @@ -111,7 +111,7 @@ test.describe('Login page functionality', () => {
test('shows validation error for invalid email format', async ({ page }) => {
await page.locator('input:not([type="password"])').first().fill('notanemail');
await page.locator('input[type="password"]').first().fill('somepassword');
await page.getByRole('button', { name: 'Sign In', exact: true }).click();
await page.getByRole('button', { name: 'Sign in', exact: true }).click();

await expect(page.getByText(/valid email/i).first()).toBeVisible({ timeout: 5000 });
});
Expand All @@ -126,7 +126,7 @@ test.describe('Login page functionality', () => {
test('successful login redirects away from /login', async ({ page }) => {
await page.locator('input:not([type="password"])').first().fill('admin@test.com');
await page.locator('input[type="password"]').first().fill('password');
await page.getByRole('button', { name: 'Sign In', exact: true }).click();
await page.getByRole('button', { name: 'Sign in', exact: true }).click();

await page.waitForURL('**/', { timeout: 15_000 });
expect(page.url()).not.toContain('/login');
Expand All @@ -135,7 +135,7 @@ test.describe('Login page functionality', () => {
test('shows error banner when server rejects credentials', async ({ page }) => {
await page.locator('input:not([type="password"])').first().fill('rejected@test.com');
await page.locator('input[type="password"]').first().fill('wrongpass');
await page.getByRole('button', { name: 'Sign In', exact: true }).click();
await page.getByRole('button', { name: 'Sign in', exact: true }).click();

await expect(
page.getByText(/invalid|failed|credentials/i).first(),
Expand All @@ -152,7 +152,7 @@ test.describe('2FA verification flow', () => {
async function triggerTwoFAStep(page: import('@playwright/test').Page) {
await page.locator('input:not([type="password"])').first().fill('2fa@test.com');
await page.locator('input[type="password"]').first().fill('password');
await page.getByRole('button', { name: 'Sign In', exact: true }).click();
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await expect(page.getByText(/authenticator/i)).toBeVisible({ timeout: 5000 });
}

Expand Down Expand Up @@ -206,26 +206,26 @@ test.describe('2FA verification flow', () => {

await page.getByText(/back to login/i).click();

await expect(page.getByRole('button', { name: 'Sign In', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible();
await expect(page.getByText(/two-factor/i)).not.toBeVisible();
});
});

test.describe('SSO availability', () => {
test('shows SSO button when OpenID is available', async ({ page }) => {
test('shows OpenID button when OpenID is available', async ({ page }) => {
await page.goto('/login');
await page.waitForLoadState('networkidle');

await expect(
page.getByRole('button', { name: /sso/i }),
page.getByRole('button', { name: /openid/i }),
).toBeVisible({ timeout: 10_000 });
});

test('has zero WCAG 2.1 AA violations with SSO button visible', async ({ page }) => {
await page.goto('/login');
await page.waitForLoadState('networkidle');

await expect(page.getByRole('button', { name: /sso/i })).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole('button', { name: /openid/i })).toBeVisible({ timeout: 10_000 });

const results = await new AxeBuilder({ page })
.withTags(WCAG_TAGS)
Expand All @@ -238,8 +238,20 @@ test.describe('SSO availability', () => {
await page.goto('/login');
await page.waitForLoadState('networkidle');

await expect(page.getByRole('button', { name: 'Sign In', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible();
await expect(page.locator('input:not([type="password"])')).toBeVisible();
await expect(page.locator('input[type="password"]')).toBeVisible();
});

test('shows Google button alongside OpenID when both are configured', async ({ page }) => {
await page.goto('/login');
await page.waitForLoadState('networkidle');

await expect(
page.getByRole('button', { name: /google/i }),
).toBeVisible({ timeout: 10_000 });
await expect(
page.getByRole('button', { name: /openid/i }),
).toBeVisible({ timeout: 10_000 });
});
});
23 changes: 21 additions & 2 deletions e2e/mock-backend.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,28 @@ const handlers = {
},
];
},
'GET /api/admin/oauth/openid/check': () => [
/**
* Mirrors LibreChat's public /api/config payload — the same endpoint the
* admin panel uses to discover which OAuth providers are enabled. Only the
* auth-relevant flags are enumerated. Both openid and google are enabled by
* default so the e2e suite can exercise the multi-provider UI path.
*/
'GET /api/config': () => [
Comment thread
dustinhealy marked this conversation as resolved.
200,
{ message: 'OpenID check successful' },
{
appTitle: 'LibreChat',
openidLoginEnabled: true,
googleLoginEnabled: true,
githubLoginEnabled: false,
discordLoginEnabled: false,
facebookLoginEnabled: false,
appleLoginEnabled: false,
samlLoginEnabled: false,
socialLoginEnabled: true,
openidLabel: 'Continue with OpenID',
openidAutoRedirect: false,
emailLoginEnabled: true,
},
],
};

Expand Down
2 changes: 1 addition & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export default defineConfig({
},
{
command: 'node e2e/mock-backend.mjs',
url: 'http://localhost:3081/api/admin/oauth/openid/check',
url: 'http://localhost:3081/api/config',
reuseExistingServer: true,
timeout: 10_000,
},
Expand Down
142 changes: 81 additions & 61 deletions src/components/AuthCard.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import { z } from 'zod';
import { REGEXP_ONLY_DIGITS } from 'input-otp';
import { useQuery } from '@tanstack/react-query';
import { useRouter } from '@tanstack/react-router';
import { useState, useEffect, useRef, useMemo } from 'react';
import { Alert, Title, Panel, Button, Separator, TextField, Container } from '@clickhouse/click-ui';
import type * as t from '@/types';
import { adminLoginFn, adminVerify2FAFn, openIdCheckOptions, openidLoginFn } from '@/server';
import { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } from './InputOTP';
import { adminLoginFn, adminVerify2FAFn, oauthLoginFn } from '@/server';
import { PasswordInput } from './PasswordInput';
import { OAuthButton } from './OAuthButton';
import { useLocalize } from '@/hooks';

export function AuthCard({
redirectTo = '/',
autoRedirectSso = false,
ssoAvailable: ssoAvailableProp,
providers = [],
ssoOnly = false,
autoRedirectProvider,
}: t.AuthCardProps) {
const router = useRouter();
const localize = useLocalize();
Expand All @@ -24,20 +25,24 @@ export function AuthCard({
const [errors, setErrors] = useState<t.FieldErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [announcement, setAnnouncement] = useState('');
const [ssoLoading, setSsoLoading] = useState(false);
const [pendingProvider, setPendingProvider] = useState<t.OAuthProvider | undefined>();
const [autoRedirectFailed, setAutoRedirectFailed] = useState(false);
const autoRedirectAttempted = useRef(false);

const [tempToken, setTempToken] = useState('');
const [totpCode, setTotpCode] = useState('');

const { data: openIdData } = useQuery({
...openIdCheckOptions,
enabled: ssoAvailableProp === undefined,
});
const ssoAvailable = ssoAvailableProp ?? openIdData?.available ?? false;

const showAutoRedirect = autoRedirectSso && !autoRedirectFailed;
const showAutoRedirect = !!autoRedirectProvider && !autoRedirectFailed;
/**
* `ssoOnly` is the deployer's intent ("no password login"). It must hide the
* password form even when SSO discovery returns no providers, otherwise a
* misconfiguration (e.g. ADMIN_SSO_ONLY=true + only Google configured +
* upstream ALLOW_SOCIAL_LOGIN=false) would silently fall back to password
* login and defeat the policy. The unconfigured-SSO banner below surfaces
* the broken state instead.
*/
const hidePasswordForm = ssoOnly;
const ssoOnlyUnconfigured = ssoOnly && providers.length === 0;

useEffect(() => {
const messages = [generalError, errors.email, errors.password].filter(Boolean);
Expand All @@ -51,11 +56,11 @@ export function AuthCard({
}, [generalError, errors.email, errors.password]);

useEffect(() => {
if (!autoRedirectSso || autoRedirectAttempted.current) return;
if (!autoRedirectProvider || autoRedirectAttempted.current) return;
autoRedirectAttempted.current = true;

setSsoLoading(true);
openidLoginFn()
setPendingProvider(autoRedirectProvider);
oauthLoginFn({ data: { provider: autoRedirectProvider } })
.then((result) => {
if (result.error || !result.authUrl) {
setAutoRedirectFailed(true);
Expand All @@ -72,8 +77,8 @@ export function AuthCard({
setAutoRedirectFailed(true);
setGeneralError(localize('com_auth_sso_redirect_failed'));
})
.finally(() => setSsoLoading(false));
}, [autoRedirectSso, localize, redirectTo]);
.finally(() => setPendingProvider(undefined));
}, [autoRedirectProvider, localize, redirectTo]);

const emailSchema = useMemo(
() => z.string().email(localize('com_auth_email_invalid')),
Expand Down Expand Up @@ -191,11 +196,11 @@ export function AuthCard({
if (e.key === 'Enter') handleLogin();
};

const handleSsoLogin = async () => {
if (ssoLoading) return;
setSsoLoading(true);
const handleProviderLogin = async (provider: t.OAuthProvider) => {
if (pendingProvider) return;
setPendingProvider(provider);
try {
const result = await openidLoginFn();
const result = await oauthLoginFn({ data: { provider } });
if (result.error) {
setGeneralError(result.message || localize('com_auth_login_failed'));
return;
Expand All @@ -206,7 +211,7 @@ export function AuthCard({
} catch {
setGeneralError(localize('com_auth_unable_connect'));
} finally {
setSsoLoading(false);
setPendingProvider(undefined);
}
};

Expand All @@ -231,6 +236,8 @@ export function AuthCard({
);
}

const showPasswordForm = !hidePasswordForm;
Comment thread
dustinhealy marked this conversation as resolved.

return (
<Panel
className="auth-card w-full max-w-md"
Expand All @@ -247,6 +254,14 @@ export function AuthCard({

{generalError && <Alert type="banner" state="danger" text={generalError} />}

{ssoOnlyUnconfigured && step !== '2fa' && (
<Alert
type="banner"
state="warning"
text={localize('com_auth_sso_required_unconfigured')}
/>
)}

{step === '2fa' ? (
<>
<p className="text-center text-sm text-(--cui-color-text-muted)">
Expand Down Expand Up @@ -292,52 +307,57 @@ export function AuthCard({
</>
) : (
<>
<TextField
label={localize('com_auth_email_label')}
placeholder={localize('com_auth_email_placeholder')}
value={email}
onChange={(value) => {
setEmail(value);
if (errors.email) setErrors((prev) => ({ ...prev, email: undefined }));
}}
onKeyDown={handleKeyDown}
error={errors.email}
/>

<PasswordInput
label={localize('com_auth_password_label')}
placeholder={localize('com_auth_password_placeholder')}
value={password}
onChange={(value) => {
setPassword(value);
if (errors.password) setErrors((prev) => ({ ...prev, password: undefined }));
}}
onKeyDown={handleKeyDown}
error={errors.password}
/>

<Button
label={isSubmitting ? localize('com_auth_signing_in') : localize('com_auth_sign_in')}
type="primary"
onClick={handleLogin}
disabled={isSubmitting}
/>

{ssoAvailable && (
{showPasswordForm && (
<>
<Separator size="sm" />
<TextField
label={localize('com_auth_email_label')}
placeholder={localize('com_auth_email_placeholder')}
value={email}
onChange={(value) => {
setEmail(value);
if (errors.email) setErrors((prev) => ({ ...prev, email: undefined }));
}}
onKeyDown={handleKeyDown}
error={errors.email}
/>

<PasswordInput
label={localize('com_auth_password_label')}
placeholder={localize('com_auth_password_placeholder')}
value={password}
onChange={(value) => {
setPassword(value);
if (errors.password) setErrors((prev) => ({ ...prev, password: undefined }));
}}
onKeyDown={handleKeyDown}
error={errors.password}
/>

<Button
label={
ssoLoading
? localize('com_auth_sso_redirecting')
: localize('com_auth_sso_sign_in')
isSubmitting ? localize('com_auth_signing_in') : localize('com_auth_sign_in')
}
type="secondary"
onClick={handleSsoLogin}
disabled={ssoLoading}
type="primary"
onClick={handleLogin}
disabled={isSubmitting}
/>
</>
)}

{providers.length > 0 && (
<>
{showPasswordForm && <Separator size="sm" />}
{providers.map((provider) => (
<OAuthButton
key={provider.id}
provider={provider}
isPending={pendingProvider === provider.id}
disabled={!!pendingProvider}
onClick={() => handleProviderLogin(provider.id)}
/>
))}
</>
)}
</>
)}
</Container>
Expand Down
Loading