mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(editor): Make SSO the primary option on the sign-in page when SSO is enabled (#39268)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
27627bc793
commit
0827eb8eda
7
packages/@n8n/api-types/src/constants/auth.ts
Normal file
7
packages/@n8n/api-types/src/constants/auth.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* Error code of the `POST /login` response when password sign-in is refused
|
||||
* because single sign-on (SAML or OIDC) is the active authentication method.
|
||||
* The sign-in page uses it to point the user to the SSO flow instead of
|
||||
* showing a generic login failure.
|
||||
*/
|
||||
export const SSO_LOGIN_REQUIRED_ERROR_CODE = 996;
|
||||
|
|
@ -853,6 +853,7 @@ export {
|
|||
SSO_ERROR_QUERY_PARAM,
|
||||
SSO_ERROR_LOGIN_FAILED,
|
||||
} from './constants/role-mapping';
|
||||
export { SSO_LOGIN_REQUIRED_ERROR_CODE } from './constants/auth';
|
||||
|
||||
export {
|
||||
instanceAiApprovalDetailsSchema,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { LoginRequestDto } from '@n8n/api-types';
|
||||
import { ResolveSignupTokenQueryDto } from '@n8n/api-types';
|
||||
import { ResolveSignupTokenQueryDto, SSO_LOGIN_REQUIRED_ERROR_CODE } from '@n8n/api-types';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { mockInstance } from '@n8n/backend-test-utils';
|
||||
import type { AuthenticatedRequest, User } from '@n8n/db';
|
||||
|
|
@ -145,6 +145,9 @@ describe('AuthController', () => {
|
|||
const execution = controller.login(req, res, body);
|
||||
await expect(execution).rejects.toThrow(AuthError);
|
||||
await expect(execution).rejects.toThrow('SSO is enabled, please log in with SSO');
|
||||
await expect(execution).rejects.toMatchObject({
|
||||
errorCode: SSO_LOGIN_REQUIRED_ERROR_CODE,
|
||||
});
|
||||
|
||||
// Assert
|
||||
|
||||
|
|
@ -177,6 +180,9 @@ describe('AuthController', () => {
|
|||
const execution = controller.login(req, res, body);
|
||||
await expect(execution).rejects.toThrow(AuthError);
|
||||
await expect(execution).rejects.toThrow('SSO is enabled, please log in with SSO');
|
||||
await expect(execution).rejects.toMatchObject({
|
||||
errorCode: SSO_LOGIN_REQUIRED_ERROR_CODE,
|
||||
});
|
||||
|
||||
expect(eventsService.emit).toHaveBeenCalledWith('user-login-failed', {
|
||||
authenticationMethod: 'email',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { LoginRequestDto, ResolveSignupTokenQueryDto } from '@n8n/api-types';
|
||||
import {
|
||||
LoginRequestDto,
|
||||
ResolveSignupTokenQueryDto,
|
||||
SSO_LOGIN_REQUIRED_ERROR_CODE,
|
||||
} from '@n8n/api-types';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { Time } from '@n8n/constants';
|
||||
import type { User, PublicUser, AuthProviderType } from '@n8n/db';
|
||||
|
|
@ -124,7 +128,7 @@ export class AuthController {
|
|||
userEmail,
|
||||
reason: 'SSO is enabled, please log in with SSO',
|
||||
});
|
||||
throw new AuthError('SSO is enabled, please log in with SSO');
|
||||
throw new AuthError('SSO is enabled, please log in with SSO', SSO_LOGIN_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -106,6 +106,48 @@ describe('OidcController', () => {
|
|||
expect(res.redirect).toHaveBeenCalledWith('/');
|
||||
});
|
||||
|
||||
test('Should redirect to the destination stored in the state after login', async () => {
|
||||
const req = mock<AuthlessRequest>({
|
||||
originalUrl: '/sso/oidc/callback?code=auth_code&state=state_value',
|
||||
browserId: 'browser-id-123',
|
||||
cookies: {
|
||||
[OIDC_STATE_COOKIE_NAME]: 'state_value',
|
||||
[OIDC_NONCE_COOKIE_NAME]: 'nonce_value',
|
||||
},
|
||||
});
|
||||
const res = mock<Response>();
|
||||
oidcService.verifyState.mockReturnValue({
|
||||
state: 'n8n_state:uuid',
|
||||
redirectUrl: '/workflow/abc?tab=1',
|
||||
});
|
||||
oidcService.loginUser.mockResolvedValueOnce({ user });
|
||||
|
||||
await controller.callbackHandler(req, res);
|
||||
|
||||
expect(res.redirect).toHaveBeenCalledWith('/workflow/abc?tab=1');
|
||||
});
|
||||
|
||||
test('Should fall back to the home page when the stored destination is not a local path', async () => {
|
||||
const req = mock<AuthlessRequest>({
|
||||
originalUrl: '/sso/oidc/callback?code=auth_code&state=state_value',
|
||||
browserId: 'browser-id-123',
|
||||
cookies: {
|
||||
[OIDC_STATE_COOKIE_NAME]: 'state_value',
|
||||
[OIDC_NONCE_COOKIE_NAME]: 'nonce_value',
|
||||
},
|
||||
});
|
||||
const res = mock<Response>();
|
||||
oidcService.verifyState.mockReturnValue({
|
||||
state: 'n8n_state:uuid',
|
||||
redirectUrl: '//evil.example/phish',
|
||||
});
|
||||
oidcService.loginUser.mockResolvedValueOnce({ user });
|
||||
|
||||
await controller.callbackHandler(req, res);
|
||||
|
||||
expect(res.redirect).toHaveBeenCalledWith('/');
|
||||
});
|
||||
|
||||
test('Should handle callback URL with different query parameters', async () => {
|
||||
const req = mock<AuthlessRequest>({
|
||||
originalUrl:
|
||||
|
|
@ -549,6 +591,34 @@ describe('OidcController', () => {
|
|||
});
|
||||
});
|
||||
|
||||
test('Should carry a safe in-app destination into the login state', async () => {
|
||||
const req = mock<Request>({ query: { redirect: '/workflow/abc?tab=1' } });
|
||||
const res = mock<Response>();
|
||||
oidcService.generateLoginUrl.mockResolvedValueOnce({
|
||||
url: new URL('https://provider.com/auth'),
|
||||
state: 'state_value',
|
||||
nonce: 'nonce_value',
|
||||
});
|
||||
|
||||
await controller.redirectToAuthProvider(req, res);
|
||||
|
||||
expect(oidcService.generateLoginUrl).toHaveBeenCalledWith('/workflow/abc?tab=1');
|
||||
});
|
||||
|
||||
test('Should replace an external destination with the home page', async () => {
|
||||
const req = mock<Request>({ query: { redirect: 'https://evil.example/phish' } });
|
||||
const res = mock<Response>();
|
||||
oidcService.generateLoginUrl.mockResolvedValueOnce({
|
||||
url: new URL('https://provider.com/auth'),
|
||||
state: 'state_value',
|
||||
nonce: 'nonce_value',
|
||||
});
|
||||
|
||||
await controller.redirectToAuthProvider(req, res);
|
||||
|
||||
expect(oidcService.generateLoginUrl).toHaveBeenCalledWith('/');
|
||||
});
|
||||
|
||||
test('Should propagate errors from OIDC service during URL generation', async () => {
|
||||
const req = mock<Request>();
|
||||
const res = mock<Response>();
|
||||
|
|
|
|||
|
|
@ -394,6 +394,37 @@ describe('OidcService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('state redirect target', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(jwtService.sign).mockImplementation((payload) => JSON.stringify(payload));
|
||||
vi.mocked(jwtService.verify).mockImplementation((token) => JSON.parse(token));
|
||||
});
|
||||
|
||||
it('round-trips the in-app destination through the signed state', () => {
|
||||
const { signed } = oidcService.generateState(false, '/workflow/abc?tab=1');
|
||||
|
||||
expect(oidcService.verifyState(signed)).toEqual({
|
||||
state: expect.stringMatching(/^n8n_state:/),
|
||||
redirectUrl: '/workflow/abc?tab=1',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits the destination for the home page', () => {
|
||||
const { signed } = oidcService.generateState(false, '/');
|
||||
|
||||
expect(oidcService.verifyState(signed)).not.toHaveProperty('redirectUrl');
|
||||
});
|
||||
|
||||
it('ignores a destination that is not a string', () => {
|
||||
const signed = JSON.stringify({
|
||||
state: 'n8n_state:5d3a3f6e-1d2b-4c8a-9e1f-0a1b2c3d4e5f',
|
||||
redirectUrl: 42,
|
||||
});
|
||||
|
||||
expect(oidcService.verifyState(signed)).not.toHaveProperty('redirectUrl');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loginUser', () => {
|
||||
it('throws an error if authorizationCodeGrant throws an error', async () => {
|
||||
oidcService.verifyState = vi.fn().mockReturnValue('valid-state');
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { SsoAccessDeniedError } from '@/modules/provisioning.ee/errors/sso-acces
|
|||
import { AuthlessRequest } from '@/requests';
|
||||
import { UrlService } from '@/services/url.service';
|
||||
import { isOidcCurrentAuthenticationMethod } from '@/sso.ee/sso-helpers';
|
||||
import { validateRedirectUrl } from '@/utils/validate-redirect-url';
|
||||
|
||||
import {
|
||||
OIDC_CLIENT_SECRET_REDACTED_VALUE,
|
||||
|
|
@ -94,8 +95,10 @@ export class OidcController {
|
|||
|
||||
@Get('/login', { skipAuth: true })
|
||||
@Licensed('feat:oidc')
|
||||
async redirectToAuthProvider(_req: Request, res: Response) {
|
||||
const authorization = await this.oidcService.generateLoginUrl();
|
||||
async redirectToAuthProvider(req: Request<{}, {}, {}, { redirect?: string }>, res: Response) {
|
||||
// Same in-app destination the sign-in page received; the callback redirects there.
|
||||
const redirectUrl = validateRedirectUrl(req.query.redirect ?? '');
|
||||
const authorization = await this.oidcService.generateLoginUrl(redirectUrl);
|
||||
const { samesite, secure } = this.globalConfig.auth.cookie;
|
||||
|
||||
res.cookie(OIDC_STATE_COOKIE_NAME, authorization.state, {
|
||||
|
|
@ -188,7 +191,7 @@ export class OidcController {
|
|||
authenticationMethod: 'oidc',
|
||||
});
|
||||
|
||||
return res.redirect('/');
|
||||
return res.redirect(validateRedirectUrl(stateInfo.redirectUrl ?? ''));
|
||||
} catch (error) {
|
||||
if (error instanceof SsoAccessDeniedError) {
|
||||
this.eventService.emit('user-login-failed', {
|
||||
|
|
|
|||
|
|
@ -131,25 +131,34 @@ export class OidcService {
|
|||
};
|
||||
}
|
||||
|
||||
generateState(testMode = false) {
|
||||
/**
|
||||
* The signed state also carries the in-app destination the user asked for
|
||||
* before the login, so the callback can send them there instead of `/`.
|
||||
*/
|
||||
generateState(testMode = false, redirectUrl?: string) {
|
||||
const state = `n8n_state:${randomUUID()}`;
|
||||
const payload: Record<string, unknown> = { state };
|
||||
if (testMode) {
|
||||
payload.testMode = true;
|
||||
}
|
||||
if (redirectUrl && redirectUrl !== '/') {
|
||||
payload.redirectUrl = redirectUrl;
|
||||
}
|
||||
return {
|
||||
signed: this.jwtService.sign(payload, { expiresIn: '15m' }),
|
||||
plaintext: state,
|
||||
};
|
||||
}
|
||||
|
||||
verifyState(signedState: string): { state: string; testMode?: boolean } {
|
||||
verifyState(signedState: string): { state: string; testMode?: boolean; redirectUrl?: string } {
|
||||
let state: string;
|
||||
let testMode: boolean | undefined;
|
||||
let redirectUrl: unknown;
|
||||
try {
|
||||
const decodedState = this.jwtService.verify(signedState);
|
||||
state = decodedState?.state;
|
||||
testMode = decodedState?.testMode;
|
||||
redirectUrl = decodedState?.redirectUrl;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to verify state', { error });
|
||||
throw new BadRequestError('Invalid state');
|
||||
|
|
@ -175,7 +184,11 @@ export class OidcService {
|
|||
this.logger.error('Provided state is not formatted correctly');
|
||||
throw new BadRequestError('Invalid state');
|
||||
}
|
||||
return { state, testMode };
|
||||
return {
|
||||
state,
|
||||
testMode,
|
||||
...(typeof redirectUrl === 'string' && { redirectUrl }),
|
||||
};
|
||||
}
|
||||
|
||||
generateNonce() {
|
||||
|
|
@ -225,12 +238,14 @@ export class OidcService {
|
|||
}
|
||||
}
|
||||
|
||||
async generateLoginUrl(): Promise<{ url: URL; state: string; nonce: string }> {
|
||||
async generateLoginUrl(
|
||||
redirectUrl?: string,
|
||||
): Promise<{ url: URL; state: string; nonce: string }> {
|
||||
this.assertOidcLoginEnabled();
|
||||
await this.loadOpenIdClient();
|
||||
const configuration = await this.getOidcConfiguration();
|
||||
|
||||
const state = this.generateState();
|
||||
const state = this.generateState(false, redirectUrl);
|
||||
const nonce = this.generateNonce();
|
||||
|
||||
const prompt = this.oidcConfig.prompt;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ describe('validateRedirectUrl', () => {
|
|||
['/path?query=1', '/path?query=1'],
|
||||
['/path#fragment', '/path#fragment'],
|
||||
[' /trimmed ', '/trimmed'],
|
||||
['/workflow/abc?tab=1&x=2', '/workflow/abc?tab=1&x=2'],
|
||||
['/%5Cexample.com', '/%5Cexample.com'],
|
||||
])('allows valid relative path %s → %s', (input, expected) => {
|
||||
expect(validateRedirectUrl(input)).toBe(expected);
|
||||
});
|
||||
|
|
@ -21,6 +23,12 @@ describe('validateRedirectUrl', () => {
|
|||
['%2F%2Fevil.com/phishing', '/'],
|
||||
['workflows/123', '/'],
|
||||
['data:text/html,<h1>hi</h1>', '/'],
|
||||
['/\\example.com/path', '/'],
|
||||
['/\\\\example.com', '/'],
|
||||
['/\t/example.com', '/'],
|
||||
['/\r\n/example.com', '/'],
|
||||
['/\n\\example.com', '/'],
|
||||
['/\\/example.com', '/'],
|
||||
])('rejects unsafe redirect %s → %s', (input, expected) => {
|
||||
expect(validateRedirectUrl(input)).toBe(expected);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
/** Any origin works here; the check is only that the path resolves back onto it. */
|
||||
const PLACEHOLDER_ORIGIN = 'https://n8n.invalid';
|
||||
|
||||
/**
|
||||
* Validates that a redirect URL is safe (relative path only, no external redirects).
|
||||
* Returns '/' if the URL is invalid or unsafe.
|
||||
|
|
@ -17,6 +20,20 @@ export function validateRedirectUrl(redirectUrl: string): string {
|
|||
if (trimmed.startsWith('//')) {
|
||||
return '/';
|
||||
}
|
||||
// Browsers read a backslash as a slash and drop tabs and newlines while they
|
||||
// parse a URL, so `/\example.com` and `/<tab>/example.com` also leave the instance.
|
||||
if (/[\\\t\n\r]/.test(trimmed)) {
|
||||
return '/';
|
||||
}
|
||||
// Let the URL parser have the final word: a relative path resolved against a
|
||||
// placeholder origin has to stay on that origin.
|
||||
try {
|
||||
if (new URL(trimmed, PLACEHOLDER_ORIGIN).origin !== PLACEHOLDER_ORIGIN) {
|
||||
return '/';
|
||||
}
|
||||
} catch {
|
||||
return '/';
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,41 @@
|
|||
import { mount } from '@vue/test-utils';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import FormInputs from './FormInputs.vue';
|
||||
import type { IFormInput } from '../../types';
|
||||
import { createFormEventBus } from '../../utils';
|
||||
import N8nFormInput from '../N8nFormInput';
|
||||
|
||||
describe('FormInputs', () => {
|
||||
describe('event bus', () => {
|
||||
const inputs: IFormInput[] = [
|
||||
{ name: 'email', properties: { type: 'text', label: 'Email' }, initialValue: 'a@b.c' },
|
||||
];
|
||||
|
||||
it('should submit the current values when the bus emits submit', async () => {
|
||||
const eventBus = createFormEventBus();
|
||||
const wrapper = mount(FormInputs, { props: { inputs, eventBus } });
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
eventBus.emit('submit');
|
||||
|
||||
expect(wrapper.emitted('submit')).toEqual([[{ email: 'a@b.c' }]]);
|
||||
});
|
||||
|
||||
it('should stop listening to the bus when unmounted', async () => {
|
||||
const eventBus = createFormEventBus();
|
||||
const onSpy = vi.spyOn(eventBus, 'on');
|
||||
const offSpy = vi.spyOn(eventBus, 'off');
|
||||
const wrapper = mount(FormInputs, { props: { inputs, eventBus } });
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
wrapper.unmount();
|
||||
|
||||
const [, registeredHandler] = onSpy.mock.calls[0];
|
||||
expect(offSpy).toHaveBeenCalledWith('submit', registeredHandler);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dangling field removal', () => {
|
||||
it('should remove values for fields that are no longer in the inputs list', async () => {
|
||||
const initialInputs: IFormInput[] = [
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
import type { FormFieldValue, IFormInput, FormFieldValueUpdate, FormValues } from '../../types';
|
||||
import type { FormEventBus } from '../../utils';
|
||||
|
|
@ -145,6 +145,12 @@ onMounted(() => {
|
|||
props.eventBus.on('submit', onSubmit);
|
||||
}
|
||||
});
|
||||
|
||||
// The bus outlives this component when the parent toggles the form, so drop the
|
||||
// listener or every remount would submit once more with stale values.
|
||||
onBeforeUnmount(() => {
|
||||
props.eventBus?.off('submit', onSubmit);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
|||
|
|
@ -253,6 +253,9 @@
|
|||
"auth.signin.ssoLoginFailed": "Your identity provider could not sign you in. Please try again, or contact your administrator if the problem continues.",
|
||||
"auth.signin.sessionExpired.title": "Session expired",
|
||||
"auth.signin.sessionExpired": "Your session has expired. Please log in again to continue using n8n.",
|
||||
"auth.signin.passwordDisclosure": "Sign in with email and password",
|
||||
"auth.signin.ssoRequired.title": "Your account uses single sign-on (SSO)",
|
||||
"auth.signin.ssoRequired": "Password sign-in isn't available for this account.",
|
||||
"auth.signout": "Sign out",
|
||||
"auth.signout.error": "Could not sign out",
|
||||
"auth.signup.finishAccountSetup": "Finish account setup",
|
||||
|
|
@ -6277,6 +6280,7 @@
|
|||
"settings.personal.mfa.enforce.disabled.message": "Two-factor authentication is no longer mandatory for users on this instance authenticating with email and password logins.",
|
||||
"settings.mfa.toast.noRecoveryCodeLeft.title": "No 2FA recovery codes remaining",
|
||||
"settings.mfa.toast.noRecoveryCodeLeft.message": "You have used all of your recovery codes. Disable then re-enable two-factor authentication to generate new codes. <a href='/settings/personal' target='_blank' >Open settings</a>",
|
||||
"sso.login.subtitle": "Your organization uses single sign-on (SSO).",
|
||||
"sso.login.divider": "or",
|
||||
"sso.login.button": "Continue with SSO",
|
||||
"executionUsage.currentUsage": "{text} {count}",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import type { IRestApiContext } from '../../types';
|
||||
import * as utils from '../../utils';
|
||||
import { initSSO } from '../sso';
|
||||
|
||||
vi.mock('../../utils');
|
||||
|
||||
const context = {} as IRestApiContext;
|
||||
|
||||
describe('initSSO', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('encodes the destination as a query parameter', async () => {
|
||||
vi.mocked(utils.makeRestApiRequest).mockResolvedValueOnce('https://idp.example.com/saml');
|
||||
|
||||
await initSSO(context, '/workflow/abc?tab=1&x=2');
|
||||
|
||||
expect(utils.makeRestApiRequest).toHaveBeenCalledWith(
|
||||
context,
|
||||
'GET',
|
||||
'/sso/saml/initsso?redirect=%2Fworkflow%2Fabc%3Ftab%3D1%26x%3D2',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits the query when there is no destination', async () => {
|
||||
vi.mocked(utils.makeRestApiRequest).mockResolvedValueOnce('https://idp.example.com/saml');
|
||||
|
||||
await initSSO(context);
|
||||
|
||||
expect(utils.makeRestApiRequest).toHaveBeenCalledWith(context, 'GET', '/sso/saml/initsso');
|
||||
});
|
||||
});
|
||||
|
|
@ -14,7 +14,8 @@ export type SamlPreferencesExtractedData = {
|
|||
};
|
||||
|
||||
export const initSSO = async (context: IRestApiContext, redirectUrl = ''): Promise<string> => {
|
||||
return await makeRestApiRequest(context, 'GET', `/sso/saml/initsso?redirect=${redirectUrl}`);
|
||||
const query = redirectUrl ? `?${new URLSearchParams({ redirect: redirectUrl })}` : '';
|
||||
return await makeRestApiRequest(context, 'GET', `/sso/saml/initsso${query}`);
|
||||
};
|
||||
|
||||
export const getSamlMetadata = async (context: IRestApiContext): Promise<SamlPreferences> => {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@ export const MFA_FORM = {
|
|||
MFA_RECOVERY_CODE: 'MFA_RECOVERY_CODE',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Sign-in page query parameter that reveals the email/password form up front
|
||||
* on SSO instances, so an admin can still sign in when the provider is down.
|
||||
*/
|
||||
export const INTERNAL_AUTH_QUERY_PARAM = 'internalAuth';
|
||||
|
||||
export const MFA_AUTHENTICATION_REQUIRED_ERROR_CODE = 998;
|
||||
|
||||
export const MFA_AUTHENTICATION_CODE_WINDOW_EXPIRED = 997;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { waitFor, within } from '@testing-library/vue';
|
||||
import SsoSigninCard from './SsoSigninCard.vue';
|
||||
import type { IFormBoxConfig } from '@/Interface';
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
RouterLink: {
|
||||
template: '<a><slot /></a>',
|
||||
},
|
||||
}));
|
||||
|
||||
const form: IFormBoxConfig = {
|
||||
title: 'Sign in',
|
||||
buttonText: 'Sign in',
|
||||
redirectText: 'Forgot my password',
|
||||
redirectLink: '/forgot-password',
|
||||
inputs: [
|
||||
{
|
||||
name: 'emailOrLdapLoginId',
|
||||
properties: { label: 'Email', type: 'email', required: true },
|
||||
},
|
||||
{
|
||||
name: 'password',
|
||||
properties: { label: 'Password', type: 'password', required: true },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const renderComponent = createComponentRenderer(SsoSigninCard, {
|
||||
pinia: createTestingPinia(),
|
||||
props: { form },
|
||||
});
|
||||
|
||||
const getEmailInput = (container: Element) =>
|
||||
container.querySelector<HTMLInputElement>('input[type="email"]');
|
||||
|
||||
describe('SsoSigninCard', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should lead with the SSO button and keep the password form collapsed', () => {
|
||||
const { getByRole, getByText, container } = renderComponent();
|
||||
|
||||
expect(getByRole('button', { name: 'Continue with SSO' })).toBeVisible();
|
||||
expect(getByText('Your organization uses single sign-on (SSO).')).toBeVisible();
|
||||
expect(getByRole('button', { name: /sign in with email and password/i })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false',
|
||||
);
|
||||
expect(getEmailInput(container)).not.toBeVisible();
|
||||
});
|
||||
|
||||
it('should reveal the password form when the disclosure is clicked', async () => {
|
||||
const { getByRole, getByText, container } = renderComponent();
|
||||
|
||||
const disclosure = getByRole('button', { name: /sign in with email and password/i });
|
||||
await userEvent.click(disclosure);
|
||||
|
||||
expect(disclosure).toHaveAttribute('aria-expanded', 'true');
|
||||
const controlledContent = document.getElementById(
|
||||
disclosure.getAttribute('aria-controls') ?? '',
|
||||
);
|
||||
expect(controlledContent).toContainElement(getEmailInput(container));
|
||||
expect(getEmailInput(container)).toBeVisible();
|
||||
expect(container.querySelector('input[type="password"]')).toBeVisible();
|
||||
expect(getByRole('button', { name: 'Sign in' })).toBeVisible();
|
||||
expect(getByText('Forgot my password')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should focus the email field when the disclosure opens', async () => {
|
||||
const { getByRole, container } = renderComponent();
|
||||
|
||||
await userEvent.click(getByRole('button', { name: /sign in with email and password/i }));
|
||||
|
||||
await waitFor(() => expect(getEmailInput(container)).toHaveFocus());
|
||||
});
|
||||
|
||||
it('should keep the entered credentials when the disclosure is closed and reopened', async () => {
|
||||
const { getByRole, emitted, container } = renderComponent({
|
||||
props: { defaultExpanded: true },
|
||||
});
|
||||
const disclosure = getByRole('button', { name: /sign in with email and password/i });
|
||||
const emailInput = getEmailInput(container);
|
||||
const passwordInput = container.querySelector('input[type="password"]');
|
||||
if (!emailInput || !passwordInput) {
|
||||
throw new Error('Inputs not found');
|
||||
}
|
||||
|
||||
await userEvent.type(emailInput, 'test@n8n.io');
|
||||
await userEvent.type(passwordInput, 'password');
|
||||
await userEvent.click(disclosure);
|
||||
expect(emailInput).not.toBeVisible();
|
||||
await userEvent.click(disclosure);
|
||||
expect(emailInput).toBeVisible();
|
||||
expect(emailInput).toHaveValue('test@n8n.io');
|
||||
|
||||
await userEvent.click(getByRole('button', { name: 'Sign in' }));
|
||||
|
||||
expect(emitted('submit')).toEqual([
|
||||
[{ emailOrLdapLoginId: 'test@n8n.io', password: 'password' }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should start with the password form revealed and focused when defaultExpanded is set', async () => {
|
||||
const { container } = renderComponent({ props: { defaultExpanded: true } });
|
||||
|
||||
expect(getEmailInput(container)).toBeVisible();
|
||||
await waitFor(() => expect(getEmailInput(container)).toHaveFocus());
|
||||
});
|
||||
|
||||
it('should emit ssoLogin from the primary button', async () => {
|
||||
const { getByRole, emitted } = renderComponent();
|
||||
|
||||
await userEvent.click(getByRole('button', { name: 'Continue with SSO' }));
|
||||
|
||||
expect(emitted('ssoLogin')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should emit submit with the entered credentials', async () => {
|
||||
const { getByRole, emitted, container } = renderComponent({
|
||||
props: { defaultExpanded: true },
|
||||
});
|
||||
const emailInput = getEmailInput(container);
|
||||
const passwordInput = container.querySelector('input[type="password"]');
|
||||
if (!emailInput || !passwordInput) {
|
||||
throw new Error('Inputs not found');
|
||||
}
|
||||
|
||||
await userEvent.type(emailInput, 'test@n8n.io');
|
||||
await userEvent.type(passwordInput, 'password');
|
||||
await userEvent.click(getByRole('button', { name: 'Sign in' }));
|
||||
|
||||
expect(emitted('submit')).toEqual([
|
||||
[{ emailOrLdapLoginId: 'test@n8n.io', password: 'password' }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should show the SSO-required callout with its own SSO action', async () => {
|
||||
const { getByTestId, queryByTestId, rerender, emitted } = renderComponent({
|
||||
props: { defaultExpanded: true },
|
||||
});
|
||||
expect(queryByTestId('sso-required-callout')).not.toBeInTheDocument();
|
||||
|
||||
await rerender({ form, defaultExpanded: true, ssoRequired: true });
|
||||
|
||||
const callout = getByTestId('sso-required-callout');
|
||||
expect(callout).toBeVisible();
|
||||
await waitFor(() => expect(callout).toHaveFocus());
|
||||
expect(within(callout).getByText('Your account uses single sign-on (SSO)')).toBeVisible();
|
||||
expect(
|
||||
within(callout).getByText("Password sign-in isn't available for this account."),
|
||||
).toBeVisible();
|
||||
|
||||
await userEvent.click(within(callout).getByRole('button', { name: 'Continue with SSO' }));
|
||||
|
||||
expect(emitted('ssoLogin')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,345 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import { CollapsibleRoot, CollapsibleTrigger } from 'reka-ui';
|
||||
|
||||
import {
|
||||
createFormEventBus,
|
||||
N8nAnimatedCollapsibleContent,
|
||||
N8nButton,
|
||||
N8nCallout,
|
||||
N8nFormInputs,
|
||||
N8nHeading,
|
||||
N8nIcon,
|
||||
N8nLink,
|
||||
N8nText,
|
||||
} from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
|
||||
import type { IFormBoxConfig } from '@/Interface';
|
||||
import type { EmailOrLdapLoginIdAndPassword } from '../views/SigninView.vue';
|
||||
|
||||
/**
|
||||
* The sign-in card for instances where SSO is the active login method.
|
||||
* "Continue with SSO" is the primary action; the email/password form sits
|
||||
* behind a disclosure so it stays one click away without inviting users to
|
||||
* type credentials that SSO would reject.
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
form: IFormBoxConfig;
|
||||
formLoading?: boolean;
|
||||
ssoLoading?: boolean;
|
||||
/** Show the inline "your account uses SSO" callout above the password fields. */
|
||||
ssoRequired?: boolean;
|
||||
/** Start with the password form revealed, for example on `?internalAuth=true`. */
|
||||
defaultExpanded?: boolean;
|
||||
}>(),
|
||||
{
|
||||
formLoading: false,
|
||||
ssoLoading: false,
|
||||
ssoRequired: false,
|
||||
defaultExpanded: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [values: EmailOrLdapLoginIdAndPassword];
|
||||
ssoLogin: [];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const formBus = createFormEventBus();
|
||||
|
||||
const isPasswordFormOpen = ref(props.defaultExpanded);
|
||||
const passwordFormRef = ref<HTMLElement | null>(null);
|
||||
const calloutRef = ref<{ $el?: unknown } | null>(null);
|
||||
|
||||
// Reka assigns the collapsible content id when the content mounts, after the
|
||||
// trigger has already rendered an empty `aria-controls`, so read the id from the
|
||||
// rendered content element and bind it explicitly.
|
||||
const passwordFormContentId = ref<string>();
|
||||
|
||||
// The form stays mounted while collapsed so typed credentials survive a
|
||||
// close/reopen, so the card decides when the first field gets focus instead of
|
||||
// the inputs focusing themselves on mount.
|
||||
const inputs = computed(() =>
|
||||
props.form.inputs.map((input) => ({
|
||||
...input,
|
||||
properties: { ...input.properties, focusInitially: false },
|
||||
})),
|
||||
);
|
||||
|
||||
const nextFrame = async () =>
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
|
||||
// Reka drops the `hidden` attribute one render after the open state flips, and
|
||||
// a field inside hidden content cannot take focus, so wait for it to show.
|
||||
const focusFirstInput = async () => {
|
||||
await nextTick();
|
||||
for (let frame = 0; frame < 10 && passwordFormRef.value?.parentElement?.hidden; frame++) {
|
||||
await nextFrame();
|
||||
}
|
||||
passwordFormRef.value?.querySelector('input')?.focus({ preventScroll: true });
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
passwordFormContentId.value = passwordFormRef.value?.parentElement?.id || undefined;
|
||||
if (props.defaultExpanded) await focusFirstInput();
|
||||
});
|
||||
|
||||
watch(isPasswordFormOpen, async (isOpen) => {
|
||||
if (isOpen) await focusFirstInput();
|
||||
});
|
||||
|
||||
// Focus the callout when it appears so keyboard and screen-reader users land on
|
||||
// the explanation right after a refused password sign-in.
|
||||
watch(
|
||||
() => props.ssoRequired,
|
||||
async (ssoRequired) => {
|
||||
if (!ssoRequired) return;
|
||||
await nextTick();
|
||||
const element = calloutRef.value?.$el;
|
||||
if (element instanceof HTMLElement) element.focus({ preventScroll: true });
|
||||
},
|
||||
);
|
||||
|
||||
const onSubmit = (values: unknown) => {
|
||||
emit('submit', values as EmailOrLdapLoginIdAndPassword);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['n8n-form-box', $style.container]" data-test-id="sso-signin-card">
|
||||
<div :class="$style.heading">
|
||||
<N8nHeading size="xlarge">{{ form.title }}</N8nHeading>
|
||||
<N8nText tag="p" size="medium" color="text-base" align="center">
|
||||
{{ i18n.baseText('sso.login.subtitle') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
|
||||
<N8nButton
|
||||
size="large"
|
||||
:class="$style.ssoButton"
|
||||
:label="i18n.baseText('sso.login.button')"
|
||||
:loading="ssoLoading"
|
||||
data-test-id="sso-login-button"
|
||||
@click="emit('ssoLogin')"
|
||||
/>
|
||||
|
||||
<div :class="$style.divider" aria-hidden="true">
|
||||
<span>{{ i18n.baseText('sso.login.divider') }}</span>
|
||||
</div>
|
||||
|
||||
<CollapsibleRoot
|
||||
v-model:open="isPasswordFormOpen"
|
||||
:unmount-on-hide="false"
|
||||
:class="$style.passwordSection"
|
||||
>
|
||||
<CollapsibleTrigger as-child>
|
||||
<N8nButton
|
||||
variant="ghost"
|
||||
size="large"
|
||||
:class="$style.revealTrigger"
|
||||
:aria-controls="passwordFormContentId"
|
||||
data-test-id="reveal-password-login"
|
||||
>
|
||||
{{ i18n.baseText('auth.signin.passwordDisclosure') }}
|
||||
<N8nIcon
|
||||
icon="chevron-down"
|
||||
size="small"
|
||||
:class="[$style.chevron, isPasswordFormOpen && $style.chevronOpen]"
|
||||
/>
|
||||
</N8nButton>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<N8nAnimatedCollapsibleContent :class="$style.passwordFormContent" blur>
|
||||
<div ref="passwordFormRef" :class="$style.passwordForm">
|
||||
<N8nCallout
|
||||
v-if="ssoRequired"
|
||||
ref="calloutRef"
|
||||
theme="warning"
|
||||
:class="$style.callout"
|
||||
tabindex="-1"
|
||||
data-test-id="sso-required-callout"
|
||||
>
|
||||
<div :class="$style.calloutContent">
|
||||
<N8nText tag="p" size="small" color="text-base" bold>
|
||||
{{ i18n.baseText('auth.signin.ssoRequired.title') }}
|
||||
</N8nText>
|
||||
<N8nText tag="p" size="small" color="text-base">
|
||||
{{ i18n.baseText('auth.signin.ssoRequired') }}
|
||||
</N8nText>
|
||||
<N8nButton
|
||||
variant="subtle"
|
||||
size="small"
|
||||
:class="$style.calloutAction"
|
||||
:label="i18n.baseText('sso.login.button')"
|
||||
:loading="ssoLoading"
|
||||
data-test-id="sso-required-callout-action"
|
||||
@click="emit('ssoLogin')"
|
||||
/>
|
||||
</div>
|
||||
</N8nCallout>
|
||||
|
||||
<div :class="$style.inputsContainer">
|
||||
<N8nFormInputs
|
||||
:inputs="inputs"
|
||||
:event-bus="formBus"
|
||||
:column-view="true"
|
||||
@submit="onSubmit"
|
||||
/>
|
||||
</div>
|
||||
<div :class="$style.buttonsContainer">
|
||||
<N8nButton
|
||||
variant="outline"
|
||||
size="large"
|
||||
:label="form.buttonText"
|
||||
:loading="formLoading"
|
||||
data-test-id="form-submit-button"
|
||||
@click="formBus.emit('submit')"
|
||||
/>
|
||||
</div>
|
||||
<div :class="$style.actionContainer">
|
||||
<N8nLink v-if="form.redirectText && form.redirectLink" :to="form.redirectLink">
|
||||
{{ form.redirectText }}
|
||||
</N8nLink>
|
||||
</div>
|
||||
</div>
|
||||
</N8nAnimatedCollapsibleContent>
|
||||
</CollapsibleRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
@use '@n8n/design-system/css/mixins/motion';
|
||||
|
||||
// Same chrome as N8nFormBox, so the card matches the other auth pages.
|
||||
.container {
|
||||
background-color: var(--color--background--light-3);
|
||||
padding: var(--spacing--lg);
|
||||
border: var(--border);
|
||||
border-radius: var(--radius--lg);
|
||||
box-shadow: 0 4px 16px rgba(99, 77, 255, 0.06);
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
margin-bottom: var(--spacing--xl);
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ssoButton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.divider {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
font-size: var(--font-size--2xs);
|
||||
color: var(--text-color--subtle);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--color--foreground);
|
||||
}
|
||||
|
||||
span {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin: var(--spacing--xs) auto;
|
||||
padding: 0 var(--spacing--sm);
|
||||
background: var(--color--background--light-3);
|
||||
}
|
||||
}
|
||||
|
||||
.passwordSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.revealTrigger {
|
||||
width: 100%;
|
||||
color: var(--text-color--subtle);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
margin-left: var(--spacing--4xs);
|
||||
transition: transform var(--duration--snappy) var(--easing--ease-out);
|
||||
|
||||
@include motion.reduced-motion;
|
||||
}
|
||||
|
||||
.chevronOpen {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
// The collapsible clips its overflow while it animates, which cut the focus
|
||||
// ring of the inputs at the card edges. Widen the clip box past the ring and
|
||||
// pull the content back in with matching padding.
|
||||
.passwordFormContent {
|
||||
margin: 0 calc(-1 * var(--spacing--3xs));
|
||||
padding: 0 var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.passwordForm {
|
||||
padding-top: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.callout {
|
||||
margin-bottom: var(--spacing--sm);
|
||||
|
||||
@include motion.fade-in-down;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
// Top-align the icon with the callout title instead of its vertical middle.
|
||||
> div:first-child {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.calloutContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing--4xs);
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.calloutAction {
|
||||
margin-top: var(--spacing--4xs);
|
||||
}
|
||||
|
||||
.inputsContainer {
|
||||
margin-bottom: var(--spacing--xl);
|
||||
}
|
||||
|
||||
.actionContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.buttonsContainer {
|
||||
composes: actionContainer;
|
||||
margin-bottom: var(--spacing--sm);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -4,13 +4,6 @@ import { createComponentRenderer } from '@/__tests__/render';
|
|||
|
||||
const renderComponent = createComponentRenderer(AuthView, {
|
||||
pinia: createTestingPinia(),
|
||||
global: {
|
||||
stubs: {
|
||||
SSOLogin: {
|
||||
template: '<div data-test-id="sso-login"></div>',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('AuthView', () => {
|
||||
|
|
@ -23,17 +16,26 @@ describe('AuthView', () => {
|
|||
expect(getByText('Some text')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render without SSO component', () => {
|
||||
const { queryByTestId } = renderComponent();
|
||||
expect(queryByTestId('sso-login')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with SSO component', () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
it('should render the form box when a form is passed', () => {
|
||||
const { getByTestId, getByText } = renderComponent({
|
||||
props: {
|
||||
withSso: true,
|
||||
form: { title: 'Form title', inputs: [] },
|
||||
},
|
||||
});
|
||||
expect(getByTestId('sso-login')).toBeInTheDocument();
|
||||
expect(getByTestId('auth-form')).toBeInTheDocument();
|
||||
expect(getByText('Form title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the default slot in place of the form box', () => {
|
||||
const { getByTestId, queryByTestId } = renderComponent({
|
||||
props: {
|
||||
form: { title: 'Form title', inputs: [] },
|
||||
},
|
||||
slots: {
|
||||
default: '<div data-test-id="custom-card"></div>',
|
||||
},
|
||||
});
|
||||
expect(getByTestId('custom-card')).toBeInTheDocument();
|
||||
expect(queryByTestId('auth-form')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { N8nLogo } from '@n8n/design-system';
|
||||
import SSOLogin from '@/features/settings/sso/components/SSOLogin.vue';
|
||||
import type { FormFieldValueUpdate, IFormBoxConfig } from '@/Interface';
|
||||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
import type { EmailOrLdapLoginIdAndPassword } from './SigninView.vue';
|
||||
|
|
@ -8,14 +7,15 @@ import type { EmailOrLdapLoginIdAndPassword } from './SigninView.vue';
|
|||
import { N8nFormBox, N8nText } from '@n8n/design-system';
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
form: IFormBoxConfig;
|
||||
/** The standard form box. Omit it when the default slot renders the card instead. */
|
||||
form?: IFormBoxConfig;
|
||||
formLoading?: boolean;
|
||||
subtitle?: string;
|
||||
withSso?: boolean;
|
||||
}>(),
|
||||
{
|
||||
form: undefined,
|
||||
formLoading: false,
|
||||
withSso: false,
|
||||
subtitle: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -49,16 +49,17 @@ const {
|
|||
<N8nText size="large">{{ subtitle }}</N8nText>
|
||||
</div>
|
||||
<div :class="$style.formContainer">
|
||||
<N8nFormBox
|
||||
v-bind="form"
|
||||
data-test-id="auth-form"
|
||||
:button-loading="formLoading"
|
||||
@secondary-click="onSecondaryClick"
|
||||
@submit="onSubmit"
|
||||
@update="onUpdate"
|
||||
>
|
||||
<SSOLogin v-if="withSso" />
|
||||
</N8nFormBox>
|
||||
<slot>
|
||||
<N8nFormBox
|
||||
v-if="form"
|
||||
v-bind="form"
|
||||
data-test-id="auth-form"
|
||||
:button-loading="formLoading"
|
||||
@secondary-click="onSecondaryClick"
|
||||
@submit="onSubmit"
|
||||
@update="onUpdate"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ import { useUsersStore } from '@n8n/stores/users.store';
|
|||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
import { useTelemetry } from '@n8n/composables/useTelemetry';
|
||||
import { useNotificationsStore } from '@n8n/stores/notifications.store';
|
||||
import { useSSOStore } from '@/features/settings/sso/sso.store';
|
||||
import { VIEWS } from '@/app/constants';
|
||||
import { SSO_LOGIN_REQUIRED_ERROR_CODE } from '@n8n/api-types';
|
||||
|
||||
vi.mock('vue-router', () => {
|
||||
const push = vi.fn();
|
||||
|
|
@ -49,6 +51,7 @@ const renderComponent = createComponentRenderer(SigninView);
|
|||
let usersStore: ReturnType<typeof mockedStore<typeof useUsersStore>>;
|
||||
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
|
||||
let notificationsStore: ReturnType<typeof mockedStore<typeof useNotificationsStore>>;
|
||||
let ssoStore: ReturnType<typeof mockedStore<typeof useSSOStore>>;
|
||||
|
||||
let router: ReturnType<typeof useRouter>;
|
||||
let telemetry: ReturnType<typeof useTelemetry>;
|
||||
|
|
@ -89,6 +92,7 @@ describe('SigninView', () => {
|
|||
usersStore = mockedStore(useUsersStore);
|
||||
settingsStore = mockedStore(useSettingsStore);
|
||||
notificationsStore = mockedStore(useNotificationsStore);
|
||||
ssoStore = mockedStore(useSSOStore);
|
||||
|
||||
router = useRouter();
|
||||
telemetry = useTelemetry();
|
||||
|
|
@ -158,6 +162,13 @@ describe('SigninView', () => {
|
|||
expect(notificationsStore.setNotificationsSuppressed.mock.calls).toEqual([[false], [true]]);
|
||||
});
|
||||
|
||||
it('should not render the SSO card when SSO is not the login method', () => {
|
||||
const { queryByTestId, getByTestId } = renderComponent();
|
||||
|
||||
expect(getByTestId('auth-form')).toBeInTheDocument();
|
||||
expect(queryByTestId('sso-signin-card')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show and submit email/password form (happy path)', async () => {
|
||||
await signInWithValidUser();
|
||||
|
||||
|
|
@ -273,4 +284,110 @@ describe('SigninView', () => {
|
|||
expect(router.push).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when SSO is the active login method', () => {
|
||||
let route: ReturnType<typeof useRoute>;
|
||||
|
||||
const getEmailInput = (container: Element) => container.querySelector('input[type="email"]');
|
||||
|
||||
const submitPasswordLogin = async (
|
||||
container: Element,
|
||||
getByRole: ReturnType<typeof renderComponent>['getByRole'],
|
||||
) => {
|
||||
const emailInput = getEmailInput(container);
|
||||
const passwordInput = container.querySelector('input[type="password"]');
|
||||
if (!emailInput || !passwordInput) {
|
||||
throw new Error('Inputs not found');
|
||||
}
|
||||
|
||||
await userEvent.type(emailInput, 'member@n8n.io');
|
||||
await userEvent.type(passwordInput, 'password');
|
||||
await userEvent.click(getByRole('button', { name: 'Sign in' }));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
route = useRoute();
|
||||
ssoStore.showSsoLoginButton = true;
|
||||
settingsStore.isCloudDeployment = false;
|
||||
settingsStore.activeModules = [];
|
||||
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { href: '', origin: 'https://n8n.local' },
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should lead with the SSO button and keep the password form collapsed', () => {
|
||||
const { getByTestId, getByRole, queryByTestId, container } = renderComponent();
|
||||
|
||||
expect(getByTestId('sso-signin-card')).toBeInTheDocument();
|
||||
expect(queryByTestId('auth-form')).not.toBeInTheDocument();
|
||||
expect(getByRole('button', { name: 'Continue with SSO' })).toBeVisible();
|
||||
expect(getEmailInput(container)).not.toBeVisible();
|
||||
});
|
||||
|
||||
it('should reveal the password form when internalAuth=true is in the URL', () => {
|
||||
vi.spyOn(route, 'query', 'get').mockReturnValue({ internalAuth: 'true' });
|
||||
|
||||
const { container } = renderComponent();
|
||||
|
||||
expect(getEmailInput(container)).toBeVisible();
|
||||
});
|
||||
|
||||
it('should redirect to the SSO login URL for the requested destination', async () => {
|
||||
ssoStore.getSsoLoginUrl.mockResolvedValue('https://idp.example.com/saml');
|
||||
const hrefSpy = vi.spyOn(window.location, 'href', 'set');
|
||||
|
||||
const { getByRole } = renderComponent();
|
||||
await userEvent.click(getByRole('button', { name: 'Continue with SSO' }));
|
||||
|
||||
expect(ssoStore.getSsoLoginUrl).toHaveBeenCalledWith('/home/workflows');
|
||||
expect(hrefSpy).toHaveBeenCalledWith('https://idp.example.com/saml');
|
||||
});
|
||||
|
||||
it('should show a toast when the SSO login URL cannot be resolved', async () => {
|
||||
ssoStore.getSsoLoginUrl.mockRejectedValue(new Error('SAML is not configured'));
|
||||
|
||||
const { getByRole } = renderComponent();
|
||||
await userEvent.click(getByRole('button', { name: 'Continue with SSO' }));
|
||||
|
||||
// Lifted so the error is not swallowed after a session-expiry redirect.
|
||||
expect(notificationsStore.setNotificationsSuppressed).toHaveBeenCalledWith(false);
|
||||
expect(showError).toHaveBeenCalledWith(expect.any(Error), 'Problem logging in');
|
||||
});
|
||||
|
||||
it('should show an inline callout instead of a toast when password sign-in requires SSO', async () => {
|
||||
vi.spyOn(route, 'query', 'get').mockReturnValue({ internalAuth: 'true' });
|
||||
usersStore.loginWithCreds.mockRejectedValueOnce(
|
||||
Object.assign(new Error('SSO is enabled, please log in with SSO'), {
|
||||
errorCode: SSO_LOGIN_REQUIRED_ERROR_CODE,
|
||||
}),
|
||||
);
|
||||
|
||||
const { getByRole, getByTestId, queryByTestId, container } = renderComponent();
|
||||
expect(queryByTestId('sso-required-callout')).not.toBeInTheDocument();
|
||||
|
||||
await submitPasswordLogin(container, getByRole);
|
||||
|
||||
expect(getByTestId('sso-required-callout')).toBeVisible();
|
||||
expect(showError).not.toHaveBeenCalled();
|
||||
expect(telemetry.track).toHaveBeenCalledWith('User attempted to login', {
|
||||
result: 'credentials_error',
|
||||
});
|
||||
});
|
||||
|
||||
it('should still show a toast for other login errors', async () => {
|
||||
vi.spyOn(route, 'query', 'get').mockReturnValue({ internalAuth: 'true' });
|
||||
usersStore.loginWithCreds.mockRejectedValueOnce(
|
||||
Object.assign(new Error('Wrong username or password'), { errorCode: 401 }),
|
||||
);
|
||||
|
||||
const { getByRole, queryByTestId, container } = renderComponent();
|
||||
|
||||
await submitPasswordLogin(container, getByRole);
|
||||
|
||||
expect(queryByTestId('sso-required-callout')).not.toBeInTheDocument();
|
||||
expect(showError).toHaveBeenCalledWith(expect.any(Error), 'Problem logging in');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router';
|
|||
|
||||
import AuthView from './AuthView.vue';
|
||||
import MfaView from './MfaView.vue';
|
||||
import SsoSigninCard from '../components/SsoSigninCard.vue';
|
||||
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
|
|
@ -15,12 +16,18 @@ import { useSettingsStore } from '@n8n/stores/settings.store';
|
|||
import { useSSOStore } from '@/features/settings/sso/sso.store';
|
||||
|
||||
import type { IFormBoxConfig } from '@/Interface';
|
||||
import { MFA_AUTHENTICATION_REQUIRED_ERROR_CODE, VIEWS, MFA_FORM } from '@/app/constants';
|
||||
import {
|
||||
INTERNAL_AUTH_QUERY_PARAM,
|
||||
MFA_AUTHENTICATION_REQUIRED_ERROR_CODE,
|
||||
VIEWS,
|
||||
MFA_FORM,
|
||||
} from '@/app/constants';
|
||||
import type { LoginRequestDto } from '@n8n/api-types';
|
||||
import {
|
||||
SSO_ERROR_ACCESS_DENIED,
|
||||
SSO_ERROR_LOGIN_FAILED,
|
||||
SSO_ERROR_QUERY_PARAM,
|
||||
SSO_LOGIN_REQUIRED_ERROR_CODE,
|
||||
} from '@n8n/api-types';
|
||||
|
||||
export type EmailOrLdapLoginIdAndPassword = Pick<
|
||||
|
|
@ -47,6 +54,14 @@ const emailOrLdapLoginId = ref('');
|
|||
const password = ref('');
|
||||
const reportError = ref(false);
|
||||
|
||||
// SSO (SAML or OIDC) is the active login method: lead with it and keep the
|
||||
// password form behind a disclosure. `?internalAuth=true` reveals the form up
|
||||
// front so an admin can still sign in when the identity provider is down.
|
||||
const isSsoLogin = computed(() => ssoStore.showSsoLoginButton);
|
||||
const isInternalAuthRequested = computed(() => route.query[INTERNAL_AUTH_QUERY_PARAM] === 'true');
|
||||
const ssoLoading = ref(false);
|
||||
const ssoRequired = ref(false);
|
||||
|
||||
const notificationsStore = useNotificationsStore();
|
||||
|
||||
// Notifications are suppressed on the auth views, so lift the suppression just
|
||||
|
|
@ -157,6 +172,21 @@ const onEmailPasswordSubmitted = async (form: EmailOrLdapLoginIdAndPassword) =>
|
|||
await login(form);
|
||||
};
|
||||
|
||||
const onSsoLogin = async () => {
|
||||
// The user acted, so an error about the SSO URL must be visible even after a
|
||||
// session-expiry redirect suppressed notifications.
|
||||
notificationsStore.setNotificationsSuppressed(false);
|
||||
ssoLoading.value = true;
|
||||
try {
|
||||
window.location.href = await ssoStore.getSsoLoginUrl(
|
||||
typeof route.query?.redirect === 'string' ? route.query.redirect : '',
|
||||
);
|
||||
} catch (error) {
|
||||
ssoLoading.value = false;
|
||||
toast.showError(error, locale.baseText('auth.signin.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const isRedirectSafe = () => {
|
||||
const redirect = getRedirectQueryParameter();
|
||||
|
||||
|
|
@ -184,6 +214,7 @@ const getRedirectQueryParameter = () => {
|
|||
|
||||
const login = async (form: LoginRequestDto) => {
|
||||
notificationsStore.setNotificationsSuppressed(false);
|
||||
ssoRequired.value = false;
|
||||
try {
|
||||
loading.value = true;
|
||||
await usersStore.loginWithCreds({
|
||||
|
|
@ -229,6 +260,18 @@ const login = async (form: LoginRequestDto) => {
|
|||
result: showMfaView.value ? 'mfa_token_rejected' : 'credentials_error',
|
||||
});
|
||||
|
||||
// The account has to sign in through SSO: explain that inline, next to the
|
||||
// SSO button, instead of surfacing it as a generic login failure.
|
||||
if (
|
||||
error.errorCode === SSO_LOGIN_REQUIRED_ERROR_CODE &&
|
||||
isSsoLogin.value &&
|
||||
!showMfaView.value
|
||||
) {
|
||||
ssoRequired.value = true;
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!showMfaView.value) {
|
||||
toast.showError(error, locale.baseText('auth.signin.error'));
|
||||
loading.value = false;
|
||||
|
|
@ -259,14 +302,26 @@ const cacheCredentials = (form: EmailOrLdapLoginIdAndPassword) => {
|
|||
|
||||
<template>
|
||||
<div>
|
||||
<AuthView
|
||||
v-if="!showMfaView"
|
||||
:form="formConfig"
|
||||
:form-loading="loading"
|
||||
:with-sso="true"
|
||||
data-test-id="signin-form"
|
||||
@submit="onEmailPasswordSubmitted"
|
||||
/>
|
||||
<template v-if="!showMfaView">
|
||||
<AuthView v-if="isSsoLogin" data-test-id="signin-form">
|
||||
<SsoSigninCard
|
||||
:form="formConfig"
|
||||
:form-loading="loading"
|
||||
:sso-loading="ssoLoading"
|
||||
:sso-required="ssoRequired"
|
||||
:default-expanded="isInternalAuthRequested"
|
||||
@submit="onEmailPasswordSubmitted"
|
||||
@sso-login="onSsoLogin"
|
||||
/>
|
||||
</AuthView>
|
||||
<AuthView
|
||||
v-else
|
||||
:form="formConfig"
|
||||
:form-loading="loading"
|
||||
data-test-id="signin-form"
|
||||
@submit="onEmailPasswordSubmitted"
|
||||
/>
|
||||
</template>
|
||||
<MfaView
|
||||
v-if="showMfaView"
|
||||
:report-error="reportError"
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
import { createTestingPinia } from '@pinia/testing';
|
||||
import merge from 'lodash/merge';
|
||||
import SSOLogin from './SSOLogin.vue';
|
||||
import { STORES } from '@n8n/stores';
|
||||
import { useSSOStore } from '../sso.store';
|
||||
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
|
||||
import { afterEach } from 'vitest';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
|
||||
let pinia: ReturnType<typeof createTestingPinia>;
|
||||
let ssoStore: ReturnType<typeof useSSOStore>;
|
||||
|
||||
const renderComponent = createComponentRenderer(SSOLogin, {
|
||||
global: {
|
||||
stubs: {
|
||||
N8nButton: {
|
||||
template: '<button data-test-id="sso-button"></button>',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('SSOLogin', () => {
|
||||
beforeEach(() => {
|
||||
pinia = createTestingPinia({
|
||||
initialState: {
|
||||
[STORES.SETTINGS]: {
|
||||
settings: merge({}, SETTINGS_STORE_DEFAULT_STATE.settings),
|
||||
},
|
||||
},
|
||||
});
|
||||
ssoStore = useSSOStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should not render button if conditions are not met', () => {
|
||||
const { queryByRole } = renderComponent({ pinia });
|
||||
expect(queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render button if the store returns true for the conditions', () => {
|
||||
vi.spyOn(ssoStore, 'showSsoLoginButton', 'get').mockReturnValue(true);
|
||||
const { queryByRole } = renderComponent({ pinia });
|
||||
expect(queryByRole('button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
<script lang="ts" setup>
|
||||
import { useSSOStore } from '../sso.store';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { N8nButton } from '@n8n/design-system';
|
||||
const i18n = useI18n();
|
||||
const ssoStore = useSSOStore();
|
||||
const toast = useToast();
|
||||
const route = useRoute();
|
||||
|
||||
const onSSOLogin = async () => {
|
||||
try {
|
||||
const redirectUrl = ssoStore.isDefaultAuthenticationSaml
|
||||
? await ssoStore.getSSORedirectUrl(
|
||||
typeof route.query?.redirect === 'string' ? route.query.redirect : '',
|
||||
)
|
||||
: ssoStore.oidc.loginUrl;
|
||||
window.location.href = redirectUrl ?? '';
|
||||
} catch (error) {
|
||||
toast.showError(error, 'Error', { message: error.message });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="ssoStore.showSsoLoginButton" :class="$style.ssoLogin">
|
||||
<div :class="$style.divider">
|
||||
<span>{{ i18n.baseText('sso.login.divider') }}</span>
|
||||
</div>
|
||||
<N8nButton
|
||||
variant="outline"
|
||||
size="large"
|
||||
:label="i18n.baseText('sso.login.button')"
|
||||
@click="onSSOLogin"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.ssoLogin {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
text-transform: uppercase;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--color--foreground);
|
||||
}
|
||||
|
||||
span {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin: var(--spacing--2xs) auto;
|
||||
padding: var(--spacing--lg);
|
||||
background: var(--color--background--light-3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -35,6 +35,26 @@ export const useSSOStore = defineStore('sso', () => {
|
|||
const getSSORedirectUrl = async (existingRedirect?: string) =>
|
||||
await ssoApi.initSSO(rootStore.restApiContext, existingRedirect);
|
||||
|
||||
/**
|
||||
* Browser URL that starts the login with the active SSO protocol. SAML asks the
|
||||
* backend for its redirect; OIDC has a static login endpoint that takes the
|
||||
* in-app destination as a query parameter and returns there after the callback.
|
||||
*/
|
||||
const getSsoLoginUrl = async (existingRedirect = ''): Promise<string> => {
|
||||
if (isDefaultAuthenticationSaml.value) {
|
||||
return await getSSORedirectUrl(existingRedirect);
|
||||
}
|
||||
|
||||
const oidcLoginUrl = oidc.value.loginUrl;
|
||||
if (!oidcLoginUrl) {
|
||||
throw new Error('The OIDC login URL is not configured');
|
||||
}
|
||||
if (!existingRedirect) return oidcLoginUrl;
|
||||
|
||||
const separator = oidcLoginUrl.includes('?') ? '&' : '?';
|
||||
return `${oidcLoginUrl}${separator}${new URLSearchParams({ redirect: existingRedirect })}`;
|
||||
};
|
||||
|
||||
const initialize = (options: {
|
||||
authenticationMethod: AuthenticationMethod;
|
||||
managedByEnv?: boolean;
|
||||
|
|
@ -211,6 +231,7 @@ export const useSSOStore = defineStore('sso', () => {
|
|||
return {
|
||||
showSsoLoginButton,
|
||||
getSSORedirectUrl,
|
||||
getSsoLoginUrl,
|
||||
initialize,
|
||||
selectedAuthProtocol,
|
||||
initializeSelectedProtocol,
|
||||
|
|
|
|||
|
|
@ -40,6 +40,54 @@ describe('SSO store', () => {
|
|||
},
|
||||
);
|
||||
|
||||
describe('getSsoLoginUrl', () => {
|
||||
const oidcLoginUrl = 'http://localhost:5678/rest/sso/oidc/login';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should ask the backend for the SAML redirect with the destination', async () => {
|
||||
vi.mocked(ssoApi.initSSO).mockResolvedValue('https://idp.example.com/saml?SAMLRequest=x');
|
||||
ssoStore.initialize({
|
||||
authenticationMethod: 'saml' as AuthenticationMethod,
|
||||
config: { saml: { loginEnabled: true } },
|
||||
features: { saml: true, ldap: false, oidc: false },
|
||||
});
|
||||
|
||||
await expect(ssoStore.getSsoLoginUrl('/workflow/abc')).resolves.toBe(
|
||||
'https://idp.example.com/saml?SAMLRequest=x',
|
||||
);
|
||||
expect(ssoApi.initSSO).toHaveBeenCalledWith(expect.anything(), '/workflow/abc');
|
||||
});
|
||||
|
||||
it('should append the destination to the OIDC login URL', async () => {
|
||||
ssoStore.initialize({
|
||||
authenticationMethod: 'oidc' as AuthenticationMethod,
|
||||
config: { oidc: { loginEnabled: true, loginUrl: oidcLoginUrl } },
|
||||
features: { saml: false, ldap: false, oidc: true },
|
||||
});
|
||||
|
||||
await expect(ssoStore.getSsoLoginUrl('/workflow/abc?tab=1&x=2')).resolves.toBe(
|
||||
`${oidcLoginUrl}?redirect=%2Fworkflow%2Fabc%3Ftab%3D1%26x%3D2`,
|
||||
);
|
||||
await expect(ssoStore.getSsoLoginUrl()).resolves.toBe(oidcLoginUrl);
|
||||
expect(ssoApi.initSSO).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject when the OIDC login URL is missing', async () => {
|
||||
ssoStore.initialize({
|
||||
authenticationMethod: 'oidc' as AuthenticationMethod,
|
||||
config: { oidc: { loginEnabled: true } },
|
||||
features: { saml: false, ldap: false, oidc: true },
|
||||
});
|
||||
|
||||
await expect(ssoStore.getSsoLoginUrl('/home')).rejects.toThrow(
|
||||
'The OIDC login URL is not configured',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OIDC callbackUrl after re-initialization', () => {
|
||||
it('should populate callbackUrl when re-initialized with authenticated settings', () => {
|
||||
// Simulate public settings (before login) — no callbackUrl
|
||||
|
|
|
|||
|
|
@ -12,11 +12,17 @@ export class SignInPage extends BasePage {
|
|||
}
|
||||
|
||||
getSubmitButton(): Locator {
|
||||
return this.page.getByRole('button', { name: 'Sign in' });
|
||||
// `exact` keeps the "Sign in with email and password" disclosure out of the match.
|
||||
return this.page.getByRole('button', { name: 'Sign in', exact: true });
|
||||
}
|
||||
|
||||
getSsoButton(): Locator {
|
||||
return this.page.getByRole('button', { name: /continue with sso/i });
|
||||
return this.page.getByTestId('sso-login-button');
|
||||
}
|
||||
|
||||
/** Disclosure that reveals the email/password form when SSO is the active login method. */
|
||||
getPasswordLoginToggle(): Locator {
|
||||
return this.page.getByTestId('reveal-password-login');
|
||||
}
|
||||
|
||||
async goto(): Promise<void> {
|
||||
|
|
@ -47,6 +53,11 @@ export class SignInPage extends BasePage {
|
|||
waitForWorkflow = false,
|
||||
): Promise<void> {
|
||||
await this.goto();
|
||||
// On SSO instances the password form starts collapsed behind a disclosure.
|
||||
await this.getEmailField().or(this.getPasswordLoginToggle()).first().waitFor();
|
||||
if (await this.getPasswordLoginToggle().isVisible()) {
|
||||
await this.getPasswordLoginToggle().click();
|
||||
}
|
||||
await this.fillEmail(email);
|
||||
await this.fillPassword(password);
|
||||
await this.clickSubmit();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user