feat: SMTP xoauth2 in console (#11545)

# Which Problems Are Solved
This adds the newly added xoauth2 option to the console allowing SMTP
Providers to be setup that use xoauth2 instead of plain auth.

# How the Problems Are Solved
Added the xoauth2 option to the SMTP Provider Settings


# Additional Changes
Heavily refactored the SMTP Provider page to allow more dynamic
configuration and use Tanstack Query for Data Fetching.

# Additional Context
- Closes #11451
- Backend changes #11239
This commit is contained in:
Ramon
2026-02-11 17:12:41 +00:00
committed by GitHub
parent cc7a6145f1
commit ceffb9c536
13 changed files with 975 additions and 869 deletions
@@ -2,36 +2,32 @@
<p class="cnsl-secondary-text">{{ 'SMTP.LIST.DESCRIPTION' | translate }}</p>
<div class="cnsl-snmp-table-wrapper">
<cnsl-smtp-table></cnsl-smtp-table>
<cnsl-smtp-table />
</div>
<h2>{{ 'SMTP.CREATE.TITLE' | translate }}</h2>
<p class="cnsl-secondary-text">{{ 'SMTP.CREATE.DESCRIPTION' | translate }}</p>
<div class="new-smtp-wrapper">
<div *ngFor="let provider of providers">
<a
class="item card"
[routerLink]="['/instance', 'smtpprovider', provider.routerLinkElement, 'create']"
*ngIf="provider.name !== 'generic'"
>
<img class="smtp-logo" src="{{ provider.image }}" alt="{{ provider.name }}" />
<div class="text-container">
<span class="title">{{ provider.name | titlecase }} </span>
</div>
</a>
<a
class="item card"
[routerLink]="['/instance', 'smtpprovider', provider.routerLinkElement, 'create']"
*ngIf="provider.name === 'generic'"
>
<div class="smtp-icon">
<mat-icon class="icon" svgIcon="mdi_smtp" />
</div>
<div class="text-container">
<span class="title">Generic SMTP</span>
</div>
</a>
</div>
@for (providerEntry of providers | keyvalue; track providerEntry.key) {
@let id = providerEntry.key;
@let provider = providerEntry.value;
<div>
<a class="item card" [routerLink]="['/instance', 'smtpprovider', id]">
@if (provider.description !== 'generic') {
<img class="smtp-logo" [src]="provider.image" [alt]="provider.description" />
<div class="text-container">
<span class="title">{{ provider.description | titlecase }} </span>
</div>
} @else {
<div class="smtp-icon">
<mat-icon class="icon" svgIcon="mdi_smtp" />
</div>
<div class="text-container">
<span class="title">Generic SMTP</span>
</div>
}
</a>
</div>
}
</div>
@@ -1,33 +1,20 @@
import { Component, Injector, Input, OnInit, Type } from '@angular/core';
import { AdminService } from 'src/app/services/admin.service';
import { ManagementService } from 'src/app/services/mgmt.service';
import { Component } from '@angular/core';
import { PolicyComponentServiceType } from '../policy-component-types.enum';
import { SMTPKnownProviders } from '../../smtp-provider/known-smtp-providers-settings';
import * as SMTPKnownProviders from '../../smtp-provider/known-smtp-providers-settings';
import { TranslatePipe } from '@ngx-translate/core';
import { SMTPTableModule } from '../../smtp-table/smtp-table.module';
import { RouterLink } from '@angular/router';
import { KeyValuePipe, TitleCasePipe } from '@angular/common';
import { MatIcon } from '@angular/material/icon';
@Component({
selector: 'cnsl-notification-smtp-provider',
templateUrl: './notification-smtp-provider.component.html',
styleUrls: ['./notification-smtp-provider.component.scss'],
standalone: false,
imports: [TranslatePipe, SMTPTableModule, RouterLink, TitleCasePipe, MatIcon, KeyValuePipe],
})
export class NotificationSMTPProviderComponent implements OnInit {
@Input() public serviceType!: PolicyComponentServiceType;
public service!: ManagementService | AdminService;
public PolicyComponentServiceType: any = PolicyComponentServiceType;
public providers = SMTPKnownProviders;
constructor(private injector: Injector) {}
ngOnInit(): void {
switch (this.serviceType) {
case PolicyComponentServiceType.MGMT:
this.service = this.injector.get(ManagementService as Type<ManagementService>);
break;
case PolicyComponentServiceType.ADMIN:
this.service = this.injector.get(AdminService as Type<AdminService>);
break;
}
}
export class NotificationSMTPProviderComponent {
protected readonly PolicyComponentServiceType = PolicyComponentServiceType;
protected readonly providers = { ...SMTPKnownProviders, generic: { description: 'generic' } } as const;
}
@@ -1,33 +0,0 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { RouterModule } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { HasRolePipeModule } from 'src/app/pipes/has-role-pipe/has-role-pipe.module';
import { CardModule } from '../../card/card.module';
import { NotificationSMTPProviderComponent } from './notification-smtp-provider.component';
import { InputModule } from '../../input/input.module';
import { FormFieldModule } from '../../form-field/form-field.module';
import { SMTPTableModule } from '../../smtp-table/smtp-table.module';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
@NgModule({
declarations: [NotificationSMTPProviderComponent],
imports: [
InputModule,
FormFieldModule,
CommonModule,
MatButtonModule,
CardModule,
MatIconModule,
SMTPTableModule,
RouterModule,
HasRolePipeModule,
MatProgressSpinnerModule,
TranslateModule,
],
exports: [NotificationSMTPProviderComponent],
})
export class NotificationSMTPProviderModule {}
@@ -27,7 +27,7 @@
<cnsl-notification-policy [serviceType]="serviceType"></cnsl-notification-policy>
</ng-container>
<ng-container *ngIf="setting()?.id === 'smtpprovider' && serviceType === PolicyComponentServiceType.ADMIN">
<cnsl-notification-smtp-provider [serviceType]="serviceType"></cnsl-notification-smtp-provider>
<cnsl-notification-smtp-provider></cnsl-notification-smtp-provider>
</ng-container>
<ng-container *ngIf="setting()?.id === 'smsprovider' && serviceType === PolicyComponentServiceType.ADMIN">
<cnsl-notification-sms-provider [serviceType]="serviceType"></cnsl-notification-sms-provider>
@@ -28,12 +28,12 @@ import FailedEventsModule from '../failed-events/failed-events.module';
import IamViewsModule from '../iam-views/iam-views.module';
import EventsModule from '../events/events.module';
import { OrgTableModule } from '../org-table/org-table.module';
import { NotificationSMTPProviderModule } from '../policies/notification-smtp-provider/notification-smtp-provider.module';
import { FeaturesComponent } from 'src/app/components/features/features.component';
import OrgListModule from 'src/app/pages/org-list/org-list.module';
import ActionsTwoModule from '../actions-two/actions-two.module';
import { provideRouter } from '@angular/router';
import { OidcWebkeysModule } from '../policies/oidc-webkeys/oidc-webkeys.module';
import { NotificationSMTPProviderComponent } from '../policies/notification-smtp-provider/notification-smtp-provider.component';
@NgModule({
declarations: [SettingsListComponent],
@@ -51,7 +51,7 @@ import { OidcWebkeysModule } from '../policies/oidc-webkeys/oidc-webkeys.module'
LanguageSettingsModule,
NotificationPolicyModule,
IdpSettingsModule,
NotificationSMTPProviderModule,
NotificationSMTPProviderComponent,
PrivacyPolicyModule,
MessageTextsPolicyModule,
SecurityPolicyModule,
@@ -63,7 +63,6 @@ import { OidcWebkeysModule } from '../policies/oidc-webkeys/oidc-webkeys.module'
TranslateModule,
HasRolePipeModule,
FeaturesComponent,
NotificationSMTPProviderModule,
NotificationSMSProviderModule,
OIDCConfigurationModule,
OidcWebkeysModule,
@@ -1,192 +1,146 @@
export interface AmazonRegionsEndpoints {
'US East (Ohio)': string;
'US East (N. Virginia)': string;
'US West (N. California)': string;
'US West (Oregon)': string;
'Asia Pacific (Mumbai)': string;
'Asia Pacific (Osaka)': string;
'Asia Pacific (Seoul)': string;
'Asia Pacific (Singapore)': string;
'Asia Pacific (Sydney)': string;
'Asia Pacific (Tokyo)': string;
'Canada (Central)': string;
'Europe (Frankfurt)': string;
'Europe (London)': string;
'Europe (Paris)': string;
'Europe (Stockholm)': string;
'South America (São Paulo)': string;
}
const amazonEndpoints = {
'US East (Ohio)': 'email-smtp.us-east-2.amazonaws.com',
'US East (N. Virginia)': 'email-smtp.us-east-1.amazonaws.com',
'US West (N. California)': 'email-smtp.us-west-1.amazonaws.com',
'US West (Oregon)': 'email-smtp.us-west-2.amazonaws.com',
'Asia Pacific (Mumbai)': 'email-smtp.ap-south-1.amazonaws.com',
'Asia Pacific (Osaka)': 'email-smtp.ap-northeast-3.amazonaws.com',
'Asia Pacific (Seoul)': 'email-smtp.ap-northeast-2.amazonaws.com',
'Asia Pacific (Singapore)': 'email-smtp.ap-southeast-1.amazonaws.com',
'Asia Pacific (Sydney)': 'email-smtp.ap-southeast-2.amazonaws.com',
'Asia Pacific (Tokyo)': 'email-smtp.ap-northeast-1.amazonaws.com',
'Canada (Central)': 'email-smtp.ca-central-1.amazonaws.com',
'Europe (Frankfurt)': 'email-smtp.eu-central-1.amazonaws.com',
'Europe (Ireland)': 'email-smtp.eu-west-1.amazonaws.com',
'Europe (London)': 'email-smtp.eu-west-2.amazonaws.com',
'Europe (Paris)': 'email-smtp.eu-west-3.amazonaws.com',
'Europe (Stockholm)': 'email-smtp.eu-north-1.amazonaws.com',
'South America (São Paulo)': 'email-smtp.sa-east-1.amazonaws.com',
};
export interface ProviderDefaultSettings {
name: string;
regions?: AmazonRegionsEndpoints;
multiHostsLabel?: string;
requiredTls: boolean;
host?: string;
unencryptedPort?: number;
encryptedPort?: number;
type ProviderDefaultSettings = {
description: string;
host: string;
user: {
value: string;
placeholder: string;
};
password: {
value: string;
placeholder: string;
};
auth:
| {
case: 'plain';
password: {
value: string;
placeholder: string;
};
}
| {
case: 'xoauth2';
scopes: string;
};
senderEmailPlaceholder?: string;
image?: string;
routerLinkElement: string;
}
image: string;
};
export const AmazonSESDefaultSettings: ProviderDefaultSettings = {
name: 'amazon SES',
regions: amazonEndpoints,
multiHostsLabel: 'Choose your region',
requiredTls: true,
encryptedPort: 587,
export const amazon = {
description: 'amazon SES',
host: 'email-smtp.us-east-2.amazonaws.com:587',
user: { value: '', placeholder: 'your Amazon SES credentials for this region' },
password: { value: '', placeholder: 'your Amazon SES credentials for this region' },
auth: {
case: 'plain',
password: { value: '', placeholder: 'your Amazon SES credentials for this region' },
},
image: './assets/images/smtp/aws-ses.svg',
routerLinkElement: 'aws-ses',
};
} as const;
export const GoogleDefaultSettings: ProviderDefaultSettings = {
name: 'google',
requiredTls: true,
host: 'smtp.gmail.com',
unencryptedPort: 587,
encryptedPort: 587,
amazon satisfies ProviderDefaultSettings;
export const google = {
description: 'google',
host: 'smtp.gmail.com:587',
user: { value: '', placeholder: 'your complete Google Workspace email address' },
password: { value: '', placeholder: 'your complete Google Workspace password' },
auth: {
case: 'plain',
password: { value: '', placeholder: 'your complete Google Workspace password' },
},
image: './assets/images/smtp/google.png',
routerLinkElement: 'google',
};
} as const;
export const MailgunDefaultSettings: ProviderDefaultSettings = {
name: 'mailgun',
requiredTls: false,
host: 'smtp.mailgun.org',
unencryptedPort: 587,
encryptedPort: 465,
google satisfies ProviderDefaultSettings;
export const mailgun = {
description: 'mailgun',
host: 'smtp.mailgun.org:465',
user: { value: '', placeholder: 'postmaster@YOURDOMAIN' },
password: { value: '', placeholder: 'Your mailgun smtp password' },
auth: {
case: 'plain',
password: { value: '', placeholder: 'Your mailgun smtp password' },
},
image: './assets/images/smtp/mailgun.svg',
routerLinkElement: 'mailgun',
};
} as const;
export const MailjetDefaultSettings: ProviderDefaultSettings = {
name: 'mailjet',
requiredTls: false,
host: 'in-v3.mailjet.com',
unencryptedPort: 587,
encryptedPort: 465,
mailgun satisfies ProviderDefaultSettings;
export const mailjet = {
description: 'mailjet',
host: 'in-v3.mailjet.com:465',
user: { value: '', placeholder: 'Your Mailjet API key' },
password: { value: '', placeholder: 'Your Mailjet Secret key' },
auth: {
case: 'plain',
password: { value: '', placeholder: 'Your Mailjet Secret key' },
},
image: './assets/images/smtp/mailjet.svg',
senderEmailPlaceholder: 'An authorized domain or email address',
routerLinkElement: 'mailjet',
};
} as const;
export const PostmarkDefaultSettings: ProviderDefaultSettings = {
name: 'postmark',
requiredTls: false,
host: 'smtp.postmarkapp.com',
unencryptedPort: 587,
encryptedPort: 587,
mailjet satisfies ProviderDefaultSettings;
export const postmark = {
description: 'postmark',
host: 'smtp.postmarkapp.com:587',
user: { value: '', placeholder: 'Your Server API token' },
password: { value: '', placeholder: 'Your Server API token' },
auth: {
case: 'plain',
password: { value: '', placeholder: 'Your Server API token' },
},
image: './assets/images/smtp/postmark.png',
senderEmailPlaceholder: 'An authorized domain or email address',
routerLinkElement: 'postmark',
};
} as const;
export const SendgridDefaultSettings: ProviderDefaultSettings = {
name: 'sendgrid',
requiredTls: false,
host: 'smtp.sendgrid.net',
unencryptedPort: 587,
encryptedPort: 465,
postmark satisfies ProviderDefaultSettings;
export const sendgrid = {
description: 'sendgrid',
host: 'smtp.sendgrid.net:465',
user: { value: 'apikey', placeholder: '' },
password: { value: '', placeholder: ' Your SendGrid API Key' },
auth: {
case: 'plain',
password: { value: '', placeholder: ' Your SendGrid API Key' },
},
image: './assets/images/smtp/sendgrid.png',
routerLinkElement: 'sendgrid',
};
} as const;
export const MailchimpDefaultSettings: ProviderDefaultSettings = {
name: 'mailchimp',
requiredTls: false,
host: 'smtp.mandrillapp.com',
unencryptedPort: 587,
encryptedPort: 465,
sendgrid satisfies ProviderDefaultSettings;
export const mailchimp = {
description: 'mailchimp',
host: 'smtp.mandrillapp.com:465',
user: { value: '', placeholder: 'Your Mailchimp primary contact email' },
password: { value: '', placeholder: 'Your Mailchimp Transactional API key' },
auth: {
case: 'plain',
password: { value: '', placeholder: 'Your Mailchimp Transactional API key' },
},
image: './assets/images/smtp/mailchimp.svg',
senderEmailPlaceholder: 'An authorized domain or email address',
routerLinkElement: 'mailchimp',
};
} as const;
export const BrevoDefaultSettings: ProviderDefaultSettings = {
name: 'brevo',
requiredTls: false,
host: 'smtp-relay.sendinblue.com',
unencryptedPort: 587,
encryptedPort: 465,
mailchimp satisfies ProviderDefaultSettings;
export const brevo = {
description: 'brevo',
host: 'smtp-relay.sendinblue.com:465',
user: { value: '', placeholder: 'Your SMTP login email address' },
password: { value: '', placeholder: 'Your SMTP key' },
auth: {
case: 'plain',
password: { value: '', placeholder: 'Your SMTP key' },
},
image: './assets/images/smtp/brevo.svg',
routerLinkElement: 'brevo',
};
} as const;
export const OutlookDefaultSettings: ProviderDefaultSettings = {
name: 'outlook.com',
requiredTls: true,
host: 'smtp-mail.outlook.com',
unencryptedPort: 587,
encryptedPort: 587,
user: { value: '', placeholder: 'Your outlook.com email address' },
password: { value: '', placeholder: 'Your outlook.com password' },
brevo satisfies ProviderDefaultSettings;
export const outlook = {
description: 'Microsoft Exchange Online',
host: 'smtp.office365.com:587',
user: {
value: '',
placeholder: 'your outlook.com email address',
},
auth: {
case: 'xoauth2',
scopes: 'https://outlook.office.com/SMTP.Send',
},
image: './assets/images/smtp/outlook.svg',
senderEmailPlaceholder: 'Your outlook.com email address',
routerLinkElement: 'outlook',
};
} as const;
export const GenericDefaultSettings: ProviderDefaultSettings = {
name: 'generic',
requiredTls: false,
user: { value: '', placeholder: 'your SMTP user' },
password: { value: '', placeholder: 'your SMTP password' },
routerLinkElement: 'generic',
};
export const SMTPKnownProviders = [
AmazonSESDefaultSettings,
BrevoDefaultSettings,
// GoogleDefaultSettings,
MailgunDefaultSettings,
MailchimpDefaultSettings,
MailjetDefaultSettings,
PostmarkDefaultSettings,
SendgridDefaultSettings,
OutlookDefaultSettings,
GenericDefaultSettings,
];
outlook satisfies ProviderDefaultSettings;
@@ -3,35 +3,7 @@ import { RouterModule, Routes } from '@angular/router';
import { SMTPProviderComponent } from './smtp-provider.component';
const types = [
{ path: 'aws-ses', component: SMTPProviderComponent },
{ path: 'generic', component: SMTPProviderComponent },
{ path: 'google', component: SMTPProviderComponent },
{ path: 'mailgun', component: SMTPProviderComponent },
{ path: 'postmark', component: SMTPProviderComponent },
{ path: 'sendgrid', component: SMTPProviderComponent },
{ path: 'mailjet', component: SMTPProviderComponent },
{ path: 'mailchimp', component: SMTPProviderComponent },
{ path: 'brevo', component: SMTPProviderComponent },
{ path: 'outlook', component: SMTPProviderComponent },
];
const routes: Routes = types.map((value) => {
return {
path: value.path,
children: [
{
path: 'create',
component: value.component,
},
],
};
});
routes.push({
path: ':id',
component: SMTPProviderComponent,
});
const routes: Routes = [{ path: ':provider', component: SMTPProviderComponent }];
@NgModule({
imports: [RouterModule.forChild(routes)],
@@ -1,281 +1,345 @@
@let state = this.state();
@let stepper = this.stepper();
<cnsl-create-layout
title="{{
id ? ('SMTP.DETAIL.TITLE' | translate) : ('SMTP.CREATE.STEPS.TITLE' | translate: { value: providerDefaultSetting.name })
}}"
[title]="
!state || 'config' in state
? ('SMTP.DETAIL.TITLE' | translate)
: ('SMTP.CREATE.STEPS.TITLE' | translate: { value: state.mainForm.controls.description.value })
"
[createSteps]="4"
[currentCreateStep]="currentCreateStep"
(closed)="close()"
[currentCreateStep]="stepper ? stepper.selectedIndex + 1 : 1"
(closed)="location.back()"
>
<div class="wizard-header">
<img
class="smtp-logo"
src="{{ providerDefaultSetting.image }}"
alt="{{ providerDefaultSetting.name }}"
*ngIf="providerDefaultSetting.name !== 'generic'"
/>
<div class="smtp-icon" *ngIf="providerDefaultSetting.name === 'generic'">
<mat-icon class="icon" svgIcon="mdi_smtp" alt="providerDefaultSetting.name" />
</div>
@if (state) {
@if ('defaults' in state) {
<img class="smtp-logo" [src]="state.defaults.image" [alt]="state.defaults.description" />
}
@if (!('defaults' in state) && !('config' in state)) {
<div class="smtp-icon">
<mat-icon class="icon" svgIcon="mdi_smtp" alt="providerDefaultSetting.name" />
</div>
}
}
<h2>
{{
!id
? ('SMTP.CREATE.STEPS.CREATE_DESC_TITLE' | translate: { value: providerDefaultSetting.name | titlecase })
: ('SMTP.CREATE.STEPS.CURRENT_DESC_TITLE' | translate)
!state || !('defaults' in state)
? ('SMTP.CREATE.STEPS.CURRENT_DESC_TITLE' | translate)
: ('SMTP.CREATE.STEPS.CREATE_DESC_TITLE' | translate: { value: state.defaults.description | titlecase })
}}
</h2>
</div>
<mat-progress-bar class="progress-bar" color="primary" *ngIf="smtpLoading" mode="indeterminate"></mat-progress-bar>
@if (configOrDefaultsQuery.isLoading()) {
<mat-progress-bar class="progress-bar" color="primary" mode="indeterminate" />
}
<mat-horizontal-stepper
class="stepper {{ 'last-edited-step-' + stepper.selectedIndex }}"
linear
#stepper
labelPosition="bottom"
(selectionChange)="changeStep($event)"
>
<mat-step [editable]="true">
<ng-template matStepLabel>{{ 'SMTP.CREATE.STEPS.PROVIDER_SETTINGS' | translate }}</ng-template>
<form [formGroup]="firstFormGroup" autocomplete="off">
<mat-checkbox class="smtp-checkbox" formControlName="tls" (change)="toggleTLS($event)" data-e2e="tls-checkbox">
{{ 'SETTING.SMTP.TLS' | translate }}
</mat-checkbox>
@if (state) {
<mat-horizontal-stepper
class="stepper {{ 'last-edited-step-' + stepper.selectedIndex }}"
[selectedIndex]="preselectedStep()"
linear
#stepper
labelPosition="bottom"
>
<mat-step [editable]="true">
<ng-template matStepLabel>{{ 'SMTP.CREATE.STEPS.PROVIDER_SETTINGS' | translate }}</ng-template>
<form [formGroup]="state.mainForm" autocomplete="off">
<mat-checkbox class="smtp-checkbox" [formControl]="state.mainForm.controls.tls" data-e2e="tls-checkbox">
{{ 'SETTING.SMTP.TLS' | translate }}
</mat-checkbox>
<cnsl-form-field class="smtp-form-field" *ngIf="providerDefaultSetting.regions">
<cnsl-label>{{ providerDefaultSetting.multiHostsLabel }}</cnsl-label>
<mat-select formControlName="region">
<mat-option *ngFor="let region of providerDefaultSetting.regions | keyvalue" [value]="region.value">
{{ region.key }}
</mat-option>
</mat-select>
</cnsl-form-field>
<cnsl-form-field class="smtp-form-field" label="Description">
<cnsl-label>{{ 'SETTING.SMTP.DESCRIPTION' | translate }}</cnsl-label>
<input cnslInput name="description" formControlName="description" placeholder="description" />
</cnsl-form-field>
<cnsl-form-field class="smtp-form-field" label="Host And Port">
<cnsl-label>{{ 'SETTING.SMTP.HOSTANDPORT' | translate }}</cnsl-label>
<input cnslInput name="hostAndPort" formControlName="hostAndPort" placeholder="host:port" required />
</cnsl-form-field>
<cnsl-form-field class="smtp-form-field" label="User">
<cnsl-label>{{ 'SETTING.SMTP.USER' | translate }}</cnsl-label>
<input
id="smtp-user"
cnslInput
name="smtp-user"
autocomplete="smtp-user"
formControlName="user"
placeholder="{{ providerDefaultSetting.user.placeholder }}"
required
/>
</cnsl-form-field>
<cnsl-form-field class="smtp-form-field" label="Password">
<cnsl-label>{{ 'SETTING.SMTP.PASSWORD' | translate }}</cnsl-label>
<input
id="smtp-password"
cnslInput
name="smtp-password"
autocomplete="off webauthn"
formControlName="password"
placeholder="{{ hasSMTPConfig ? '****************' : providerDefaultSetting.password.placeholder }}"
type="password"
required="{{ !hasSMTPConfig }}"
/>
</cnsl-form-field>
</form>
<div class="smtp-create-actions">
<button
mat-raised-button
[disabled]="firstFormGroup.invalid"
color="primary"
matStepperNext
data-e2e="continue-to-2nd-form"
>
{{ 'ACTIONS.CONTINUE' | translate }}
</button>
</div>
</mat-step>
<mat-step [editable]="true">
<form [formGroup]="secondFormGroup">
<ng-template matStepLabel>{{ 'SMTP.CREATE.STEPS.SENDER_SETTINGS' | translate }}</ng-template>
<cnsl-form-field class="smtp-form-field" label="Sender Address">
<cnsl-label>{{ 'SETTING.SMTP.SENDERADDRESS' | translate }}</cnsl-label>
<input
cnslInput
name="senderAddress"
formControlName="senderAddress"
placeholder="{{ senderEmailPlaceholder }}"
required
/>
</cnsl-form-field>
<cnsl-form-field class="smtp-form-field" label="Sender Name">
<cnsl-label>{{ 'SETTING.SMTP.SENDERNAME' | translate }}</cnsl-label>
<input cnslInput name="senderName" formControlName="senderName" placeholder="Zitadel" required />
</cnsl-form-field>
<cnsl-form-field class="smtp-form-field" label="Reply-To Address">
<cnsl-label>{{ 'SETTING.SMTP.REPLYTOADDRESS' | translate }}</cnsl-label>
<input cnslInput name="senderReplyToAddress" formControlName="replyToAddress" placeholder="replyto@example.com" />
</cnsl-form-field>
</form>
<div class="smtp-create-actions">
<button mat-stroked-button matStepperPrevious class="bck-button">{{ 'ACTIONS.BACK' | translate }}</button>
<button
mat-raised-button
[disabled]="secondFormGroup.invalid"
color="primary"
matStepperNext
data-e2e="continue-button"
>
{{ 'ACTIONS.CONTINUE' | translate }}
</button>
</div>
</mat-step>
<mat-step [editable]="true">
<form>
<ng-template matStepLabel>{{ 'SMTP.CREATE.STEPS.SAVE_SETTINGS' | translate }}</ng-template>
<cnsl-info-section>
<div class="title-row">
<div class="left">
<h2 class="title">{{ 'SMTP.CREATE.STEPS.TEST.TITLE' | translate }}</h2>
</div>
<div class="right">
<button color="primary" mat-raised-button class="continue-button" (click)="testEmailConfiguration()">
{{ 'ACTIONS.TEST' | translate }}
</button>
</div>
</div>
<p class="cnsl-secondary-text description">{{ 'SMTP.CREATE.STEPS.TEST.DESCRIPTION' | translate }}</p>
<cnsl-form-field class="formfield">
<cnsl-label>{{ 'SMTP.LIST.DIALOG.TEST_EMAIL' | translate }}</cnsl-label>
<cnsl-form-field class="smtp-form-field" label="Description">
<cnsl-label>{{ 'SETTING.SMTP.DESCRIPTION' | translate }}</cnsl-label>
<input
cnslInput
[(ngModel)]="email"
[ngModelOptions]="{ standalone: true }"
data-e2e="email-test-dialog-input"
name="description"
[formControl]="state.mainForm.controls.description"
placeholder="description"
/>
</cnsl-form-field>
<div class="is-loading" *ngIf="isLoading()">
<mat-spinner diameter="50"></mat-spinner>
</div>
<cnsl-form-field class="formfield" *ngIf="testResult">
<cnsl-label>{{ 'SMTP.LIST.DIALOG.TEST_RESULT' | translate }}</cnsl-label>
<textarea
<cnsl-form-field class="smtp-form-field" label="Host And Port">
<cnsl-label>{{ 'SETTING.SMTP.HOSTANDPORT' | translate }}</cnsl-label>
<input
cnslInput
class="{{ resultClass }}"
[(ngModel)]="testResult"
[ngModelOptions]="{ standalone: true }"
></textarea>
name="hostAndPort"
[formControl]="state.mainForm.controls.host"
placeholder="host:port"
required
/>
</cnsl-form-field>
</cnsl-info-section>
<mat-checkbox class="smtp-checkbox" [formControl]="state.mainForm.controls.xoauth2">XOAUTH2</mat-checkbox>
<cnsl-form-field class="smtp-form-field" label="User">
<cnsl-label>{{ 'SETTING.SMTP.USER' | translate }}</cnsl-label>
<input
id="smtp-user"
cnslInput
name="smtp-user"
autocomplete="smtp-user"
[formControl]="state.mainForm.controls.user"
required
/>
</cnsl-form-field>
@if ('tokenEndpoint' in state.authForm.controls) {
<cnsl-form-field class="smtp-form-field" label="tokenEndpoint">
<cnsl-label>Token Endpoint</cnsl-label>
<input cnslInput name="token-endpoint" [formControl]="state.authForm.controls.tokenEndpoint" required />
</cnsl-form-field>
<cnsl-form-field class="smtp-form-field" label="scopes">
<cnsl-label>Scopes comma separated</cnsl-label>
<input cnslInput name="scopes" [formControl]="state.authForm.controls.scopes" required />
</cnsl-form-field>
<cnsl-form-field class="smtp-form-field" label="clientId">
<cnsl-label>Client ID</cnsl-label>
<input cnslInput name="client-id" [formControl]="state.authForm.controls.clientId" required />
</cnsl-form-field>
<cnsl-form-field class="smtp-form-field" label="clientSecret">
<cnsl-label>Client Secret</cnsl-label>
<input
cnslInput
type="password"
name="client-id"
[formControl]="state.authForm.controls.clientSecret"
required
/>
</cnsl-form-field>
} @else {
<cnsl-form-field class="smtp-form-field" label="Password">
<cnsl-label>{{ 'SETTING.SMTP.PASSWORD' | translate }}</cnsl-label>
<input
id="smtp-password"
cnslInput
name="smtp-password"
autocomplete="off webauthn"
[formControl]="state.authForm.controls.password"
[placeholder]="
'defaults' in state && state.defaults.auth.case === 'plain' ? state.defaults.auth.password.placeholder : ''
"
type="password"
/>
</cnsl-form-field>
}
</form>
<div class="smtp-create-actions">
<button
mat-raised-button
[disabled]="state.mainForm.invalid || state.authForm.invalid"
color="primary"
matStepperNext
data-e2e="continue-to-2nd-form"
>
{{ 'ACTIONS.CONTINUE' | translate }}
</button>
</div>
</mat-step>
<mat-step [editable]="true">
<form [formGroup]="state.senderForm">
<ng-template matStepLabel>{{ 'SMTP.CREATE.STEPS.SENDER_SETTINGS' | translate }}</ng-template>
<cnsl-form-field class="smtp-form-field" label="Sender Address">
<cnsl-label>{{ 'SETTING.SMTP.SENDERADDRESS' | translate }}</cnsl-label>
<input
cnslInput
name="senderAddress"
formControlName="senderAddress"
[placeholder]="state.senderEmailPlaceholder"
required
/>
</cnsl-form-field>
<cnsl-form-field class="smtp-form-field" label="Sender Name">
<cnsl-label>{{ 'SETTING.SMTP.SENDERNAME' | translate }}</cnsl-label>
<input cnslInput name="senderName" formControlName="senderName" placeholder="Zitadel" required />
</cnsl-form-field>
<cnsl-form-field class="smtp-form-field" label="Reply-To Address">
<cnsl-label>{{ 'SETTING.SMTP.REPLYTOADDRESS' | translate }}</cnsl-label>
<input
cnslInput
name="senderReplyToAddress"
formControlName="replyToAddress"
placeholder="replyto@example.com"
/>
</cnsl-form-field>
</form>
<div class="smtp-create-actions">
<button mat-stroked-button matStepperPrevious class="bck-button">{{ 'ACTIONS.BACK' | translate }}</button>
<button
mat-raised-button
class="create-button"
[disabled]="state.senderForm.invalid"
color="primary"
data-e2e="create-button"
(click)="savePolicy(stepper)"
[disabled]="
firstFormGroup.invalid || secondFormGroup.invalid || (['iam.policy.write'] | hasRole | async) === false
"
matStepperNext
data-e2e="continue-button"
>
{{ !hasSMTPConfig ? ('ACTIONS.CREATE' | translate) : ('ACTIONS.SAVE' | translate) }}
{{ 'ACTIONS.CONTINUE' | translate }}
</button>
</div>
</form>
</mat-step>
</mat-step>
<mat-step [editable]="true">
<form>
<mat-step [editable]="true" [completed]="'config' in state">
<form>
<ng-template matStepLabel>{{ 'SMTP.CREATE.STEPS.SAVE_SETTINGS' | translate }}</ng-template>
<cnsl-info-section>
<div class="title-row">
<div class="left">
<h2 class="title">{{ 'SMTP.CREATE.STEPS.TEST.TITLE' | translate }}</h2>
</div>
<div class="right">
<button
color="primary"
mat-raised-button
class="continue-button"
(click)="testEmailConfigurationMutation.mutate()"
>
{{ 'ACTIONS.TEST' | translate }}
</button>
</div>
</div>
<p class="cnsl-secondary-text description">{{ 'SMTP.CREATE.STEPS.TEST.DESCRIPTION' | translate }}</p>
<cnsl-form-field class="formfield">
<cnsl-label>{{ 'SMTP.LIST.DIALOG.TEST_EMAIL' | translate }}</cnsl-label>
<input
[disabled]="emailQuery.isLoading()"
cnslInput
(ngModelChange)="email.set($event)"
[ngModel]="email()"
[ngModelOptions]="{ standalone: true }"
data-e2e="email-test-dialog-input"
/>
</cnsl-form-field>
<div class="is-loading" *ngIf="testEmailConfigurationMutation.isPending()">
<mat-spinner diameter="50"></mat-spinner>
</div>
<cnsl-form-field
class="formfield"
*ngIf="testEmailConfigurationMutation.isError() || testEmailConfigurationMutation.isSuccess()"
>
<cnsl-label>{{ 'SMTP.LIST.DIALOG.TEST_RESULT' | translate }}</cnsl-label>
@let error = testEmailConfigurationMutation.error();
<textarea
cnslInput
[ngModel]="error ? error.message : ('SMTP.CREATE.STEPS.TEST.RESULT' | translate)"
[ngModelOptions]="{ standalone: true }"
></textarea>
</cnsl-form-field>
</cnsl-info-section>
<div class="smtp-create-actions">
<button mat-stroked-button matStepperPrevious class="bck-button">{{ 'ACTIONS.BACK' | translate }}</button>
<button
(click)="updateDataMutation.mutate()"
mat-raised-button
class="create-button"
color="primary"
data-e2e="create-button"
[disabled]="
state.mainForm.invalid || state.senderForm.invalid || (['iam.policy.write'] | hasRole | async) === false
"
>
{{ 'config' in state ? ('ACTIONS.SAVE' | translate) : ('ACTIONS.CREATE' | translate) }}
</button>
</div>
</form>
</mat-step>
<mat-step [editable]="true">
<ng-template matStepLabel>{{ 'SMTP.CREATE.STEPS.NEXT_STEPS' | translate }}</ng-template>
<cnsl-info-section *ngIf="!isActive">
<div class="title-row">
<div class="left">
<h2 class="title">{{ 'SMTP.CREATE.STEPS.ACTIVATE.TITLE' | translate }}</h2>
<div>
<a
mat-icon-button
card-actions
mat-icon-button
href="https://zitadel.com/docs/guides/manage/console/default-settings#smtp"
rel="noreferrer"
target="_blank"
>
<mat-icon class="next-icon">info_outline</mat-icon>
</a>
</div>
</div>
<div class="right">
@if ('config' in state) {
<form>
@if (state.config.state === EmailProviderState.EMAIL_PROVIDER_ACTIVE) {
<cnsl-info-section>
<div class="title-row">
<div class="left">
<h2 class="title">{{ 'SMTP.CREATE.STEPS.DEACTIVATE.TITLE' | translate }}</h2>
<div>
<a
mat-icon-button
card-actions
mat-icon-button
href="https://zitadel.com/docs/guides/manage/console/default-settings#smtp"
rel="noreferrer"
target="_blank"
>
<mat-icon class="next-icon">info_outline</mat-icon>
</a>
</div>
</div>
<div class="right">
<button
color="primary"
mat-raised-button
class="continue-button"
data-e2e="deactivate-button"
(click)="deactivateSMTPConfig(state.config.id); $event.stopPropagation()"
>
{{ 'ACTIONS.DEACTIVATE' | translate }}
</button>
</div>
</div>
<p class="cnsl-secondary-text description">{{ 'SMTP.CREATE.STEPS.DEACTIVATE.DESCRIPTION' | translate }}</p>
</cnsl-info-section>
} @else if (state.config.state === EmailProviderState.EMAIL_PROVIDER_INACTIVE) {
<cnsl-info-section>
<div class="title-row">
<div class="left">
<h2 class="title">{{ 'SMTP.CREATE.STEPS.ACTIVATE.TITLE' | translate }}</h2>
<div>
<a
mat-icon-button
card-actions
mat-icon-button
href="https://zitadel.com/docs/guides/manage/console/default-settings#smtp"
rel="noreferrer"
target="_blank"
>
<mat-icon class="next-icon">info_outline</mat-icon>
</a>
</div>
</div>
<div class="right">
<button
color="primary"
mat-raised-button
class="continue-button"
data-e2e="activate-button"
(click)="activateSMTPConfig(state.config.id); $event.stopPropagation()"
>
{{ 'ACTIONS.ACTIVATE' | translate }}
</button>
</div>
</div>
<p class="cnsl-secondary-text description">{{ 'SMTP.CREATE.STEPS.ACTIVATE.DESCRIPTION' | translate }}</p>
</cnsl-info-section>
}
<div class="smtp-create-actions">
<button mat-stroked-button matStepperPrevious class="bck-button">{{ 'ACTIONS.BACK' | translate }}</button>
<button
color="primary"
mat-raised-button
class="continue-button"
data-e2e="activate-button"
(click)="activateSMTPConfig(); $event.stopPropagation()"
class="create-button"
color="primary"
data-e2e="close-button"
(click)="location.back()"
>
{{ 'ACTIONS.ACTIVATE' | translate }}
{{ 'ACTIONS.CLOSE' | translate }}
</button>
</div>
</div>
<p class="cnsl-secondary-text description">{{ 'SMTP.CREATE.STEPS.ACTIVATE.DESCRIPTION' | translate }}</p>
</cnsl-info-section>
</form>
}
</mat-step>
<cnsl-info-section *ngIf="isActive">
<div class="title-row">
<div class="left">
<h2 class="title">{{ 'SMTP.CREATE.STEPS.DEACTIVATE.TITLE' | translate }}</h2>
<div>
<a
mat-icon-button
card-actions
mat-icon-button
href="https://zitadel.com/docs/guides/manage/console/default-settings#smtp"
rel="noreferrer"
target="_blank"
>
<mat-icon class="next-icon">info_outline</mat-icon>
</a>
</div>
</div>
<div class="right">
<button
color="primary"
mat-raised-button
class="continue-button"
data-e2e="deactivate-button"
(click)="deactivateSMTPConfig(); $event.stopPropagation()"
>
{{ 'ACTIONS.DEACTIVATE' | translate }}
</button>
</div>
</div>
<p class="cnsl-secondary-text description">{{ 'SMTP.CREATE.STEPS.DEACTIVATE.DESCRIPTION' | translate }}</p>
</cnsl-info-section>
<div class="smtp-create-actions">
<button mat-stroked-button matStepperPrevious class="bck-button">{{ 'ACTIONS.BACK' | translate }}</button>
<button mat-raised-button class="create-button" color="primary" data-e2e="close-button" (click)="close()">
{{ 'ACTIONS.CLOSE' | translate }}
</button>
</div>
</form>
</mat-step>
<ng-template matStepperIcon="edit">
<mat-icon>check</mat-icon>
</ng-template>
</mat-horizontal-stepper>
<ng-template matStepperIcon="edit">
<mat-icon>check</mat-icon>
</ng-template>
</mat-horizontal-stepper>
}
</cnsl-create-layout>
@@ -1,41 +1,24 @@
import { COMMA, ENTER, SPACE } from '@angular/cdk/keycodes';
import { Location } from '@angular/common';
import { Component, signal } from '@angular/core';
import { AbstractControl, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { Subject, take } from 'rxjs';
import { StepperSelectionEvent } from '@angular/cdk/stepper';
import { Options } from 'src/app/proto/generated/zitadel/idp_pb';
import { Component, computed, effect, inject, linkedSignal, Signal, viewChild } from '@angular/core';
import { FormBuilder, FormControl, Validators } from '@angular/forms';
import { requiredValidator } from '../form-field/validators/validators';
import { PolicyComponentServiceType } from '../policies/policy-component-types.enum';
import {
AddSMTPConfigRequest,
AddSMTPConfigResponse,
TestSMTPConfigRequest,
UpdateSMTPConfigRequest,
UpdateSMTPConfigResponse,
} from 'src/app/proto/generated/zitadel/admin_pb';
import { AdminService } from 'src/app/services/admin.service';
import { ToastService } from 'src/app/services/toast.service';
import { ActivatedRoute, Router } from '@angular/router';
import { MatCheckboxChange } from '@angular/material/checkbox';
import {
AmazonSESDefaultSettings,
BrevoDefaultSettings,
GenericDefaultSettings,
GoogleDefaultSettings,
MailchimpDefaultSettings,
MailgunDefaultSettings,
MailjetDefaultSettings,
PostmarkDefaultSettings,
ProviderDefaultSettings,
OutlookDefaultSettings,
SendgridDefaultSettings,
} from './known-smtp-providers-settings';
import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
import * as SMTPKnownProviders from './known-smtp-providers-settings';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { UserService } from '../../services/user.service';
import { injectMutation, injectQuery, QueryFunction } from '@tanstack/angular-query-experimental';
import { NewAdminService } from '../../services/new-admin.service';
import { MessageInitShape } from '@bufbuild/protobuf';
import { AddEmailProviderSMTPRequestSchema, GetEmailProviderByIdResponse } from '@zitadel/proto/zitadel/admin_pb';
import { EMPTY, map, switchMap } from 'rxjs';
import { filter, startWith } from 'rxjs/operators';
import { EmailProviderState } from '@zitadel/proto/zitadel/settings_pb';
import { ToastService } from '../../services/toast.service';
import { MatStepper } from '@angular/material/stepper';
import { SMTPConfigState } from 'src/app/proto/generated/zitadel/settings_pb';
import { TranslateService } from '@ngx-translate/core';
import { ConnectError } from '@zitadel/client';
type Provider = (typeof SMTPKnownProviders)[keyof typeof SMTPKnownProviders];
type State = SMTPProviderComponent['state'] extends Signal<infer T> ? NonNullable<T> : never;
@Component({
selector: 'cnsl-smtp-provider',
@@ -44,330 +27,489 @@ import { TranslateService } from '@ngx-translate/core';
standalone: false,
})
export class SMTPProviderComponent {
public showOptional: boolean = false;
public options: Options = new Options().setIsCreationAllowed(true).setIsLinkingAllowed(true);
public id: string = '';
public providerDefaultSetting: ProviderDefaultSettings = GenericDefaultSettings;
public serviceType: PolicyComponentServiceType = PolicyComponentServiceType.MGMT;
protected readonly EmailProviderState = EmailProviderState;
public readonly separatorKeysCodes: number[] = [ENTER, COMMA, SPACE];
protected readonly emailQuery = this.buildEmailQuery();
protected readonly email = linkedSignal(() => this.emailQuery.data() ?? 'test@example.com');
public smtpLoading: boolean = false;
public hasSMTPConfig: boolean = false;
public isActive: boolean = false;
public updateClientSecret: boolean = false;
protected readonly newAdminService = inject(NewAdminService);
private readonly toast = inject(ToastService);
// stepper
public currentCreateStep: number = 1;
public requestRedirectValuesSubject$: Subject<void> = new Subject();
public firstFormGroup!: UntypedFormGroup;
public secondFormGroup!: UntypedFormGroup;
protected readonly router = inject(Router);
protected readonly location = inject(Location);
private readonly activatedRoute = inject(ActivatedRoute);
private readonly fb = inject(FormBuilder);
public senderEmailPlaceholder = 'sender@example.com';
protected readonly configOrDefaultsQuery: ReturnType<typeof this.buildConfigOrDefaultsQuery>;
protected readonly state: ReturnType<typeof this.buildState>;
protected readonly updateDataMutation: ReturnType<typeof this.buildUpdateDataMutation>;
protected readonly testEmailConfigurationMutation: ReturnType<typeof this.buildTestEmailConfigurationMutation>;
public resultClass = 'test-success';
public isLoading = signal(false);
public email: string = '';
public testResult: string = '';
protected readonly stepper = viewChild(MatStepper);
protected readonly preselectedStep: ReturnType<typeof this.getPreselectedStep>;
constructor(
private service: AdminService,
private _location: Location,
private fb: UntypedFormBuilder,
private toast: ToastService,
private router: Router,
private route: ActivatedRoute,
private authService: GrpcAuthService,
private translate: TranslateService,
) {
this.route.parent?.url.subscribe((urlPath) => {
const providerName = urlPath[urlPath.length - 1].path;
switch (providerName) {
case 'aws-ses':
this.providerDefaultSetting = AmazonSESDefaultSettings;
break;
case 'google':
this.providerDefaultSetting = GoogleDefaultSettings;
break;
case 'mailgun':
this.providerDefaultSetting = MailgunDefaultSettings;
break;
case 'mailjet':
this.providerDefaultSetting = MailjetDefaultSettings;
break;
case 'postmark':
this.providerDefaultSetting = PostmarkDefaultSettings;
break;
case 'sendgrid':
this.providerDefaultSetting = SendgridDefaultSettings;
break;
case 'mailchimp':
this.providerDefaultSetting = MailchimpDefaultSettings;
break;
case 'brevo':
this.providerDefaultSetting = BrevoDefaultSettings;
break;
case 'outlook':
this.providerDefaultSetting = OutlookDefaultSettings;
break;
constructor() {
this.configOrDefaultsQuery = this.buildConfigOrDefaultsQuery();
this.state = this.buildState(this.configOrDefaultsQuery);
this.updateDataMutation = this.buildUpdateDataMutation(this.configOrDefaultsQuery, this.state);
this.testEmailConfigurationMutation = this.buildTestEmailConfigurationMutation(this.state);
effect(() => {
const error = this.configOrDefaultsQuery.error();
if (error) {
this.toast.showError(error);
}
});
this.preselectedStep = this.getPreselectedStep(this.activatedRoute);
}
private getPreselectedStep(activatedRoute: ActivatedRoute) {
const paramMapSignal = toSignal(activatedRoute.paramMap, { requireSync: true });
return computed(() => {
const paramMap = paramMapSignal();
const step = paramMap.get('step');
if (!step) {
return 0;
}
this.firstFormGroup = this.fb.group({
description: [this.providerDefaultSetting.name],
tls: [{ value: this.providerDefaultSetting.requiredTls, disabled: this.providerDefaultSetting.requiredTls }],
region: [''],
hostAndPort: [
this.providerDefaultSetting?.host
? `${this.providerDefaultSetting?.host}:${this.providerDefaultSetting?.unencryptedPort}`
: '',
],
user: [this.providerDefaultSetting?.user.value || ''],
password: [this.providerDefaultSetting?.password.value || ''],
});
this.senderEmailPlaceholder = this.providerDefaultSetting?.senderEmailPlaceholder || 'sender@example.com';
this.secondFormGroup = this.fb.group({
senderAddress: ['', [requiredValidator]],
senderName: ['', [requiredValidator]],
replyToAddress: [''],
});
this.region?.valueChanges.subscribe((region: string) => {
this.hostAndPort?.setValue(
`${region}:${
this.tls ? this.providerDefaultSetting?.encryptedPort : this.providerDefaultSetting?.unencryptedPort
}`,
);
});
if (!this.router.url.endsWith('/create')) {
this.id = this.route.snapshot.paramMap.get('id') || '';
if (this.id) {
this.fetchData(this.id);
}
}
this.authService
.getMyUser()
.then((resp) => {
if (resp.user) {
this.email = resp.user.human?.email?.email || '';
}
})
.catch((error) => {
this.toast.showError(error);
});
return Number(step);
});
}
public changeStep(event: StepperSelectionEvent): void {
this.currentCreateStep = event.selectedIndex + 1;
if (event.selectedIndex >= 2) {
this.requestRedirectValuesSubject$.next();
}
}
public close(): void {
this._location.back();
}
public toggleTLS(event: MatCheckboxChange) {
if (this.providerDefaultSetting.host) {
this.hostAndPort?.setValue(
`${this.providerDefaultSetting?.host}:${
event.checked ? this.providerDefaultSetting?.encryptedPort : this.providerDefaultSetting?.unencryptedPort
}`,
);
}
}
private fetchData(id: string): void {
this.smtpLoading = true;
this.service
.getSMTPConfigById(id)
.then((data) => {
this.smtpLoading = false;
if (data.smtpConfig) {
this.isActive = data.smtpConfig.state === SMTPConfigState.SMTP_CONFIG_ACTIVE;
this.hasSMTPConfig = true;
this.firstFormGroup.patchValue({
['description']: data.smtpConfig.description,
['tls']: data.smtpConfig.tls,
['hostAndPort']: data.smtpConfig.host,
['user']: data.smtpConfig.user,
});
this.secondFormGroup.patchValue({
['senderAddress']: data.smtpConfig.senderAddress,
['senderName']: data.smtpConfig.senderName,
['replyToAddress']: data.smtpConfig.replyToAddress,
});
private buildEmailQuery() {
const userQueryOptions = inject(UserService).userQueryOptions();
return injectQuery(() => ({
...userQueryOptions,
select: (user) => {
if (user?.type.case !== 'human') {
return '';
}
})
.catch((error) => {
this.smtpLoading = false;
if (error && error.code === 5) {
this.hasSMTPConfig = false;
}
});
return user.type.value.email?.email ?? '';
},
}));
}
private buildState(configOrDefaultsQuery: typeof this.configOrDefaultsQuery) {
const stateSignal = computed(() => {
const configOrDefaults = configOrDefaultsQuery.data();
if (!configOrDefaults) {
return undefined;
}
return configOrDefaults.case === 'defaults'
? ({ ...this.buildFormFromDefaults(configOrDefaults.defaults) } as const)
: ({ ...this.buildFormFromConfig(configOrDefaults.config) } as const);
});
const authFormSignal = this.buildAuthForm(stateSignal);
return computed(() => {
const state = stateSignal();
const authForm = authFormSignal();
if (!state || !authForm) {
return undefined;
}
return {
...state,
authForm,
} as const;
});
}
private updateData(): Promise<UpdateSMTPConfigResponse.AsObject | AddSMTPConfigResponse.AsObject> {
if (this.hasSMTPConfig) {
const req = new UpdateSMTPConfigRequest();
req.setId(this.id);
req.setDescription(this.description?.value || '');
req.setTls(this.tls?.value ?? false);
private readonly hostnameValidator = Validators.pattern(/.+:[0-9]+/);
if (this.hostAndPort && this.hostAndPort.value) {
req.setHost(this.hostAndPort.value);
private buildFormFromConfig(config: ReturnType<typeof this.getConfig>) {
const mainForm = this.fb.group({
description: new FormControl<string>(config.description, {
nonNullable: true,
validators: [requiredValidator],
}),
user: new FormControl<string>(config.config.value.user, { nonNullable: true, validators: [requiredValidator] }),
host: new FormControl(config.config.value.host, {
nonNullable: true,
validators: [this.hostnameValidator],
}),
tls: new FormControl(config.config.value.tls, { nonNullable: true }),
xoauth2: new FormControl<boolean>(config.config.value.Auth.case === 'xoauth2', { nonNullable: true }),
});
mainForm.controls.xoauth2.disable();
const senderForm = this.buildSenderForm(config.config.value);
return {
mainForm,
senderForm,
senderEmailPlaceholder: 'sender@example.com',
config,
};
}
private buildFormFromDefaults(defaults?: Provider):
| {
mainForm: typeof mainForm;
senderForm: typeof senderForm;
senderEmailPlaceholder: string;
}
if (this.user && this.user.value) {
req.setUser(this.user.value);
}
if (this.password && this.password.value) {
req.setPassword(this.password.value);
}
if (this.senderAddress && this.senderAddress.value) {
req.setSenderAddress(this.senderAddress.value);
}
if (this.senderName && this.senderName.value) {
req.setSenderName(this.senderName.value);
}
if (this.replyToAddress && this.replyToAddress.value) {
req.setReplyToAddress(this.replyToAddress.value);
}
return this.service.updateSMTPConfig(req);
} else {
const req = new AddSMTPConfigRequest();
req.setDescription(this.description?.value ?? '');
req.setHost(this.hostAndPort?.value ?? '');
req.setSenderAddress(this.senderAddress?.value ?? '');
req.setSenderName(this.senderName?.value ?? '');
req.setReplyToAddress(this.replyToAddress?.value ?? '');
req.setTls(this.tls?.value ?? false);
req.setUser(this.user?.value ?? '');
req.setPassword(this.password?.value ?? '');
return this.service.addSMTPConfig(req);
| {
mainForm: typeof mainForm;
senderForm: typeof senderForm;
senderEmailPlaceholder: string;
defaults: Provider;
} {
const mainForm = this.fb.group({
description: new FormControl<string>(defaults?.description ?? '', {
nonNullable: true,
validators: [requiredValidator],
}),
user: new FormControl<string>(defaults?.user.value ?? '', { nonNullable: true, validators: [requiredValidator] }),
host: new FormControl(defaults?.host ?? '', {
nonNullable: true,
validators: [this.hostnameValidator],
}),
tls: new FormControl<boolean>(true, { nonNullable: true }),
xoauth2: new FormControl<boolean>(defaults?.auth.case === 'xoauth2', { nonNullable: true }),
});
if (defaults) {
mainForm.controls.tls.disable();
mainForm.controls.xoauth2.disable();
}
const senderForm = this.buildSenderForm();
const senderEmailPlaceholder =
defaults && 'senderEmailPlaceholder' in defaults ? defaults.senderEmailPlaceholder : 'sender@example.com';
return defaults
? {
mainForm,
senderForm,
senderEmailPlaceholder,
defaults,
}
: {
mainForm,
senderForm,
senderEmailPlaceholder,
};
}
public activateSMTPConfig() {
this.service
.activateSMTPConfig(this.id)
.then(() => {
this.toast.showInfo('SMTP.LIST.DIALOG.ACTIVATED', true);
this.isActive = true;
})
.catch((error) => {
this.toast.showError(error);
});
private buildSenderForm(config?: { senderAddress: string; senderName: string; replyToAddress: string }) {
return this.fb.group({
senderAddress: new FormControl(config?.senderAddress ?? '', { nonNullable: true, validators: [requiredValidator] }),
senderName: new FormControl(config?.senderName ?? '', { nonNullable: true, validators: [requiredValidator] }),
replyToAddress: new FormControl(config?.replyToAddress ?? '', { nonNullable: true }),
});
}
public deactivateSMTPConfig() {
this.service
.deactivateSMTPConfig(this.id)
.then(() => {
this.toast.showInfo('SMTP.LIST.DIALOG.DEACTIVATED', true);
this.isActive = false;
})
.catch((error) => {
this.toast.showError(error);
});
}
private buildAuthForm(
stateSignal: Signal<
ReturnType<typeof this.buildFormFromDefaults> | ReturnType<typeof this.buildFormFromConfig> | undefined
>,
) {
const xoauth2$ = toObservable(stateSignal).pipe(
switchMap((state) => {
if (!state) {
return EMPTY;
}
const xoauth2 = state.mainForm.controls.xoauth2;
return xoauth2.valueChanges.pipe(startWith(xoauth2.value));
}),
);
public savePolicy(stepper: MatStepper): void {
this.updateData()
.then((resp) => {
if (!this.id) {
// This is a new SMTP provider let's get the ID from the addSMTPConfig response
let createResponse = resp as AddSMTPConfigResponse.AsObject;
this.id = createResponse.id;
const xoauth2Signal = toSignal(xoauth2$);
return computed(() => {
const state = stateSignal();
const xoauth2 = xoauth2Signal();
if (!state || xoauth2 === undefined) {
return undefined;
}
if (!xoauth2) {
const form = this.fb.group({
password: new FormControl('', {
nonNullable: true,
validators: 'defaults' in state ? [requiredValidator] : [],
}),
});
if ('config' in state) {
form.controls.password.disable();
}
this.toast.showInfo('SETTING.SMTP.SAVED', true);
setTimeout(() => {
stepper.next();
}, 2000);
})
.catch((error: unknown) => {
if (`${error}`.includes('No changes')) {
return form;
}
const defaultValues =
'config' in state && state.config.config.value.Auth.case === 'xoauth2'
? {
tokenEndpoint: state.config.config.value.Auth.value.tokenEndpoint,
scopes: state.config.config.value.Auth.value.scopes.join(','),
clientId: state.config.config.value.Auth.value.OAuth2Type.value?.clientId ?? '',
}
: 'defaults' in state && state.defaults.auth.case === 'xoauth2'
? { scopes: state.defaults.auth.scopes }
: {};
const form = this.fb.group({
tokenEndpoint: new FormControl<string>(defaultValues.tokenEndpoint ?? '', {
nonNullable: true,
validators: [requiredValidator],
}),
scopes: new FormControl<string>(defaultValues.scopes ?? '', { nonNullable: true, validators: [requiredValidator] }),
clientId: new FormControl<string>(defaultValues.clientId ?? '', {
nonNullable: true,
validators: [requiredValidator],
}),
clientSecret: new FormControl<string>('', { nonNullable: true, validators: [requiredValidator] }),
});
if ('config' in state) {
form.controls.tokenEndpoint.disable();
form.controls.scopes.disable();
form.controls.clientId.disable();
form.controls.clientSecret.disable();
}
return form;
});
}
private buildConfigOrDefaultsQuery() {
const idOrProvider$ = this.activatedRoute.paramMap.pipe(
map((params) => params.get('provider')),
filter(Boolean),
);
const idOrProviderSignal = toSignal(idOrProvider$, { requireSync: true });
return injectQuery(() => {
const idOrProvider = idOrProviderSignal();
const query = this.newAdminService.getEmailProviderByIdQueryOptions(idOrProvider);
const queryKey = query.queryKey as (string | undefined)[];
const queryFn = query.queryFn as QueryFunction<GetEmailProviderByIdResponse | string>;
const select = (configOrProvider: GetEmailProviderByIdResponse | string) =>
typeof configOrProvider === 'string'
? ({
case: 'defaults',
defaults: SMTPKnownProviders[configOrProvider as keyof typeof SMTPKnownProviders] as
| (typeof SMTPKnownProviders)[keyof typeof SMTPKnownProviders]
| undefined,
} as const)
: ({ case: 'config', config: this.getConfig(configOrProvider) } as const);
if (idOrProvider in SMTPKnownProviders || idOrProvider === 'generic') {
return {
queryKey,
queryFn: (async () => idOrProvider) as typeof queryFn,
gcTime: 0,
select,
} as const;
}
return {
queryKey,
queryFn,
select,
} as const;
});
}
private getConfig(resp: GetEmailProviderByIdResponse) {
if (!resp.config) {
throw new Error('No SMTP provider config found');
}
if (resp.config.config.case !== 'smtp') {
throw new Error('Email provider config with id ' + resp.config.id + ' is not an SMTP config');
}
const config = resp.config.config.value;
return {
...resp.config,
config: {
case: 'smtp' as const,
value: {
...config,
Auth: config.Auth,
},
},
};
}
private buildUpdateDataMutation(configOrDefaultsQuery: typeof this.configOrDefaultsQuery, stateSignal: typeof this.state) {
return injectMutation(() => {
const state = stateSignal();
const stepper = this.stepper();
return {
mutationFn: () => {
if (!state) {
throw new Error('Invalid state');
}
return this.updateData(state);
},
onSuccess: () => {
this.toast.showInfo('SETTING.SMTP.SAVED', true);
stepper?.next();
},
onError: (error: ConnectError) => {
if (!error.message.includes('No changes')) {
this.toast.showError(error);
return;
}
this.toast.showInfo('SETTING.SMTP.NOCHANGES', true);
setTimeout(() => {
stepper.next();
}, 2000);
} else {
this.toast.showError(error);
}
stepper?.next();
},
onSettled: () => configOrDefaultsQuery.refetch(),
};
});
}
private async updateData(state: State) {
const authValues = state.authForm.getRawValue();
const { user, tls, host, description } = state.mainForm.getRawValue();
const { senderAddress, senderName, replyToAddress } = state.senderForm.getRawValue();
if ('config' in state) {
return this.newAdminService.updateEmailProviderSMTP({
id: state.config.id,
description,
senderAddress,
senderName,
replyToAddress,
host,
user,
tls,
});
}
const Auth: MessageInitShape<typeof AddEmailProviderSMTPRequestSchema>['Auth'] =
'tokenEndpoint' in authValues
? {
case: 'xoauth2',
value: {
tokenEndpoint: authValues.tokenEndpoint,
scopes: authValues.scopes.replace(/\s/g, '').split(','),
OAuth2Type: {
case: 'clientCredentials',
value: {
clientId: authValues.clientId,
clientSecret: authValues.clientSecret,
},
},
},
}
: authValues.password
? { case: 'plain', value: { password: authValues.password } }
: { case: 'none', value: {} };
const res = await this.newAdminService.addEmailProviderSMTP({
senderAddress,
senderName,
description,
replyToAddress,
host,
user,
tls,
Auth,
});
await this.router.navigate(['/instance/smtpprovider', res.id, { step: 3 }], { skipLocationChange: true });
return res;
}
public testEmailConfiguration(): void {
this.isLoading.set(true);
const req = new TestSMTPConfigRequest();
req.setSenderAddress(this.senderAddress?.value ?? '');
req.setSenderName(this.senderName?.value ?? '');
req.setHost(this.hostAndPort?.value ?? '');
req.setUser(this.user?.value);
req.setPassword(this.password?.value ?? '');
req.setTls(this.tls?.value ?? false);
req.setId(this.id ?? '');
req.setReceiverAddress(this.email ?? '');
this.service
.testSMTPConfig(req)
.then(() => {
this.resultClass = 'test-success';
this.isLoading.set(false);
this.translate
.get('SMTP.CREATE.STEPS.TEST.RESULT')
.pipe(take(1))
.subscribe((msg) => {
this.testResult = msg;
});
})
.catch((error) => {
this.resultClass = 'test-error';
this.isLoading.set(false);
this.testResult = error;
});
protected async activateSMTPConfig(id: string) {
try {
await this.newAdminService.activateSMTPConfig(id);
this.toast.showInfo('SMTP.LIST.DIALOG.ACTIVATED', true);
await this.configOrDefaultsQuery.refetch();
} catch (error) {
this.toast.showError(error);
}
}
public get description(): AbstractControl | null {
return this.firstFormGroup.get('description');
protected async deactivateSMTPConfig(id: string) {
try {
await this.newAdminService.deactivateSMTPConfig(id);
this.toast.showInfo('SMTP.LIST.DIALOG.DEACTIVATED', true);
await this.configOrDefaultsQuery.refetch();
} catch (error) {
this.toast.showError(error);
}
}
public get tls(): AbstractControl | null {
return this.firstFormGroup.get('tls');
}
protected buildTestEmailConfigurationMutation(stateSignal: typeof this.state) {
const buildRequest = (state: State, receiverAddress: string) => {
const authValues = state.authForm.getRawValue();
const { user, tls, host } = state.mainForm.getRawValue();
const { senderAddress, senderName } = state.senderForm.getRawValue();
public get region(): AbstractControl | null {
return this.firstFormGroup.get('region');
}
if ('config' in state) {
return {
id: state.config.id,
senderAddress,
senderName,
host,
user,
tls,
receiverAddress,
};
}
public get hostAndPort(): AbstractControl | null {
return this.firstFormGroup.get('hostAndPort');
}
const Auth: MessageInitShape<typeof AddEmailProviderSMTPRequestSchema>['Auth'] =
'tokenEndpoint' in authValues
? {
case: 'xoauth2',
value: {
tokenEndpoint: authValues.tokenEndpoint,
scopes: authValues.scopes.replace(/\s/g, '').split(','),
OAuth2Type: {
case: 'clientCredentials',
value: {
clientId: authValues.clientId,
clientSecret: authValues.clientSecret,
},
},
},
}
: authValues.password
? { case: 'plain', value: { password: authValues.password } }
: { case: 'none', value: {} };
public get user(): AbstractControl | null {
return this.firstFormGroup.get('user');
}
return {
senderAddress,
senderName,
host,
user,
tls,
Auth,
receiverAddress,
};
};
public get password(): AbstractControl | null {
return this.firstFormGroup.get('password');
}
return injectMutation(() => {
const state = stateSignal();
const email = this.email();
public get senderAddress(): AbstractControl | null {
return this.secondFormGroup.get('senderAddress');
}
public get senderName(): AbstractControl | null {
return this.secondFormGroup.get('senderName');
}
public get replyToAddress(): AbstractControl | null {
return this.secondFormGroup.get('replyToAddress');
return {
mutationFn: () => {
if (!state) {
throw new Error('Invalid state');
}
return this.newAdminService.testEmailProviderSMTP(buildRequest(state, email));
},
};
});
}
}
@@ -193,18 +193,10 @@ export class SMTPTableComponent implements OnInit {
this.getData(this.paginator.pageSize, this.paginator.pageIndex * this.paginator.pageSize);
}
public get createRouterLink(): RouterLink | any {
return ['/instance', 'idp', 'create'];
}
public routerLinkForRow(row: SMTPConfig.AsObject): any {
return ['/instance', 'smtpprovider', row.id];
}
public get displayedColumnsWithActions(): string[] {
return ['actions', ...this.displayedColumns];
}
public navigateToProvider(row: SMTPConfig.AsObject) {
if (!row.senderAddress) {
return;
+36 -3
View File
@@ -2,12 +2,14 @@ import { Injectable } from '@angular/core';
import { GrpcService } from './grpc.service';
import { MessageInitShape } from '@bufbuild/protobuf';
import {
AddEmailProviderSMTPRequestSchema,
GetDefaultOrgResponse,
GetMyInstanceResponse,
SetUpOrgRequestSchema,
SetUpOrgResponse,
TestEmailProviderSMTPRequestSchema,
UpdateEmailProviderSMTPRequestSchema,
} from '@zitadel/proto/zitadel/admin_pb';
import { injectQuery } from '@tanstack/angular-query-experimental';
import { injectQuery, queryOptions, skipToken } from '@tanstack/angular-query-experimental';
import { NewAuthService } from './new-auth.service';
import { UserService } from './user.service';
@@ -21,7 +23,7 @@ export class NewAdminService {
private readonly userService: UserService,
) {}
public setupOrg(req: MessageInitShape<typeof SetUpOrgRequestSchema>): Promise<SetUpOrgResponse> {
public setupOrg(req: MessageInitShape<typeof SetUpOrgRequestSchema>) {
return this.grpcService.adminNew.setUpOrg(req);
}
@@ -41,4 +43,35 @@ export class NewAdminService {
enabled: (listMyZitadelPermissionsQuery.data() ?? []).includes('iam.write'),
}));
}
public testEmailProviderSMTP(req: MessageInitShape<typeof TestEmailProviderSMTPRequestSchema>) {
return this.grpcService.adminNew.testEmailProviderSMTP(req);
}
public getEmailProviderById(id: string, signal: AbortSignal) {
return this.grpcService.adminNew.getEmailProviderById({ id }, { signal });
}
public getEmailProviderByIdQueryOptions(id?: string) {
return queryOptions({
queryKey: [this.userService.userId(), 'AdminService', 'getEmailProviderById', id],
queryFn: id ? ({ signal }) => this.getEmailProviderById(id, signal) : skipToken,
});
}
public addEmailProviderSMTP(req: MessageInitShape<typeof AddEmailProviderSMTPRequestSchema>) {
return this.grpcService.adminNew.addEmailProviderSMTP(req);
}
public updateEmailProviderSMTP(req: MessageInitShape<typeof UpdateEmailProviderSMTPRequestSchema>) {
return this.grpcService.adminNew.updateEmailProviderSMTP(req);
}
public activateSMTPConfig(id: string) {
return this.grpcService.adminNew.activateSMTPConfig({ id });
}
public deactivateSMTPConfig(id: string) {
return this.grpcService.adminNew.deactivateSMTPConfig({ id });
}
}
@@ -36,19 +36,19 @@ describe('instance notifications', () => {
cy.get<SMTPProvider>('@provider').then((provider) => {
cy.visit(smtpPath);
cy.get(`a:contains('Mailgun')`).click();
cy.get('[formcontrolname="description"]').should('be.enabled').clear().type(provider.description);
cy.get('[formcontrolname="hostAndPort"]').should('have.value', 'smtp.mailgun.org:587');
cy.get('[formcontrolname="user"]').should('be.enabled').clear().type('user@example.com');
cy.get('[formcontrolname="password"]').should('be.enabled').clear().type('password');
cy.get('input[name="description"]').should('be.enabled').clear().type(provider.description);
cy.get('input[name="hostAndPort"]').should('have.value', 'smtp.mailgun.org:465');
cy.get('input[name="smtp-user"]').should('be.enabled').clear().type('user@example.com');
cy.get('input[name="smtp-password"]').should('be.enabled').clear().type('password');
cy.get('[data-e2e="continue-to-2nd-form"]').should('be.enabled').click();
cy.get('[formcontrolname="senderAddress"]').should('be.enabled').clear().type('sender1@example.com');
cy.get('[formcontrolname="senderName"]').should('be.enabled').clear().type('Test1');
cy.get('[formcontrolname="replyToAddress"]').should('be.enabled').clear().type('replyto1@example.com');
cy.get('input[name="senderAddress"]').should('be.enabled').clear().type('sender1@example.com');
cy.get('input[name="senderName"]').should('be.enabled').clear().type('Test1');
cy.get('input[name="senderReplyToAddress"]').should('be.enabled').clear().type('replyto1@example.com');
cy.get('[data-e2e="continue-button"]').should('be.enabled').click();
cy.get('[data-e2e="create-button"]').should('be.enabled').click();
cy.shouldConfirmSuccess();
cy.get('[data-e2e="close-button"]').should('be.enabled').click();
cy.get(provider.rowSelector).contains('smtp.mailgun.org:587');
cy.get(provider.rowSelector).contains('smtp.mailgun.org:465');
cy.get(provider.rowSelector).contains('sender1@example.com');
});
});
@@ -57,14 +57,14 @@ describe('instance notifications', () => {
cy.get<SMTPProvider>('@provider').then((provider) => {
cy.visit(smtpPath);
cy.get(`a:contains('Mailgun')`).click();
cy.get('[formcontrolname="description"]').should('be.enabled').clear().type(provider.description);
cy.get('[formcontrolname="hostAndPort"]').should('have.value', 'smtp.mailgun.org:587');
cy.get('[formcontrolname="user"]').should('be.enabled').clear().type('user@example.com');
cy.get('[formcontrolname="password"]').should('be.enabled').clear().type('password');
cy.get('input[name="description"]').should('be.enabled').clear().type(provider.description);
cy.get('input[name="hostAndPort"]').should('have.value', 'smtp.mailgun.org:465');
cy.get('input[name="smtp-user"]').should('be.enabled').clear().type('user@example.com');
cy.get('input[name="smtp-password"]').should('be.enabled').clear().type('password');
cy.get('[data-e2e="continue-to-2nd-form"]').should('be.enabled').click();
cy.get('[formcontrolname="senderAddress"]').should('be.enabled').clear().type('sender1@example.com');
cy.get('[formcontrolname="senderName"]').should('be.enabled').clear().type('Test1');
cy.get('[formcontrolname="replyToAddress"]').should('be.enabled').clear().type('replyto1@example.com');
cy.get('input[name="senderAddress"]').should('be.enabled').clear().type('sender1@example.com');
cy.get('input[name="senderName"]').should('be.enabled').clear().type('Test1');
cy.get('input[name="senderReplyToAddress"]').should('be.enabled').clear().type('replyto1@example.com');
cy.get('[data-e2e="continue-button"]').should('be.enabled').click();
cy.get('[data-e2e="create-button"]').click();
cy.shouldConfirmSuccess();
@@ -72,7 +72,7 @@ describe('instance notifications', () => {
cy.shouldConfirmSuccess();
cy.get('[data-e2e="close-button"]').click();
cy.get(provider.rowSelector).find('[data-e2e="active-provider"]');
cy.get(provider.rowSelector).contains('smtp.mailgun.org:587');
cy.get(provider.rowSelector).contains('smtp.mailgun.org:465');
cy.get(provider.rowSelector).contains('sender1@example.com');
});
});
@@ -91,8 +91,8 @@ describe('instance notifications', () => {
cy.get<SMTPProvider>('@provider').then(({ rowSelector }) => {
cy.get(rowSelector).click();
cy.get('[data-e2e="continue-to-2nd-form"]').click();
cy.get('[formcontrolname="senderAddress"]').should('be.enabled').clear().type('senderchange1@example.com');
cy.get('[formcontrolname="senderName"]').clear().type('Change1');
cy.get('input[name="senderAddress"]').should('be.enabled').clear().type('senderchange1@example.com');
cy.get('input[name="senderName"]').clear().type('Change1');
cy.get('[data-e2e="continue-button"]').click();
cy.get('[data-e2e="create-button"]').click();
cy.shouldConfirmSuccess();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"implicitDependencies": ["@zitadel/api"],
"implicitDependencies": ["@zitadel/api", "@zitadel/console"],
"targets": {
"run-db": {
"continuous": true,