From 55eaeb7770ecc4574f2aa4217ca9aa83ad014551 Mon Sep 17 00:00:00 2001 From: ccremer Date: Thu, 13 Apr 2023 11:31:56 +0200 Subject: [PATCH 1/9] Terminate Observable for getAllMemoized in Kubernetes service Most of the time, we want only 1 value to be emitted. If we require continuous updates, we use the `stream` methods. --- src/app/billingentity/billing-entity.component.ts | 4 ++-- .../billingentity-members/billing-entity-members.component.ts | 3 +-- .../invitation-detail/invitation-detail.component.ts | 4 +--- .../invitations/invitation-edit/invitation-edit.component.ts | 2 +- src/app/store/kubernetes-collection.service.ts | 2 +- 5 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/app/billingentity/billing-entity.component.ts b/src/app/billingentity/billing-entity.component.ts index f2927a04..4000f0fa 100644 --- a/src/app/billingentity/billing-entity.component.ts +++ b/src/app/billingentity/billing-entity.component.ts @@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { BillingEntity } from '../types/billing-entity'; import { faAdd, faEdit, faInfo, faMagnifyingGlass, faUserGroup, faWarning } from '@fortawesome/free-solid-svg-icons'; -import { combineLatestAll, forkJoin, from, map, Observable, of, take } from 'rxjs'; +import { combineLatestAll, forkJoin, from, map, Observable, of } from 'rxjs'; import { BillingEntityCollectionService } from '../store/billingentity-collection.service'; import { switchMap } from 'rxjs/operators'; @@ -25,7 +25,7 @@ export class BillingEntityComponent implements OnInit { ngOnInit(): void { this.payload$ = forkJoin([ - this.billingEntityService.getAllMemoized().pipe(take(1)), + this.billingEntityService.getAllMemoized(), this.billingEntityService.canCreateBilling(), ]).pipe( switchMap(([entities, canCreateBilling]) => { diff --git a/src/app/billingentity/billingentity-members/billing-entity-members.component.ts b/src/app/billingentity/billingentity-members/billing-entity-members.component.ts index ee302509..8078bbba 100644 --- a/src/app/billingentity/billingentity-members/billing-entity-members.component.ts +++ b/src/app/billingentity/billingentity-members/billing-entity-members.component.ts @@ -97,9 +97,8 @@ export class BillingEntityMembersComponent implements OnInit, OnDestroy { this.router.navigateByUrl('/home'); } - // Note: The `take(1)`s below ensure our `forkJoin`s actually finish, otherwise they wait forever. return forkJoin([ - this.billingService.getByKeyMemoized(beName).pipe(take(1)), + this.billingService.getByKeyMemoized(beName), this.rolebindingService .getByKeyMemoized(adminClusterRoleBindingName) .pipe(catchError(defaultIfNotFound(this.newRoleBinding(adminClusterRoleBindingName))), take(1)), diff --git a/src/app/invitations/invitation-detail/invitation-detail.component.ts b/src/app/invitations/invitation-detail/invitation-detail.component.ts index 64a3c608..081de3f8 100644 --- a/src/app/invitations/invitation-detail/invitation-detail.component.ts +++ b/src/app/invitations/invitation-detail/invitation-detail.component.ts @@ -14,7 +14,7 @@ import { BillingEntity } from '../../types/billing-entity'; import { Team } from '../../types/team'; import { Invitation } from '../../types/invitation'; import { getBillingEntityFromClusterRoleName } from '../../store/entity-filter'; -import { catchError, combineLatestAll, forkJoin, from, map, Observable, of, take } from 'rxjs'; +import { catchError, combineLatestAll, forkJoin, from, map, Observable, of } from 'rxjs'; import { OrganizationCollectionService } from '../../store/organization-collection.service'; import { BillingEntityCollectionService } from '../../store/billingentity-collection.service'; import { TeamCollectionService } from '../../store/team-collection.service'; @@ -116,7 +116,6 @@ export class InvitationDetailComponent implements OnInit { const teams$ = teamNames.map((team) => this.teamService.getByKeyMemoized(team).pipe( - take(1), catchError(() => { console.warn(`could not fetch team '${team}' to resolve display name, resort to fallback value`); const teamName = team.split('/'); @@ -149,7 +148,6 @@ export class InvitationDetailComponent implements OnInit { } const billing$ = beNames.map((be) => this.billingService.getByKeyMemoized(be).pipe( - take(1), catchError(() => { console.warn(`could not fetch billing entity '${be}' to resolve display name, resort to fallback value`); return of({ diff --git a/src/app/invitations/invitation-edit/invitation-edit.component.ts b/src/app/invitations/invitation-edit/invitation-edit.component.ts index a4fb15b2..a9e4bb07 100644 --- a/src/app/invitations/invitation-edit/invitation-edit.component.ts +++ b/src/app/invitations/invitation-edit/invitation-edit.component.ts @@ -33,7 +33,7 @@ export class InvitationEditComponent implements OnInit { this.billingService.canViewBillingEntities$, ]).pipe( switchMap(([canViewOrganizations, canViewBillingEntities]) => { - const organizations$ = canViewOrganizations ? this.organizationService.getAllMemoized().pipe(take(1)) : of([]); + const organizations$ = canViewOrganizations ? this.organizationService.getAllMemoized() : of([]); const billingEntities$ = canViewBillingEntities ? this.fetchBilling$() : of([]); return forkJoin([of(canViewOrganizations), organizations$, of(canViewBillingEntities), billingEntities$]); }), diff --git a/src/app/store/kubernetes-collection.service.ts b/src/app/store/kubernetes-collection.service.ts index ebbd7535..857521af 100644 --- a/src/app/store/kubernetes-collection.service.ts +++ b/src/app/store/kubernetes-collection.service.ts @@ -113,7 +113,7 @@ export class KubernetesCollectionService extends EntityCol take(1), switchMap(() => { if (this.memoizedAllEntities) { - return this.entities$; + return this.entities$.pipe(take(1)); } return this.getAll(options); }) From f59b8524df3eb4709be5549a3dfae3069f3b39bb Mon Sep 17 00:00:00 2001 From: ccremer Date: Thu, 13 Apr 2023 12:34:48 +0200 Subject: [PATCH 2/9] Refactor organization list --- src/app/app.component.ts | 32 +++-- .../organization-selection.component.ts | 3 +- .../organization-edit.component.html | 2 +- .../organizations.component.html | 99 +++++++++------- .../organizations/organizations.component.ts | 111 ++++++++++++------ .../store/organization-collection.service.ts | 18 +-- src/app/store/user-collection.service.ts | 11 ++ src/app/user/user-edit/user-edit.component.ts | 8 ++ 8 files changed, 179 insertions(+), 105 deletions(-) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 46ccea5d..fd0e60b9 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -8,7 +8,7 @@ import { IconDefinition } from '@fortawesome/fontawesome-common-types'; import * as Sentry from '@sentry/browser'; import { AppConfigService } from './app-config.service'; import { IdentityService } from './core/identity.service'; -import { forkJoin } from 'rxjs'; +import { catchError, forkJoin } from 'rxjs'; import { OrganizationCollectionService } from './store/organization-collection.service'; import { SelfSubjectAccessReviewCollectionService } from './store/ssar-collection.service'; import { firstInList, metadataNameFilter } from './store/entity-filter'; @@ -17,6 +17,7 @@ import { BillingEntityPermissions } from './types/billing-entity'; import { UserCollectionService } from './store/user-collection.service'; import { ZonePermissions } from './types/zone'; import { InvitationPermissions } from './types/invitation'; +import { defaultIfStatusCode } from './store/kubernetes-collection.service'; @Component({ selector: 'app-root', @@ -47,17 +48,24 @@ export class AppComponent implements OnInit { ngOnInit(): void { // initial filter, otherwise teams cannot be loaded if no default organization is defined in the user this.organizationService.setFilter(firstInList()); - this.userService.setFilter(metadataNameFilter(this.identityService.getUsername())); - this.userService.getByKey(this.identityService.getUsername()).subscribe({ - next: (user) => { - if (user.spec.preferences?.defaultOrganizationRef) { - this.organizationService.setFilter(metadataNameFilter(user.spec.preferences.defaultOrganizationRef)); - } - }, - error: (err) => { - console.warn('could not load the user object:', err.message ?? err); - }, - }); + const userName = this.identityService.getUsername(); + this.userService.setFilter(metadataNameFilter(userName)); + this.userService + .getByKey(userName) + .pipe(catchError(defaultIfStatusCode(this.userService.newUser(userName), [401, 403, 404]))) + .subscribe({ + next: (user) => { + if (user.spec.preferences?.defaultOrganizationRef) { + this.organizationService.setFilter(metadataNameFilter(user.spec.preferences?.defaultOrganizationRef ?? '')); + } + if (!user.metadata.resourceVersion) { + this.userService.upsertOneInCache(user); + } + }, + error: (err) => { + console.warn('could not load the user object:', err.message ?? err); + }, + }); this.name = this.identityService.getName(); this.username = this.identityService.getUsername(); diff --git a/src/app/organization-selection/organization-selection.component.ts b/src/app/organization-selection/organization-selection.component.ts index 6af85e53..563cc742 100644 --- a/src/app/organization-selection/organization-selection.component.ts +++ b/src/app/organization-selection/organization-selection.component.ts @@ -1,7 +1,6 @@ import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; import { map, Observable, Subscription } from 'rxjs'; import { SelectItem } from 'primeng/api'; -import { Store } from '@ngrx/store'; import { faSitemap } from '@fortawesome/free-solid-svg-icons'; import { FormControl } from '@angular/forms'; import { OrganizationCollectionService } from '../store/organization-collection.service'; @@ -19,7 +18,7 @@ export class OrganizationSelectionComponent implements OnInit, OnDestroy { private subscriptions: Subscription[] = []; - constructor(private store: Store, private organizationService: OrganizationCollectionService) {} + constructor(private organizationService: OrganizationCollectionService) {} ngOnInit(): void { this.organizations$ = this.organizationService.getAllMemoized().pipe( diff --git a/src/app/organizations/organization-edit/organization-edit.component.html b/src/app/organizations/organization-edit/organization-edit.component.html index a604bdc0..aa3c542c 100644 --- a/src/app/organizations/organization-edit/organization-edit.component.html +++ b/src/app/organizations/organization-edit/organization-edit.component.html @@ -2,7 +2,7 @@
-
+
New Organization {{ payload.organization.metadata.name }} diff --git a/src/app/organizations/organizations.component.html b/src/app/organizations/organizations.component.html index ece7598a..6cb2a450 100644 --- a/src/app/organizations/organizations.component.html +++ b/src/app/organizations/organizations.component.html @@ -1,48 +1,64 @@ -
-

Organizations

+ -
-
- -
+ +
+

Organizations

+ +
+
+ +
- -
- +
+ +
-
-
-
+
- - - +
- - - - - -
No organizations available.
-
-
+ + + + +
No organizations available.
+
+
+
- - +
Organizations could not be loaded.
diff --git a/src/app/organizations/organizations.component.ts b/src/app/organizations/organizations.component.ts index 2a68ea1e..86c0e4de 100644 --- a/src/app/organizations/organizations.component.ts +++ b/src/app/organizations/organizations.component.ts @@ -1,6 +1,5 @@ import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; -import { Store } from '@ngrx/store'; -import { map, Observable, Subscription, take } from 'rxjs'; +import { combineLatestAll, filter, forkJoin, from, map, Observable, of, Subscription, switchMap } from 'rxjs'; import { faAdd, faDollarSign, @@ -12,17 +11,11 @@ import { } from '@fortawesome/free-solid-svg-icons'; import { DialogService } from 'primeng/dynamicdialog'; import { JoinOrganizationDialogComponent } from './join-organization-dialog/join-organization-dialog.component'; -import { selectQueryParam } from '../store/router.selectors'; import { ActivatedRoute, Router } from '@angular/router'; import { OrganizationCollectionService } from '../store/organization-collection.service'; import { Organization } from '../types/organization'; import { OrganizationMembersCollectionService } from '../store/organizationmembers-collection.service'; - -interface OrganizationConfig { - organization: Organization; - canEdit$: Observable; - canViewMembers$: Observable; -} +import { BillingEntityCollectionService } from '../store/billingentity-collection.service'; @Component({ selector: 'app-organizations', @@ -38,47 +31,82 @@ export class OrganizationsComponent implements OnInit, OnDestroy { faSitemap = faSitemap; faUserGroup = faUserGroup; faDollarSign = faDollarSign; - private showJoinDialogSubscription?: Subscription; - organizations$?: Observable; - canAddOrganizations$?: Observable; + subscriptions: Subscription[] = []; + + organizations$?: Observable; constructor( - private store: Store, private dialogService: DialogService, private router: Router, private activatedRoute: ActivatedRoute, public organizationService: OrganizationCollectionService, - private organizationMembersService: OrganizationMembersCollectionService + private organizationMembersService: OrganizationMembersCollectionService, + private billingService: BillingEntityCollectionService ) {} ngOnInit(): void { - this.canAddOrganizations$ = this.organizationService.canAddOrganizations$; - - this.organizations$ = this.organizationService.getAllMemoized().pipe( - take(1), - map((orgs) => - orgs.map((org) => { - return { - organization: org, - canEdit$: this.organizationService.canEditOrganization(org), - canViewMembers$: this.organizationMembersService.canViewMembers(org.metadata.name), - } satisfies OrganizationConfig; - }) - ) + this.organizations$ = forkJoin([ + this.organizationService.canAddOrganizations$, + this.billingService.canViewBillingEntities$, + this.organizationService.getAllMemoized(), + ]).pipe( + switchMap(([canAddOrganizations, canViewBilling, orgs]) => { + if (orgs.length === 0 && canAddOrganizations && canViewBilling) { + void this.router.navigate(['organizations', '$new'], { + queryParams: { firstTime: undefined }, + queryParamsHandling: 'merge', + }); + return forkJoin([of(canAddOrganizations), of([])]); + } + return forkJoin([of(canAddOrganizations), this.fetchOrganizationData$(orgs)]); + }), + map(([canAddOrganizations, orgVMList]) => { + const vm: ViewModel = { + canAddOrganizations, + organizations: orgVMList, + }; + return vm; + }) ); - this.showJoinDialogSubscription = this.store - .select(selectQueryParam('showJoinDialog')) - // eslint-disable-next-line ngrx/no-store-subscription - .subscribe((showJoinDialog) => { - if (showJoinDialog) { + + this.subscriptions.push( + this.activatedRoute.queryParamMap + .pipe( + map((q) => q.get('showJoinDialog')), + filter((v) => v === 'true') + ) + .subscribe(() => { this.openJoinOrganizationDialog(); - this.router.navigate([], { + void this.router.navigate([], { relativeTo: this.activatedRoute, queryParams: { showJoinDialog: undefined }, queryParamsHandling: 'merge', }); - } - }); + }) + ); + } + + private fetchOrganizationData$(orgs: Organization[]): Observable { + if (orgs.length === 0) { + return of([]); + } + const list = orgs.map((org) => { + return forkJoin([ + of(org), + this.organizationService.canEditOrganization(org), + this.organizationMembersService.canViewMembers(org.metadata.name), + ]).pipe( + map(([organization, canEdit, canViewMembers]) => { + const orgVM: OrganizationViewModel = { + organization, + canEdit, + canViewMembers, + }; + return orgVM; + }) + ); + }); + return from(list).pipe(combineLatestAll()); } openJoinOrganizationDialog(): void { @@ -90,6 +118,17 @@ export class OrganizationsComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { - this.showJoinDialogSubscription?.unsubscribe(); + this.subscriptions.forEach((sub) => sub.unsubscribe()); } } + +interface ViewModel { + organizations: OrganizationViewModel[]; + canAddOrganizations: boolean; +} + +interface OrganizationViewModel { + organization: Organization; + canEdit: boolean; + canViewMembers: boolean; +} diff --git a/src/app/store/organization-collection.service.ts b/src/app/store/organization-collection.service.ts index 5b2e0437..65660492 100644 --- a/src/app/store/organization-collection.service.ts +++ b/src/app/store/organization-collection.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { EntityCollectionServiceElementsFactory } from '@ngrx/data'; import { Organization, OrganizationPermissions } from '../types/organization'; import { organizationEntityKey } from './entity-metadata-map'; -import { combineLatest, filter, forkJoin, map, Observable } from 'rxjs'; +import { filter, forkJoin, map, Observable } from 'rxjs'; import { KubernetesCollectionService } from './kubernetes-collection.service'; import { SelfSubjectAccessReviewCollectionService } from './ssar-collection.service'; import { Verb } from './app.reducer'; @@ -13,7 +13,6 @@ import { metadataNameFilter } from './entity-filter'; providedIn: 'root', }) export class OrganizationCollectionService extends KubernetesCollectionService { - isEmptyAndLoaded$: Observable; canAddOrganizations$: Observable; canViewOrganizations$: Observable; selectedOrganization$: Observable; @@ -23,17 +22,12 @@ export class OrganizationCollectionService extends KubernetesCollectionService { - if (loaded) { - return entities.length === 0; - } - return false; - }); - this.canAddOrganizations$ = forkJoin([ - permissionService.isAllowed(OrganizationPermissions.group, OrganizationPermissions.resource, Verb.Create), - permissionService.isAllowed(BillingEntityPermissions.group, BillingEntityPermissions.resource, Verb.List), - ]).pipe(map(([orgCreateAllowed, beListAllowed]) => orgCreateAllowed && beListAllowed)); + this.canAddOrganizations$ = permissionService.isAllowed( + OrganizationPermissions.group, + OrganizationPermissions.resource, + Verb.Create + ); this.canViewOrganizations$ = permissionService.isAllowed( OrganizationPermissions.group, diff --git a/src/app/store/user-collection.service.ts b/src/app/store/user-collection.service.ts index c5d89367..01de3845 100644 --- a/src/app/store/user-collection.service.ts +++ b/src/app/store/user-collection.service.ts @@ -19,4 +19,15 @@ export class UserCollectionService extends KubernetesCollectionService { map((users) => users[0]) ); } + + newUser(userName: string): User { + return { + kind: 'User', + apiVersion: 'appuio.io/v1', + metadata: { + name: userName, + }, + spec: {}, + }; + } } diff --git a/src/app/user/user-edit/user-edit.component.ts b/src/app/user/user-edit/user-edit.component.ts index c24e4421..23afc255 100644 --- a/src/app/user/user-edit/user-edit.component.ts +++ b/src/app/user/user-edit/user-edit.component.ts @@ -38,6 +38,14 @@ export class UserEditComponent implements OnInit { ngOnInit(): void { const userName = this.identityService.getUsername(); this.payload$ = this.userService.getByKeyMemoized(userName).pipe( + switchMap((user) => { + if (user.metadata.resourceVersion) { + return of(user); + } + // we only have a faked user in the store based on IDP, we need to get the real user object. + // this case could happen if the user is a first-time user where the actual User object doesn't yet exist in Kubernetes. + return this.userService.getByKey(userName); + }), combineLatestWith(this.organizationService.getAllMemoized()), switchMap(([user, orgs]) => { return forkJoin([this.filterOrganizationsForMembership(orgs, userName), of(user)]); From 099daf67764c0d8c193b8fdc84a91a81804b1fab Mon Sep 17 00:00:00 2001 From: ccremer Date: Thu, 13 Apr 2023 12:35:10 +0200 Subject: [PATCH 3/9] Update first-time dialog to guide new users through setup --- cypress/e2e/billingentity-form.cy.ts | 40 +++- cypress/e2e/first-time-login.cy.ts | 213 ++++++++++++------ cypress/e2e/organizations.cy.ts | 154 +++++++------ .../billing-entity-form.component.ts | 8 + .../first-time-login-dialog.component.html | 162 +++++++------ .../first-time-login-dialog.component.ts | 152 ++++++------- .../invitations/invitations-routing.module.ts | 4 + 7 files changed, 438 insertions(+), 295 deletions(-) diff --git a/cypress/e2e/billingentity-form.cy.ts b/cypress/e2e/billingentity-form.cy.ts index aafa5abf..884508b8 100644 --- a/cypress/e2e/billingentity-form.cy.ts +++ b/cypress/e2e/billingentity-form.cy.ts @@ -1,6 +1,7 @@ import { createUser } from '../fixtures/user'; import { BillingEntity, BillingEntityPermissions, BillingEntitySpec } from '../../src/app/types/billing-entity'; import { billingEntityNxt, setBillingEntities } from '../fixtures/billingentities'; +import { OrganizationPermissions } from '../../src/app/types/organization'; describe('Test billing entity form elements', () => { beforeEach(() => { @@ -186,7 +187,11 @@ describe('Test billing entity create', () => { cy.intercept('GET', 'appuio-api/apis/appuio.io/v1/users/mig', { body: createUser({ username: 'mig', defaultOrganizationRef: 'nxt' }), }); - cy.setPermission({ verb: 'list', ...BillingEntityPermissions }, { verb: 'create', ...BillingEntityPermissions }); + cy.setPermission( + { verb: 'list', ...BillingEntityPermissions }, + { verb: 'create', ...BillingEntityPermissions }, + { verb: 'list', ...OrganizationPermissions } + ); }); it('should create billing', () => { @@ -247,6 +252,39 @@ describe('Test billing entity create', () => { cy.get('.flex-wrap > .text-900').eq(4).should('contain.text', 'mig hallo@nxt.engineering'); cy.get('.flex-wrap > .text-900').eq(5).should('contain.text', '🇩🇪'); }); + + it('should forward to organizations if first time', () => { + cy.intercept('POST', '/appuio-api/apis/billing.appuio.io/v1/billingentities', (req) => { + const be: BillingEntity = req.body; + be.metadata.name = 'be-2345'; + be.metadata.generateName = ''; + req.reply(be); + }).as('createBillingEntity'); + setBillingEntities(cy, billingEntityNxt); + + cy.visit('/billingentities/$new?edit=y&firstTime=y'); + cy.get('#title').should('contain.text', 'New Billing'); + + cy.get('#displayName').type('➡️ Engineering GmbH'); + + cy.get('#companyEmail').find('input').type('hallo@nxt.engineering,'); + cy.get('#phone').type('☎️'); + cy.get('#line1').type('📃'); + cy.get('#line2').type('📋'); + cy.get('#postal').type('🏤'); + cy.get('#city').type('🏙️'); + cy.get('p-dropdown').click().contains('Switzerland').click(); + + cy.get('#accountingName').type('mig'); + + cy.get('button[type="submit"]').should('be.enabled').click(); + cy.wait('@createBillingEntity'); + + cy.get('p-toast').should('contain.text', 'Successfully saved'); + cy.url().should('include', '/organizations/$new').should('not.include', '?edit=y'); + + cy.get('#title').should('contain.text', 'New Organization'); + }); }); describe('Test billing entity edit', () => { diff --git a/cypress/e2e/first-time-login.cy.ts b/cypress/e2e/first-time-login.cy.ts index 0a25d4ea..7527ca51 100644 --- a/cypress/e2e/first-time-login.cy.ts +++ b/cypress/e2e/first-time-login.cy.ts @@ -1,9 +1,12 @@ import { createUser } from '../fixtures/user'; -import { organizationListNxtVshn, setOrganization } from '../fixtures/organization'; +import { organizationListNxtVshn, organizationNxt, setOrganization } from '../fixtures/organization'; import { OrganizationPermissions } from '../../src/app/types/organization'; -import { createOrganizationMembers } from '../fixtures/organization-members'; import { OrganizationMembersPermissions } from '../../src/app/types/organization-members'; import { InvitationPermissions } from '../../src/app/types/invitation'; +import { BillingEntityPermissions } from '../../src/app/types/billing-entity'; +import { billingEntityNxt, setBillingEntities } from '../fixtures/billingentities'; +import { createInvitation } from '../fixtures/invitations'; +import { createOrganizationMembers } from '../fixtures/organization-members'; describe('Test First Time Login', () => { beforeEach(() => { @@ -12,103 +15,75 @@ describe('Test First Time Login', () => { cy.disableCookieBanner(); }); beforeEach(() => { - // needed for initial getUser request + // we assume we don't have the user object yet. cy.intercept('GET', 'appuio-api/apis/appuio.io/v1/users/mig', { - body: createUser({ username: 'mig', defaultOrganizationRef: 'nxt' }), + statusCode: 403, }); }); - it('join organization', () => { - cy.setPermission({ verb: 'list', ...OrganizationPermissions }); + it('join organization if not part of organization', () => { + cy.setPermission( + { verb: 'list', ...OrganizationPermissions }, + { verb: 'list', ...BillingEntityPermissions }, + { verb: 'create', ...BillingEntityPermissions } + ); setOrganization(cy); + setBillingEntities(cy); cy.visit('/'); cy.get('.p-dialog-header').should('contain.text', 'Welcome to the APPUiO Cloud Portal'); + cy.get('#setDefaultOrganizationDialogButton').should('not.exist'); + cy.get('#addBillingDialogButton').should('exist'); + cy.get('#addOrganizationDialogButton').should('not.exist'); cy.get('#joinOrganizationDialogButton').click(); cy.get('.p-dialog-header').should('contain.text', 'Join Organization'); }); - // some requirements may change, see https://github.com/appuio/cloud-portal/issues/438, skipping until it's clear. - it.skip('add organization', () => { + + it('join organization if not part of organization and no billing access', () => { cy.setPermission({ verb: 'list', ...OrganizationPermissions }); setOrganization(cy); cy.visit('/'); cy.get('.p-dialog-header').should('contain.text', 'Welcome to the APPUiO Cloud Portal'); - cy.get('#addOrganizationDialogButton').click(); - cy.get('.text-3xl > .ng-star-inserted').should('contain.text', 'New Organization'); - }); - - it('do not show dialog', () => { - cy.setPermission( - { verb: 'list', resource: 'organizationmembers', group: 'rbac.appuio.io' }, - { verb: 'list', ...OrganizationPermissions } - ); - setOrganization(cy, ...organizationListNxtVshn.items); - cy.intercept('GET', 'appuio-api/apis/appuio.io/v1/namespaces/nxt/organizationmembers/members', { - body: createOrganizationMembers({ - namespace: 'nxt', - userRefs: [{ name: 'mig' }, { name: 'miw' }], - }), - }); - cy.intercept('GET', 'appuio-api/apis/appuio.io/v1/namespaces/vshn/organizationmembers/members', { - body: createOrganizationMembers({ - namespace: 'vshn', - userRefs: [{ name: 'tobru' }, { name: 'corvus' }], - }), - }); - cy.visit('/'); - cy.get('.p-dialog-header').should('not.exist'); + cy.get('#setDefaultOrganizationDialogButton').should('not.exist'); + cy.get('#addBillingDialogButton').should('not.exist'); + cy.get('#addOrganizationDialogButton').should('not.exist'); + cy.get('#joinOrganizationDialogButton').click(); + cy.get('.p-dialog-header').should('contain.text', 'Join Organization'); }); - it('do not show dialog when redeeming invitations', () => { + it('add organization if not part of organization but part of billing', () => { cy.setPermission( - { verb: 'list', ...OrganizationMembersPermissions }, { verb: 'list', ...OrganizationPermissions }, - { verb: 'list', ...InvitationPermissions } + { verb: 'list', ...BillingEntityPermissions }, + { verb: 'create', ...BillingEntityPermissions } ); - setOrganization(cy); - cy.visit('/invitations/uuid'); - cy.get('#title').should('contain.text', 'Invitation'); - cy.get('.p-dialog-header').should('not.exist'); - }); - - // some requirements may change, see https://github.com/appuio/cloud-portal/issues/438, skipping until it's clear. - it.skip('do not show dialog again', () => { - cy.setPermission({ verb: 'list', ...OrganizationPermissions }); + setBillingEntities(cy, billingEntityNxt); setOrganization(cy); cy.visit('/'); + cy.get('.p-dialog-header').should('contain.text', 'Welcome to the APPUiO Cloud Portal'); cy.get('#joinOrganizationDialogButton').should('exist'); - cy.get('#addOrganizationDialogButton').should('exist'); - - cy.get('label[for=hideFirstTimeLoginDialogCheckbox]').click(); + cy.get('#setDefaultOrganizationDialogButton').should('not.exist'); + cy.get('#addBillingDialogButton').should('not.exist'); cy.get('#addOrganizationDialogButton').click(); cy.get('.text-3xl > .ng-star-inserted').should('contain.text', 'New Organization'); - cy.visit('/teams'); - cy.get('#teams-title').should('contain.text', 'Teams'); cy.get('.p-dialog-header').should('not.exist'); }); - it('show dialog because no organization contains current username', () => { + it('add billing if not part of organization', () => { cy.setPermission( - { verb: 'list', resource: 'organizationmembers', group: 'rbac.appuio.io' }, - { verb: 'list', resource: 'organizations', group: 'rbac.appuio.io' } + { verb: 'list', ...OrganizationPermissions }, + { verb: 'list', ...BillingEntityPermissions }, + { verb: 'create', ...BillingEntityPermissions } ); - setOrganization(cy, ...organizationListNxtVshn.items); - - cy.intercept('GET', 'appuio-api/apis/appuio.io/v1/namespaces/nxt/organizationmembers/members', { - body: createOrganizationMembers({ - namespace: 'nxt', - userRefs: [{ name: 'miw' }], - }), - }); - cy.intercept('GET', 'appuio-api/apis/appuio.io/v1/namespaces/vshn/organizationmembers/members', { - body: createOrganizationMembers({ - namespace: 'vshn', - userRefs: [{ name: 'tobru' }, { name: 'corvus' }], - }), - }); + setBillingEntities(cy); + setOrganization(cy); cy.visit('/'); + cy.get('.p-dialog-header').should('contain.text', 'Welcome to the APPUiO Cloud Portal'); cy.get('#joinOrganizationDialogButton').should('exist'); - cy.get('#addOrganizationDialogButton').should('exist'); cy.get('#setDefaultOrganizationDialogButton').should('not.exist'); + cy.get('#addOrganizationDialogButton').should('not.exist'); + cy.get('#addBillingDialogButton').click(); + cy.get('.text-3xl > .ng-star-inserted').should('contain.text', 'New Billing'); + cy.get('.p-dialog-header').should('not.exist'); }); it('show dialog with button to set default org because user has no default organization yet', () => { @@ -116,25 +91,113 @@ describe('Test First Time Login', () => { body: createUser({ username: 'mig' }), }).as('getUser'); cy.setPermission( - { verb: 'list', resource: 'organizationmembers', group: 'rbac.appuio.io' }, - { verb: 'list', resource: 'organizations', group: 'rbac.appuio.io' } + { verb: 'list', ...OrganizationPermissions }, + { verb: 'list', ...BillingEntityPermissions }, + { verb: 'create', ...BillingEntityPermissions } ); - setOrganization(cy, ...organizationListNxtVshn.items); + setOrganization(cy, organizationNxt); + setBillingEntities(cy, billingEntityNxt); cy.intercept('GET', 'appuio-api/apis/appuio.io/v1/namespaces/nxt/organizationmembers/members', { body: createOrganizationMembers({ namespace: 'nxt', userRefs: [{ name: 'mig' }], }), }); - cy.intercept('GET', 'appuio-api/apis/appuio.io/v1/namespaces/vshn/organizationmembers/members', { + + cy.visit('/'); + cy.wait('@getUser'); + cy.get('.p-dialog-header').should('contain.text', 'Welcome to the APPUiO Cloud Portal'); + cy.get('#joinOrganizationDialogButton').should('not.exist'); + cy.get('#addBillingDialogButton').should('not.exist'); + cy.get('#setDefaultOrganizationDialogButton').click(); + + cy.get('.p-dialog-header').should('not.exist'); + cy.get('.text-3xl', { timeout: 1000000 }).should('contain.text', 'User'); + }); +}); + +describe('hide dialog', () => { + beforeEach(() => { + cy.setupAuth(); + window.localStorage.removeItem('hideFirstTimeLoginDialog'); + cy.disableCookieBanner(); + }); + beforeEach(() => { + // needed for initial getUser request + cy.intercept('GET', 'appuio-api/apis/appuio.io/v1/users/mig', { + body: createUser({ username: 'mig' }), + }); + }); + + it('do not show dialog again', () => { + cy.setPermission({ verb: 'list', ...OrganizationPermissions }); + setOrganization(cy, organizationNxt); + cy.intercept('GET', 'appuio-api/apis/appuio.io/v1/namespaces/nxt/organizationmembers/members', { body: createOrganizationMembers({ - namespace: 'vshn', - userRefs: [{ name: 'tobru' }, { name: 'corvus' }], + namespace: 'nxt', + userRefs: [{ name: 'mig' }, { name: 'miw' }], }), }); + cy.visit('/'); + + cy.get('.p-dialog-header').should('contain.text', 'Welcome to the APPUiO Cloud Portal'); cy.get('#joinOrganizationDialogButton').should('not.exist'); - cy.get('#addOrganizationDialogButton').should('not.exist'); - cy.get('#setDefaultOrganizationDialogButton').should('exist'); + + cy.get('label[for=hideFirstTimeLoginDialogCheckbox]').click(); + cy.get('#setDefaultOrganizationDialogButton').click(); + + cy.get('.text-3xl').should('contain.text', 'User'); + + cy.get('app-navbar-item').contains('Organizations').click(); + cy.get('#organizations-title').should('contain.text', 'Organizations'); + cy.get('.p-dialog-header').should('not.exist'); + cy.getAllLocalStorage().then((result) => { + const expected = { + 'http://localhost:4200': { + hideFirstTimeLoginDialog: 'true', + }, + }; + console.debug('expected', expected); + console.debug('result', result); + expect(result).to.deep.eq(expected); + }); + }); +}); + +describe('skip dialog', () => { + beforeEach(() => { + cy.setupAuth(); + window.localStorage.removeItem('hideFirstTimeLoginDialog'); + cy.disableCookieBanner(); + }); + beforeEach(() => { + // needed for initial getUser request + cy.intercept('GET', 'appuio-api/apis/appuio.io/v1/users/mig', { + body: createUser({ username: 'mig', defaultOrganizationRef: 'nxt' }), + }); + }); + + it('do not show dialog if member of organization', () => { + cy.setPermission({ verb: 'list', ...OrganizationPermissions }); + setOrganization(cy, ...organizationListNxtVshn.items); + cy.visit('/'); + cy.wait('@organizationList'); + cy.get('.p-dialog-header').should('not.exist'); + }); + + it('do not show dialog when redeeming invitations', () => { + cy.setPermission( + { verb: 'list', ...OrganizationMembersPermissions }, + { verb: 'list', ...OrganizationPermissions }, + { verb: 'list', ...InvitationPermissions } + ); + setOrganization(cy); + cy.intercept('GET', 'appuio-api/apis/user.appuio.io/v1/invitations/uuid', { + body: createInvitation({}), + }); + cy.visit('/invitations/uuid'); + cy.get('#title').should('contain.text', 'Invitation'); + cy.get('.p-dialog-header').should('not.exist'); }); }); diff --git a/cypress/e2e/organizations.cy.ts b/cypress/e2e/organizations.cy.ts index b2fb9143..6b5b66d1 100644 --- a/cypress/e2e/organizations.cy.ts +++ b/cypress/e2e/organizations.cy.ts @@ -2,12 +2,14 @@ import { createUser } from '../fixtures/user'; import { organizationListNxtVshn, organizationListNxtVshnWithDisplayName, + organizationNxt, organizationVshn, setOrganization, } from '../fixtures/organization'; import { OrganizationPermissions } from '../../src/app/types/organization'; import { BillingEntityPermissions } from '../../src/app/types/billing-entity'; import { billingEntityNxt, billingEntityVshn, setBillingEntities } from '../fixtures/billingentities'; +import { OrganizationMembersPermissions } from '../../src/app/types/organization-members'; describe('Test organization list', () => { beforeEach(() => { @@ -16,6 +18,7 @@ describe('Test organization list', () => { cy.disableCookieBanner(); }); beforeEach(() => { + cy.setPermission({ verb: 'list', ...OrganizationPermissions }); // needed for initial getUser request cy.intercept('GET', 'appuio-api/apis/appuio.io/v1/users/mig', { body: createUser({ username: 'mig', defaultOrganizationRef: 'nxt' }), @@ -23,7 +26,6 @@ describe('Test organization list', () => { }); it('list with two entries', () => { - cy.setPermission({ verb: 'list', ...OrganizationPermissions }); cy.intercept('GET', 'appuio-api/apis/organization.appuio.io/v1/organizations', { body: organizationListNxtVshn, }); @@ -33,8 +35,7 @@ describe('Test organization list', () => { cy.get(':nth-child(3) > .flex-row > .text-3xl').should('contain.text', 'vshn'); }); - it('empty list', () => { - cy.setPermission({ verb: 'list', ...OrganizationPermissions }); + it('empty list without billing list permission', () => { setOrganization(cy); cy.visit('/organizations'); cy.wait('@organizationList'); @@ -42,19 +43,29 @@ describe('Test organization list', () => { cy.get('#no-organization-message').should('contain.text', 'No organizations available.'); }); + it('empty list with billing list permission', () => { + cy.setPermission( + { verb: 'list', ...OrganizationPermissions }, + { verb: 'create', ...OrganizationPermissions }, + { verb: 'list', ...BillingEntityPermissions } + ); + setOrganization(cy); + setBillingEntities(cy); + cy.visit('/organizations'); + cy.wait('@organizationList'); + cy.get('#title').should('contain.text', 'New Organization'); + cy.url().should('contain', '/organizations/$new'); + }); + it('request failed', () => { - cy.setPermission({ verb: 'list', ...OrganizationPermissions }); cy.intercept('GET', 'appuio-api/apis/organization.appuio.io/v1/organizations', { statusCode: 403, }); cy.visit('/organizations'); - cy.get('#organizations-title').should('contain.text', 'Organizations'); cy.get('#failure-message').should('contain.text', 'Organizations could not be loaded.'); }); it('failed requests are retried', () => { - cy.setPermission({ verb: 'list', ...OrganizationPermissions }); - let interceptCount = 0; cy.intercept('GET', 'appuio-api/apis/organization.appuio.io/v1/organizations', (req) => { if (interceptCount === 0) { @@ -66,18 +77,71 @@ describe('Test organization list', () => { }); cy.visit('/organizations'); - cy.get('#organizations-title').should('contain.text', 'Organizations'); cy.get(':nth-child(2) > .flex-row > .text-3xl').should('contain.text', 'nxt'); cy.get(':nth-child(3) > .flex-row > .text-3xl').should('contain.text', 'vshn'); }); +}); - it('no permission', () => { - cy.intercept('POST', 'appuio-api/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', { - body: { spec: { resourceAttributes: { resource: '', group: '', verb: '' } }, status: { allowed: false } }, +describe('Test limited permissions', () => { + beforeEach(() => { + cy.setupAuth(); + window.localStorage.setItem('hideFirstTimeLoginDialog', 'true'); + cy.disableCookieBanner(); + }); + beforeEach(() => { + // needed for initial getUser request + cy.intercept('GET', 'appuio-api/apis/appuio.io/v1/users/mig', { + body: createUser({ username: 'mig', defaultOrganizationRef: 'nxt' }), }); + }); + + it('no organization list permission', () => { + cy.setPermission(); cy.visit('/organizations'); cy.get('h1').should('contain.text', 'Welcome to the APPUiO Cloud Portal'); }); + + it('no organization create permission', () => { + cy.setPermission({ verb: 'list', ...OrganizationPermissions }); + setOrganization(cy); + setBillingEntities(cy); + + cy.visit('/organizations'); + cy.get('#organizations-title').should('contain.text', 'Organizations'); + cy.get('#no-organization-message').should('contain.text', 'No organizations available.'); + cy.get('#addOrganizationButton').should('not.exist'); + }); + + it('no organization edit permission', () => { + cy.setPermission({ verb: 'list', ...OrganizationPermissions }, { verb: 'list', ...BillingEntityPermissions }); + cy.intercept('GET', 'appuio-api/apis/organization.appuio.io/v1/organizations', { + body: organizationListNxtVshn, + }); + cy.visit('/organizations'); + cy.get('#organizations-title').should('contain.text', 'Organizations'); + cy.get(':nth-child(3) > .flex-row > .text-blue-500 > .ng-fa-icon').should('not.exist'); + }); + + it('should show empty list if no billing list permissions', () => { + cy.setPermission({ verb: 'list', ...OrganizationPermissions }); + setOrganization(cy); + + cy.visit('/organizations'); + cy.get('#no-organization-message').should('contain.text', 'No organizations available.'); + }); + + it('should hide billing link if no billing list permission', () => { + cy.setPermission( + { verb: 'list', ...OrganizationPermissions }, + { verb: 'update', ...OrganizationPermissions, namespace: organizationVshn.metadata.name } + ); + + setBillingEntities(cy); + setOrganization(cy, organizationVshn); + cy.visit('/organizations'); + cy.get('#organizations-title').should('contain.text', 'Organizations'); + cy.get(':nth-child(3) > .flex-row > .text-blue-500 > .ng-fa-icon').should('not.exist'); + }); }); describe('Test organization edit', () => { @@ -145,30 +209,6 @@ describe('Test organization edit', () => { 'VSHN - the DevOps Company' ); }); - - it('no edit permission', () => { - cy.setPermission({ verb: 'list', ...OrganizationPermissions }, { verb: 'list', ...BillingEntityPermissions }); - cy.intercept('GET', 'appuio-api/apis/organization.appuio.io/v1/organizations', { - body: organizationListNxtVshn, - }); - cy.visit('/organizations'); - cy.get('#organizations-title').should('contain.text', 'Organizations'); - cy.get(':nth-child(3) > .flex-row > .text-blue-500 > .ng-fa-icon').should('not.exist'); - }); - it('no billing list permission', () => { - cy.setPermission( - { verb: 'list', ...OrganizationPermissions }, - { verb: 'update', ...OrganizationPermissions, namespace: organizationVshn.metadata.name } - ); - - setBillingEntities(cy); - cy.intercept('GET', 'appuio-api/apis/organization.appuio.io/v1/organizations', { - body: organizationListNxtVshn, - }); - cy.visit('/organizations'); - cy.get('#organizations-title').should('contain.text', 'Organizations'); - cy.get(':nth-child(3) > .flex-row > .text-blue-500 > .ng-fa-icon').should('not.exist'); - }); }); describe('Test organization add', () => { @@ -188,10 +228,10 @@ describe('Test organization add', () => { { verb: 'create', ...OrganizationPermissions }, { verb: 'list', ...BillingEntityPermissions }, { verb: 'update', ...OrganizationPermissions, namespace: organizationVshn.metadata.name }, - { verb: 'list', resource: 'organizationmembers', group: 'appuio.io', namespace: organizationVshn.metadata.name } + { verb: 'list', ...OrganizationMembersPermissions, namespace: organizationVshn.metadata.name } ); - setOrganization(cy); + setOrganization(cy, organizationNxt); setBillingEntities(cy, billingEntityNxt, billingEntityVshn); cy.intercept('POST', 'appuio-api/apis/organization.appuio.io/v1/organizations', { @@ -200,7 +240,6 @@ describe('Test organization add', () => { }).as('add'); cy.visit('/organizations'); cy.get('#organizations-title').should('contain.text', 'Organizations'); - cy.get('#no-organization-message').should('contain.text', 'No organizations available.'); cy.get('#addOrganizationButton').click(); @@ -220,13 +259,13 @@ describe('Test organization add', () => { expect(body.spec.displayName).to.eq('VSHN - the DevOps Company'); expect(body.spec.billingEntityRef).to.eq('be-2347'); }); - cy.get(':nth-child(2) > .flex-row > .text-3xl').should('contain.text', 'vshn'); - cy.get(':nth-child(2) > .border-top-1 > .list-none > .flex > .text-900').should( + cy.get(':nth-child(3) > .flex-row > .text-3xl').should('contain.text', 'vshn'); + cy.get(':nth-child(3) > .border-top-1 > .list-none > .flex > .text-900').should( 'contain.text', 'VSHN - the DevOps Company' ); - cy.get(':nth-child(2) > .flex-row [title="Edit organization"]').should('exist'); - cy.get(':nth-child(2) > .flex-row [title="Edit members"]').should('exist'); + cy.get(':nth-child(3) > .flex-row [title="Edit organization"]').should('exist'); + cy.get(':nth-child(3) > .flex-row [title="Edit members"]').should('exist'); }); it('add organization with invalid id', () => { @@ -237,11 +276,8 @@ describe('Test organization add', () => { ); setOrganization(cy); setBillingEntities(cy, billingEntityNxt); - cy.visit('/organizations'); - cy.get('#organizations-title').should('contain.text', 'Organizations'); - cy.get('#no-organization-message').should('contain.text', 'No organizations available.'); + cy.visit('/organizations/$new'); - cy.get('#addOrganizationButton').click(); cy.get('#selectedBillingEntity').click().contains('Engineering').click(); cy.get('#displayName').type('VSHN - the DevOps Company'); @@ -258,11 +294,7 @@ describe('Test organization add', () => { ); setOrganization(cy); setBillingEntities(cy, billingEntityNxt); - cy.visit('/organizations'); - cy.get('#organizations-title').should('contain.text', 'Organizations'); - cy.get('#no-organization-message').should('contain.text', 'No organizations available.'); - - cy.get('#addOrganizationButton').click(); + cy.visit('/organizations/$new'); cy.get('#displayName').type('VSHN - the DevOps Company'); cy.get('#selectedBillingEntity').click().contains('Engineering').click(); @@ -299,24 +331,4 @@ describe('Test organization add', () => { cy.get('.p-error').should('be.visible').and('contain.text', 'organization ID'); cy.get('button[type=submit]').should('be.disabled'); }); - - it('no create permission', () => { - cy.setPermission({ verb: 'list', ...OrganizationPermissions }, { verb: 'list', ...BillingEntityPermissions }); - setOrganization(cy); - setBillingEntities(cy); - - cy.visit('/organizations'); - cy.get('#organizations-title').should('contain.text', 'Organizations'); - cy.get('#no-organization-message').should('contain.text', 'No organizations available.'); - cy.get('#addOrganizationButton').should('not.exist'); - }); - it('no list billing permission', () => { - cy.setPermission({ verb: 'list', ...OrganizationPermissions }, { verb: 'create', ...OrganizationPermissions }); - setOrganization(cy); - - cy.visit('/organizations'); - cy.get('#organizations-title').should('contain.text', 'Organizations'); - cy.get('#no-organization-message').should('contain.text', 'No organizations available.'); - cy.get('#addOrganizationButton').should('not.exist'); - }); }); diff --git a/src/app/billingentity/billingentity-form/billing-entity-form.component.ts b/src/app/billingentity/billingentity-form/billing-entity-form.component.ts index 6a90269b..f646660b 100644 --- a/src/app/billingentity/billingentity-form/billing-entity-form.component.ts +++ b/src/app/billingentity/billingentity-form/billing-entity-form.component.ts @@ -155,6 +155,14 @@ export class BillingEntityFormComponent implements OnInit { severity: 'success', summary: $localize`Successfully saved`, }); + const firstTime = this.activatedRoute.snapshot.queryParamMap.get('firstTime') === 'y'; + if (firstTime) { + void this.router.navigate(['organizations', '$new'], { + queryParams: { edit: undefined }, + queryParamsHandling: 'merge', + }); + return; + } // TODO: navigating to previous location with fallback might not work correctly. // But since the backend hasn't implemented creating/editing BE yet, it's hard to test. const previous = this.navigationService.previousRoute(`../${be.metadata.name}`); diff --git a/src/app/first-time-login-dialog/first-time-login-dialog.component.html b/src/app/first-time-login-dialog/first-time-login-dialog.component.html index 15cca4a5..005d2b5f 100644 --- a/src/app/first-time-login-dialog/first-time-login-dialog.component.html +++ b/src/app/first-time-login-dialog/first-time-login-dialog.component.html @@ -1,71 +1,101 @@ - -
- -
-

- Unfortunately, you do not yet belong to an organization. You can either join an existing organization or create - a new one. -

-

- You don't yet have a default organization set in your settings. Do you want to do that now? -

+ + +
+ +
+

+ You do not yet belong to an organization. + You can either join an existing organization or create a new one. + You can join an existing organization. + To create a new organization, first setup Billing. +

+

+ You don't have a default organization set in your settings. + Do you want to do that now? +

-

- Don't forget to have a look at the - documentation - of APPUiO Cloud. -

+

+ Don't forget to have a look at the + documentation + of APPUiO Cloud. +

- + +
-
- -
- + +
- + - -
-
- + + + + + + +
+
+ + + diff --git a/src/app/first-time-login-dialog/first-time-login-dialog.component.ts b/src/app/first-time-login-dialog/first-time-login-dialog.component.ts index 6fb332f0..db1d1bf3 100644 --- a/src/app/first-time-login-dialog/first-time-login-dialog.component.ts +++ b/src/app/first-time-login-dialog/first-time-login-dialog.component.ts @@ -1,14 +1,14 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; -import { Router } from '@angular/router'; -import { faAdd, faCog, faSitemap } from '@fortawesome/free-solid-svg-icons'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core'; +import { ActivatedRoute, ActivationStart, Router } from '@angular/router'; +import { faAdd, faCog, faDollarSign, faSitemap } from '@fortawesome/free-solid-svg-icons'; import { FormControl } from '@angular/forms'; import { IdentityService } from '../core/identity.service'; -import { Organization } from '../types/organization'; -import { combineLatestWith, forkJoin, Subscription } from 'rxjs'; -import { User } from '../types/user'; +import { filter, forkJoin, map, Observable, of, take } from 'rxjs'; import { OrganizationCollectionService } from '../store/organization-collection.service'; -import { OrganizationMembersCollectionService } from '../store/organizationmembers-collection.service'; import { UserCollectionService } from '../store/user-collection.service'; +import { BrowserStorageService } from '../shared/browser-storage.service'; +import { BillingEntityCollectionService } from '../store/billingentity-collection.service'; +import { switchMap } from 'rxjs/operators'; export const hideFirstTimeLoginDialogKey = 'hideFirstTimeLoginDialog'; @@ -18,106 +18,94 @@ export const hideFirstTimeLoginDialogKey = 'hideFirstTimeLoginDialog'; styleUrls: ['./first-time-login-dialog.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class FirstTimeLoginDialogComponent implements OnInit, OnDestroy { - showFirstLoginDialog = false; +export class FirstTimeLoginDialogComponent implements OnInit { + viewModel$?: Observable; + faSitemap = faSitemap; faAdd = faAdd; faCoq = faCog; + faDollarSign = faDollarSign; hideFirstTimeLoginDialogControl = new FormControl(false); - nextAction?: 'join' | 'add' | 'setDefault'; - userHasDefaultOrganization = true; - userBelongsToOrganization = true; - private subscriptions: Subscription[] = []; + nextAction?: 'join' | 'add'; constructor( private router: Router, + private route: ActivatedRoute, + private storageService: BrowserStorageService, private changeDetectorRef: ChangeDetectorRef, - private organizationMembersService: OrganizationMembersCollectionService, private organizationService: OrganizationCollectionService, + private billingService: BillingEntityCollectionService, private identityService: IdentityService, private userService: UserCollectionService ) {} ngOnInit(): void { - if (window.location.pathname.includes('/invitations/')) { + if (this.storageService.getLocalStorageItem(hideFirstTimeLoginDialogKey) === 'true') { return; } - if (window.localStorage.getItem(hideFirstTimeLoginDialogKey) !== 'true') { - this.subscriptions.push( - this.organizationService - .getAllMemoized() - .pipe(combineLatestWith(this.userService.currentUser$)) - .subscribe(([orgs, user]) => { - this.userHasDefaultOrganization = !!this.getDefaultOrganization(user); - this.showFirstLoginDialogIfNecessary(orgs); - }) - ); - } - } - - ngOnDestroy(): void { - this.subscriptions.forEach((s) => s.unsubscribe()); - } - private showFirstLoginDialogIfNecessary(organizationList: Organization[]): void { - this.userBelongsToOrganization = organizationList.length > 0; - if (!this.userBelongsToOrganization || !this.userHasDefaultOrganization) { - this.showFirstLoginDialog = true; - this.changeDetectorRef.markForCheck(); - return; - } - - const getOrganizationMembersRequests = organizationList.map((organization) => - this.organizationMembersService.getByKeyMemoized(`${organization.metadata.name}/members`) + this.viewModel$ = forkJoin([ + this.router.events.pipe( + filter((e) => e instanceof ActivationStart), + map((e) => { + return e as ActivationStart; + }), + take(2), + map((e) => e.snapshot.data) + ), + this.billingService.canCreateBilling(), + ]).pipe( + switchMap(([data, canCreateBilling]) => { + const hideDialogByRoute: boolean = data[hideFirstTimeLoginDialogKey]; + if (hideDialogByRoute) { + return of({ + showFirstLoginDialog: false, + } satisfies ViewModel); + } + const be$ = canCreateBilling && !hideDialogByRoute ? this.billingService.getAllMemoized() : of([]); + return forkJoin([ + be$, + this.organizationService.getAllMemoized(), + this.userService.currentUser$.pipe(take(1)), + ]).pipe( + map(([beList, orgs, user]) => { + const userHasDefaultOrganization = !!user?.spec.preferences?.defaultOrganizationRef; + const userSettingsExists = user.metadata.resourceVersion !== undefined; + const userBelongsToBilling = beList.length > 0; + const userBelongsToOrganization = orgs.length > 0; + return { + showFirstLoginDialog: !userBelongsToOrganization || !userHasDefaultOrganization, + userBelongsToOrganization, + showJoinOrganizationButton: !userBelongsToOrganization, + showSetDefaultOrganizationButton: + !userHasDefaultOrganization && userBelongsToOrganization && userSettingsExists, + showAddBillingButton: !userBelongsToBilling && canCreateBilling, + showAddOrganizationButton: !userBelongsToOrganization && userBelongsToBilling, + } satisfies ViewModel; + }) + ); + }) ); - - forkJoin(getOrganizationMembersRequests).subscribe((members) => { - const usernames = members - .map((organizationMembers) => (organizationMembers.spec.userRefs ?? []).map((userRef) => userRef.name)) - .flatMap((usernames) => usernames); - if (!usernames.includes(this.identityService.getUsername())) { - this.userBelongsToOrganization = false; - this.showFirstLoginDialog = true; - this.changeDetectorRef.markForCheck(); - } - }); } - getDefaultOrganization(user: User): string | undefined { - return user?.spec.preferences?.defaultOrganizationRef; - } - - addOrganization(): void { - this.showFirstLoginDialog = false; - this.nextAction = 'add'; - } - - joinOrganization(): void { - this.showFirstLoginDialog = false; - this.nextAction = 'join'; - } - - setDefaultOrganization(): void { - this.showFirstLoginDialog = false; - this.nextAction = 'setDefault'; + hideDialog(vm: ViewModel): void { + vm.showFirstLoginDialog = false; } onHide(): void { - this.firstTimeLoginDialogHide(); - if (this.nextAction === 'add') { - void this.router.navigate(['organizations/$new']); - } else if (this.nextAction === 'join') { - void this.router.navigate(['organizations'], { queryParams: { showJoinDialog: true } }); - } else if (this.nextAction === 'setDefault') { - void this.router.navigate(['user']); - } - } - - firstTimeLoginDialogHide(): void { if (this.hideFirstTimeLoginDialogControl.value) { - window.localStorage.setItem(hideFirstTimeLoginDialogKey, 'true'); + this.storageService.setLocalStorageItem(hideFirstTimeLoginDialogKey, 'true'); } else { - window.localStorage.removeItem(hideFirstTimeLoginDialogKey); + this.storageService.removeLocalStorageItem(hideFirstTimeLoginDialogKey); } } } + +interface ViewModel { + showFirstLoginDialog: boolean; + userBelongsToOrganization?: boolean; + showJoinOrganizationButton?: boolean; + showSetDefaultOrganizationButton?: boolean; + showAddBillingButton?: boolean; + showAddOrganizationButton?: boolean; +} diff --git a/src/app/invitations/invitations-routing.module.ts b/src/app/invitations/invitations-routing.module.ts index f53cb4a3..6f781856 100644 --- a/src/app/invitations/invitations-routing.module.ts +++ b/src/app/invitations/invitations-routing.module.ts @@ -5,6 +5,7 @@ import { InvitationsComponent } from './invitations.component'; import { InvitationPermissions } from '../types/invitation'; import { InvitationViewComponent } from './invitation-view/invitation-view.component'; import { InvitationEditComponent } from './invitation-edit/invitation-edit.component'; +import { hideFirstTimeLoginDialogKey } from '../first-time-login-dialog/first-time-login-dialog.component'; const routes: Routes = [ { @@ -26,6 +27,9 @@ const routes: Routes = [ { path: ':name', component: InvitationViewComponent, + data: { + [hideFirstTimeLoginDialogKey]: true, + }, }, ]; From d16e0c3ce5fd761897bdb366eaa405336cfcb69a Mon Sep 17 00:00:00 2001 From: ccremer Date: Thu, 13 Apr 2023 14:26:06 +0200 Subject: [PATCH 4/9] Handle case if default org doesn't exist At least it stops the Team list from loading forever --- src/app/app.component.ts | 6 ++++-- src/app/store/entity-filter.ts | 13 ++++++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index fd0e60b9..2ca94fbf 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -12,7 +12,7 @@ import { catchError, forkJoin } from 'rxjs'; import { OrganizationCollectionService } from './store/organization-collection.service'; import { SelfSubjectAccessReviewCollectionService } from './store/ssar-collection.service'; import { firstInList, metadataNameFilter } from './store/entity-filter'; -import { OrganizationPermissions } from './types/organization'; +import { Organization, OrganizationPermissions } from './types/organization'; import { BillingEntityPermissions } from './types/billing-entity'; import { UserCollectionService } from './store/user-collection.service'; import { ZonePermissions } from './types/zone'; @@ -56,7 +56,9 @@ export class AppComponent implements OnInit { .subscribe({ next: (user) => { if (user.spec.preferences?.defaultOrganizationRef) { - this.organizationService.setFilter(metadataNameFilter(user.spec.preferences?.defaultOrganizationRef ?? '')); + this.organizationService.setFilter( + metadataNameFilter(user.spec.preferences?.defaultOrganizationRef ?? '', firstInList()) + ); } if (!user.metadata.resourceVersion) { this.userService.upsertOneInCache(user); diff --git a/src/app/store/entity-filter.ts b/src/app/store/entity-filter.ts index 422dd310..fcd1dbdf 100644 --- a/src/app/store/entity-filter.ts +++ b/src/app/store/entity-filter.ts @@ -1,12 +1,19 @@ import { KubeObject } from '../types/entity'; -export function metadataNameFilter(metadataName: string): (entities: KubeObject[]) => KubeObject[] { +export function metadataNameFilter( + metadataName: string, + defaultFn?: (entities: T[]) => T[] +): (entities: T[]) => T[] { return function (entities) { - return entities.filter((entity) => entity.metadata.name === metadataName); + const entity = entities.find((e) => e.metadata.name === metadataName); + if (entity) { + return [entity]; + } + return defaultFn ? defaultFn(entities) : entities.filter((e) => e.metadata.name === metadataName); }; } -export function firstInList(): (entity: T[]) => T[] { +export function firstInList(): (entities: T[]) => T[] { return function (entities) { if (entities.length === 0) { return []; From e1b2a4978949029eb9fef0adf2ba353b1697d7fa Mon Sep 17 00:00:00 2001 From: ccremer Date: Thu, 13 Apr 2023 12:51:04 +0200 Subject: [PATCH 5/9] Fix redirect if empty list --- cypress/e2e/billingentities.cy.ts | 9 +++--- cypress/e2e/billingentity-form.cy.ts | 4 +-- cypress/e2e/organizations.cy.ts | 32 +++++++++++++++++++ .../billingentity/billing-entity.component.ts | 14 +++++++- .../first-time-login-dialog.component.ts | 4 +-- .../organization-form.component.ts | 11 ++++++- 6 files changed, 63 insertions(+), 11 deletions(-) diff --git a/cypress/e2e/billingentities.cy.ts b/cypress/e2e/billingentities.cy.ts index d4833f40..1e6b171d 100644 --- a/cypress/e2e/billingentities.cy.ts +++ b/cypress/e2e/billingentities.cy.ts @@ -25,12 +25,12 @@ describe('Test billing entity list', () => { cy.get(':nth-child(3) > .flex-row > .text-3xl').should('contain.text', 'be-2347'); }); - it('empty list', () => { + it('empty list should redirect to new', () => { setBillingEntities(cy); cy.visit('/billingentities'); - cy.get('#billingentities-title').should('contain.text', 'Billing'); - cy.get('#addButton').should('contain.text', 'Add new Billing'); - cy.get('#no-billingentity-message').should('contain.text', 'No billing entities available.'); + cy.get('#title').should('contain.text', 'New Billing'); + + cy.url().should('contain', 'billingentities/$new?edit=y'); }); it('request failed', () => { @@ -91,6 +91,7 @@ describe('no permissions', () => { cy.visit('/billingentities'); cy.get('h1').should('contain.text', 'Billing'); cy.get('addButton').should('not.exist'); + cy.get('#no-billingentity-message').should('contain.text', 'No billing entities available.'); }); it('no edit permission', () => { diff --git a/cypress/e2e/billingentity-form.cy.ts b/cypress/e2e/billingentity-form.cy.ts index 884508b8..d273c385 100644 --- a/cypress/e2e/billingentity-form.cy.ts +++ b/cypress/e2e/billingentity-form.cy.ts @@ -162,7 +162,7 @@ describe('Test billing entity form elements', () => { }); it('should cancel editing', () => { - setBillingEntities(cy); + setBillingEntities(cy, billingEntityNxt); // give at least 1 item to avoid redirect back to form. ['.p-button-secondary', 'a[appbacklink]'].forEach((cancelSelector) => { cy.visit('/billingentities/$new?edit=y'); @@ -171,7 +171,7 @@ describe('Test billing entity form elements', () => { cy.get(cancelSelector).click(); cy.get('app-billingentity-form').should('not.exist'); - cy.get('p-messages').should('contain.text', 'No billing entities available'); + cy.get('.text-3xl').should('have.length', 1); }); }); }); diff --git a/cypress/e2e/organizations.cy.ts b/cypress/e2e/organizations.cy.ts index 6b5b66d1..722f547f 100644 --- a/cypress/e2e/organizations.cy.ts +++ b/cypress/e2e/organizations.cy.ts @@ -10,6 +10,8 @@ import { OrganizationPermissions } from '../../src/app/types/organization'; import { BillingEntityPermissions } from '../../src/app/types/billing-entity'; import { billingEntityNxt, billingEntityVshn, setBillingEntities } from '../fixtures/billingentities'; import { OrganizationMembersPermissions } from '../../src/app/types/organization-members'; +import { ZonePermissions } from '../../src/app/types/zone'; +import { zoneCloudscale1 } from '../fixtures/zone'; describe('Test organization list', () => { beforeEach(() => { @@ -268,6 +270,36 @@ describe('Test organization add', () => { cy.get(':nth-child(3) > .flex-row [title="Edit members"]').should('exist'); }); + it('add organization for first-time', () => { + cy.setPermission( + { verb: 'list', ...OrganizationPermissions }, + { verb: 'create', ...OrganizationPermissions }, + { verb: 'list', ...BillingEntityPermissions }, + { verb: 'list', ...ZonePermissions }, + { verb: 'update', ...OrganizationPermissions, namespace: organizationVshn.metadata.name }, + { verb: 'list', ...OrganizationMembersPermissions, namespace: organizationVshn.metadata.name } + ); + + setOrganization(cy, organizationNxt); + setBillingEntities(cy, billingEntityNxt); + cy.intercept('GET', 'appuio-api/apis/appuio.io/v1/zones', { + body: { items: [zoneCloudscale1] }, + }); + + cy.intercept('POST', 'appuio-api/apis/organization.appuio.io/v1/organizations', { + body: organizationVshn, + }).as('add'); + cy.visit('/organizations/$new?firstTime=y'); + cy.get('#title').should('contain.text', 'New Organization'); + + cy.get('#id').type('nxt'); + cy.get('#selectedBillingEntity').click().contains('Engineering').click(); + cy.get('button[type=submit]').click(); + cy.wait('@add'); + cy.url().should('contain', '/zones'); + cy.get('#zones-title').should('contain.text', 'Zones'); + }); + it('add organization with invalid id', () => { cy.setPermission( { verb: 'list', ...OrganizationPermissions }, diff --git a/src/app/billingentity/billing-entity.component.ts b/src/app/billingentity/billing-entity.component.ts index 4000f0fa..8b9343b1 100644 --- a/src/app/billingentity/billing-entity.component.ts +++ b/src/app/billingentity/billing-entity.component.ts @@ -5,6 +5,7 @@ import { faAdd, faEdit, faInfo, faMagnifyingGlass, faUserGroup, faWarning } from import { combineLatestAll, forkJoin, from, map, Observable, of } from 'rxjs'; import { BillingEntityCollectionService } from '../store/billingentity-collection.service'; import { switchMap } from 'rxjs/operators'; +import { ActivatedRoute, Router } from '@angular/router'; @Component({ selector: 'app-billing-entity', @@ -21,7 +22,11 @@ export class BillingEntityComponent implements OnInit { payload$?: Observable; - constructor(public billingEntityService: BillingEntityCollectionService) {} + constructor( + public billingEntityService: BillingEntityCollectionService, + private router: Router, + private route: ActivatedRoute + ) {} ngOnInit(): void { this.payload$ = forkJoin([ @@ -30,6 +35,13 @@ export class BillingEntityComponent implements OnInit { ]).pipe( switchMap(([entities, canCreateBilling]) => { if (entities.length === 0) { + if (canCreateBilling) { + void this.router.navigate(['$new'], { + queryParams: { firstTime: undefined, edit: 'y' }, + queryParamsHandling: 'merge', + relativeTo: this.route, + }); + } // no billing entities return of({ canCreateBilling: canCreateBilling, diff --git a/src/app/first-time-login-dialog/first-time-login-dialog.component.ts b/src/app/first-time-login-dialog/first-time-login-dialog.component.ts index db1d1bf3..fea46813 100644 --- a/src/app/first-time-login-dialog/first-time-login-dialog.component.ts +++ b/src/app/first-time-login-dialog/first-time-login-dialog.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core'; import { ActivatedRoute, ActivationStart, Router } from '@angular/router'; -import { faAdd, faCog, faDollarSign, faSitemap } from '@fortawesome/free-solid-svg-icons'; +import { faAdd, faCog, faSitemap } from '@fortawesome/free-solid-svg-icons'; import { FormControl } from '@angular/forms'; import { IdentityService } from '../core/identity.service'; import { filter, forkJoin, map, Observable, of, take } from 'rxjs'; @@ -24,9 +24,7 @@ export class FirstTimeLoginDialogComponent implements OnInit { faSitemap = faSitemap; faAdd = faAdd; faCoq = faCog; - faDollarSign = faDollarSign; hideFirstTimeLoginDialogControl = new FormControl(false); - nextAction?: 'join' | 'add'; constructor( private router: Router, diff --git a/src/app/organizations/organization-form/organization-form.component.ts b/src/app/organizations/organization-form/organization-form.component.ts index b6409875..82117a82 100644 --- a/src/app/organizations/organization-form/organization-form.component.ts +++ b/src/app/organizations/organization-form/organization-form.component.ts @@ -134,7 +134,16 @@ export class OrganizationFormComponent implements OnInit, OnDestroy { severity: 'success', summary: $localize`Successfully saved`, }); - void this.router.navigate([this.navigationService.previousLocation()], { relativeTo: this.activatedRoute }); + const firstTime = this.activatedRoute.snapshot.queryParamMap.get('firstTime') === 'y'; + if (firstTime) { + void this.router.navigate(['zones'], { + queryParams: { edit: undefined, firstTime: undefined }, + queryParamsHandling: 'merge', + }); + return; + } + const previous = this.navigationService.previousRoute('..'); + void this.router.navigate([previous.path], { relativeTo: this.activatedRoute, queryParams: previous.queryParams }); } private saveOrUpdateFailure(err: Error): void { From 2c734f45bcab7e5ccbf3ef2fb539e3f737fdaa17 Mon Sep 17 00:00:00 2001 From: ccremer Date: Tue, 18 Apr 2023 10:31:47 +0200 Subject: [PATCH 6/9] Replace redirect to "new"-form with message --- cypress/e2e/billingentities.cy.ts | 10 ++--- cypress/e2e/organizations.cy.ts | 16 +------ src/app/app.module.ts | 2 + .../billing-entity.component.html | 43 +++++++++++++------ .../billingentity/billing-entity.component.ts | 29 ++++++------- .../join-dialog.component.html} | 4 +- .../join-dialog.component.scss} | 0 .../join-dialog.component.ts} | 12 +++--- src/app/join-dialog/join-dialog.service.ts | 18 ++++++++ .../organizations.component.html | 8 +++- .../organizations/organizations.component.ts | 25 ++--------- src/app/organizations/organizations.module.ts | 2 - 12 files changed, 88 insertions(+), 81 deletions(-) rename src/app/{organizations/join-organization-dialog/join-organization-dialog.component.html => join-dialog/join-dialog.component.html} (61%) rename src/app/{organizations/join-organization-dialog/join-organization-dialog.component.scss => join-dialog/join-dialog.component.scss} (100%) rename src/app/{organizations/join-organization-dialog/join-organization-dialog.component.ts => join-dialog/join-dialog.component.ts} (64%) create mode 100644 src/app/join-dialog/join-dialog.service.ts diff --git a/cypress/e2e/billingentities.cy.ts b/cypress/e2e/billingentities.cy.ts index 1e6b171d..0ad09c60 100644 --- a/cypress/e2e/billingentities.cy.ts +++ b/cypress/e2e/billingentities.cy.ts @@ -25,12 +25,12 @@ describe('Test billing entity list', () => { cy.get(':nth-child(3) > .flex-row > .text-3xl').should('contain.text', 'be-2347'); }); - it('empty list should redirect to new', () => { + it('empty list', () => { setBillingEntities(cy); cy.visit('/billingentities'); - cy.get('#title').should('contain.text', 'New Billing'); - - cy.url().should('contain', 'billingentities/$new?edit=y'); + cy.get('#billingentities-title').should('contain.text', 'Billing'); + cy.get('#addButton').should('contain.text', 'Add new Billing'); + cy.get('#no-billingentity-message').should('contain.text', 'No billing address available.'); }); it('request failed', () => { @@ -91,7 +91,7 @@ describe('no permissions', () => { cy.visit('/billingentities'); cy.get('h1').should('contain.text', 'Billing'); cy.get('addButton').should('not.exist'); - cy.get('#no-billingentity-message').should('contain.text', 'No billing entities available.'); + cy.get('#no-billingentity-message').should('contain.text', 'No billing address available.'); }); it('no edit permission', () => { diff --git a/cypress/e2e/organizations.cy.ts b/cypress/e2e/organizations.cy.ts index 722f547f..001291ff 100644 --- a/cypress/e2e/organizations.cy.ts +++ b/cypress/e2e/organizations.cy.ts @@ -37,7 +37,7 @@ describe('Test organization list', () => { cy.get(':nth-child(3) > .flex-row > .text-3xl').should('contain.text', 'vshn'); }); - it('empty list without billing list permission', () => { + it('empty list', () => { setOrganization(cy); cy.visit('/organizations'); cy.wait('@organizationList'); @@ -45,20 +45,6 @@ describe('Test organization list', () => { cy.get('#no-organization-message').should('contain.text', 'No organizations available.'); }); - it('empty list with billing list permission', () => { - cy.setPermission( - { verb: 'list', ...OrganizationPermissions }, - { verb: 'create', ...OrganizationPermissions }, - { verb: 'list', ...BillingEntityPermissions } - ); - setOrganization(cy); - setBillingEntities(cy); - cy.visit('/organizations'); - cy.wait('@organizationList'); - cy.get('#title').should('contain.text', 'New Organization'); - cy.url().should('contain', '/organizations/$new'); - }); - it('request failed', () => { cy.intercept('GET', 'appuio-api/apis/organization.appuio.io/v1/organizations', { statusCode: 403, diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 5faab6ca..ce08564a 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -40,6 +40,7 @@ import { SelfSubjectAccessReviewCollectionService } from './store/ssar-collectio import { NavigationService } from './shared/navigation.service'; import { invitationTokenLocalStorageKey } from './types/invitation'; import { BrowserStorageService } from './shared/browser-storage.service'; +import { JoinDialogComponent } from './join-dialog/join-dialog.component'; @NgModule({ declarations: [ @@ -52,6 +53,7 @@ import { BrowserStorageService } from './shared/browser-storage.service'; IdentityMenuComponent, InfoMenuComponent, InfoMenuItemComponent, + JoinDialogComponent, ], imports: [ BrowserModule, diff --git a/src/app/billingentity/billing-entity.component.html b/src/app/billingentity/billing-entity.component.html index d38e56db..c60f1ee7 100644 --- a/src/app/billingentity/billing-entity.component.html +++ b/src/app/billingentity/billing-entity.component.html @@ -3,18 +3,31 @@

Billing

-
- +
+
+ +
+
+ +
@@ -72,7 +85,11 @@

Billing

-
No billing entities available.
+
+ No billing address available. + Please create one first or ask to join an existing billing address. + Please ask to join an existing billing address. +
diff --git a/src/app/billingentity/billing-entity.component.ts b/src/app/billingentity/billing-entity.component.ts index 8b9343b1..8d47d531 100644 --- a/src/app/billingentity/billing-entity.component.ts +++ b/src/app/billingentity/billing-entity.component.ts @@ -1,11 +1,19 @@ import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { BillingEntity } from '../types/billing-entity'; -import { faAdd, faEdit, faInfo, faMagnifyingGlass, faUserGroup, faWarning } from '@fortawesome/free-solid-svg-icons'; +import { + faAdd, + faDollarSign, + faEdit, + faInfo, + faMagnifyingGlass, + faUserGroup, + faWarning, +} from '@fortawesome/free-solid-svg-icons'; import { combineLatestAll, forkJoin, from, map, Observable, of } from 'rxjs'; import { BillingEntityCollectionService } from '../store/billingentity-collection.service'; import { switchMap } from 'rxjs/operators'; -import { ActivatedRoute, Router } from '@angular/router'; +import { JoinDialogService } from '../join-dialog/join-dialog.service'; @Component({ selector: 'app-billing-entity', @@ -19,13 +27,14 @@ export class BillingEntityComponent implements OnInit { faEdit = faEdit; faUserGroup = faUserGroup; faDetails = faMagnifyingGlass; + faAdd = faAdd; + faDollarSign = faDollarSign; payload$?: Observable; constructor( public billingEntityService: BillingEntityCollectionService, - private router: Router, - private route: ActivatedRoute + public joinDialogService: JoinDialogService ) {} ngOnInit(): void { @@ -35,16 +44,8 @@ export class BillingEntityComponent implements OnInit { ]).pipe( switchMap(([entities, canCreateBilling]) => { if (entities.length === 0) { - if (canCreateBilling) { - void this.router.navigate(['$new'], { - queryParams: { firstTime: undefined, edit: 'y' }, - queryParamsHandling: 'merge', - relativeTo: this.route, - }); - } - // no billing entities return of({ - canCreateBilling: canCreateBilling, + canCreateBilling, billingModels: [], } satisfies ViewModel); } @@ -80,8 +81,6 @@ export class BillingEntityComponent implements OnInit { }) ); } - - protected readonly faAdd = faAdd; } interface ViewModel { diff --git a/src/app/organizations/join-organization-dialog/join-organization-dialog.component.html b/src/app/join-dialog/join-dialog.component.html similarity index 61% rename from src/app/organizations/join-organization-dialog/join-organization-dialog.component.html rename to src/app/join-dialog/join-dialog.component.html index 9f2f474e..e284381e 100644 --- a/src/app/organizations/join-organization-dialog/join-organization-dialog.component.html +++ b/src/app/join-dialog/join-dialog.component.html @@ -1,7 +1,7 @@

- To join an organization, you must provide your username + To join an organization or billing address, you must provide your username {{ username }} - to the administrator of the organization. + to the administrator of the organization or billing address.

diff --git a/src/app/organizations/join-organization-dialog/join-organization-dialog.component.scss b/src/app/join-dialog/join-dialog.component.scss similarity index 100% rename from src/app/organizations/join-organization-dialog/join-organization-dialog.component.scss rename to src/app/join-dialog/join-dialog.component.scss diff --git a/src/app/organizations/join-organization-dialog/join-organization-dialog.component.ts b/src/app/join-dialog/join-dialog.component.ts similarity index 64% rename from src/app/organizations/join-organization-dialog/join-organization-dialog.component.ts rename to src/app/join-dialog/join-dialog.component.ts index 319c7491..f8bd225c 100644 --- a/src/app/organizations/join-organization-dialog/join-organization-dialog.component.ts +++ b/src/app/join-dialog/join-dialog.component.ts @@ -1,14 +1,14 @@ import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { faPaperPlane } from '@fortawesome/free-solid-svg-icons'; -import { IdentityService } from '../../core/identity.service'; +import { IdentityService } from '../core/identity.service'; @Component({ - selector: 'app-join-organization-dialog', - templateUrl: './join-organization-dialog.component.html', - styleUrls: ['./join-organization-dialog.component.scss'], + selector: 'app-join-dialog', + templateUrl: './join-dialog.component.html', + styleUrls: ['./join-dialog.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class JoinOrganizationDialogComponent implements OnInit { +export class JoinDialogComponent implements OnInit { username = ''; mailto = ''; faPaperPlane = faPaperPlane; @@ -19,7 +19,7 @@ export class JoinOrganizationDialogComponent implements OnInit { this.username = this.identityService.getUsername(); const name = this.identityService.getName(); const body = encodeURI( - $localize`Hi\n\nI would like to join the APPUiO Cloud organization. My username on APPUiO Cloud is "${this.username}".\n\nBest wishes\n${name}` + $localize`Hi\n\nI would like to join the APPUiO Cloud organization or billing address. My username on APPUiO Cloud is "${this.username}".\n\nBest wishes\n${name}` ); const subject = encodeURI($localize`Join Organization`); this.mailto = `mailto:?subject=${subject}&body=${body}`; diff --git a/src/app/join-dialog/join-dialog.service.ts b/src/app/join-dialog/join-dialog.service.ts new file mode 100644 index 00000000..5373eb0d --- /dev/null +++ b/src/app/join-dialog/join-dialog.service.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@angular/core'; +import { JoinDialogComponent } from './join-dialog.component'; +import { DialogService } from 'primeng/dynamicdialog'; + +@Injectable({ + providedIn: 'root', +}) +export class JoinDialogService { + constructor(private dialogService: DialogService) {} + + showDialog(): void { + this.dialogService.open(JoinDialogComponent, { + modal: true, + closable: true, + header: $localize`Join Organization or Billing address`, + }); + } +} diff --git a/src/app/organizations/organizations.component.html b/src/app/organizations/organizations.component.html index 6cb2a450..b377b8dd 100644 --- a/src/app/organizations/organizations.component.html +++ b/src/app/organizations/organizations.component.html @@ -7,7 +7,7 @@

Organizations