diff --git a/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts b/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts index bdf514f6f2b5fd78539ffcee8ca271131bb1f483..673ea2c47571873cc69795547f6dc5359609e99a 100644 --- a/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts +++ b/alfa-client/apps/admin-e2e/src/components/benutzer/benutzer.e2e.component.ts @@ -115,6 +115,10 @@ export class BenutzerE2EComponent { return cy.getTestElement(this.headline); } + public getHeadline(): Cypress.Chainable<Element> { + return cy.getTestElement(this.headline); + } + public getVornameInput(): Cypress.Chainable<Element> { return cy.getTestElement(this.userVorname); } diff --git a/alfa-client/apps/admin-e2e/src/components/postfach/postfach.e2e.component.ts b/alfa-client/apps/admin-e2e/src/components/postfach/postfach.e2e.component.ts index 62527711781b022092af5336db78779b0df2c914..f177cf791efec0113ceaebf9d6098e2008bc21f9 100644 --- a/alfa-client/apps/admin-e2e/src/components/postfach/postfach.e2e.component.ts +++ b/alfa-client/apps/admin-e2e/src/components/postfach/postfach.e2e.component.ts @@ -21,19 +21,24 @@ * Die sprachspezifischen Genehmigungen und Beschränkungen * unter der Lizenz sind dem Lizenztext zu entnehmen. */ -import { haveValue, typeText } from '../../support/cypress.util'; +import { haveValue } from '../../support/cypress.util'; export class PostfachE2EComponent { + private readonly headline: string = 'headline'; private readonly signaturText: string = 'signature-textarea'; private readonly saveSignaturButton: string = 'save-button'; + public getHeadline(): any { + return cy.getTestElement(this.headline); + } + public getSignaturText(): any { return cy.getTestElement(this.signaturText); } public setSignatur(signatur: string): void { this.clearSignatur(); - typeText(this.getSignaturText(), signatur); + this.getSignaturText().type(signatur); } public clearSignatur(): void { diff --git a/alfa-client/apps/admin-e2e/src/components/zustaendige-stelle/zustaendige-stelle-dialog.e2e.component.ts b/alfa-client/apps/admin-e2e/src/components/zustaendige-stelle/zustaendige-stelle-dialog.e2e.component.ts index 337cad0fe51c3ba1e7bf0b1123e8f324c57dbc73..6bd293d6fc2c39febbea6bfef0fb4e66de7e22b6 100644 --- a/alfa-client/apps/admin-e2e/src/components/zustaendige-stelle/zustaendige-stelle-dialog.e2e.component.ts +++ b/alfa-client/apps/admin-e2e/src/components/zustaendige-stelle/zustaendige-stelle-dialog.e2e.component.ts @@ -1,5 +1,3 @@ -import { typeText } from '../../support/cypress.util'; - export class ZustaendigeStelleDialogE2EComponent { private readonly locatorZustaendigeStelleForm: string = 'search-organisations-einheit'; private readonly locatorSearchInput: string = 'instant_search-text-input'; @@ -15,7 +13,7 @@ export class ZustaendigeStelleDialogE2EComponent { } public enterSearchTerm(searchTerm: string): void { - typeText(this.getSearchInput(), searchTerm); + this.getSearchInput().type(searchTerm); } public countSearchEntries(): Cypress.Chainable<number> { diff --git a/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer_rollen.cy.ts b/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer_rollen.cy.ts index faa2082e1c7e0fe5d47730d7682cd4d4e5fd11ae..9c1ff6974d5b21497c2529e3babae686b28f6f10 100644 --- a/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer_rollen.cy.ts +++ b/alfa-client/apps/admin-e2e/src/e2e/main-tests/benutzer_rollen/benutzer_rollen.cy.ts @@ -21,23 +21,10 @@ * Die sprachspezifischen Genehmigungen und Beschränkungen * unter der Lizenz sind dem Lizenztext zu entnehmen. */ -import { - BenutzerE2EComponent, - BenutzerListE2EComponent, - BenutzerListItemE2EComponent, -} from 'apps/admin-e2e/src/components/benutzer/benutzer.e2e.component'; +import { BenutzerE2EComponent, BenutzerListE2EComponent, BenutzerListItemE2EComponent, } from 'apps/admin-e2e/src/components/benutzer/benutzer.e2e.component'; import { E2EBenutzerHelper } from 'apps/admin-e2e/src/helper/benutzer/benutzer.helper'; import { OrganisationsEinheitE2E } from 'apps/admin-e2e/src/model/organisations-einheit'; -import { - beChecked, - beEnabled, - contains, - exist, - mouseEnter, - notBeChecked, - notBeEnabled, - visible, -} from 'apps/admin-e2e/src/support/cypress.util'; +import { beChecked, beEnabled, contains, exist, mouseEnter, notBeChecked, notBeEnabled, visible, } from 'apps/admin-e2e/src/support/cypress.util'; import { AlfaRollen, AlfaUsers, loginAsAriane } from 'apps/admin-e2e/src/support/user-util'; describe('Benutzer und Rollen', () => { @@ -56,7 +43,7 @@ describe('Benutzer und Rollen', () => { it('should show users and attributes in list', () => { helper.openBenutzerListPage(); - const ariane: BenutzerListItemE2EComponent = benutzerListPage.getItem(AlfaUsers.ARAINE); + const ariane: BenutzerListItemE2EComponent = benutzerListPage.getItem(AlfaUsers.ARIANE); exist(ariane.getRoot()); contains(ariane.getRoles(), AlfaRollen.USER); @@ -85,25 +72,21 @@ describe('Benutzer und Rollen', () => { exist(richard.getNoOrganisationsEinheitText()); }); - it('should show single user screen on click', () => { + it('should show checkbox for each role', () => { helper.openNewBenutzerPage(); - exist(benutzerPage.getVornameInput()); - exist(benutzerPage.getNachnameInput()); - exist(benutzerPage.getBenutzernameInput()); - exist(benutzerPage.getMailInput()); - - notBeChecked(benutzerPage.getAdminCheckbox().getRoot()); - notBeChecked(benutzerPage.getLoeschenCheckbox().getRoot()); - notBeChecked(benutzerPage.getUserCheckbox().getRoot()); - notBeChecked(benutzerPage.getPostCheckbox().getRoot()); + notBeChecked(benutzerPage.getAdminCheckbox()); + notBeChecked(benutzerPage.getDatenbeauftragungCheckbox()); + notBeChecked(benutzerPage.getLoeschenCheckbox()); + notBeChecked(benutzerPage.getUserCheckbox()); + notBeChecked(benutzerPage.getPostCheckbox()); }); - it('should activate loeschen checkbox and deactivate the other two checkboxes', () => { - benutzerPage.getLoeschenCheckbox().getRoot().click(); - beChecked(benutzerPage.getLoeschenCheckbox().getRoot()); - notBeEnabled(benutzerPage.getUserCheckbox().getRoot()); - notBeEnabled(benutzerPage.getPostCheckbox().getRoot()); + it('should deactivate other alfa roles if "loeschen" role is selected', () => { + benutzerPage.getLoeschenCheckbox().click(); + beChecked(benutzerPage.getLoeschenCheckbox()); + notBeEnabled(benutzerPage.getUserCheckbox()); + notBeEnabled(benutzerPage.getPostCheckbox()); benutzerPage.getLoeschenCheckbox().getRoot().click(); notBeChecked(benutzerPage.getLoeschenCheckbox().getRoot()); @@ -111,39 +94,40 @@ describe('Benutzer und Rollen', () => { beEnabled(benutzerPage.getPostCheckbox().getRoot()); }); - it('should additionally activate and deactivate admin checkbox', () => { - benutzerPage.getLoeschenCheckbox().getRoot().click(); - benutzerPage.getAdminCheckbox().getRoot().click(); - beChecked(benutzerPage.getLoeschenCheckbox().getRoot()); - beChecked(benutzerPage.getAdminCheckbox().getRoot()); + it('should deactivate other alfa roles if "user" role is selected', () => { + benutzerPage.getUserCheckbox().click(); + beChecked(benutzerPage.getUserCheckbox()); + notBeEnabled(benutzerPage.getLoeschenCheckbox()); + notBeEnabled(benutzerPage.getPostCheckbox()); - benutzerPage.getAdminCheckbox().getRoot().click(); - notBeChecked(benutzerPage.getAdminCheckbox().getRoot()); + benutzerPage.getUserCheckbox().click(); + notBeChecked(benutzerPage.getUserCheckbox()); + beEnabled(benutzerPage.getLoeschenCheckbox()); + beEnabled(benutzerPage.getPostCheckbox()); }); - it('should activate user checkbox and deactivate the other two checkboxes', () => { - benutzerPage.getLoeschenCheckbox().getRoot().click(); - benutzerPage.getUserCheckbox().getRoot().click(); - beChecked(benutzerPage.getUserCheckbox().getRoot()); - notBeEnabled(benutzerPage.getLoeschenCheckbox().getRoot()); - notBeEnabled(benutzerPage.getPostCheckbox().getRoot()); - - benutzerPage.getUserCheckbox().getRoot().click(); - notBeChecked(benutzerPage.getUserCheckbox().getRoot()); - beEnabled(benutzerPage.getLoeschenCheckbox().getRoot()); - beEnabled(benutzerPage.getPostCheckbox().getRoot()); + it('should deactivate other alfa roles if "poststelle" role is selected', () => { + benutzerPage.getPostCheckbox().click(); + beChecked(benutzerPage.getPostCheckbox()); + notBeEnabled(benutzerPage.getLoeschenCheckbox()); + notBeEnabled(benutzerPage.getUserCheckbox()); + + benutzerPage.getPostCheckbox().click(); + notBeChecked(benutzerPage.getPostCheckbox()); + beEnabled(benutzerPage.getLoeschenCheckbox()); + beEnabled(benutzerPage.getUserCheckbox()); }); - it('should activate post checkbox and deactivate the other two checkboxes', () => { - benutzerPage.getPostCheckbox().getRoot().click(); - beChecked(benutzerPage.getPostCheckbox().getRoot()); - notBeEnabled(benutzerPage.getLoeschenCheckbox().getRoot()); - notBeEnabled(benutzerPage.getUserCheckbox().getRoot()); + it('should activate and deactivate admin roles', () => { + benutzerPage.getAdminCheckbox().click(); + benutzerPage.getDatenbeauftragungCheckbox().click(); + beChecked(benutzerPage.getAdminCheckbox()); + beChecked(benutzerPage.getDatenbeauftragungCheckbox()); - benutzerPage.getPostCheckbox().getRoot().click(); - notBeChecked(benutzerPage.getPostCheckbox().getRoot()); - beEnabled(benutzerPage.getLoeschenCheckbox().getRoot()); - beEnabled(benutzerPage.getUserCheckbox().getRoot()); + benutzerPage.getAdminCheckbox().click(); + benutzerPage.getDatenbeauftragungCheckbox().click(); + notBeChecked(benutzerPage.getAdminCheckbox()); + notBeChecked(benutzerPage.getDatenbeauftragungCheckbox()); }); describe('hint text', () => { diff --git a/alfa-client/apps/admin-e2e/src/e2e/main-tests/navigation/ariane.cy.ts b/alfa-client/apps/admin-e2e/src/e2e/main-tests/navigation/ariane.cy.ts index 80987765996348c4f24f3105abc2328713ef4371..64218bf8c50d791ca4f221c518a454cc54df37fe 100644 --- a/alfa-client/apps/admin-e2e/src/e2e/main-tests/navigation/ariane.cy.ts +++ b/alfa-client/apps/admin-e2e/src/e2e/main-tests/navigation/ariane.cy.ts @@ -1,29 +1,33 @@ -import { MainPage, waitForSpinnerToDisappear } from 'apps/admin-e2e/src/page-objects/main.po'; -import { exist, notExist } from 'apps/admin-e2e/src/support/cypress.util'; +import { MainPage } from 'apps/admin-e2e/src/page-objects/main.po'; +import { containClass, exist, notExist } from 'apps/admin-e2e/src/support/cypress.util'; import { loginAsAriane } from 'apps/admin-e2e/src/support/user-util'; -describe('Navigation', () => { +describe('Ariane Navigation', () => { const mainPage: MainPage = new MainPage(); describe('with user ariane', () => { before(() => { loginAsAriane(); - - waitForSpinnerToDisappear(); }); it('should show benutzer navigation item', () => { exist(mainPage.getBenutzerNavigationItem()); }); - it('should show postfach navigation item', () => { - exist(mainPage.getPostfachNavigationItem()); - }); - it('should show organisationseinheiten navigation item', () => { exist(mainPage.getOrganisationEinheitNavigationItem()); }); + describe('postfach navigation item', () => { + it('should be visible', () => { + exist(mainPage.getPostfachNavigationItem()); + }); + + it('should be selected initial', () => { + containClass(mainPage.getPostfachNavigationItem(), 'border-selected'); + }); + }); + it('should hide statistik navigation item', () => { notExist(mainPage.getStatistikNavigationItem()); }); diff --git a/alfa-client/apps/admin-e2e/src/e2e/main-tests/navigation/daria.cy.ts b/alfa-client/apps/admin-e2e/src/e2e/main-tests/navigation/daria.cy.ts index 33a06a73b83c3a839b0490f96aa17ddb42106994..4fda0a2dee6bfb74ad354a08e46f27ced5e15bc6 100644 --- a/alfa-client/apps/admin-e2e/src/e2e/main-tests/navigation/daria.cy.ts +++ b/alfa-client/apps/admin-e2e/src/e2e/main-tests/navigation/daria.cy.ts @@ -1,43 +1,34 @@ -import { MainPage, waitForSpinnerToDisappear } from 'apps/admin-e2e/src/page-objects/main.po'; -import { exist, notExist, visible } from 'apps/admin-e2e/src/support/cypress.util'; +import { MainPage } from 'apps/admin-e2e/src/page-objects/main.po'; +import { containClass, exist, notExist } from 'apps/admin-e2e/src/support/cypress.util'; import { loginAsDaria } from 'apps/admin-e2e/src/support/user-util'; -import { StatistikE2EComponent } from '../../../components/statistik/statistik.e2e.component'; -describe('Navigation', () => { +describe('Daria Navigation', () => { const mainPage: MainPage = new MainPage(); - const statistikPage: StatistikE2EComponent = new StatistikE2EComponent(); - describe('with user daria', () => { before(() => { loginAsDaria(); - - waitForSpinnerToDisappear(); }); - it('should hide other navigation item', () => { + it('should hide benutzer navigation item', () => { notExist(mainPage.getBenutzerNavigationItem()); }); - it('should hide postfach navigation item', () => { - notExist(mainPage.getPostfachNavigationItem()); - }); - it('should hide organisationseinheiten navigation item', () => { notExist(mainPage.getOrganisationEinheitNavigationItem()); }); - describe('statistik', () => { + it('should hide postfach navigation item', () => { + notExist(mainPage.getPostfachNavigationItem()); + }); + + describe('statistik navigation item', () => { it('should be visible', () => { exist(mainPage.getStatistikNavigationItem()); }); it('should be initial selected', () => { - mainPage.isStatistikNavigationItemSelected(); - }); - - it('should show header text', () => { - visible(statistikPage.getHeaderText()); + containClass(mainPage.getStatistikNavigationItem(), 'border-selected'); }); }); }); diff --git a/alfa-client/apps/admin-e2e/src/e2e/main-tests/navigation/safira.cy.ts b/alfa-client/apps/admin-e2e/src/e2e/main-tests/navigation/safira.cy.ts index 005da2e5a576948787fdeb1f26165bd250b22138..ae328f579e493b6c493a687da703269120bebbd5 100644 --- a/alfa-client/apps/admin-e2e/src/e2e/main-tests/navigation/safira.cy.ts +++ b/alfa-client/apps/admin-e2e/src/e2e/main-tests/navigation/safira.cy.ts @@ -1,50 +1,35 @@ -import { MainPage, waitForSpinnerToDisappear } from 'apps/admin-e2e/src/page-objects/main.po'; -import { exist, visible } from 'apps/admin-e2e/src/support/cypress.util'; +import { MainPage } from 'apps/admin-e2e/src/page-objects/main.po'; +import { containClass, exist } from 'apps/admin-e2e/src/support/cypress.util'; import { loginAsSafira } from 'apps/admin-e2e/src/support/user-util'; -import { StatistikE2EComponent } from '../../../components/statistik/statistik.e2e.component'; -describe('Navigation', () => { +describe('Safira Navigation', () => { const mainPage: MainPage = new MainPage(); - const statistikPage: StatistikE2EComponent = new StatistikE2EComponent(); - describe('with user safira', () => { before(() => { loginAsSafira(); - - waitForSpinnerToDisappear(); }); it('should show benutzer navigation item', () => { exist(mainPage.getBenutzerNavigationItem()); }); - it('should show postfach navigation item', () => { - exist(mainPage.getPostfachNavigationItem()); - }); - it('should show organisationseinheiten navigation item', () => { exist(mainPage.getOrganisationEinheitNavigationItem()); }); - describe('statistik', () => { + describe('postfach navigation item', () => { it('should be visible', () => { - exist(mainPage.getStatistikNavigationItem()); + exist(mainPage.getPostfachNavigationItem()); }); - describe('on selection', () => { - before(() => { - mainPage.clickStatistikNavigationItem(); - }); - - it('should show page on selection', () => { - visible(statistikPage.getHeaderText()); - }); - - it('should mark navigation item as selected', () => { - mainPage.isStatistikNavigationItemSelected(); - }); + it('should be selected initial', () => { + containClass(mainPage.getPostfachNavigationItem(), 'border-selected'); }); }); + + it('should show statistik navigation item', () => { + exist(mainPage.getStatistikNavigationItem()); + }); }); }); diff --git a/alfa-client/apps/admin-e2e/src/e2e/main-tests/postfach/postfach-signatur.cy.ts b/alfa-client/apps/admin-e2e/src/e2e/main-tests/postfach/postfach-signatur.cy.ts index 1a8b4a69f8b1f19d1c2daf27b65274f46fea62e0..779a88f3c46a4724f9d188768e7344e9b992cf1d 100644 --- a/alfa-client/apps/admin-e2e/src/e2e/main-tests/postfach/postfach-signatur.cy.ts +++ b/alfa-client/apps/admin-e2e/src/e2e/main-tests/postfach/postfach-signatur.cy.ts @@ -21,12 +21,12 @@ * Die sprachspezifischen Genehmigungen und Beschränkungen * unter der Lizenz sind dem Lizenztext zu entnehmen. */ +import { E2EPostfachHelper } from 'apps/admin-e2e/src/helper/postfach/postfach.helper'; import { PostfachE2EComponent } from '../../../components/postfach/postfach.e2e.component'; -import { waitForSpinnerToDisappear } from '../../../page-objects/main.po'; -import { exist } from '../../../support/cypress.util'; import { loginAsAriane } from '../../../support/user-util'; -describe('Signatur', () => { +describe('(TODO: Ist noch wackelig in Bezug auf die Eingabe in das Feld) Postfach Signatur', () => { + const postfachHelper: E2EPostfachHelper = new E2EPostfachHelper(); const postfach: PostfachE2EComponent = new PostfachE2EComponent(); const signaturText: string = 'Signatur\nmit\n\n\n\nZeilenumbruch\n\n'; @@ -35,12 +35,9 @@ describe('Signatur', () => { loginAsAriane(); }); - it('should show Postfach page', () => { - waitForSpinnerToDisappear(); - exist(postfach.getSignaturText()); - }); - it('should show signature input with scrollbar', () => { + postfachHelper.openPostfachPage(); + postfach.setSignatur(signaturText); postfach.saveSignatur(); diff --git a/alfa-client/apps/admin-e2e/src/helper/postfach/postfach.helper.ts b/alfa-client/apps/admin-e2e/src/helper/postfach/postfach.helper.ts new file mode 100644 index 0000000000000000000000000000000000000000..90624b0151bdae26e5e1859734132d6b2e24cf62 --- /dev/null +++ b/alfa-client/apps/admin-e2e/src/helper/postfach/postfach.helper.ts @@ -0,0 +1,9 @@ +import { E2EPostfachNavigator } from './postfach.navigator'; + +export class E2EPostfachHelper { + private readonly navigator: E2EPostfachNavigator = new E2EPostfachNavigator(); + + public openPostfachPage(): void { + this.navigator.openPostfachPage(); + } +} diff --git a/alfa-client/apps/admin-e2e/src/helper/postfach/postfach.navigator.ts b/alfa-client/apps/admin-e2e/src/helper/postfach/postfach.navigator.ts new file mode 100644 index 0000000000000000000000000000000000000000..074438baaaf22cc22669f085412ddd5d2f22046d --- /dev/null +++ b/alfa-client/apps/admin-e2e/src/helper/postfach/postfach.navigator.ts @@ -0,0 +1,18 @@ +import { PostfachE2EComponent } from '../../components/postfach/postfach.e2e.component'; +import { MainPage } from '../../page-objects/main.po'; +import { exist } from '../../support/cypress.util'; + +export class E2EPostfachNavigator { + private mainPage: MainPage = new MainPage(); + private postfach: PostfachE2EComponent = new PostfachE2EComponent(); + + public openPostfachPage(): void { + this.navigateToDomain(); + this.mainPage.getPostfachNavigationItem().click(); + exist(this.postfach.getHeadline()); + } + + private navigateToDomain(): void { + this.mainPage.getHeader().getLogo().click(); + } +} diff --git a/alfa-client/apps/admin-e2e/src/model/util.ts b/alfa-client/apps/admin-e2e/src/model/util.ts index 03041ea28be5dae394e7df97d6bf38ae72d2a182..7ad5cd61ab1741c43e02532c38b711c92eb9c8dc 100644 --- a/alfa-client/apps/admin-e2e/src/model/util.ts +++ b/alfa-client/apps/admin-e2e/src/model/util.ts @@ -46,6 +46,7 @@ export interface AdminUserE2E { username: string; email: string; isAdmin?: boolean; + isDatenbeauftragung?: boolean; isUser?: boolean; isLoeschen?: boolean; isPoststelle?: boolean; diff --git a/alfa-client/apps/admin-e2e/src/page-objects/main.po.ts b/alfa-client/apps/admin-e2e/src/page-objects/main.po.ts index 338c98286a6f1fc02e97d52381b99de0dbfc3db6..75ecbdaefa5d692ca637af325afb58ec05f80abe 100644 --- a/alfa-client/apps/admin-e2e/src/page-objects/main.po.ts +++ b/alfa-client/apps/admin-e2e/src/page-objects/main.po.ts @@ -22,17 +22,16 @@ * unter der Lizenz sind dem Lizenztext zu entnehmen. */ import { BuildInfoE2EComponent } from '../components/buildinfo/buildinfo.e2e.component'; -import { containClass, exist } from '../support/cypress.util'; import { HeaderE2EComponent } from './header.po'; export class MainPage { private readonly buildInfo: BuildInfoE2EComponent = new BuildInfoE2EComponent(); private readonly header: HeaderE2EComponent = new HeaderE2EComponent(); - private readonly benutzerNavigationItem: string = 'caption-Benutzer__Rollen'; - private readonly postfachNavigationItem: string = 'postfach-navigation'; - private readonly organisationEinheitNavigationItem: string = 'organisations-einheiten-navigation'; - private readonly statistikNavigationItem: string = 'statistik-navigation'; + private readonly benutzerNavigationItem: string = 'link-path-benutzer'; + private readonly organisationEinheitNavigationItem: string = 'link-path-organisationseinheiten'; + private readonly postfachNavigationItem: string = 'link-path-postfach'; + private readonly statistikNavigationItem: string = 'link-path-statistik'; public getBuildInfo(): BuildInfoE2EComponent { return this.buildInfo; @@ -46,41 +45,17 @@ export class MainPage { return cy.getTestElement(this.benutzerNavigationItem); } - public clickBenutzerNavigationItem(): void { - this.getBenutzerNavigationItem().click(); - } - - public benutzerNavigationItemIsVisible(): void { - exist(this.getBenutzerNavigationItem()); - } - public getPostfachNavigationItem(): Cypress.Chainable<Element> { return cy.getTestElement(this.postfachNavigationItem); } - public clickPostfachNavigationItem(): void { - this.getPostfachNavigationItem().click(); - } - public getOrganisationEinheitNavigationItem(): Cypress.Chainable<Element> { return cy.getTestElement(this.organisationEinheitNavigationItem); } - public clickOrganisationsEinheitenNavigationItem(): void { - this.getOrganisationEinheitNavigationItem().click(); - } - public getStatistikNavigationItem(): Cypress.Chainable<Element> { return cy.getTestElement(this.statistikNavigationItem); } - - public clickStatistikNavigationItem(): void { - this.getStatistikNavigationItem().click(); - } - - public isStatistikNavigationItemSelected(): void { - containClass(this.getStatistikNavigationItem().get('a'), 'border-selected'); - } } export function waitForSpinnerToDisappear(): boolean { diff --git a/alfa-client/apps/admin-e2e/src/support/cypress.util.ts b/alfa-client/apps/admin-e2e/src/support/cypress.util.ts index d18cc34fbfe2cedb9d21aae60b730283b96e9392..63f3c24872ebea08644aa85eead331dac07f1b40 100644 --- a/alfa-client/apps/admin-e2e/src/support/cypress.util.ts +++ b/alfa-client/apps/admin-e2e/src/support/cypress.util.ts @@ -124,10 +124,6 @@ export function enterWith(element: Cypress.Chainable<Element>, value: string, de element.type(CypressKeyboardActions.ENTER); } -export function typeText(element: Cypress.Chainable<Element>, value: string): void { - element.type(value); -} - export function backspaceOn(element: Cypress.Chainable<Element>): void { element.type(CypressKeyboardActions.BACKSPACE); } diff --git a/alfa-client/apps/admin-e2e/src/support/user-util.ts b/alfa-client/apps/admin-e2e/src/support/user-util.ts index d5e7462638087a82b1e4d365e0a59e11932962e5..989e061babc526985e62ace05e516bdb2d058516 100644 --- a/alfa-client/apps/admin-e2e/src/support/user-util.ts +++ b/alfa-client/apps/admin-e2e/src/support/user-util.ts @@ -89,10 +89,12 @@ export enum AlfaRollen { LOESCHEN = 'VERWALTUNG_LOESCHEN', POSTSTELLE = 'VERWALTUNG_POSTSTELLE', ADMIN = 'ADMIN_ADMIN', + DATEN = 'DATENBEAUFTRAGUNG', } export enum AlfaUsers { - ARAINE = 'ariane', + ARIANE = 'ariane', + DARIA = 'daria', DOROTHEA = 'dorothea', LUDWIG = 'ludwig', PETER = 'peter', diff --git a/alfa-client/apps/admin/src/app/app.component.spec.ts b/alfa-client/apps/admin/src/app/app.component.spec.ts index b0a354caa1b666ad9ad15a4527e77f8a632496c6..baa8c7fcc532a0e859fe596782a94bf7b2257803 100644 --- a/alfa-client/apps/admin/src/app/app.component.spec.ts +++ b/alfa-client/apps/admin/src/app/app.component.spec.ts @@ -55,29 +55,31 @@ describe('AppComponent', () => { const routerOutletSelector: string = getDataTestIdOf('router-outlet'); const menuContainer: string = getDataTestIdOf('menu-container'); - const authenticationService: Mock<AuthenticationService> = { - ...mock(AuthenticationService), - login: jest.fn().mockResolvedValue(Promise.resolve()), - }; - - const router: Mock<Router> = mock(Router); - const route: Mock<ActivatedRoute> = { - ...mock(ActivatedRoute), - snapshot: { - queryParams: { - iss: 'some-iss', - state: 'some-state', - session_state: 'some-session-state', - code: 'some-code', - }, - } as any, - }; - - const apiRootService: Mock<ApiRootService> = mock(ApiRootService); + let authenticationService: Mock<AuthenticationService>; + let router: Mock<Router>; + let route: Mock<ActivatedRoute>; + let apiRootService: Mock<ApiRootService>; let configurationService: Mock<ConfigurationService>; let keycloakTokenService: Mock<KeycloakTokenService>; beforeEach(async () => { + authenticationService = { + ...mock(AuthenticationService), + login: jest.fn().mockResolvedValue(Promise.resolve()), + }; + router = mock(Router); + route = { + ...mock(ActivatedRoute), + snapshot: { + queryParams: { + iss: 'some-iss', + state: 'some-state', + session_state: 'some-session-state', + code: 'some-code', + }, + } as any, + }; + apiRootService = mock(ApiRootService); configurationService = mock(ConfigurationService); keycloakTokenService = mock(KeycloakTokenService); @@ -145,81 +147,110 @@ describe('AppComponent', () => { expect(authenticationService.login).toHaveBeenCalled(); }); - it('should call doAfterLoggedIn', async () => { - component.doAfterLoggedIn = jest.fn(); + it('should call doAfterLoggedIn only once', async () => { + component._doAfterLoggedIn = jest.fn(); component.ngOnInit(); await fixture.whenStable(); - expect(component.doAfterLoggedIn).toHaveBeenCalled(); + expect(component._doAfterLoggedIn).toHaveBeenCalledTimes(1); }); }); describe('do after logged in', () => { beforeEach(() => { - component.evaluateInitialNavigation = jest.fn(); + component._evaluateInitialNavigation = jest.fn(); }); it('should call apiRootService to getApiRoot', () => { - component.doAfterLoggedIn(); + component._doAfterLoggedIn(); expect(apiRootService.getApiRoot).toHaveBeenCalled(); }); it('should call keycloak token service to register token provider', () => { - component.doAfterLoggedIn(); + component._doAfterLoggedIn(); expect(keycloakTokenService.registerAccessTokenProvider).toHaveBeenCalled(); }); it('should call evaluateInitialNavigation', () => { - component.doAfterLoggedIn(); + component._doAfterLoggedIn(); - expect(component.evaluateInitialNavigation).toHaveBeenCalled(); + expect(component._evaluateInitialNavigation).toHaveBeenCalled(); }); }); describe('evaluate initial navigation', () => { beforeEach(() => { - component.evaluateNavigationByApiRoot = jest.fn(); + component._evaluateNavigationByApiRoot = jest.fn(); + component._subscribeApiRootForEvaluation = jest.fn(); }); - it('should call evaluate navigation by apiRoot', () => { - const apiRootResource: ApiRootResource = createApiRootResource(); + it('should call router navigate', () => { + window.location.pathname = '/path'; + (router as any).url = '/path'; + + component._evaluateInitialNavigation(); + + expect(router.navigate).toHaveBeenCalledWith([window.location.pathname]); + }); + + it('should call subscribe api root evaluation if url starts with /?state', () => { + (router as any).url = '/?state=some-state'; + + component._evaluateInitialNavigation(); + + expect(component._subscribeApiRootForEvaluation).toHaveBeenCalled(); + }); + }); + + describe('subscribeApiRootForEvaluation', () => { + const apiRootResource: ApiRootResource = createApiRootResource(); + + beforeEach(() => { component.apiRootStateResource$ = of(createStateResource(apiRootResource)); + component._evaluateNavigationByApiRoot = jest.fn(); + }); - component.evaluateInitialNavigation(); + it('should set apiRootSubscription', () => { + component._subscribeApiRootForEvaluation(); + + expect(component.apiRootSubscription).toBeDefined(); + }); + + it('should call evaluate navigation by apiRoot', () => { + component._subscribeApiRootForEvaluation(); - expect(component.evaluateNavigationByApiRoot).toHaveBeenCalledWith(apiRootResource); + expect(component._evaluateNavigationByApiRoot).toHaveBeenCalledWith(apiRootResource); }); it('should NOT call evaluate navigation by apiRoot if resource is loading', () => { - component.apiRootStateResource$ = of(createEmptyStateResource<ApiRootResource>(true)); - component.evaluateNavigationByApiRoot = jest.fn(); + component.apiRootStateResource$ = of(createStateResource(apiRootResource, true)); - component.evaluateInitialNavigation(); + component._subscribeApiRootForEvaluation(); - expect(component.evaluateNavigationByApiRoot).not.toHaveBeenCalled(); + expect(component._evaluateNavigationByApiRoot).not.toHaveBeenCalled(); }); }); describe('evaluate navigation api root', () => { it('should evaluate navigation by configuration if link exists', () => { - component.evaluateNavigationByConfiguration = jest.fn(); + component._evaluateNavigationByConfiguration = jest.fn(); const apiRootResource: ApiRootResource = createApiRootResource([ApiRootLinkRel.CONFIGURATION]); - component.evaluateNavigationByApiRoot(apiRootResource); + component._evaluateNavigationByApiRoot(apiRootResource); - expect(component.evaluateNavigationByConfiguration).toHaveBeenCalled(); + expect(component._evaluateNavigationByConfiguration).toHaveBeenCalled(); }); it('should navigate by api root if link is missing', () => { - component.navigateByApiRoot = jest.fn(); + component._navigateByApiRoot = jest.fn(); const apiRootResource: ApiRootResource = createApiRootResource(); - component.evaluateNavigationByApiRoot(apiRootResource); + component._evaluateNavigationByApiRoot(apiRootResource); - expect(component.navigateByApiRoot).toHaveBeenCalledWith(apiRootResource); + expect(component._navigateByApiRoot).toHaveBeenCalledWith(apiRootResource); }); }); @@ -228,83 +259,83 @@ describe('AppComponent', () => { beforeEach(() => { configurationService.get.mockReturnValue(of(createStateResource(configurationResource))); - component.navigateByConfiguration = jest.fn(); + component._navigateByConfiguration = jest.fn(); }); it('should call configuration service to get resource', () => { - component.evaluateNavigationByConfiguration(); + component._evaluateNavigationByConfiguration(); expect(configurationService.get).toHaveBeenCalled(); }); it('should call navigate by configuration', () => { - component.evaluateNavigationByConfiguration(); + component._evaluateNavigationByConfiguration(); - expect(component.navigateByConfiguration).toHaveBeenCalledWith(configurationResource); + expect(component._navigateByConfiguration).toHaveBeenCalledWith(configurationResource); }); it('should NOT call navigate by configuration if resource is loading', () => { configurationService.get.mockReturnValue(of(createEmptyStateResource<ConfigurationResource>(true))); - component.evaluateNavigationByConfiguration(); + component._evaluateNavigationByConfiguration(); - expect(component.navigateByConfiguration).not.toHaveBeenCalled(); + expect(component._navigateByConfiguration).not.toHaveBeenCalled(); }); }); describe('navigate by configuration', () => { beforeEach(() => { - component.unsubscribe = jest.fn(); + component._unsubscribe = jest.fn(); }); it('should navigate to postfach if settings link exists', () => { - component.navigateByConfiguration(createConfigurationResource([ConfigurationLinkRel.SETTING])); + component._navigateByConfiguration(createConfigurationResource([ConfigurationLinkRel.SETTING])); expect(router.navigate).toHaveBeenCalledWith(['/postfach']); }); it('should navigate to statistik if aggregation mapping link exists', () => { - component.navigateByConfiguration(createConfigurationResource([ConfigurationLinkRel.AGGREGATION_MAPPINGS])); + component._navigateByConfiguration(createConfigurationResource([ConfigurationLinkRel.AGGREGATION_MAPPINGS])); expect(router.navigate).toHaveBeenCalledWith(['/statistik']); }); it('should navigate to unavailable page if no link exists', () => { - component.navigateByConfiguration(createConfigurationResource()); + component._navigateByConfiguration(createConfigurationResource()); expect(router.navigate).toHaveBeenCalledWith(['/unavailable']); }); it('should call unsubscribe', () => { - component.navigateByConfiguration(createConfigurationResource()); + component._navigateByConfiguration(createConfigurationResource()); - expect(component.unsubscribe).toHaveBeenCalled(); + expect(component._unsubscribe).toHaveBeenCalled(); }); }); describe('navigate by api root', () => { beforeEach(() => { - component.unsubscribe = jest.fn(); + component._unsubscribe = jest.fn(); }); it('should navigate to benutzer und rollen if users link exists', () => { const apiRootResource: ApiRootResource = createApiRootResource([ApiRootLinkRel.USERS]); - component.navigateByApiRoot(apiRootResource); + component._navigateByApiRoot(apiRootResource); expect(router.navigate).toHaveBeenCalledWith([`/${ROUTES.BENUTZER}`]); }); it('should navigate to unavailable page if no link exists', () => { - component.navigateByApiRoot(createApiRootResource()); + component._navigateByApiRoot(createApiRootResource()); expect(router.navigate).toHaveBeenCalledWith([`/${ROUTES.UNAVAILABLE}`]); }); it('should call unsubscribe', () => { - component.navigateByApiRoot(createApiRootResource()); + component._navigateByApiRoot(createApiRootResource()); - expect(component.unsubscribe).toHaveBeenCalled(); + expect(component._unsubscribe).toHaveBeenCalled(); }); }); @@ -314,7 +345,7 @@ describe('AppComponent', () => { component.apiRootSubscription = <any>mock(Subscription); component.apiRootSubscription.unsubscribe = jest.fn(); - component.unsubscribe(); + component._unsubscribe(); expect(component.apiRootSubscription.unsubscribe).toHaveBeenCalled(); }); @@ -323,7 +354,7 @@ describe('AppComponent', () => { component.apiRootSubscription = undefined; const unsubscribeSpy: jest.SpyInstance = jest.spyOn(Subscription.prototype, 'unsubscribe'); - component.unsubscribe(); + component._unsubscribe(); expect(unsubscribeSpy).not.toHaveBeenCalled(); unsubscribeSpy.mockRestore(); @@ -335,7 +366,7 @@ describe('AppComponent', () => { component.configurationSubscription = <any>mock(Subscription); component.configurationSubscription.unsubscribe = jest.fn(); - component.unsubscribe(); + component._unsubscribe(); expect(component.configurationSubscription.unsubscribe).toHaveBeenCalled(); }); @@ -344,7 +375,7 @@ describe('AppComponent', () => { component.apiRootSubscription = undefined; const unsubscribeSpy: jest.SpyInstance = jest.spyOn(Subscription.prototype, 'unsubscribe'); - component.unsubscribe(); + component._unsubscribe(); expect(unsubscribeSpy).not.toHaveBeenCalled(); unsubscribeSpy.mockRestore(); diff --git a/alfa-client/apps/admin/src/app/app.component.ts b/alfa-client/apps/admin/src/app/app.component.ts index bb5f2013fde365c95ae01525f1b8cb170decddd6..909b6cd198bd3ccb91dd61e922abb6a1ed79210c 100644 --- a/alfa-client/apps/admin/src/app/app.component.ts +++ b/alfa-client/apps/admin/src/app/app.component.ts @@ -33,13 +33,7 @@ import { Component, inject, OnInit } from '@angular/core'; import { Router, RouterLink, RouterOutlet } from '@angular/router'; import { AuthenticationService } from '@authentication'; import { hasLink } from '@ngxp/rest'; -import { - AdminLogoIconComponent, - NavbarComponent, - NavItemComponent, - OrgaUnitIconComponent, - UsersIconComponent, -} from '@ods/system'; +import { AdminLogoIconComponent, NavbarComponent, NavItemComponent, OrgaUnitIconComponent, UsersIconComponent, } from '@ods/system'; import { filter, Observable, Subscription } from 'rxjs'; import { UserProfileButtonContainerComponent } from '../../../../libs/admin/user-profile/src/lib/user-menu/user-profile.button-container.component'; import { UnavailablePageComponent } from '../pages/unavailable/unavailable-page/unavailable-page.component'; @@ -83,37 +77,49 @@ export class AppComponent implements OnInit { public readonly routes = ROUTES; ngOnInit(): void { - this.authenticationService.login().then(() => this.doAfterLoggedIn()); + this.authenticationService.login().then(() => this._doAfterLoggedIn()); } - doAfterLoggedIn(): void { + _doAfterLoggedIn(): void { this.apiRootStateResource$ = this.apiRootService.getApiRoot(); this.keycloakTokenService.registerAccessTokenProvider(); - this.evaluateInitialNavigation(); + this._evaluateInitialNavigation(); } - evaluateInitialNavigation(): void { + _evaluateInitialNavigation(): void { + if (this.router.url.startsWith('/?state')) { + this._subscribeApiRootForEvaluation(); + } else { + this.refreshForGuardEvaluation(); + } + } + + _subscribeApiRootForEvaluation(): void { this.apiRootSubscription = this.apiRootStateResource$ .pipe(filter(isLoaded), mapToResource<ApiRootResource>()) - .subscribe((apiRootResource: ApiRootResource) => this.evaluateNavigationByApiRoot(apiRootResource)); + .subscribe((apiRootResource: ApiRootResource) => this._evaluateNavigationByApiRoot(apiRootResource)); + } + + private refreshForGuardEvaluation(): void { + this.router.navigate([window.location.pathname]); } - evaluateNavigationByApiRoot(apiRootResource: ApiRootResource): void { + _evaluateNavigationByApiRoot(apiRootResource: ApiRootResource): void { if (hasLink(apiRootResource, ApiRootLinkRel.CONFIGURATION)) { - this.evaluateNavigationByConfiguration(); + this._evaluateNavigationByConfiguration(); } else { - this.navigateByApiRoot(apiRootResource); + this._navigateByApiRoot(apiRootResource); } } - evaluateNavigationByConfiguration(): void { + _evaluateNavigationByConfiguration(): void { this.configurationSubscription = this.configurationService .get() .pipe(filter(isLoaded), mapToResource<ApiRootResource>()) - .subscribe((configurationResource: ConfigurationResource) => this.navigateByConfiguration(configurationResource)); + .subscribe((configurationResource: ConfigurationResource) => this._navigateByConfiguration(configurationResource)); } - navigateByConfiguration(configurationResource: ConfigurationResource): void { + _navigateByConfiguration(configurationResource: ConfigurationResource): void { if (hasLink(configurationResource, ConfigurationLinkRel.SETTING)) { this.navigate(ROUTES.POSTFACH); } else if (hasLink(configurationResource, ConfigurationLinkRel.AGGREGATION_MAPPINGS)) { @@ -121,23 +127,23 @@ export class AppComponent implements OnInit { } else { this.navigate(ROUTES.UNAVAILABLE); } - this.unsubscribe(); + this._unsubscribe(); } - navigateByApiRoot(apiRootResource: ApiRootResource): void { + _navigateByApiRoot(apiRootResource: ApiRootResource): void { if (hasLink(apiRootResource, ApiRootLinkRel.USERS)) { this.navigate(ROUTES.BENUTZER); } else { this.navigate(ROUTES.UNAVAILABLE); } - this.unsubscribe(); + this._unsubscribe(); } private navigate(routePath: string): void { this.router.navigate(['/' + routePath]); } - unsubscribe(): void { + _unsubscribe(): void { if (isNotUndefined(this.apiRootSubscription)) this.apiRootSubscription.unsubscribe(); if (isNotUndefined(this.configurationSubscription)) this.configurationSubscription.unsubscribe(); } diff --git a/alfa-client/apps/admin/src/app/app.guard.spec.ts b/alfa-client/apps/admin/src/app/app.guard.spec.ts index 24e0a21eca4fb630c66112e19ba4b0024bf983a9..41cfe7506366e35bdd3d16d4f24d054c16eef40c 100644 --- a/alfa-client/apps/admin/src/app/app.guard.spec.ts +++ b/alfa-client/apps/admin/src/app/app.guard.spec.ts @@ -27,6 +27,7 @@ import { createStateResource, LinkRelationName, StateResource } from '@alfa-clie import { Mock, mock, useFromMock } from '@alfa-client/test-utils'; import { TestBed } from '@angular/core/testing'; import { ActivatedRouteSnapshot, Router, UrlTree } from '@angular/router'; +import { AuthenticationService } from '@authentication'; import { Resource } from '@ngxp/rest'; import { createConfigurationResource } from 'libs/admin/configuration-shared/test/configuration'; import { createApiRootResource } from 'libs/api-root-shared/test/api-root'; @@ -139,10 +140,49 @@ describe('Guard', () => { const resource: Resource = createDummyResource([DummyLinkRel.DUMMY]); const stateResource$: Observable<StateResource<Resource>> = of(createStateResource(resource)); + let authService: Mock<AuthenticationService>; + let router: Mock<Router>; + + beforeEach(() => { + authService = { + ...mock(AuthenticationService), + isLoggedIn: jest.fn().mockReturnValue(true), + }; + router = mock(Router); + + TestBed.configureTestingModule({ + providers: [ + { + provide: Router, + useValue: router, + }, + { + provide: AuthenticationService, + useValue: authService, + }, + ], + }); + }); + afterEach(() => { jest.restoreAllMocks(); }); + it('should call authenticationService isLoggedIn', () => { + evaluate().subscribe(); + + expect(authService.isLoggedIn).toHaveBeenCalled(); + }); + + it('should return observable true if not logged in', (done) => { + authService.isLoggedIn.mockReturnValue(false); + + evaluate().subscribe((resolvedValue) => { + expect(resolvedValue).toEqual(true); + done(); + }); + }); + it('should evaluate resource', () => { const urlTreeMock: Mock<UrlTree> = mock(UrlTree); const evaluateResourceSpy: jest.SpyInstance = jest @@ -151,7 +191,7 @@ describe('Guard', () => { evaluate().subscribe(); - expect(evaluateResourceSpy).toHaveBeenCalledWith(resource, DummyLinkRel.DUMMY); + expect(evaluateResourceSpy).toHaveBeenCalledWith(resource, DummyLinkRel.DUMMY, router); }); it('should return evaluated boolean', (done) => { @@ -181,34 +221,23 @@ describe('Guard', () => { }); describe('evaluate resource', () => { - let router: Mock<Router>; - - beforeEach(() => { - router = mock(Router); - - TestBed.configureTestingModule({ - providers: [ - { - provide: Router, - useValue: router, - }, - ], - }); - }); + const router: Mock<Router> = mock(Router); afterEach(() => { jest.restoreAllMocks(); }); it('should return true if link exists', () => { - const result: boolean = <boolean>evaluateResource(createDummyResource([DummyLinkRel.DUMMY])); + const result: boolean = <boolean>( + Guard.evaluateResource(createDummyResource([DummyLinkRel.DUMMY]), DummyLinkRel.DUMMY, useFromMock(router)) + ); expect(result).toBeTruthy(); }); describe('if link not exists', () => { it('should call router', () => { - evaluateResource(createDummyResource()); + Guard.evaluateResource(createDummyResource(), DummyLinkRel.DUMMY, useFromMock(router)); expect(router.createUrlTree).toHaveBeenCalledWith(['/unavailable']); }); @@ -217,14 +246,10 @@ describe('Guard', () => { const urlTree: Mock<UrlTree> = mock(UrlTree); router.createUrlTree.mockReturnValue(urlTree); - const result: UrlTree = <UrlTree>evaluateResource(createDummyResource()); + const result: UrlTree = <UrlTree>Guard.evaluateResource(createDummyResource(), DummyLinkRel.DUMMY, useFromMock(router)); expect(result).toEqual(urlTree); }); }); - - function evaluateResource(dummyResource: Resource): boolean | UrlTree { - return <boolean | UrlTree>TestBed.runInInjectionContext(() => Guard.evaluateResource(dummyResource, DummyLinkRel.DUMMY)); - } }); }); diff --git a/alfa-client/apps/admin/src/app/app.guard.ts b/alfa-client/apps/admin/src/app/app.guard.ts index 85625be095c256aa593faa2d0d415352258aa1e0..c61df188c0c4ea38de5c66cfe69a55e39fc780ce 100644 --- a/alfa-client/apps/admin/src/app/app.guard.ts +++ b/alfa-client/apps/admin/src/app/app.guard.ts @@ -21,24 +21,25 @@ * Die sprachspezifischen Genehmigungen und Beschränkungen * unter der Lizenz sind dem Lizenztext zu entnehmen. */ +import { ConfigurationService } from '@admin-client/configuration-shared'; import { ROUTES } from '@admin-client/shared'; import { ApiRootService } from '@alfa-client/api-root-shared'; import { LinkRelationName, mapToResource, StateResource } from '@alfa-client/tech-shared'; import { inject } from '@angular/core'; import { ActivatedRouteSnapshot, CanActivateFn, Router, UrlTree } from '@angular/router'; +import { AuthenticationService } from '@authentication'; import { hasLink, Resource } from '@ngxp/rest'; -import { filter, map, Observable } from 'rxjs'; +import { filter, map, Observable, of } from 'rxjs'; import { GuardData } from './app.routes'; -import { ConfigurationService } from '@admin-client/configuration-shared'; import * as Guard from './app.guard'; -export const apiRootGuard: CanActivateFn = (route: ActivatedRouteSnapshot) => { +export const apiRootGuard: CanActivateFn = (route: ActivatedRouteSnapshot): Observable<true | UrlTree> => { const apiRootService: ApiRootService = inject(ApiRootService); return Guard.evaluate(apiRootService.getApiRoot(), getLinkRelationName(route)); }; -export const configurationGuard: CanActivateFn = (route: ActivatedRouteSnapshot) => { +export const configurationGuard: CanActivateFn = (route: ActivatedRouteSnapshot): Observable<true | UrlTree> => { const configurationService: ConfigurationService = inject(ConfigurationService); return Guard.evaluate(configurationService.get(), getLinkRelationName(route)); }; @@ -46,11 +47,16 @@ export const configurationGuard: CanActivateFn = (route: ActivatedRouteSnapshot) export function evaluate( stateResource$: Observable<StateResource<Resource>>, linkRelationName: LinkRelationName, -): Observable<boolean | UrlTree> { +): Observable<true | UrlTree> { + const authService = inject(AuthenticationService); + const router = inject(Router); + + if (!authService.isLoggedIn()) return of(true); + return stateResource$.pipe( - filter((stateResource: StateResource<Resource>) => stateResource.loaded), + filter((stateResource: StateResource<Resource>): boolean => stateResource.loaded), mapToResource<Resource>(), - map((resource: Resource) => Guard.evaluateResource(resource, linkRelationName)), + map((resource: Resource): true | UrlTree => Guard.evaluateResource(resource, linkRelationName, router)), ); } @@ -58,10 +64,10 @@ function getLinkRelationName(route: ActivatedRouteSnapshot): string { return (<GuardData>route.data).linkRelName; } -export function evaluateResource(resource: Resource, linkRelationName: LinkRelationName): boolean | UrlTree { - return hasLink(resource, linkRelationName) ? true : redirectToUnavailable(); +export function evaluateResource(resource: Resource, linkRelationName: LinkRelationName, router: Router): true | UrlTree { + return hasLink(resource, linkRelationName) ? true : redirectToUnavailable(router); } -function redirectToUnavailable(): UrlTree { - return inject(Router).createUrlTree(['/' + ROUTES.UNAVAILABLE]); +function redirectToUnavailable(router: Router): UrlTree { + return router.createUrlTree(['/' + ROUTES.UNAVAILABLE]); } diff --git a/alfa-client/apps/admin/src/app/app.routes.ts b/alfa-client/apps/admin/src/app/app.routes.ts index 279141277b1bbd700ca663d7009e886b89453429..c5974993c237ec15254220a64157b63ae0f46136 100644 --- a/alfa-client/apps/admin/src/app/app.routes.ts +++ b/alfa-client/apps/admin/src/app/app.routes.ts @@ -44,6 +44,7 @@ export const appRoutes: Route[] = [ path: ROUTES.POSTFACH, component: PostfachPageComponent, title: 'Admin | Postfach', + runGuardsAndResolvers: 'always', canActivate: [configurationGuard], data: <GuardData>{ linkRelName: ConfigurationLinkRel.SETTING }, }, @@ -51,6 +52,7 @@ export const appRoutes: Route[] = [ path: ROUTES.BENUTZER, component: UserListPageComponent, title: 'Admin | Benutzer', + runGuardsAndResolvers: 'always', canActivate: [apiRootGuard], data: <GuardData>{ linkRelName: ApiRootLinkRel.USERS }, }, @@ -58,6 +60,7 @@ export const appRoutes: Route[] = [ path: ROUTES.BENUTZER_NEU, component: UserFormPageComponent, title: 'Admin | Benutzer anlegen', + runGuardsAndResolvers: 'always', canActivate: [apiRootGuard], data: <GuardData>{ linkRelName: ApiRootLinkRel.USERS }, }, @@ -65,6 +68,7 @@ export const appRoutes: Route[] = [ path: ROUTES.BENUTZER_ID, component: UserFormComponent, title: 'Admin | Benutzer bearbeiten', + runGuardsAndResolvers: 'always', canActivate: [apiRootGuard], data: <GuardData>{ linkRelName: ApiRootLinkRel.USERS }, }, @@ -82,6 +86,7 @@ export const appRoutes: Route[] = [ path: ROUTES.STATISTIK, component: StatistikPageComponent, title: 'Admin | Statistik', + runGuardsAndResolvers: 'always', canActivate: [configurationGuard], data: <GuardData>{ linkRelName: ConfigurationLinkRel.AGGREGATION_MAPPINGS }, }, @@ -89,7 +94,13 @@ export const appRoutes: Route[] = [ path: ROUTES.STATISTIK_NEU, component: StatistikFieldsFormPageComponent, title: 'Admin | Statistik weitere Felder auswerten', + runGuardsAndResolvers: 'always', canActivate: [configurationGuard], data: <GuardData>{ linkRelName: ConfigurationLinkRel.AGGREGATION_MAPPINGS }, }, + { + path: '**', + component: UnavailablePageComponent, + title: 'Unavailable', + }, ]; diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/form.util.spec.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/form.util.spec.ts index be3f115f38c800b3b1cf87b3aaec5044e9b3c121..348a6c4b49125dca4d13765528be6b83f8b6e1de 100644 --- a/alfa-client/libs/admin/keycloak-shared/src/lib/form.util.spec.ts +++ b/alfa-client/libs/admin/keycloak-shared/src/lib/form.util.spec.ts @@ -1,18 +1,18 @@ +import { mockWindowError } from '@alfa-client/test-utils'; import { FormControl, FormGroup } from '@angular/forms'; import { patchForm } from './form.util'; describe('FormUtil', () => { describe('patch form', () => { it('should not throw any errors', () => { - const errorHandler = jest.fn(); - window.onerror = errorHandler; + const errorSpy: jest.SpyInstance = mockWindowError(); const formGroup: FormGroup = new FormGroup({ existingKey: new FormControl(null), }); patchForm({ missingKey: 'dummyValue' }, formGroup); - expect(errorHandler).not.toHaveBeenCalled(); + expect(errorSpy).not.toHaveBeenCalled(); }); describe('on strings', () => { @@ -57,6 +57,15 @@ describe('FormUtil', () => { expect(arrayFormGroup.controls['arrayValue1'].value).toBeTruthy(); expect(arrayFormGroup.controls['arrayValue2'].value).toBeFalsy(); }); + + it('should not throw any error if control is missing', () => { + const errorSpy: jest.SpyInstance = mockWindowError(); + const formGroup: FormGroup = new FormGroup({ array: new FormGroup({}) }); + + patchForm({ array: ['arrayValue1'] }, formGroup); + + expect(errorSpy).not.toHaveBeenCalled(); + }); }); describe('on object value', () => { diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/form.util.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/form.util.ts index 877cf9e458b14b0d2f7732086f5e033ba3b40bf5..d57cf619353a41d05d4e9d866ca098331d4345b1 100644 --- a/alfa-client/libs/admin/keycloak-shared/src/lib/form.util.ts +++ b/alfa-client/libs/admin/keycloak-shared/src/lib/form.util.ts @@ -15,7 +15,9 @@ function patchNonStringValues(valueToPatch: any, formGroup: FormGroup): void { function patchNonStringValue(value: any, formGroup: FormGroup): void { if (Array.isArray(value)) { - value.forEach((oneValue: any) => formGroup.controls[oneValue].patchValue(true)); + value.forEach((oneValue: any) => { + if (formGroup.contains(oneValue)) formGroup.controls[oneValue].patchValue(true); + }); } else if (!isString(value) && !isBoolean(value)) { patchNonStringValues(value, formGroup); } diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.spec.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.spec.ts index d0806e198efa5090d375832bbd313ca99482149b..afbb09bee049b4e79cda30ee0d97f3e4810799f9 100644 --- a/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.spec.ts +++ b/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.spec.ts @@ -21,7 +21,7 @@ * Die sprachspezifischen Genehmigungen und Beschränkungen * unter der Lizenz sind dem Lizenztext zu entnehmen. */ -import { User } from '@admin-client/user-shared'; +import { RoleMappings, User } from '@admin-client/user-shared'; import { UserRepository } from '@admin/keycloak-shared'; import { StateResource } from '@alfa-client/tech-shared'; import { Mock, mock } from '@alfa-client/test-utils'; @@ -30,10 +30,10 @@ import { faker } from '@faker-js/faker'; import KcAdminClient from '@keycloak/keycloak-admin-client'; import GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation'; import MappingsRepresentation from '@keycloak/keycloak-admin-client/lib/defs/mappingsRepresentation'; -import RoleRepresentation, { RoleMappingPayload } from '@keycloak/keycloak-admin-client/lib/defs/roleRepresentation'; +import { RoleMappingPayload } from '@keycloak/keycloak-admin-client/lib/defs/roleRepresentation'; import { Users } from '@keycloak/keycloak-admin-client/lib/resources/users'; import { cold } from 'jest-marbles'; -import { omit } from 'lodash-es'; +import { omit, times } from 'lodash-es'; import { throwError } from 'rxjs'; import { createUser } from '../../../user-shared/test/user'; import { UserFormService } from '../../../user/src/lib/user-form/user.formservice'; @@ -71,18 +71,18 @@ describe('UserRepository', () => { expect(kcAdminClient.users['create']).toHaveBeenCalledWith(omit(user, 'groupIds')); }); - it('should call addUserRoles', fakeAsync(() => { - repository._addUserRoles = jest.fn(); + it('should call updateUserRoles', fakeAsync(() => { + repository._updateUserRoles = jest.fn(); repository.createInKeycloak(user).subscribe(); tick(); - expect(repository._addUserRoles).toHaveBeenCalledWith(user.id, user.clientRoles); + expect(repository._updateUserRoles).toHaveBeenCalledWith(user.id, user.clientRoles); })); it('should call sendActivationMail', (done) => { repository._sendActivationMail = jest.fn(); - repository._addUserRoles = jest.fn().mockReturnValue(Promise.resolve()); + repository._updateUserRoles = jest.fn().mockReturnValue(Promise.resolve()); repository.createInKeycloak(user).subscribe(() => { expect(repository._sendActivationMail).toHaveBeenCalledWith(user.id); @@ -107,7 +107,7 @@ describe('UserRepository', () => { update: jest.fn().mockReturnValue(Promise.resolve()), }; - repository._addUserRoles = jest.fn().mockReturnValue(Promise.resolve()); + repository._updateUserRoles = jest.fn().mockReturnValue(Promise.resolve()); repository._updateUserGroups = jest.fn().mockReturnValue(Promise.resolve()); }); @@ -117,11 +117,11 @@ describe('UserRepository', () => { expect(kcAdminClient.users['update']).toHaveBeenCalledWith({ id: user.id }, omit(user, 'groupIds')); }); - it('should call addUserRoles', fakeAsync(() => { + it('should call updateUserRoles', fakeAsync(() => { repository.saveInKeycloak(user).subscribe(); tick(); - expect(repository._addUserRoles).toHaveBeenCalledWith(user.id, user.clientRoles); + expect(repository._updateUserRoles).toHaveBeenCalledWith(user.id, user.clientRoles); })); it('should call updateUserGroups', (done) => { @@ -241,70 +241,197 @@ describe('UserRepository', () => { }); }); - describe('addUserRoles', () => { - it('should call addUserRolesForClient for admin', async () => { - repository._addUserRolesForClient = jest.fn(); + describe('UpdateUserRoles', () => { + const clientId: string = faker.string.uuid(); - await repository._addUserRoles(user.id, { admin: [UserFormService.ADMIN], alfa: [] }); + beforeEach(() => { + repository._updateUserRolesForClient = jest.fn(); + repository._getClientId = jest.fn().mockReturnValue(clientId); + }); - expect(repository._addUserRolesForClient).toHaveBeenCalledWith( - user.id, - [UserFormService.ADMIN], - UserRepository.ADMIN_CLIENT_NAME, - ); + it('should call getClientId for admin', async () => { + await repository._updateUserRoles(user.id, { admin: [UserFormService.ADMIN], alfa: [] }); + + expect(repository._getClientId).toHaveBeenCalledWith(UserRepository.ADMIN_CLIENT_NAME); }); - it('should call addUserRolesForClient for admin', async () => { - repository._addUserRolesForClient = jest.fn(); + it('should call UpdateUserRolesForClient for admin', async () => { + await repository._updateUserRoles(user.id, { admin: [UserFormService.ADMIN], alfa: [] }); - await repository._addUserRoles(user.id, { alfa: [UserFormService.POSTSTELLE], admin: [] }); + expect(repository._updateUserRolesForClient).toHaveBeenCalledWith(user.id, [UserFormService.ADMIN], clientId); + }); - expect(repository._addUserRolesForClient).toHaveBeenCalledWith( - user.id, - [UserFormService.POSTSTELLE], - UserRepository.ALFA_CLIENT_NAME, - ); + it('should call getClientId for admin', async () => { + await repository._updateUserRoles(user.id, { alfa: [UserFormService.POSTSTELLE], admin: [] }); + + expect(repository._getClientId).toHaveBeenCalledWith(UserRepository.ALFA_CLIENT_NAME); + }); + + it('should call UpdateUserRolesForClient for admin', async () => { + await repository._updateUserRoles(user.id, { alfa: [UserFormService.POSTSTELLE], admin: [] }); + + expect(repository._updateUserRolesForClient).toHaveBeenCalledWith(user.id, [UserFormService.POSTSTELLE], clientId); }); + }); + + describe('updateUserRolesForClient', () => { + const clientId: string = faker.string.uuid(); + const clientRoles: string[] = times(3, () => faker.word.sample()); + const newClientRoleMappings: RoleMappingPayload[] = clientRoles.map((role: string) => ({ + id: faker.string.uuid(), + name: role, + })); + const oldClientRoleMappings: RoleMappingPayload[] = newClientRoleMappings.slice(1); + const clientRoleMappings: RoleMappings = { newClientRoleMappings, oldClientRoleMappings }; - it('should not call addUserRolesForClient if clientRoles alfa and admin are empty', async () => { - repository._addUserRolesForClient = jest.fn(); + beforeEach(() => { + repository._getClientRoleMappings = jest.fn().mockReturnValue(Promise.resolve(clientRoleMappings)); + repository._deleteUserRoles = jest.fn(); + repository._addUserRoles = jest.fn(); + }); + + it('should call getClientRoleMappings', async () => { + await repository._updateUserRolesForClient(user.id, clientRoles, clientId); + + expect(repository._getClientRoleMappings).toHaveBeenCalledWith(user.id, clientId, clientRoles); + }); - await repository._addUserRoles(user.id, { admin: [], alfa: [] }); + it('should call deleteUserRoles', async () => { + await repository._updateUserRolesForClient(user.id, clientRoles, clientId); - expect(repository._addUserRolesForClient).not.toHaveBeenCalled(); + expect(repository._deleteUserRoles).toHaveBeenCalledWith(user.id, clientRoleMappings, clientId); + }); + + it('should call addUserRoles', async () => { + await repository._updateUserRolesForClient(user.id, clientRoles, clientId); + + expect(repository._addUserRoles).toHaveBeenCalledWith(user.id, clientRoleMappings, clientId); }); }); - describe('addUserRolesForClient', () => { + describe('getClientRoleMappings', () => { const clientId: string = faker.string.uuid(); - const roleMapping: RoleMappingPayload[] = [{ id: faker.string.uuid(), name: faker.word.sample() }]; + const newClientRoles: string[] = times(3, () => faker.word.sample()); + const newClientRoleMappings: RoleMappingPayload[] = newClientRoles.map((role: string) => ({ + id: faker.string.uuid(), + name: role, + })); + const oldClientRoleMappings: RoleMappingPayload[] = newClientRoleMappings.slice(1); + const clientRoleMappings: RoleMappings = { newClientRoleMappings, oldClientRoleMappings }; beforeEach(() => { + repository._getOldUserRoleMappings = jest.fn().mockReturnValue(Promise.resolve(oldClientRoleMappings)); repository._getClientId = jest.fn().mockReturnValue(Promise.resolve(clientId)); - repository._mapUserRoles = jest.fn().mockReturnValue(Promise.resolve(roleMapping)); - repository._addUserRolesInKeycloak = jest.fn(); + repository._mapUserRoles = jest.fn().mockReturnValue(Promise.resolve(newClientRoleMappings)); + repository._deleteUserRoles = jest.fn(); + repository._addUserRoles = jest.fn(); + }); + + it('should call mapUserRoles', () => { + repository._getClientRoleMappings(user.id, clientId, newClientRoles); + + expect(repository._mapUserRoles).toHaveBeenCalledWith(clientId, newClientRoles); + }); + + it('should call get old user role mappings', async () => { + await repository._getClientRoleMappings(user.id, clientId, newClientRoles); + + expect(repository._getOldUserRoleMappings).toHaveBeenCalledWith(user.id, clientId); + }); + + it('should return clientRoleMappings', async () => { + const result: RoleMappings = await repository._getClientRoleMappings(user.id, clientId, newClientRoles); + + expect(result).toEqual(clientRoleMappings); + }); + }); + + describe('getOldUserRoleMappings', () => { + const clientId: string = faker.string.uuid(); + const roleMapping: RoleMappingPayload[] = [{ id: faker.string.uuid(), name: faker.word.sample() }]; + + beforeEach(() => { + kcAdminClient.users = <any>{ + listClientRoleMappings: jest.fn().mockReturnValue(Promise.resolve(roleMapping)), + }; + }); + + it('should call users listClientRoleMappings', () => { + repository._getOldUserRoleMappings(user.id, clientId); + + expect(kcAdminClient.users['listClientRoleMappings']).toHaveBeenCalledWith({ id: user.id, clientUniqueId: clientId }); + }); + + it('should return roleMapping', async () => { + const result: RoleMappingPayload[] = await repository._getOldUserRoleMappings(user.id, clientId); + + expect(result).toEqual(roleMapping); + }); + }); + + describe('deleteUserRoles', () => { + const clientId: string = faker.string.uuid(); + const clientRoles: string[] = times(3, () => faker.word.sample()); + const oldClientRoleMappings: RoleMappingPayload[] = clientRoles.map((role: string) => ({ + id: faker.string.uuid(), + name: role, + })); + const newClientRoleMappings: RoleMappingPayload[] = oldClientRoleMappings.slice(1); + const clientRoleMappings: RoleMappings = { newClientRoleMappings, oldClientRoleMappings }; + + beforeEach(() => { + repository._deleteUserRolesInKeycloak = jest.fn(); kcAdminClient.users = <any>{ addClientRoleMappings: jest.fn().mockReturnValue(Promise.resolve()), }; }); - it('should call getClientId', async () => { - await repository._addUserRolesForClient(user.id, [UserFormService.ADMIN], UserRepository.ADMIN_CLIENT_NAME); + it('should call deleteUserRolesInKeycloak', async () => { + await repository._deleteUserRoles(user.id, clientRoleMappings, clientId); + + expect(repository._deleteUserRolesInKeycloak).toHaveBeenCalledWith(user.id, clientId, [oldClientRoleMappings[0]]); + }); + + it('should call not call deleteUserRolesInKeycloak if no roles to delete', async () => { + await repository._deleteUserRoles( + user.id, + { oldClientRoleMappings, newClientRoleMappings: oldClientRoleMappings }, + clientId, + ); - expect(repository._getClientId).toHaveBeenCalled(); + expect(repository._deleteUserRolesInKeycloak).not.toHaveBeenCalled(); }); + }); + + describe('addUserRoles', () => { + const clientId: string = faker.string.uuid(); + const clientRoles: string[] = times(3, () => faker.word.sample()); + const newClientRoleMappings: RoleMappingPayload[] = clientRoles.map((role: string) => ({ + id: faker.string.uuid(), + name: role, + })); + const oldClientRoleMappings: RoleMappingPayload[] = newClientRoleMappings.slice(1); + const clientRoleMappings: RoleMappings = { newClientRoleMappings, oldClientRoleMappings }; - it('should call getAlfaClientId', async () => { - await repository._addUserRolesForClient(user.id, [UserFormService.ADMIN], UserRepository.ADMIN_CLIENT_NAME); + beforeEach(() => { + repository._addUserRolesInKeycloak = jest.fn(); - expect(repository._mapUserRoles).toHaveBeenCalledWith(clientId, [UserFormService.ADMIN]); + kcAdminClient.users = <any>{ + addClientRoleMappings: jest.fn().mockReturnValue(Promise.resolve()), + }; }); it('should call addUserRolesInKeycloak', async () => { - await repository._addUserRolesForClient(user.id, [UserFormService.ADMIN], UserRepository.ADMIN_CLIENT_NAME); + await repository._addUserRoles(user.id, clientRoleMappings, clientId); - expect(repository._addUserRolesInKeycloak).toHaveBeenCalledWith(user.id, clientId, roleMapping); + expect(repository._addUserRolesInKeycloak).toHaveBeenCalledWith(user.id, clientId, [newClientRoleMappings[0]]); + }); + + it('should not call addUserRolesInKeycloak if no roles to add', async () => { + await repository._addUserRoles(user.id, { newClientRoleMappings, oldClientRoleMappings: newClientRoleMappings }, clientId); + + expect(repository._addUserRolesInKeycloak).not.toHaveBeenCalled(); }); }); @@ -332,11 +459,11 @@ describe('UserRepository', () => { describe('mapUserRoles', () => { const clientId: string = faker.string.uuid(); - const clientRoles: RoleRepresentation[] = Array.from({ length: 3 }, () => ({ + const clientRoles: RoleMappingPayload[] = Array.from({ length: 3 }, () => ({ id: faker.string.uuid(), name: faker.word.sample(), })); - const userRoles: string[] = clientRoles.map((role) => role.name).slice(1); + const userRoles: string[] = clientRoles.map((role: RoleMappingPayload): string => role.name).slice(1); beforeEach(() => { kcAdminClient.clients = <any>{ @@ -353,7 +480,7 @@ describe('UserRepository', () => { it('should return roleMapping', async () => { const result: RoleMappingPayload[] = await repository._mapUserRoles(clientId, userRoles); - expect(result).toEqual(clientRoles.slice(1).map((role) => ({ id: role.id, name: role.name }))); + expect(result).toEqual(clientRoles.slice(1)); }); it('should filter roles if they are not in clientRoles', async () => { @@ -383,6 +510,26 @@ describe('UserRepository', () => { }); }); + describe('deleteUserRolesInKeycloak', () => { + beforeEach(() => { + kcAdminClient.users = <any>{ + delClientRoleMappings: jest.fn().mockReturnValue(Promise.resolve()), + }; + }); + + it('should call kcAdminClient users delClientRoleMappings', async () => { + const clientId: string = faker.string.uuid(); + const roles: RoleMappingPayload[] = Array.from({ length: 3 }, () => ({ + id: faker.string.uuid(), + name: faker.word.sample(), + })); + + await repository._deleteUserRolesInKeycloak(user.id, clientId, roles); + + expect(kcAdminClient.users['delClientRoleMappings']).toHaveBeenCalledWith({ id: user.id, clientUniqueId: clientId, roles }); + }); + }); + describe('sendActivationMail', () => { it('should call kcAdminClient users executeActionsEmail', () => { const userId: string = faker.string.uuid(); diff --git a/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.ts b/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.ts index a5df128a7de4ceb8bd71c4d2edc337078b57bcca..e5eca5be54120f7fdf51b66d130053a330ced543 100644 --- a/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.ts +++ b/alfa-client/libs/admin/keycloak-shared/src/lib/user.repository.ts @@ -21,18 +21,20 @@ * Die sprachspezifischen Genehmigungen und Beschränkungen * unter der Lizenz sind dem Lizenztext zu entnehmen. */ -import { ClientMapping, ClientRoles, User } from '@admin-client/user-shared'; +import { ClientMapping, ClientRoles, RoleMappings, User } from '@admin-client/user-shared'; import { createStateResource, StateResource } from '@alfa-client/tech-shared'; import { inject, Injectable } from '@angular/core'; import KcAdminClient from '@keycloak/keycloak-admin-client'; import ClientRepresentation from '@keycloak/keycloak-admin-client/lib/defs/clientRepresentation'; import GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation'; import MappingsRepresentation from '@keycloak/keycloak-admin-client/lib/defs/mappingsRepresentation'; -import RoleRepresentation, { RoleMappingPayload } from '@keycloak/keycloak-admin-client/lib/defs/roleRepresentation'; +import { RoleMappingPayload } from '@keycloak/keycloak-admin-client/lib/defs/roleRepresentation'; import UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation'; import { isNil, omit } from 'lodash-es'; import { catchError, concatMap, forkJoin, from, map, mergeMap, Observable, tap, throwError } from 'rxjs'; +import * as _ from 'lodash-es'; + @Injectable({ providedIn: 'root', }) @@ -45,7 +47,7 @@ export class UserRepository { public createInKeycloak(user: User): Observable<User> { return from(this.kcAdminClient.users.create(omit(user, 'groupIds'))).pipe( concatMap(async (response: { id: string }): Promise<{ id: string }> => { - await this._addUserRoles(response.id, user.clientRoles); + await this._updateUserRoles(response.id, user.clientRoles); return response; }), tap((response: { id: string }): void => this._sendActivationMail(response.id)), @@ -58,7 +60,7 @@ export class UserRepository { const { groupIds, ...userToSave } = user; return from(this.kcAdminClient.users.update({ id: user.id }, userToSave)).pipe( concatMap(async (): Promise<void> => { - await this._addUserRoles(user.id, user.clientRoles); + await this._updateUserRoles(user.id, user.clientRoles); await this._updateUserGroups(user.id, user.groupIds); }), map((): User => user), @@ -73,8 +75,10 @@ export class UserRepository { } async _getOldUserGroupIds(userId: string): Promise<string[]> { - const oldUserGroupsReps: GroupRepresentation[] = await this.kcAdminClient.users.listGroups({ id: userId }); - return oldUserGroupsReps.map((group: GroupRepresentation): string => group.id); + const oldUserGroupsReps: RoleMappingPayload[] = <RoleMappingPayload[]>( + await this.kcAdminClient.users.listGroups({ id: userId }) + ); + return _.map(oldUserGroupsReps, 'id'); } async _deleteUserGroups(userId: string, newGroupIds: string[], oldGroupIds: string[]): Promise<void> { @@ -87,7 +91,7 @@ export class UserRepository { ); } - async _addUserGroups(userId: string, newGroupIds: string[], oldGroupIds): Promise<void> { + async _addUserGroups(userId: string, newGroupIds: string[], oldGroupIds: string[]): Promise<void> { await Promise.all( newGroupIds .filter((group) => !oldGroupIds.includes(group)) @@ -97,20 +101,54 @@ export class UserRepository { ); } - async _addUserRoles(userId: string, clientRoles: ClientRoles): Promise<void> { - if (clientRoles.admin.length > 0) { - await this._addUserRolesForClient(userId, clientRoles.admin, UserRepository.ADMIN_CLIENT_NAME); + async _updateUserRoles(userId: string, clientRoles: ClientRoles): Promise<void> { + await this._updateUserRolesForClient(userId, clientRoles.admin, await this._getClientId(UserRepository.ADMIN_CLIENT_NAME)); + await this._updateUserRolesForClient(userId, clientRoles.alfa, await this._getClientId(UserRepository.ALFA_CLIENT_NAME)); + } + + async _updateUserRolesForClient(userId: string, clientRoles: string[], clientId: string): Promise<void> { + const roleMappings: RoleMappings = await this._getClientRoleMappings(userId, clientId, clientRoles); + await this._deleteUserRoles(userId, roleMappings, clientId); + await this._addUserRoles(userId, roleMappings, clientId); + } + + async _getClientRoleMappings(userId: string, clientId: string, clientRoles: string[]): Promise<RoleMappings> { + const newClientRoleMappings: RoleMappingPayload[] = await this._mapUserRoles(clientId, clientRoles); + const oldClientRoleMappings: RoleMappingPayload[] = await this._getOldUserRoleMappings(userId, clientId); + return { newClientRoleMappings, oldClientRoleMappings }; + } + + async _getOldUserRoleMappings(userId: string, clientId: string): Promise<RoleMappingPayload[]> { + return <RoleMappingPayload[]>await this.kcAdminClient.users.listClientRoleMappings({ + id: userId, + clientUniqueId: clientId, + }); + } + + async _deleteUserRoles(userId: string, roleMappings: RoleMappings, clientId: string): Promise<void> { + const rolesToDelete: RoleMappingPayload[] = this.getRolesToDelete(roleMappings); + if (rolesToDelete.length > 0) { + await this._deleteUserRolesInKeycloak(userId, clientId, rolesToDelete); } + } + + private getRolesToDelete(roleMappings: RoleMappings): RoleMappingPayload[] { + return roleMappings.oldClientRoleMappings.filter( + (role: RoleMappingPayload) => !_.map(roleMappings.newClientRoleMappings, 'name').includes(role.name), + ); + } - if (clientRoles.alfa.length > 0) { - await this._addUserRolesForClient(userId, clientRoles.alfa, UserRepository.ALFA_CLIENT_NAME); + async _addUserRoles(userId: string, roleMappings: RoleMappings, clientId: string): Promise<void> { + const rolesToAdd: RoleMappingPayload[] = this.getRolesToAdd(roleMappings); + if (rolesToAdd.length > 0) { + await this._addUserRolesInKeycloak(userId, clientId, rolesToAdd); } } - async _addUserRolesForClient(userId: string, userRoles: string[], client: string): Promise<void> { - const clientId: string = await this._getClientId(client); - const roles: RoleMappingPayload[] = await this._mapUserRoles(clientId, userRoles); - await this._addUserRolesInKeycloak(userId, clientId, roles); + private getRolesToAdd(roleMappings: RoleMappings): RoleMappingPayload[] { + return roleMappings.newClientRoleMappings.filter( + (role: RoleMappingPayload) => !_.map(roleMappings.oldClientRoleMappings, 'name').includes(role.name), + ); } async _getClientId(client: string): Promise<string | undefined> { @@ -119,10 +157,8 @@ export class UserRepository { } async _mapUserRoles(clientId: string, userRoles: string[]): Promise<RoleMappingPayload[]> { - const roles: RoleRepresentation[] = await this.kcAdminClient.clients.listRoles({ id: clientId }); - return roles - .filter((role: RoleRepresentation): boolean => userRoles.includes(role.name)) - .map((role: RoleRepresentation): RoleMappingPayload => ({ id: role.id, name: role.name })); + const roles: RoleMappingPayload[] = <RoleMappingPayload[]>await this.kcAdminClient.clients.listRoles({ id: clientId }); + return roles.filter((role: RoleMappingPayload): boolean => userRoles.includes(role.name)); } async _addUserRolesInKeycloak(userId: string, clientId: string, roles: RoleMappingPayload[]): Promise<void> { @@ -133,6 +169,14 @@ export class UserRepository { }); } + async _deleteUserRolesInKeycloak(userId: string, clientId: string, roles: RoleMappingPayload[]): Promise<void> { + await this.kcAdminClient.users.delClientRoleMappings({ + id: userId, + clientUniqueId: clientId, + roles, + }); + } + _sendActivationMail(userId: string): void { this.kcAdminClient.users.executeActionsEmail({ id: userId, @@ -208,7 +252,7 @@ export class UserRepository { private getUserGroups(user: User): Observable<string[]> { return from(this.kcAdminClient.users.listGroups({ id: user.id })).pipe( - map((groups: GroupRepresentation[]): string[] => groups.map((group: GroupRepresentation): string => group.name)), + map((groups: GroupRepresentation[]): string[] => _.map(groups, 'name')), ); } @@ -230,6 +274,6 @@ export class UserRepository { return []; } - return clientMappingsAlfa.mappings.map((role: RoleRepresentation): string => role.name); + return _.map(clientMappingsAlfa.mappings, 'name'); } } diff --git a/alfa-client/libs/admin/postfach/src/lib/postfach-container/postfach-container.component.html b/alfa-client/libs/admin/postfach/src/lib/postfach-container/postfach-container.component.html index 3e8e4b49ae09ba083768eb4a1ffa4155ab1bb49c..4cc1c39f9ceae871719596a641734e16f8f966c4 100644 --- a/alfa-client/libs/admin/postfach/src/lib/postfach-container/postfach-container.component.html +++ b/alfa-client/libs/admin/postfach/src/lib/postfach-container/postfach-container.component.html @@ -1,2 +1,2 @@ -<h1 class="heading-1">Postfach</h1> +<h1 class="heading-1" data-test-id="headline">Postfach</h1> <admin-postfach-form [postfachStateResource]="postfachStateResource$ | async" /> diff --git a/alfa-client/libs/admin/user-shared/src/lib/user.model.ts b/alfa-client/libs/admin/user-shared/src/lib/user.model.ts index db185a0209450a60d50e2ffc89a263d62f480d12..f496e42b8734907ab060fe4872fc91cb29b14c42 100644 --- a/alfa-client/libs/admin/user-shared/src/lib/user.model.ts +++ b/alfa-client/libs/admin/user-shared/src/lib/user.model.ts @@ -21,7 +21,7 @@ * Die sprachspezifischen Genehmigungen und Beschränkungen * unter der Lizenz sind dem Lizenztext zu entnehmen. */ -import RoleRepresentation from '@keycloak/keycloak-admin-client/lib/defs/roleRepresentation'; +import RoleRepresentation, { RoleMappingPayload } from '@keycloak/keycloak-admin-client/lib/defs/roleRepresentation'; export interface User { id?: string; @@ -43,3 +43,8 @@ export interface ClientRoles { export interface ClientMapping { mappings: RoleRepresentation[]; } + +export interface RoleMappings { + newClientRoleMappings: RoleMappingPayload[]; + oldClientRoleMappings: RoleMappingPayload[]; +} diff --git a/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.html b/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.html index b604db41588c7f1fbd381c194a8fb98a69aad0c3..d25b43725be072ab68c5e322fc268d172a88982b 100644 --- a/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.html +++ b/alfa-client/libs/admin/user/src/lib/user-form/user-form-roles/user-form-roles.component.html @@ -12,6 +12,16 @@ <ods-info-icon /> </button> </div> + <div class="flex items-center gap-2"> + <ods-checkbox-editor + [formControlName]="UserFormService.DATENBEAUFTRAGUNG" + label="Datenbeauftragung" + inputId="datenbeauftragung" + /> + <ods-info-icon + tooltip='Diese Rolle kann in der Administration unter dem Menüpunkt "Statistik" Felder zur Auswertung konfigurieren. Sie ist mit allen anderen Rollen kompatibel.' + /> + </div> </div> <div [formGroupName]="UserFormService.ALFA_GROUP" class="flex flex-col gap-2"> <h3 class="text-md block font-medium text-text">Alfa</h3> diff --git a/alfa-client/libs/admin/user/src/lib/user-form/user.formservice.ts b/alfa-client/libs/admin/user/src/lib/user-form/user.formservice.ts index 3d0960b1d363fe87688c1c9fd07f7b61ddefc67c..494673b77cf049e6219418f7a726173af20e3fd0 100644 --- a/alfa-client/libs/admin/user/src/lib/user-form/user.formservice.ts +++ b/alfa-client/libs/admin/user/src/lib/user-form/user.formservice.ts @@ -52,6 +52,7 @@ export class UserFormService extends KeycloakFormService<User> implements OnDest public static readonly GROUPS: string = 'groups'; public static readonly ADMINISTRATION_GROUP: string = 'admin'; public static readonly ADMIN: string = 'ADMIN_ADMIN'; + public static readonly DATENBEAUFTRAGUNG: string = 'DATENBEAUFTRAGUNG'; public static readonly ALFA_GROUP: string = 'alfa'; public static readonly LOESCHEN: string = 'VERWALTUNG_LOESCHEN'; public static readonly USER: string = 'VERWALTUNG_USER'; @@ -100,6 +101,7 @@ export class UserFormService extends KeycloakFormService<User> implements OnDest { [UserFormService.ADMINISTRATION_GROUP]: this.formBuilder.group({ [UserFormService.ADMIN]: new FormControl(false), + [UserFormService.DATENBEAUFTRAGUNG]: new FormControl(false), }), [UserFormService.ALFA_GROUP]: this.formBuilder.group({ [UserFormService.LOESCHEN]: new FormControl(false), diff --git a/alfa-client/libs/admin/user/test/form.ts b/alfa-client/libs/admin/user/test/form.ts index 7b3f06850de1ff16504596b38056fb3752b3d5e5..d8ac4bb73c5b96a51e5559e85d8876a1695506aa 100644 --- a/alfa-client/libs/admin/user/test/form.ts +++ b/alfa-client/libs/admin/user/test/form.ts @@ -12,6 +12,7 @@ export function createUserFormGroup(): UntypedFormGroup { [UserFormService.CLIENT_ROLES]: new UntypedFormGroup({ [UserFormService.ADMINISTRATION_GROUP]: new UntypedFormGroup({ [UserFormService.ADMIN]: new FormControl(false), + [UserFormService.DATENBEAUFTRAGUNG]: new FormControl(false), }), [UserFormService.ALFA_GROUP]: new UntypedFormGroup({ [UserFormService.LOESCHEN]: new FormControl(false), diff --git a/alfa-client/libs/authentication/src/lib/authentication.service.spec.ts b/alfa-client/libs/authentication/src/lib/authentication.service.spec.ts index 486415f425de82475e9733ec3f5800cb47128c92..0e2ba5d44dd5054b2fa2b0e651b8205cf0327258 100644 --- a/alfa-client/libs/authentication/src/lib/authentication.service.spec.ts +++ b/alfa-client/libs/authentication/src/lib/authentication.service.spec.ts @@ -23,10 +23,11 @@ */ import { Mock, mock, useFromMock } from '@alfa-client/test-utils'; import { UserProfileResource } from '@alfa-client/user-profile-shared'; +import { NavigationEnd, Router } from '@angular/router'; import { AuthConfig, OAuthEvent, OAuthService } from 'angular-oauth2-oidc'; import { Environment } from 'libs/environment-shared/src/lib/environment.model'; import { createUserProfileResource } from 'libs/user-profile-shared/test/user-profile'; -import { Subject } from 'rxjs'; +import { of, Subject } from 'rxjs'; import { createEnvironment } from '../../../environment-shared/test/environment'; import { createAuthConfig, createOAuthEvent } from '../../test/authentication'; import { AuthenticationService } from './authentication.service'; @@ -34,6 +35,7 @@ import { AuthenticationService } from './authentication.service'; describe('AuthenticationService', () => { let service: AuthenticationService; let oAuthService: Mock<OAuthService>; + let router: Mock<Router>; let environmentConfig: Environment; let eventsSubject: Subject<OAuthEvent>; @@ -41,6 +43,7 @@ describe('AuthenticationService', () => { beforeEach(() => { eventsSubject = new Subject<OAuthEvent>(); + router = mock(Router); oAuthService = <any>{ ...mock(OAuthService), loadDiscoveryDocumentAndLogin: jest.fn().mockResolvedValue(() => Promise.resolve()), @@ -50,12 +53,19 @@ describe('AuthenticationService', () => { Object.defineProperty(oAuthService, 'events', { get: () => eventsSubject }); environmentConfig = createEnvironment(); - service = new AuthenticationService(useFromMock(oAuthService), environmentConfig); + service = new AuthenticationService(useFromMock(oAuthService), useFromMock(router), environmentConfig); }); describe('login', () => { beforeEach(() => { service.buildAuthEventPromise = jest.fn(); + service._initialNavigationIsDone = jest.fn().mockResolvedValue(() => Promise.resolve()); + }); + + it('should wait for initial navigation', async () => { + await service.login(); + + expect(service._initialNavigationIsDone).toHaveBeenCalled(); }); it('should configure service with authConfig', async () => { @@ -89,8 +99,8 @@ describe('AuthenticationService', () => { expect(service.buildAuthEventPromise).toHaveBeenCalled(); }); - it('should load discovery document and login', () => { - service.login(); + it('should load discovery document and login', async () => { + await service.login(); expect(oAuthService.loadDiscoveryDocumentAndLogin).toHaveBeenCalled(); }); @@ -105,6 +115,16 @@ describe('AuthenticationService', () => { }); }); + describe('_initialNavigationIsDone', () => { + it('should wait for navigation end event', async () => { + (router.events as any) = of(new NavigationEnd(0, 'url1', 'url1')); + + const promise: Promise<void> = service._initialNavigationIsDone(); + + await expect(promise).resolves.toBeUndefined(); + }); + }); + describe('build auth event promise', () => { const event: OAuthEvent = createOAuthEvent(); @@ -350,4 +370,20 @@ describe('AuthenticationService', () => { expect(oAuthService.revokeTokenAndLogout).toHaveBeenCalled(); }); }); + + describe('isLoggedIn', () => { + it('should call oAuthService hasValidAccessToken', () => { + service.isLoggedIn(); + + expect(oAuthService.hasValidAccessToken).toHaveBeenCalled(); + }); + + it('should return result', () => { + oAuthService.hasValidAccessToken.mockReturnValue(true); + + const isLoggedIn: boolean = service.isLoggedIn(); + + expect(isLoggedIn).toBe(true); + }); + }); }); diff --git a/alfa-client/libs/authentication/src/lib/authentication.service.ts b/alfa-client/libs/authentication/src/lib/authentication.service.ts index e350dcafe7cb9312799db97db5eb50c0d25c8319..4918b8f8d7e95c9ff71b2c53a7aa487266e91155 100644 --- a/alfa-client/libs/authentication/src/lib/authentication.service.ts +++ b/alfa-client/libs/authentication/src/lib/authentication.service.ts @@ -23,11 +23,12 @@ */ import { Environment, ENVIRONMENT_CONFIG } from '@alfa-client/environment-shared'; import { Inject, Injectable } from '@angular/core'; +import { NavigationEnd, Router } from '@angular/router'; import { AuthConfig, OAuthEvent, OAuthService } from 'angular-oauth2-oidc'; import { JwksValidationHandler } from 'angular-oauth2-oidc-jwks'; import { UserProfileResource } from 'libs/user-profile-shared/src/lib/user-profile.model'; import { getUserNameInitials } from 'libs/user-profile-shared/src/lib/user-profile.util'; -import { filter, Subscription } from 'rxjs'; +import { filter, firstValueFrom, Subscription } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class AuthenticationService { @@ -37,10 +38,13 @@ export class AuthenticationService { constructor( private oAuthService: OAuthService, + private router: Router, @Inject(ENVIRONMENT_CONFIG) private envConfig: Environment, ) {} public async login(): Promise<void> { + await this._initialNavigationIsDone(); + this.oAuthService.configure(this.buildConfiguration()); this.oAuthService.setupAutomaticSilentRefresh(); this.oAuthService.tokenValidationHandler = new JwksValidationHandler(); @@ -50,6 +54,10 @@ export class AuthenticationService { return eventPromise; } + async _initialNavigationIsDone(): Promise<void> { + await firstValueFrom(this.router.events.pipe(filter((event) => event instanceof NavigationEnd))); + } + buildAuthEventPromise(): Promise<void> { return new Promise<void>((resolve, reject) => this.handleAuthEventsForPromise(resolve, reject)); } @@ -90,7 +98,7 @@ export class AuthenticationService { return { issuer: this.envConfig.authServer + '/realms/' + this.envConfig.realm, tokenEndpoint: this.envConfig.authServer + '/realms/' + this.envConfig.realm + '/protocol/openid-connect/token', - redirectUri: window.location.origin + '/', + redirectUri: window.location.origin + window.location.pathname, clientId: this.envConfig.clientId, scope: 'openid profile', requireHttps: false, @@ -119,4 +127,8 @@ export class AuthenticationService { public logout(): void { this.oAuthService.revokeTokenAndLogout(); } + + public isLoggedIn(): boolean { + return this.oAuthService.hasValidAccessToken(); + } } diff --git a/alfa-client/libs/bescheid/src/lib/bescheid-wizard-container/bescheid-wizard/dokumente-hochladen-container/form/bescheid-wizard-dokumente-hochladen-form.component.html b/alfa-client/libs/bescheid/src/lib/bescheid-wizard-container/bescheid-wizard/dokumente-hochladen-container/form/bescheid-wizard-dokumente-hochladen-form.component.html index edd8aff832236fb11a9b372a47c40f4bbb8d06d3..007a02ec854d9001c4eaef88f688760d6db3fa8d 100644 --- a/alfa-client/libs/bescheid/src/lib/bescheid-wizard-container/bescheid-wizard/dokumente-hochladen-container/form/bescheid-wizard-dokumente-hochladen-form.component.html +++ b/alfa-client/libs/bescheid/src/lib/bescheid-wizard-container/bescheid-wizard/dokumente-hochladen-container/form/bescheid-wizard-dokumente-hochladen-form.component.html @@ -23,7 +23,7 @@ unter der Lizenz sind dem Lizenztext zu entnehmen. --> -<div class="mt-4 flex flex-col gap-4"> +<div class="mt-4 flex max-w-72 flex-col gap-4"> @if (bescheidResource | hasLink: BescheidLinkRel.CREATE_DOCUMENT) { <alfa-bescheid-wizard-create-document-button-container [bescheidResource]="bescheidResource" diff --git a/alfa-client/libs/binary-file/src/lib/binary-file-list-container/binary-file-list-container.component.html b/alfa-client/libs/binary-file/src/lib/binary-file-list-container/binary-file-list-container.component.html index cfa93fbc6b3b9dbd01834a8d31c1bf74f4088299..de2d67af94b520ec15bb8292ef9278cb78d8e395 100644 --- a/alfa-client/libs/binary-file/src/lib/binary-file-list-container/binary-file-list-container.component.html +++ b/alfa-client/libs/binary-file/src/lib/binary-file-list-container/binary-file-list-container.component.html @@ -23,9 +23,7 @@ unter der Lizenz sind dem Lizenztext zu entnehmen. --> -<ods-attachment-wrapper> - <alfa-binary-file-list - [binaryFileListStateResource]="binaryFileListStateResource$ | async" - [listOrientation]="listOrientation" - ></alfa-binary-file-list> -</ods-attachment-wrapper> +<alfa-binary-file-list + [binaryFileListStateResource]="binaryFileListStateResource$ | async" + [listOrientation]="listOrientation" +></alfa-binary-file-list> diff --git a/alfa-client/libs/binary-file/src/lib/binary-file-list-container/binary-file-list-container.component.spec.ts b/alfa-client/libs/binary-file/src/lib/binary-file-list-container/binary-file-list-container.component.spec.ts index e9473e9ee486bc437328ee6fe85227d12c6293e5..6cca0983e8268484b83772fa1f78dafda782e70e 100644 --- a/alfa-client/libs/binary-file/src/lib/binary-file-list-container/binary-file-list-container.component.spec.ts +++ b/alfa-client/libs/binary-file/src/lib/binary-file-list-container/binary-file-list-container.component.spec.ts @@ -34,27 +34,19 @@ import { of } from 'rxjs'; import { BinaryFileListContainerComponent } from './binary-file-list-container.component'; import { BinaryFileListComponent } from './binary-file-list/binary-file-list.component'; -import { AttachmentWrapperComponent } from '@ods/system'; - describe('BinaryFileListContainerComponent', () => { let component: BinaryFileListContainerComponent; let fixture: ComponentFixture<BinaryFileListContainerComponent>; const binaryFileService: Mock<BinaryFileService> = mock(BinaryFileService); - const binaryFileStateResource: StateResource<BinaryFileResource> = createStateResource( - createBinaryFileResource(), - ); + const binaryFileStateResource: StateResource<BinaryFileResource> = createStateResource(createBinaryFileResource()); const resource: Resource = createDummyResource(); const linkRel: LinkRelationName = DummyLinkRel.DUMMY; beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [ - BinaryFileListContainerComponent, - MockComponent(BinaryFileListComponent), - MockComponent(AttachmentWrapperComponent), - ], + declarations: [BinaryFileListContainerComponent, MockComponent(BinaryFileListComponent)], providers: [ { provide: BinaryFileService, @@ -85,8 +77,10 @@ describe('BinaryFileListContainerComponent', () => { describe('binary file list', () => { it('should be called with binary file state resource', () => { - const binaryFileListComponent: BinaryFileListComponent = - getMockComponent<BinaryFileListComponent>(fixture, BinaryFileListComponent); + const binaryFileListComponent: BinaryFileListComponent = getMockComponent<BinaryFileListComponent>( + fixture, + BinaryFileListComponent, + ); expect(binaryFileListComponent.binaryFileListStateResource).toBe(binaryFileStateResource); }); diff --git a/alfa-client/libs/binary-file/src/lib/binary-file-list-container/binary-file-list/binary-file-list.component.html b/alfa-client/libs/binary-file/src/lib/binary-file-list-container/binary-file-list/binary-file-list.component.html index 70054de869940144f9b0e1d3fa0eb7c23ea72a33..a4ee6adc69c75525b8a32dd2e0ad5a9352701c2d 100644 --- a/alfa-client/libs/binary-file/src/lib/binary-file-list-container/binary-file-list/binary-file-list.component.html +++ b/alfa-client/libs/binary-file/src/lib/binary-file-list-container/binary-file-list/binary-file-list.component.html @@ -23,11 +23,13 @@ unter der Lizenz sind dem Lizenztext zu entnehmen. --> -<div [binaryFileListOrientation]="listOrientation"> - <alfa-binary-file2-container - *ngFor="let binaryFile of binaryFileListStateResource.resource | toEmbeddedResources: binaryFileListLinkRel.FILE_LIST" - [file]="binaryFile" - [deletable]="false" - > - </alfa-binary-file2-container> -</div> +@if (binaryFileListStateResource.resource | toEmbeddedResources: binaryFileListLinkRel.FILE_LIST; as binaryFileList) { + @if (binaryFileList.length) { + <ods-attachment-wrapper data-test-id="binary-file-list-wrapper"> + <div [binaryFileListOrientation]="listOrientation"> + <alfa-binary-file2-container *ngFor="let binaryFile of binaryFileList" [file]="binaryFile" [deletable]="false"> + </alfa-binary-file2-container> + </div> + </ods-attachment-wrapper> + } +} diff --git a/alfa-client/libs/binary-file/src/lib/binary-file-list-container/binary-file-list/binary-file-list.component.spec.ts b/alfa-client/libs/binary-file/src/lib/binary-file-list-container/binary-file-list/binary-file-list.component.spec.ts index fbbc820600ee4d1b3a9235dafd3515f9c1f16404..354a9d2be51cb3f1bd9bdb20d1525ed75b653904 100644 --- a/alfa-client/libs/binary-file/src/lib/binary-file-list-container/binary-file-list/binary-file-list.component.spec.ts +++ b/alfa-client/libs/binary-file/src/lib/binary-file-list-container/binary-file-list/binary-file-list.component.spec.ts @@ -23,9 +23,11 @@ */ import { BinaryFileListResource, BinaryFileResource } from '@alfa-client/binary-file-shared'; import { createStateResource, StateResource, ToEmbeddedResourcesPipe } from '@alfa-client/tech-shared'; -import { getMockComponent } from '@alfa-client/test-utils'; +import { existsAsHtmlElement, getMockComponent, notExistsAsHtmlElement } from '@alfa-client/test-utils'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { AttachmentWrapperComponent } from '@ods/system'; import { createBinaryFileListResource, createBinaryFileResource } from 'libs/binary-file-shared/test/binary-file'; +import { getDataTestIdOf } from 'libs/tech-shared/test/data-test'; import { MockComponent, MockDirective } from 'ng-mocks'; import { BinaryFile2ContainerComponent } from '../../binary-file2-container/binary-file2-container.component'; import { BinaryFileListOrientationDirective } from '../../directive/binary-file-list-orientation/binary-file-list-orientation.directive'; @@ -36,9 +38,11 @@ describe('BinaryFileListComponent', () => { let fixture: ComponentFixture<BinaryFileListComponent>; const binaryFile: BinaryFileResource = createBinaryFileResource(); - const binaryFileListStateResource: StateResource<BinaryFileListResource> = createStateResource( - createBinaryFileListResource([binaryFile]), - ); + function getBinaryFileListStateResource(binaryFiles: BinaryFileResource[]): StateResource<BinaryFileListResource> { + return createStateResource(createBinaryFileListResource(binaryFiles)); + } + + const wrapperSelector: string = getDataTestIdOf('binary-file-list-wrapper'); beforeEach(async () => { await TestBed.configureTestingModule({ @@ -46,13 +50,14 @@ describe('BinaryFileListComponent', () => { BinaryFileListComponent, ToEmbeddedResourcesPipe, MockComponent(BinaryFile2ContainerComponent), + MockComponent(AttachmentWrapperComponent), MockDirective(BinaryFileListOrientationDirective), ], }).compileComponents(); fixture = TestBed.createComponent(BinaryFileListComponent); component = fixture.componentInstance; - component.binaryFileListStateResource = binaryFileListStateResource; + component.binaryFileListStateResource = getBinaryFileListStateResource([binaryFile]); fixture.detectChanges(); }); @@ -60,6 +65,22 @@ describe('BinaryFileListComponent', () => { expect(component).toBeTruthy(); }); + describe('template', () => { + describe('attachment wrapper', () => { + it('should show', () => { + existsAsHtmlElement(fixture, wrapperSelector); + }); + + it('should hide', () => { + component.binaryFileListStateResource = getBinaryFileListStateResource([]); + + fixture.detectChanges(); + + notExistsAsHtmlElement(fixture, wrapperSelector); + }); + }); + }); + describe('binary file container', () => { it('should be called with file', () => { const binaryFileContainerComponent: BinaryFile2ContainerComponent = getMockComponent<BinaryFile2ContainerComponent>( diff --git a/alfa-client/libs/binary-file/src/lib/directive/binary-file-list-orientation/binary-file-list-orientation.directive.ts b/alfa-client/libs/binary-file/src/lib/directive/binary-file-list-orientation/binary-file-list-orientation.directive.ts index 3a1a42958aca047046c5aa29edaed15dc77a1705..956be5722cdcd4966e12c8a5216bfb0dde53d706 100644 --- a/alfa-client/libs/binary-file/src/lib/directive/binary-file-list-orientation/binary-file-list-orientation.directive.ts +++ b/alfa-client/libs/binary-file/src/lib/directive/binary-file-list-orientation/binary-file-list-orientation.directive.ts @@ -1,7 +1,7 @@ import { Directive, ElementRef, Input } from '@angular/core'; export const _verticalClasses: string[] = ['flex', 'flex-col']; -export const _horizontalClasses: string[] = ['flex', 'flex-row', 'flex-wrap']; +export const _horizontalClasses: string[] = ['flex', 'flex-wrap', 'gap-2']; export enum BinaryFileListOrientation { HORIZONTAL = 'horizontal', diff --git a/alfa-client/libs/binary-file/src/lib/multi-file-upload-editor/multi-file-upload-editor.component.html b/alfa-client/libs/binary-file/src/lib/multi-file-upload-editor/multi-file-upload-editor.component.html index 1bfa1fb53740999476b2e6b342777abec551f000..4024dc2de95e58d010447f113834a2b1b0387252 100644 --- a/alfa-client/libs/binary-file/src/lib/multi-file-upload-editor/multi-file-upload-editor.component.html +++ b/alfa-client/libs/binary-file/src/lib/multi-file-upload-editor/multi-file-upload-editor.component.html @@ -4,10 +4,12 @@ [attr.data-test-id]="(label | convertForDataTest) + '-file-upload-button'" [multi]="true" [isLoading]="isUploadInProgress$ | async" - class="relative w-72" + [variant]="uploadButtonVariant" data-test-id="binary-file-upload" > <ods-spinner-icon spinner size="medium" /> <ods-attachment-icon icon size="medium" /> - <p text class="text-center">{{ label }}</p> + @if (label) { + <p text data-test-id="upload-button-label" class="text-center">{{ label }}</p> + } </ods-file-upload-button> diff --git a/alfa-client/libs/binary-file/src/lib/multi-file-upload-editor/multi-file-upload-editor.component.spec.ts b/alfa-client/libs/binary-file/src/lib/multi-file-upload-editor/multi-file-upload-editor.component.spec.ts index dc42e69a0f52518ba01f5f3ddf2009ef04e344e9..38303a464c9f128eb05963f94df756663567e129 100644 --- a/alfa-client/libs/binary-file/src/lib/multi-file-upload-editor/multi-file-upload-editor.component.spec.ts +++ b/alfa-client/libs/binary-file/src/lib/multi-file-upload-editor/multi-file-upload-editor.component.spec.ts @@ -1,11 +1,17 @@ import { BinaryFileService, FileUploadType, ToUploadFile } from '@alfa-client/binary-file-shared'; import { ConvertForDataTestPipe } from '@alfa-client/tech-shared'; -import { existsAsHtmlElement, getElementComponentFromFixtureByCss, mock, Mock } from '@alfa-client/test-utils'; +import { + existsAsHtmlElement, + getElementComponentFromFixtureByCss, + mock, + Mock, + notExistsAsHtmlElement, +} from '@alfa-client/test-utils'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { faker } from '@faker-js/faker/.'; import { expect } from '@jest/globals'; import { getUrl, Resource } from '@ngxp/rest'; -import { FileUploadButtonComponent, SpinnerIconComponent } from '@ods/system'; +import { FileUploadButtonComponent, SpinnerIconComponent, UploadButtonVariants } from '@ods/system'; import { getDataTestIdOf } from 'libs/tech-shared/test/data-test'; import { MockComponent } from 'ng-mocks'; import { of } from 'rxjs'; @@ -23,6 +29,7 @@ describe('MultiFileUploadEditorComponent', () => { const uploadResource: Resource = createDummyResource([uploadLinkRel]); const buttonTestId: string = getDataTestIdOf('Ein_Label-file-upload-button'); + const buttonLabelTestId: string = getDataTestIdOf('upload-button-label'); let binaryFileService: Mock<BinaryFileService>; @@ -104,6 +111,24 @@ describe('MultiFileUploadEditorComponent', () => { } as ToUploadFile); }); }); + + describe('get uploadButtonVariant', () => { + it('should return "label"', () => { + component.label = 'test'; + + const result: UploadButtonVariants['variant'] = component.uploadButtonVariant; + + expect(result).toBe('label'); + }); + + it('should return "icon"', () => { + component.label = ''; + + const result: UploadButtonVariants['variant'] = component.uploadButtonVariant; + + expect(result).toBe('icon'); + }); + }); }); describe('template', () => { @@ -125,5 +150,22 @@ describe('MultiFileUploadEditorComponent', () => { expect(fileButtonComponent.isLoading).toEqual(true); }); }); + describe('upload button label', () => { + it('should show', () => { + component.label = 'test'; + + fixture.detectChanges(); + + existsAsHtmlElement(fixture, buttonLabelTestId); + }); + + it('should hide', () => { + component.label = ''; + + fixture.detectChanges(); + + notExistsAsHtmlElement(fixture, buttonLabelTestId); + }); + }); }); }); diff --git a/alfa-client/libs/binary-file/src/lib/multi-file-upload-editor/multi-file-upload-editor.component.ts b/alfa-client/libs/binary-file/src/lib/multi-file-upload-editor/multi-file-upload-editor.component.ts index 4c057b3f3d7b52b25dc63164e62f06bb054b83c8..2a9623612015f5350b39822c605c8faed1903d59 100644 --- a/alfa-client/libs/binary-file/src/lib/multi-file-upload-editor/multi-file-upload-editor.component.ts +++ b/alfa-client/libs/binary-file/src/lib/multi-file-upload-editor/multi-file-upload-editor.component.ts @@ -5,7 +5,7 @@ import { AsyncPipe } from '@angular/common'; import { Component, HostListener, inject, Input, OnInit } from '@angular/core'; import { ControlContainer, FormGroupDirective, ReactiveFormsModule } from '@angular/forms'; import { getUrl, Resource } from '@ngxp/rest'; -import { AttachmentIconComponent, FileUploadButtonComponent, SpinnerIconComponent } from '@ods/system'; +import { AttachmentIconComponent, FileUploadButtonComponent, SpinnerIconComponent, UploadButtonVariants } from '@ods/system'; import { uniqueId } from 'lodash-es'; import { Observable } from 'rxjs'; @@ -55,4 +55,8 @@ export class MultiFileUploadEditorComponent implements OnInit { }); } } + + get uploadButtonVariant(): UploadButtonVariants['variant'] { + return this.label ? 'label' : 'icon'; + } } diff --git a/alfa-client/libs/binary-file/src/lib/multi-file-upload/multi-file-upload.component.html b/alfa-client/libs/binary-file/src/lib/multi-file-upload/multi-file-upload.component.html index 184ac868f9f594da443f6f89b969ce1cc85b1dfc..8efd002c007d46dba8fc79c2526e59e132191c66 100644 --- a/alfa-client/libs/binary-file/src/lib/multi-file-upload/multi-file-upload.component.html +++ b/alfa-client/libs/binary-file/src/lib/multi-file-upload/multi-file-upload.component.html @@ -1,13 +1,15 @@ -<ods-file-upload-list-container - [parentFormArrayName]="filesFormFieldName" - [fileUploadType]="fileUploadType" - [filesResource]="filesResource" - [filesLinkRel]="filesLinkRelation" - data-test-id="file-list" -></ods-file-upload-list-container> -<ods-multi-file-upload-editor - [fileUploadType]="fileUploadType" - [uploadResource]="uploadResource" - [uploadLinkRelation]="uploadLinkRelation" - data-test-id="multi-file-upload-editor" -></ods-multi-file-upload-editor> \ No newline at end of file +<div class="flex flex-col gap-2"> + <ods-file-upload-list-container + [parentFormArrayName]="filesFormFieldName" + [fileUploadType]="fileUploadType" + [filesResource]="filesResource" + [filesLinkRel]="filesLinkRelation" + data-test-id="file-list" + /> + <ods-multi-file-upload-editor + [fileUploadType]="fileUploadType" + [uploadResource]="uploadResource" + [uploadLinkRelation]="uploadLinkRelation" + data-test-id="multi-file-upload-editor" + /> +</div> diff --git a/alfa-client/libs/design-component/src/lib/form/single-file-upload-editor/single-file-upload-editor.component.ts b/alfa-client/libs/design-component/src/lib/form/single-file-upload-editor/single-file-upload-editor.component.ts index a62e384fb3c9ff9c15b453cc9927438d1e1790c4..7de4a0bf3fc04340fa80daed1896235559f6dd18 100644 --- a/alfa-client/libs/design-component/src/lib/form/single-file-upload-editor/single-file-upload-editor.component.ts +++ b/alfa-client/libs/design-component/src/lib/form/single-file-upload-editor/single-file-upload-editor.component.ts @@ -24,7 +24,7 @@ import { ConvertForDataTestPipe, isNotNil } from '@alfa-client/tech-shared'; import { Component, EventEmitter, HostListener, Input, Output } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; -import { FileUploadButtonComponent, SpinnerIconComponent } from '@ods/system'; +import { FileUploadButtonComponent } from '@ods/system'; import { uniqueId } from 'lodash-es'; import { FormControlEditorAbstractComponent } from '../formcontrol-editor.abstract.component'; @@ -32,7 +32,8 @@ import { FormControlEditorAbstractComponent } from '../formcontrol-editor.abstra selector: 'ods-single-file-upload-editor', templateUrl: './single-file-upload-editor.component.html', standalone: true, - imports: [FileUploadButtonComponent, SpinnerIconComponent, ReactiveFormsModule, ConvertForDataTestPipe], + styles: [':host {@apply contents}'], + imports: [FileUploadButtonComponent, ReactiveFormsModule, ConvertForDataTestPipe], }) export class SingleFileUploadEditorComponent extends FormControlEditorAbstractComponent { @Input() label: string = ''; diff --git a/alfa-client/libs/design-system/src/lib/form/file-upload-button/file-upload-button.component.html b/alfa-client/libs/design-system/src/lib/form/file-upload-button/file-upload-button.component.html index dc2b6cef68ea7358023c7791fea0eb6efbaf47bf..b65b88d5b02c74982b872549c6d87aeccdf1644b 100644 --- a/alfa-client/libs/design-system/src/lib/form/file-upload-button/file-upload-button.component.html +++ b/alfa-client/libs/design-system/src/lib/form/file-upload-button/file-upload-button.component.html @@ -34,14 +34,13 @@ [multiple]="multi" [attr.data-test-id]="(id | convertForDataTest) + '-file-upload-input'" /> -<label - [for]="id" - class="z-10 inline-flex w-full flex-grow items-center justify-start gap-4 break-words rounded-md bg-background-50 py-3 pl-6 pr-6 text-text hover:bg-background-100 focus:outline-none focus:ring-2 focus:ring-primary peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-ozgblue-800 peer-disabled:cursor-wait peer-disabled:hover:bg-background-50" - role="button" -> - <ng-content *ngIf="!isLoading" select="[icon]"></ng-content> - <ng-content *ngIf="isLoading" select="[spinner]"></ng-content> - <div class="flex-grow"> +<label [for]="id" [ngClass]="uploadButtonVariants({ variant })" role="button"> + @if (isLoading) { + <ng-content select="[spinner]"></ng-content> + } @else { + <ng-content select="[icon]"></ng-content> + } + <div class="flex-grow empty:hidden"> <ng-content select="[text]"></ng-content> </div> </label> diff --git a/alfa-client/libs/design-system/src/lib/form/file-upload-button/file-upload-button.component.ts b/alfa-client/libs/design-system/src/lib/form/file-upload-button/file-upload-button.component.ts index 2371918f9cdba62e0ea0ee3a5e315ee9f1d9e378..b5048be11b1b7e67eaf8c077e3d4cd0caab66388 100644 --- a/alfa-client/libs/design-system/src/lib/form/file-upload-button/file-upload-button.component.ts +++ b/alfa-client/libs/design-system/src/lib/form/file-upload-button/file-upload-button.component.ts @@ -24,12 +24,33 @@ import { ConvertForDataTestPipe } from '@alfa-client/tech-shared'; import { CommonModule } from '@angular/common'; import { Component, ElementRef, Input, ViewChild } from '@angular/core'; +import { cva, VariantProps } from 'class-variance-authority'; + +export const uploadButtonVariants = cva( + [ + 'z-10 inline-flex flex-grow items-center justify-start gap-4 break-words rounded-md text-primary', + 'border border-transparent hover:bg-ghost-hover peer-focus-visible:border-background-200', + 'peer-focus-visible:outline peer-focus-visible:outline-focus peer-focus-visible:bg-ghost-hover peer-focus-visible:outline-offset-1', + ], + { + variants: { + variant: { + label: 'py-3 px-6 bg-background-50 w-full', + icon: 'p-2 w-fit', + }, + }, + defaultVariants: { + variant: 'label', + }, + }, +); +export type UploadButtonVariants = VariantProps<typeof uploadButtonVariants>; @Component({ selector: 'ods-file-upload-button', standalone: true, imports: [CommonModule, ConvertForDataTestPipe], - styles: [':host {@apply inline-flex}'], + styles: [':host {@apply relative}'], templateUrl: './file-upload-button.component.html', }) export class FileUploadButtonComponent { @@ -37,9 +58,12 @@ export class FileUploadButtonComponent { @Input() isLoading: boolean = false; @Input() accept: string = '*/*'; @Input() multi: boolean = false; + @Input() variant: UploadButtonVariants['variant']; @ViewChild('inputElement') inputElement: ElementRef = new ElementRef({}); + readonly uploadButtonVariants = uploadButtonVariants; + resetInput(): void { this.inputElement.nativeElement.value = ''; }