diff --git a/cms-ui/apps/admin-ui/e2e/api-tokens.spec.ts b/cms-ui/apps/admin-ui/e2e/api-tokens.spec.ts new file mode 100644 index 0000000000..1fbc6d62aa --- /dev/null +++ b/cms-ui/apps/admin-ui/e2e/api-tokens.spec.ts @@ -0,0 +1,239 @@ +import { + createClient, + EntityImporter, + findNotification, + findTableAction, + GroupImportData, + IMPORT_ID, + IMPORT_TYPE, + IMPORT_TYPE_GROUP, + IMPORT_TYPE_USER, + ImportPermissions, + loginWithForm, + matchRequest, + navigateToApp, + openContext, + TestSize, + UserImportData, +} from '@gentics/e2e-utils'; +import { expect, Page, test } from '@playwright/test'; +import { cloneWithSymbols } from '@gentics/common'; +import { AccessControlledType, GcmsPermission, LoginResponse } from '@gentics/cms-models'; + +const API_MODAL = 'gtx-api-tokens-modal'; +const API_CREATE_MODAL = 'gtx-api-tokens-create-modal'; +const BASE_GROUP: GroupImportData = { + [IMPORT_TYPE]: IMPORT_TYPE_GROUP, + [IMPORT_ID]: 'apiTokenMaintenance_group_base', + name: 'apiTokenMaintenance_base', + description: 'Api-Token-Maintenance: Base', + permissions: [], +}; + +const TEST_USER: UserImportData = { + [IMPORT_TYPE]: IMPORT_TYPE_USER, + [IMPORT_ID]: 'apiTokenMaintenance_user_test', + + email: 'test@example.com', + firstName: 'Content-Maintenance', + lastName: 'Test', + group: BASE_GROUP, + + login: 'apiTokenMaintenance_test', + password: 'foobar2026', +}; + +test.describe('Api Tokens', () => { + + const IMPORTER = new EntityImporter(); + let login: LoginResponse; + + test.beforeAll(async ({ request }) => { + await test.step('Client Setup', async () => { + IMPORTER.setApiContext(request); + await IMPORTER.clearClient(); + }); + + await test.step('Test Bootstrapping', async () => { + await IMPORTER.cleanupTest(); + await IMPORTER.bootstrapSuite(TestSize.MINIMAL); + }); + }); + + test.beforeEach(async ({ page, request, context }) => { + await test.step('Client Setup', async () => { + IMPORTER.setApiContext(request); + await context.clearCookies(); + await IMPORTER.clearClient(); + }); + + await test.step('Common Test Setup', async () => { + await IMPORTER.cleanupTest(); + await IMPORTER.syncPackages(TestSize.MINIMAL); + await IMPORTER.setupTest(TestSize.MINIMAL); + }); + + await navigateToApp(page); + + await setupWithPermissions(IMPORTER, login, page, [ + { + type: AccessControlledType.ADMIN, + perms: [ + { type: GcmsPermission.SET_PERMISSION, value: true }, + ], + }, + ]); + }); + + test('should show api modal', async ({ page }) => { + await openApiTokenModal(page); + + const table = page.locator(API_MODAL).locator('gtx-manage-api-tokens-table'); + + await expect(table).toBeVisible(); + }); + + test('should show api creation modal', async ({ page }) => { + await openApiTokenModal(page); + + await openApiTokenCreateModal(page); + + await expect(page.locator(API_CREATE_MODAL)).toBeVisible(); + }); + + test('can save when form is valid', async ({ page }) => { + await openApiTokenModal(page); + + await openApiTokenCreateModal(page); + + const form = page.locator(API_CREATE_MODAL).locator('form'); + + await expect(form).toBeVisible(); + + const submitBtn = page.locator(API_CREATE_MODAL).locator('.modal-footer').locator('[data-action="confirm"]'); + + await expect(submitBtn.locator('button')).toBeDisabled(); + + // fill only name, keep date empty + const nameInput = form.locator('input[type="text"]'); + await nameInput.fill('Test'); + + await expect(submitBtn.locator('button')).toBeEnabled(); + + const dateInput = form.locator('input[type="date"]'); + + // fill with date from yesterday + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + + await dateInput.fill(yesterday.toISOString().split('T')[0]); + + await expect(submitBtn.locator('button')).toBeDisabled(); + + // fill with date from tomorrow + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + await dateInput.fill(tomorrow.toISOString().split('T')[0]); + + await expect(submitBtn.locator('button')).toBeEnabled(); + + await submitBtn.click(); + + await expect(page.locator('.success-message')).toBeVisible(); + await expect(findNotification(page, 'api-token-create-success')).toBeVisible(); + await expect(page.locator('.success-message').locator('.content')).toHaveText(/\S+/); + }); + + test('can delete Api Tokens', async ({ page }) => { + await addEntries(login, page, 1); + await openApiTokenModal(page); + + const table = page.locator(API_MODAL).locator('gtx-manage-api-tokens-table'); + + await expect(table).toBeVisible(); + + const row = table.locator('.data-row').first(); + + const deleteButton = findTableAction(row, 'delete'); + + await deleteButton.click(); + + await expect(page.locator('[data-action="api-token-delete"]')).toBeVisible(); + + await page.locator('[data-action="api-token-delete"]').click(); + + await expect(findNotification(page, 'api-token-delete-success')).toBeVisible(); + + await expect(row).not.toBeAttached(); + }); + + test('can delete multiple Api Tokens', async ({ page }) => { + await addEntries(login, page, 2); + await openApiTokenModal(page); + + const table = page.locator(API_MODAL).locator('gtx-manage-api-tokens-table'); + + await expect(table).toBeVisible(); + + const row = table.locator('.header-row'); + + await row.locator('gtx-checkbox').click(); + + await findTableAction(row, 'delete').click(); + + await expect(page.locator('[data-action="api-token-delete"]')).toBeVisible(); + + await page.locator('[data-action="api-token-delete"]').click(); + + await expect(findNotification(page, 'api-token-delete-success')).toBeVisible(); + + await expect(table.locator('.data-row')).toHaveCount(0); + }); + +}); + +async function openApiTokenModal(page: Page) { + await page.locator('gtx-user-menu .toggle-button').click(); + const menu = page.locator('gtx-user-menu'); + const dropdown = await openContext(menu.locator('.user-name gtx-dropdown-list')); + await dropdown.locator('[data-action="manage-api-tokens"]').click(); +} + +async function openApiTokenCreateModal(page: Page) { + const modalTable = page.locator(API_MODAL).locator('gtx-manage-api-tokens-table'); + const createNewBtn = modalTable.locator('[data-action="create"]'); + await createNewBtn.click(); +} + +async function addEntries(login: LoginResponse, page: Page, amount: number) { + const client = await createClient({ + context: page.context().request, + isPageContext: true, + }); + + client.sid = login?.sid ?? null; + + for (let i = 0; i < amount; i++) { + await client.admin.addApiTokens({ name: `token ${i + 1}` }).send(); + } +} + +async function setupWithPermissions(importer: EntityImporter, login: LoginResponse, page: Page, permissions: ImportPermissions[]): Promise { + await test.step('Test User Setup', async () => { + const TEST_GROUP = cloneWithSymbols(BASE_GROUP); + TEST_GROUP.permissions = permissions; + + await importer.importData([ + TEST_GROUP, + TEST_USER, + ]); + }); + + await test.step('Open ADMIN-UI', async () => { + await navigateToApp(page); + const loginReq = page.waitForResponse(matchRequest('POST', '/rest/auth/login')); + await loginWithForm(page, TEST_USER); + login = await (await loginReq).json(); + }); +} diff --git a/cms-ui/apps/admin-ui/public/i18n/de.json b/cms-ui/apps/admin-ui/public/i18n/de.json index 778be2e825..ab32a74cc4 100644 --- a/cms-ui/apps/admin-ui/public/i18n/de.json +++ b/cms-ui/apps/admin-ui/public/i18n/de.json @@ -753,7 +753,7 @@ "change_password_label": "Passwort ändern", "log_in": "Anmelden", "log_out": "Abmelden", - "logged_in_as": "'Angemeldet als {{name}}'", + "logged_in_as": "'Angemeldet als ", "password": "Passwort", "user_name": "Benutzername", "confirm_delete": "Nochmals klicken um zu löschen", diff --git a/cms-ui/apps/admin-ui/public/i18n/en.json b/cms-ui/apps/admin-ui/public/i18n/en.json index b3039a8c80..1bacb18221 100644 --- a/cms-ui/apps/admin-ui/public/i18n/en.json +++ b/cms-ui/apps/admin-ui/public/i18n/en.json @@ -753,7 +753,7 @@ "change_password_label": "Change password", "log_in": "Log In", "log_out": "Log out", - "logged_in_as": "'Logged in as {{name}}'", + "logged_in_as": "'Logged in as ", "password": "Password", "user_name": "User Name", "confirm_delete": "Click again to confirm delete", diff --git a/cms-ui/apps/admin-ui/src/app/app.component.html b/cms-ui/apps/admin-ui/src/app/app.component.html index d1fb8729f3..549910a51f 100644 --- a/cms-ui/apps/admin-ui/src/app/app.component.html +++ b/cms-ui/apps/admin-ui/src/app/app.component.html @@ -42,6 +42,7 @@ (toggle)="userMenuOpened = $event" (setLanguage)="setLanguageConfirmation($event)" (showPasswordModal)="onShowPasswordModal()" + (logout)="onLogoutClick()" > ; - @SelectState(state => state.auth.isLoggedIn) + @SelectState((state) => state.auth.isLoggedIn) isLoggedIn$: Observable; - @SelectState(state => state.messages.unread.length) + @SelectState((state) => state.messages.unread.length) unreadMessageCount$: Observable; activitiesCount$: Observable; @@ -112,11 +113,11 @@ export class AppComponent implements OnDestroy, OnInit { this.entityManager.init(); - this.showLoginSpinner$ = this.appState.select(state => state.auth).pipe( - map(auth => auth.loggingIn || auth.loggingOut), + this.showLoginSpinner$ = this.appState.select((state) => state.auth).pipe( + map((auth) => auth.loggingIn || auth.loggingOut), ); - this.keycloakSignOut$ = this.appState.select(state => state.features.global[Feature.KEYCLOAK_SIGNOUT]); + this.keycloakSignOut$ = this.appState.select((state) => state.features.global[Feature.KEYCLOAK_SIGNOUT]); this.authOps.validateSessionFromLocalStorage(); @@ -134,7 +135,7 @@ export class AppComponent implements OnDestroy, OnInit { }); this.message.poll(); - this.currentLanguage$ = this.appState.select(state => state.ui.language); + this.currentLanguage$ = this.appState.select((state) => state.ui.language); selectLoginEventOrIsLoggedIn(this.appState).pipe( takeUntil(this.stopper.stopper$), @@ -146,27 +147,27 @@ export class AppComponent implements OnDestroy, OnInit { this.usersnapService.init(); this.activitiesCount$ = this.activityManager.activities$.pipe( - map(tasks => tasks.length), + map((tasks) => tasks.length), ); this.activitiesPending$ = this.activityManager.activities$.pipe( - map(tasks => tasks.some(task => task.inProgress)), + map((tasks) => tasks.some((task) => task.inProgress)), ); - this.currentUser$ = this.appState.select(state => state.auth).pipe( - filter(auth => auth.isLoggedIn), - map(auth => auth.user), + this.currentUser$ = this.appState.select((state) => state.auth).pipe( + filter((auth) => auth.isLoggedIn), + map((auth) => auth.user), first(), ); - this.cmpVersion$ = this.appState.select(state => state.ui.cmpVersion); - this.uiVersion$ = this.appState.select(state => state.ui.uiVersion); - this.featureHideManual$ = this.appState.select(state => state.features.global[Feature.HIDE_MANUAL] || false); + this.cmpVersion$ = this.appState.select((state) => state.ui.cmpVersion); + this.uiVersion$ = this.appState.select((state) => state.ui.uiVersion); + this.featureHideManual$ = this.appState.select((state) => state.features.global[Feature.HIDE_MANUAL] || false); this.isLoggedIn$.pipe( - filter(loggedIn => loggedIn), + filter((loggedIn) => loggedIn), switchMap(() => this.nodeOperations.getAll()), - ).subscribe(nodes => { + ).subscribe((nodes) => { this.loadedNodes = nodes; this.changeDetector.markForCheck(); }); @@ -177,7 +178,7 @@ export class AppComponent implements OnDestroy, OnInit { } onLogoutClick(): void { - this.keycloakSignOut$.pipe(first()).subscribe(singleSignOut => { + this.keycloakSignOut$.pipe(first()).subscribe((singleSignOut) => { this.authOps.logout(this.appState.now.auth.sid) .then(() => { if (singleSignOut) { @@ -195,8 +196,8 @@ export class AppComponent implements OnDestroy, OnInit { setLanguageConfirmation(language: GcmsUiLanguage): void { this.modalService.fromComponent(ConfirmReloadModalComponent, { closeOnOverlayClick: false }) - .then(modal => modal.open()) - .then(value => { + .then((modal) => modal.open()) + .then((value) => { if (value) { this.languageHandler.setActiveUiLanguage(language).subscribe(() => { this.appState.dispatch(new SetBackendLanguage(language)); @@ -218,7 +219,7 @@ export class AppComponent implements OnDestroy, OnInit { onShowPasswordModal(): void { this.modalService.fromComponent(ChangePasswordModalComponent) - .then(modal => modal.open()) + .then((modal) => modal.open()) .catch(this.errorHandler.catch); } @@ -235,9 +236,9 @@ export class AppComponent implements OnDestroy, OnInit { type: AccessControlledType.AUTO_UPDATE, permissions: GcmsPermission.READ, }).pipe( - switchMap(perm => perm ? - this.adminOps.getCmsUpdates(true) : - of(null), + switchMap((perm) => perm + ? this.adminOps.getCmsUpdates(true) + : of(null), ), ), diff --git a/cms-ui/apps/admin-ui/src/app/features/mesh-browser/components/mesh-browser-editor/mesh-browser-editor.component.html b/cms-ui/apps/admin-ui/src/app/features/mesh-browser/components/mesh-browser-editor/mesh-browser-editor.component.html index 96f0b077d2..cbb5a2884f 100644 --- a/cms-ui/apps/admin-ui/src/app/features/mesh-browser/components/mesh-browser-editor/mesh-browser-editor.component.html +++ b/cms-ui/apps/admin-ui/src/app/features/mesh-browser/components/mesh-browser-editor/mesh-browser-editor.component.html @@ -19,7 +19,7 @@

UUID
- {{ 'mesh.api_token_from' | gtxI18n:{ user: displayName } }}