feat(console): integrate app (#7417)

* docs, frameworks view

* project select, integrate app page

* fix search project autocomplete

* framework autocomplete

* framwork select component, integrate, mapping to oidc config

* param

* fix route handler

* setname projectid context

* app-create page without context

* show description of app type, info section

* redirects section

* updatevalue observable

* fix redirect uris section

* i18n

* setup config

* backbutton behavior, cleanup

* cleanup

* lint

* allow other framework jump off

* dev mode warning

* navigate to project

* rm import

* i18n, guide link

* edit name dialog

* show warning for duplicate name
This commit is contained in:
Max Peintner
2024-02-28 16:52:21 +00:00
committed by GitHub
parent f4c72cbe14
commit 0fcdfe460c
64 changed files with 2381 additions and 176 deletions
@@ -57,6 +57,13 @@
</cnsl-top-view>
<div class="max-width-container">
<cnsl-info-section *ngIf="isNew()" class="problem" [type]="InfoSectionType.INFO">
<div class="jumptoproject-row">
<span class="jumptoproject">{{ 'APP.PAGES.JUMPTOPROJECT' | translate }}</span>
<a [routerLink]="['/projects', projectId]" color="primary" mat-raised-button>{{ 'ACTIONS.CONFIGURE' | translate }}</a>
</div>
</cnsl-info-section>
<div
class="compliance"
*ngIf="app && app.oidcConfig && app.oidcConfig.complianceProblemsList && app.oidcConfig.complianceProblemsList?.length"
@@ -125,6 +125,16 @@
}
}
.jumptoproject-row {
display: flex;
justify-content: space-between;
.jumptoproject {
margin-top: 0.25rem;
display: block;
}
}
.compliance {
padding-bottom: 1rem;
padding-top: 0.5rem;
@@ -1,6 +1,6 @@
import { COMMA, ENTER, SPACE } from '@angular/cdk/keycodes';
import { Location } from '@angular/common';
import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core';
import { Component, OnDestroy, OnInit, ViewEncapsulation, signal } from '@angular/core';
import { AbstractControl, FormControl, UntypedFormBuilder, UntypedFormControl, UntypedFormGroup } from '@angular/forms';
import { MatCheckboxChange } from '@angular/material/checkbox';
import { MatDialog } from '@angular/material/dialog';
@@ -149,6 +149,7 @@ export class AppDetailComponent implements OnInit, OnDestroy {
public settingsList: SidenavSetting[] = [{ id: 'configuration', i18nKey: 'APP.CONFIGURATION' }];
public currentSetting: string | undefined = this.settingsList[0].id;
public isNew = signal<boolean>(false);
constructor(
private envSvc: EnvironmentService,
public translate: TranslateService,
@@ -245,6 +246,9 @@ export class AppDetailComponent implements OnInit, OnDestroy {
public ngOnInit(): void {
const projectId = this.route.snapshot.paramMap.get('projectid');
const appId = this.route.snapshot.paramMap.get('appid');
const isNew = this.route.snapshot.queryParamMap.get('new');
this.isNew.set(isNew === 'true');
if (projectId && appId) {
this.projectId = projectId;
@@ -3,6 +3,7 @@ import { RouterModule, Routes } from '@angular/router';
import { AppCreateComponent } from '../apps/app-create/app-create.component';
import { AppDetailComponent } from '../apps/app-detail/app-detail.component';
import { IntegrateAppComponent } from './integrate/integrate.component';
const routes: Routes = [
{
@@ -10,6 +11,11 @@ const routes: Routes = [
component: AppCreateComponent,
data: { animation: 'AddPage' },
},
{
path: 'integrate',
component: IntegrateAppComponent,
data: { animation: 'AddPage' },
},
{
path: ':appid',
component: AppDetailComponent,
@@ -43,6 +43,9 @@ import { AuthMethodDialogComponent } from './app-detail/auth-method-dialog/auth-
import { AppSecretDialogComponent } from './app-secret-dialog/app-secret-dialog.component';
import { AppsRoutingModule } from './apps-routing.module';
import { RedirectUrisComponent } from './redirect-uris/redirect-uris.component';
import { IntegrateAppComponent } from './integrate/integrate.component';
import { OIDCConfigurationComponent } from 'src/app/components/oidc-configuration/oidc-configuration.component';
import { FrameworkChangeComponent } from 'src/app/components/framework-change/framework-change.component';
@NgModule({
declarations: [
@@ -50,12 +53,15 @@ import { RedirectUrisComponent } from './redirect-uris/redirect-uris.component';
AppDetailComponent,
AppSecretDialogComponent,
RedirectUrisComponent,
IntegrateAppComponent,
AdditionalOriginsComponent,
AuthMethodDialogComponent,
],
imports: [
FrameworkChangeComponent,
CommonModule,
A11yModule,
OIDCConfigurationComponent,
RedirectPipeModule,
NameDialogModule,
AppRadioModule,
@@ -0,0 +1,138 @@
<div class="app-integrate-wrapper">
<div class="integrate-layout-container">
<div class="max-width-container">
<div class="top-control">
<button (click)="close()" mat-icon-button matTooltip="{{ 'ACTIONS.CLOSE' | translate }}">
<mat-icon *ngIf="navigation.isBackPossible">arrow_back</mat-icon>
<mat-icon *ngIf="!navigation.isBackPossible">close</mat-icon>
</button>
<span class="abort">{{ 'APP.PAGES.CREATE' | translate }}</span>
<mat-progress-spinner
class="progress-spinner"
color="primary"
*ngIf="loading"
diameter="30"
mode="indeterminate"
></mat-progress-spinner>
</div>
</div>
</div>
<div class="max-width-container">
<div class="offset-content">
<h1>{{ 'QUICKSTART.ALMOSTDONE' | translate }}</h1>
<p>{{ 'QUICKSTART.REVIEWCONFIGURATION_DESCRIPTION' | translate: { value: framework()?.title } }}</p>
<div class="grid-layout">
<div>
<cnsl-framework-change
*ngIf="framework"
class="framework-selector"
(frameworkChanged)="setFramework($event)"
></cnsl-framework-change>
<div class="steps">
<div class="step">
<span class="step-title">{{ 'PROJECT.PAGES.TITLE' | translate }}</span>
<span>{{ projectName$ | async }}</span>
</div>
<div class="step top-border">
<a *ngIf="framework()" [href]="'https://zitadel.com' + framework()?.docsLink" target="_blank"
>{{ framework()?.title }} {{ 'QUICKSTART.GUIDE' | translate }}</a
>
<a href="https://zitadel.com/docs/sdk-examples/introduction" target="_blank">{{
'QUICKSTART.BROWSEEXAMPLES' | translate
}}</a>
</div>
</div>
</div>
<div class="card-wrapper">
<cnsl-card class="review-card" title="{{ 'QUICKSTART.REVIEWCONFIGURATION' | translate }}">
<cnsl-info-section *ngIf="showRenameWarning | async" [type]="InfoSectionType.WARN"
><span class="duplicate-name-warning">{{
'QUICKSTART.DUPLICATEAPPRENAME' | translate
}}</span></cnsl-info-section
>
<cnsl-oidc-app-configuration
*ngIf="(oidcAppRequest | async)?.toObject() as config"
[name]="(oidcAppRequest | async)?.toObject()?.name"
(changeName)="editName()"
[configuration]="config"
></cnsl-oidc-app-configuration>
<cnsl-info-section *ngIf="framework()?.description as desc" [type]="InfoSectionType.INFO">
<p class="review-description">
{{ desc }}
</p>
</cnsl-info-section>
</cnsl-card>
<cnsl-card
title="{{ 'QUICKSTART.REDIRECTS' | translate }}"
description="{{ 'APP.OIDC.REDIRECTTITLE' | translate }}"
>
<cnsl-info-section [type]="InfoSectionType.ALERT">
<span class="redirect-description">
{{ 'QUICKSTART.DEVMODEWARN' | translate }}
</span>
</cnsl-info-section>
<cnsl-info-section *ngIf="(oidcAppRequest | async)?.toObject()?.appType === OIDCAppType.OIDC_APP_TYPE_NATIVE">
<span class="redirect-description">
{{ 'APP.OIDC.REDIRECTDESCRIPTIONNATIVE' | translate }}
</span>
</cnsl-info-section>
<cnsl-info-section
*ngIf="
(oidcAppRequest | async)?.toObject()?.appType === OIDCAppType.OIDC_APP_TYPE_WEB ||
(oidcAppRequest | async)?.toObject()?.appType === OIDCAppType.OIDC_APP_TYPE_USER_AGENT
"
>
<span class="redirect-description">
{{ 'APP.OIDC.REDIRECTDESCRIPTIONWEB' | translate }}
</span>
</cnsl-info-section>
<cnsl-redirect-uris
*ngIf="requestRedirectValuesSubject$"
class="redirect-section"
[disabled]="false"
[isNative]="(oidcAppRequest | async)?.toObject()?.appType === OIDCAppType.OIDC_APP_TYPE_NATIVE"
[(ngModel)]="redirectUris"
[getValues]="requestRedirectValuesSubject$"
title="{{ 'APP.OIDC.REDIRECT' | translate }}"
[devMode]="true"
data-e2e="redirect-uris"
>
</cnsl-redirect-uris>
<cnsl-redirect-uris
*ngIf="requestRedirectValuesSubject$"
class="redirect-section"
[disabled]="false"
[(ngModel)]="postLogoutUrisList"
[getValues]="requestRedirectValuesSubject$"
title="{{ 'APP.OIDC.POSTLOGOUTREDIRECT' | translate }}"
[isNative]="(oidcAppRequest | async)?.toObject()?.appType === OIDCAppType.OIDC_APP_TYPE_NATIVE"
[devMode]="true"
data-e2e="postlogout-uris"
>
</cnsl-redirect-uris>
</cnsl-card>
</div>
</div>
<div class="app-integrate-actions">
<button
mat-raised-button
[disabled]="loading"
class="create-button"
color="primary"
(click)="createApp()"
data-e2e="create-button"
>
{{ 'ACTIONS.CREATE' | translate }}
</button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,214 @@
h1 {
font-weight: 500;
font-size: 2rem;
}
.integrate-layout-container {
margin: 0 -2rem 2rem -2rem;
padding: 3rem 2rem 14rem 2rem;
.top-control {
display: flex;
align-items: center;
.abort {
font-size: 1.2rem;
margin-left: 1.5rem;
text-transform: uppercase;
font-size: 14px;
opacity: 0.8;
letter-spacing: 0.05em;
}
.progress-spinner {
margin-left: 2rem;
}
}
}
.offset-content {
margin-top: -14rem;
}
.grid-layout {
margin-top: 2rem;
display: grid;
grid-template-columns: [first] 300px [second] auto;
grid-column-gap: 5rem;
.framework-selector {
margin-bottom: 2rem;
display: block;
}
.steps {
margin-top: 0.5rem;
.step {
display: flex;
flex-direction: column;
padding: 1rem 0;
.step-title {
font-weight: 700;
font-size: 12px;
opacity: 0.8;
margin-bottom: 0.5rem;
text-transform: uppercase;
}
a {
margin-bottom: 0.5rem;
}
}
}
.redirect-p {
margin-top: 0;
}
.redirect-description {
font-size: 14px;
margin: 0.25rem 0 0 0;
display: block;
}
}
@media only screen and (max-width: 900px) {
.integrate-layout-container {
margin: 0 -2rem 2rem -2rem;
background: transparent;
padding: 3rem 2rem 14rem 2rem;
}
.grid-layout {
display: flex;
flex-direction: column;
.steps {
display: none;
}
}
}
.card-wrapper {
margin-top: -1rem;
.duplicate-name-warning {
margin-top: 0.25rem;
display: block;
}
}
@mixin app-integrate-theme($theme) {
$primary: map-get($theme, primary);
$warn: map-get($theme, warn);
$background: map-get($theme, background);
$accent: map-get($theme, accent);
$primary-color: map-get($primary, 500);
$warn-color: map-get($warn, 500);
$accent-color: map-get($accent, 500);
$foreground: map-get($theme, foreground);
$is-dark-theme: map-get($theme, is-dark);
$back: map-get($background, background);
$list-background-color: map-get($background, 300);
$card-background-color: map-get($background, cards);
$border-color: if($is-dark-theme, rgba(#8795a1, 0.2), rgba(#8795a1, 0.2));
$border-selected-color: if($is-dark-theme, #fff, #000);
.integrate-layout-container {
background: map-get($background, metadata-section);
}
.review-description {
font-size: 14px;
margin: 0.25rem 0 0 0;
display: block;
}
.grid-layout {
.step {
&.top-border {
border-top: 2px solid $border-color;
}
}
a {
color: $primary-color;
}
}
.framework-card-wrapper {
display: flex;
align-items: center;
gap: 1rem;
.framework-card {
position: relative;
flex-shrink: 0;
text-decoration: none;
border-radius: 0.5rem;
box-sizing: border-box;
transition: all 0.1s ease-in;
display: flex;
flex-direction: row;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
width: fit-content;
border: 1px solid rgba(#8795a1, 0.2);
padding: 0 0.5rem;
img {
width: 100%;
height: 100%;
max-width: 40px;
max-height: 40px;
object-fit: contain;
object-position: center;
}
.dark-only {
display: if($is-dark-theme, block, none);
}
.light-only {
display: if($is-dark-theme, none, block);
}
span {
margin: 0.5rem;
text-align: center;
color: map-get($foreground, text);
}
.action-row {
display: flex;
align-items: center;
justify-content: flex-end;
font-size: 14px;
margin-bottom: 0.5rem;
color: map-get($primary, 400);
.icon {
margin-left: 0rem;
}
}
}
}
}
.app-integrate-actions {
margin-top: 2rem;
display: flex;
align-items: center;
justify-content: flex-end;
.create-button {
height: 3.5rem;
padding: 0 4rem;
}
}
@@ -0,0 +1,24 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { IntegrateAppComponent } from './integrate.component';
describe('IntegrateAppComponent', () => {
let component: IntegrateAppComponent;
let fixture: ComponentFixture<IntegrateAppComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [IntegrateAppComponent],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(IntegrateAppComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,220 @@
import { C, COMMA, ENTER, SPACE } from '@angular/cdk/keycodes';
import { StepperSelectionEvent } from '@angular/cdk/stepper';
import { Location } from '@angular/common';
import { Component, OnDestroy, OnInit, Signal, computed, effect, signal } from '@angular/core';
import { AbstractControl, UntypedFormBuilder, UntypedFormControl, UntypedFormGroup } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Params, Router } from '@angular/router';
import { Buffer } from 'buffer';
import { BehaviorSubject, Subject, Subscription, combineLatest } from 'rxjs';
import { debounceTime, map, takeUntil } from 'rxjs/operators';
import { RadioItemAuthType } from 'src/app/modules/app-radio/app-auth-method-radio/app-auth-method-radio.component';
import { requiredValidator } from 'src/app/modules/form-field/validators/validators';
import {
APIAuthMethodType,
OIDCAppType,
OIDCAuthMethodType,
OIDCGrantType,
OIDCResponseType,
} from 'src/app/proto/generated/zitadel/app_pb';
import {
AddAPIAppRequest,
AddAPIAppResponse,
AddOIDCAppRequest,
AddOIDCAppResponse,
AddSAMLAppRequest,
} 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 { AppSecretDialogComponent } from '../app-secret-dialog/app-secret-dialog.component';
import { InfoSectionType } from 'src/app/modules/info-section/info-section.component';
import { Framework } from 'src/app/components/quickstart/quickstart.component';
import { OIDC_CONFIGURATIONS } from 'src/app/utils/framework';
import { NavigationService } from 'src/app/services/navigation.service';
import { NameDialogComponent } from 'src/app/modules/name-dialog/name-dialog.component';
@Component({
selector: 'cnsl-integrate',
templateUrl: './integrate.component.html',
styleUrls: ['./integrate.component.scss'],
})
export class IntegrateAppComponent implements OnInit, OnDestroy {
private destroy$: Subject<void> = new Subject();
public projectId: string = '';
public loading: boolean = false;
public InfoSectionType: any = InfoSectionType;
public framework = signal<Framework | undefined>(undefined);
public showRenameWarning: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
public oidcAppRequest: BehaviorSubject<AddOIDCAppRequest> = new BehaviorSubject(new AddOIDCAppRequest());
public OIDCAppType: any = OIDCAppType;
public requestRedirectValuesSubject$: Subject<void> = new Subject();
constructor(
private activatedRoute: ActivatedRoute,
private router: Router,
private toast: ToastService,
private dialog: MatDialog,
private mgmtService: ManagementService,
private _location: Location,
private breadcrumbService: BreadcrumbService,
public navigation: NavigationService,
) {
effect(() => {
const fwId = this.framework()?.id;
const fw = this.framework();
if (fw && fwId) {
const request = OIDC_CONFIGURATIONS[fwId];
request.setProjectId(this.projectId);
request.setName(fw.title);
request.setDevMode(true);
this.requestRedirectValuesSubject$.next();
this.showRenameWarning.next(false);
this.oidcAppRequest.next(request);
return request;
} else {
const request = new AddOIDCAppRequest();
this.oidcAppRequest.next(request);
return request;
}
});
}
public projectName$ = combineLatest([this.mgmtService.ownedProjects, this.mgmtService.grantedProjects]).pipe(
map(([projects, grantedProjects]) => {
const project = projects.find((project) => project.id === this.activatedRoute.snapshot.paramMap.get('projectid'));
const grantedproject = grantedProjects.find(
(grantedproject) => grantedproject.projectId === this.activatedRoute.snapshot.paramMap.get('projectid'),
);
return project?.name ?? grantedproject?.projectName ?? '';
}),
);
public setFramework(framework: Framework | undefined) {
this.framework.set(framework);
}
public ngOnInit(): void {
const projectId = this.activatedRoute.snapshot.paramMap.get('projectid');
if (projectId) {
const breadcrumbs = [
new Breadcrumb({
type: BreadcrumbType.ORG,
routerLink: ['/org'],
}),
new Breadcrumb({
type: BreadcrumbType.PROJECT,
name: '',
param: { key: 'projectid', value: projectId },
routerLink: ['/projects', projectId],
isZitadel: false,
}),
];
this.projectId = projectId;
this.breadcrumbService.setBreadcrumb(breadcrumbs);
}
}
public ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
public close(): void {
if (this.navigation.isBackPossible) {
this._location.back();
} else {
this.router.navigate(['/projects', this.projectId]);
}
}
public createApp(): void {
this.loading = true;
this.mgmtService
.addOIDCApp(this.oidcAppRequest.getValue())
.then((resp) => {
this.loading = false;
this.showRenameWarning.next(false);
this.toast.showInfo('APP.TOAST.CREATED', true);
if (resp.clientSecret) {
this.showSavedDialog(resp);
} else {
this.router.navigate(['projects', this.projectId, 'apps', resp.appId], { queryParams: { new: true } });
}
})
.catch((error) => {
if (error.code === 6) {
this.showRenameWarning.next(true);
}
this.loading = false;
this.toast.showError(error);
});
}
public editName() {
const dialogRef = this.dialog.open(NameDialogComponent, {
data: {
name: this.oidcAppRequest.getValue()?.getName() ?? '',
titleKey: 'APP.NAMEDIALOG.TITLE',
descKey: 'APP.NAMEDIALOG.DESCRIPTION',
labelKey: 'APP.NAMEDIALOG.NAME',
},
width: '400px',
});
dialogRef.afterClosed().subscribe((name) => {
if (name && name !== this.framework()?.title) {
const request = this.oidcAppRequest.getValue();
request.setName(name);
this.showRenameWarning.next(false);
this.oidcAppRequest.next(request);
}
});
}
public showSavedDialog(added: AddOIDCAppResponse.AsObject | AddAPIAppResponse.AsObject): void {
let clientSecret = '';
if (added.clientSecret) {
clientSecret = added.clientSecret;
}
let clientId = '';
if (added.clientId) {
clientId = added.clientId;
}
const dialogRef = this.dialog.open(AppSecretDialogComponent, {
data: {
clientSecret: clientSecret,
clientId: clientId,
},
});
dialogRef.afterClosed().subscribe(() => {
this.router.navigate(['projects', this.projectId, 'apps', added.appId], { queryParams: { new: true } });
});
}
public get redirectUris() {
return this.oidcAppRequest.getValue().toObject().redirectUrisList;
}
public set redirectUris(value: string[]) {
const request = this.oidcAppRequest.getValue();
request.setRedirectUrisList(value);
this.oidcAppRequest.next(request);
}
public get postLogoutUrisList() {
return this.oidcAppRequest.getValue().toObject().postLogoutRedirectUrisList;
}
public set postLogoutUrisList(value: string[]) {
const request = this.oidcAppRequest.getValue();
request.setPostLogoutRedirectUrisList(value);
this.oidcAppRequest.next(request);
}
}