mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
test: add ui tests for sign-up and sign-in settings (#4373)
This commit is contained in:
parent
e13107438c
commit
462f677cdc
8 changed files with 1443 additions and 138 deletions
|
@ -1,138 +0,0 @@
|
||||||
import { logtoConsoleUrl as logtoConsoleUrlString } from '#src/constants.js';
|
|
||||||
import { goToAdminConsole, trySaveChanges, waitForToaster } from '#src/ui-helpers/index.js';
|
|
||||||
import { appendPathname, expectNavigation } from '#src/utils.js';
|
|
||||||
|
|
||||||
await page.setViewport({ width: 1920, height: 1080 });
|
|
||||||
|
|
||||||
const defaultPrimaryColor = '#6139F6';
|
|
||||||
const testPrimaryColor = '#5B4D8E';
|
|
||||||
|
|
||||||
describe('sign-in experience', () => {
|
|
||||||
const logtoConsoleUrl = new URL(logtoConsoleUrlString);
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
await goToAdminConsole();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('navigate to sign-in experience page', async () => {
|
|
||||||
await expectNavigation(
|
|
||||||
page.goto(appendPathname('/console/sign-in-experience', logtoConsoleUrl).href)
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(page).toMatchElement(
|
|
||||||
'div[class$=main] div[class$=container] div[class$=cardTitle] div[class$=titleEllipsis]',
|
|
||||||
{
|
|
||||||
text: 'Sign-in experience',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Start & finish guide
|
|
||||||
await expect(page).toClick('div[class$=container] div[class$=content] button span', {
|
|
||||||
text: 'Get Started',
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(page).toClick(
|
|
||||||
'div[class$=ReactModalPortal] div[class$=footerContent] > button span',
|
|
||||||
{
|
|
||||||
text: 'Done',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Land on branding tab by default
|
|
||||||
expect(page.url()).toBe(new URL(`console/sign-in-experience/branding`, logtoConsoleUrl).href);
|
|
||||||
|
|
||||||
// Wait for the branding tab to load
|
|
||||||
await expect(page).toMatchElement('div[class$=tabContent] div[class$=card] div[class$=title]', {
|
|
||||||
text: 'BRANDING AREA',
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(page).toMatchElement('div[class$=tabContent] div[class$=card] div[class$=title]', {
|
|
||||||
text: 'Custom CSS',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('update branding config', () => {
|
|
||||||
it('update branding config', async () => {
|
|
||||||
// Enabled dark mode
|
|
||||||
await expect(page).toClick(
|
|
||||||
'form div[class$=field] label[class$=switch]:has(input[name="color.isDarkModeEnabled"])'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update brand color
|
|
||||||
const brandColorField = await expect(page).toMatchElement(
|
|
||||||
'div[class$=field]:has(div[class$=headline] div[class$=title])',
|
|
||||||
{
|
|
||||||
text: 'Brand color',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(brandColorField).toClick('div[role=button]');
|
|
||||||
|
|
||||||
await expect(page).toFill('input[id^=rc-editable-input]', testPrimaryColor);
|
|
||||||
|
|
||||||
// Close the color input
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
|
|
||||||
// Recalculate dark brand color
|
|
||||||
await expect(page).toClick('div[class$=darkModeTip] button span', { text: 'Recalculate' });
|
|
||||||
|
|
||||||
// Wait for the recalculate to finish
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
|
|
||||||
// Fill in the custom CSS
|
|
||||||
await expect(page).toFill(
|
|
||||||
'div[class$=editor] textarea',
|
|
||||||
'body { background-color: #5B4D8E; }'
|
|
||||||
);
|
|
||||||
|
|
||||||
await trySaveChanges(page);
|
|
||||||
|
|
||||||
await waitForToaster(page, {
|
|
||||||
text: 'Saved',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reset branding config', async () => {
|
|
||||||
// Reset branding config
|
|
||||||
const brandColorField = await expect(page).toMatchElement(
|
|
||||||
'div[class$=field]:has(div[class$=headline] div[class$=title])',
|
|
||||||
{
|
|
||||||
text: 'Brand color',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(brandColorField).toClick('div[role=button]');
|
|
||||||
|
|
||||||
await expect(page).toFill('input[id^=rc-editable-input]', defaultPrimaryColor);
|
|
||||||
|
|
||||||
// Close the color input
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
|
|
||||||
// Recalculate dark brand color
|
|
||||||
await expect(page).toClick('div[class$=darkModeTip] button span', { text: 'Recalculate' });
|
|
||||||
|
|
||||||
// Wait for the recalculate to finish
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
|
|
||||||
// Fill in the custom CSS
|
|
||||||
await expect(page).toFill('div[class$=editor] textarea', '');
|
|
||||||
|
|
||||||
await trySaveChanges(page);
|
|
||||||
|
|
||||||
await waitForToaster(page, {
|
|
||||||
text: 'Saved',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Disable dark mode
|
|
||||||
await expect(page).toClick(
|
|
||||||
'form div[class$=field] label[class$=switch]:has(input[name="color.isDarkModeEnabled"])'
|
|
||||||
);
|
|
||||||
|
|
||||||
await trySaveChanges(page);
|
|
||||||
|
|
||||||
await waitForToaster(page, {
|
|
||||||
text: 'Saved',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { logtoConsoleUrl as logtoConsoleUrlString } from '#src/constants.js';
|
||||||
|
import { goToAdminConsole } from '#src/ui-helpers/index.js';
|
||||||
|
import { expectNavigation, appendPathname } from '#src/utils.js';
|
||||||
|
|
||||||
|
import { waitForFormCard, expectToSelectColor, expectToSaveSignInExperience } from './helpers.js';
|
||||||
|
|
||||||
|
const defaultPrimaryColor = '#6139F6';
|
||||||
|
const testPrimaryColor = '#5B4D8E';
|
||||||
|
|
||||||
|
await page.setViewport({ width: 1920, height: 1080 });
|
||||||
|
|
||||||
|
describe('sign-in experience: branding', () => {
|
||||||
|
const logtoConsoleUrl = new URL(logtoConsoleUrlString);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await goToAdminConsole();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigate to sign-in experience page', async () => {
|
||||||
|
await expectNavigation(
|
||||||
|
page.goto(appendPathname('/console/sign-in-experience', logtoConsoleUrl).href)
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(page).toMatchElement(
|
||||||
|
'div[class$=main] div[class$=container] div[class$=cardTitle] div[class$=titleEllipsis]',
|
||||||
|
{
|
||||||
|
text: 'Sign-in experience',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start & finish guide
|
||||||
|
await expect(page).toClick('div[class$=container] div[class$=content] button span', {
|
||||||
|
text: 'Get Started',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page).toClick(
|
||||||
|
'div[class$=ReactModalPortal] div[class$=footerContent] > button span',
|
||||||
|
{
|
||||||
|
text: 'Done',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Land on branding tab by default
|
||||||
|
expect(page.url()).toBe(new URL(`console/sign-in-experience/branding`, logtoConsoleUrl).href);
|
||||||
|
|
||||||
|
// Wait for the branding tab to load
|
||||||
|
await waitForFormCard(page, 'BRANDING AREA');
|
||||||
|
await waitForFormCard(page, 'Custom CSS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update branding config', async () => {
|
||||||
|
// Enabled dark mode
|
||||||
|
await expect(page).toClick(
|
||||||
|
'form div[class$=field] label[class$=switch]:has(input[name="color.isDarkModeEnabled"])'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update brand color
|
||||||
|
await expectToSelectColor(page, {
|
||||||
|
field: 'Brand color',
|
||||||
|
color: testPrimaryColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recalculate dark brand color
|
||||||
|
await expect(page).toClick('div[class$=darkModeTip] button span', { text: 'Recalculate' });
|
||||||
|
|
||||||
|
// Wait for the recalculate to finish
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Fill in the custom CSS
|
||||||
|
await expect(page).toFill('div[class$=editor] textarea', 'body { background-color: #5B4D8E; }');
|
||||||
|
|
||||||
|
await expectToSaveSignInExperience(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reset branding config', async () => {
|
||||||
|
// Reset branding config
|
||||||
|
await expectToSelectColor(page, {
|
||||||
|
field: 'Brand color',
|
||||||
|
color: defaultPrimaryColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recalculate dark brand color
|
||||||
|
await expect(page).toClick('div[class$=darkModeTip] button span', { text: 'Recalculate' });
|
||||||
|
|
||||||
|
// Wait for the recalculate to finish
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Fill in the custom CSS
|
||||||
|
await expect(page).toFill('div[class$=editor] textarea', '');
|
||||||
|
|
||||||
|
await expectToSaveSignInExperience(page);
|
||||||
|
|
||||||
|
// Disable dark mode
|
||||||
|
await expect(page).toClick(
|
||||||
|
'form div[class$=field] label[class$=switch]:has(input[name="color.isDarkModeEnabled"])'
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectToSaveSignInExperience(page);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { type Page } from 'puppeteer';
|
||||||
|
|
||||||
|
import { trySaveChanges, expectConfirmModalAndAct, waitForToaster } from '#src/ui-helpers/index.js';
|
||||||
|
|
||||||
|
export const waitForFormCard = async (page: Page, title: string) => {
|
||||||
|
await expect(page).toMatchElement('div[class$=tabContent] div[class$=card] div[class$=title]', {
|
||||||
|
text: title,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExpectToSelectColorOptions = {
|
||||||
|
field: string;
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const expectToSelectColor = async (
|
||||||
|
page: Page,
|
||||||
|
{ field, color }: ExpectToSelectColorOptions
|
||||||
|
) => {
|
||||||
|
const colorField = await expect(page).toMatchElement(
|
||||||
|
'div[class$=field]:has(div[class$=headline] div[class$=title])',
|
||||||
|
{
|
||||||
|
text: field,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(colorField).toClick('div[role=button]');
|
||||||
|
|
||||||
|
await expect(page).toFill('input[id^=rc-editable-input]', color);
|
||||||
|
|
||||||
|
// Close the color input
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExpectToSaveSignInExperienceOptions = {
|
||||||
|
needToConfirmChanges?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const expectToSaveSignInExperience = async (
|
||||||
|
page: Page,
|
||||||
|
options?: ExpectToSaveSignInExperienceOptions
|
||||||
|
) => {
|
||||||
|
const { needToConfirmChanges = false } = options ?? {};
|
||||||
|
|
||||||
|
await trySaveChanges(page);
|
||||||
|
|
||||||
|
if (needToConfirmChanges) {
|
||||||
|
// Confirm changes
|
||||||
|
await expectConfirmModalAndAct(page, {
|
||||||
|
title: 'Reminder',
|
||||||
|
actionText: 'Confirm',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await waitForToaster(page, {
|
||||||
|
text: 'Saved',
|
||||||
|
});
|
||||||
|
};
|
|
@ -0,0 +1,161 @@
|
||||||
|
import { ConnectorType } from '@logto/schemas';
|
||||||
|
import { type Page } from 'puppeteer';
|
||||||
|
|
||||||
|
import { logtoConsoleUrl as logtoConsoleUrlString } from '#src/constants.js';
|
||||||
|
import { expectToClickDetailsPageOption, waitForToaster } from '#src/ui-helpers/index.js';
|
||||||
|
import { expectNavigation, appendPathname } from '#src/utils.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
expectToConfirmConnectorDeletion,
|
||||||
|
expectToSelectConnector,
|
||||||
|
waitForConnectorCreationGuide,
|
||||||
|
} from '../../connectors/helpers.js';
|
||||||
|
|
||||||
|
const logtoConsoleUrl = new URL(logtoConsoleUrlString);
|
||||||
|
|
||||||
|
type TestConnector = {
|
||||||
|
factoryId: string;
|
||||||
|
name: string;
|
||||||
|
connectorType: ConnectorType;
|
||||||
|
data: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const testSendgridConnector: TestConnector = {
|
||||||
|
factoryId: 'sendgrid-email-service',
|
||||||
|
name: 'SendGrid Email',
|
||||||
|
connectorType: ConnectorType.Email,
|
||||||
|
data: {
|
||||||
|
'formConfig.apiKey': 'api-key',
|
||||||
|
'formConfig.fromEmail': 'foo@example.com',
|
||||||
|
'formConfig.fromName': 'Logto',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const testTwilioConnector: TestConnector = {
|
||||||
|
factoryId: 'twilio-short-message-service',
|
||||||
|
name: 'Twilio SMS Service',
|
||||||
|
connectorType: ConnectorType.Sms,
|
||||||
|
data: {
|
||||||
|
'formConfig.accountSID': 'account-sid',
|
||||||
|
'formConfig.authToken': 'auth-token',
|
||||||
|
'formConfig.fromMessagingServiceSID': 'from-messaging-service-sid',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const testAppleConnector: TestConnector = {
|
||||||
|
factoryId: 'apple-universal',
|
||||||
|
name: 'Apple',
|
||||||
|
connectorType: ConnectorType.Social,
|
||||||
|
data: {
|
||||||
|
'formConfig.clientId': 'client-id',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const expectToSetupPasswordlessConnector = async (
|
||||||
|
page: Page,
|
||||||
|
{ factoryId, name, connectorType, data }: TestConnector
|
||||||
|
) => {
|
||||||
|
if (connectorType === ConnectorType.Social) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await expectNavigation(
|
||||||
|
page.goto(appendPathname('/console/connectors/passwordless', logtoConsoleUrl).href)
|
||||||
|
);
|
||||||
|
|
||||||
|
const connectorItem = await expect(page).toMatchElement(
|
||||||
|
'div[class$=item] div[class$=previewTitle]:has(>div)',
|
||||||
|
{
|
||||||
|
text: connectorType === ConnectorType.Email ? 'Email connector' : 'SMS connector',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const setupConnectorButton = await expect(connectorItem).toMatchElement('button span', {
|
||||||
|
text: 'Set Up',
|
||||||
|
});
|
||||||
|
|
||||||
|
await setupConnectorButton.click();
|
||||||
|
|
||||||
|
await setupConnectorButton.click();
|
||||||
|
|
||||||
|
await expectToSelectConnector(page, {
|
||||||
|
factoryId,
|
||||||
|
connectorType,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForConnectorCreationGuide(page, name);
|
||||||
|
await expect(page).toFillForm('.ReactModalPortal form', data);
|
||||||
|
|
||||||
|
await expect(page).toClick('.ReactModalPortal form div[class$=footer] button[type=submit] span', {
|
||||||
|
text: 'Save and Done',
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForToaster(page, { text: 'Saved' });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const expectToSetupSocialConnector = async (
|
||||||
|
page: Page,
|
||||||
|
{ factoryId, name, connectorType, data }: TestConnector
|
||||||
|
) => {
|
||||||
|
if (connectorType !== ConnectorType.Social) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await expectNavigation(
|
||||||
|
page.goto(appendPathname('/console/connectors/social', logtoConsoleUrl).href)
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(page).toClick('div[class$=headline] button[class$=withIcon] span', {
|
||||||
|
text: 'Add Social Connector',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectToSelectConnector(page, {
|
||||||
|
factoryId,
|
||||||
|
connectorType,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForConnectorCreationGuide(page, name);
|
||||||
|
await expect(page).toFillForm('.ReactModalPortal form', data);
|
||||||
|
|
||||||
|
await expect(page).toClick('.ReactModalPortal form div[class$=footer] button[type=submit] span', {
|
||||||
|
text: 'Save and Done',
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForToaster(page, { text: 'Saved' });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const expectToDeletePasswordlessConnector = async (page: Page, { name }: TestConnector) => {
|
||||||
|
await expectNavigation(
|
||||||
|
page.goto(appendPathname('/console/connectors/passwordless', logtoConsoleUrl).href)
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(page).toClick('table tbody tr td div[class$=item] a[class$=title] span', {
|
||||||
|
text: name,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page).toMatchElement('div[class$=header] div[class$=name] span', {
|
||||||
|
text: name,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectToClickDetailsPageOption(page, 'Delete');
|
||||||
|
|
||||||
|
await expectToConfirmConnectorDeletion(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const expectToDeleteSocialConnector = async (page: Page, { name }: TestConnector) => {
|
||||||
|
await expectNavigation(
|
||||||
|
page.goto(appendPathname('/console/connectors/social', logtoConsoleUrl).href)
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(page).toClick('table tbody tr td div[class$=item] a[class$=title] span', {
|
||||||
|
text: name,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page).toMatchElement('div[class$=header] div[class$=name] span', {
|
||||||
|
text: name,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectToClickDetailsPageOption(page, 'Delete');
|
||||||
|
|
||||||
|
await expectToConfirmConnectorDeletion(page);
|
||||||
|
};
|
|
@ -0,0 +1,577 @@
|
||||||
|
import { logtoConsoleUrl as logtoConsoleUrlString } from '#src/constants.js';
|
||||||
|
import { expectToClickNavTab, goToAdminConsole } from '#src/ui-helpers/index.js';
|
||||||
|
import { expectNavigation, appendPathname } from '#src/utils.js';
|
||||||
|
|
||||||
|
import { expectToSaveSignInExperience, waitForFormCard } from '../helpers.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
expectToDeletePasswordlessConnector,
|
||||||
|
expectToSetupPasswordlessConnector,
|
||||||
|
testSendgridConnector,
|
||||||
|
testTwilioConnector,
|
||||||
|
expectToSetupSocialConnector,
|
||||||
|
testAppleConnector,
|
||||||
|
expectToDeleteSocialConnector,
|
||||||
|
} from './connector-setup-helpers.js';
|
||||||
|
import {
|
||||||
|
expectToAddSignInMethod,
|
||||||
|
expectToAddSocialSignInConnector,
|
||||||
|
expectToClickSignInMethodAuthnOption,
|
||||||
|
expectToClickSignUpAuthnOption,
|
||||||
|
expectToRemoveSignInMethod,
|
||||||
|
expectToRemoveSocialSignInConnector,
|
||||||
|
expectToResetSignUpAndSignInConfig,
|
||||||
|
expectToSelectSignUpIdentifier,
|
||||||
|
expectToSwapSignInMethodAuthnOption,
|
||||||
|
} from './helpers.js';
|
||||||
|
|
||||||
|
await page.setViewport({ width: 1920, height: 1080 });
|
||||||
|
|
||||||
|
describe('sign-in experience(happy path): sign-up and sign-in', () => {
|
||||||
|
const logtoConsoleUrl = new URL(logtoConsoleUrlString);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await goToAdminConsole();
|
||||||
|
// Email connector
|
||||||
|
await expectToSetupPasswordlessConnector(page, testSendgridConnector);
|
||||||
|
// SMS connector
|
||||||
|
await expectToSetupPasswordlessConnector(page, testTwilioConnector);
|
||||||
|
// Social connector
|
||||||
|
await expectToSetupSocialConnector(page, testAppleConnector);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await expectToDeletePasswordlessConnector(page, testSendgridConnector);
|
||||||
|
await expectToDeletePasswordlessConnector(page, testTwilioConnector);
|
||||||
|
await expectToDeleteSocialConnector(page, testAppleConnector);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigate to sign-in experience page', async () => {
|
||||||
|
await expectNavigation(
|
||||||
|
page.goto(appendPathname('/console/sign-in-experience', logtoConsoleUrl).href)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Land on branding tab by default
|
||||||
|
expect(page.url()).toBe(new URL(`console/sign-in-experience/branding`, logtoConsoleUrl).href);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigate to sign-up and sign-in tab', async () => {
|
||||||
|
await expectToClickNavTab(page, 'Sign-up and Sign-in');
|
||||||
|
|
||||||
|
await waitForFormCard(page, 'SIGN UP');
|
||||||
|
await waitForFormCard(page, 'SIGN IN');
|
||||||
|
await waitForFormCard(page, 'SOCIAL SIGN-IN');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('email as sign-up identifier (verify only)', () => {
|
||||||
|
afterAll(async () => {
|
||||||
|
await expectToResetSignUpAndSignInConfig(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('select email as sign-in method and disable password settings for sign-up', async () => {
|
||||||
|
await expectToSelectSignUpIdentifier(page, 'Email address');
|
||||||
|
// Disable password settings for sign-up
|
||||||
|
await expectToClickSignUpAuthnOption(page, 'Create your password');
|
||||||
|
// Username will be added in later tests
|
||||||
|
await expectToRemoveSignInMethod(page, 'Username');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Email address: password + verification code
|
||||||
|
*/
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update email sign-in method', async () => {
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Email address: verification code
|
||||||
|
*/
|
||||||
|
await expectToClickSignInMethodAuthnOption(page, {
|
||||||
|
method: 'Email address',
|
||||||
|
option: 'Password',
|
||||||
|
});
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Email address: verification code + password
|
||||||
|
*/
|
||||||
|
await expectToClickSignInMethodAuthnOption(page, {
|
||||||
|
method: 'Email address',
|
||||||
|
option: 'Password',
|
||||||
|
});
|
||||||
|
await expectToSwapSignInMethodAuthnOption(page, 'Email address');
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('add username sign-in method', async () => {
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Email address: verification code + password
|
||||||
|
* - Username: password
|
||||||
|
*/
|
||||||
|
await expectToAddSignInMethod(page, 'Username');
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('add & update phone number sign-in method', async () => {
|
||||||
|
await expectToAddSignInMethod(page, 'Phone number');
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Email address: verification code + password
|
||||||
|
* - Username: password
|
||||||
|
* - Phone number: password + verification code
|
||||||
|
*/
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Email address: verification code + password
|
||||||
|
* - Username: password
|
||||||
|
* - Phone number: verification code
|
||||||
|
*/
|
||||||
|
await expectToClickSignInMethodAuthnOption(page, {
|
||||||
|
method: 'Phone number',
|
||||||
|
option: 'Password',
|
||||||
|
});
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Email address: verification code + password
|
||||||
|
* - Phone number: verification code
|
||||||
|
*/
|
||||||
|
await expectToRemoveSignInMethod(page, 'Username');
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Email address: verification code + password
|
||||||
|
* - Phone number: password + verification code
|
||||||
|
*/
|
||||||
|
await expectToClickSignInMethodAuthnOption(page, {
|
||||||
|
method: 'Phone number',
|
||||||
|
option: 'Password',
|
||||||
|
});
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('email as sign-up identifier (password & verify)', () => {
|
||||||
|
afterAll(async () => {
|
||||||
|
await expectToResetSignUpAndSignInConfig(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('select email as sign-in method and enable password settings for sign-up', async () => {
|
||||||
|
await expectToSelectSignUpIdentifier(page, 'Email address');
|
||||||
|
// Username will be added in later tests
|
||||||
|
await expectToRemoveSignInMethod(page, 'Username');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Email address: password + verification code
|
||||||
|
*/
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update email sign-in method', async () => {
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Email address: verification code + password
|
||||||
|
*/
|
||||||
|
// Sign-in method: Email address + verification code + password
|
||||||
|
await expectToSwapSignInMethodAuthnOption(page, 'Email address');
|
||||||
|
await expectToSaveSignInExperience(page);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Email address: password
|
||||||
|
*/
|
||||||
|
await expectToClickSignInMethodAuthnOption(page, {
|
||||||
|
method: 'Email address',
|
||||||
|
option: 'Verification code',
|
||||||
|
});
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('add phone number & username as sign-in method', async () => {
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Email address: password
|
||||||
|
* - Phone number: password + verification code
|
||||||
|
*/
|
||||||
|
await expectToAddSignInMethod(page, 'Phone number');
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Email address: password
|
||||||
|
* - Phone number: password
|
||||||
|
*/
|
||||||
|
await expectToClickSignInMethodAuthnOption(page, {
|
||||||
|
method: 'Phone number',
|
||||||
|
option: 'Verification code',
|
||||||
|
});
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Email address: password
|
||||||
|
* - Phone number: password
|
||||||
|
* - Username: password
|
||||||
|
*/
|
||||||
|
await expectToAddSignInMethod(page, 'Username');
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Email address: password
|
||||||
|
* - Phone number: password + verification code
|
||||||
|
* - Username: password
|
||||||
|
*/
|
||||||
|
await expectToClickSignInMethodAuthnOption(page, {
|
||||||
|
method: 'Phone number',
|
||||||
|
option: 'Verification code',
|
||||||
|
});
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('phone as sign-up identifier (verify only)', () => {
|
||||||
|
afterAll(async () => {
|
||||||
|
await expectToResetSignUpAndSignInConfig(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('select email as sign-in method and disable password settings for sign-up', async () => {
|
||||||
|
await expectToSelectSignUpIdentifier(page, 'Phone number');
|
||||||
|
// Disable password settings for sign-up
|
||||||
|
await expectToClickSignUpAuthnOption(page, 'Create your password');
|
||||||
|
// Username will be added in later tests
|
||||||
|
await expectToRemoveSignInMethod(page, 'Username');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Phone number: password + verification code
|
||||||
|
*/
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update sign-in methods', async () => {
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Phone number: verification code + password
|
||||||
|
*/
|
||||||
|
await expectToSwapSignInMethodAuthnOption(page, 'Phone number');
|
||||||
|
await expectToSaveSignInExperience(page);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Phone number: verification code
|
||||||
|
*/
|
||||||
|
await expectToClickSignInMethodAuthnOption(page, {
|
||||||
|
method: 'Phone number',
|
||||||
|
option: 'Password',
|
||||||
|
});
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Phone number: verification code
|
||||||
|
* - Email address: password + verification code
|
||||||
|
*/
|
||||||
|
await expectToAddSignInMethod(page, 'Email address');
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Phone number: verification code
|
||||||
|
* - Email address: verification code
|
||||||
|
*/
|
||||||
|
await expectToClickSignInMethodAuthnOption(page, {
|
||||||
|
method: 'Email address',
|
||||||
|
option: 'Password',
|
||||||
|
});
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Phone number: verification code
|
||||||
|
* - Email address: verification code
|
||||||
|
* - Username: password
|
||||||
|
*/
|
||||||
|
await expectToAddSignInMethod(page, 'Username');
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('phone as sign-up identifier (password & verify)', () => {
|
||||||
|
afterAll(async () => {
|
||||||
|
await expectToResetSignUpAndSignInConfig(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('select email as sign-in method and enable password settings for sign-up', async () => {
|
||||||
|
await expectToSelectSignUpIdentifier(page, 'Phone number');
|
||||||
|
// Username will be added in later tests
|
||||||
|
await expectToRemoveSignInMethod(page, 'Username');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Phone number: password + verification code
|
||||||
|
*/
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update sign-in methods', async () => {
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Phone number: verification code + password
|
||||||
|
*/
|
||||||
|
await expectToSwapSignInMethodAuthnOption(page, 'Phone number');
|
||||||
|
await expectToSaveSignInExperience(page);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Phone number: password
|
||||||
|
*/
|
||||||
|
await expectToClickSignInMethodAuthnOption(page, {
|
||||||
|
method: 'Phone number',
|
||||||
|
option: 'Verification code',
|
||||||
|
});
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Phone number: password
|
||||||
|
* - Username: password
|
||||||
|
*/
|
||||||
|
await expectToAddSignInMethod(page, 'Username');
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('email or phone as sign-up identifier (verify only)', () => {
|
||||||
|
afterAll(async () => {
|
||||||
|
await expectToResetSignUpAndSignInConfig(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('select email or phone as sign-up identifier and disable password settings for sign-up', async () => {
|
||||||
|
await expectToSelectSignUpIdentifier(page, 'Email address or phone number');
|
||||||
|
await expectToClickSignUpAuthnOption(page, 'Create your password');
|
||||||
|
// Username will be added in later tests
|
||||||
|
await expectToRemoveSignInMethod(page, 'Username');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Email address: password + verification code
|
||||||
|
* - Phone number: password + verification code
|
||||||
|
*/
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update sign-in method configs', async () => {
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Email address: verification code + password
|
||||||
|
* - Phone number: verification code + password
|
||||||
|
*/
|
||||||
|
await expectToSwapSignInMethodAuthnOption(page, 'Email address');
|
||||||
|
await expectToSwapSignInMethodAuthnOption(page, 'Phone number');
|
||||||
|
await expectToSaveSignInExperience(page);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Email address: verification code
|
||||||
|
* - Phone number: verification code + password
|
||||||
|
*/
|
||||||
|
await expectToClickSignInMethodAuthnOption(page, {
|
||||||
|
method: 'Email address',
|
||||||
|
option: 'Password',
|
||||||
|
});
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Email address: verification code
|
||||||
|
* - Phone number: verification code
|
||||||
|
*/
|
||||||
|
await expectToClickSignInMethodAuthnOption(page, {
|
||||||
|
method: 'Phone number',
|
||||||
|
option: 'Password',
|
||||||
|
});
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Email address: verification code
|
||||||
|
* - Phone number: verification code
|
||||||
|
* - Username: password
|
||||||
|
*/
|
||||||
|
await expectToAddSignInMethod(page, 'Username');
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('email or phone as sign-up identifier (password & verify)', () => {
|
||||||
|
afterAll(async () => {
|
||||||
|
await expectToResetSignUpAndSignInConfig(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('select email or phone as sign-up identifier and enable password settings for sign-up', async () => {
|
||||||
|
await expectToSelectSignUpIdentifier(page, 'Email address or phone number');
|
||||||
|
// Username will be added in later tests
|
||||||
|
await expectToRemoveSignInMethod(page, 'Username');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Email address: password + verification code
|
||||||
|
* - Phone number: password + verification code
|
||||||
|
*/
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update sign-in method configs', async () => {
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Email address: verification code + password
|
||||||
|
* - Phone number: verification code + password
|
||||||
|
*/
|
||||||
|
await expectToSwapSignInMethodAuthnOption(page, 'Email address');
|
||||||
|
await expectToSwapSignInMethodAuthnOption(page, 'Phone number');
|
||||||
|
await expectToSaveSignInExperience(page);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Email address: password
|
||||||
|
* - Phone number: verification code + password
|
||||||
|
*/
|
||||||
|
await expectToClickSignInMethodAuthnOption(page, {
|
||||||
|
method: 'Email address',
|
||||||
|
option: 'Verification code',
|
||||||
|
});
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Email address: password
|
||||||
|
* - Phone number: password
|
||||||
|
*/
|
||||||
|
await expectToClickSignInMethodAuthnOption(page, {
|
||||||
|
method: 'Phone number',
|
||||||
|
option: 'Verification code',
|
||||||
|
});
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Email address: password
|
||||||
|
* - Phone number: password
|
||||||
|
* - Username: password
|
||||||
|
*/
|
||||||
|
await expectToAddSignInMethod(page, 'Username');
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('not applicable as sign-up identifier', () => {
|
||||||
|
afterAll(async () => {
|
||||||
|
await expectToResetSignUpAndSignInConfig(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('select not applicable as sign-up identifier', async () => {
|
||||||
|
await expectToSelectSignUpIdentifier(page, 'Not applicable');
|
||||||
|
await expectToRemoveSignInMethod(page, 'Username');
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update sign-in methods', async () => {
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Email address: password + verification code
|
||||||
|
*/
|
||||||
|
await expectToAddSignInMethod(page, 'Email address', false);
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Email address: password
|
||||||
|
*/
|
||||||
|
await expectToClickSignInMethodAuthnOption(page, {
|
||||||
|
method: 'Email address',
|
||||||
|
option: 'Verification code',
|
||||||
|
});
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Email address: verification code
|
||||||
|
*/
|
||||||
|
await expectToClickSignInMethodAuthnOption(page, {
|
||||||
|
method: 'Email address',
|
||||||
|
option: 'Verification code',
|
||||||
|
});
|
||||||
|
await expectToClickSignInMethodAuthnOption(page, {
|
||||||
|
method: 'Email address',
|
||||||
|
option: 'Password',
|
||||||
|
});
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Email address: verification code
|
||||||
|
* - Phone number: password verification code
|
||||||
|
*/
|
||||||
|
await expectToAddSignInMethod(page, 'Phone number');
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Email address: verification code
|
||||||
|
* - Phone number: password
|
||||||
|
*/
|
||||||
|
await expectToClickSignInMethodAuthnOption(page, {
|
||||||
|
method: 'Phone number',
|
||||||
|
option: 'Verification code',
|
||||||
|
});
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-in method
|
||||||
|
* - Email address: verification code
|
||||||
|
* - Phone number: password
|
||||||
|
* - Username: password
|
||||||
|
*/
|
||||||
|
await expectToAddSignInMethod(page, 'Username');
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('add social sign-in connector', async () => {
|
||||||
|
await expectToAddSocialSignInConnector(page, 'Apple');
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
await expectToRemoveSocialSignInConnector(page, 'Apple');
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('disable user registration', () => {
|
||||||
|
it('navigate to others tab', async () => {
|
||||||
|
await expectToClickNavTab(page, 'Others');
|
||||||
|
|
||||||
|
await waitForFormCard(page, 'TERMS');
|
||||||
|
await waitForFormCard(page, 'LANGUAGES');
|
||||||
|
await waitForFormCard(page, 'ADVANCED OPTIONS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disable user registration', async () => {
|
||||||
|
const switchSelector = 'label[class$=switch]:has(input[name=createAccountEnabled])';
|
||||||
|
await expect(page).toClick(switchSelector);
|
||||||
|
await expectToSaveSignInExperience(page);
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
await expect(page).toClick(switchSelector);
|
||||||
|
await expectToSaveSignInExperience(page);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,240 @@
|
||||||
|
import { type Page } from 'puppeteer';
|
||||||
|
|
||||||
|
import { expectToSaveSignInExperience } from '../helpers.js';
|
||||||
|
|
||||||
|
export const expectToSelectSignUpIdentifier = async (page: Page, identifier: string) => {
|
||||||
|
const signUpIdentifierField = await expect(page).toMatchElement(
|
||||||
|
'div[class$=field]:has(div[class$=headline] > div[class$=title])',
|
||||||
|
{
|
||||||
|
text: 'Sign-up identifier',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(signUpIdentifierField).toClick('div[role=button][class*=select]');
|
||||||
|
|
||||||
|
// Wait for the dropdown to be rendered in the correct position
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
await expect(page).toClick(
|
||||||
|
'.ReactModalPortal div[class$=dropdownContainer] div[role=menuitem] div',
|
||||||
|
{
|
||||||
|
text: identifier,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.waitForSelector('.ReactModalPortal div[class$=dropdownContainer]', {
|
||||||
|
hidden: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(signUpIdentifierField).toMatchElement('div[class*=select] div[class$=title] div', {
|
||||||
|
text: identifier,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for the config to update
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const expectToClickSignUpAuthnOption = async (page: Page, option: string) => {
|
||||||
|
const signUpAuthnSettingsFiled = await expect(page).toMatchElement(
|
||||||
|
'div[class$=field]:has(div[class$=headline] > div[class$=title])',
|
||||||
|
{
|
||||||
|
text: 'Authentication setting for sign-up',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(signUpAuthnSettingsFiled).toClick('div[class$=selections] span[class$=label]', {
|
||||||
|
text: option,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const expectToAddSignInMethod = async (page: Page, method: string, isAddAnother = true) => {
|
||||||
|
const signInMethodsField = await expect(page).toMatchElement(
|
||||||
|
'div[class$=field]:has(div[class$=headline] > div[class$=title])',
|
||||||
|
{
|
||||||
|
text: 'Identifier and authentication settings for sign-in',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Click Add another
|
||||||
|
await expect(signInMethodsField).toClick('button span', {
|
||||||
|
text: isAddAnother ? 'Add Another' : 'Add Sign-in Method',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for the dropdown to be rendered in the correct position
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
await expect(page).toClick('.ReactModalPortal div[class$=dropdownContainer] div[role=menuitem]', {
|
||||||
|
text: method,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.waitForSelector('.ReactModalPortal div[class$=dropdownContainer]', {
|
||||||
|
hidden: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExpectSignInMethodAuthnOptionOptions = {
|
||||||
|
method: string;
|
||||||
|
option: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const expectToClickSignInMethodAuthnOption = async (
|
||||||
|
page: Page,
|
||||||
|
{ method, option }: ExpectSignInMethodAuthnOptionOptions
|
||||||
|
) => {
|
||||||
|
const methodItem = await expect(page).toMatchElement(
|
||||||
|
'div[class$=signInMethodItem]:has(div[class$=identifier])',
|
||||||
|
{
|
||||||
|
text: method,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(methodItem).toClick('div[class*=authentication] span[class$=label]', {
|
||||||
|
text: option,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for the config to update
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const expectToSwapSignInMethodAuthnOption = async (page: Page, method: string) => {
|
||||||
|
const methodItem = await expect(page).toMatchElement(
|
||||||
|
'div[class$=signInMethodItem]:has(div[class$=identifier])',
|
||||||
|
{
|
||||||
|
text: method,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(methodItem).toClick('div[class*=authentication] div[class$=swapButton] button');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const expectToRemoveSignInMethod = async (page: Page, method: string) => {
|
||||||
|
const methodItem = await expect(page).toMatchElement(
|
||||||
|
'div[class$=signInMethodItem]:has(div[class$=identifier])',
|
||||||
|
{
|
||||||
|
text: method,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(methodItem).toClick('div[class$=anchor] button:last-of-type');
|
||||||
|
|
||||||
|
// Wait for the config to update
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const expectSignInMethodError = async (page: Page, method: string) => {
|
||||||
|
await expect(page).toMatchElement(
|
||||||
|
'div[class$=signInMethodItem] div[class$=error] div[class$=identifier]',
|
||||||
|
{
|
||||||
|
text: method,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExpectNotificationOnFiledOptions = {
|
||||||
|
field: string;
|
||||||
|
content?: RegExp | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const expectNotificationInFiled = async (
|
||||||
|
page: Page,
|
||||||
|
{ field, content }: ExpectNotificationOnFiledOptions
|
||||||
|
) => {
|
||||||
|
const signInMethodsField = await expect(page).toMatchElement(
|
||||||
|
'div[class$=field]:has(div[class$=headline] > div[class$=title])',
|
||||||
|
{
|
||||||
|
text: field,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(signInMethodsField).toMatchElement(
|
||||||
|
'div[class*=inlineNotification] div[class$=content]',
|
||||||
|
{
|
||||||
|
text: content,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const expectSignUpIdentifierSelectorError = async (page: Page) => {
|
||||||
|
const signUpIdentifierField = await expect(page).toMatchElement(
|
||||||
|
'div[class$=field]:has(div[class$=headline] > div[class$=title])',
|
||||||
|
{
|
||||||
|
text: 'Sign-up identifier',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(signUpIdentifierField).toMatchElement('div[class*=select][class*=error]');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const expectToResetSignUpAndSignInConfig = async (page: Page, needSave = true) => {
|
||||||
|
// Select 'Email address or phone number' first to ensure the sign-in method contains phone and email
|
||||||
|
await expectToSelectSignUpIdentifier(page, 'Email address or phone number');
|
||||||
|
await expectToSelectSignUpIdentifier(page, 'Username');
|
||||||
|
await expectToRemoveSignInMethod(page, 'Email address');
|
||||||
|
await expectToRemoveSignInMethod(page, 'Phone number');
|
||||||
|
if (needSave) {
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const expectToAddSocialSignInConnector = async (page: Page, name: string) => {
|
||||||
|
const socialSignInField = await expect(page).toMatchElement(
|
||||||
|
'div[class$=field]:has(div[class$=headline] > div[class$=title])',
|
||||||
|
{
|
||||||
|
text: 'Social sign-in',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(socialSignInField).toClick('button span', {
|
||||||
|
text: 'Add Social Connector',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for the dropdown to be rendered in the correct position
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
await expect(page).toClick(
|
||||||
|
'.ReactModalPortal div[class$=dropdownContainer] div[role=menuitem] span[class$=name]',
|
||||||
|
{
|
||||||
|
text: name,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.waitForSelector('.ReactModalPortal div[class$=dropdownContainer]', {
|
||||||
|
hidden: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const expectToRemoveSocialSignInConnector = async (page: Page, name: string) => {
|
||||||
|
const socialSignInField = await expect(page).toMatchElement(
|
||||||
|
'div[class$=field]:has(div[class$=headline] > div[class$=title])',
|
||||||
|
{
|
||||||
|
text: 'Social sign-in',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const connectorItem = await expect(socialSignInField).toMatchElement(
|
||||||
|
'div[class$=item]:has(span[class$=name])',
|
||||||
|
{
|
||||||
|
text: name,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(connectorItem).toClick('button:last-of-type');
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExpectErrorsOnNavTabOptions = {
|
||||||
|
tab: string;
|
||||||
|
error?: RegExp | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const expectErrorsOnNavTab = async (
|
||||||
|
page: Page,
|
||||||
|
{ tab, error }: ExpectErrorsOnNavTabOptions
|
||||||
|
) => {
|
||||||
|
const signUpAndSignInTab = await expect(page).toMatchElement('nav div[class$=item]:has(a)', {
|
||||||
|
text: tab,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(signUpAndSignInTab).toMatchElement('div[class$=errors]', {
|
||||||
|
text: error,
|
||||||
|
});
|
||||||
|
};
|
|
@ -0,0 +1,301 @@
|
||||||
|
import { logtoConsoleUrl as logtoConsoleUrlString } from '#src/constants.js';
|
||||||
|
import { expectToClickNavTab, goToAdminConsole, trySaveChanges } from '#src/ui-helpers/index.js';
|
||||||
|
import { expectNavigation, appendPathname } from '#src/utils.js';
|
||||||
|
|
||||||
|
import { expectToSaveSignInExperience, waitForFormCard } from '../helpers.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
expectToDeletePasswordlessConnector,
|
||||||
|
expectToSetupPasswordlessConnector,
|
||||||
|
testSendgridConnector,
|
||||||
|
testTwilioConnector,
|
||||||
|
} from './connector-setup-helpers.js';
|
||||||
|
import {
|
||||||
|
expectToSelectSignUpIdentifier,
|
||||||
|
expectNotificationInFiled,
|
||||||
|
expectSignUpIdentifierSelectorError,
|
||||||
|
expectToAddSignInMethod,
|
||||||
|
expectSignInMethodError,
|
||||||
|
expectErrorsOnNavTab,
|
||||||
|
expectToClickSignUpAuthnOption,
|
||||||
|
expectToClickSignInMethodAuthnOption,
|
||||||
|
expectToResetSignUpAndSignInConfig,
|
||||||
|
} from './helpers.js';
|
||||||
|
|
||||||
|
describe('sign-in experience(sad path): sign-up and sign-in', () => {
|
||||||
|
const logtoConsoleUrl = new URL(logtoConsoleUrlString);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await goToAdminConsole();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigate to sign-in experience page', async () => {
|
||||||
|
await expectNavigation(
|
||||||
|
page.goto(appendPathname('/console/sign-in-experience', logtoConsoleUrl).href)
|
||||||
|
);
|
||||||
|
// Land on branding tab by default
|
||||||
|
expect(page.url()).toBe(new URL(`console/sign-in-experience/branding`, logtoConsoleUrl).href);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigate to sign-up and sign-in tab', async () => {
|
||||||
|
await expectToClickNavTab(page, 'Sign-up and Sign-in');
|
||||||
|
|
||||||
|
await waitForFormCard(page, 'SIGN UP');
|
||||||
|
await waitForFormCard(page, 'SIGN IN');
|
||||||
|
await waitForFormCard(page, 'SOCIAL SIGN-IN');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cases that no connector is setup', () => {
|
||||||
|
describe('email address as sign-up identifier', () => {
|
||||||
|
afterAll(async () => {
|
||||||
|
await expectToResetSignUpAndSignInConfig(page, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail to setup email as sign-up identifier', async () => {
|
||||||
|
await expectToSelectSignUpIdentifier(page, 'Email address');
|
||||||
|
// Disable password settings for sign-up settings
|
||||||
|
await expectToClickSignUpAuthnOption(page, 'Create your password');
|
||||||
|
|
||||||
|
await expectNotificationInFiled(page, {
|
||||||
|
field: 'Sign-up identifier',
|
||||||
|
content: /No email connector set-up yet./,
|
||||||
|
});
|
||||||
|
|
||||||
|
await trySaveChanges(page);
|
||||||
|
|
||||||
|
await expectSignUpIdentifierSelectorError(page);
|
||||||
|
|
||||||
|
await expectErrorsOnNavTab(page, {
|
||||||
|
tab: 'Sign-up and Sign-in',
|
||||||
|
error: '1 errors',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail to add phone number sign-in method', async () => {
|
||||||
|
await expectToAddSignInMethod(page, 'Phone number');
|
||||||
|
await expectNotificationInFiled(page, {
|
||||||
|
field: 'Identifier and authentication settings for sign-in',
|
||||||
|
content: /No SMS connector set-up yet./,
|
||||||
|
});
|
||||||
|
|
||||||
|
await trySaveChanges(page);
|
||||||
|
|
||||||
|
await expectSignInMethodError(page, 'Phone number');
|
||||||
|
await expectErrorsOnNavTab(page, {
|
||||||
|
tab: 'Sign-up and Sign-in',
|
||||||
|
error: '2 errors',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disable password option for sign-in method
|
||||||
|
await expectToClickSignInMethodAuthnOption(page, {
|
||||||
|
method: 'Phone number',
|
||||||
|
option: 'Password',
|
||||||
|
});
|
||||||
|
|
||||||
|
await trySaveChanges(page);
|
||||||
|
|
||||||
|
await expectSignInMethodError(page, 'Phone number');
|
||||||
|
await expectErrorsOnNavTab(page, {
|
||||||
|
tab: 'Sign-up and Sign-in',
|
||||||
|
error: '2 errors',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('phone number as sign-up identifier', () => {
|
||||||
|
afterAll(async () => {
|
||||||
|
await expectToResetSignUpAndSignInConfig(page, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail to setup phone number as sign-up identifier', async () => {
|
||||||
|
await expectToSelectSignUpIdentifier(page, 'Phone number');
|
||||||
|
// Disable password settings for sign-up settings
|
||||||
|
await expectToClickSignUpAuthnOption(page, 'Create your password');
|
||||||
|
|
||||||
|
await expectNotificationInFiled(page, {
|
||||||
|
field: 'Sign-up identifier',
|
||||||
|
content: /No SMS connector set-up yet./,
|
||||||
|
});
|
||||||
|
|
||||||
|
await trySaveChanges(page);
|
||||||
|
|
||||||
|
await expectSignUpIdentifierSelectorError(page);
|
||||||
|
|
||||||
|
await expectErrorsOnNavTab(page, {
|
||||||
|
tab: 'Sign-up and Sign-in',
|
||||||
|
error: '1 errors',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail to add email address sign-in method', async () => {
|
||||||
|
await expectToAddSignInMethod(page, 'Email address');
|
||||||
|
await expectNotificationInFiled(page, {
|
||||||
|
field: 'Identifier and authentication settings for sign-in',
|
||||||
|
content: /No email connector set-up yet./,
|
||||||
|
});
|
||||||
|
|
||||||
|
await trySaveChanges(page);
|
||||||
|
|
||||||
|
await expectSignInMethodError(page, 'Email address');
|
||||||
|
await expectErrorsOnNavTab(page, {
|
||||||
|
tab: 'Sign-up and Sign-in',
|
||||||
|
error: '2 errors',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disable password option for sign-in method
|
||||||
|
await expectToClickSignInMethodAuthnOption(page, {
|
||||||
|
method: 'Email address',
|
||||||
|
option: 'Password',
|
||||||
|
});
|
||||||
|
|
||||||
|
await trySaveChanges(page);
|
||||||
|
|
||||||
|
await expectSignInMethodError(page, 'Email address');
|
||||||
|
await expectErrorsOnNavTab(page, {
|
||||||
|
tab: 'Sign-up and Sign-in',
|
||||||
|
error: '2 errors',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('social sign-in', () => {
|
||||||
|
it('should display no social connector notification in social sign-in field', async () => {
|
||||||
|
await expectNotificationInFiled(page, {
|
||||||
|
field: 'Social sign-in',
|
||||||
|
content: /No social connector set-up yet./,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cases that only Email connector is setup', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Email connector
|
||||||
|
await expectToSetupPasswordlessConnector(page, testSendgridConnector);
|
||||||
|
// Nav back to sign-in experience page
|
||||||
|
await expectNavigation(
|
||||||
|
page.goto(
|
||||||
|
appendPathname('/console/sign-in-experience/sign-up-and-sign-in', logtoConsoleUrl).href
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await expectToDeletePasswordlessConnector(page, testSendgridConnector);
|
||||||
|
await expectNavigation(
|
||||||
|
page.goto(
|
||||||
|
appendPathname('/console/sign-in-experience/sign-up-and-sign-in', logtoConsoleUrl).href
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('email address as sign-up identifier', () => {
|
||||||
|
afterAll(async () => {
|
||||||
|
await expectToResetSignUpAndSignInConfig(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should setup email as sign-up identifier', async () => {
|
||||||
|
await expectToSelectSignUpIdentifier(page, 'Email address');
|
||||||
|
// Disable password settings for sign-up settings
|
||||||
|
await expectToClickSignUpAuthnOption(page, 'Create your password');
|
||||||
|
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail to add phone number sign-in method', async () => {
|
||||||
|
await expectToAddSignInMethod(page, 'Phone number');
|
||||||
|
await expectNotificationInFiled(page, {
|
||||||
|
field: 'Identifier and authentication settings for sign-in',
|
||||||
|
content: /No SMS connector set-up yet./,
|
||||||
|
});
|
||||||
|
|
||||||
|
await trySaveChanges(page);
|
||||||
|
|
||||||
|
await expectSignInMethodError(page, 'Phone number');
|
||||||
|
await expectErrorsOnNavTab(page, {
|
||||||
|
tab: 'Sign-up and Sign-in',
|
||||||
|
error: '1 errors',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disable password option for sign-in method
|
||||||
|
await expectToClickSignInMethodAuthnOption(page, {
|
||||||
|
method: 'Phone number',
|
||||||
|
option: 'Password',
|
||||||
|
});
|
||||||
|
|
||||||
|
await trySaveChanges(page);
|
||||||
|
|
||||||
|
await expectSignInMethodError(page, 'Phone number');
|
||||||
|
await expectErrorsOnNavTab(page, {
|
||||||
|
tab: 'Sign-up and Sign-in',
|
||||||
|
error: '1 errors',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cases that only SMS connector is setup', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
// SMS connector
|
||||||
|
await expectToSetupPasswordlessConnector(page, testTwilioConnector);
|
||||||
|
// Nav back to sign-in experience page
|
||||||
|
await expectNavigation(
|
||||||
|
page.goto(
|
||||||
|
appendPathname('/console/sign-in-experience/sign-up-and-sign-in', logtoConsoleUrl).href
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await expectToDeletePasswordlessConnector(page, testTwilioConnector);
|
||||||
|
await expectNavigation(
|
||||||
|
page.goto(
|
||||||
|
appendPathname('/console/sign-in-experience/sign-up-and-sign-in', logtoConsoleUrl).href
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('phone number as sign-up identifier', () => {
|
||||||
|
afterAll(async () => {
|
||||||
|
await expectToResetSignUpAndSignInConfig(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should setup phone number as sign-up identifier', async () => {
|
||||||
|
await expectToSelectSignUpIdentifier(page, 'Phone number');
|
||||||
|
// Disable password settings for sign-up settings
|
||||||
|
await expectToClickSignUpAuthnOption(page, 'Create your password');
|
||||||
|
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail to add email sign-in method', async () => {
|
||||||
|
await expectToAddSignInMethod(page, 'Email address');
|
||||||
|
await expectNotificationInFiled(page, {
|
||||||
|
field: 'Identifier and authentication settings for sign-in',
|
||||||
|
content: /No email connector set-up yet./,
|
||||||
|
});
|
||||||
|
|
||||||
|
await trySaveChanges(page);
|
||||||
|
|
||||||
|
await expectSignInMethodError(page, 'Email address');
|
||||||
|
await expectErrorsOnNavTab(page, {
|
||||||
|
tab: 'Sign-up and Sign-in',
|
||||||
|
error: '1 errors',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disable password option for sign-in method
|
||||||
|
await expectToClickSignInMethodAuthnOption(page, {
|
||||||
|
method: 'Email address',
|
||||||
|
option: 'Password',
|
||||||
|
});
|
||||||
|
|
||||||
|
await trySaveChanges(page);
|
||||||
|
|
||||||
|
await expectSignInMethodError(page, 'Email address');
|
||||||
|
await expectErrorsOnNavTab(page, {
|
||||||
|
tab: 'Sign-up and Sign-in',
|
||||||
|
error: '1 errors',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -105,3 +105,9 @@ export const expectConfirmModalAndAct = async (
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const expectToClickNavTab = async (page: Page, tab: string) => {
|
||||||
|
await expect(page).toClick('nav div[class$=item] div[class$=link] a', {
|
||||||
|
text: tab,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
Loading…
Reference in a new issue