From f963ea9f8635741eff38213bca402d269ec30122 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Thu, 28 Aug 2025 11:23:14 +0200 Subject: [PATCH] feat(console): rehauled navigation for instances including breadcrumb (#10283) This PR significantly improves user navigation by introducing a new instance-level navigation bar. This new bar, positioned above the existing organization navigation, provides quick access to key sections: Home, Organizations, Actions, and Settings. Additionally, the breadcrumb component has been refined for more consistent behavior, reintroducing intuitive breadcrumb buttons to easily navigate up the hierarchy. These changes also include design improvements for a cleaner and more streamlined appearance across the interface. Screenshot 2025-07-17 at 14 55 46 Screenshot 2025-07-17 at 14 56 41 Screenshot 2025-07-17 at 14 56 10 Screenshot 2025-07-17 at 14 56 20 --------- Co-authored-by: conblem Co-authored-by: Stefan Benz <46600784+stebenz@users.noreply.github.com> Co-authored-by: Florian Forster --- console/README.md | 10 +- console/package.json | 5 +- console/src/app/app-routing.module.ts | 17 +- console/src/app/app.component.html | 8 +- console/src/app/app.component.ts | 59 ++- console/src/app/app.module.ts | 14 + .../components/features/features.component.ts | 2 +- .../accounts-card/accounts-card.component.ts | 12 +- .../actions-two-actions.component.html | 4 +- ...actions-two-add-action-target.component.ts | 1 - .../actions-two-targets.component.html | 4 +- .../app/modules/changes/changes.component.ts | 24 +- .../app/modules/header/header.component.html | 162 +----- .../app/modules/header/header.component.ts | 19 +- .../src/app/modules/header/header.module.ts | 2 + .../modules/info-row/info-row.component.html | 4 +- .../modules/info-row/info-row.component.ts | 17 +- .../members-table.component.html | 22 +- .../members-table/members-table.component.ts | 44 +- .../memberships-table.component.ts | 67 ++- .../src/app/modules/nav/nav.component.html | 62 ++- .../src/app/modules/nav/nav.component.scss | 52 +- console/src/app/modules/nav/nav.component.ts | 5 +- .../header-button.component.html | 4 + .../header-button.component.scss | 45 ++ .../header-button/header-button.component.ts | 17 + .../header-dropdown.component.html | 15 + .../header-dropdown.component.scss | 46 ++ .../header-dropdown.component.ts | 108 ++++ .../instance-selector.component.html | 26 + .../instance-selector.component.scss | 40 ++ .../instance-selector.component.ts | 37 ++ .../new-header/new-header.component.html | 82 +++ .../new-header/new-header.component.scss | 35 ++ .../new-header/new-header.component.ts | 114 +++++ .../organization-selector.component.html | 48 ++ .../organization-selector.component.scss | 83 ++++ .../organization-selector.component.ts | 268 ++++++++++ .../org-context/org-context.component.ts | 5 +- .../org-table/org-table.component.html | 33 +- .../modules/org-table/org-table.component.ts | 236 ++++----- .../app/modules/org-table/org-table.module.ts | 2 + .../domain-policy/domain-policy.component.ts | 20 +- .../private-labeling-policy.component.ts | 14 +- .../provider-saml-sp.component.ts | 18 +- .../app/modules/settings-grid/settinglinks.ts | 60 --- .../settings-grid.component.html | 55 --- .../settings-grid.component.scss | 156 ------ .../settings-grid.component.spec.ts | 24 - .../settings-grid/settings-grid.component.ts | 49 -- .../settings-grid/settings-grid.module.ts | 29 -- .../settings-list.component.html | 14 +- .../src/app/modules/settings-list/settings.ts | 29 -- .../modules/shortcuts/shortcuts.component.ts | 120 +++-- .../user-grants/user-grants.component.html | 2 +- .../user-grants/user-grants.component.ts | 50 +- .../app/pages/actions/actions.component.html | 118 +---- .../app/pages/actions/actions.component.scss | 19 +- .../pages/actions/actions.component.spec.ts | 20 +- .../app/pages/actions/actions.component.ts | 180 +------ .../src/app/pages/actions/actions.module.ts | 63 +-- .../src/app/pages/home/home.component.html | 8 +- .../src/app/pages/home/home.component.scss | 54 +- console/src/app/pages/home/home.component.ts | 26 +- .../pages/instance/instance.component.html | 14 - .../pages/instance/instance.component.scss | 8 - .../app/pages/instance/instance.component.ts | 8 - .../src/app/pages/instance/instance.module.ts | 2 - .../action-table/action-table.component.html | 0 .../action-table/action-table.component.scss | 0 .../action-table.component.spec.ts | 0 .../action-table/action-table.component.ts | 0 .../org-actions/actions-routing.module.ts | 17 + .../pages/org-actions/actions.component.html | 107 ++++ .../pages/org-actions/actions.component.scss | 186 +++++++ .../org-actions/actions.component.spec.ts | 24 + .../pages/org-actions/actions.component.ts | 180 +++++++ .../app/pages/org-actions/actions.module.ts | 68 +++ .../add-action-dialog.component.html | 0 .../add-action-dialog.component.scss | 0 .../add-action-dialog.component.spec.ts | 0 .../add-action-dialog.component.ts | 0 .../add-flow-dialog.component.html | 0 .../add-flow-dialog.component.scss | 0 .../add-flow-dialog.component.spec.ts | 0 .../add-flow-dialog.component.ts | 0 .../pages/org-create/org-create.component.ts | 113 +++-- .../pages/org-list/org-list.component.html | 2 +- .../orgs/org-detail/org-detail.component.html | 126 ++--- .../orgs/org-detail/org-detail.component.ts | 239 ++++----- console/src/app/pages/orgs/org.module.ts | 2 - .../project-grid/project-grid.component.ts | 8 +- .../user-grant-create.component.html | 1 + .../user-grant-create.component.ts | 29 +- .../user-create-v2.component.ts | 8 +- .../auth-user-detail.component.html | 32 +- .../auth-user-detail.component.ts | 156 +++--- .../password/password.component.ts | 20 +- .../user-detail/user-detail.component.ts | 1 - .../user-table/user-table.component.html | 465 +++++++++--------- .../user-table/user-table.component.ts | 100 ++-- .../app/pipes/has-role-pipe/has-role.pipe.ts | 32 +- console/src/app/services/admin.service.ts | 35 -- .../app/services/authentication.service.ts | 10 +- console/src/app/services/grpc-auth.service.ts | 121 +---- console/src/app/services/grpc.service.ts | 17 +- .../services/interceptors/org.interceptor.ts | 9 +- console/src/app/services/new-admin.service.ts | 44 ++ console/src/app/services/new-auth.service.ts | 24 +- console/src/app/services/new-mgmt.service.ts | 26 + .../app/services/new-organization.service.ts | 212 ++++++++ console/src/app/services/storage.service.ts | 2 +- console/src/app/services/user.service.ts | 142 +++--- console/src/assets/i18n/en.json | 3 +- console/src/component-themes.scss | 12 +- console/tsconfig.json | 7 +- pnpm-lock.yaml | 46 ++ 117 files changed, 3286 insertions(+), 2360 deletions(-) create mode 100644 console/src/app/modules/new-header/header-button/header-button.component.html create mode 100644 console/src/app/modules/new-header/header-button/header-button.component.scss create mode 100644 console/src/app/modules/new-header/header-button/header-button.component.ts create mode 100644 console/src/app/modules/new-header/header-dropdown/header-dropdown.component.html create mode 100644 console/src/app/modules/new-header/header-dropdown/header-dropdown.component.scss create mode 100644 console/src/app/modules/new-header/header-dropdown/header-dropdown.component.ts create mode 100644 console/src/app/modules/new-header/instance-selector/instance-selector.component.html create mode 100644 console/src/app/modules/new-header/instance-selector/instance-selector.component.scss create mode 100644 console/src/app/modules/new-header/instance-selector/instance-selector.component.ts create mode 100644 console/src/app/modules/new-header/new-header.component.html create mode 100644 console/src/app/modules/new-header/new-header.component.scss create mode 100644 console/src/app/modules/new-header/new-header.component.ts create mode 100644 console/src/app/modules/new-header/organization-selector/organization-selector.component.html create mode 100644 console/src/app/modules/new-header/organization-selector/organization-selector.component.scss create mode 100644 console/src/app/modules/new-header/organization-selector/organization-selector.component.ts delete mode 100644 console/src/app/modules/settings-grid/settinglinks.ts delete mode 100644 console/src/app/modules/settings-grid/settings-grid.component.html delete mode 100644 console/src/app/modules/settings-grid/settings-grid.component.scss delete mode 100644 console/src/app/modules/settings-grid/settings-grid.component.spec.ts delete mode 100644 console/src/app/modules/settings-grid/settings-grid.component.ts delete mode 100644 console/src/app/modules/settings-grid/settings-grid.module.ts rename console/src/app/pages/{actions => org-actions}/action-table/action-table.component.html (100%) rename console/src/app/pages/{actions => org-actions}/action-table/action-table.component.scss (100%) rename console/src/app/pages/{actions => org-actions}/action-table/action-table.component.spec.ts (100%) rename console/src/app/pages/{actions => org-actions}/action-table/action-table.component.ts (100%) create mode 100644 console/src/app/pages/org-actions/actions-routing.module.ts create mode 100644 console/src/app/pages/org-actions/actions.component.html create mode 100644 console/src/app/pages/org-actions/actions.component.scss create mode 100644 console/src/app/pages/org-actions/actions.component.spec.ts create mode 100644 console/src/app/pages/org-actions/actions.component.ts create mode 100644 console/src/app/pages/org-actions/actions.module.ts rename console/src/app/pages/{actions => org-actions}/add-action-dialog/add-action-dialog.component.html (100%) rename console/src/app/pages/{actions => org-actions}/add-action-dialog/add-action-dialog.component.scss (100%) rename console/src/app/pages/{actions => org-actions}/add-action-dialog/add-action-dialog.component.spec.ts (100%) rename console/src/app/pages/{actions => org-actions}/add-action-dialog/add-action-dialog.component.ts (100%) rename console/src/app/pages/{actions => org-actions}/add-flow-dialog/add-flow-dialog.component.html (100%) rename console/src/app/pages/{actions => org-actions}/add-flow-dialog/add-flow-dialog.component.scss (100%) rename console/src/app/pages/{actions => org-actions}/add-flow-dialog/add-flow-dialog.component.spec.ts (100%) rename console/src/app/pages/{actions => org-actions}/add-flow-dialog/add-flow-dialog.component.ts (100%) create mode 100644 console/src/app/services/new-admin.service.ts create mode 100644 console/src/app/services/new-organization.service.ts diff --git a/console/README.md b/console/README.md index 2d958aa48c3..9e5844d88df 100644 --- a/console/README.md +++ b/console/README.md @@ -39,7 +39,7 @@ Generated files: To generate proto files: ```bash -pnpm run generate +pnpm turbo generate --filter=./console ``` This automatically runs both generations in the correct order via Turbo dependencies. @@ -49,7 +49,7 @@ This automatically runs both generations in the correct order via Turbo dependen To start the development server: ```bash -pnpm start +pnpm turbo start --filter=./console ``` This will: @@ -62,7 +62,7 @@ This will: To build for production: ```bash -pnpm run build +pnpm turbo build --filter=./console ``` This will: @@ -75,13 +75,13 @@ This will: To run linting and formatting checks: ```bash -pnpm run lint +pnpm turbo lint --filter=./console ``` To auto-fix formatting issues: ```bash -pnpm run lint:fix +pnpm turbo lint:fix --filter=./console ``` ## Project Structure diff --git a/console/package.json b/console/package.json index 2a1b42eb93e..757eb09abe6 100644 --- a/console/package.json +++ b/console/package.json @@ -34,7 +34,10 @@ "@fortawesome/angular-fontawesome": "^0.13.0", "@fortawesome/fontawesome-svg-core": "^6.7.2", "@fortawesome/free-brands-svg-icons": "^6.7.2", + "@ng-icons/core": "^25.0.0", + "@ng-icons/heroicons": "^25.0.0", "@ngx-translate/core": "^15.0.0", + "@tanstack/angular-query-experimental": "^5.85.5", "@zitadel/client": "workspace:*", "@zitadel/proto": "workspace:*", "angular-oauth2-oidc": "^15.0.1", @@ -94,4 +97,4 @@ "prettier-plugin-organize-imports": "^4.1.0", "typescript": "5.1" } -} \ No newline at end of file +} diff --git a/console/src/app/app-routing.module.ts b/console/src/app/app-routing.module.ts index 582f65d8af9..9366305aee7 100644 --- a/console/src/app/app-routing.module.ts +++ b/console/src/app/app-routing.module.ts @@ -10,7 +10,7 @@ const routes: Routes = [ { path: '', loadChildren: () => import('./pages/home/home.module'), - canActivate: [authGuard, roleGuard], + canActivate: [authGuard], data: { roles: ['.'], }, @@ -31,7 +31,10 @@ const routes: Routes = [ { path: 'orgs', loadChildren: () => import('./pages/org-list/org-list.module'), - canActivate: [authGuard], + canActivate: [authGuard, roleGuard], + data: { + roles: ['org.read'], + }, }, { path: 'granted-projects', @@ -75,7 +78,15 @@ const routes: Routes = [ loadChildren: () => import('./pages/actions/actions.module'), canActivate: [authGuard, roleGuard], data: { - roles: ['org.action.read', 'org.flow.read'], + roles: ['iam.read', 'iam.read'], + }, + }, + { + path: 'actions-v1', + loadChildren: () => import('./pages/org-actions/actions.module'), + canActivate: [authGuard, roleGuard], + data: { + roles: ['iam.read', 'iam.read'], }, }, { diff --git a/console/src/app/app.component.html b/console/src/app/app.component.html index 9907e233e18..0d8831c355f 100644 --- a/console/src/app/app.component.html +++ b/console/src/app/app.component.html @@ -1,17 +1,17 @@
+ (changedActiveOrg)="changedOrg()" + /> diff --git a/console/src/app/app.component.ts b/console/src/app/app.component.ts index bd46e30cee9..c6d4f4ebef5 100644 --- a/console/src/app/app.component.ts +++ b/console/src/app/app.component.ts @@ -1,14 +1,14 @@ import { BreakpointObserver } from '@angular/cdk/layout'; import { OverlayContainer } from '@angular/cdk/overlay'; import { DOCUMENT, ViewportScroller } from '@angular/common'; -import { Component, DestroyRef, HostBinding, HostListener, Inject, OnDestroy, ViewChild } from '@angular/core'; +import { Component, DestroyRef, effect, HostBinding, HostListener, Inject, OnDestroy, ViewChild } from '@angular/core'; import { MatIconRegistry } from '@angular/material/icon'; import { MatDrawer } from '@angular/material/sidenav'; import { DomSanitizer } from '@angular/platform-browser'; import { ActivatedRoute, Router, RouterOutlet } from '@angular/router'; import { LangChangeEvent, TranslateService } from '@ngx-translate/core'; -import { Observable, of, Subject, switchMap } from 'rxjs'; -import { filter, map, startWith, takeUntil, tap } from 'rxjs/operators'; +import { Observable, of } from 'rxjs'; +import { filter, map, startWith } from 'rxjs/operators'; import { accountCard, adminLineAnimation, navAnimations, routeAnimations, toolbarAnimation } from './animations'; import { Org } from './proto/generated/zitadel/org_pb'; @@ -22,6 +22,8 @@ import { UpdateService } from './services/update.service'; import { fallbackLanguage, supportedLanguages, supportedLanguagesRegexp } from './utils/language'; import { PosthogService } from './services/posthog.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { NewOrganizationService } from './services/new-organization.service'; +import { NewAuthService } from './services/new-auth.service'; @Component({ selector: 'cnsl-root', @@ -42,12 +44,14 @@ export class AppComponent { @HostListener('window:scroll', ['$event']) onScroll(event: Event): void { this.yoffset = this.viewPortScroller.getScrollPosition()[1]; } - public org!: Org.AsObject; public orgs$: Observable = of([]); public showAccount: boolean = false; public isDarkTheme: Observable = of(true); public showProjectSection: boolean = false; + public activeOrganizationQuery = this.newOrganizationService.activeOrganizationQuery(); + + private listMyZitadelPermissionsQuery = this.newAuthService.listMyZitadelPermissionsQuery(); public language: string = 'en'; public privacyPolicy!: PrivacyPolicy.AsObject; @@ -70,6 +74,8 @@ export class AppComponent { @Inject(DOCUMENT) private document: Document, private posthog: PosthogService, private readonly destroyRef: DestroyRef, + private readonly newOrganizationService: NewOrganizationService, + private readonly newAuthService: NewAuthService, ) { console.log( '%cWait!', @@ -199,9 +205,9 @@ export class AppComponent { this.getProjectCount(); - this.authService.activeOrgChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((org) => { - if (org) { - this.org = org; + effect(() => { + const orgId = this.newOrganizationService.orgId(); + if (orgId) { this.getProjectCount(); } }); @@ -212,21 +218,28 @@ export class AppComponent { filter(Boolean), takeUntilDestroyed(this.destroyRef), ) - .subscribe((org) => this.authService.getActiveOrg(org)); + .subscribe((orgId) => this.newOrganizationService.setOrgId(orgId)); - this.authenticationService.authenticationChanged - .pipe( - filter(Boolean), - switchMap(() => this.authService.getActiveOrg()), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe({ - next: (org) => (this.org = org), - error: async (err) => { - console.error(err); - return this.router.navigate(['/users/me']); - }, - }); + effect(() => { + const permissions = this.listMyZitadelPermissionsQuery.data(); + const error = this.listMyZitadelPermissionsQuery.error(); + + if (!permissions && !error) { + // not loaded yet + return; + } + + // if we have an error this is gonna be false anyway as permissions will be undefined + if (permissions?.includes('org.read')) { + return; + } + + if (error) { + console.error(error); + } + + this.router.navigate(['/users/me']).then(); + }); this.isDarkTheme = this.themeService.isDarkTheme; this.isDarkTheme.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((dark) => { @@ -237,7 +250,6 @@ export class AppComponent { this.translate.onLangChange.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((language: LangChangeEvent) => { this.document.documentElement.lang = language.lang; - this.language = language.lang; }); } @@ -266,7 +278,7 @@ export class AppComponent { this.componentCssClass = theme; } - public changedOrg(org: Org.AsObject): void { + public changedOrg(): void { // Reference: https://stackoverflow.com/a/58114797 const currentUrl = this.router.url; this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => { @@ -287,7 +299,6 @@ export class AppComponent { ? userprofile.human.profile?.preferredLanguage : fallbackLang; this.translate.use(lang); - this.language = lang; this.document.documentElement.lang = lang; }); } diff --git a/console/src/app/app.module.ts b/console/src/app/app.module.ts index d344ae288f1..6ea87a6a43f 100644 --- a/console/src/app/app.module.ts +++ b/console/src/app/app.module.ts @@ -74,6 +74,10 @@ import { ThemeService } from './services/theme.service'; import { ToastService } from './services/toast.service'; import { LanguagesService } from './services/languages.service'; import { PosthogService } from './services/posthog.service'; +import { NewHeaderComponent } from './modules/new-header/new-header.component'; +import { provideTanStackQuery, QueryClient, withDevtools } from '@tanstack/angular-query-experimental'; +import { CdkOverlayOrigin } from '@angular/cdk/overlay'; +import { provideNgIconsConfig } from '@ng-icons/core'; registerLocaleData(localeDe); i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/de.json')); @@ -171,6 +175,8 @@ const authConfig: AuthConfig = { MatDialogModule, KeyboardShortcutsModule, ServiceWorkerModule.register('ngsw-worker.js', { enabled: false }), + NewHeaderComponent, + CdkOverlayOrigin, ], providers: [ ThemeService, @@ -245,8 +251,16 @@ const authConfig: AuthConfig = { LanguagesService, PosthogService, { provide: 'windowObject', useValue: window }, + provideTanStackQuery( + new QueryClient(), + withDevtools(() => ({ loadDevtools: 'auto' })), + ), + provideNgIconsConfig({ + size: '1rem', + }), ], bootstrap: [AppComponent], + exports: [], }) export class AppModule { constructor() {} diff --git a/console/src/app/components/features/features.component.ts b/console/src/app/components/features/features.component.ts index b6624feefae..785b88a4792 100644 --- a/console/src/app/components/features/features.component.ts +++ b/console/src/app/components/features/features.component.ts @@ -21,7 +21,7 @@ import { } from '@zitadel/proto/zitadel/feature/v2/instance_pb'; import { Source } from '@zitadel/proto/zitadel/feature/v2/feature_pb'; import { MessageInitShape } from '@bufbuild/protobuf'; -import { firstValueFrom, Observable, ReplaySubject, shareReplay, switchMap } from 'rxjs'; +import { Observable, ReplaySubject, shareReplay, switchMap } from 'rxjs'; import { filter, map, startWith } from 'rxjs/operators'; import { LoginV2FeatureToggleComponent } from '../feature-toggle/login-v2-feature-toggle/login-v2-feature-toggle.component'; diff --git a/console/src/app/modules/accounts-card/accounts-card.component.ts b/console/src/app/modules/accounts-card/accounts-card.component.ts index 273af86467d..fc46d46b836 100644 --- a/console/src/app/modules/accounts-card/accounts-card.component.ts +++ b/console/src/app/modules/accounts-card/accounts-card.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Input, NgIterable, Output } from '@angular/core'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; import { Router } from '@angular/router'; import { AuthConfig } from 'angular-oauth2-oidc'; import { SessionState as V1SessionState, User, UserState } from 'src/app/proto/generated/zitadel/user_pb'; @@ -146,22 +146,22 @@ export class AccountsCardComponent { this.closedCard.emit(); } - public selectAccount(loginHint: string): void { + public async selectAccount(loginHint: string): Promise { const configWithPrompt: Partial = { customQueryParams: { login_hint: loginHint, }, }; - this.authService.authenticate(configWithPrompt).then(); + await this.authService.authenticate(configWithPrompt); } - public selectNewAccount(): void { + public async selectNewAccount(): Promise { const configWithPrompt: Partial = { customQueryParams: { prompt: 'login', - } as any, + }, }; - this.authService.authenticate(configWithPrompt).then(); + await this.authService.authenticate(configWithPrompt); } public logout(): void { diff --git a/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions.component.html b/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions.component.html index 3e6c31fc0eb..f7a10f92c56 100644 --- a/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions.component.html +++ b/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions.component.html @@ -1,7 +1,5 @@

{{ 'ACTIONSTWO.EXECUTION.TITLE' | translate }}

- - {{ 'ACTIONSTWO.BETA_NOTE' | translate }} - +

{{ 'ACTIONSTWO.EXECUTION.DESCRIPTION' | translate }}

{{ 'ACTIONSTWO.TARGET.TITLE' | translate }} - - {{ 'ACTIONSTWO.BETA_NOTE' | translate }} - +

{{ 'ACTIONSTWO.TARGET.DESCRIPTION' | translate }}

; + @Input() public refresh?: Observable; public bottom: boolean = false; private _done: BehaviorSubject = new BehaviorSubject(false); @@ -65,30 +67,26 @@ export class ChangesComponent implements OnInit, OnDestroy { loading: Observable = this._loading.asObservable(); public data: Observable = this._data.asObservable().pipe( scan((acc, val) => { - return false ? val.concat(acc) : acc.concat(val); + return acc.concat(val); }), ); public changes!: ListChanges; - private destroyed$: Subject = new Subject(); constructor( - private mgmtUserService: ManagementService, - private authUserService: GrpcAuthService, + private readonly mgmtUserService: ManagementService, + private readonly authUserService: GrpcAuthService, + private readonly destroyRef: DestroyRef, ) {} ngOnInit(): void { this.init(); if (this.refresh) { - this.refresh.pipe(takeUntil(this.destroyed$), debounceTime(2000)).subscribe(() => { + this.refresh.pipe(takeUntilDestroyed(this.destroyRef), debounceTime(2000)).subscribe(() => { this._data = new BehaviorSubject([]); this.init(); }); } } - ngOnDestroy(): void { - this.destroyed$.next(); - } - public init(): void { let first: Promise; switch (this.changeType) { diff --git a/console/src/app/modules/header/header.component.html b/console/src/app/modules/header/header.component.html index f1a32447921..379c0f2f767 100644 --- a/console/src/app/modules/header/header.component.html +++ b/console/src/app/modules/header/header.component.html @@ -4,7 +4,11 @@
- + - - - - - - - - - - - - - - - - -
- - {{ org.name ? org.name : 'NO NAME' }} - -
- - - - - - -
-
-
- - - - - - - - - + @@ -178,33 +55,6 @@ - -
- +
diff --git a/console/src/app/modules/members-table/members-table.component.ts b/console/src/app/modules/members-table/members-table.component.ts index 3ac361488de..fd5b7922a3f 100644 --- a/console/src/app/modules/members-table/members-table.component.ts +++ b/console/src/app/modules/members-table/members-table.component.ts @@ -2,8 +2,8 @@ import { SelectionModel } from '@angular/cdk/collections'; import { Component, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { MatTable } from '@angular/material/table'; -import { Observable, Subject } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; +import { combineLatestWith, firstValueFrom, Observable, ReplaySubject, Subject } from 'rxjs'; +import { map, startWith, takeUntil } from 'rxjs/operators'; import { InstanceMembersDataSource } from 'src/app/pages/instance/instance-members/instance-members-datasource'; import { OrgMembersDataSource } from 'src/app/pages/orgs/org-members/org-members-datasource'; import { ProjectGrantMembersDataSource } from 'src/app/pages/projects/owned-projects/project-grant-detail/project-grant-members-datasource'; @@ -29,8 +29,16 @@ type MemberDatasource = }) export class MembersTableComponent implements OnInit, OnDestroy { public INITIALPAGESIZE: number = 25; - @Input() public canDelete: boolean | null = false; - @Input() public canWrite: boolean | null = false; + @Input() + public set canWrite(value: boolean | null) { + this.canWrite$.next(!!value); + } + + @Input() + public set canDelete(value: boolean | null) { + this.canDelete$.next(!!value); + } + @ViewChild(PaginatorComponent) public paginator!: PaginatorComponent; @ViewChild(MatTable) public table!: MatTable; @Input() public dataSource?: MemberDatasource; @@ -42,24 +50,38 @@ export class MembersTableComponent implements OnInit, OnDestroy { @Output() public changedSelection: EventEmitter = new EventEmitter(); @Output() public deleteMember: EventEmitter = new EventEmitter(); + protected readonly displayedColumns$: Observable; + protected readonly canWrite$ = new ReplaySubject(1); + protected readonly canDelete$ = new ReplaySubject(1); private destroyed: Subject = new Subject(); - public displayedColumns: string[] = ['select', 'userId', 'displayName', 'loginname', 'email', 'roles']; public UserType: any = Type; constructor(private dialog: MatDialog) { this.selection.changed.pipe(takeUntil(this.destroyed)).subscribe((_) => { this.changedSelection.emit(this.selection.selected); }); + + this.displayedColumns$ = this.getDisplayedColumns(); } public ngOnInit(): void { this.refreshTrigger.pipe(takeUntil(this.destroyed)).subscribe(() => { this.changePage(this.paginator); }); + } - if (this.canDelete || this.canWrite) { - this.displayedColumns.push('actions'); - } + private getDisplayedColumns() { + const defaultColumns = ['select', 'userId', 'displayName', 'loginname', 'email', 'roles']; + return this.canWrite$.pipe( + combineLatestWith(this.canDelete$), + map(([canWrite, canDelete]) => { + if (canWrite || canDelete) { + return [...defaultColumns, 'actions']; + } + return defaultColumns; + }), + startWith(defaultColumns), + ); } public ngOnDestroy(): void { @@ -99,7 +121,11 @@ export class MembersTableComponent implements OnInit, OnDestroy { } } - public addRole(member: Member.AsObject) { + public async addRole(member: Member.AsObject) { + if (!(await firstValueFrom(this.canWrite$))) { + return; + } + const dialogRef = this.dialog.open(AddMemberRolesDialogComponent, { data: { user: member.displayName, diff --git a/console/src/app/modules/memberships-table/memberships-table.component.ts b/console/src/app/modules/memberships-table/memberships-table.component.ts index c7771d2abba..d9eccf718b2 100644 --- a/console/src/app/modules/memberships-table/memberships-table.component.ts +++ b/console/src/app/modules/memberships-table/memberships-table.component.ts @@ -4,19 +4,19 @@ import { MatTable } from '@angular/material/table'; import { Router } from '@angular/router'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; -import { Org } from 'src/app/proto/generated/zitadel/org_pb'; import { Membership } from 'src/app/proto/generated/zitadel/user_pb'; import { AdminService } from 'src/app/services/admin.service'; import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; import { ManagementService } from 'src/app/services/mgmt.service'; import { OverlayWorkflowService } from 'src/app/services/overlay/overlay-workflow.service'; import { OrgContextChangedWorkflowOverlays } from 'src/app/services/overlay/workflows'; -import { StorageLocation, StorageService } from 'src/app/services/storage.service'; import { ToastService } from 'src/app/services/toast.service'; import { getMembershipColor } from 'src/app/utils/color'; import { PageEvent, PaginatorComponent } from '../paginator/paginator.component'; import { MembershipsDataSource } from './memberships-datasource'; +import { NewOrganizationService } from '../../services/new-organization.service'; +import { Organization } from '@zitadel/proto/zitadel/org/v2/org_pb'; @Component({ selector: 'cnsl-memberships-table', @@ -49,7 +49,7 @@ export class MembershipsTableComponent implements OnInit, OnDestroy { private toast: ToastService, private router: Router, private workflowService: OverlayWorkflowService, - private storageService: StorageService, + private readonly newOrganizationService: NewOrganizationService, ) { this.selection.changed.pipe(takeUntil(this.destroyed)).subscribe((_) => { this.changedSelection.emit(this.selection.selected); @@ -116,53 +116,44 @@ export class MembershipsTableComponent implements OnInit, OnDestroy { } } - public goto(membership: Membership.AsObject): void { - const org: Org.AsObject | null = this.storageService.getItem('organization', StorageLocation.session); + public async goto(membership: Membership.AsObject) { + const orgId = this.newOrganizationService.orgId(); if (membership.orgId && !membership.projectId && !membership.projectGrantId) { // only shown on auth user, or if currentOrg === resourceOwner - this.authService - .getActiveOrg(membership.orgId) - .then((membershipOrg) => { - this.router.navigate(['/org/members']).then(() => { - this.startOrgContextWorkflow(membershipOrg, org); - }); - }) - .catch(() => { - this.toast.showInfo('USER.MEMBERSHIPS.NOPERMISSIONTOEDIT', true); - }); + try { + const membershipOrg = await this.newOrganizationService.setOrgId(membership.orgId); + await this.router.navigate(['/org/members']); + this.startOrgContextWorkflow(membershipOrg, orgId); + } catch (error) { + this.toast.showInfo('USER.MEMBERSHIPS.NOPERMISSIONTOEDIT', true); + } } else if (membership.projectGrantId && membership.details?.resourceOwner) { // only shown on auth user - this.authService - .getActiveOrg(membership.details?.resourceOwner) - .then((membershipOrg) => { - this.router.navigate(['/granted-projects', membership.projectId, 'grants', membership.projectGrantId]).then(() => { - this.startOrgContextWorkflow(membershipOrg, org); - }); - }) - .catch(() => { - this.toast.showInfo('USER.MEMBERSHIPS.NOPERMISSIONTOEDIT', true); - }); + try { + const membershipOrg = await this.newOrganizationService.setOrgId(membership.details?.resourceOwner); + await this.router.navigate(['/granted-projects', membership.projectId, 'grants', membership.projectGrantId]); + this.startOrgContextWorkflow(membershipOrg, orgId); + } catch (error) { + this.toast.showInfo('USER.MEMBERSHIPS.NOPERMISSIONTOEDIT', true); + } } else if (membership.projectId && membership.details?.resourceOwner) { // only shown on auth user, or if currentOrg === resourceOwner - this.authService - .getActiveOrg(membership.details?.resourceOwner) - .then((membershipOrg) => { - this.router.navigate(['/projects', membership.projectId, 'members']).then(() => { - this.startOrgContextWorkflow(membershipOrg, org); - }); - }) - .catch(() => { - this.toast.showInfo('USER.MEMBERSHIPS.NOPERMISSIONTOEDIT', true); - }); + try { + const membershipOrg = await this.newOrganizationService.setOrgId(membership.details?.resourceOwner); + await this.router.navigate(['/projects', membership.projectId, 'members']); + this.startOrgContextWorkflow(membershipOrg, orgId); + } catch (error) { + this.toast.showInfo('USER.MEMBERSHIPS.NOPERMISSIONTOEDIT', true); + } } else if (membership.iam) { // only shown on auth user - this.router.navigate(['/instance/members']); + await this.router.navigate(['/instance/members']); } } - private startOrgContextWorkflow(membershipOrg: Org.AsObject, currentOrg?: Org.AsObject | null): void { - if (!currentOrg || (membershipOrg.id && currentOrg.id && currentOrg.id !== membershipOrg.id)) { + private startOrgContextWorkflow(membershipOrg: Organization, currentOrgId?: string | null): void { + if (!currentOrgId || (membershipOrg.id && currentOrgId && currentOrgId !== membershipOrg.id)) { setTimeout(() => { this.workflowService.startWorkflow(OrgContextChangedWorkflowOverlays, null); }, 1000); diff --git a/console/src/app/modules/nav/nav.component.html b/console/src/app/modules/nav/nav.component.html index d61b750d9f3..80998895033 100644 --- a/console/src/app/modules/nav/nav.component.html +++ b/console/src/app/modules/nav/nav.component.html @@ -1,19 +1,16 @@ - + - - @@ -86,14 +85,14 @@ - -
@@ -116,14 +122,14 @@ [selectable]="false" class="cnsl-chip" *ngFor="let role of member.rolesList" - [removable]="canWrite" + [removable]="canWrite$ | async" (removed)="removeRole(member, role)" data-e2e="role" >
{{ role | roletransform }} -
@@ -135,8 +141,8 @@
{{ 'ORG.PAGES.ID' | translate }}{{ org.id }}{{ org.id }} {{ 'ORG.PAGES.PRIMARYDOMAIN' | translate }} +
{{ org.primaryDomain }}
+ {{ org.name }}{{ 'ORG.PAGES.DEFAULTLABEL' | translate @@ -60,12 +59,12 @@ {{ 'ORG.PAGES.STATE' | translate }} + {{ 'ORG.STATE.' + org.state | translate }} {{ 'ORG.PAGES.CREATIONDATE' | translate }} - + {{ org.details?.creationDate | timestampToDate | localizedDate: 'fromNow' }} {{ 'ORG.PAGES.DATECHANGED' | translate }} + {{ org.details?.changeDate | timestampToDate | localizedDate: 'fromNow' }} + - - - -
- - {{ trigger.triggerType?.name?.localizedMessage }} - - -
- -
-
- - {{ action.name }} - - - {{ 'FLOWS.STATES.' + action.state | translate }} -
-
-
- - - - - + + + + diff --git a/console/src/app/pages/actions/actions.component.scss b/console/src/app/pages/actions/actions.component.scss index 6798d86c81c..f42cb39693e 100644 --- a/console/src/app/pages/actions/actions.component.scss +++ b/console/src/app/pages/actions/actions.component.scss @@ -1,18 +1,9 @@ -.actions-title-row { - display: flex; - align-items: center; +h1 { + margin: 0; +} - h1 { - margin: 0; - } - - a { - .icon { - font-size: 1.2rem; - height: 1.2rem; - width: 1.2rem; - } - } +.org-desc { + font-size: 14px; } @mixin actions-theme($theme) { diff --git a/console/src/app/pages/actions/actions.component.spec.ts b/console/src/app/pages/actions/actions.component.spec.ts index 5b4d68e7de1..f8c586f23a3 100644 --- a/console/src/app/pages/actions/actions.component.spec.ts +++ b/console/src/app/pages/actions/actions.component.spec.ts @@ -1,19 +1,19 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { ActionsComponent } from './actions.component'; +import { OrgListComponent } from './actions.component'; -describe('ActionsComponent', () => { - let component: ActionsComponent; - let fixture: ComponentFixture; +describe('OrgListComponent', () => { + let component: OrgListComponent; + let fixture: ComponentFixture; - beforeEach(async () => { - await TestBed.configureTestingModule({ - declarations: [ActionsComponent], + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [OrgListComponent], }).compileComponents(); - }); + })); beforeEach(() => { - fixture = TestBed.createComponent(ActionsComponent); + fixture = TestBed.createComponent(OrgListComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/console/src/app/pages/actions/actions.component.ts b/console/src/app/pages/actions/actions.component.ts index f926e7b819e..346ead6b4ad 100644 --- a/console/src/app/pages/actions/actions.component.ts +++ b/console/src/app/pages/actions/actions.component.ts @@ -1,180 +1,30 @@ -import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; -import { Component, DestroyRef } from '@angular/core'; -import { UntypedFormControl } from '@angular/forms'; -import { MatDialog } from '@angular/material/dialog'; -import { ActionKeysType } from 'src/app/modules/action-keys/action-keys.component'; +import { Component, signal } from '@angular/core'; +import { enterAnimations } from 'src/app/animations'; import { InfoSectionType } from 'src/app/modules/info-section/info-section.component'; -import { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component'; -import { Action, ActionState, Flow, FlowType, TriggerType } from 'src/app/proto/generated/zitadel/action_pb'; -import { SetTriggerActionsRequest } from 'src/app/proto/generated/zitadel/management_pb'; +import { SidenavSetting } from 'src/app/modules/sidenav/sidenav.component'; import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service'; -import { ManagementService } from 'src/app/services/mgmt.service'; -import { ToastService } from 'src/app/services/toast.service'; -import { AddFlowDialogComponent } from './add-flow-dialog/add-flow-dialog.component'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +const ACTIONS: SidenavSetting = { id: 'actions', i18nKey: 'MENU.ACTIONS' }; +const TARGETS: SidenavSetting = { id: 'targets', i18nKey: 'MENU.TARGETS' }; @Component({ selector: 'cnsl-actions', templateUrl: './actions.component.html', styleUrls: ['./actions.component.scss'], + animations: [enterAnimations], }) export class ActionsComponent { - protected flow!: Flow.AsObject; + public settingsList: SidenavSetting[] = [ACTIONS, TARGETS]; + protected readonly currentSetting$ = signal(this.settingsList[0]); + protected readonly InfoSectionType = InfoSectionType; - protected typeControl: UntypedFormControl = new UntypedFormControl(); - - protected typesForSelection: FlowType.AsObject[] = []; - - protected selection: Action.AsObject[] = []; - protected InfoSectionType = InfoSectionType; - protected ActionKeysType = ActionKeysType; - - protected maxActions: number | null = null; - protected ActionState = ActionState; - constructor( - private mgmtService: ManagementService, - breadcrumbService: BreadcrumbService, - private dialog: MatDialog, - private toast: ToastService, - destroyRef: DestroyRef, - ) { - const bread: Breadcrumb = { - type: BreadcrumbType.ORG, - routerLink: ['/org'], - }; - breadcrumbService.setBreadcrumb([bread]); - - this.getFlowTypes().then(); - - this.typeControl.valueChanges.pipe(takeUntilDestroyed(destroyRef)).subscribe((value) => { - this.loadFlow((value as FlowType.AsObject).id); - }); - } - - private async getFlowTypes(): Promise { - try { - let resp = await this.mgmtService.listFlowTypes(); - this.typesForSelection = resp.resultList; - if (!this.flow && resp.resultList[0]) { - const type = resp.resultList[0]; - this.typeControl.setValue(type); - } - } catch (error) { - this.toast.showError(error); - } - } - - private loadFlow(id: string) { - this.mgmtService.getFlow(id).then((flowResponse) => { - if (flowResponse.flow) { - this.flow = flowResponse.flow; - } - }); - } - - public clearFlow(id: string): void { - const dialogRef = this.dialog.open(WarnDialogComponent, { - data: { - confirmKey: 'ACTIONS.CLEAR', - cancelKey: 'ACTIONS.CANCEL', - titleKey: 'FLOWS.DIALOG.CLEAR.TITLE', - descriptionKey: 'FLOWS.DIALOG.CLEAR.DESCRIPTION', - }, - width: '400px', + constructor(breadcrumbService: BreadcrumbService) { + const iamBread = new Breadcrumb({ + type: BreadcrumbType.INSTANCE, + name: 'Instance', + routerLink: ['/instance'], }); - dialogRef.afterClosed().subscribe((resp) => { - if (resp) { - this.mgmtService - .clearFlow(id) - .then(() => { - this.toast.showInfo('FLOWS.FLOWCLEARED', true); - this.loadFlow(id); - }) - .catch((error: any) => { - this.toast.showError(error); - }); - } - }); - } - - protected openAddTrigger(flow: FlowType.AsObject, trigger?: TriggerType.AsObject): void { - const dialogRef = this.dialog.open(AddFlowDialogComponent, { - data: { - flowType: flow, - actions: this.selection && this.selection.length ? this.selection : [], - }, - width: '400px', - }); - - dialogRef.afterClosed().subscribe((req: SetTriggerActionsRequest) => { - if (req) { - this.mgmtService - .setTriggerActions(req.getActionIdsList(), req.getFlowType(), req.getTriggerType()) - .then(() => { - this.toast.showInfo('FLOWS.FLOWCHANGED', true); - this.loadFlow(flow.id); - }) - .catch((error: any) => { - this.toast.showError(error); - }); - } - }); - } - - drop(triggerActionsListIndex: number, array: any[], event: CdkDragDrop) { - moveItemInArray(array, event.previousIndex, event.currentIndex); - this.saveFlow(triggerActionsListIndex); - } - - saveFlow(index: number) { - if ( - this.flow.type && - this.flow.triggerActionsList && - this.flow.triggerActionsList[index] && - this.flow.triggerActionsList[index]?.triggerType - ) { - this.mgmtService - .setTriggerActions( - this.flow.triggerActionsList[index].actionsList.map((action) => action.id), - this.flow.type.id, - this.flow.triggerActionsList[index].triggerType?.id ?? '', - ) - .then(() => { - this.toast.showInfo('FLOWS.TOAST.ACTIONSSET', true); - }) - .catch((error) => { - this.toast.showError(error); - }); - } - } - - protected removeTriggerActionsList(index: number) { - if (this.flow.type && this.flow.triggerActionsList && this.flow.triggerActionsList[index]) { - const dialogRef = this.dialog.open(WarnDialogComponent, { - data: { - confirmKey: 'ACTIONS.CLEAR', - cancelKey: 'ACTIONS.CANCEL', - titleKey: 'FLOWS.DIALOG.REMOVEACTIONSLIST.TITLE', - descriptionKey: 'FLOWS.DIALOG.REMOVEACTIONSLIST.DESCRIPTION', - }, - width: '400px', - }); - - dialogRef.afterClosed().subscribe((resp) => { - if (resp) { - this.mgmtService - .setTriggerActions([], this.flow?.type?.id ?? '', this.flow.triggerActionsList[index].triggerType?.id ?? '') - .then(() => { - this.toast.showInfo('FLOWS.TOAST.ACTIONSSET', true); - this.loadFlow(this.flow?.type?.id ?? ''); - }) - .catch((error) => { - this.toast.showError(error); - }); - } - }); - } + breadcrumbService.setBreadcrumb([iamBread]); } } diff --git a/console/src/app/pages/actions/actions.module.ts b/console/src/app/pages/actions/actions.module.ts index c5da8663fa9..e0f14cee5dc 100644 --- a/console/src/app/pages/actions/actions.module.ts +++ b/console/src/app/pages/actions/actions.module.ts @@ -1,68 +1,29 @@ -import { DragDropModule } from '@angular/cdk/drag-drop'; import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatDialogModule } from '@angular/material/dialog'; -import { MatIconModule } from '@angular/material/icon'; -import { MatSelectModule } from '@angular/material/select'; -import { MatTableModule } from '@angular/material/table'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { CodemirrorModule } from '@ctrl/ngx-codemirror'; import { TranslateModule } from '@ngx-translate/core'; -import { HasRoleModule } from 'src/app/directives/has-role/has-role.module'; -import { ActionKeysModule } from 'src/app/modules/action-keys/action-keys.module'; -import { CardModule } from 'src/app/modules/card/card.module'; -import { FormFieldModule } from 'src/app/modules/form-field/form-field.module'; -import { InfoSectionModule } from 'src/app/modules/info-section/info-section.module'; -import { InputModule } from 'src/app/modules/input/input.module'; -import { PaginatorModule } from 'src/app/modules/paginator/paginator.module'; -import { RefreshTableModule } from 'src/app/modules/refresh-table/refresh-table.module'; -import { TableActionsModule } from 'src/app/modules/table-actions/table-actions.module'; -import { WarnDialogModule } from 'src/app/modules/warn-dialog/warn-dialog.module'; -import { DurationToSecondsPipeModule } from 'src/app/pipes/duration-to-seconds-pipe/duration-to-seconds-pipe.module'; -import { HasRolePipeModule } from 'src/app/pipes/has-role-pipe/has-role-pipe.module'; -import { LocalizedDatePipeModule } from 'src/app/pipes/localized-date-pipe/localized-date-pipe.module'; -import { TimestampToDatePipeModule } from 'src/app/pipes/timestamp-to-date-pipe/timestamp-to-date-pipe.module'; +import { OrgTableModule } from 'src/app/modules/org-table/org-table.module'; -import { ActionTableComponent } from './action-table/action-table.component'; import { ActionsRoutingModule } from './actions-routing.module'; import { ActionsComponent } from './actions.component'; -import { AddActionDialogComponent } from './add-action-dialog/add-action-dialog.component'; -import { AddFlowDialogComponent } from './add-flow-dialog/add-flow-dialog.component'; +import { MetaLayoutModule } from 'src/app/modules/meta-layout/meta-layout.module'; +import { SidenavModule } from 'src/app/modules/sidenav/sidenav.module'; +import { ActionsTwoActionsComponent } from 'src/app/modules/actions-two/actions-two-actions/actions-two-actions.component'; +import ActionsTwoModule from 'src/app/modules/actions-two/actions-two.module'; +import { FormsModule } from '@angular/forms'; +import { InfoSectionModule } from 'src/app/modules/info-section/info-section.module'; @NgModule({ - declarations: [ActionsComponent, ActionTableComponent, AddActionDialogComponent, AddFlowDialogComponent], + declarations: [ActionsComponent], imports: [ CommonModule, FormsModule, ActionsRoutingModule, + OrgTableModule, TranslateModule, - MatDialogModule, - RefreshTableModule, - MatTableModule, - PaginatorModule, - MatButtonModule, - ReactiveFormsModule, - MatIconModule, - DurationToSecondsPipeModule, - TimestampToDatePipeModule, - LocalizedDatePipeModule, - HasRoleModule, - ActionKeysModule, - MatTooltipModule, - CardModule, - MatCheckboxModule, - InputModule, - FormFieldModule, - MatSelectModule, - WarnDialogModule, - DragDropModule, InfoSectionModule, - HasRolePipeModule, - TableActionsModule, - CodemirrorModule, + SidenavModule, + ActionsTwoModule, ], + exports: [ActionsComponent], }) export default class ActionsModule {} diff --git a/console/src/app/pages/home/home.component.html b/console/src/app/pages/home/home.component.html index c5c3485ea9c..3d61e7e3416 100644 --- a/console/src/app/pages/home/home.component.html +++ b/console/src/app/pages/home/home.component.html @@ -5,16 +5,14 @@ - + - + -

{{ 'HOME.DISCLAIMER' | translate }}

- -

{{ 'ONBOARDING.MOREDESCRIPTION' | translate }}

+

{{ 'ONBOARDING.MOREDESCRIPTION' | translate }}

diff --git a/console/src/app/pages/home/home.component.scss b/console/src/app/pages/home/home.component.scss index cf60d83fcb6..eafcbcdd534 100644 --- a/console/src/app/pages/home/home.component.scss +++ b/console/src/app/pages/home/home.component.scss @@ -38,9 +38,9 @@ } } - .desc { + .home-desc { font-size: 1.2rem; - margin-top: 0; + margin-top: 2rem; text-transform: uppercase; } @@ -77,49 +77,6 @@ cursor: move; } - .grid-item-avatar { - height: 40px; - width: 40px; - margin-right: 1rem; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - background: linear-gradient(40deg, rgb(129, 85, 185) 30%, #7b8ada); - - &.purple { - background: linear-gradient(40deg, #7c3aed 30%, #6d28d9); - } - - &.red { - background: linear-gradient(40deg, #dc2626 30%, #db2777); - } - - &.green { - background: linear-gradient(40deg, #03704e 30%, #047857); - } - - &.blue { - background: linear-gradient(40deg, #306ccc 30%, #4f46e5); - } - - &.yellow { - background: linear-gradient(40deg, #f59e0b 30%, #b45309); - } - - &.black { - background: linear-gradient(40deg, #1f2937, #111827); - } - - .icon, - i { - font-size: 1.5rem; - height: 1.5rem; - line-height: 1.5rem; - color: white; - } - } - .icon-wrapper { display: flex; justify-content: center; @@ -143,13 +100,6 @@ } } - .disclaimer { - font-size: 14px; - margin-top: 0; - margin-bottom: 5rem; - font-style: italic; - } - .fill-space { flex: 1; } diff --git a/console/src/app/pages/home/home.component.ts b/console/src/app/pages/home/home.component.ts index f84e91fc05b..03adc4159a7 100644 --- a/console/src/app/pages/home/home.component.ts +++ b/console/src/app/pages/home/home.component.ts @@ -1,8 +1,11 @@ -import { Component } from '@angular/core'; +import { Component, effect } from '@angular/core'; +import { PolicyComponentServiceType } from 'src/app/modules/policies/policy-component-types.enum'; import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service'; import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; import { ThemeService } from 'src/app/services/theme.service'; import { COLORS } from 'src/app/utils/color'; +import { NewAuthService } from 'src/app/services/new-auth.service'; +import { Router } from '@angular/router'; @Component({ selector: 'cnsl-home', @@ -21,19 +24,36 @@ export class HomeComponent { public dark: boolean = true; + protected readonly PolicyComponentServiceType = PolicyComponentServiceType; + + private readonly permissions = this.newAuthService.listMyZitadelPermissionsQuery(); + constructor( public authService: GrpcAuthService, + private readonly newAuthService: NewAuthService, breadcrumbService: BreadcrumbService, public themeService: ThemeService, + private readonly router: Router, ) { const bread: Breadcrumb = { - type: BreadcrumbType.ORG, - routerLink: ['/org'], + type: BreadcrumbType.INSTANCE, + routerLink: ['/'], }; breadcrumbService.setBreadcrumb([bread]); const theme = localStorage.getItem('theme'); this.dark = theme === 'dark-theme' ? true : theme === 'light-theme' ? false : true; + + effect(() => { + const permission = this.permissions.data(); + if (!permission) { + return; + } + if (permission.includes('iam.read')) { + return; + } + this.router.navigate(['/org']).then(); + }); } } diff --git a/console/src/app/pages/instance/instance.component.html b/console/src/app/pages/instance/instance.component.html index c73881f168d..b53c65a9fa7 100644 --- a/console/src/app/pages/instance/instance.component.html +++ b/console/src/app/pages/instance/instance.component.html @@ -8,20 +8,6 @@ stateTooltip="{{ 'INSTANCE.STATE.' + instance?.state | translate }}" >
- -
- {{ 'MENU.CUSTOMERPORTAL' | translate }} - -
-
; - protected readonly customerPortalLink$ = this.envService.env.pipe(map((env) => env.customer_portal)); constructor( protected readonly adminService: AdminService, @@ -101,7 +94,6 @@ export class InstanceComponent { breadcrumbService: BreadcrumbService, private readonly router: Router, private readonly authService: GrpcAuthService, - private readonly envService: EnvironmentService, activatedRoute: ActivatedRoute, private readonly destroyRef: DestroyRef, ) { diff --git a/console/src/app/pages/instance/instance.module.ts b/console/src/app/pages/instance/instance.module.ts index 31029834384..c8e5a94fcd6 100644 --- a/console/src/app/pages/instance/instance.module.ts +++ b/console/src/app/pages/instance/instance.module.ts @@ -21,7 +21,6 @@ import { InputModule } from 'src/app/modules/input/input.module'; import { MetaLayoutModule } from 'src/app/modules/meta-layout/meta-layout.module'; import { OrgTableModule } from 'src/app/modules/org-table/org-table.module'; import { RefreshTableModule } from 'src/app/modules/refresh-table/refresh-table.module'; -import { SettingsGridModule } from 'src/app/modules/settings-grid/settings-grid.module'; import { TopViewModule } from 'src/app/modules/top-view/top-view.module'; import { HasRolePipeModule } from 'src/app/pipes/has-role-pipe/has-role-pipe.module'; import { LocalizedDatePipeModule } from 'src/app/pipes/localized-date-pipe/localized-date-pipe.module'; @@ -64,7 +63,6 @@ import { SettingsListModule } from 'src/app/modules/settings-list/settings-list. HasRolePipeModule, SettingsListModule, MatSortModule, - SettingsGridModule, ], }) export default class InstanceModule {} diff --git a/console/src/app/pages/actions/action-table/action-table.component.html b/console/src/app/pages/org-actions/action-table/action-table.component.html similarity index 100% rename from console/src/app/pages/actions/action-table/action-table.component.html rename to console/src/app/pages/org-actions/action-table/action-table.component.html diff --git a/console/src/app/pages/actions/action-table/action-table.component.scss b/console/src/app/pages/org-actions/action-table/action-table.component.scss similarity index 100% rename from console/src/app/pages/actions/action-table/action-table.component.scss rename to console/src/app/pages/org-actions/action-table/action-table.component.scss diff --git a/console/src/app/pages/actions/action-table/action-table.component.spec.ts b/console/src/app/pages/org-actions/action-table/action-table.component.spec.ts similarity index 100% rename from console/src/app/pages/actions/action-table/action-table.component.spec.ts rename to console/src/app/pages/org-actions/action-table/action-table.component.spec.ts diff --git a/console/src/app/pages/actions/action-table/action-table.component.ts b/console/src/app/pages/org-actions/action-table/action-table.component.ts similarity index 100% rename from console/src/app/pages/actions/action-table/action-table.component.ts rename to console/src/app/pages/org-actions/action-table/action-table.component.ts diff --git a/console/src/app/pages/org-actions/actions-routing.module.ts b/console/src/app/pages/org-actions/actions-routing.module.ts new file mode 100644 index 00000000000..8fcd4b5f123 --- /dev/null +++ b/console/src/app/pages/org-actions/actions-routing.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { ActionsComponent } from './actions.component'; + +const routes: Routes = [ + { + path: '', + component: ActionsComponent, + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class ActionsRoutingModule {} diff --git a/console/src/app/pages/org-actions/actions.component.html b/console/src/app/pages/org-actions/actions.component.html new file mode 100644 index 00000000000..af936e2351a --- /dev/null +++ b/console/src/app/pages/org-actions/actions.component.html @@ -0,0 +1,107 @@ +
+
+
+

{{ 'DESCRIPTIONS.ACTIONS.TITLE' | translate }}

+ + info_outline + +
+ + {{ 'DESCRIPTIONS.ACTIONS.ACTIONSTWO_NOTE' | translate }} + +

{{ 'DESCRIPTIONS.ACTIONS.DESCRIPTION' | translate }}

+ + {{ 'FLOWS.ACTIONSMAX' | translate: { value: maxActions } }} + + + + + + + + +
+

{{ 'DESCRIPTIONS.ACTIONS.FLOWS.TITLE' | translate }}

+ +
+ +

{{ 'DESCRIPTIONS.ACTIONS.FLOWS.DESCRIPTION' | translate }}

+ + +
+ + {{ 'FLOWS.FLOWTYPE' | translate }} + + + {{ type.name?.localizedMessage }} + + + + +
+
+ +
+ + {{ flow.type?.name?.localizedMessage }} + +
+ + +
+ + {{ trigger.triggerType?.name?.localizedMessage }} + + +
+ +
+
+ + {{ action.name }} + + + {{ 'FLOWS.STATES.' + action.state | translate }} +
+
+
+ + +
+
+
+
+
diff --git a/console/src/app/pages/org-actions/actions.component.scss b/console/src/app/pages/org-actions/actions.component.scss new file mode 100644 index 00000000000..f15ca606763 --- /dev/null +++ b/console/src/app/pages/org-actions/actions.component.scss @@ -0,0 +1,186 @@ +.actions-title-row { + display: flex; + align-items: center; + + h1 { + margin: 0; + } + + a { + .icon { + font-size: 1.2rem; + height: 1.2rem; + width: 1.2rem; + } + } +} + +@mixin actions-theme($theme) { + $foreground: map-get($theme, foreground); + $background: map-get($theme, background); + $is-dark-theme: map-get($theme, is-dark); + $primary: map-get($theme, primary); + $primary-color: map-get($primary, 500); + + .actions-enlarged-container { + h1 { + margin: 0; + } + + .desc { + margin-bottom: 2rem; + font-size: 14px; + } + + .title-section { + display: flex; + align-items: center; + margin-top: 3rem; + margin-bottom: 1rem; + + h2 { + margin: 0; + } + + i { + margin-left: 0.5rem; + } + } + + .actions-flow { + display: flex; + flex-direction: column; + max-width: 1000px; + position: relative; + + .formfield { + max-width: 300px; + } + + .flow-type { + margin: 0.5rem 0; + display: flex; + align-items: center; + justify-content: flex-start; + padding: 0 1.5rem; + + .type-icon { + color: $primary-color; + } + + .type-button-icon, + .type-icon, + span { + margin-right: 1rem; + } + + .type-icon, + .type-button-icon { + position: relative; + } + } + + .trigger-wrapper { + position: relative; + + .trigger { + display: flex; + align-items: center; + position: relative; + + .trigger-top { + display: flex; + align-items: center; + margin-bottom: 0.5rem; + padding-left: 7px; + + .fill-space { + flex: 1; + } + } + + .icon { + margin-right: 1rem; + color: $primary-color; + } + + .fill-space { + flex: 1; + } + + .flow-action-wrapper { + padding: 0 0.5rem; + margin: 0; + + .flow-action { + display: flex; + align-items: center; + font-size: 14px; + padding: 0.5rem 0; + cursor: move; + + .flow-action-name { + margin-right: 1rem; + } + + .fill-space { + flex: 1; + } + + i { + margin-right: 0.5rem; + } + } + + .state { + margin-left: 1rem; + } + } + } + } + + .actions-topbottomline { + position: absolute; + top: 26px; + bottom: 1.5rem; + left: 35px; + width: 2px; + z-index: 0; + background-color: $primary-color; + } + + .add-btn { + display: flex; + align-items: center; + align-self: flex-start; + margin: 1rem 0; + } + } + + .cdk-drag-preview { + color: white; + display: flex; + align-items: center; + font-size: 14px; + border-radius: 0.5rem; + padding: 0 0.5rem; + background-color: $primary-color; + box-shadow: + 0 5px 5px -3px rgba(0, 0, 0, 0.2), + 0 8px 10px 1px rgba(0, 0, 0, 0.14), + 0 3px 14px 2px rgba(0, 0, 0, 0.12); + + i { + margin-right: 0.5rem; + } + } + + .cdk-drag-placeholder { + opacity: 0; + } + + .cdk-drag-animating { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); + } + } +} diff --git a/console/src/app/pages/org-actions/actions.component.spec.ts b/console/src/app/pages/org-actions/actions.component.spec.ts new file mode 100644 index 00000000000..5b4d68e7de1 --- /dev/null +++ b/console/src/app/pages/org-actions/actions.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ActionsComponent } from './actions.component'; + +describe('ActionsComponent', () => { + let component: ActionsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ActionsComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ActionsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/console/src/app/pages/org-actions/actions.component.ts b/console/src/app/pages/org-actions/actions.component.ts new file mode 100644 index 00000000000..f926e7b819e --- /dev/null +++ b/console/src/app/pages/org-actions/actions.component.ts @@ -0,0 +1,180 @@ +import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; +import { Component, DestroyRef } from '@angular/core'; +import { UntypedFormControl } from '@angular/forms'; +import { MatDialog } from '@angular/material/dialog'; +import { ActionKeysType } from 'src/app/modules/action-keys/action-keys.component'; +import { InfoSectionType } from 'src/app/modules/info-section/info-section.component'; +import { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component'; +import { Action, ActionState, Flow, FlowType, TriggerType } from 'src/app/proto/generated/zitadel/action_pb'; +import { SetTriggerActionsRequest } from 'src/app/proto/generated/zitadel/management_pb'; +import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service'; +import { ManagementService } from 'src/app/services/mgmt.service'; +import { ToastService } from 'src/app/services/toast.service'; + +import { AddFlowDialogComponent } from './add-flow-dialog/add-flow-dialog.component'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +@Component({ + selector: 'cnsl-actions', + templateUrl: './actions.component.html', + styleUrls: ['./actions.component.scss'], +}) +export class ActionsComponent { + protected flow!: Flow.AsObject; + + protected typeControl: UntypedFormControl = new UntypedFormControl(); + + protected typesForSelection: FlowType.AsObject[] = []; + + protected selection: Action.AsObject[] = []; + protected InfoSectionType = InfoSectionType; + protected ActionKeysType = ActionKeysType; + + protected maxActions: number | null = null; + protected ActionState = ActionState; + constructor( + private mgmtService: ManagementService, + breadcrumbService: BreadcrumbService, + private dialog: MatDialog, + private toast: ToastService, + destroyRef: DestroyRef, + ) { + const bread: Breadcrumb = { + type: BreadcrumbType.ORG, + routerLink: ['/org'], + }; + breadcrumbService.setBreadcrumb([bread]); + + this.getFlowTypes().then(); + + this.typeControl.valueChanges.pipe(takeUntilDestroyed(destroyRef)).subscribe((value) => { + this.loadFlow((value as FlowType.AsObject).id); + }); + } + + private async getFlowTypes(): Promise { + try { + let resp = await this.mgmtService.listFlowTypes(); + this.typesForSelection = resp.resultList; + if (!this.flow && resp.resultList[0]) { + const type = resp.resultList[0]; + this.typeControl.setValue(type); + } + } catch (error) { + this.toast.showError(error); + } + } + + private loadFlow(id: string) { + this.mgmtService.getFlow(id).then((flowResponse) => { + if (flowResponse.flow) { + this.flow = flowResponse.flow; + } + }); + } + + public clearFlow(id: string): void { + const dialogRef = this.dialog.open(WarnDialogComponent, { + data: { + confirmKey: 'ACTIONS.CLEAR', + cancelKey: 'ACTIONS.CANCEL', + titleKey: 'FLOWS.DIALOG.CLEAR.TITLE', + descriptionKey: 'FLOWS.DIALOG.CLEAR.DESCRIPTION', + }, + width: '400px', + }); + + dialogRef.afterClosed().subscribe((resp) => { + if (resp) { + this.mgmtService + .clearFlow(id) + .then(() => { + this.toast.showInfo('FLOWS.FLOWCLEARED', true); + this.loadFlow(id); + }) + .catch((error: any) => { + this.toast.showError(error); + }); + } + }); + } + + protected openAddTrigger(flow: FlowType.AsObject, trigger?: TriggerType.AsObject): void { + const dialogRef = this.dialog.open(AddFlowDialogComponent, { + data: { + flowType: flow, + actions: this.selection && this.selection.length ? this.selection : [], + }, + width: '400px', + }); + + dialogRef.afterClosed().subscribe((req: SetTriggerActionsRequest) => { + if (req) { + this.mgmtService + .setTriggerActions(req.getActionIdsList(), req.getFlowType(), req.getTriggerType()) + .then(() => { + this.toast.showInfo('FLOWS.FLOWCHANGED', true); + this.loadFlow(flow.id); + }) + .catch((error: any) => { + this.toast.showError(error); + }); + } + }); + } + + drop(triggerActionsListIndex: number, array: any[], event: CdkDragDrop) { + moveItemInArray(array, event.previousIndex, event.currentIndex); + this.saveFlow(triggerActionsListIndex); + } + + saveFlow(index: number) { + if ( + this.flow.type && + this.flow.triggerActionsList && + this.flow.triggerActionsList[index] && + this.flow.triggerActionsList[index]?.triggerType + ) { + this.mgmtService + .setTriggerActions( + this.flow.triggerActionsList[index].actionsList.map((action) => action.id), + this.flow.type.id, + this.flow.triggerActionsList[index].triggerType?.id ?? '', + ) + .then(() => { + this.toast.showInfo('FLOWS.TOAST.ACTIONSSET', true); + }) + .catch((error) => { + this.toast.showError(error); + }); + } + } + + protected removeTriggerActionsList(index: number) { + if (this.flow.type && this.flow.triggerActionsList && this.flow.triggerActionsList[index]) { + const dialogRef = this.dialog.open(WarnDialogComponent, { + data: { + confirmKey: 'ACTIONS.CLEAR', + cancelKey: 'ACTIONS.CANCEL', + titleKey: 'FLOWS.DIALOG.REMOVEACTIONSLIST.TITLE', + descriptionKey: 'FLOWS.DIALOG.REMOVEACTIONSLIST.DESCRIPTION', + }, + width: '400px', + }); + + dialogRef.afterClosed().subscribe((resp) => { + if (resp) { + this.mgmtService + .setTriggerActions([], this.flow?.type?.id ?? '', this.flow.triggerActionsList[index].triggerType?.id ?? '') + .then(() => { + this.toast.showInfo('FLOWS.TOAST.ACTIONSSET', true); + this.loadFlow(this.flow?.type?.id ?? ''); + }) + .catch((error) => { + this.toast.showError(error); + }); + } + }); + } + } +} diff --git a/console/src/app/pages/org-actions/actions.module.ts b/console/src/app/pages/org-actions/actions.module.ts new file mode 100644 index 00000000000..c5da8663fa9 --- /dev/null +++ b/console/src/app/pages/org-actions/actions.module.ts @@ -0,0 +1,68 @@ +import { DragDropModule } from '@angular/cdk/drag-drop'; +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatDialogModule } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { MatSelectModule } from '@angular/material/select'; +import { MatTableModule } from '@angular/material/table'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { CodemirrorModule } from '@ctrl/ngx-codemirror'; +import { TranslateModule } from '@ngx-translate/core'; +import { HasRoleModule } from 'src/app/directives/has-role/has-role.module'; +import { ActionKeysModule } from 'src/app/modules/action-keys/action-keys.module'; +import { CardModule } from 'src/app/modules/card/card.module'; +import { FormFieldModule } from 'src/app/modules/form-field/form-field.module'; +import { InfoSectionModule } from 'src/app/modules/info-section/info-section.module'; +import { InputModule } from 'src/app/modules/input/input.module'; +import { PaginatorModule } from 'src/app/modules/paginator/paginator.module'; +import { RefreshTableModule } from 'src/app/modules/refresh-table/refresh-table.module'; +import { TableActionsModule } from 'src/app/modules/table-actions/table-actions.module'; +import { WarnDialogModule } from 'src/app/modules/warn-dialog/warn-dialog.module'; +import { DurationToSecondsPipeModule } from 'src/app/pipes/duration-to-seconds-pipe/duration-to-seconds-pipe.module'; +import { HasRolePipeModule } from 'src/app/pipes/has-role-pipe/has-role-pipe.module'; +import { LocalizedDatePipeModule } from 'src/app/pipes/localized-date-pipe/localized-date-pipe.module'; +import { TimestampToDatePipeModule } from 'src/app/pipes/timestamp-to-date-pipe/timestamp-to-date-pipe.module'; + +import { ActionTableComponent } from './action-table/action-table.component'; +import { ActionsRoutingModule } from './actions-routing.module'; +import { ActionsComponent } from './actions.component'; +import { AddActionDialogComponent } from './add-action-dialog/add-action-dialog.component'; +import { AddFlowDialogComponent } from './add-flow-dialog/add-flow-dialog.component'; + +@NgModule({ + declarations: [ActionsComponent, ActionTableComponent, AddActionDialogComponent, AddFlowDialogComponent], + imports: [ + CommonModule, + FormsModule, + ActionsRoutingModule, + TranslateModule, + MatDialogModule, + RefreshTableModule, + MatTableModule, + PaginatorModule, + MatButtonModule, + ReactiveFormsModule, + MatIconModule, + DurationToSecondsPipeModule, + TimestampToDatePipeModule, + LocalizedDatePipeModule, + HasRoleModule, + ActionKeysModule, + MatTooltipModule, + CardModule, + MatCheckboxModule, + InputModule, + FormFieldModule, + MatSelectModule, + WarnDialogModule, + DragDropModule, + InfoSectionModule, + HasRolePipeModule, + TableActionsModule, + CodemirrorModule, + ], +}) +export default class ActionsModule {} diff --git a/console/src/app/pages/actions/add-action-dialog/add-action-dialog.component.html b/console/src/app/pages/org-actions/add-action-dialog/add-action-dialog.component.html similarity index 100% rename from console/src/app/pages/actions/add-action-dialog/add-action-dialog.component.html rename to console/src/app/pages/org-actions/add-action-dialog/add-action-dialog.component.html diff --git a/console/src/app/pages/actions/add-action-dialog/add-action-dialog.component.scss b/console/src/app/pages/org-actions/add-action-dialog/add-action-dialog.component.scss similarity index 100% rename from console/src/app/pages/actions/add-action-dialog/add-action-dialog.component.scss rename to console/src/app/pages/org-actions/add-action-dialog/add-action-dialog.component.scss diff --git a/console/src/app/pages/actions/add-action-dialog/add-action-dialog.component.spec.ts b/console/src/app/pages/org-actions/add-action-dialog/add-action-dialog.component.spec.ts similarity index 100% rename from console/src/app/pages/actions/add-action-dialog/add-action-dialog.component.spec.ts rename to console/src/app/pages/org-actions/add-action-dialog/add-action-dialog.component.spec.ts diff --git a/console/src/app/pages/actions/add-action-dialog/add-action-dialog.component.ts b/console/src/app/pages/org-actions/add-action-dialog/add-action-dialog.component.ts similarity index 100% rename from console/src/app/pages/actions/add-action-dialog/add-action-dialog.component.ts rename to console/src/app/pages/org-actions/add-action-dialog/add-action-dialog.component.ts diff --git a/console/src/app/pages/actions/add-flow-dialog/add-flow-dialog.component.html b/console/src/app/pages/org-actions/add-flow-dialog/add-flow-dialog.component.html similarity index 100% rename from console/src/app/pages/actions/add-flow-dialog/add-flow-dialog.component.html rename to console/src/app/pages/org-actions/add-flow-dialog/add-flow-dialog.component.html diff --git a/console/src/app/pages/actions/add-flow-dialog/add-flow-dialog.component.scss b/console/src/app/pages/org-actions/add-flow-dialog/add-flow-dialog.component.scss similarity index 100% rename from console/src/app/pages/actions/add-flow-dialog/add-flow-dialog.component.scss rename to console/src/app/pages/org-actions/add-flow-dialog/add-flow-dialog.component.scss diff --git a/console/src/app/pages/actions/add-flow-dialog/add-flow-dialog.component.spec.ts b/console/src/app/pages/org-actions/add-flow-dialog/add-flow-dialog.component.spec.ts similarity index 100% rename from console/src/app/pages/actions/add-flow-dialog/add-flow-dialog.component.spec.ts rename to console/src/app/pages/org-actions/add-flow-dialog/add-flow-dialog.component.spec.ts diff --git a/console/src/app/pages/actions/add-flow-dialog/add-flow-dialog.component.ts b/console/src/app/pages/org-actions/add-flow-dialog/add-flow-dialog.component.ts similarity index 100% rename from console/src/app/pages/actions/add-flow-dialog/add-flow-dialog.component.ts rename to console/src/app/pages/org-actions/add-flow-dialog/add-flow-dialog.component.ts diff --git a/console/src/app/pages/org-create/org-create.component.ts b/console/src/app/pages/org-create/org-create.component.ts index fc1fc65ee8d..cbc71640cc4 100644 --- a/console/src/app/pages/org-create/org-create.component.ts +++ b/console/src/app/pages/org-create/org-create.component.ts @@ -5,17 +5,17 @@ import { AbstractControl, UntypedFormBuilder, UntypedFormGroup } from '@angular/ import { MatSlideToggleChange } from '@angular/material/slide-toggle'; import { Router } from '@angular/router'; import { passwordConfirmValidator, requiredValidator } from 'src/app/modules/form-field/validators/validators'; -import { SetUpOrgRequest } from 'src/app/proto/generated/zitadel/admin_pb'; import { Gender } from 'src/app/proto/generated/zitadel/user_pb'; -import { AdminService } from 'src/app/services/admin.service'; import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service'; -import { ManagementService } from 'src/app/services/mgmt.service'; import { ToastService } from 'src/app/services/toast.service'; import { LanguagesService } from 'src/app/services/languages.service'; -import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; import { PasswordComplexityPolicy } from '@zitadel/proto/zitadel/policy_pb'; import { NewMgmtService } from 'src/app/services/new-mgmt.service'; import { PasswordComplexityValidatorFactoryService } from 'src/app/services/password-complexity-validator-factory.service'; +import { injectMutation } from '@tanstack/angular-query-experimental'; +import { NewOrganizationService } from '../../services/new-organization.service'; +import { MessageInitShape } from '@bufbuild/protobuf'; +import { SetUpOrgRequestSchema } from '@zitadel/proto/zitadel/admin_pb'; @Component({ selector: 'cnsl-org-create', @@ -32,32 +32,33 @@ import { PasswordComplexityValidatorFactoryService } from 'src/app/services/pass ], }) export class OrgCreateComponent { - public orgForm: UntypedFormGroup = this.fb.group({ + protected orgForm = this.fb.group({ name: ['', [requiredValidator]], domain: [''], }); - public userForm?: UntypedFormGroup; - public pwdForm?: UntypedFormGroup; + protected userForm?: UntypedFormGroup; + protected pwdForm?: UntypedFormGroup; - public genders: Gender[] = [Gender.GENDER_FEMALE, Gender.GENDER_MALE, Gender.GENDER_UNSPECIFIED]; + protected readonly genders: Gender[] = [Gender.GENDER_FEMALE, Gender.GENDER_MALE, Gender.GENDER_UNSPECIFIED]; - public policy?: PasswordComplexityPolicy; - public usePassword: boolean = false; + protected policy?: PasswordComplexityPolicy; + protected usePassword: boolean = false; - public forSelf: boolean = true; + protected forSelf: boolean = true; + + protected readonly setupOrgMutation = injectMutation(this.newOrganizationService.setupOrgMutationOptions); + protected readonly addOrgMutation = injectMutation(this.newOrganizationService.addOrgMutationOptions); constructor( private readonly router: Router, private readonly toast: ToastService, - private readonly adminService: AdminService, - private readonly _location: Location, + private readonly location: Location, private readonly fb: UntypedFormBuilder, - private readonly mgmtService: ManagementService, private readonly newMgmtService: NewMgmtService, - private readonly authService: GrpcAuthService, private readonly passwordComplexityValidatorFactory: PasswordComplexityValidatorFactoryService, public readonly langSvc: LanguagesService, + private readonly newOrganizationService: NewOrganizationService, breadcrumbService: BreadcrumbService, ) { const instanceBread = new Breadcrumb({ @@ -73,38 +74,38 @@ export class OrgCreateComponent { public createSteps: number = 2; public currentCreateStep: number = 1; - public finish(): void { - const createOrgRequest: SetUpOrgRequest.Org = new SetUpOrgRequest.Org(); - createOrgRequest.setName(this.name?.value); - createOrgRequest.setDomain(this.domain?.value); + public async finish(): Promise { + const req: MessageInitShape = { + org: { + name: this.name?.value, + domain: this.domain?.value, + }, + user: { + case: 'human', + value: { + email: { + email: this.email?.value, + isEmailVerified: this.isVerified?.value, + }, + userName: this.userName?.value, + profile: { + firstName: this.firstName?.value, + lastName: this.lastName?.value, + nickName: this.nickName?.value, + gender: this.gender?.value, + preferredLanguage: this.preferredLanguage?.value, + }, + password: this.usePassword && this.password ? this.password.value : undefined, + }, + }, + }; - const humanRequest: SetUpOrgRequest.Human = new SetUpOrgRequest.Human(); - humanRequest.setEmail( - new SetUpOrgRequest.Human.Email().setEmail(this.email?.value).setIsEmailVerified(this.isVerified?.value), - ); - humanRequest.setUserName(this.userName?.value); - - const profile: SetUpOrgRequest.Human.Profile = new SetUpOrgRequest.Human.Profile(); - profile.setFirstName(this.firstName?.value); - profile.setLastName(this.lastName?.value); - profile.setNickName(this.nickName?.value); - profile.setGender(this.gender?.value); - profile.setPreferredLanguage(this.preferredLanguage?.value); - - humanRequest.setProfile(profile); - if (this.usePassword && this.password) { - humanRequest.setPassword(this.password?.value); + try { + await this.setupOrgMutation.mutateAsync(req); + await this.router.navigate(['/orgs']); + } catch (error) { + this.toast.showError(error); } - - this.adminService - .SetUpOrg(createOrgRequest, humanRequest) - .then(() => { - this.authService.revalidateOrgs().then(); - this.router.navigate(['/orgs']).then(); - }) - .catch((error) => { - this.toast.showError(error); - }); } public next(): void { @@ -161,17 +162,15 @@ export class OrgCreateComponent { } } - public createOrgForSelf(): void { - if (this.name && this.name.value) { - this.mgmtService - .addOrg(this.name.value) - .then(() => { - this.authService.revalidateOrgs().then(); - this.router.navigate(['/orgs']).then(); - }) - .catch((error) => { - this.toast.showError(error); - }); + public async createOrgForSelf() { + if (!this.name?.value) { + return; + } + try { + await this.addOrgMutation.mutateAsync(this.name.value); + await this.router.navigate(['/orgs']); + } catch (error) { + this.toast.showError(error); } } @@ -224,6 +223,6 @@ export class OrgCreateComponent { } public close(): void { - this._location.back(); + this.location.back(); } } diff --git a/console/src/app/pages/org-list/org-list.component.html b/console/src/app/pages/org-list/org-list.component.html index b30cc46d08e..5090ec08796 100644 --- a/console/src/app/pages/org-list/org-list.component.html +++ b/console/src/app/pages/org-list/org-list.component.html @@ -3,6 +3,6 @@

{{ 'ORG.PAGES.LIST' | translate }}

{{ 'ORG.PAGES.LISTDESCRIPTION' | translate }}

- +
diff --git a/console/src/app/pages/orgs/org-detail/org-detail.component.html b/console/src/app/pages/orgs/org-detail/org-detail.component.html index c5a6f9533c1..701fc6a729d 100644 --- a/console/src/app/pages/orgs/org-detail/org-detail.component.html +++ b/console/src/app/pages/orgs/org-detail/org-detail.component.html @@ -1,78 +1,60 @@ - - - - - - - - - - - + - + + - - -
- - - + + + + + + + - + + +
+ + - -
- {{ - 'ORG.PAGES.NOPERMISSION' | translate - }} +
+
- - -
- -
- -
+
+
+ diff --git a/console/src/app/pages/orgs/org-detail/org-detail.component.ts b/console/src/app/pages/orgs/org-detail/org-detail.component.ts index 39514d33d32..7187a10a042 100644 --- a/console/src/app/pages/orgs/org-detail/org-detail.component.ts +++ b/console/src/app/pages/orgs/org-detail/org-detail.component.ts @@ -1,7 +1,7 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { ChangeDetectorRef, Component, effect, OnInit, signal } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { Router } from '@angular/router'; -import { BehaviorSubject, from, Observable, of, Subject, takeUntil } from 'rxjs'; +import { BehaviorSubject, from, lastValueFrom, Observable, of } from 'rxjs'; import { catchError, finalize, map } from 'rxjs/operators'; import { CreationType, MemberCreateDialogComponent } from 'src/app/modules/add-member-dialog/member-create-dialog.component'; import { ChangeType } from 'src/app/modules/changes/changes.component'; @@ -12,24 +12,24 @@ import { PolicyComponentServiceType } from 'src/app/modules/policies/policy-comp import { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component'; import { Member } from 'src/app/proto/generated/zitadel/member_pb'; import { Metadata } from 'src/app/proto/generated/zitadel/metadata_pb'; -import { Org, OrgState } from 'src/app/proto/generated/zitadel/org_pb'; import { User } from 'src/app/proto/generated/zitadel/user_pb'; -import { AdminService } from 'src/app/services/admin.service'; import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service'; -import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; import { ManagementService } from 'src/app/services/mgmt.service'; import { ToastService } from 'src/app/services/toast.service'; +import { NewOrganizationService } from '../../../services/new-organization.service'; +import { injectMutation } from '@tanstack/angular-query-experimental'; +import { Organization, OrganizationState } from '@zitadel/proto/zitadel/org/v2/org_pb'; +import { toObservable } from '@angular/core/rxjs-interop'; @Component({ selector: 'cnsl-org-detail', templateUrl: './org-detail.component.html', styleUrls: ['./org-detail.component.scss'], }) -export class OrgDetailComponent implements OnInit, OnDestroy { - public org?: Org.AsObject; +export class OrgDetailComponent implements OnInit { public PolicyComponentServiceType: any = PolicyComponentServiceType; - public OrgState: any = OrgState; + public OrganizationState = OrganizationState; public ChangeType: any = ChangeType; public metadata: Metadata.AsObject[] = []; @@ -40,18 +40,25 @@ export class OrgDetailComponent implements OnInit, OnDestroy { public loading$: Observable = this.loadingSubject.asObservable(); public totalMemberResult: number = 0; public membersSubject: BehaviorSubject = new BehaviorSubject([]); - private destroy$: Subject = new Subject(); public InfoSectionType: any = InfoSectionType; + protected readonly orgQuery = this.newOrganizationService.activeOrganizationQuery(); + private readonly reactivateOrgMutation = injectMutation(this.newOrganizationService.reactivateOrgMutationOptions); + private readonly deactivateOrgMutation = injectMutation(this.newOrganizationService.deactivateOrgMutationOptions); + private readonly deleteOrgMutation = injectMutation(this.newOrganizationService.deleteOrgMutationOptions); + private readonly renameOrgMutation = injectMutation(this.newOrganizationService.renameOrgMutationOptions); + + protected reloadChanges = signal(true); + constructor( - private auth: GrpcAuthService, - private dialog: MatDialog, - public mgmtService: ManagementService, - private adminService: AdminService, - private toast: ToastService, - private router: Router, + private readonly dialog: MatDialog, + private readonly mgmtService: ManagementService, + private readonly toast: ToastService, + private readonly router: Router, + private readonly newOrganizationService: NewOrganizationService, breadcrumbService: BreadcrumbService, + cdr: ChangeDetectorRef, ) { const bread: Breadcrumb = { type: BreadcrumbType.ORG, @@ -59,26 +66,30 @@ export class OrgDetailComponent implements OnInit, OnDestroy { }; breadcrumbService.setBreadcrumb([bread]); - auth.activeOrgChanged.pipe(takeUntil(this.destroy$)).subscribe((org) => { - if (this.org && org) { - this.getData(); - this.loadMetadata(); + effect(() => { + const orgId = this.newOrganizationService.orgId(); + if (!orgId) { + return; } + this.loadMembers(); + this.loadMetadata(); + }); + + // force rerender changes because it is not reactive to orgId changes + toObservable(this.newOrganizationService.orgId).subscribe(() => { + this.reloadChanges.set(false); + cdr.detectChanges(); + this.reloadChanges.set(true); }); } public ngOnInit(): void { - this.getData(); + this.loadMembers(); this.loadMetadata(); } - public ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } - - public changeState(newState: OrgState): void { - if (newState === OrgState.ORG_STATE_ACTIVE) { + public async changeState(newState: OrganizationState) { + if (newState === OrganizationState.ACTIVE) { const dialogRef = this.dialog.open(WarnDialogComponent, { data: { confirmKey: 'ACTIONS.REACTIVATE', @@ -88,20 +99,20 @@ export class OrgDetailComponent implements OnInit, OnDestroy { }, width: '400px', }); - dialogRef.afterClosed().subscribe((resp) => { - if (resp) { - this.mgmtService - .reactivateOrg() - .then(() => { - this.toast.showInfo('ORG.TOAST.REACTIVATED', true); - this.org!.state = OrgState.ORG_STATE_ACTIVE; - }) - .catch((error) => { - this.toast.showError(error); - }); - } - }); - } else if (newState === OrgState.ORG_STATE_INACTIVE) { + const resp = await lastValueFrom(dialogRef.afterClosed()); + if (!resp) { + return; + } + try { + await this.reactivateOrgMutation.mutateAsync(); + this.toast.showInfo('ORG.TOAST.REACTIVATED', true); + } catch (error) { + this.toast.showError(error); + } + return; + } + + if (newState === OrganizationState.INACTIVE) { const dialogRef = this.dialog.open(WarnDialogComponent, { data: { confirmKey: 'ACTIONS.DEACTIVATE', @@ -111,23 +122,21 @@ export class OrgDetailComponent implements OnInit, OnDestroy { }, width: '400px', }); - dialogRef.afterClosed().subscribe((resp) => { - if (resp) { - this.mgmtService - .deactivateOrg() - .then(() => { - this.toast.showInfo('ORG.TOAST.DEACTIVATED', true); - this.org!.state = OrgState.ORG_STATE_INACTIVE; - }) - .catch((error) => { - this.toast.showError(error); - }); - } - }); + + const resp = await lastValueFrom(dialogRef.afterClosed()); + if (!resp) { + return; + } + try { + await this.deactivateOrgMutation.mutateAsync(); + this.toast.showInfo('ORG.TOAST.DEACTIVATED', true); + } catch (error) { + this.toast.showError(error); + } } } - public deleteOrg(): void { + public async deleteOrg(org: Organization) { const mgmtUserData = { confirmKey: 'ACTIONS.DELETE', cancelKey: 'ACTIONS.CANCEL', @@ -136,66 +145,25 @@ export class OrgDetailComponent implements OnInit, OnDestroy { hintKey: 'ORG.DIALOG.DELETE.TYPENAME', hintParam: 'ORG.DIALOG.DELETE.DESCRIPTION', confirmationKey: 'ORG.DIALOG.DELETE.ORGNAME', - confirmation: this.org?.name, + confirmation: org.name, }; - if (this.org) { - let dialogRef; + const dialogRef = this.dialog.open(WarnDialogComponent, { + data: mgmtUserData, + width: '400px', + }); - dialogRef = this.dialog.open(WarnDialogComponent, { - data: mgmtUserData, - width: '400px', - }); - - // Before we remove the org we get the current default org - // we have to query before the current org is removed - dialogRef.afterClosed().subscribe((resp) => { - if (resp) { - this.adminService - .getDefaultOrg() - .then((response) => { - const org = response?.org; - if (org) { - // We now remove the org - this.mgmtService - .removeOrg() - .then(() => { - setTimeout(() => { - // We change active org to default org as - // current org was deleted to avoid Organization doesn't exist - this.auth.setActiveOrg(org); - // Now we visit orgs - this.router.navigate(['/orgs']); - }, 1000); - this.toast.showInfo('ORG.TOAST.DELETED', true); - }) - .catch((error) => { - this.toast.showError(error); - }); - } else { - this.toast.showError('ORG.TOAST.DEFAULTORGNOTFOUND', false, true); - } - }) - .catch((error) => { - this.toast.showError(error); - }); - } - }); + if (!(await lastValueFrom(dialogRef.afterClosed()))) { + return; } - } - private async getData(): Promise { - this.mgmtService - .getMyOrg() - .then((resp) => { - if (resp.org) { - this.org = resp.org; - } - }) - .catch((error) => { - this.toast.showError(error); - }); - this.loadMembers(); + try { + await this.deleteOrgMutation.mutateAsync(); + this.toast.showInfo('ORG.TOAST.DELETED', true); + await this.router.navigate(['/orgs']); + } catch (error) { + this.toast.showError(error); + } } public openAddMember(): void { @@ -234,8 +202,8 @@ export class OrgDetailComponent implements OnInit, OnDestroy { }); } - public showDetail(): void { - this.router.navigate(['org/members']); + public showDetail() { + return this.router.navigate(['org/members']); } public loadMembers(): void { @@ -296,10 +264,10 @@ export class OrgDetailComponent implements OnInit, OnDestroy { }); } - public renameOrg(): void { + public async renameOrg(org: Organization): Promise { const dialogRef = this.dialog.open(NameDialogComponent, { data: { - name: this.org?.name, + name: org.name, titleKey: 'ORG.PAGES.RENAME.TITLE', descKey: 'ORG.PAGES.RENAME.DESCRIPTION', labelKey: 'ORG.PAGES.NAME', @@ -307,37 +275,20 @@ export class OrgDetailComponent implements OnInit, OnDestroy { width: '400px', }); - dialogRef.afterClosed().subscribe((name) => { - if (name) { - this.updateOrg(name); - } - }); - } + const name = await lastValueFrom(dialogRef.afterClosed()); + if (org.name === name) { + return; + } - public updateOrg(name: string): void { - if (this.org) { - this.mgmtService - .updateOrg(name) - .then(() => { - this.toast.showInfo('ORG.TOAST.UPDATED', true); - if (this.org) { - this.org.name = name; - } - this.mgmtService - .getMyOrg() - .then((resp) => { - if (resp.org) { - this.org = resp.org; - this.auth.setActiveOrg(resp.org); - } - }) - .catch((error) => { - this.toast.showError(error); - }); - }) - .catch((error) => { - this.toast.showError(error); - }); + try { + await this.renameOrgMutation.mutateAsync(name); + this.toast.showInfo('ORG.TOAST.UPDATED', true); + const resp = await this.mgmtService.getMyOrg(); + if (resp.org) { + await this.newOrganizationService.setOrgId(resp.org.id); + } + } catch (error) { + this.toast.showError(error); } } } diff --git a/console/src/app/pages/orgs/org.module.ts b/console/src/app/pages/orgs/org.module.ts index ae36a626efe..12f97482247 100644 --- a/console/src/app/pages/orgs/org.module.ts +++ b/console/src/app/pages/orgs/org.module.ts @@ -19,7 +19,6 @@ import { InputModule } from 'src/app/modules/input/input.module'; import { MetaLayoutModule } from 'src/app/modules/meta-layout/meta-layout.module'; import { MetadataModule } from 'src/app/modules/metadata/metadata.module'; import { NameDialogModule } from 'src/app/modules/name-dialog/name-dialog.module'; -import { SettingsGridModule } from 'src/app/modules/settings-grid/settings-grid.module'; import { TopViewModule } from 'src/app/modules/top-view/top-view.module'; import { WarnDialogModule } from 'src/app/modules/warn-dialog/warn-dialog.module'; import { HasRolePipeModule } from 'src/app/pipes/has-role-pipe/has-role-pipe.module'; @@ -55,7 +54,6 @@ import { OrgRoutingModule } from './org-routing.module'; MatProgressSpinnerModule, MetadataModule, TranslateModule, - SettingsGridModule, ContributorsModule, CopyToClipboardModule, ], diff --git a/console/src/app/pages/projects/project-grid/project-grid.component.ts b/console/src/app/pages/projects/project-grid/project-grid.component.ts index fba5dc358fb..407315853f4 100644 --- a/console/src/app/pages/projects/project-grid/project-grid.component.ts +++ b/console/src/app/pages/projects/project-grid/project-grid.component.ts @@ -220,13 +220,13 @@ export class ProjectGridComponent implements OnInit, OnDestroy { } private async getPrefixedItem(key: string): Promise { - const org = this.storage.getItem(StorageKey.organization, StorageLocation.session) as Org.AsObject; - return localStorage.getItem(`${org?.id}:${key}`); + const org = this.storage.getItem(StorageKey.organizationId, StorageLocation.session); + return localStorage.getItem(`${org}:${key}`); } private async setPrefixedItem(key: string, value: any): Promise { - const org = this.storage.getItem(StorageKey.organization, StorageLocation.session) as Org.AsObject; - return localStorage.setItem(`${org.id}:${key}`, value); + const org = this.storage.getItem(StorageKey.organizationId, StorageLocation.session); + return localStorage.setItem(`${org}:${key}`, value); } public navigateToProject(type: ProjectType, item: Project.AsObject | GrantedProject.AsObject, event: any): void { diff --git a/console/src/app/pages/user-grant-create/user-grant-create.component.html b/console/src/app/pages/user-grant-create/user-grant-create.component.html index e2ebbf95838..5d414d4350a 100644 --- a/console/src/app/pages/user-grant-create/user-grant-create.component.html +++ b/console/src/app/pages/user-grant-create/user-grant-create.component.html @@ -1,4 +1,5 @@ = new Subject(); constructor( - private userService: ManagementService, - private toast: ToastService, - private _location: Location, - private route: ActivatedRoute, - private mgmtService: ManagementService, - private storage: StorageService, + private readonly userService: ManagementService, + private readonly toast: ToastService, + private readonly _location: Location, + private readonly route: ActivatedRoute, + private readonly mgmtService: ManagementService, + private readonly newOrganizationService: NewOrganizationService, breadcrumbService: BreadcrumbService, ) { breadcrumbService.setBreadcrumb([ @@ -101,10 +99,11 @@ export class UserGrantCreateComponent implements OnDestroy { } }); - const temporg = this.storage.getItem(StorageKey.organization, StorageLocation.session); - if (temporg) { - this.org = temporg; - } + effect(() => { + if (this.activateOrganizationQuery.isError()) { + this.toast.showError(this.activateOrganizationQuery.error()); + } + }); } public close(): void { diff --git a/console/src/app/pages/users/user-create/user-create-v2/user-create-v2.component.ts b/console/src/app/pages/users/user-create/user-create-v2/user-create-v2.component.ts index 7c5d5a0e7f9..db007e4a67b 100644 --- a/console/src/app/pages/users/user-create/user-create-v2/user-create-v2.component.ts +++ b/console/src/app/pages/users/user-create/user-create-v2/user-create-v2.component.ts @@ -33,6 +33,7 @@ import { PasswordComplexityValidatorFactoryService } from 'src/app/services/pass import { NewFeatureService } from 'src/app/services/new-feature.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; +import { NewOrganizationService } from '../../../../services/new-organization.service'; type PwdForm = ReturnType; type AuthenticationFactor = @@ -54,6 +55,7 @@ export class UserCreateV2Component implements OnInit { private readonly passwordComplexityPolicy$: Observable; protected readonly authenticationFactor$: Observable; private readonly useLoginV2$: Observable; + private orgId = this.organizationService.getOrgId(); constructor( private readonly router: Router, @@ -67,6 +69,7 @@ export class UserCreateV2Component implements OnInit { private readonly route: ActivatedRoute, protected readonly location: Location, private readonly authService: GrpcAuthService, + private readonly organizationService: NewOrganizationService, ) { this.userForm = this.buildUserForm(); @@ -182,12 +185,11 @@ export class UserCreateV2Component implements OnInit { private async createUserV2Try(authenticationFactor: AuthenticationFactor) { this.loading.set(true); - const org = await this.authService.getActiveOrg(); - + this.organizationService.getOrgId(); const userValues = this.userForm.getRawValue(); const humanReq: MessageInitShape = { - organization: { org: { case: 'orgId', value: org.id } }, + organization: { org: { case: 'orgId', value: this.orgId() } }, username: userValues.username, profile: { givenName: userValues.givenName, diff --git a/console/src/app/pages/users/user-detail/auth-user-detail/auth-user-detail.component.html b/console/src/app/pages/users/user-detail/auth-user-detail/auth-user-detail.component.html index c39caba8c40..888150380e3 100644 --- a/console/src/app/pages/users/user-detail/auth-user-detail/auth-user-detail.component.html +++ b/console/src/app/pages/users/user-detail/auth-user-detail/auth-user-detail.component.html @@ -1,34 +1,34 @@ - + + /> -
+
- + - + @@ -58,7 +58,7 @@ class="icon-button" card-actions mat-icon-button - (click)="refreshChanges$.emit()" + (click)="invalidateUser()" matTooltip="{{ 'ACTIONS.REFRESH' | translate }}" > refresh @@ -94,7 +94,7 @@ - +
@@ -121,7 +121,7 @@ @@ -170,7 +170,7 @@
- +
diff --git a/console/src/app/pages/users/user-detail/auth-user-detail/auth-user-detail.component.ts b/console/src/app/pages/users/user-detail/auth-user-detail/auth-user-detail.component.ts index b29d21dc9e9..1ed66cff87b 100644 --- a/console/src/app/pages/users/user-detail/auth-user-detail/auth-user-detail.component.ts +++ b/console/src/app/pages/users/user-detail/auth-user-detail/auth-user-detail.component.ts @@ -1,11 +1,10 @@ -import { MediaMatcher } from '@angular/cdk/layout'; -import { Component, DestroyRef, EventEmitter, OnInit, signal } from '@angular/core'; +import { Component, computed, DestroyRef, effect, OnInit, signal } from '@angular/core'; import { Validators } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { Buffer } from 'buffer'; -import { defer, EMPTY, mergeWith, Observable, of, shareReplay, Subject, switchMap, take } from 'rxjs'; +import { defer, EMPTY, Observable, of, shareReplay, Subject, switchMap, take } from 'rxjs'; import { ChangeType } from 'src/app/modules/changes/changes.component'; import { phoneValidator, requiredValidator } from 'src/app/modules/form-field/validators/validators'; import { InfoDialogComponent } from 'src/app/modules/info-dialog/info-dialog.component'; @@ -34,8 +33,7 @@ import { Metadata } from '@zitadel/proto/zitadel/metadata_pb'; import { UserService } from 'src/app/services/user.service'; import { LoginPolicy } from '@zitadel/proto/zitadel/policy_pb'; import { query } from '@angular/animations'; - -type UserQuery = { state: 'success'; value: User } | { state: 'error'; error: any } | { state: 'loading'; value?: User }; +import { QueryClient } from '@tanstack/angular-query-experimental'; type MetadataQuery = | { state: 'success'; value: Metadata[] } @@ -57,7 +55,6 @@ export class AuthUserDetailComponent implements OnInit { protected readonly UserState = UserState; protected USERGRANTCONTEXT: UserGrantContext = UserGrantContext.AUTHUSER; - protected readonly refreshChanges$: EventEmitter = new EventEmitter(); protected readonly refreshMetadata$ = new Subject(); protected readonly settingsList: SidenavSetting[] = [ @@ -72,12 +69,25 @@ export class AuthUserDetailComponent implements OnInit { requiredRoles: { [PolicyComponentServiceType.MGMT]: ['user.read'] }, }, ]; - protected readonly user$: Observable; protected readonly metadata$: Observable; - private readonly savedLanguage$: Observable; protected readonly currentSetting$ = signal(this.settingsList[0]); protected readonly loginPolicy$: Observable; - protected readonly userName$: Observable; + protected readonly user = this.userService.userQuery(); + protected readonly refreshChanges$ = new Subject(); + + protected readonly userName = computed(() => { + const user = this.user.data(); + if (!user) { + return ''; + } + if (user.type.case === 'human') { + return user.type.value.profile?.displayName ?? ''; + } + if (user.type.case === 'machine') { + return user.type.value.name; + } + return ''; + }); constructor( private translate: TranslateService, @@ -92,11 +102,8 @@ export class AuthUserDetailComponent implements OnInit { private readonly newMgmtService: NewMgmtService, private readonly userService: UserService, private readonly destroyRef: DestroyRef, - private readonly router: Router, + private readonly queryClient: QueryClient, ) { - this.user$ = this.getUser$().pipe(shareReplay({ refCount: true, bufferSize: 1 })); - this.userName$ = this.getUserName(this.user$); - this.savedLanguage$ = this.getSavedLanguage$(this.user$); this.metadata$ = this.getMetadata$().pipe(shareReplay({ refCount: true, bufferSize: 1 })); this.loginPolicy$ = defer(() => this.newMgmtService.getLoginPolicy()).pipe( @@ -104,61 +111,40 @@ export class AuthUserDetailComponent implements OnInit { map(({ policy }) => policy), filter(Boolean), ); - } - getUserName(user$: Observable) { - return user$.pipe( - map((query) => { - const user = this.user(query); - if (!user) { - return ''; + effect( + () => { + const user = this.user.data(); + if (!user || user.type.case !== 'human') { + return; } - if (user.type.case === 'human') { - return user.type.value.profile?.displayName ?? ''; - } - if (user.type.case === 'machine') { - return user.type.value.name; - } - return ''; - }), - ); - } - getSavedLanguage$(user$: Observable) { - return user$.pipe( - switchMap((query) => { - if (query.state !== 'success' || query.value.type.case !== 'human') { - return EMPTY; - } - return query.value.type.value.profile?.preferredLanguage ?? EMPTY; - }), - startWith(this.translate.defaultLang), - ); - } - - ngOnInit(): void { - this.user$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((query) => { - if ((query.state === 'loading' || query.state === 'success') && query.value?.type.case === 'human') { this.breadcrumbService.setBreadcrumb([ new Breadcrumb({ type: BreadcrumbType.AUTHUSER, - name: query.value.type.value.profile?.displayName, + name: user.type.value.profile?.displayName, routerLink: ['/users', 'me'], }), ]); + }, + { allowSignalWrites: true }, + ); + + effect(() => { + const error = this.user.error(); + if (error) { + this.toast.showError(error); } }); + } - this.user$.pipe(mergeWith(this.metadata$), takeUntilDestroyed(this.destroyRef)).subscribe((query) => { + ngOnInit(): void { + this.metadata$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((query) => { if (query.state == 'error') { this.toast.showError(query.error); } }); - this.savedLanguage$ - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((savedLanguage) => this.translate.use(savedLanguage)); - const param = this.route.snapshot.queryParamMap.get('id'); if (!param) { return; @@ -170,28 +156,6 @@ export class AuthUserDetailComponent implements OnInit { this.currentSetting$.set(setting); } - private getUser$(): Observable { - return this.refreshChanges$.pipe( - startWith(true), - switchMap(() => this.getMyUser()), - pairwiseStartWith(undefined), - map(([prev, curr]) => { - if (prev?.state === 'success' && curr.state === 'loading') { - return { state: 'loading', value: prev.value } as const; - } - return curr; - }), - ); - } - - private getMyUser(): Observable { - return this.userService.user$.pipe( - map((user) => ({ state: 'success' as const, value: user })), - catchError((error) => of({ state: 'error', error } as const)), - startWith({ state: 'loading' } as const), - ); - } - getMetadata$(): Observable { return this.refreshMetadata$.pipe( startWith(true), @@ -214,7 +178,14 @@ export class AuthUserDetailComponent implements OnInit { ); } - public changeUsername(user: User): void { + protected invalidateUser() { + this.refreshChanges$.next(); + return this.queryClient.invalidateQueries({ + queryKey: this.userService.userQueryOptions().queryKey, + }); + } + + protected changeUsername(user: User): void { const data = { confirmKey: 'ACTIONS.CHANGE' as const, cancelKey: 'ACTIONS.CANCEL' as const, @@ -239,7 +210,7 @@ export class AuthUserDetailComponent implements OnInit { .subscribe({ next: () => { this.toast.showInfo('USER.TOAST.USERNAMECHANGED', true); - this.refreshChanges$.emit(); + this.invalidateUser().then(); }, error: (error) => { this.toast.showError(error); @@ -262,7 +233,7 @@ export class AuthUserDetailComponent implements OnInit { }) .then(() => { this.toast.showInfo('USER.TOAST.SAVED', true); - this.refreshChanges$.emit(); + this.invalidateUser().then(); }) .catch((error) => { this.toast.showError(error); @@ -274,7 +245,7 @@ export class AuthUserDetailComponent implements OnInit { .verifyMyPhone(code) .then(() => { this.toast.showInfo('USER.TOAST.PHONESAVED', true); - this.refreshChanges$.emit(); + this.invalidateUser().then(); this.promptSetupforSMSOTP(); }) .catch((error) => { @@ -315,7 +286,7 @@ export class AuthUserDetailComponent implements OnInit { .resendHumanEmailVerification(user.userId) .then(() => { this.toast.showInfo('USER.TOAST.EMAILVERIFICATIONSENT', true); - this.refreshChanges$.emit(); + this.invalidateUser().then(); }) .catch((error) => { this.toast.showError(error); @@ -327,7 +298,7 @@ export class AuthUserDetailComponent implements OnInit { .resendHumanPhoneVerification(user.userId) .then(() => { this.toast.showInfo('USER.TOAST.PHONEVERIFICATIONSENT', true); - this.refreshChanges$.emit(); + this.invalidateUser().then(); }) .catch((error) => { this.toast.showError(error); @@ -339,7 +310,7 @@ export class AuthUserDetailComponent implements OnInit { .removePhone(user.userId) .then(() => { this.toast.showInfo('USER.TOAST.PHONEREMOVED', true); - this.refreshChanges$.emit(); + this.invalidateUser().then(); }) .catch((error) => { this.toast.showError(error); @@ -388,7 +359,7 @@ export class AuthUserDetailComponent implements OnInit { .subscribe({ next: () => { this.toast.showInfo('USER.TOAST.EMAILSAVED', true); - this.refreshChanges$.emit(); + this.invalidateUser().then(); }, error: (error) => this.toast.showError(error), }); @@ -420,7 +391,7 @@ export class AuthUserDetailComponent implements OnInit { .subscribe({ next: () => { this.toast.showInfo('USER.TOAST.PHONESAVED', true); - this.refreshChanges$.emit(); + this.invalidateUser().then(); }, error: (error) => { this.toast.showError(error); @@ -482,24 +453,7 @@ export class AuthUserDetailComponent implements OnInit { protected readonly query = query; - protected user(user: UserQuery): User | undefined { - if (user.state === 'success' || user.state === 'loading') { - return user.value; - } - return; - } - - public async goToSetting(setting: string) { - await this.router.navigate([], { - relativeTo: this.route, - queryParams: { id: setting }, - queryParamsHandling: 'merge', - skipLocationChange: true, - }); - } - - public humanUser(userQuery: UserQuery): UserWithHumanType | undefined { - const user = this.user(userQuery); + public humanUser(user: User | undefined): UserWithHumanType | undefined { if (user?.type.case === 'human') { return { ...user, type: user.type }; } diff --git a/console/src/app/pages/users/user-detail/password/password.component.ts b/console/src/app/pages/users/user-detail/password/password.component.ts index ef18559e096..e18116c2c3f 100644 --- a/console/src/app/pages/users/user-detail/password/password.component.ts +++ b/console/src/app/pages/users/user-detail/password/password.component.ts @@ -1,4 +1,4 @@ -import { Component, DestroyRef, OnInit } from '@angular/core'; +import { Component, computed, DestroyRef, OnInit } from '@angular/core'; import { AbstractControl, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; import { @@ -17,7 +17,7 @@ import { passwordConfirmValidator, requiredValidator } from 'src/app/modules/for import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service'; import { ToastService } from 'src/app/services/toast.service'; import { catchError, filter } from 'rxjs/operators'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { UserService } from 'src/app/services/user.service'; import { User } from '@zitadel/proto/zitadel/user/v2/user_pb'; import { NewAuthService } from 'src/app/services/new-auth.service'; @@ -62,12 +62,16 @@ export class PasswordComponent implements OnInit { } private getUser() { - return this.userService.user$.pipe( - catchError((err) => { - this.toast.showError(err); - return EMPTY; - }), - ); + const userQuery = this.userService.userQuery(); + const userSignal = computed(() => { + if (userQuery.isError()) { + this.toast.showError(userQuery.error()); + } + + return userQuery.data(); + }); + + return toObservable(userSignal).pipe(filter(Boolean)); } private getUsername(user$: Observable) { diff --git a/console/src/app/pages/users/user-detail/user-detail/user-detail.component.ts b/console/src/app/pages/users/user-detail/user-detail/user-detail.component.ts index 45ed6cd30a0..2a769b9e79f 100644 --- a/console/src/app/pages/users/user-detail/user-detail/user-detail.component.ts +++ b/console/src/app/pages/users/user-detail/user-detail/user-detail.component.ts @@ -37,7 +37,6 @@ import { combineLatestWith, defer, EMPTY, - identity, mergeWith, Observable, ObservedValueOf, diff --git a/console/src/app/pages/users/user-list/user-table/user-table.component.html b/console/src/app/pages/users/user-list/user-table/user-table.component.html index af293c01a43..3dac51d8e21 100644 --- a/console/src/app/pages/users/user-list/user-table/user-table.component.html +++ b/console/src/app/pages/users/user-list/user-table/user-table.component.html @@ -1,244 +1,247 @@ - -
- - -
-

- {{ - (type === Type.HUMAN ? 'DESCRIPTIONS.USERS.HUMANS.DESCRIPTION' : 'DESCRIPTIONS.USERS.MACHINES.DESCRIPTION') | translate - }} -

- - - - - - - + + + -
- add - {{ 'ACTIONS.NEW' | translate }} - - -
- - + (filterChanged)="this.searchQueries$.next($any($event))" + (filterOpen)="filterOpen = $event" + >
+ + + -
- - - - + + -
- - {{ 'USER.TABLE.EMPTY' | translate }} -
- - - + + + + + + + + + + + + +
-
- - - -
-
-
- - + + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - - - - - - - - - - - - - - - + - + {{ 'USER.STATEV2.' + user.state | translate }} + + + - - -
+
+ - - - + - - -
- - + + +
+
+ + + + + + + + + +
+
- {{ 'USER.PROFILE.DISPLAYNAME' | translate }} - - {{ user.type.value?.profile?.displayName }} - {{ user.type.value.name }} - + {{ 'USER.PROFILE.DISPLAYNAME' | translate }} + + {{ user.type.value?.profile?.displayName }} + {{ user.type.value.name }} + - {{ 'USER.PROFILE.PREFERREDLOGINNAME' | translate }} - - {{ user.preferredLoginName }} - + {{ 'USER.PROFILE.PREFERREDLOGINNAME' | translate }} + + {{ user.preferredLoginName }} + - {{ 'USER.PROFILE.USERNAME' | translate }} - - {{ user.username }} - + {{ 'USER.PROFILE.USERNAME' | translate }} + + {{ user.username }} + - {{ 'USER.EMAIL' | translate }} - - {{ user.type.value.email.email }} - + {{ 'USER.EMAIL' | translate }} + + {{ user.type.value.email.email }} + {{ 'USER.DATA.STATE' | translate }} - - {{ 'USER.STATEV2.' + user.state | translate }} - - {{ 'USER.TABLE.CREATIONDATE' | translate }} - {{ user.details.creationDate | timestampToDate | localizedDate: 'regular' }} - {{ 'USER.TABLE.CHANGEDATE' | translate }} - {{ user.details.changeDate | timestampToDate | localizedDate: 'regular' }} - - - {{ 'USER.DATA.STATE' | translate }} + - - - -
-
+ +
{{ 'USER.TABLE.CREATIONDATE' | translate }} + {{ user.details.creationDate | timestampToDate | localizedDate: 'regular' }} + {{ 'USER.TABLE.CHANGEDATE' | translate }} + {{ user.details.changeDate | timestampToDate | localizedDate: 'regular' }} + + + + +
+
+ +
+ + {{ 'USER.TABLE.EMPTY' | translate }} +
+ + +
+ diff --git a/console/src/app/pages/users/user-list/user-table/user-table.component.ts b/console/src/app/pages/users/user-list/user-table/user-table.component.ts index cc5d753f36e..c5d6d468006 100644 --- a/console/src/app/pages/users/user-list/user-table/user-table.component.ts +++ b/console/src/app/pages/users/user-list/user-table/user-table.component.ts @@ -1,5 +1,16 @@ import { SelectionModel } from '@angular/cdk/collections'; -import { Component, DestroyRef, EventEmitter, Input, OnInit, Output, signal, Signal, ViewChild } from '@angular/core'; +import { + Component, + DestroyRef, + effect, + EventEmitter, + Input, + OnInit, + Output, + signal, + Signal, + ViewChild, +} from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { MatSort, SortDirection } from '@angular/material/sort'; import { MatTableDataSource } from '@angular/material/table'; @@ -11,13 +22,11 @@ import { delay, distinctUntilChanged, EMPTY, - from, Observable, of, ReplaySubject, shareReplay, switchMap, - toArray, } from 'rxjs'; import { catchError, filter, finalize, map, startWith, take } from 'rxjs/operators'; import { enterAnimations } from 'src/app/animations'; @@ -26,15 +35,14 @@ import { PaginatorComponent } from 'src/app/modules/paginator/paginator.componen import { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component'; import { ToastService } from 'src/app/services/toast.service'; import { UserService } from 'src/app/services/user.service'; -import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; import { SearchQuery as UserSearchQuery } from 'src/app/proto/generated/zitadel/user_pb'; import { Type, UserFieldName } from '@zitadel/proto/zitadel/user/v2/query_pb'; import { UserState, User } from '@zitadel/proto/zitadel/user/v2/user_pb'; import { MessageInitShape } from '@bufbuild/protobuf'; import { ListUsersRequestSchema, ListUsersResponse } from '@zitadel/proto/zitadel/user/v2/user_service_pb'; -import { AuthenticationService } from 'src/app/services/authentication.service'; -import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; import { UserState as UserStateV1 } from 'src/app/proto/generated/zitadel/user_pb'; +import { NewOrganizationService } from 'src/app/services/new-organization.service'; type ListUsersRequest = MessageInitShape; type QueriesArray = NonNullable; @@ -50,20 +58,25 @@ type Query = NonNullable; export class UserTableComponent implements OnInit { protected readonly Type = Type; protected readonly refresh$ = new ReplaySubject(1); + protected readonly userQuery = this.userService.userQuery(); @Input() public canWrite$: Observable = of(false); @Input() public canDelete$: Observable = of(false); protected readonly dataSize: Signal; - protected readonly loading = signal(false); + protected readonly loading = signal(true); private readonly paginator$ = new ReplaySubject(1); - @ViewChild(PaginatorComponent) public set paginator(paginator: PaginatorComponent) { - this.paginator$.next(paginator); + @ViewChild(PaginatorComponent) public set paginator(paginator: PaginatorComponent | null) { + if (paginator) { + this.paginator$.next(paginator); + } } private readonly sort$ = new ReplaySubject(1); - @ViewChild(MatSort) public set sort(sort: MatSort) { - this.sort$.next(sort); + @ViewChild(MatSort) public set sort(sort: MatSort | null) { + if (sort) { + this.sort$.next(sort); + } } protected readonly INITIAL_PAGE_SIZE = 20; @@ -73,7 +86,6 @@ export class UserTableComponent implements OnInit { protected readonly users$: Observable; protected readonly type$: Observable; protected readonly searchQueries$ = new ReplaySubject(1); - protected readonly myUser: Signal; @Input() public displayedColumnsHuman: string[] = [ 'select', @@ -110,12 +122,10 @@ export class UserTableComponent implements OnInit { private readonly dialog: MatDialog, private readonly route: ActivatedRoute, private readonly destroyRef: DestroyRef, - private readonly authenticationService: AuthenticationService, - private readonly authService: GrpcAuthService, + private readonly newOrganizationService: NewOrganizationService, ) { this.type$ = this.getType$().pipe(shareReplay({ refCount: true, bufferSize: 1 })); this.users$ = this.getUsers(this.type$).pipe(shareReplay({ refCount: true, bufferSize: 1 })); - this.myUser = toSignal(this.getMyUser()); this.dataSize = toSignal( this.users$.pipe( @@ -124,6 +134,12 @@ export class UserTableComponent implements OnInit { ), { initialValue: 0 }, ); + + effect(() => { + if (this.userQuery.isError()) { + this.toast.showError(this.userQuery.error()); + } + }); } ngOnInit(): void { @@ -158,15 +174,6 @@ export class UserTableComponent implements OnInit { .then(); } - private getMyUser() { - return this.userService.user$.pipe( - catchError((error) => { - this.toast.showError(error); - return EMPTY; - }), - ); - } - private getType$(): Observable { return this.route.queryParamMap.pipe( map((params) => params.get('type')), @@ -221,20 +228,19 @@ export class UserTableComponent implements OnInit { } private getQueries(type$: Observable): Observable { - const activeOrgId$ = this.getActiveOrgId(); - + const orgId$ = toObservable(this.newOrganizationService.orgId).pipe(filter(Boolean)); return this.searchQueries$.pipe( startWith([]), - combineLatestWith(type$, activeOrgId$), - switchMap(([queries, type, organizationId]) => - from(queries).pipe( - map((query) => this.searchQueryToV2(query.toObject())), - startWith({ case: 'typeQuery' as const, value: { type } }), - startWith(organizationId ? { case: 'organizationIdQuery' as const, value: { organizationId } } : undefined), - filter(Boolean), - toArray(), - ), - ), + combineLatestWith(type$, orgId$), + map(([queries, type, organizationId]) => { + const mappedQueries = queries.map((q) => this.searchQueryToV2(q.toObject())); + + return [ + { case: 'typeQuery' as const, value: { type } }, + organizationId ? { case: 'organizationIdQuery' as const, value: { organizationId } } : undefined, + ...mappedQueries, + ].filter((q): q is NonNullable => !!q); + }), ); } @@ -399,7 +405,7 @@ export class UserTableComponent implements OnInit { }, 1000); } - public deleteUser(user: User): void { + public deleteUser(user: User, authUser: User): void { const authUserData = { confirmKey: 'ACTIONS.DELETE', cancelKey: 'ACTIONS.CANCEL', @@ -423,9 +429,7 @@ export class UserTableComponent implements OnInit { }; if (user?.userId) { - const authUser = this.myUser(); - console.log('my user', authUser); - const isMe = authUser?.userId === user.userId; + const isMe = authUser.userId === user.userId; let dialogRef; @@ -469,20 +473,4 @@ export class UserTableComponent implements OnInit { const selected = this.selection.selected; return selected ? selected.findIndex((user) => user.state !== UserState.INACTIVE) > -1 : false; } - - private getActiveOrgId() { - return this.authenticationService.authenticationChanged.pipe( - startWith(true), - filter(Boolean), - switchMap(() => - from(this.authService.getActiveOrg()).pipe( - catchError((err) => { - this.toast.showError(err); - return of(undefined); - }), - ), - ), - map((org) => org?.id), - ); - } } diff --git a/console/src/app/pipes/has-role-pipe/has-role.pipe.ts b/console/src/app/pipes/has-role-pipe/has-role.pipe.ts index 76848699abe..1a47a3820c7 100644 --- a/console/src/app/pipes/has-role-pipe/has-role.pipe.ts +++ b/console/src/app/pipes/has-role-pipe/has-role.pipe.ts @@ -1,14 +1,36 @@ -import { Pipe, PipeTransform } from '@angular/core'; -import { Observable } from 'rxjs'; +import { computed, Injector, Pipe, PipeTransform } from '@angular/core'; +import { delay, Observable } from 'rxjs'; import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; +import { NewAuthService } from '../../services/new-auth.service'; +import { toObservable } from '@angular/core/rxjs-interop'; +import { filter } from 'rxjs/operators'; @Pipe({ name: 'hasRole', }) export class HasRolePipe implements PipeTransform { - constructor(private authService: GrpcAuthService) {} + private readonly permissions = this.newAuthService.listMyZitadelPermissionsQuery(); - public transform(values: string[], requresAll: boolean = false): Observable { - return this.authService.isAllowed(values, requresAll); + constructor( + private readonly authService: GrpcAuthService, + private readonly newAuthService: NewAuthService, + private readonly injector: Injector, + ) {} + + public transform(values: string[], requiresAll: boolean = false): Observable { + const signal = computed(() => { + const permissions = this.permissions.data(); + if (!permissions) { + return undefined; + } + return this.authService.hasRoles(permissions, values, requiresAll); + }); + + return toObservable(signal, { + injector: this.injector, + }).pipe( + filter((hasRole): hasRole is Exclude => hasRole !== undefined), + delay(0), + ); } } diff --git a/console/src/app/services/admin.service.ts b/console/src/app/services/admin.service.ts index 59c125380f5..506056d915d 100644 --- a/console/src/app/services/admin.service.ts +++ b/console/src/app/services/admin.service.ts @@ -171,8 +171,6 @@ import { ListLoginPolicySecondFactorsResponse, ListMilestonesRequest, ListMilestonesResponse, - ListOrgsRequest, - ListOrgsResponse, ListProvidersRequest, ListProvidersResponse, ListSecretGeneratorsRequest, @@ -307,7 +305,6 @@ import { UpdateSMTPConfigRequest, UpdateSMTPConfigResponse, } from '../proto/generated/zitadel/admin_pb'; -import { Event } from '../proto/generated/zitadel/event_pb'; import { ResetCustomDomainClaimedMessageTextToDefaultRequest, ResetCustomDomainClaimedMessageTextToDefaultResponse, @@ -340,9 +337,6 @@ import { MilestoneQuery, MilestoneType, } from '../proto/generated/zitadel/milestone/v1/milestone_pb'; -import { OrgFieldName, OrgQuery } from '../proto/generated/zitadel/org_pb'; -import { SortDirection } from '@angular/material/sort'; -import { SMTPConfig } from '../proto/generated/zitadel/settings_pb'; export interface OnboardingActions { order: number; @@ -1404,33 +1398,4 @@ export class AdminService { public listMilestones(req: ListMilestonesRequest): Promise { return this.grpcService.admin.listMilestones(req, null).then((resp) => resp.toObject()); } - - public listOrgs( - limit: number, - offset: number, - queriesList?: OrgQuery[], - sortingColumn?: OrgFieldName, - sortingDirection?: SortDirection, - ): Promise { - const req = new ListOrgsRequest(); - const query = new ListQuery(); - if (limit) { - query.setLimit(limit); - } - if (offset) { - query.setOffset(offset); - } - if (sortingDirection) { - query.setAsc(sortingDirection === 'asc'); - } - req.setQuery(query); - if (sortingColumn) { - req.setSortingColumn(sortingColumn); - } - - if (queriesList) { - req.setQueriesList(queriesList); - } - return this.grpcService.admin.listOrgs(req, null).then((resp) => resp.toObject()); - } } diff --git a/console/src/app/services/authentication.service.ts b/console/src/app/services/authentication.service.ts index 3ee3024e40d..8b0fcc24050 100644 --- a/console/src/app/services/authentication.service.ts +++ b/console/src/app/services/authentication.service.ts @@ -10,8 +10,8 @@ import { ToastService } from './toast.service'; }) export class AuthenticationService { private authConfig!: AuthConfig; - private _authenticated: boolean = false; - private readonly _authenticationChanged: BehaviorSubject = new BehaviorSubject(this.authenticated); + private _authenticated = false; + private readonly _authenticationChanged = new BehaviorSubject(this.authenticated); constructor( private oauthService: OAuthService, @@ -45,9 +45,11 @@ export class AuthenticationService { } this.oauthService.configure(this.authConfig); this.oauthService.strictDiscoveryDocumentValidation = false; - await this.oauthService.loadDiscoveryDocumentAndTryLogin().catch((error) => { + try { + await this.oauthService.loadDiscoveryDocumentAndTryLogin(); + } catch (error) { this.toast.showError(error, false, false); - }); + } this._authenticated = this.oauthService.hasValidAccessToken(); if (!this.oauthService.hasValidIdToken() || !this.authenticated || partialConfig || force) { diff --git a/console/src/app/services/grpc-auth.service.ts b/console/src/app/services/grpc-auth.service.ts index 105cb624874..bcf65b8b348 100644 --- a/console/src/app/services/grpc-auth.service.ts +++ b/console/src/app/services/grpc-auth.service.ts @@ -1,18 +1,7 @@ import { Injectable } from '@angular/core'; import { SortDirection } from '@angular/material/sort'; import { OAuthService } from 'angular-oauth2-oidc'; -import { - BehaviorSubject, - combineLatestWith, - EMPTY, - identity, - mergeWith, - NEVER, - Observable, - of, - shareReplay, - Subject, -} from 'rxjs'; +import { BehaviorSubject, combineLatestWith, EMPTY, identity, mergeWith, NEVER, Observable, of, shareReplay } from 'rxjs'; import { catchError, distinctUntilChanged, filter, finalize, map, startWith, switchMap, tap, timeout } from 'rxjs/operators'; import { @@ -96,21 +85,18 @@ import { import { ChangeQuery } from '../proto/generated/zitadel/change_pb'; import { MetadataQuery } from '../proto/generated/zitadel/metadata_pb'; import { ListQuery } from '../proto/generated/zitadel/object_pb'; -import { Org, OrgFieldName, OrgIDQuery, OrgQuery } from '../proto/generated/zitadel/org_pb'; +import { OrgFieldName, OrgQuery } from '../proto/generated/zitadel/org_pb'; import { LabelPolicy, PrivacyPolicy } from '../proto/generated/zitadel/policy_pb'; import { Gender, MembershipQuery, User, WebAuthNVerification } from '../proto/generated/zitadel/user_pb'; import { GrpcService } from './grpc.service'; -import { StorageKey, StorageLocation, StorageService } from './storage.service'; - -const ORG_LIMIT = 10; +import { NewOrganizationService } from './new-organization.service'; +import { toObservable } from '@angular/core/rxjs-interop'; @Injectable({ providedIn: 'root', }) export class GrpcAuthService { - private _activeOrgChanged: Subject = new Subject(); public user: Observable; - private triggerPermissionsRefresh: Subject = new Subject(); public zitadelPermissions: Observable; public labelpolicy$!: Observable; @@ -121,26 +107,27 @@ export class GrpcAuthService { PrivacyPolicy.AsObject | undefined >(undefined); - public cachedOrgs: BehaviorSubject = new BehaviorSubject([]); private cachedLabelPolicies: { [orgId: string]: LabelPolicy.AsObject } = {}; private cachedPrivacyPolicies: { [orgId: string]: PrivacyPolicy.AsObject } = {}; constructor( private readonly grpcService: GrpcService, private oauthService: OAuthService, - private storage: StorageService, + newOrganizationService: NewOrganizationService, ) { - this.labelpolicy$ = this.activeOrgChanged.pipe( + const activeOrg = toObservable(newOrganizationService.orgId); + + this.labelpolicy$ = activeOrg.pipe( tap(() => this.labelPolicyLoading$.next(true)), - switchMap((org) => this.getMyLabelPolicy(org ? org.id : '')), + switchMap((org) => this.getMyLabelPolicy(org ?? '')), tap(() => this.labelPolicyLoading$.next(false)), finalize(() => this.labelPolicyLoading$.next(false)), filter((policy) => !!policy), shareReplay({ refCount: true, bufferSize: 1 }), ); - this.privacypolicy$ = this.activeOrgChanged.pipe( - switchMap((org) => this.getMyPrivacyPolicy(org ? org.id : '')), + this.privacypolicy$ = activeOrg.pipe( + switchMap((org) => this.getMyPrivacyPolicy(org ?? '')), filter((policy) => !!policy), catchError((err) => { console.error(err); @@ -161,7 +148,7 @@ export class GrpcAuthService { ); this.zitadelPermissions = this.user.pipe( - combineLatestWith(this.activeOrgChanged), + combineLatestWith(activeOrg), // ignore errors from observables catchError(() => of(true)), // make sure observable never completes @@ -197,85 +184,6 @@ export class GrpcAuthService { return this.grpcService.auth.listMyMetadata(req, null).then((resp) => resp.toObject()); } - public async getActiveOrg(id?: string): Promise { - if (id) { - const find = this.cachedOrgs.getValue().find((tmp) => tmp.id === id); - if (find) { - this.setActiveOrg(find); - return Promise.resolve(find); - } else { - const orgQuery = new OrgQuery(); - const orgIdQuery = new OrgIDQuery(); - orgIdQuery.setId(id); - orgQuery.setIdQuery(orgIdQuery); - - const orgs = (await this.listMyProjectOrgs(ORG_LIMIT, 0, [orgQuery])).resultList; - if (orgs.length === 1) { - this.setActiveOrg(orgs[0]); - return Promise.resolve(orgs[0]); - } else { - // throw error if the org was specifically requested but not found - return Promise.reject(new Error('requested organization not found')); - } - } - } else { - let orgs = this.cachedOrgs.getValue(); - const org = this.storage.getItem(StorageKey.organization, StorageLocation.local); - - if (org) { - orgs = (await this.listMyProjectOrgs(ORG_LIMIT, 0)).resultList; - this.cachedOrgs.next(orgs); - - const find = this.cachedOrgs.getValue().find((tmp) => tmp.id === id); - if (find) { - this.setActiveOrg(find); - return Promise.resolve(find); - } else { - const orgQuery = new OrgQuery(); - const orgIdQuery = new OrgIDQuery(); - orgIdQuery.setId(org.id); - orgQuery.setIdQuery(orgIdQuery); - - const specificOrg = (await this.listMyProjectOrgs(ORG_LIMIT, 0, [orgQuery])).resultList; - if (specificOrg.length === 1) { - this.setActiveOrg(specificOrg[0]); - return Promise.resolve(specificOrg[0]); - } - } - } else { - orgs = (await this.listMyProjectOrgs(ORG_LIMIT, 0)).resultList; - this.cachedOrgs.next(orgs); - } - - if (orgs.length === 0) { - this._activeOrgChanged.next(undefined); - return Promise.reject(new Error('No organizations found!')); - } - - const orgToSet = orgs.find((element) => element.id !== '0' && element.name !== ''); - if (orgToSet) { - this.setActiveOrg(orgToSet); - return Promise.resolve(orgToSet); - } - return Promise.resolve(orgs[0]); - } - } - - public get activeOrgChanged(): Observable { - return this._activeOrgChanged.asObservable(); - } - - public setActiveOrg(org: Org.AsObject): void { - // Set organization in localstorage to get the last used organization in a new tab - this.storage.setItem(StorageKey.organization, org, StorageLocation.local); - this.storage.setItem(StorageKey.organization, org, StorageLocation.session); - this._activeOrgChanged.next(org); - } - - private loadPermissions(): void { - this.triggerPermissionsRefresh.next(); - } - /** * returns true if user has one of the provided roles * @param roles roles of the user @@ -345,11 +253,6 @@ export class GrpcAuthService { return this.grpcService.auth.getMyUser(new GetMyUserRequest(), null).then((resp) => resp.toObject()); } - public async revalidateOrgs() { - const orgs = (await this.listMyProjectOrgs(ORG_LIMIT, 0)).resultList; - this.cachedOrgs.next(orgs); - } - public listMyProjectOrgs( limit?: number, offset?: number, diff --git a/console/src/app/services/grpc.service.ts b/console/src/app/services/grpc.service.ts index b8be808f8da..51a40963930 100644 --- a/console/src/app/services/grpc.service.ts +++ b/console/src/app/services/grpc.service.ts @@ -16,8 +16,13 @@ import { ExhaustedGrpcInterceptor } from './interceptors/exhausted.grpc.intercep import { I18nInterceptor } from './interceptors/i18n.interceptor'; import { NewConnectWebOrgInterceptor, OrgInterceptor, OrgInterceptorProvider } from './interceptors/org.interceptor'; import { UserServiceClient } from '../proto/generated/zitadel/user/v2/User_serviceServiceClientPb'; -import { createFeatureServiceClient, createUserServiceClient, createSessionServiceClient } from '@zitadel/client/v2'; -import { createAuthServiceClient, createManagementServiceClient } from '@zitadel/client/v1'; +import { + createFeatureServiceClient, + createUserServiceClient, + createSessionServiceClient, + createOrganizationServiceClient, +} from '@zitadel/client/v2'; +import { createAdminServiceClient, createAuthServiceClient, createManagementServiceClient } from '@zitadel/client/v1'; import { createGrpcWebTransport } from '@connectrpc/connect-web'; import { createClientFor } from '@zitadel/client'; @@ -42,6 +47,10 @@ export class GrpcService { public featureNew!: ReturnType; public actionNew!: ReturnType; public webKey!: ReturnType; + public organizationNew!: ReturnType; + public adminNew!: ReturnType; + + public assets!: void; constructor( private readonly envService: EnvironmentService, @@ -93,10 +102,12 @@ export class GrpcService { this.userNew = createUserServiceClient(transport); this.session = createSessionServiceClient(transport); this.mgmtNew = createManagementServiceClient(transportOldAPIs); - this.authNew = createAuthServiceClient(transport); + this.authNew = createAuthServiceClient(transportOldAPIs); this.featureNew = createFeatureServiceClient(transport); this.actionNew = createActionServiceClient(transport); this.webKey = createWebKeyServiceClient(transport); + this.organizationNew = createOrganizationServiceClient(transport); + this.adminNew = createAdminServiceClient(transportOldAPIs); const authConfig: AuthConfig = { scope: 'openid profile email', diff --git a/console/src/app/services/interceptors/org.interceptor.ts b/console/src/app/services/interceptors/org.interceptor.ts index e9e9745b125..a8a9f35f416 100644 --- a/console/src/app/services/interceptors/org.interceptor.ts +++ b/console/src/app/services/interceptors/org.interceptor.ts @@ -1,12 +1,8 @@ import { Injectable } from '@angular/core'; import { Request, RpcError, StatusCode, UnaryInterceptor, UnaryResponse } from 'grpc-web'; -import { Org } from 'src/app/proto/generated/zitadel/org_pb'; import { StorageKey, StorageLocation, StorageService } from '../storage.service'; import { ConnectError, Interceptor } from '@connectrpc/connect'; -import { firstValueFrom, identity, Observable, Subject } from 'rxjs'; -import { debounceTime, filter, map } from 'rxjs/operators'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; const ORG_HEADER_KEY = 'x-zitadel-orgid'; @Injectable({ providedIn: 'root' }) @@ -43,8 +39,7 @@ export class OrgInterceptorProvider { constructor(private storageService: StorageService) {} getOrgId() { - const org: Org.AsObject | null = this.storageService.getItem(StorageKey.organization, StorageLocation.session); - return org?.id; + return this.storageService.getItem(StorageKey.organizationId, StorageLocation.session); } handleError = (error: any): never => { @@ -57,7 +52,7 @@ export class OrgInterceptorProvider { error.code === StatusCode.PERMISSION_DENIED && error.message.startsWith("Organisation doesn't exist") ) { - this.storageService.removeItem(StorageKey.organization, StorageLocation.session); + this.storageService.removeItem(StorageKey.organizationId, StorageLocation.session); } throw error; diff --git a/console/src/app/services/new-admin.service.ts b/console/src/app/services/new-admin.service.ts new file mode 100644 index 00000000000..665f9f96126 --- /dev/null +++ b/console/src/app/services/new-admin.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@angular/core'; +import { GrpcService } from './grpc.service'; +import { MessageInitShape } from '@bufbuild/protobuf'; +import { + GetDefaultOrgResponse, + GetMyInstanceResponse, + SetUpOrgRequestSchema, + SetUpOrgResponse, +} from '@zitadel/proto/zitadel/admin_pb'; +import { injectQuery } from '@tanstack/angular-query-experimental'; +import { NewAuthService } from './new-auth.service'; +import { UserService } from './user.service'; + +@Injectable({ + providedIn: 'root', +}) +export class NewAdminService { + constructor( + private readonly grpcService: GrpcService, + private readonly authService: NewAuthService, + private readonly userService: UserService, + ) {} + + public setupOrg(req: MessageInitShape): Promise { + return this.grpcService.adminNew.setUpOrg(req); + } + + public getDefaultOrg(): Promise { + return this.grpcService.adminNew.getDefaultOrg({}); + } + + private getMyInstance(signal?: AbortSignal): Promise { + return this.grpcService.adminNew.getMyInstance({}, { signal }); + } + + public getMyInstanceQuery() { + const listMyZitadelPermissionsQuery = this.authService.listMyZitadelPermissionsQuery(); + return injectQuery(() => ({ + queryKey: [this.userService.userId(), 'admin', 'getMyInstance'], + queryFn: async () => this.getMyInstance(), + enabled: (listMyZitadelPermissionsQuery.data() ?? []).includes('iam.write'), + })); + } +} diff --git a/console/src/app/services/new-auth.service.ts b/console/src/app/services/new-auth.service.ts index 37f05454599..c0f552f52ab 100644 --- a/console/src/app/services/new-auth.service.ts +++ b/console/src/app/services/new-auth.service.ts @@ -6,7 +6,6 @@ import { GetMyLoginPolicyResponse, GetMyLoginPolicyRequestSchema, GetMyPasswordComplexityPolicyResponse, - GetMyUserResponse, ListMyAuthFactorsRequestSchema, ListMyAuthFactorsResponse, RemoveMyAuthFactorOTPEmailRequestSchema, @@ -19,17 +18,19 @@ import { RemoveMyAuthFactorOTPSMSResponse, ListMyMetadataResponse, VerifyMyPhoneResponse, + ListMyZitadelPermissionsResponse, } from '@zitadel/proto/zitadel/auth_pb'; +import { injectQuery } from '@tanstack/angular-query-experimental'; +import { UserService } from './user.service'; @Injectable({ providedIn: 'root', }) export class NewAuthService { - constructor(private readonly grpcService: GrpcService) {} - - public getMyUser(): Promise { - return this.grpcService.authNew.getMyUser({}); - } + constructor( + private readonly grpcService: GrpcService, + private readonly userService: UserService, + ) {} public verifyMyPhone(code: string): Promise { return this.grpcService.authNew.verifyMyPhone({ code }); @@ -70,4 +71,15 @@ export class NewAuthService { public getMyPasswordComplexityPolicy(): Promise { return this.grpcService.authNew.getMyPasswordComplexityPolicy({}); } + + public listMyZitadelPermissions(): Promise { + return this.grpcService.authNew.listMyZitadelPermissions({}); + } + + public listMyZitadelPermissionsQuery() { + return injectQuery(() => ({ + queryKey: [this.userService.userId(), 'auth', 'listMyZitadelPermissions'], + queryFn: () => this.listMyZitadelPermissions().then(({ result }) => result), + })); + } } diff --git a/console/src/app/services/new-mgmt.service.ts b/console/src/app/services/new-mgmt.service.ts index 9e0c9dc6e42..efcaa9cd10e 100644 --- a/console/src/app/services/new-mgmt.service.ts +++ b/console/src/app/services/new-mgmt.service.ts @@ -1,6 +1,8 @@ import { Injectable } from '@angular/core'; import { GrpcService } from './grpc.service'; import { + AddOrgResponse, + DeactivateOrgResponse, GenerateMachineSecretRequestSchema, GenerateMachineSecretResponse, GetDefaultPasswordComplexityPolicyResponse, @@ -9,8 +11,10 @@ import { GetPasswordComplexityPolicyResponse, ListUserMetadataRequestSchema, ListUserMetadataResponse, + ReactivateOrgResponse, RemoveMachineSecretRequestSchema, RemoveMachineSecretResponse, + RemoveOrgResponse, RemoveUserMetadataRequestSchema, RemoveUserMetadataResponse, ResendHumanEmailVerificationRequestSchema, @@ -26,6 +30,8 @@ import { SetUserMetadataResponse, UpdateMachineRequestSchema, UpdateMachineResponse, + UpdateOrgRequestSchema, + UpdateOrgResponse, } from '@zitadel/proto/zitadel/management_pb'; import { MessageInitShape, create } from '@bufbuild/protobuf'; @@ -98,4 +104,24 @@ export class NewMgmtService { public getDefaultPasswordComplexityPolicy(): Promise { return this.grpcService.mgmtNew.getDefaultPasswordComplexityPolicy({}); } + + public updateOrg(req: MessageInitShape): Promise { + return this.grpcService.mgmtNew.updateOrg(req); + } + + public removeOrg(): Promise { + return this.grpcService.mgmtNew.removeOrg({}); + } + + public reactivateOrg(): Promise { + return this.grpcService.mgmtNew.reactivateOrg({}); + } + + public deactivateOrg(): Promise { + return this.grpcService.mgmtNew.deactivateOrg({}); + } + + public addOrg(name: string): Promise { + return this.grpcService.mgmtNew.addOrg({ name }); + } } diff --git a/console/src/app/services/new-organization.service.ts b/console/src/app/services/new-organization.service.ts new file mode 100644 index 00000000000..16a461b0804 --- /dev/null +++ b/console/src/app/services/new-organization.service.ts @@ -0,0 +1,212 @@ +import { computed, Injectable, signal } from '@angular/core'; +import { GrpcService } from './grpc.service'; +import { injectQuery, mutationOptions, QueryClient, queryOptions, skipToken } from '@tanstack/angular-query-experimental'; +import { MessageInitShape } from '@bufbuild/protobuf'; +import { ListOrganizationsRequestSchema, ListOrganizationsResponse } from '@zitadel/proto/zitadel/org/v2/org_service_pb'; +import { NewMgmtService } from './new-mgmt.service'; +import { OrgInterceptorProvider } from './interceptors/org.interceptor'; +import { NewAdminService } from './new-admin.service'; +import { SetUpOrgRequestSchema } from '@zitadel/proto/zitadel/admin_pb'; +import { TranslateService } from '@ngx-translate/core'; +import { lastValueFrom } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { StorageKey, StorageLocation, StorageService } from './storage.service'; +import { UserService } from './user.service'; + +@Injectable({ + providedIn: 'root', +}) +export class NewOrganizationService { + constructor( + private readonly grpcService: GrpcService, + private readonly newMgtmService: NewMgmtService, + private readonly newAdminService: NewAdminService, + private readonly orgInterceptorProvider: OrgInterceptorProvider, + private readonly queryClient: QueryClient, + private readonly translate: TranslateService, + private readonly storage: StorageService, + private readonly userService: UserService, + ) {} + + private readonly orgIdSignal = signal( + this.storage.getItem(StorageKey.organizationId, StorageLocation.session) ?? + this.storage.getItem(StorageKey.organizationId, StorageLocation.local) ?? + undefined, + ); + public readonly orgId = this.orgIdSignal.asReadonly(); + + public getOrgId() { + return computed(() => { + const orgId = this.orgIdSignal(); + if (orgId === undefined) { + throw new Error('No organization ID set'); + } + return orgId; + }); + } + + public async setOrgId(orgId?: string) { + const organization = await this.queryClient.fetchQuery(this.organizationByIdQueryOptions(orgId ?? this.getOrgId()())); + if (organization) { + this.storage.setItem(StorageKey.organizationId, orgId, StorageLocation.session); + this.storage.setItem(StorageKey.organizationId, orgId, StorageLocation.local); + this.orgIdSignal.set(orgId); + } else { + throw new Error('request organization not found'); + } + return organization; + } + + public organizationByIdQueryOptions(organizationId?: string) { + const req = { + query: { + limit: 1, + }, + queries: [ + { + query: { + case: 'idQuery' as const, + value: { + id: organizationId?.toString(), + }, + }, + }, + ], + }; + + return queryOptions({ + queryKey: [this.userService.userId(), 'organization', 'listOrganizations', req], + queryFn: organizationId + ? () => this.listOrganizations(req).then((resp) => resp.result.find(Boolean) ?? null) + : skipToken, + }); + } + + public activeOrganizationQuery() { + return injectQuery(() => this.organizationByIdQueryOptions(this.orgId())); + } + + public listOrganizationsQueryOptions(req?: MessageInitShape) { + return queryOptions({ + queryKey: this.listOrganizationsQueryKey(req), + queryFn: () => this.listOrganizations(req ?? {}), + }); + } + + public listOrganizationsQueryKey(req?: MessageInitShape) { + if (!req) { + return [this.userService.userId(), 'organization', 'listOrganizations']; + } + + // needed because angular query isn't able to serialize a bigint key + const query = req.query ? { ...req.query, offset: req.query.offset ? Number(req.query.offset) : undefined } : undefined; + const queryKey = { + ...req, + ...(query ? { query } : {}), + }; + + return [this.userService.userId(), 'organization', 'listOrganizations', queryKey]; + } + + public listOrganizations( + req: MessageInitShape, + signal?: AbortSignal, + ): Promise { + return this.grpcService.organizationNew.listOrganizations(req, { signal }); + } + + private async getDefaultOrganization() { + let resp = await this.listOrganizations({ + query: { + limit: 1, + }, + queries: [ + { + query: { + case: 'defaultQuery', + value: {}, + }, + }, + ], + }); + return resp.result.find(Boolean) ?? null; + } + + private invalidateAllOrganizationQueries() { + return this.queryClient.invalidateQueries({ + queryKey: this.listOrganizationsQueryOptions().queryKey, + }); + } + + public renameOrgMutationOptions = () => + mutationOptions({ + mutationKey: ['renameOrg'], + mutationFn: (name: string) => this.newMgtmService.updateOrg({ name }), + onSettled: () => this.invalidateAllOrganizationQueries(), + }); + + public deleteOrgMutationOptions = () => + mutationOptions({ + mutationKey: ['deleteOrg'], + mutationFn: async () => { + // Before we remove the org we get the current default org + // we have to query before the current org is removed + const defaultOrg = await this.getDefaultOrganization(); + if (!defaultOrg) { + const error$ = this.translate.get('ORG.TOAST.DEFAULTORGOTFOUND').pipe(first()); + throw { message: await lastValueFrom(error$) }; + } + + const resp = await this.newMgtmService.removeOrg(); + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // We change active org to default org as + // current org was deleted to avoid Organization doesn't exist + await this.setOrgId(defaultOrg.id); + + return resp; + }, + onSettled: async () => { + const orgId = this.orgInterceptorProvider.getOrgId(); + if (orgId) { + this.queryClient.removeQueries({ + queryKey: this.organizationByIdQueryOptions(orgId).queryKey, + }); + } + + await this.invalidateAllOrganizationQueries(); + }, + }); + + public reactivateOrgMutationOptions = () => + mutationOptions({ + mutationKey: ['reactivateOrg'], + mutationFn: () => this.newMgtmService.reactivateOrg(), + onSettled: () => this.invalidateAllOrganizationQueries(), + }); + + public deactivateOrgMutationOptions = () => + mutationOptions({ + mutationKey: ['deactivateOrg'], + mutationFn: () => this.newMgtmService.deactivateOrg(), + onSettled: () => this.invalidateAllOrganizationQueries(), + }); + + public setupOrgMutationOptions = () => + mutationOptions({ + mutationKey: ['setupOrg'], + mutationFn: (req: MessageInitShape) => this.newAdminService.setupOrg(req), + onSettled: async () => { + await this.invalidateAllOrganizationQueries(); + }, + }); + + public addOrgMutationOptions = () => + mutationOptions({ + mutationKey: ['addOrg'], + mutationFn: (name: string) => this.newMgtmService.addOrg(name), + onSettled: async () => { + await this.invalidateAllOrganizationQueries(); + }, + }); +} diff --git a/console/src/app/services/storage.service.ts b/console/src/app/services/storage.service.ts index 5b3ab3f985b..3ea841bed94 100644 --- a/console/src/app/services/storage.service.ts +++ b/console/src/app/services/storage.service.ts @@ -43,7 +43,7 @@ export class StorageConfig { } export enum StorageKey { - organization = 'organization', + organizationId = 'organizationId', } export enum StorageLocation { diff --git a/console/src/app/services/user.service.ts b/console/src/app/services/user.service.ts index 714ffa5fe14..0c843e3ab97 100644 --- a/console/src/app/services/user.service.ts +++ b/console/src/app/services/user.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { computed, Injectable, Signal } from '@angular/core'; import { GrpcService } from './grpc.service'; import { AddHumanUserRequestSchema, @@ -18,9 +18,6 @@ import { ListPasskeysResponse, ListUsersRequestSchema, ListUsersResponse, - LockUserRequestSchema, - LockUserResponse, - PasswordResetRequestSchema, ReactivateUserRequestSchema, ReactivateUserResponse, RemoveOTPEmailRequestSchema, @@ -49,84 +46,85 @@ import { UpdateHumanUserResponse, } from '@zitadel/proto/zitadel/user/v2/user_service_pb'; import type { MessageInitShape } from '@bufbuild/protobuf'; -import { - AccessTokenType, - Gender, - HumanProfile, - HumanProfileSchema, - HumanUser, - HumanUserSchema, - MachineUser, - MachineUserSchema, - User as UserV2, - UserSchema, - UserState, -} from '@zitadel/proto/zitadel/user/v2/user_pb'; import { create } from '@bufbuild/protobuf'; -import { Timestamp as TimestampV2, TimestampSchema } from '@bufbuild/protobuf/wkt'; -import { Details, DetailsSchema } from '@zitadel/proto/zitadel/object/v2/object_pb'; -import { Human, Machine, Phone, Profile, User } from '../proto/generated/zitadel/user_pb'; -import { ObjectDetails } from '../proto/generated/zitadel/object_pb'; -import { Timestamp } from '../proto/generated/google/protobuf/timestamp_pb'; -import { HumanPhone, HumanPhoneSchema } from '@zitadel/proto/zitadel/user/v2/phone_pb'; import { OAuthService } from 'angular-oauth2-oidc'; -import { debounceTime, EMPTY, Observable, of, ReplaySubject, shareReplay, switchAll, switchMap } from 'rxjs'; -import { catchError, filter, map, startWith } from 'rxjs/operators'; +import { filter, map } from 'rxjs/operators'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { injectQuery, queryOptions, skipToken } from '@tanstack/angular-query-experimental'; @Injectable({ providedIn: 'root', }) export class UserService { - private user$$ = new ReplaySubject>(1); - public user$ = this.user$$.pipe( - startWith(this.getUser()), - // makes sure if many subscribers reset the observable only one wins - debounceTime(10), - switchAll(), - catchError((err) => { - // reset user observable on error - this.user$$.next(this.getUser()); - throw err; - }), - ); + private readonly payload: Signal; + public readonly userId: Signal; + public readonly isExpired: Signal; + + public userQuery() { + return injectQuery(() => this.userQueryOptions()); + } + + public userQueryOptions() { + const userId = this.userId(); + return queryOptions({ + queryKey: [userId, 'user'], + queryFn: userId ? () => this.getUserById(userId).then((resp) => resp.user) : skipToken, + }); + } constructor( private readonly grpcService: GrpcService, private readonly oauthService: OAuthService, - ) {} - - private getUserId() { - return this.oauthService.events.pipe( - filter((event) => event.type === 'token_received'), - map(() => this.oauthService.getIdToken()), - startWith(this.oauthService.getIdToken()), - filter(Boolean), - switchMap((token) => { - // we do this in a try catch so the observable will retry this logic if it fails - try { - // split jwt and get base64 encoded payload - const unparsedPayload = atob(token.split('.')[1]); - // parse payload - const payload: unknown = JSON.parse(unparsedPayload); - // check if sub is in payload and is a string - if (payload && typeof payload === 'object' && 'sub' in payload && typeof payload.sub === 'string') { - return of(payload.sub); - } - return EMPTY; - } catch { - return EMPTY; - } - }), - ); + ) { + this.payload = this.getPayload(); + this.userId = this.getUserId(this.payload); + this.isExpired = this.getIsExpired(this.payload); } - private getUser() { - return this.getUserId().pipe( - switchMap((id) => this.getUserById(id)), - map((resp) => resp.user), - filter(Boolean), - shareReplay({ refCount: true, bufferSize: 1 }), + private getPayload() { + const idToken$ = this.oauthService.events.pipe( + filter((event) => event.type === 'token_received'), + // can actually return null + // https://github.com/manfredsteyer/angular-oauth2-oidc/blob/c724ad73eadbb28338b084e3afa5ed49a0ea058c/projects/lib/src/oauth-service.ts#L2365 + map(() => this.oauthService.getIdToken() as string | null), ); + const idToken = toSignal(idToken$, { initialValue: this.oauthService.getIdToken() as string | null }); + + return computed(() => { + try { + // split jwt and get base64 encoded payload + const unparsedPayload = atob((idToken() ?? '').split('.')[1]); + // parse payload + return JSON.parse(unparsedPayload) as unknown; + } catch { + return undefined; + } + }); + } + + private getUserId(payloadSignal: Signal) { + return computed(() => { + const payload = payloadSignal(); + if (payload && typeof payload === 'object' && 'sub' in payload && typeof payload.sub === 'string') { + return payload.sub; + } + return undefined; + }); + } + + private getIsExpired(payloadSignal: Signal) { + const expSignal = computed(() => { + const payload = payloadSignal(); + if (payload && typeof payload === 'object' && 'exp' in payload && typeof payload.exp === 'number') { + return new Date(payload.exp * 1000); + } + return undefined; + }); + + return computed(() => { + const exp = expSignal(); + return exp ? exp <= new Date() : true; + }); } public addHumanUser(req: MessageInitShape): Promise { @@ -157,10 +155,6 @@ export class UserService { return this.grpcService.userNew.updateHumanUser(create(UpdateHumanUserRequestSchema, req)); } - public lockUser(userId: string): Promise { - return this.grpcService.userNew.lockUser(create(LockUserRequestSchema, { userId })); - } - public unlockUser(userId: string): Promise { return this.grpcService.userNew.unlockUser(create(UnlockUserRequestSchema, { userId })); } @@ -221,10 +215,6 @@ export class UserService { return this.grpcService.userNew.createInviteCode(create(CreateInviteCodeRequestSchema, req)); } - public passwordReset(req: MessageInitShape) { - return this.grpcService.userNew.passwordReset(create(PasswordResetRequestSchema, req)); - } - public setPassword(req: MessageInitShape): Promise { return this.grpcService.userNew.setPassword(create(SetPasswordRequestSchema, req)); } diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json index 4e650c280ef..e6550f473b9 100644 --- a/console/src/assets/i18n/en.json +++ b/console/src/assets/i18n/en.json @@ -80,7 +80,7 @@ }, "SETTINGS": { "INSTANCE": { - "TITLE": "Default Settings", + "TITLE": "Instance Settings", "DESCRIPTION": "Default settings for all organizations. With the right permissions, some of them are overridable in organization settings." }, "ORG": { @@ -431,6 +431,7 @@ "SHOWORGS": "Show All Organizations", "GRANTS": "Authorizations", "ACTIONS": "Actions", + "TARGETS": "Targets", "PRIVACY": "Privacy", "TOS": "Terms of Service", "OPENSHORTCUTSTOOLTIP": "Type ? to show keyboard shortcuts", diff --git a/console/src/component-themes.scss b/console/src/component-themes.scss index d695affe9d5..ef769438f9e 100644 --- a/console/src/component-themes.scss +++ b/console/src/component-themes.scss @@ -37,7 +37,7 @@ @import 'src/app/pages/users/user-list/user-table/user-table.component'; @import 'src/app/pages/users/user-detail/contact/contact.component'; @import 'src/app/pages/projects/project-grid/project-grid.component'; -@import 'src/app/pages/actions/actions.component'; +@import 'src/app/pages/org-actions/actions.component'; @import 'src/app/app.component.scss'; @import './styles/color.scss'; @import 'src/app/pages/instance/instance.component.scss'; @@ -78,6 +78,11 @@ @import './styles/codemirror.scss'; @import 'src/app/components/copy-row/copy-row.component.scss'; @import 'src/app/modules/providers/provider-next/provider-next.component.scss'; +@import 'src/app/modules/new-header/organization-selector/organization-selector.component.scss'; +@import 'src/app/modules/new-header/instance-selector/instance-selector.component.scss'; +@import 'src/app/modules/new-header/header-dropdown/header-dropdown.component.scss'; +@import 'src/app/modules/new-header/new-header.component.scss'; +@import 'src/app/modules/new-header/header-button/header-button.component.scss'; @mixin component-themes($theme) { @include cnsl-color-theme($theme); @@ -159,4 +164,9 @@ @include copy-row-theme($theme); @include provider-next-theme($theme); @include smtp-settings-theme($theme); + @include organization-selector-theme($theme); + @include instance-selector-theme($theme); + @include header-dropdown-theme($theme); + @include new-header-theme($theme); + @include header-button-theme($theme); } diff --git a/console/tsconfig.json b/console/tsconfig.json index 2d7f1b7b1ef..ba6c7151971 100644 --- a/console/tsconfig.json +++ b/console/tsconfig.json @@ -22,7 +22,12 @@ "target": "ES2022", "module": "ES2022", "useDefineForClassFields": false, - "lib": ["ES2022", "dom"] + "lib": ["ES2022", "dom"], + "paths": { + "@ng-icons/core": ["./node_modules/@ng-icons/core"], + "@ng-icons/heroicons/outline": ["./node_modules/@ng-icons/heroicons/outline"], + "@ng-icons/heroicons/solid": ["./node_modules/@ng-icons/heroicons/solid"] + } }, "angularCompilerOptions": { "enableI18nLegacyMessageIdFormat": false, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f223ea9e06b..080c406da6a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -290,9 +290,18 @@ importers: '@fortawesome/free-brands-svg-icons': specifier: ^6.7.2 version: 6.7.2 + '@ng-icons/core': + specifier: ^25.0.0 + version: 25.6.1 + '@ng-icons/heroicons': + specifier: ^25.0.0 + version: 25.6.1 '@ngx-translate/core': specifier: ^15.0.0 version: 15.0.0(@angular/common@16.2.12(@angular/core@16.2.12(rxjs@7.8.2)(zone.js@0.13.3))(rxjs@7.8.2))(@angular/core@16.2.12(rxjs@7.8.2)(zone.js@0.13.3))(rxjs@7.8.2) + '@tanstack/angular-query-experimental': + specifier: ^5.85.5 + version: 5.85.5(@angular/common@16.2.12(@angular/core@16.2.12(rxjs@7.8.2)(zone.js@0.13.3))(rxjs@7.8.2))(@angular/core@16.2.12(rxjs@7.8.2)(zone.js@0.13.3)) '@zitadel/client': specifier: workspace:* version: link:../packages/zitadel-client @@ -3471,6 +3480,12 @@ packages: cpu: [x64] os: [win32] + '@ng-icons/core@25.6.1': + resolution: {integrity: sha512-o6vCttlzXvDZRYiOKOULr7fsX8gY/DwwxzBSrBQzwa/at+pC0xRoe6uczJ9Ato+y1EDWP/PlrEMAQfvokBA6tQ==} + + '@ng-icons/heroicons@25.6.1': + resolution: {integrity: sha512-QGTIIl+S6/w2vQvYGP1zNLbNvJLLRS+1evlOPWZZzWow+77qRxs0E96CukSsjItBFUnLKvzuOfMBBcNtb2SIHQ==} + '@ngtools/webpack@16.2.16': resolution: {integrity: sha512-4gm2allK0Pjy/Lxb9IGRnhEZNEOJSOTWwy09VOdHouV2ODRK7Tto2LgteaFJUUSLkuvWRsI7pfuA6yrz8KDfHw==} engines: {node: ^16.14.0 || >=18.10.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} @@ -5050,6 +5065,18 @@ packages: '@tanem/svg-injector@10.1.68': resolution: {integrity: sha512-UkJajeR44u73ujtr5GVSbIlELDWD/mzjqWe54YMK61ljKxFcJoPd9RBSaO7xj02ISCWUqJW99GjrS+sVF0UnrA==} + '@tanstack/angular-query-experimental@5.85.5': + resolution: {integrity: sha512-IhjAOcmrLK49P0w6dszIdfRlEmZkI+fuC+OvlhOT6Y0o6khWL0b3hEUTPFtFqzRes6WQMfUNfVDzEhTcHxLVRQ==} + peerDependencies: + '@angular/common': '>=16.0.0' + '@angular/core': '>=16.0.0' + + '@tanstack/query-core@5.85.5': + resolution: {integrity: sha512-KO0WTob4JEApv69iYp1eGvfMSUkgw//IpMnq+//cORBzXf0smyRwPLrUvEe5qtAEGjwZTXrjxg+oJNP/C00t6w==} + + '@tanstack/query-devtools@5.84.0': + resolution: {integrity: sha512-fbF3n+z1rqhvd9EoGp5knHkv3p5B2Zml1yNRjh7sNXklngYI5RVIWUrUjZ1RIcEoscarUb0+bOvIs5x9dwzOXQ==} + '@tanstack/react-virtual@3.13.12': resolution: {integrity: sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==} peerDependencies: @@ -19241,6 +19268,14 @@ snapshots: '@next/swc-win32-x64-msvc@15.4.0-canary.86': optional: true + '@ng-icons/core@25.6.1': + dependencies: + tslib: 2.8.1 + + '@ng-icons/heroicons@25.6.1': + dependencies: + tslib: 2.8.1 + '@ngtools/webpack@16.2.16(@angular/compiler-cli@16.2.12(@angular/compiler@16.2.12(@angular/core@16.2.12(rxjs@7.8.2)(zone.js@0.13.3)))(typescript@5.1.6))(typescript@5.1.6)(webpack@5.94.0(@swc/core@1.13.4(@swc/helpers@0.5.17))(esbuild@0.18.17))': dependencies: '@angular/compiler-cli': 16.2.12(@angular/compiler@16.2.12(@angular/core@16.2.12(rxjs@7.8.2)(zone.js@0.13.3)))(typescript@5.1.6) @@ -20804,6 +20839,17 @@ snapshots: content-type: 1.0.5 tslib: 2.8.1 + '@tanstack/angular-query-experimental@5.85.5(@angular/common@16.2.12(@angular/core@16.2.12(rxjs@7.8.2)(zone.js@0.13.3))(rxjs@7.8.2))(@angular/core@16.2.12(rxjs@7.8.2)(zone.js@0.13.3))': + dependencies: + '@angular/common': 16.2.12(@angular/core@16.2.12(rxjs@7.8.2)(zone.js@0.13.3))(rxjs@7.8.2) + '@angular/core': 16.2.12(rxjs@7.8.2)(zone.js@0.13.3) + '@tanstack/query-core': 5.85.5 + '@tanstack/query-devtools': 5.84.0 + + '@tanstack/query-core@5.85.5': {} + + '@tanstack/query-devtools@5.84.0': {} + '@tanstack/react-virtual@3.13.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@tanstack/virtual-core': 3.13.12