mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
Merge pull request #4087 from logto-io/yemq-fix-multi-tenancy-flow
fix(console): fix multi tenancy flow and sync ui-test
This commit is contained in:
commit
5be498c7ff
7 changed files with 219 additions and 73 deletions
|
@ -9,6 +9,7 @@ import { withTranslation } from 'react-i18next';
|
|||
|
||||
import AppError from '@/components/AppError';
|
||||
import SessionExpired from '@/components/SessionExpired';
|
||||
import { isInCallback } from '@/utils/url';
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
|
@ -59,8 +60,7 @@ class ErrorBoundary extends Component<Props, State> {
|
|||
if (error) {
|
||||
// Different strategies for handling errors in callback pages since the callback errors
|
||||
// are likely unexpected and unrecoverable.
|
||||
const { pathname } = window.location;
|
||||
if (['/callback', '-callback'].some((path) => pathname.endsWith(path))) {
|
||||
if (isInCallback()) {
|
||||
if (error instanceof LogtoError && error.data instanceof OidcError) {
|
||||
return (
|
||||
<AppError
|
||||
|
|
|
@ -1,15 +1,22 @@
|
|||
import { useHandleSignInCallback } from '@logto/react';
|
||||
import { conditionalString } from '@silverhand/essentials';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import AppLoading from '@/components/AppLoading';
|
||||
import { isCloud } from '@/consts/env';
|
||||
import { getUserTenantId } from '@/consts/tenants';
|
||||
import { getUserTenantId } from '@/consts';
|
||||
import { isInFirstLevelCallback } from '@/utils/url';
|
||||
|
||||
function Callback() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useHandleSignInCallback(() => {
|
||||
navigate('/' + conditionalString(isCloud && getUserTenantId()), { replace: true });
|
||||
/**
|
||||
* The first level callback check is due to the usage of `basename`
|
||||
* for tenant-specific routes, e.g., `/:tenantId/applications`.
|
||||
* Once we merge all the routes into one router, we can remove this check.
|
||||
*/
|
||||
navigate(isInFirstLevelCallback() ? `/${getUserTenantId()}` : '/', {
|
||||
replace: true,
|
||||
});
|
||||
});
|
||||
|
||||
return <AppLoading />;
|
||||
|
|
|
@ -27,7 +27,8 @@ const tenantProfileToForm = (tenant?: TenantInfo): TenantSettingsForm => {
|
|||
function TenantBasicSettings() {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const api = useCloudApi();
|
||||
const { currentTenant, currentTenantId, updateTenant, removeTenant } = useContext(TenantsContext);
|
||||
const { currentTenant, currentTenantId, updateTenant, removeTenant, navigateTenant } =
|
||||
useContext(TenantsContext);
|
||||
const [error, setError] = useState<Error>();
|
||||
const [isDeletionModalOpen, setIsDeletionModalOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
@ -89,6 +90,7 @@ function TenantBasicSettings() {
|
|||
await api.delete(`/api/tenants/:tenantId`, { params: { tenantId: currentTenantId } });
|
||||
setIsDeletionModalOpen(false);
|
||||
removeTenant(currentTenantId);
|
||||
navigateTenant('');
|
||||
} catch (error: unknown) {
|
||||
setError(
|
||||
error instanceof Error
|
||||
|
|
|
@ -2,3 +2,11 @@ export const buildUrl = (path: string, searchParameters: Record<string, string>)
|
|||
`${path}?${new URLSearchParams(searchParameters).toString()}`;
|
||||
|
||||
export const formatSearchKeyword = (keyword: string) => `%${keyword}%`;
|
||||
|
||||
/** If the current pathname is `/callback` or ends with `-callback`, we consider it as a callback page. */
|
||||
export const isInCallback = () =>
|
||||
['/callback', '-callback'].some((path) => window.location.pathname.endsWith(path));
|
||||
|
||||
/** If the current pathname is a callback page and the pathname only has one level. */
|
||||
export const isInFirstLevelCallback = () =>
|
||||
window.location.pathname.split('/').length === 1 && isInCallback();
|
||||
|
|
94
packages/integration-tests/src/tests/ui-cloud/operations.ts
Normal file
94
packages/integration-tests/src/tests/ui-cloud/operations.ts
Normal file
|
@ -0,0 +1,94 @@
|
|||
import { type Page } from 'puppeteer';
|
||||
|
||||
export const onboardingWelcome = async (page: Page) => {
|
||||
// Select the project type option
|
||||
await page.click('div[role=radio]:has(input[name=project][value=personal])');
|
||||
|
||||
// Select the deployment type option
|
||||
await page.click('div[role=radio]:has(input[name=deploymentType][value=open-source])');
|
||||
|
||||
// Click the next button
|
||||
await page.click('div[class$=actions] button:first-child');
|
||||
};
|
||||
|
||||
export const onboardingUserSurvey = async (page: Page) => {
|
||||
// Wait for the sie config to load
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Select the first reason option
|
||||
await page.click('div[role=button][class$=item]');
|
||||
|
||||
// Click the next button
|
||||
await expect(page).toClick('div[class$=actions] button', { text: 'Next' });
|
||||
};
|
||||
|
||||
export const onboardingSieConfig = async (page: Page) => {
|
||||
// Wait for the sie config to load
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Select username as the identifier
|
||||
await page.click('div[role=radio]:has(input[name=identifier][value=username])');
|
||||
|
||||
// Click the finish button
|
||||
await page.click('div[class$=continueActions] button:last-child');
|
||||
};
|
||||
|
||||
export const onboardingFinish = async (page: Page) => {
|
||||
// Wait for the sie config to load
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Click the enter ac button
|
||||
await page.click('div[class$=content] >button');
|
||||
|
||||
// Wait for the admin console to load
|
||||
await page.waitForNavigation({ waitUntil: 'networkidle0' });
|
||||
};
|
||||
|
||||
export const openTenantDropdown = async (page: Page) => {
|
||||
// Click 'current tenant card' locates in topbar
|
||||
const currentTenantCard = await page.waitForSelector(
|
||||
'div[class$=topbar] > div[class$=currentTenantCard][role=button]:has(div[class$=name])'
|
||||
);
|
||||
await currentTenantCard?.click();
|
||||
};
|
||||
|
||||
export const openCreateTenantModal = async (page: Page) => {
|
||||
const createTenantButton = await page.waitForSelector(
|
||||
'div[class$=ReactModalPortal] div[class$=dropdownContainer] div[class$=dropdown] button[class$=createTenantButton]:has(div)'
|
||||
);
|
||||
await createTenantButton?.click();
|
||||
};
|
||||
|
||||
export const fillAndCreateTenant = async (page: Page, tenantName: string) => {
|
||||
// Create tenant with name 'new-tenant' and tag 'production'
|
||||
await page.waitForTimeout(500);
|
||||
await page.waitForSelector(
|
||||
'div[class$=ReactModalPortal] div[class*=card][class$=medium] input[type=text][name=name]'
|
||||
);
|
||||
await page.waitForSelector(
|
||||
'div[class$=ReactModalPortal] div[class*=radioGroup][class$=small] div[class*=radio][class$=small][role=radio] > div[class$=content]:has(input[value=production])'
|
||||
);
|
||||
await page.type(
|
||||
'div[class$=ReactModalPortal] div[class*=card][class$=medium] input[type=text][name=name]',
|
||||
tenantName
|
||||
);
|
||||
await page.click(
|
||||
'div[class$=ReactModalPortal] div[class*=radioGroup][class$=small] div[class*=radio][class$=small][role=radio] > div[class$=content]:has(input[value=production])'
|
||||
);
|
||||
|
||||
// Click create button
|
||||
await page.waitForTimeout(500);
|
||||
await page.click(
|
||||
'div[class$=ReactModalPortal] div[class*=card][class$=medium] div[class$=footer] button[type=submit]'
|
||||
);
|
||||
};
|
||||
|
||||
export const createNewTenant = async (page: Page, tenantName: string) => {
|
||||
await page.waitForTimeout(500);
|
||||
await openTenantDropdown(page);
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
await openCreateTenantModal(page);
|
||||
|
||||
await fillAndCreateTenant(page, tenantName);
|
||||
};
|
|
@ -5,6 +5,17 @@ import { setDefaultOptions } from 'expect-puppeteer';
|
|||
import { logtoCloudUrl as logtoCloudUrlString, logtoConsoleUrl } from '#src/constants.js';
|
||||
import { generatePassword } from '#src/utils.js';
|
||||
|
||||
import {
|
||||
onboardingWelcome,
|
||||
onboardingUserSurvey,
|
||||
onboardingSieConfig,
|
||||
onboardingFinish,
|
||||
createNewTenant,
|
||||
fillAndCreateTenant,
|
||||
openTenantDropdown,
|
||||
openCreateTenantModal,
|
||||
} from './operations.js';
|
||||
|
||||
await page.setViewport({ width: 1280, height: 720 });
|
||||
setDefaultOptions({ timeout: 5000 });
|
||||
|
||||
|
@ -51,16 +62,7 @@ describe('smoke testing for cloud', () => {
|
|||
});
|
||||
|
||||
it('can complete the onboarding welcome process and enter the user survey page', async () => {
|
||||
// Select the project type option
|
||||
await expect(page).toClick('div[role=radio]:has(input[name=project][value=personal])');
|
||||
|
||||
// Select the deployment type option
|
||||
await expect(page).toClick(
|
||||
'div[role=radio]:has(input[name=deploymentType][value=open-source])'
|
||||
);
|
||||
|
||||
// Click the next button
|
||||
await expect(page).toClick('div[class$=actions] button:first-child');
|
||||
await onboardingWelcome(page);
|
||||
|
||||
// Wait for the next page to load
|
||||
await expect(page).toMatchElement('div[class$=content] div[class$=title]', {
|
||||
|
@ -71,11 +73,7 @@ describe('smoke testing for cloud', () => {
|
|||
});
|
||||
|
||||
it('can complete the onboarding user survey process and enter the sie page', async () => {
|
||||
// Select the first reason option
|
||||
await expect(page).toClick('div[role=button][class$=item]');
|
||||
|
||||
// Click the next button
|
||||
await expect(page).toClick('div[class$=actions] button', { text: 'Next' });
|
||||
await onboardingUserSurvey(page);
|
||||
|
||||
// Wait for the next page to load
|
||||
await expect(page).toMatchElement('div[class$=config] div[class$=title]', {
|
||||
|
@ -86,14 +84,7 @@ describe('smoke testing for cloud', () => {
|
|||
});
|
||||
|
||||
it('can complete the sie configuration process and enter the congrats page', async () => {
|
||||
// Wait for the sie config to load
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Select username as the identifier
|
||||
await expect(page).toClick('div[role=radio]:has(input[name=identifier][value=username])');
|
||||
|
||||
// Click the finish button
|
||||
await expect(page).toClick('div[class$=continueActions] button:last-child');
|
||||
await onboardingSieConfig(page);
|
||||
|
||||
// Wait for the next page to load
|
||||
await expect(page).toMatchElement('div[class$=content] div[class$=title]', {
|
||||
|
@ -104,11 +95,7 @@ describe('smoke testing for cloud', () => {
|
|||
});
|
||||
|
||||
it('can complete the onboarding process and enter the admin console', async () => {
|
||||
// Click the enter ac button
|
||||
await expect(page).toClick('div[class$=content] >button');
|
||||
|
||||
// Wait for the admin console to load
|
||||
await page.waitForNavigation({ waitUntil: 'networkidle0' });
|
||||
await onboardingFinish(page);
|
||||
const mainContent = await page.waitForSelector('div[class$=main]:has(div[class$=title])');
|
||||
await expect(mainContent).toMatchElement('div[class$=title]', {
|
||||
text: 'Something to explore to help you succeed',
|
||||
|
@ -118,41 +105,8 @@ describe('smoke testing for cloud', () => {
|
|||
});
|
||||
|
||||
it('can create a new tenant using tenant dropdown', async () => {
|
||||
// Click 'current tenant card' locates in topbar
|
||||
const currentTenantCard = await page.waitForSelector(
|
||||
'div[class$=topbar] > div[class$=currentTenantCard][role=button]:has(div[class$=name])'
|
||||
);
|
||||
await expect(currentTenantCard).toMatchElement('div[class$=name]', { text: 'My Project' });
|
||||
await currentTenantCard.click();
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
const createTenantButton = await page.waitForSelector(
|
||||
'div[class$=ReactModalPortal] div[class$=dropdownContainer] > div[class$=dropdown] > button[class$=createTenantButton]:has(div)'
|
||||
);
|
||||
await expect(createTenantButton).toMatchElement('div', { text: 'Create tenant' });
|
||||
await createTenantButton.click();
|
||||
|
||||
// Create tenant with name 'new-tenant' and tag 'production'
|
||||
await page.waitForTimeout(500);
|
||||
await page.waitForSelector(
|
||||
'div[class$=ReactModalPortal] div[class*=card][class$=medium] input[type=text][name=name]'
|
||||
);
|
||||
await page.waitForSelector(
|
||||
'div[class$=ReactModalPortal] div[class*=radioGroup][class$=small] div[class*=radio][class$=small][role=radio] > div[class$=content]:has(input[value=production])'
|
||||
);
|
||||
await expect(page).toFill(
|
||||
'div[class$=ReactModalPortal] div[class*=card][class$=medium] input[type=text][name=name]',
|
||||
createTenantName
|
||||
);
|
||||
await expect(page).toClick(
|
||||
'div[class$=ReactModalPortal] div[class*=radioGroup][class$=small] div[class*=radio][class$=small][role=radio] > div[class$=content]:has(input[value=production])'
|
||||
);
|
||||
|
||||
// Click create button
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page).toClick(
|
||||
'div[class$=ReactModalPortal] div[class*=card][class$=medium] div[class$=footer] button[type=submit]'
|
||||
);
|
||||
await page.waitForTimeout(2000);
|
||||
await createNewTenant(page, createTenantName);
|
||||
|
||||
expect(new URL(page.url()).pathname.endsWith(`/get-started`)).toBeTruthy();
|
||||
});
|
||||
|
@ -170,7 +124,7 @@ describe('smoke testing for cloud', () => {
|
|||
|
||||
it('can sign out of admin console', async () => {
|
||||
const userInfoButton = await page.waitForSelector('div[class$=topbar] > div[class$=container]');
|
||||
await userInfoButton.click();
|
||||
await userInfoButton?.click();
|
||||
|
||||
// Try awaiting for 500ms before clicking sign-out button
|
||||
await page.waitForTimeout(500);
|
||||
|
@ -178,7 +132,7 @@ describe('smoke testing for cloud', () => {
|
|||
const signOutButton = await page.waitForSelector(
|
||||
'div[class$=ReactModalPortal] div[class$=dropdownContainer] div[class$=dropdownItem]:last-child'
|
||||
);
|
||||
await signOutButton.click();
|
||||
await signOutButton?.click();
|
||||
|
||||
await page.waitForNavigation({ waitUntil: 'networkidle0' });
|
||||
|
||||
|
@ -208,4 +162,84 @@ describe('smoke testing for cloud', () => {
|
|||
expect(page.url().startsWith(logtoCloudUrl.origin)).toBeTruthy();
|
||||
expect(page.url().endsWith('/onboarding/welcome')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('can complete onboarding process with new account', async () => {
|
||||
await onboardingWelcome(page);
|
||||
await onboardingUserSurvey(page);
|
||||
await onboardingSieConfig(page);
|
||||
await onboardingFinish(page);
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
expect(new URL(page.url()).pathname.endsWith('/get-started')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('go to tenant settings and delete current tenant', async () => {
|
||||
await page.waitForTimeout(2000);
|
||||
const tenantSettingButton = await page.waitForSelector(
|
||||
'div[class$=content] > div[class$=sidebar] a[class$=row][href$=tenant-settings] > div[class$=title]'
|
||||
);
|
||||
await tenantSettingButton?.click();
|
||||
|
||||
const deleteButton = await page.waitForSelector(
|
||||
'div[class$=main] form[class$=container] div[class$=deletionButtonContainer] button[class$=medium][type=button]'
|
||||
);
|
||||
await deleteButton?.click();
|
||||
|
||||
const textInput = await page.waitForSelector(
|
||||
'div[class$=ReactModalPortal] div[class$=container] input[type=text]'
|
||||
);
|
||||
await textInput?.type('My Project');
|
||||
|
||||
const deleteConfirmButton = await page.waitForSelector(
|
||||
'div[class$=ReactModalPortal] div[class$=footer] > button:last-child'
|
||||
);
|
||||
await deleteConfirmButton?.click();
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
const placeholderTitle = await page.waitForSelector(
|
||||
'div[class$=pageContainer] div[class$=placeholder]:has(div[class$=title])'
|
||||
);
|
||||
await expect(placeholderTitle).toMatchElement('div[class$=title]', {
|
||||
text: 'You haven’t created a tenant yet',
|
||||
});
|
||||
});
|
||||
|
||||
it('can create tenant from landing page', async () => {
|
||||
const createTenantButton = await page.waitForSelector(
|
||||
'div[class$=pageContainer] div[class$=placeholder] button[class$=button][type=button]'
|
||||
);
|
||||
await createTenantButton?.click();
|
||||
|
||||
await fillAndCreateTenant(page, 'tenant1');
|
||||
await page.waitForTimeout(5000);
|
||||
expect(new URL(page.url()).pathname.endsWith('/get-started')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('can create two more tenant for new account', async () => {
|
||||
await createNewTenant(page, 'tenant2');
|
||||
await page.waitForTimeout(5000);
|
||||
await createNewTenant(page, 'tenant3');
|
||||
await page.waitForTimeout(5000);
|
||||
expect(new URL(page.url()).pathname.endsWith('/get-started')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('can not open create tenant modal when reach the limit', async () => {
|
||||
await page.waitForTimeout(2000);
|
||||
await openTenantDropdown(page);
|
||||
await openCreateTenantModal(page);
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
const pageTitle = await page.waitForSelector(
|
||||
'div[class$=main] > div[class$=container] > div[class$=header]:has(div[class$=title])'
|
||||
);
|
||||
await expect(pageTitle).toMatchElement('div[class$=title]', {
|
||||
text: 'Something to explore to help you succeed',
|
||||
});
|
||||
|
||||
const createTenantModalTitleSelector =
|
||||
'div[class$=ReactModalPortal] div[class$=iconAndTitle] > div[class*=container][class$=large]:has(div[class*=title][class$=titleEllipsis])';
|
||||
await expect(
|
||||
page.waitForSelector(createTenantModalTitleSelector, { timeout: 3000 })
|
||||
).rejects.toThrow(); // Throws error if selector is not found.
|
||||
}, 20_000);
|
||||
});
|
||||
|
|
|
@ -9,7 +9,8 @@
|
|||
"#src/*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"types": ["jest", "jest-puppeteer"],
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue