diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 128eb61a5a..990e759aae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -101,7 +101,8 @@ Please make sure you cover your changes with tests before marking a Pull Request - [ ] Integration tests against the gRPC server ensure that probable good and bad read and write permissions are tested. - [ ] Integration tests against the gRPC server ensure that the API is easily usable despite eventual consistency. - [ ] Integration tests against the gRPC server ensure that all probable login and registration flows are covered." -- [ ] Integration tests ensure that certain commands send expected notifications. +- [ ] Integration tests ensure that certain commands emit expected events that trigger notifications. +- [ ] Integration tests ensure that certain events trigger expected notifications. ## Contribute diff --git a/README.md b/README.md index 96c6bd95e0..d5a89a5669 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,10 @@ Do you look for a user management that's quickly set up like Auth0 and open sour Do you have a project that requires multi-tenant user management with self-service for your customers? -Look no further — ZITADEL combines the ease of Auth0 with the versatility of Keycloak. +Look no further — ZITADEL is the identity infrastructure, simplified for you. We provide you with a wide range of out-of-the-box features to accelerate your project. -Multi-tenancy with branding customization, secure login, self-service, OpenID Connect, OAuth2.x, SAML2, LDAP, Passwordless with FIDO2 (including Passkeys), OTP, U2F, and an unlimited audit trail is there for you, ready to use. +Multi-tenancy with branding customization, secure login, self-service, OpenID Connect, OAuth2.x, SAML2, LDAP, Passkeys / FIDO2, OTP, U2F, and an unlimited audit trail is there for you, ready to use. With ZITADEL you can rely on a hardened and extensible turnkey solution to solve all of your authentication and authorization needs. @@ -67,7 +67,7 @@ See all guides [here](https://zitadel.com/docs/self-hosting/deploy/overview) If you want to experience a hands-free ZITADEL, you should use [ZITADEL Cloud](https://zitadel.cloud). -It is free for up to 25'000 authenticated requests and provides you all the features that make ZITADEL great. +ZITADEL Cloud comes with a free tier and provides you all the features that you find in the open source version. Learn more about the [pay-as-you-go pricing](https://zitadel.com/pricing). ### Example applications @@ -84,6 +84,7 @@ We built ZITADEL with a complex multi-tenancy architecture in mind and provide t Yet it offers everything you need for a customer identity ([CIAM](https://zitadel.com/docs/guides/solution-scenarios/b2c)) use case. - [API-first approach](https://zitadel.com/docs/apis/introduction) +- [Multi-tenancy](https://zitadel.com/docs/guides/solution-scenarios/b2b) authentication and access management - Strong audit trail thanks to [event sourcing](https://zitadel.com/docs/concepts/eventstore/overview) as storage pattern - [Actions](https://zitadel.com/docs/apis/actions/introduction) to react on events with custom code and extended ZITADEL for you needs - [Branding](https://zitadel.com/docs/guides/manage/customize/branding) for a uniform user experience across multiple organizations @@ -94,12 +95,15 @@ Yet it offers everything you need for a customer identity ([CIAM](https://zitade Authentication - Single Sign On (SSO) -- Passwordless with FIDO2 support (Including Passkeys) +- Passkeys support (FIDO2 / WebAuthN) - Username / Password - Multifactor authentication with OTP, U2F, Email OTP, SMS OTP - LDAP +- External enterprise identity providers and social logins +- [Device authorization](https://zitadel.com/docs/guides/solution-scenarios/device-authorization) - [OpenID Connect certified](https://openid.net/certification/#OPs) => [OIDC Endpoints](https://zitadel.com/docs/apis/openidoauth/endpoints) - [SAML 2.0](http://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-tech-overview-2.0.html) => [SAML Endpoints](https://zitadel.com/docs/apis/saml/endpoints) +- [Custom sessions](https://zitadel.com/docs/guides/integrate/login-ui/username-password) if you need to go beyond OIDC or SAML - [Machine-to-machine](https://zitadel.com/docs/guides/integrate/serviceusers) with JWT profile, Personal Access Tokens (PAT), and Client Credentials Multi-Tenancy diff --git a/cmd/start/start.go b/cmd/start/start.go index 4c6a92c4e8..6a017af0db 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -235,7 +235,6 @@ func startZitadel(config *Config, masterKey string, server chan<- *Server) error commands, queries, eventstoreClient, - assets.AssetAPIFromDomain(config.ExternalSecure, config.ExternalPort), config.Login.DefaultOTPEmailURLV2, config.SystemDefaults.Notifications.FileSystemPath, keys.User, @@ -311,6 +310,8 @@ func startAPIs( authZRepo, queries, } + // always set the origin in the context if available in the http headers, no matter for what protocol + router.Use(middleware.OriginHandler) verifier := internal_authz.Start(repo, http_util.BuildHTTP(config.ExternalDomain, config.ExternalPort, config.ExternalSecure), config.SystemAPIUsers) tlsConfig, err := config.TLS.Config() if err != nil { @@ -444,7 +445,6 @@ func startAPIs( if err := apis.RegisterService(ctx, oidc_v2.CreateServer(commands, queries, oidcProvider, config.ExternalSecure)); err != nil { return err } - // handle grpc at last to be able to handle the root, because grpc and gateway require a lot of different prefixes apis.RouteGRPC() return nil diff --git a/console/src/app/modules/policies/notification-policy/notification-policy.component.html b/console/src/app/modules/policies/notification-policy/notification-policy.component.html index 786fe4ec14..f2ba5f04c5 100644 --- a/console/src/app/modules/policies/notification-policy/notification-policy.component.html +++ b/console/src/app/modules/policies/notification-policy/notification-policy.component.html @@ -26,6 +26,7 @@ color="primary" name="hasUppercase" ngDefaultControl + data-e2e="notification-policy-checkbox" [(ngModel)]="notificationData.passwordChange" [disabled]="(['policy.write'] | hasRole | async) === false" > @@ -43,6 +44,7 @@ color="primary" type="submit" mat-raised-button + data-e2e="save-notification-policy-button" > {{ 'ACTIONS.SAVE' | translate }} diff --git a/console/src/app/modules/policies/notification-settings/notification-settings.component.spec.ts b/console/src/app/modules/policies/notification-settings/notification-settings.component.spec.ts deleted file mode 100644 index cd48a053f7..0000000000 --- a/console/src/app/modules/policies/notification-settings/notification-settings.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { NotificationSettingsComponent } from './notification-settings.component'; - -describe('NotificationSettingsComponent', () => { - let component: NotificationSettingsComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - declarations: [NotificationSettingsComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(NotificationSettingsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/console/src/app/modules/policies/notification-settings/dialog-add-sms-provider/dialog-add-sms-provider.component.html b/console/src/app/modules/policies/notification-sms-provider/dialog-add-sms-provider/dialog-add-sms-provider.component.html similarity index 97% rename from console/src/app/modules/policies/notification-settings/dialog-add-sms-provider/dialog-add-sms-provider.component.html rename to console/src/app/modules/policies/notification-sms-provider/dialog-add-sms-provider/dialog-add-sms-provider.component.html index f9631924d4..b8d239ef18 100644 --- a/console/src/app/modules/policies/notification-settings/dialog-add-sms-provider/dialog-add-sms-provider.component.html +++ b/console/src/app/modules/policies/notification-sms-provider/dialog-add-sms-provider/dialog-add-sms-provider.component.html @@ -33,6 +33,7 @@ class="ok-button" color="primary" (click)="closeDialogWithRequest()" + data-e2e="save-sms-settings-button" > {{ 'ACTIONS.SAVE' | translate }} diff --git a/console/src/app/modules/policies/notification-settings/dialog-add-sms-provider/dialog-add-sms-provider.component.scss b/console/src/app/modules/policies/notification-sms-provider/dialog-add-sms-provider/dialog-add-sms-provider.component.scss similarity index 100% rename from console/src/app/modules/policies/notification-settings/dialog-add-sms-provider/dialog-add-sms-provider.component.scss rename to console/src/app/modules/policies/notification-sms-provider/dialog-add-sms-provider/dialog-add-sms-provider.component.scss diff --git a/console/src/app/modules/policies/notification-settings/dialog-add-sms-provider/dialog-add-sms-provider.component.ts b/console/src/app/modules/policies/notification-sms-provider/dialog-add-sms-provider/dialog-add-sms-provider.component.ts similarity index 99% rename from console/src/app/modules/policies/notification-settings/dialog-add-sms-provider/dialog-add-sms-provider.component.ts rename to console/src/app/modules/policies/notification-sms-provider/dialog-add-sms-provider/dialog-add-sms-provider.component.ts index 18ed54e5ec..2594dd0728 100644 --- a/console/src/app/modules/policies/notification-settings/dialog-add-sms-provider/dialog-add-sms-provider.component.ts +++ b/console/src/app/modules/policies/notification-sms-provider/dialog-add-sms-provider/dialog-add-sms-provider.component.ts @@ -14,7 +14,6 @@ import { import { SMSProvider, TwilioConfig } from 'src/app/proto/generated/zitadel/settings_pb'; import { AdminService } from 'src/app/services/admin.service'; import { ToastService } from 'src/app/services/toast.service'; - import { PasswordDialogComponent } from '../password-dialog/password-dialog.component'; enum SMSProviderType { diff --git a/console/src/app/modules/policies/notification-sms-provider/notification-sms-provider.component.html b/console/src/app/modules/policies/notification-sms-provider/notification-sms-provider.component.html new file mode 100644 index 0000000000..adde321c63 --- /dev/null +++ b/console/src/app/modules/policies/notification-sms-provider/notification-sms-provider.component.html @@ -0,0 +1,56 @@ +

{{ 'SETTING.SMS.TITLE' | translate }}

+ +
+ +
+ +
+ +
+

Twilio

+ + {{ 'SETTING.SMS.SMSPROVIDERSTATE.' + twilio.state | translate }} + + + + + +
+
+
diff --git a/console/src/app/modules/policies/notification-settings/notification-settings.component.scss b/console/src/app/modules/policies/notification-sms-provider/notification-sms-provider.component.scss similarity index 57% rename from console/src/app/modules/policies/notification-settings/notification-settings.component.scss rename to console/src/app/modules/policies/notification-sms-provider/notification-sms-provider.component.scss index f382b0b415..38ea6f44f4 100644 --- a/console/src/app/modules/policies/notification-settings/notification-settings.component.scss +++ b/console/src/app/modules/policies/notification-sms-provider/notification-sms-provider.component.scss @@ -2,35 +2,6 @@ margin: 0.5rem 0; } -.smtp-form-field, -.info-section-warn { - max-width: 400px; - display: block; -} - -.info-section-warn { - margin-bottom: 0.5rem; -} - -.smtp-checkbox { - max-width: 400px; - display: block; - margin: 1rem 0; -} - -.set-password-btn { - margin-bottom: 1rem; -} - -.general-btn-container { - display: flex; - justify-content: flex-start; - - .save-button { - display: block; - } -} - .sms-providers { display: flex; align-items: center; diff --git a/console/src/app/modules/policies/notification-sms-provider/notification-sms-provider.component.spec.ts b/console/src/app/modules/policies/notification-sms-provider/notification-sms-provider.component.spec.ts new file mode 100644 index 0000000000..6d7a487c69 --- /dev/null +++ b/console/src/app/modules/policies/notification-sms-provider/notification-sms-provider.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NotificationSMSProviderComponent } from './notification-sms-provider.component'; + +describe('NotificationSMSProviderComponent', () => { + let component: NotificationSMSProviderComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [NotificationSMSProviderComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(NotificationSMSProviderComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/console/src/app/modules/policies/notification-sms-provider/notification-sms-provider.component.ts b/console/src/app/modules/policies/notification-sms-provider/notification-sms-provider.component.ts new file mode 100644 index 0000000000..eb14ce8a0c --- /dev/null +++ b/console/src/app/modules/policies/notification-sms-provider/notification-sms-provider.component.ts @@ -0,0 +1,140 @@ +import { Component, Input } from '@angular/core'; +import { AddSMSProviderTwilioRequest, UpdateSMSProviderTwilioRequest } from 'src/app/proto/generated/zitadel/admin_pb'; +import { SMSProvider, SMSProviderConfigState } from 'src/app/proto/generated/zitadel/settings_pb'; + +import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog'; +import { AdminService } from 'src/app/services/admin.service'; +import { ToastService } from 'src/app/services/toast.service'; +import { InfoSectionType } from '../../info-section/info-section.component'; +import { WarnDialogComponent } from '../../warn-dialog/warn-dialog.component'; +import { PolicyComponentServiceType } from '../policy-component-types.enum'; +import { DialogAddSMSProviderComponent } from './dialog-add-sms-provider/dialog-add-sms-provider.component'; + +@Component({ + selector: 'cnsl-notification-sms-provider', + templateUrl: './notification-sms-provider.component.html', + styleUrls: ['./notification-sms-provider.component.scss'], +}) +export class NotificationSMSProviderComponent { + @Input() public serviceType!: PolicyComponentServiceType; + public smsProviders: SMSProvider.AsObject[] = []; + + public smsProvidersLoading: boolean = false; + + public SMSProviderConfigState: any = SMSProviderConfigState; + public InfoSectionType: any = InfoSectionType; + + constructor( + private service: AdminService, + private dialog: MatDialog, + private toast: ToastService, + ) {} + + private fetchData(): void { + this.smsProvidersLoading = true; + this.service + .listSMSProviders() + .then((smsProviders) => { + this.smsProvidersLoading = false; + if (smsProviders.resultList) { + this.smsProviders = smsProviders.resultList; + } + }) + .catch((error) => { + this.smsProvidersLoading = false; + this.toast.showError(error); + }); + } + + public editSMSProvider(): void { + const dialogRef = this.dialog.open(DialogAddSMSProviderComponent, { + width: '400px', + data: { + smsProviders: this.smsProviders, + }, + }); + + dialogRef.afterClosed().subscribe((req: AddSMSProviderTwilioRequest | UpdateSMSProviderTwilioRequest) => { + if (req) { + if (!!this.twilio) { + this.service + .updateSMSProviderTwilio(req as UpdateSMSProviderTwilioRequest) + .then(() => { + this.toast.showInfo('SETTING.SMS.TWILIO.ADDED', true); + this.fetchData(); + }) + .catch((error) => { + this.toast.showError(error); + }); + } else { + this.service + .addSMSProviderTwilio(req as AddSMSProviderTwilioRequest) + .then(() => { + this.toast.showInfo('SETTING.SMS.TWILIO.ADDED', true); + this.fetchData(); + }) + .catch((error) => { + this.toast.showError(error); + }); + } + } + }); + } + + public toggleSMSProviderState(id: string): void { + const provider = this.smsProviders.find((p) => p.id === id); + if (provider) { + if (provider.state === SMSProviderConfigState.SMS_PROVIDER_CONFIG_ACTIVE) { + this.service + .deactivateSMSProvider(id) + .then(() => { + this.toast.showInfo('SETTING.SMS.DEACTIVATED', true); + this.fetchData(); + }) + .catch((error) => { + this.toast.showError(error); + }); + } else if (provider.state === SMSProviderConfigState.SMS_PROVIDER_CONFIG_INACTIVE) { + this.service + .activateSMSProvider(id) + .then(() => { + this.toast.showInfo('SETTING.SMS.ACTIVATED', true); + this.fetchData(); + }) + .catch((error) => { + this.toast.showError(error); + }); + } + } + } + + public removeSMSProvider(id: string): void { + const dialogRef = this.dialog.open(WarnDialogComponent, { + data: { + confirmKey: 'ACTIONS.DELETE', + cancelKey: 'ACTIONS.CANCEL', + titleKey: 'SETTING.SMS.REMOVEPROVIDER', + descriptionKey: 'SETTING.SMS.REMOVEPROVIDER_DESC', + }, + width: '400px', + }); + + dialogRef.afterClosed().subscribe((resp) => { + if (resp) { + this.service + .removeSMSProvider(id) + .then(() => { + this.toast.showInfo('SETTING.SMS.TWILIO.REMOVED', true); + this.fetchData(); + }) + .catch((error) => { + this.toast.showError(error); + }); + } + }); + } + + public get twilio(): SMSProvider.AsObject | undefined { + return this.smsProviders.find((p) => p.twilio); + } +} diff --git a/console/src/app/modules/policies/notification-settings/notification-settings.module.ts b/console/src/app/modules/policies/notification-sms-provider/notification-sms-provider.module.ts similarity index 81% rename from console/src/app/modules/policies/notification-settings/notification-settings.module.ts rename to console/src/app/modules/policies/notification-sms-provider/notification-sms-provider.module.ts index 8f5be4c19a..4d00252a4f 100644 --- a/console/src/app/modules/policies/notification-settings/notification-settings.module.ts +++ b/console/src/app/modules/policies/notification-sms-provider/notification-sms-provider.module.ts @@ -15,11 +15,10 @@ import { InfoSectionModule } from '../../info-section/info-section.module'; import { InputModule } from '../../input/input.module'; import { WarnDialogModule } from '../../warn-dialog/warn-dialog.module'; import { DialogAddSMSProviderComponent } from './dialog-add-sms-provider/dialog-add-sms-provider.component'; -import { NotificationSettingsComponent } from './notification-settings.component'; -import { PasswordDialogComponent } from './password-dialog/password-dialog.component'; +import { NotificationSMSProviderComponent } from './notification-sms-provider.component'; @NgModule({ - declarations: [NotificationSettingsComponent, DialogAddSMSProviderComponent, PasswordDialogComponent], + declarations: [NotificationSMSProviderComponent, DialogAddSMSProviderComponent], imports: [ CommonModule, CardModule, @@ -38,6 +37,6 @@ import { PasswordDialogComponent } from './password-dialog/password-dialog.compo MatSelectModule, TranslateModule, ], - exports: [NotificationSettingsComponent], + exports: [NotificationSMSProviderComponent], }) -export class NotificationSettingsModule {} +export class NotificationSMSProviderModule {} diff --git a/console/src/app/modules/policies/notification-settings/password-dialog/password-dialog.component.html b/console/src/app/modules/policies/notification-sms-provider/password-dialog/password-dialog.component.html similarity index 74% rename from console/src/app/modules/policies/notification-settings/password-dialog/password-dialog.component.html rename to console/src/app/modules/policies/notification-sms-provider/password-dialog/password-dialog.component.html index 43709eb197..a7e8a624f8 100644 --- a/console/src/app/modules/policies/notification-settings/password-dialog/password-dialog.component.html +++ b/console/src/app/modules/policies/notification-sms-provider/password-dialog/password-dialog.component.html @@ -4,7 +4,13 @@
{{ data.i18nLabel | translate }} - +
@@ -19,6 +25,7 @@ mat-raised-button class="ok-button" (click)="closeDialog(password)" + data-e2e="save-notification-setting-password-button" > {{ 'ACTIONS.OK' | translate }} diff --git a/console/src/app/modules/policies/notification-settings/password-dialog/password-dialog.component.scss b/console/src/app/modules/policies/notification-sms-provider/password-dialog/password-dialog.component.scss similarity index 100% rename from console/src/app/modules/policies/notification-settings/password-dialog/password-dialog.component.scss rename to console/src/app/modules/policies/notification-sms-provider/password-dialog/password-dialog.component.scss diff --git a/console/src/app/modules/policies/notification-settings/password-dialog/password-dialog.component.spec.ts b/console/src/app/modules/policies/notification-sms-provider/password-dialog/password-dialog.component.spec.ts similarity index 100% rename from console/src/app/modules/policies/notification-settings/password-dialog/password-dialog.component.spec.ts rename to console/src/app/modules/policies/notification-sms-provider/password-dialog/password-dialog.component.spec.ts diff --git a/console/src/app/modules/policies/notification-settings/password-dialog/password-dialog.component.ts b/console/src/app/modules/policies/notification-sms-provider/password-dialog/password-dialog.component.ts similarity index 100% rename from console/src/app/modules/policies/notification-settings/password-dialog/password-dialog.component.ts rename to console/src/app/modules/policies/notification-sms-provider/password-dialog/password-dialog.component.ts diff --git a/console/src/app/modules/policies/notification-settings/notification-settings.component.html b/console/src/app/modules/policies/notification-smtp-provider/notification-smtp-provider.component.html similarity index 58% rename from console/src/app/modules/policies/notification-settings/notification-settings.component.html rename to console/src/app/modules/policies/notification-smtp-provider/notification-smtp-provider.component.html index 5895eb58f5..fcde1404df 100644 --- a/console/src/app/modules/policies/notification-settings/notification-settings.component.html +++ b/console/src/app/modules/policies/notification-smtp-provider/notification-smtp-provider.component.html @@ -1,7 +1,7 @@

{{ 'SETTING.SMTP.TITLE' | translate }}

- +
{{ 'SETTING.SMTP.SETPASSWORD' | translate }} @@ -60,55 +61,9 @@ color="primary" type="submit" mat-raised-button + data-e2e="save-smtp-settings-button" > {{ 'ACTIONS.SAVE' | translate }}
- -
-

{{ 'SETTING.SMS.TITLE' | translate }}

-
- -
-

Twilio

- - {{ 'SETTING.SMS.SMSPROVIDERSTATE.' + twilio.state | translate }} - - - - - -
-
-
diff --git a/console/src/app/modules/policies/notification-smtp-provider/notification-smtp-provider.component.scss b/console/src/app/modules/policies/notification-smtp-provider/notification-smtp-provider.component.scss new file mode 100644 index 0000000000..aa056af7e5 --- /dev/null +++ b/console/src/app/modules/policies/notification-smtp-provider/notification-smtp-provider.component.scss @@ -0,0 +1,32 @@ +.spinner-wr { + margin: 0.5rem 0; +} + +.smtp-form-field, +.info-section-warn { + max-width: 400px; + display: block; +} + +.info-section-warn { + margin-bottom: 0.5rem; +} + +.smtp-checkbox { + max-width: 400px; + display: block; + margin: 1rem 0; +} + +.set-password-btn { + margin-bottom: 1rem; +} + +.general-btn-container { + display: flex; + justify-content: flex-start; + + .save-button { + display: block; + } +} diff --git a/console/src/app/modules/policies/notification-smtp-provider/notification-smtp-provider.component.spec.ts b/console/src/app/modules/policies/notification-smtp-provider/notification-smtp-provider.component.spec.ts new file mode 100644 index 0000000000..65d781de8b --- /dev/null +++ b/console/src/app/modules/policies/notification-smtp-provider/notification-smtp-provider.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NotificationSMTPProviderComponent } from './notification-smtp-provider.component'; + +describe('NotificationSMTPProviderComponent', () => { + let component: NotificationSMTPProviderComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [NotificationSMTPProviderComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(NotificationSMTPProviderComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/console/src/app/modules/policies/notification-settings/notification-settings.component.ts b/console/src/app/modules/policies/notification-smtp-provider/notification-smtp-provider.component.ts similarity index 50% rename from console/src/app/modules/policies/notification-settings/notification-settings.component.ts rename to console/src/app/modules/policies/notification-smtp-provider/notification-smtp-provider.component.ts index dfd8a9e568..6bdaf0ba10 100644 --- a/console/src/app/modules/policies/notification-settings/notification-settings.component.ts +++ b/console/src/app/modules/policies/notification-smtp-provider/notification-smtp-provider.component.ts @@ -3,45 +3,33 @@ import { AbstractControl, UntypedFormBuilder, UntypedFormGroup } from '@angular/ import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog'; import { take } from 'rxjs'; import { - AddSMSProviderTwilioRequest, AddSMTPConfigRequest, AddSMTPConfigResponse, - UpdateSMSProviderTwilioRequest, UpdateSMTPConfigPasswordRequest, UpdateSMTPConfigRequest, UpdateSMTPConfigResponse, } from 'src/app/proto/generated/zitadel/admin_pb'; -import { DebugNotificationProvider, SMSProvider, SMSProviderConfigState } from 'src/app/proto/generated/zitadel/settings_pb'; import { AdminService } from 'src/app/services/admin.service'; import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; import { ToastService } from 'src/app/services/toast.service'; import { requiredValidator } from '../../form-field/validators/validators'; import { InfoSectionType } from '../../info-section/info-section.component'; -import { WarnDialogComponent } from '../../warn-dialog/warn-dialog.component'; +import { PasswordDialogComponent } from '../notification-sms-provider/password-dialog/password-dialog.component'; import { PolicyComponentServiceType } from '../policy-component-types.enum'; -import { DialogAddSMSProviderComponent } from './dialog-add-sms-provider/dialog-add-sms-provider.component'; -import { PasswordDialogComponent } from './password-dialog/password-dialog.component'; @Component({ - selector: 'cnsl-notification-settings', - templateUrl: './notification-settings.component.html', - styleUrls: ['./notification-settings.component.scss'], + selector: 'cnsl-notification-smtp-provider', + templateUrl: './notification-smtp-provider.component.html', + styleUrls: ['./notification-smtp-provider.component.scss'], }) -export class NotificationSettingsComponent implements OnInit { +export class NotificationSMTPProviderComponent implements OnInit { @Input() public serviceType!: PolicyComponentServiceType; - public smsProviders: SMSProvider.AsObject[] = []; - public logNotificationProvider!: DebugNotificationProvider.AsObject; - public fileNotificationProvider!: DebugNotificationProvider.AsObject; public smtpLoading: boolean = false; - public smsProvidersLoading: boolean = false; - public logProviderLoading: boolean = false; - public fileProviderLoading: boolean = false; public form!: UntypedFormGroup; - public SMSProviderConfigState: any = SMSProviderConfigState; public InfoSectionType: any = InfoSectionType; public hasSMTPConfig: boolean = false; @@ -96,46 +84,6 @@ export class NotificationSettingsComponent implements OnInit { this.hasSMTPConfig = false; } }); - - this.smsProvidersLoading = true; - this.service - .listSMSProviders() - .then((smsProviders) => { - this.smsProvidersLoading = false; - if (smsProviders.resultList) { - this.smsProviders = smsProviders.resultList; - } - }) - .catch((error) => { - this.smsProvidersLoading = false; - this.toast.showError(error); - }); - - this.logProviderLoading = true; - this.service - .getLogNotificationProvider() - .then((logNotificationProvider) => { - this.logProviderLoading = false; - if (logNotificationProvider.provider) { - this.logNotificationProvider = logNotificationProvider.provider; - } - }) - .catch(() => { - this.logProviderLoading = false; - }); - - this.fileProviderLoading = true; - this.service - .getFileSystemNotificationProvider() - .then((fileNotificationProvider) => { - this.fileProviderLoading = false; - if (fileNotificationProvider.provider) { - this.fileNotificationProvider = fileNotificationProvider.provider; - } - }) - .catch(() => { - this.fileProviderLoading = false; - }); } private updateData(): Promise { @@ -175,41 +123,6 @@ export class NotificationSettingsComponent implements OnInit { }); } - public editSMSProvider(): void { - const dialogRef = this.dialog.open(DialogAddSMSProviderComponent, { - width: '400px', - data: { - smsProviders: this.smsProviders, - }, - }); - - dialogRef.afterClosed().subscribe((req: AddSMSProviderTwilioRequest | UpdateSMSProviderTwilioRequest) => { - if (req) { - if (!!this.twilio) { - this.service - .updateSMSProviderTwilio(req as UpdateSMSProviderTwilioRequest) - .then(() => { - this.toast.showInfo('SETTING.SMS.TWILIO.ADDED', true); - this.fetchData(); - }) - .catch((error) => { - this.toast.showError(error); - }); - } else { - this.service - .addSMSProviderTwilio(req as AddSMSProviderTwilioRequest) - .then(() => { - this.toast.showInfo('SETTING.SMS.TWILIO.ADDED', true); - this.fetchData(); - }) - .catch((error) => { - this.toast.showError(error); - }); - } - } - }); - } - public setSMTPPassword(): void { const dialogRef = this.dialog.open(PasswordDialogComponent, { width: '400px', @@ -236,63 +149,6 @@ export class NotificationSettingsComponent implements OnInit { }); } - public toggleSMSProviderState(id: string): void { - const provider = this.smsProviders.find((p) => p.id === id); - if (provider) { - if (provider.state === SMSProviderConfigState.SMS_PROVIDER_CONFIG_ACTIVE) { - this.service - .deactivateSMSProvider(id) - .then(() => { - this.toast.showInfo('SETTING.SMS.DEACTIVATED', true); - this.fetchData(); - }) - .catch((error) => { - this.toast.showError(error); - }); - } else if (provider.state === SMSProviderConfigState.SMS_PROVIDER_CONFIG_INACTIVE) { - this.service - .activateSMSProvider(id) - .then(() => { - this.toast.showInfo('SETTING.SMS.ACTIVATED', true); - this.fetchData(); - }) - .catch((error) => { - this.toast.showError(error); - }); - } - } - } - - public removeSMSProvider(id: string): void { - const dialogRef = this.dialog.open(WarnDialogComponent, { - data: { - confirmKey: 'ACTIONS.DELETE', - cancelKey: 'ACTIONS.CANCEL', - titleKey: 'SETTING.SMS.REMOVEPROVIDER', - descriptionKey: 'SETTING.SMS.REMOVEPROVIDER_DESC', - }, - width: '400px', - }); - - dialogRef.afterClosed().subscribe((resp) => { - if (resp) { - this.service - .removeSMSProvider(id) - .then(() => { - this.toast.showInfo('SETTING.SMS.TWILIO.REMOVED', true); - this.fetchData(); - }) - .catch((error) => { - this.toast.showError(error); - }); - } - }); - } - - public get twilio(): SMSProvider.AsObject | undefined { - return this.smsProviders.find((p) => p.twilio); - } - public get senderAddress(): AbstractControl | null { return this.form.get('senderAddress'); } diff --git a/console/src/app/modules/policies/notification-smtp-provider/notification-smtp-provider.module.ts b/console/src/app/modules/policies/notification-smtp-provider/notification-smtp-provider.module.ts new file mode 100644 index 0000000000..61bb79023e --- /dev/null +++ b/console/src/app/modules/policies/notification-smtp-provider/notification-smtp-provider.module.ts @@ -0,0 +1,42 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatIconModule } from '@angular/material/icon'; +import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button'; +import { MatLegacyCheckboxModule as MatCheckboxModule } from '@angular/material/legacy-checkbox'; +import { MatLegacyProgressSpinnerModule as MatProgressSpinnerModule } from '@angular/material/legacy-progress-spinner'; +import { MatLegacySelectModule as MatSelectModule } from '@angular/material/legacy-select'; +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 { FormFieldModule } from '../../form-field/form-field.module'; +import { InfoSectionModule } from '../../info-section/info-section.module'; +import { InputModule } from '../../input/input.module'; +import { WarnDialogModule } from '../../warn-dialog/warn-dialog.module'; +import { PasswordDialogComponent } from '../notification-sms-provider/password-dialog/password-dialog.component'; +import { NotificationSMTPProviderComponent } from './notification-smtp-provider.component'; + +@NgModule({ + declarations: [NotificationSMTPProviderComponent, PasswordDialogComponent], + imports: [ + CommonModule, + CardModule, + InfoSectionModule, + FormsModule, + ReactiveFormsModule, + HasRolePipeModule, + MatButtonModule, + MatCheckboxModule, + InputModule, + MatIconModule, + FormFieldModule, + WarnDialogModule, + MatSelectModule, + MatProgressSpinnerModule, + MatSelectModule, + TranslateModule, + ], + exports: [NotificationSMTPProviderComponent], +}) +export class NotificationSMTPProviderModule {} diff --git a/console/src/app/modules/settings-grid/settinglinks.ts b/console/src/app/modules/settings-grid/settinglinks.ts index 34295104e0..726677ea73 100644 --- a/console/src/app/modules/settings-grid/settinglinks.ts +++ b/console/src/app/modules/settings-grid/settinglinks.ts @@ -51,7 +51,7 @@ export const NOTIFICATION_GROUP: SettingLinks = { i18nTitle: 'SETTINGS.GROUPS.NOTIFICATIONS', i18nDesc: 'SETTINGS.LIST.NOTIFICATIONS_DESC', iamRouterLink: ['/settings'], - queryParams: { id: 'notifications' }, + queryParams: { id: 'smtpprovider' }, iamWithRole: ['iam.policy.read'], icon: 'las la-bell', color: 'red', diff --git a/console/src/app/modules/settings-list/settings-list.component.html b/console/src/app/modules/settings-list/settings-list.component.html index 6f13cda807..44b3521fb7 100644 --- a/console/src/app/modules/settings-list/settings-list.component.html +++ b/console/src/app/modules/settings-list/settings-list.component.html @@ -27,19 +27,18 @@ - + - - - - - + + + + + + - diff --git a/console/src/app/modules/settings-list/settings-list.module.ts b/console/src/app/modules/settings-list/settings-list.module.ts index b42b94a10e..bfe64e07c6 100644 --- a/console/src/app/modules/settings-list/settings-list.module.ts +++ b/console/src/app/modules/settings-list/settings-list.module.ts @@ -13,7 +13,8 @@ import { LoginPolicyModule } from '../policies/login-policy/login-policy.module' import { LoginTextsPolicyModule } from '../policies/login-texts/login-texts.module'; import { MessageTextsPolicyModule } from '../policies/message-texts/message-texts.module'; import { NotificationPolicyModule } from '../policies/notification-policy/notification-policy.module'; -import { NotificationSettingsModule } from '../policies/notification-settings/notification-settings.module'; +import { NotificationSMSProviderModule } from '../policies/notification-sms-provider/notification-sms-provider.module'; +import { NotificationSMTPProviderModule } from '../policies/notification-smtp-provider/notification-smtp-provider.module'; import { OIDCConfigurationModule } from '../policies/oidc-configuration/oidc-configuration.module'; import { PasswordComplexityPolicyModule } from '../policies/password-complexity-policy/password-complexity-policy.module'; import { PasswordLockoutPolicyModule } from '../policies/password-lockout-policy/password-lockout-policy.module'; @@ -46,7 +47,8 @@ import { SettingsListComponent } from './settings-list.component'; DomainPolicyModule, TranslateModule, HasRolePipeModule, - NotificationSettingsModule, + NotificationSMTPProviderModule, + NotificationSMSProviderModule, OIDCConfigurationModule, SecretGeneratorModule, ], diff --git a/console/src/app/modules/settings-list/settings.ts b/console/src/app/modules/settings-list/settings.ts index dba29eac3f..69808e414b 100644 --- a/console/src/app/modules/settings-list/settings.ts +++ b/console/src/app/modules/settings-list/settings.ts @@ -98,15 +98,25 @@ export const NOTIFICATIONS: SidenavSetting = { groupI18nKey: 'SETTINGS.GROUPS.NOTIFICATIONS', requiredRoles: { [PolicyComponentServiceType.ADMIN]: ['iam.policy.read'], + [PolicyComponentServiceType.MGMT]: ['policy.read'], }, }; -export const NOTIFICATION_POLICY: SidenavSetting = { - id: 'notifications', - i18nKey: 'SETTINGS.LIST.NOTIFICATIONS', +export const SMTP_PROVIDER: SidenavSetting = { + id: 'smtpprovider', + i18nKey: 'SETTINGS.LIST.SMTP_PROVIDER', groupI18nKey: 'SETTINGS.GROUPS.NOTIFICATIONS', requiredRoles: { - [PolicyComponentServiceType.MGMT]: ['policy.read'], + [PolicyComponentServiceType.ADMIN]: ['iam.policy.read'], + }, +}; + +export const SMS_PROVIDER: SidenavSetting = { + id: 'smsprovider', + i18nKey: 'SETTINGS.LIST.SMS_PROVIDER', + groupI18nKey: 'SETTINGS.GROUPS.NOTIFICATIONS', + requiredRoles: { + [PolicyComponentServiceType.ADMIN]: ['iam.policy.read'], }, }; diff --git a/console/src/app/pages/instance-settings/instance-settings.component.ts b/console/src/app/pages/instance-settings/instance-settings.component.ts index 8b7db8b85f..2a379df62d 100644 --- a/console/src/app/pages/instance-settings/instance-settings.component.ts +++ b/console/src/app/pages/instance-settings/instance-settings.component.ts @@ -21,6 +21,8 @@ import { PRIVACYPOLICY, SECRETS, SECURITY, + SMS_PROVIDER, + SMTP_PROVIDER, } from '../../modules/settings-list/settings'; @Component({ @@ -36,6 +38,8 @@ export class InstanceSettingsComponent implements OnInit, OnDestroy { // notifications // { showWarn: true, ...NOTIFICATIONS }, NOTIFICATIONS, + SMTP_PROVIDER, + SMS_PROVIDER, // login LOGIN, IDP, @@ -80,7 +84,10 @@ export class InstanceSettingsComponent implements OnInit, OnDestroy { } ngOnInit(): void { - this.settingsList = this.authService.isAllowedMapper(this.defaultSettingsList, (setting) => setting.requiredRoles.admin); + this.settingsList = this.authService.isAllowedMapper( + this.defaultSettingsList, + (setting) => setting.requiredRoles.admin || [], + ); } ngOnDestroy(): void { diff --git a/console/src/app/pages/org-settings/org-settings.component.ts b/console/src/app/pages/org-settings/org-settings.component.ts index b0914fb7cc..79624e204a 100644 --- a/console/src/app/pages/org-settings/org-settings.component.ts +++ b/console/src/app/pages/org-settings/org-settings.component.ts @@ -15,7 +15,7 @@ import { LOGIN, LOGINTEXTS, MESSAGETEXTS, - NOTIFICATION_POLICY, + NOTIFICATIONS, PRIVACYPOLICY, VERIFIED_DOMAINS, } from '../../modules/settings-list/settings'; @@ -34,7 +34,7 @@ export class OrgSettingsComponent implements OnInit { IDP, COMPLEXITY, LOCKOUT, - NOTIFICATION_POLICY, + NOTIFICATIONS, VERIFIED_DOMAINS, DOMAIN, BRANDING, @@ -68,7 +68,7 @@ export class OrgSettingsComponent implements OnInit { ngOnInit(): void { this.settingsList = this.authService - .isAllowedMapper(this.defaultSettingsList, (setting) => setting.requiredRoles.mgmt) + .isAllowedMapper(this.defaultSettingsList, (setting) => setting.requiredRoles.mgmt || []) .pipe(take(1)); } } diff --git a/console/src/app/utils/onboarding.ts b/console/src/app/utils/onboarding.ts index 4f66c15198..97f2de8c52 100644 --- a/console/src/app/utils/onboarding.ts +++ b/console/src/app/utils/onboarding.ts @@ -71,7 +71,7 @@ export const ONBOARDING_EVENTS: OnboardingActions[] = [ eventType: 'instance.smtp.config.added', oneof: ['instance.smtp.config.added', 'instance.smtp.config.changed'], link: ['/settings'], - fragment: 'notifications', + fragment: 'smtpprovider', iconClasses: 'las la-envelope', darkcolor: yellowdark, lightcolor: yellowlight, diff --git a/console/src/assets/i18n/bg.json b/console/src/assets/i18n/bg.json index 33f53c9c56..4137e7538e 100644 --- a/console/src/assets/i18n/bg.json +++ b/console/src/assets/i18n/bg.json @@ -1013,6 +1013,8 @@ "LOCKOUT": "Блокиране", "COMPLEXITY": "Сложност на паролата", "NOTIFICATIONS": "Настройки за известията", + "SMTP_PROVIDER": "SMTP доставчик", + "SMS_PROVIDER": "Доставчик на SMS/телефон", "NOTIFICATIONS_DESC": "Настройки за SMTP и SMS", "MESSAGETEXTS": "Текстове на съобщения", "IDP": "Доставчици на идентичност", diff --git a/console/src/assets/i18n/de.json b/console/src/assets/i18n/de.json index 9e2b39c8de..37cbb671a5 100644 --- a/console/src/assets/i18n/de.json +++ b/console/src/assets/i18n/de.json @@ -1019,6 +1019,8 @@ "LOCKOUT": "Sperrmechanismen", "COMPLEXITY": "Passwordkomplexität", "NOTIFICATIONS": "Benachrichtigungseinstellungen", + "SMTP_PROVIDER": "SMTP-Anbieter", + "SMS_PROVIDER": "SMS / Telefon Anbieter", "NOTIFICATIONS_DESC": "SMTP und SMS Einstellungen", "MESSAGETEXTS": "Benachrichtigungstexte", "IDP": "Identitätsanbieter", diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json index ac804fee34..135b62c82c 100644 --- a/console/src/assets/i18n/en.json +++ b/console/src/assets/i18n/en.json @@ -1019,7 +1019,9 @@ "LOGIN": "Login Behavior and Security", "LOCKOUT": "Lockout", "COMPLEXITY": "Password complexity", - "NOTIFICATIONS": "Notification settings", + "NOTIFICATIONS": "Notifications", + "SMTP_PROVIDER": "SMTP Provider", + "SMS_PROVIDER": "SMS/Phone Provider", "NOTIFICATIONS_DESC": "SMTP and SMS Settings", "MESSAGETEXTS": "Message Texts", "IDP": "Identity Providers", diff --git a/console/src/assets/i18n/es.json b/console/src/assets/i18n/es.json index bbb14c0faf..e2f8f054c5 100644 --- a/console/src/assets/i18n/es.json +++ b/console/src/assets/i18n/es.json @@ -1020,6 +1020,8 @@ "LOCKOUT": "Bloqueo", "COMPLEXITY": "Complejidad de contraseña", "NOTIFICATIONS": "Ajustes de notificación", + "SMTP_PROVIDER": "Proveedor SMTP", + "SMS_PROVIDER": "Proveedor SMS/Teléfono", "NOTIFICATIONS_DESC": "Ajustes SMTP y SMS", "MESSAGETEXTS": "Mensajes de texto", "IDP": "Proveedores de identidad", diff --git a/console/src/assets/i18n/fr.json b/console/src/assets/i18n/fr.json index 39aae0e9d4..ec44f4855a 100644 --- a/console/src/assets/i18n/fr.json +++ b/console/src/assets/i18n/fr.json @@ -1019,6 +1019,8 @@ "LOCKOUT": "Verrouillage", "COMPLEXITY": "Complexité du mot de passe", "NOTIFICATIONS": "Paramètres de notification", + "SMTP_PROVIDER": "Fournisseur SMTP", + "SMS_PROVIDER": "SMS/Téléphone Fournisseur", "NOTIFICATIONS_DESC": "Paramètres SMTP et SMS", "MESSAGETEXTS": "Textes des messages", "IDP": "Fournisseurs d'identité", diff --git a/console/src/assets/i18n/it.json b/console/src/assets/i18n/it.json index b06fc82e68..fa0f76745e 100644 --- a/console/src/assets/i18n/it.json +++ b/console/src/assets/i18n/it.json @@ -1019,6 +1019,8 @@ "LOCKOUT": "Meccanismi di bloccaggio", "COMPLEXITY": "Complessità della password", "NOTIFICATIONS": "Impostazioni di notifica", + "SMTP_PROVIDER": "Fornitore SMTP", + "SMS_PROVIDER": "Fornitore di servizi SMS/telefonici", "NOTIFICATIONS_DESC": "Impostazioni SMTP e SMS", "MESSAGETEXTS": "Testi di notifica", "IDP": "Fornitori di identità", diff --git a/console/src/assets/i18n/ja.json b/console/src/assets/i18n/ja.json index 7f90040d4f..d33e84ce30 100644 --- a/console/src/assets/i18n/ja.json +++ b/console/src/assets/i18n/ja.json @@ -1020,6 +1020,8 @@ "LOCKOUT": "ロックアウト", "COMPLEXITY": "パスワードの複雑さ", "NOTIFICATIONS": "通知設定", + "SMTP_PROVIDER": "SMTPプロバイダー", + "SMS_PROVIDER": "SMS/電話プロバイダー", "NOTIFICATIONS_DESC": "SMTPおよびSMS設定", "MESSAGETEXTS": "メッセージテキスト", "IDP": "IDプロバイダー", diff --git a/console/src/assets/i18n/mk.json b/console/src/assets/i18n/mk.json index e21458534b..51ea7b4e20 100644 --- a/console/src/assets/i18n/mk.json +++ b/console/src/assets/i18n/mk.json @@ -1021,6 +1021,8 @@ "LOCKOUT": "Забрана на пристап", "COMPLEXITY": "Сложеност на лозинката", "NOTIFICATIONS": "Подесувања за известувања", + "SMTP_PROVIDER": "SMTP провајдер", + "SMS_PROVIDER": "СМС/Провајдер на телефон", "NOTIFICATIONS_DESC": "Подесувања за SMTP и SMS", "MESSAGETEXTS": "Текстови на пораки", "IDP": "Identity Providers", diff --git a/console/src/assets/i18n/pl.json b/console/src/assets/i18n/pl.json index 244de0b78a..5aca4919f3 100644 --- a/console/src/assets/i18n/pl.json +++ b/console/src/assets/i18n/pl.json @@ -1019,6 +1019,8 @@ "LOCKOUT": "Blokada", "COMPLEXITY": "Złożoność hasła", "NOTIFICATIONS": "Ustawienia powiadomień", + "SMTP_PROVIDER": "Dostawca SMTP", + "SMS_PROVIDER": "Dostawca SMS-ów/telefonów", "NOTIFICATIONS_DESC": "Ustawienia SMTP i SMS", "MESSAGETEXTS": "Teksty wiadomości", "IDP": "Dostawcy tożsamości", diff --git a/console/src/assets/i18n/pt.json b/console/src/assets/i18n/pt.json index ecbabcc56a..b98860e7de 100644 --- a/console/src/assets/i18n/pt.json +++ b/console/src/assets/i18n/pt.json @@ -1021,6 +1021,8 @@ "LOCKOUT": "Bloqueio", "COMPLEXITY": "Complexidade de Senha", "NOTIFICATIONS": "Configurações de Notificação", + "SMTP_PROVIDER": "Provedor SMTP", + "SMS_PROVIDER": "Provedor de SMS/Telefone", "NOTIFICATIONS_DESC": "Configurações de SMTP e SMS", "MESSAGETEXTS": "Textos de Mensagem", "IDP": "Provedores de Identidade", diff --git a/console/src/assets/i18n/zh.json b/console/src/assets/i18n/zh.json index 9b8606d901..ffd818cb3b 100644 --- a/console/src/assets/i18n/zh.json +++ b/console/src/assets/i18n/zh.json @@ -1019,6 +1019,8 @@ "LOCKOUT": "安全锁策略", "COMPLEXITY": "密码复杂性", "NOTIFICATIONS": "通知设置", + "SMTP_PROVIDER": "SMTP 提供商", + "SMS_PROVIDER": "短信/电话提供商", "NOTIFICATIONS_DESC": "SMTP 和 SMS 设置", "MESSAGETEXTS": "消息文本", "IDP": "身份提供者", diff --git a/docs/docs/guides/manage/console/organizations.mdx b/docs/docs/guides/manage/console/organizations.mdx index 15841397f5..3ac96e75bd 100644 --- a/docs/docs/guides/manage/console/organizations.mdx +++ b/docs/docs/guides/manage/console/organizations.mdx @@ -9,7 +9,7 @@ import OrgDescription from "../../../concepts/structure/_org_description.mdx"; import Column from "../../../../src/components/column"; An Organization is where your projects and users live. Looking at a B2B use case, an organization represents a business partner who typically has its own branding and has different access settings like additional federated login providers. -Users from one organization are seperated from others. +Users from one organization are separated from others. ## Create a new organization @@ -23,7 +23,7 @@ If you choose your logged in user as organization manager, a membership for the alt="Select Organization" /> -If you want to enable your customers to create their organization by themselves, we provide a creation form for a organization. ` Note: You can set this setting on your instance as well as your organizations. All available usernames are shown on the top of the user pages. @@ -59,12 +59,14 @@ Users that you create within your organization will be suffixed with this domain You can improve the user experience, by suffixing users with a domain name that is in your control. If the "validate org domains" settings in the [Domain Settings](./instance-settings#domain-settings) is set to true, you have to prove the ownership of your domain, by DNS or HTTP challenge. -If the settings is set to false, the created domain will automatically be set to verifed. +If the setting is set to false, the created domain will automatically be set to verifed. An organization can have multiple domain names, but only one domain can be primary. The primary domain defines which login name ZITADEL displays to the user, and what information gets asserted in access_tokens (`preferred_username`). -Please note that domain verification also removes the logonname from all users, who might have used this combination in the global organization (ie. users not belonging to a specific organization). Relating to our example with acme.ch: If a user ‘coyote’ exists in the global organization with the logonname coyote@acme.ch, then after verification of acme.ch, this logonname will be replaced with `coyote@{randomvalue.tld}`. ZITADEL will notify users affected by this change. +Please note that domain verification also removes the login name from all users, who might have used this combination in the global organization (ie. users not belonging to a specific organization). +Relating to our example with acme.ch: If a user ‘coyote’ exists in the global organization with the login name coyote@acme.ch, then after verification of acme.ch, this login name will be replaced with `coyote@{randomvalue.tld}`. +ZITADEL will notify users affected by this change. ## Verify your domain name diff --git a/docs/docs/guides/migrate/sources/keycloak.md b/docs/docs/guides/migrate/sources/keycloak.md new file mode 100644 index 0000000000..666c96adfc --- /dev/null +++ b/docs/docs/guides/migrate/sources/keycloak.md @@ -0,0 +1,306 @@ +--- +title: Migrate from Keycloak +sidebar_label: From Keycloak +--- + +## Migrating from Keycloak to ZITADEL + +This guide will use [Docker installation](https://www.docker.com/) to run Keycloak and ZITADEL. However, both Keycloak and ZITADEL offer different installation methods. As a result, this guide won't include any required production tuning or security hardening for either system. However, it's advised you follow [recommended guidelines](https://zitadel.com/docs/guides/manage/self-hosted/production) before putting those systems into production. You can skip setting up Keycloak and ZITADEL if you already have running instances. + +## Set up Keycloak +### Run Keycloak + +To begin setting up Keycloak, you need to refer to the official [Keycloak Docker image](https://www.keycloak.org/getting-started/getting-started-docker). You'll use it to run a development version of the Keycloak server on your local machine: + + +```bash +docker run -d -p 8081:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:22.0.1 start-dev +``` + +In a few seconds, Keycloak will be available at [http://localhost:8081](http://localhost:8081). Access the **Administration Console** via the username `admin` and password `admin`: + +Migrating users from Keycloak to ZITADEL + +Migrating users from Keycloak to ZITADEL + + +### Create a realm in Keycloak + +In order to configure Keycloak as the identity provider for your application, you need to create a new realm. This will allow users and authentication resources to be isolated from any other Keycloak usage. Click on the sidebar drop-down menu and select **Create Realm**. Then input the desired realm name and click **Create**: + +Migrating users from Keycloak to ZITADEL + +Migrating users from Keycloak to ZITADEL + +Migrating users from Keycloak to ZITADEL + + +### Create user in Keycloak + +The last thing you need to do in Keycloak is to create at least one new user. This user will be able to log into your application. + +On the menu on the left, select **Users**, and click **Add user**. Fill in the username, email, and first and last names, and mark the email as verified. Click on **Create** to create a new user: + +Migrating users from Keycloak to ZITADEL + +Migrating users from Keycloak to ZITADEL + +Migrating users from Keycloak to ZITADEL + +Now you should attach a password to this user. Select the **Credentials** tab and click **Set password**. + +Migrating users from Keycloak to ZITADEL + + On the new modal panel, input the desired password and select **Save**. + +Migrating users from Keycloak to ZITADEL + +Migrating users from Keycloak to ZITADEL + +### Export Keycloak users + +Keycloak provides an [export](https://www.keycloak.org/server/importExport) functionality that allows user information to be extracted into JSON files. While it's intended to be used in another Keycloak instance, you can manipulate it to export users to a different user management system. + +For example, in order to generate the export files with Keycloak, you will need to enter the Docker container, run the export command, and copy it outside the container: + +```bash +# Recover the Container ID for Keycloak +docker ps + +# Run the export command inside the Keycloak container +# use the container ID of Keycloak +docker exec /opt/keycloak/bin/kc.sh export --dir /tmp + +# copy generated files from docker container to local machine +docker cp :/tmp/my-realm-users-0.json . +``` + +## Set up ZITADEL + +After creating a sample application that connects to Keycloak, you need to set up ZITADEL in order to migrate the application and users from Keycloak to ZITADEL. For this, ZITADEL offers a [Docker Compose](https://zitadel.com/docs/self-hosting/deploy/compose) installation guide. Follow the instructions under the [Docker compose](https://zitadel.com/docs/self-hosting/deploy/compose#docker-compose) section to run a ZITADEL instance locally. + +Next, the application will be available at [http://localhost:8080/ui/console/](http://localhost:8080/ui/console/). + +Migrating users from Keycloak to ZITADEL + +Now you can access the console with the following default credentials: + +* **Username**: `zitadel-admin@zitadel.localhost` +* **Password**: `Password1!` + + +## Import Keycloak users into ZITADEL + +As explained in this [ZITADEL user migration guide](https://zitadel.com/docs/guides/migrate/users), you can import users individually or in bulk. Since we are looking at importing a single user from Keycloak, migrating that individual user to ZITADEL can be done with the [ImportHumanUser](https://zitadel.com/docs/apis/resources/mgmt/management-service-import-human-user) endpoint. + +> With this endpoint, an email will only be sent to the user if the email is marked as not verified or if there's no password set. + +### Create a service user to consume ZITADEL API + +But first of all, in order to use this ZITADEL API, you need to create a [service user](https://zitadel.com/docs/guides/integrate/serviceusers#exercise-create-a-service-user). + +Go to the **Users** menu and select the **Service Users** tab. And click the **+ New** button. + +Migrating users from Keycloak to ZITADEL + +Fill in the details of the service user and click **Create**. + +Migrating users from Keycloak to ZITADEL + +Your service user is now created and listed. + +Migrating users from Keycloak to ZITADEL + +### Provide 'Org Owner' permissions to the service user + +This service user needs to have elevated permissions in order to import users. For this example, you should make the service user an organization owner as explained in [this guide](https://zitadel.com/docs/guides/integrate/access-zitadel-apis#add-org_owner-to-service-user). + +Let's change the permissions as follows: + +Click on the button shown in the image below: + +Migrating users from Keycloak to ZITADEL + +Next, select your service user that you created and select the **Org Owner** checkbox to assign the permissions of an organization owner to the service user. + +Migrating users from Keycloak to ZITADEL + +### Generate an access token for the service user + +In order for the service user to access the API, they must be able to authenticate themselves. To authenticate the user, you can use either [JWT with Private Key](/docs/guides/integrate/serviceusers#authenticating-a-service-user) flow (recommended for production) or [Personal Access Tokens](/docs/guides/integrate/pat)(PAT). In this guide, we will choose the latter. + +Go to **Users** -> **Service Users** again and click on the service user, then select **Personal Access Tokens** on the left and click the **+ New** button. Copy the generated personal access token to use it later. Click **Close** after copying the PAT. + +Migrating users from Keycloak to ZITADEL + +### Import user to ZITADEL via ZITADEL API + +if your Keycloak Realm has a single user, your `my-realm-users-0.json` file, into which you exported your Keycloak user previously, will look like this: + +```js +{ + "realm" : "my-realm", + "users" : [ { + "id" : "826731b2-bf17-4bd9-b45c-6a26c76ddaae", + "createdTimestamp" : 1693887631918, + "username" : "test-user", + "enabled" : true, + "totp" : false, + "emailVerified" : true, + "firstName" : "John", + "lastName" : "Doe", + "email" : "test-user@mail.com", + "credentials" : [ { + "id" : "c3f3759e-9d8a-4628-aad9-09e66f28a4e2", + "type" : "password", + "userLabel" : "My password", + "createdDate" : 1693888572700, + "secretData" : "{\"value\":\"ng6oDRung/pBLayd5ro7IU3mL/p86pg3WvQNQc+N1Eg=\",\"salt\":\"RaXjs4RiUKgJGkX6kp277w==\",\"additionalParameters\":{}}", + "credentialData" : "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\",\"additionalParameters\":{}}" + } ], + "disableableCredentialTypes" : [ ], + "requiredActions" : [ ], + "realmRoles" : [ "default-roles-my-realm" ], + "notBefore" : 0, + "groups" : [ ] + } ] +} +``` + +Now, you need to transform the JSON to the ZITADEL data format by adhering to the ZITADEL API [specification](https://zitadel.com/docs/apis/resources/mgmt/management-service-import-human-user) to import a user. The minimal format would be as shown below: + +```js +{ + "userName": "test-user", + "profile": { + "firstName": "John", + "lastName": "Doe" + }, + "email": { + "email": "test-user@mail.com", + "isEmailVerified": true + }, + "hashedPassword": { + "value": "$pbkdf2-sha256$27500$RaXjs4RiUKgJGkX6kp277w==$ng6oDRung/pBLayd5ro7IU3mL/p86pg3WvQNQc+N1Eg=" + } +} + +``` + +Next, you must install [`zitadel-tools`](https://github.com/zitadel/zitadel-tools/tree/main), which is a utility toolset designed to facilitate various interactions with the ZITADEL platform, mainly with tasks related to authentication, authorization, and data migration. We will be using the `migrate` command: + +Purpose: Assists users in transforming exported data from other identity providers to be compatible with Zitadel's import schema. +Supported Providers: Currently, migrations from Auth0 and Keycloak are supported. +Usage: Users can get a list of available sub-commands and flags with the --help flag. + +Install `zitadel-tools` using the command below. Ensure you have Go already installed on your machine. + +```bash +go install github.com/zitadel/zitadel-tools@main +``` + +Now you can run the migration tool for Keycloak as explained in this [guide](https://github.com/zitadel/zitadel-tools/blob/main/cmd/migration/keycloak/readme.md). Let's go through the steps: + +The Keycloak migration tool facilitates the transfer of data to ZITADEL by creating a JSON file tailored to serve as the body for an import request to the ZITADEL API. Note that it's essential that an organization already exists within ZITADEL/ + +To perform the migration, you'll need: + +- The organization ID (--org) +- A realm.json file (in our case, `my-realm-users-0.json`) that houses your exported Keycloak realm with user details (--realm). +- Output path via --output (default: ./importBody.json) +- Timeout duration for the data import request using --timeout (default: 30 minutes) +- Pretty printing the output JSON with --multiline. + +Execute with: + +```bash +zitadel-tools migrate keycloak --org= --realm=./realm.json --output=./importBody.json --timeout=1h --multiline +``` + +Example: + +```bash +zitadel-tools migrate keycloak --org=233868910057750531 --realm=./my-realm-users-0.json --output=./importBody.json --timeout=1h --multiline +``` + +Ensure `my-realm-users-0.json` is in the same directory for the tool to process it, or provide the path to the file. + +`importBody.json` will now contain the transformed data as shown below: + +```bash +{ + "dataOrgs": { + "orgs": [ + { + "orgId": "233868910057750531", + "humanUsers": [ + { + "userId": "826731b2-bf17-4bd9-b45c-6a26c76ddaae", + "user": { + "userName": "test-user", + "profile": { + "firstName": "John", + "lastName": "Doe" + }, + "email": { + "email": "test-user@mail.com", + "isEmailVerified": true + }, + "hashedPassword": { + "value": "$pbkdf2-sha256$27500$RaXjs4RiUKgJGkX6kp277w==$ng6oDRung/pBLayd5ro7IU3mL/p86pg3WvQNQc+N1Eg=" + } + } + } + ] + } + ] + }, + "timeout": "1h0m0s" +} +``` + +Now copy the following portion to a separate file and name the file `zitadel-users-file.json`. + +```bash +"userId": "826731b2-bf17-4bd9-b45c-6a26c76ddaae", +"user": { + "userName": "test-user", + "profile": { + "firstName": "John", + "lastName": "Doe" + }, + "email": { + "email": "test-user@mail.com", + "isEmailVerified": true + }, + "hashedPassword": { + "value": "$pbkdf2-sha256$27500$RaXjs4RiUKgJGkX6kp277w==$ng6oDRung/pBLayd5ro7IU3mL/p86pg3WvQNQc+N1Eg=" + } +} +``` + +Now that we have the user details in the required JSON format, let’s call the ZITADEL API to add the user. + +Run the following cURL command to invoke the API and don't forget to replace `` with the service user's personal access token: + +```bash +curl --request POST \ + --url http://localhost:8080/management/v1/users/human/_import \ + --header 'Content-Type: application/json' \ + --header 'Authorization: Bearer ' \ + --data @zitadel-users-file.json +``` + +A successful response would be as shown below: + +Migrating users from Keycloak to ZITADEL + +> Note that the previous request imports a single user. If you're using ZITADEL Cloud and have a large number of users, you may hit its rate limit or may need to pay the excess number of API requests. If you experience this, reach out to the [ZITADEL support team](https://zitadel.com/contact), as they can provide an alternative migration tools to move a large number of users. + + +Now you have imported the Keycloak user into ZITADEL. To view your user go to [http://localhost:8080/ui/console/users](http://localhost:8080/ui/console/users) (or go to the **Users** tab to see the users). + +Migrating users from Keycloak to ZITADEL + + +You can now view the Keycloak user's details in ZITADEL. You can see that the password is available too. diff --git a/docs/sidebars.js b/docs/sidebars.js index 4a3d219012..a7513f905e 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -125,6 +125,7 @@ module.exports = { items: [ "guides/migrate/sources/zitadel", "guides/migrate/sources/auth0", + "guides/migrate/sources/keycloak", ] }, ] diff --git a/docs/static/img/guides/console/smtp.png b/docs/static/img/guides/console/smtp.png index b8c0295685..4334264eda 100644 Binary files a/docs/static/img/guides/console/smtp.png and b/docs/static/img/guides/console/smtp.png differ diff --git a/docs/static/img/guides/console/twilio.png b/docs/static/img/guides/console/twilio.png index 1dfde84a88..eb253128a4 100644 Binary files a/docs/static/img/guides/console/twilio.png and b/docs/static/img/guides/console/twilio.png differ diff --git a/docs/static/img/guides/migrate/keycloak-01.png b/docs/static/img/guides/migrate/keycloak-01.png new file mode 100644 index 0000000000..2957665b7b Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-01.png differ diff --git a/docs/static/img/guides/migrate/keycloak-02.png b/docs/static/img/guides/migrate/keycloak-02.png new file mode 100644 index 0000000000..3146b66f62 Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-02.png differ diff --git a/docs/static/img/guides/migrate/keycloak-03.png b/docs/static/img/guides/migrate/keycloak-03.png new file mode 100644 index 0000000000..e2d90a5c1f Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-03.png differ diff --git a/docs/static/img/guides/migrate/keycloak-04.png b/docs/static/img/guides/migrate/keycloak-04.png new file mode 100644 index 0000000000..dd07602de1 Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-04.png differ diff --git a/docs/static/img/guides/migrate/keycloak-05.png b/docs/static/img/guides/migrate/keycloak-05.png new file mode 100644 index 0000000000..bd6c6cf5d5 Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-05.png differ diff --git a/docs/static/img/guides/migrate/keycloak-06.png b/docs/static/img/guides/migrate/keycloak-06.png new file mode 100644 index 0000000000..6d774a1557 Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-06.png differ diff --git a/docs/static/img/guides/migrate/keycloak-07.png b/docs/static/img/guides/migrate/keycloak-07.png new file mode 100644 index 0000000000..dae6a1b2c4 Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-07.png differ diff --git a/docs/static/img/guides/migrate/keycloak-08.png b/docs/static/img/guides/migrate/keycloak-08.png new file mode 100644 index 0000000000..a587eda893 Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-08.png differ diff --git a/docs/static/img/guides/migrate/keycloak-09.png b/docs/static/img/guides/migrate/keycloak-09.png new file mode 100644 index 0000000000..6a91bebc6c Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-09.png differ diff --git a/docs/static/img/guides/migrate/keycloak-10.png b/docs/static/img/guides/migrate/keycloak-10.png new file mode 100644 index 0000000000..533f4c5a1a Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-10.png differ diff --git a/docs/static/img/guides/migrate/keycloak-11.png b/docs/static/img/guides/migrate/keycloak-11.png new file mode 100644 index 0000000000..c32176c462 Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-11.png differ diff --git a/docs/static/img/guides/migrate/keycloak-12.png b/docs/static/img/guides/migrate/keycloak-12.png new file mode 100644 index 0000000000..3acf2214da Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-12.png differ diff --git a/docs/static/img/guides/migrate/keycloak-13.png b/docs/static/img/guides/migrate/keycloak-13.png new file mode 100644 index 0000000000..56f2ab771f Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-13.png differ diff --git a/docs/static/img/guides/migrate/keycloak-14.png b/docs/static/img/guides/migrate/keycloak-14.png new file mode 100644 index 0000000000..bcee425738 Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-14.png differ diff --git a/docs/static/img/guides/migrate/keycloak-15.png b/docs/static/img/guides/migrate/keycloak-15.png new file mode 100644 index 0000000000..bc61912b4f Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-15.png differ diff --git a/docs/static/img/guides/migrate/keycloak-16.png b/docs/static/img/guides/migrate/keycloak-16.png new file mode 100644 index 0000000000..2ed7e4cf46 Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-16.png differ diff --git a/docs/static/img/guides/migrate/keycloak-17.png b/docs/static/img/guides/migrate/keycloak-17.png new file mode 100644 index 0000000000..b118cfb380 Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-17.png differ diff --git a/docs/static/img/guides/migrate/keycloak-18.png b/docs/static/img/guides/migrate/keycloak-18.png new file mode 100644 index 0000000000..ddc7ccdde1 Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-18.png differ diff --git a/docs/static/img/guides/migrate/keycloak-19.png b/docs/static/img/guides/migrate/keycloak-19.png new file mode 100644 index 0000000000..597cdf2c37 Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-19.png differ diff --git a/docs/static/img/guides/migrate/keycloak-20.png b/docs/static/img/guides/migrate/keycloak-20.png new file mode 100644 index 0000000000..ffd5cf1baf Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-20.png differ diff --git a/docs/static/img/guides/migrate/keycloak-21.png b/docs/static/img/guides/migrate/keycloak-21.png new file mode 100644 index 0000000000..3b989acee4 Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-21.png differ diff --git a/docs/static/img/guides/migrate/keycloak-22.png b/docs/static/img/guides/migrate/keycloak-22.png new file mode 100644 index 0000000000..fa930bbe0e Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-22.png differ diff --git a/docs/static/img/guides/migrate/keycloak-23.png b/docs/static/img/guides/migrate/keycloak-23.png new file mode 100644 index 0000000000..3187ba9075 Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-23.png differ diff --git a/docs/static/img/guides/migrate/keycloak-24.png b/docs/static/img/guides/migrate/keycloak-24.png new file mode 100644 index 0000000000..c8cc267334 Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-24.png differ diff --git a/docs/static/img/guides/migrate/keycloak-25.png b/docs/static/img/guides/migrate/keycloak-25.png new file mode 100644 index 0000000000..2f5bfa4a9c Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-25.png differ diff --git a/docs/static/img/guides/migrate/keycloak-26.png b/docs/static/img/guides/migrate/keycloak-26.png new file mode 100644 index 0000000000..ce5fb1b339 Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-26.png differ diff --git a/docs/static/img/guides/migrate/keycloak-27.png b/docs/static/img/guides/migrate/keycloak-27.png new file mode 100644 index 0000000000..2cafac879d Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-27.png differ diff --git a/docs/static/img/guides/migrate/keycloak-28.png b/docs/static/img/guides/migrate/keycloak-28.png new file mode 100644 index 0000000000..4a8b0c15c7 Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-28.png differ diff --git a/docs/static/img/guides/migrate/keycloak-29.png b/docs/static/img/guides/migrate/keycloak-29.png new file mode 100644 index 0000000000..1c320d052c Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-29.png differ diff --git a/docs/static/img/guides/migrate/keycloak-30.png b/docs/static/img/guides/migrate/keycloak-30.png new file mode 100644 index 0000000000..a79aca5d70 Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-30.png differ diff --git a/docs/static/img/guides/migrate/keycloak-31.png b/docs/static/img/guides/migrate/keycloak-31.png new file mode 100644 index 0000000000..6fc1c256fc Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-31.png differ diff --git a/docs/static/img/guides/migrate/keycloak-32.png b/docs/static/img/guides/migrate/keycloak-32.png new file mode 100644 index 0000000000..9858391f70 Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-32.png differ diff --git a/docs/static/img/guides/migrate/keycloak-33.png b/docs/static/img/guides/migrate/keycloak-33.png new file mode 100644 index 0000000000..3228e5710f Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-33.png differ diff --git a/docs/static/img/guides/migrate/keycloak-34.png b/docs/static/img/guides/migrate/keycloak-34.png new file mode 100644 index 0000000000..e060ef7ad2 Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-34.png differ diff --git a/docs/static/img/guides/migrate/keycloak-35.png b/docs/static/img/guides/migrate/keycloak-35.png new file mode 100644 index 0000000000..b5366c2ac1 Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-35.png differ diff --git a/docs/static/img/guides/migrate/keycloak-36.png b/docs/static/img/guides/migrate/keycloak-36.png new file mode 100644 index 0000000000..0c01718b92 Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-36.png differ diff --git a/docs/static/img/guides/migrate/keycloak-37.png b/docs/static/img/guides/migrate/keycloak-37.png new file mode 100644 index 0000000000..df8e47edc2 Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-37.png differ diff --git a/docs/static/img/guides/migrate/keycloak-38.png b/docs/static/img/guides/migrate/keycloak-38.png new file mode 100644 index 0000000000..76a5cfdef9 Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-38.png differ diff --git a/docs/static/img/guides/migrate/keycloak-39.png b/docs/static/img/guides/migrate/keycloak-39.png new file mode 100644 index 0000000000..4f7bd844d8 Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-39.png differ diff --git a/docs/static/img/guides/migrate/keycloak-40.png b/docs/static/img/guides/migrate/keycloak-40.png new file mode 100644 index 0000000000..2dd629b769 Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-40.png differ diff --git a/docs/static/img/guides/migrate/keycloak-41.png b/docs/static/img/guides/migrate/keycloak-41.png new file mode 100644 index 0000000000..c5bc6f1842 Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-41.png differ diff --git a/docs/static/img/guides/migrate/keycloak-42.png b/docs/static/img/guides/migrate/keycloak-42.png new file mode 100644 index 0000000000..525374477e Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-42.png differ diff --git a/docs/static/img/guides/migrate/keycloak-43.png b/docs/static/img/guides/migrate/keycloak-43.png new file mode 100644 index 0000000000..3e5cb46b5c Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-43.png differ diff --git a/docs/static/img/guides/migrate/keycloak-44.png b/docs/static/img/guides/migrate/keycloak-44.png new file mode 100644 index 0000000000..ad5f123462 Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-44.png differ diff --git a/docs/static/img/guides/migrate/keycloak-45.png b/docs/static/img/guides/migrate/keycloak-45.png new file mode 100644 index 0000000000..2174e998b8 Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-45.png differ diff --git a/docs/static/img/guides/migrate/keycloak-46.png b/docs/static/img/guides/migrate/keycloak-46.png new file mode 100644 index 0000000000..5d00119a5a Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-46.png differ diff --git a/docs/static/img/guides/migrate/keycloak-47.png b/docs/static/img/guides/migrate/keycloak-47.png new file mode 100644 index 0000000000..47b4b7e5b7 Binary files /dev/null and b/docs/static/img/guides/migrate/keycloak-47.png differ diff --git a/e2e/cypress/e2e/instance/settings/notifications.cy.ts b/e2e/cypress/e2e/instance/settings/notifications.cy.ts new file mode 100644 index 0000000000..02548d6636 --- /dev/null +++ b/e2e/cypress/e2e/instance/settings/notifications.cy.ts @@ -0,0 +1,62 @@ +const notificationPath = `/settings?id=notifications`; +const smtpPath = `/settings?id=smtpprovider`; +const smsPath = `/settings?id=smsprovider`; + +beforeEach(() => { + cy.context().as('ctx'); +}); + +describe('instance notifications', () => { + describe('notification settings', () => { + it(`should show notification settings`, () => { + cy.visit(notificationPath); + cy.contains('Notification'); + }); + }); + + describe('smtp settings', () => { + it(`should show SMTP provider settings`, () => { + cy.visit(smtpPath); + cy.contains('SMTP Settings'); + }); + + it(`should add SMTP provider settings`, () => { + cy.visit(smtpPath); + cy.get('[formcontrolname="senderAddress"]').clear().type('sender@example.com'); + cy.get('[formcontrolname="senderName"]').clear().type('Zitadel'); + cy.get('[formcontrolname="hostAndPort"]').clear().type('smtp.mailtrap.io:2525'); + cy.get('[formcontrolname="user"]').clear().type('user@example.com'); + cy.get('[data-e2e="save-smtp-settings-button"]').click(); + cy.shouldConfirmSuccess(); + cy.get('[formcontrolname="senderAddress"]').should('have.value', 'sender@example.com'); + cy.get('[formcontrolname="senderName"]').should('have.value', 'Zitadel'); + cy.get('[formcontrolname="hostAndPort"]').should('have.value', 'smtp.mailtrap.io:2525'); + cy.get('[formcontrolname="user"]').should('have.value', 'user@example.com'); + }); + + it(`should add SMTP provider password`, () => { + cy.visit(smtpPath); + cy.get('[data-e2e="add-smtp-password-button"]').click(); + cy.get('[data-e2e="notification-setting-password"]').clear().type('dummy@example.com'); + cy.get('[data-e2e="save-notification-setting-password-button"]').click(); + cy.shouldConfirmSuccess(); + }); + }); + + describe('sms settings', () => { + it(`should show SMS provider settings`, () => { + cy.visit(smsPath); + cy.contains('SMS Settings'); + }); + + it(`should add SMS provider`, () => { + cy.visit(smsPath); + cy.get('[data-e2e="new-twilio-button"]').click(); + cy.get('[formcontrolname="sid"]').clear().type('test'); + cy.get('[formcontrolname="token"]').clear().type('token'); + cy.get('[formcontrolname="senderNumber"]').clear().type('2312123132'); + cy.get('[data-e2e="save-sms-settings-button"]').click(); + cy.shouldConfirmSuccess(); + }); + }); +}); diff --git a/internal/api/assets/asset.go b/internal/api/assets/asset.go index 3dd03b3f2d..9830baeb90 100644 --- a/internal/api/assets/asset.go +++ b/internal/api/assets/asset.go @@ -55,12 +55,6 @@ func AssetAPI(externalSecure bool) func(context.Context) string { } } -func AssetAPIFromDomain(externalSecure bool, externalPort uint16) func(context.Context) string { - return func(ctx context.Context) string { - return http_util.BuildHTTP(authz.GetInstance(ctx).RequestedDomain(), externalPort, externalSecure) + HandlerPrefix - } -} - type Uploader interface { UploadAsset(ctx context.Context, info string, asset *command.AssetUpload, commands *command.Commands) error ObjectName(data authz.CtxData) (string, error) diff --git a/internal/api/authz/context.go b/internal/api/authz/context.go index bfdd648fe4..0d022879ca 100644 --- a/internal/api/authz/context.go +++ b/internal/api/authz/context.go @@ -133,7 +133,7 @@ func GetAllPermissionsFromCtx(ctx context.Context) []string { func checkOrigin(ctx context.Context, origins []string) error { origin := grpc.GetGatewayHeader(ctx, http_util.Origin) if origin == "" { - origin = http_util.OriginFromCtx(ctx) + origin = http_util.OriginHeader(ctx) if origin == "" { return nil } diff --git a/internal/api/http/domain_check.go b/internal/api/http/domain_check.go index 3d39897991..419682202b 100644 --- a/internal/api/http/domain_check.go +++ b/internal/api/http/domain_check.go @@ -1,6 +1,7 @@ package http import ( + errorsAs "errors" "fmt" "io/ioutil" "net" @@ -36,6 +37,9 @@ func ValidateDomainHTTP(domain, token, verifier string) error { return errors.ThrowInternal(err, "HTTP-BH42h", "Errors.Internal") } if resp.StatusCode != 200 { + if resp.StatusCode == 404 { + return errors.ThrowNotFound(err, "ORG-F4zhw", "Errors.Org.DomainVerificationHTTPNotFound") + } return errors.ThrowInternal(err, "HTTP-G2zsw", "Errors.Internal") } defer resp.Body.Close() @@ -46,12 +50,21 @@ func ValidateDomainHTTP(domain, token, verifier string) error { if string(body) == verifier { return nil } - return errors.ThrowInvalidArgument(err, "HTTP-GH422", "Errors.Internal") + return errors.ThrowNotFound(err, "ORG-GH422", "Errors.Org.DomainVerificationHTTPNoMatch") } func ValidateDomainDNS(domain, verifier string) error { txtRecords, err := net.LookupTXT(tokenUrlDNS(domain)) if err != nil { + var dnsError *net.DNSError + if errorsAs.As(err, &dnsError) { + if dnsError.IsNotFound { + return errors.ThrowNotFound(err, "ORG-G241f", "Errors.Org.DomainVerificationTXTNotFound") + } + if dnsError.IsTimeout { + return errors.ThrowNotFound(err, "ORG-K563l", "Errors.Org.DomainVerificationTimeout") + } + } return errors.ThrowInternal(err, "HTTP-Hwsw2", "Errors.Internal") } @@ -60,7 +73,7 @@ func ValidateDomainDNS(domain, verifier string) error { return nil } } - return errors.ThrowInvalidArgument(err, "HTTP-G241f", "Errors.Internal") + return errors.ThrowNotFound(err, "ORG-G28if", "Errors.Org.DomainVerificationTXTNoMatch") } func TokenUrl(domain, token string, checkType CheckType) (string, error) { diff --git a/internal/api/http/header.go b/internal/api/http/header.go index f2d3e19c13..14ae3dfb68 100644 --- a/internal/api/http/header.go +++ b/internal/api/http/header.go @@ -45,6 +45,7 @@ type key int const ( httpHeaders key = iota remoteAddr + origin ) func CopyHeadersToContext(h http.Handler) http.Handler { @@ -61,7 +62,7 @@ func HeadersFromCtx(ctx context.Context) (http.Header, bool) { return headers, ok } -func OriginFromCtx(ctx context.Context) string { +func OriginHeader(ctx context.Context) string { headers, ok := ctx.Value(httpHeaders).(http.Header) if !ok { return "" @@ -69,6 +70,18 @@ func OriginFromCtx(ctx context.Context) string { return headers.Get(Origin) } +func ComposedOrigin(ctx context.Context) string { + o, ok := ctx.Value(origin).(string) + if !ok { + return "" + } + return o +} + +func WithComposedOrigin(ctx context.Context, composed string) context.Context { + return context.WithValue(ctx, origin, composed) +} + func RemoteIPFromCtx(ctx context.Context) string { ctxHeaders, ok := HeadersFromCtx(ctx) if !ok { diff --git a/internal/api/http/middleware/origin_interceptor.go b/internal/api/http/middleware/origin_interceptor.go new file mode 100644 index 0000000000..2cf9a644f5 --- /dev/null +++ b/internal/api/http/middleware/origin_interceptor.go @@ -0,0 +1,103 @@ +package middleware + +import ( + "fmt" + "net/http" + "net/url" + + "github.com/muhlemmer/httpforwarded" + "github.com/zitadel/logging" + + http_util "github.com/zitadel/zitadel/internal/api/http" +) + +func OriginHandler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := composeOrigin(r) + if !http_util.IsOrigin(origin) { + logging.Debugf("extracted origin is not valid: %s", origin) + next.ServeHTTP(w, r) + return + } + next.ServeHTTP(w, r.WithContext(http_util.WithComposedOrigin(r.Context(), origin))) + }) +} + +func composeOrigin(r *http.Request) string { + if origin, err := originFromForwardedHeader(r); err != nil { + logging.OnError(err).Debug("failed to build origin from forwarded header, trying x-forwarded-* headers") + } else { + return origin + } + if origin, err := originFromXForwardedHeaders(r); err != nil { + logging.OnError(err).Debug("failed to build origin from x-forwarded-* headers, using host header") + } else { + return origin + } + scheme := "https" + if r.TLS == nil { + scheme = "http" + } + return fmt.Sprintf("%s://%s", scheme, r.Host) +} + +func originFromForwardedHeader(r *http.Request) (string, error) { + fwd, err := httpforwarded.ParseFromRequest(r) + if err != nil { + return "", err + } + var fwdProto, fwdHost, fwdPort string + if fwdProto = mostRecentValue(fwd, "proto"); fwdProto == "" { + return "", fmt.Errorf("no proto in forwarded header") + } + if fwdHost = mostRecentValue(fwd, "host"); fwdHost == "" { + return "", fmt.Errorf("no host in forwarded header") + } + fwdPort, foundFwdFor := extractPort(mostRecentValue(fwd, "for")) + if !foundFwdFor { + return "", fmt.Errorf("no for in forwarded header") + } + o := fmt.Sprintf("%s://%s", fwdProto, fwdHost) + if fwdPort != "" { + o += ":" + fwdPort + } + return o, nil +} + +func originFromXForwardedHeaders(r *http.Request) (string, error) { + scheme := r.Header.Get("X-Forwarded-Proto") + if scheme == "" { + return "", fmt.Errorf("no X-Forwarded-Proto header") + } + host := r.Header.Get("X-Forwarded-Host") + if host == "" { + return "", fmt.Errorf("no X-Forwarded-Host header") + } + return fmt.Sprintf("%s://%s", scheme, host), nil +} + +func extractPort(raw string) (string, bool) { + if u, ok := parseURL(raw); ok { + return u.Port(), ok + } + return "", false +} + +func parseURL(raw string) (*url.URL, bool) { + if raw == "" { + return nil, false + } + u, err := url.Parse(raw) + return u, err == nil +} + +func mostRecentValue(forwarded map[string][]string, key string) string { + if forwarded == nil { + return "" + } + values := forwarded[key] + if len(values) == 0 { + return "" + } + return values[len(values)-1] +} diff --git a/internal/command/instance_smtp_config_model.go b/internal/command/instance_smtp_config_model.go index e852db3597..ee2c20700a 100644 --- a/internal/command/instance_smtp_config_model.go +++ b/internal/command/instance_smtp_config_model.go @@ -119,6 +119,7 @@ func (wm *InstanceSMTPConfigWriteModel) Query() *eventstore.SearchQueryBuilder { AggregateIDs(wm.AggregateID). EventTypes( instance.SMTPConfigAddedEventType, + instance.SMTPConfigRemovedEventType, instance.SMTPConfigChangedEventType, instance.SMTPConfigPasswordChangedEventType, instance.InstanceDomainAddedEventType, diff --git a/internal/command/org_domain.go b/internal/command/org_domain.go index f08b925886..aae1b14b68 100644 --- a/internal/command/org_domain.go +++ b/internal/command/org_domain.go @@ -213,9 +213,11 @@ func (c *Commands) ValidateOrgDomain(ctx context.Context, orgDomain *domain.OrgD return writeModelToObjectDetails(&domainWriteModel.WriteModel), nil } events = append(events, org.NewDomainVerificationFailedEvent(ctx, orgAgg, orgDomain.Domain)) - _, err = c.eventstore.Push(ctx, events...) - logging.LogWithFields("ORG-dhTE", "orgID", orgAgg.ID, "domain", orgDomain.Domain).OnError(err).Error("NewDomainVerificationFailedEvent push failed") - return nil, errors.ThrowInvalidArgument(err, "ORG-GH3s", "Errors.Org.DomainVerificationFailed") + + _, errPush := c.eventstore.Push(ctx, events...) + logging.LogWithFields("ORG-dhTE", "orgID", orgAgg.ID, "domain", orgDomain.Domain).OnError(errPush).Error("NewDomainVerificationFailedEvent push failed") + + return nil, err } func (c *Commands) SetPrimaryOrgDomain(ctx context.Context, orgDomain *domain.OrgDomain) (*domain.ObjectDetails, error) { diff --git a/internal/notification/channels.go b/internal/notification/channels.go new file mode 100644 index 0000000000..68ad673472 --- /dev/null +++ b/internal/notification/channels.go @@ -0,0 +1,106 @@ +package notification + +import ( + "context" + + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/notification/channels/smtp" + "github.com/zitadel/zitadel/internal/notification/channels/twilio" + "github.com/zitadel/zitadel/internal/notification/channels/webhook" + "github.com/zitadel/zitadel/internal/notification/handlers" + "github.com/zitadel/zitadel/internal/notification/senders" + "github.com/zitadel/zitadel/internal/notification/types" + "github.com/zitadel/zitadel/internal/telemetry/metrics" +) + +var _ types.ChannelChains = (*channels)(nil) + +type counters struct { + success deliveryMetrics + failed deliveryMetrics +} + +type deliveryMetrics struct { + email string + sms string + json string +} + +type channels struct { + q *handlers.NotificationQueries + counters counters +} + +func newChannels(q *handlers.NotificationQueries) *channels { + c := &channels{ + q: q, + counters: counters{ + success: deliveryMetrics{ + email: "successful_deliveries_email", + sms: "successful_deliveries_sms", + json: "successful_deliveries_json", + }, + failed: deliveryMetrics{ + email: "failed_deliveries_email", + sms: "failed_deliveries_sms", + json: "failed_deliveries_json", + }, + }, + } + registerCounter(c.counters.success.email, "Successfully delivered emails") + registerCounter(c.counters.failed.email, "Failed email deliveries") + registerCounter(c.counters.success.sms, "Successfully delivered SMS") + registerCounter(c.counters.failed.sms, "Failed SMS deliveries") + registerCounter(c.counters.success.json, "Successfully delivered JSON messages") + registerCounter(c.counters.failed.json, "Failed JSON message deliveries") + return c +} + +func registerCounter(counter, desc string) { + err := metrics.RegisterCounter(counter, desc) + logging.WithFields("metric", counter).OnError(err).Panic("unable to register counter") +} + +func (c *channels) Email(ctx context.Context) (*senders.Chain, *smtp.Config, error) { + smtpCfg, err := c.q.GetSMTPConfig(ctx) + if err != nil { + return nil, nil, err + } + chain, err := senders.EmailChannels( + ctx, + smtpCfg, + c.q.GetFileSystemProvider, + c.q.GetLogProvider, + c.counters.success.email, + c.counters.failed.email, + ) + return chain, smtpCfg, err +} + +func (c *channels) SMS(ctx context.Context) (*senders.Chain, *twilio.Config, error) { + twilioCfg, err := c.q.GetTwilioConfig(ctx) + if err != nil { + return nil, nil, err + } + chain, err := senders.SMSChannels( + ctx, + twilioCfg, + c.q.GetFileSystemProvider, + c.q.GetLogProvider, + c.counters.success.sms, + c.counters.failed.sms, + ) + return chain, twilioCfg, err +} + +func (c *channels) Webhook(ctx context.Context, cfg webhook.Config) (*senders.Chain, error) { + return senders.WebhookChannels( + ctx, + cfg, + c.q.GetFileSystemProvider, + c.q.GetLogProvider, + c.counters.success.json, + c.counters.failed.json, + ) +} diff --git a/internal/notification/channels/mock/channel.mock.go b/internal/notification/channels/mock/channel.mock.go index 47d50e08a2..6a51073b4e 100644 --- a/internal/notification/channels/mock/channel.mock.go +++ b/internal/notification/channels/mock/channel.mock.go @@ -5,8 +5,8 @@ package mock import ( - channels "github.com/zitadel/zitadel/internal/notification/channels" gomock "github.com/golang/mock/gomock" + channels "github.com/zitadel/zitadel/internal/notification/channels" reflect "reflect" ) diff --git a/internal/notification/channels/mock/message.mock.go b/internal/notification/channels/mock/message.mock.go index a6189fe41c..986bb1b0b9 100644 --- a/internal/notification/channels/mock/message.mock.go +++ b/internal/notification/channels/mock/message.mock.go @@ -6,6 +6,7 @@ package mock import ( gomock "github.com/golang/mock/gomock" + eventstore "github.com/zitadel/zitadel/internal/eventstore" reflect "reflect" ) @@ -33,11 +34,12 @@ func (m *MockMessage) EXPECT() *MockMessageMockRecorder { } // GetContent mocks base method -func (m *MockMessage) GetContent() string { +func (m *MockMessage) GetContent() (string, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetContent") ret0, _ := ret[0].(string) - return ret0 + ret1, _ := ret[1].(error) + return ret0, ret1 } // GetContent indicates an expected call of GetContent @@ -45,3 +47,17 @@ func (mr *MockMessageMockRecorder) GetContent() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetContent", reflect.TypeOf((*MockMessage)(nil).GetContent)) } + +// GetTriggeringEvent mocks base method +func (m *MockMessage) GetTriggeringEvent() eventstore.Event { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTriggeringEvent") + ret0, _ := ret[0].(eventstore.Event) + return ret0 +} + +// GetTriggeringEvent indicates an expected call of GetTriggeringEvent +func (mr *MockMessageMockRecorder) GetTriggeringEvent() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTriggeringEvent", reflect.TypeOf((*MockMessage)(nil).GetTriggeringEvent)) +} diff --git a/internal/notification/channels/smtp/channel.go b/internal/notification/channels/smtp/channel.go index 5d8542ea3a..ceea5c9dfb 100644 --- a/internal/notification/channels/smtp/channel.go +++ b/internal/notification/channels/smtp/channel.go @@ -1,7 +1,6 @@ package smtp import ( - "context" "crypto/tls" "net" "net/smtp" @@ -23,24 +22,18 @@ type Email struct { replyToAddress string } -func InitChannel(ctx context.Context, getSMTPConfig func(ctx context.Context) (*Config, error)) (*Email, error) { - smtpConfig, err := getSMTPConfig(ctx) - if err != nil { - return nil, err - } - client, err := smtpConfig.SMTP.connectToSMTP(smtpConfig.Tls) +func InitChannel(cfg *Config) (*Email, error) { + client, err := cfg.SMTP.connectToSMTP(cfg.Tls) if err != nil { logging.New().WithError(err).Error("could not connect to smtp") return nil, err } - logging.New().Debug("successfully initialized smtp email channel") - return &Email{ smtpClient: client, - senderName: smtpConfig.FromName, - senderAddress: smtpConfig.From, - replyToAddress: smtpConfig.ReplyToAddress, + senderName: cfg.FromName, + senderAddress: cfg.From, + replyToAddress: cfg.ReplyToAddress, }, nil } diff --git a/internal/notification/handlers/commands.go b/internal/notification/handlers/commands.go new file mode 100644 index 0000000000..5308a27c44 --- /dev/null +++ b/internal/notification/handlers/commands.go @@ -0,0 +1,24 @@ +package handlers + +import ( + "context" + + "github.com/zitadel/zitadel/internal/repository/milestone" + "github.com/zitadel/zitadel/internal/repository/quota" +) + +type Commands interface { + HumanInitCodeSent(ctx context.Context, orgID, userID string) error + HumanEmailVerificationCodeSent(ctx context.Context, orgID, userID string) error + PasswordCodeSent(ctx context.Context, orgID, userID string) error + HumanOTPSMSCodeSent(ctx context.Context, userID, resourceOwner string) error + HumanOTPEmailCodeSent(ctx context.Context, userID, resourceOwner string) error + OTPSMSSent(ctx context.Context, sessionID, resourceOwner string) error + OTPEmailSent(ctx context.Context, sessionID, resourceOwner string) error + UserDomainClaimedSent(ctx context.Context, orgID, userID string) error + HumanPasswordlessInitCodeSent(ctx context.Context, userID, resourceOwner, codeID string) error + PasswordChangeSent(ctx context.Context, orgID, userID string) error + HumanPhoneVerificationCodeSent(ctx context.Context, orgID, userID string) error + UsageNotificationSent(ctx context.Context, dueEvent *quota.NotificationDueEvent) error + MilestonePushed(ctx context.Context, msType milestone.Type, endpoints []string, primaryDomain string) error +} diff --git a/internal/notification/handlers/gen_mock.go b/internal/notification/handlers/gen_mock.go new file mode 100644 index 0000000000..e248633361 --- /dev/null +++ b/internal/notification/handlers/gen_mock.go @@ -0,0 +1,4 @@ +package handlers + +//go:generate mockgen -package mock -destination ./mock/queries.mock.go github.com/zitadel/zitadel/internal/notification/handlers Queries +//go:generate mockgen -package mock -destination ./mock/commands.mock.go github.com/zitadel/zitadel/internal/notification/handlers Commands diff --git a/internal/notification/handlers/mock/commands.mock.go b/internal/notification/handlers/mock/commands.mock.go new file mode 100644 index 0000000000..b8584c7daf --- /dev/null +++ b/internal/notification/handlers/mock/commands.mock.go @@ -0,0 +1,218 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/zitadel/zitadel/internal/notification/handlers (interfaces: Commands) + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + gomock "github.com/golang/mock/gomock" + milestone "github.com/zitadel/zitadel/internal/repository/milestone" + quota "github.com/zitadel/zitadel/internal/repository/quota" + reflect "reflect" +) + +// MockCommands is a mock of Commands interface +type MockCommands struct { + ctrl *gomock.Controller + recorder *MockCommandsMockRecorder +} + +// MockCommandsMockRecorder is the mock recorder for MockCommands +type MockCommandsMockRecorder struct { + mock *MockCommands +} + +// NewMockCommands creates a new mock instance +func NewMockCommands(ctrl *gomock.Controller) *MockCommands { + mock := &MockCommands{ctrl: ctrl} + mock.recorder = &MockCommandsMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockCommands) EXPECT() *MockCommandsMockRecorder { + return m.recorder +} + +// HumanEmailVerificationCodeSent mocks base method +func (m *MockCommands) HumanEmailVerificationCodeSent(arg0 context.Context, arg1, arg2 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HumanEmailVerificationCodeSent", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// HumanEmailVerificationCodeSent indicates an expected call of HumanEmailVerificationCodeSent +func (mr *MockCommandsMockRecorder) HumanEmailVerificationCodeSent(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanEmailVerificationCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanEmailVerificationCodeSent), arg0, arg1, arg2) +} + +// HumanInitCodeSent mocks base method +func (m *MockCommands) HumanInitCodeSent(arg0 context.Context, arg1, arg2 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HumanInitCodeSent", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// HumanInitCodeSent indicates an expected call of HumanInitCodeSent +func (mr *MockCommandsMockRecorder) HumanInitCodeSent(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanInitCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanInitCodeSent), arg0, arg1, arg2) +} + +// HumanOTPEmailCodeSent mocks base method +func (m *MockCommands) HumanOTPEmailCodeSent(arg0 context.Context, arg1, arg2 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HumanOTPEmailCodeSent", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// HumanOTPEmailCodeSent indicates an expected call of HumanOTPEmailCodeSent +func (mr *MockCommandsMockRecorder) HumanOTPEmailCodeSent(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanOTPEmailCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanOTPEmailCodeSent), arg0, arg1, arg2) +} + +// HumanOTPSMSCodeSent mocks base method +func (m *MockCommands) HumanOTPSMSCodeSent(arg0 context.Context, arg1, arg2 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HumanOTPSMSCodeSent", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// HumanOTPSMSCodeSent indicates an expected call of HumanOTPSMSCodeSent +func (mr *MockCommandsMockRecorder) HumanOTPSMSCodeSent(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanOTPSMSCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanOTPSMSCodeSent), arg0, arg1, arg2) +} + +// HumanPasswordlessInitCodeSent mocks base method +func (m *MockCommands) HumanPasswordlessInitCodeSent(arg0 context.Context, arg1, arg2, arg3 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HumanPasswordlessInitCodeSent", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(error) + return ret0 +} + +// HumanPasswordlessInitCodeSent indicates an expected call of HumanPasswordlessInitCodeSent +func (mr *MockCommandsMockRecorder) HumanPasswordlessInitCodeSent(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanPasswordlessInitCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanPasswordlessInitCodeSent), arg0, arg1, arg2, arg3) +} + +// HumanPhoneVerificationCodeSent mocks base method +func (m *MockCommands) HumanPhoneVerificationCodeSent(arg0 context.Context, arg1, arg2 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HumanPhoneVerificationCodeSent", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// HumanPhoneVerificationCodeSent indicates an expected call of HumanPhoneVerificationCodeSent +func (mr *MockCommandsMockRecorder) HumanPhoneVerificationCodeSent(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanPhoneVerificationCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanPhoneVerificationCodeSent), arg0, arg1, arg2) +} + +// MilestonePushed mocks base method +func (m *MockCommands) MilestonePushed(arg0 context.Context, arg1 milestone.Type, arg2 []string, arg3 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MilestonePushed", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(error) + return ret0 +} + +// MilestonePushed indicates an expected call of MilestonePushed +func (mr *MockCommandsMockRecorder) MilestonePushed(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MilestonePushed", reflect.TypeOf((*MockCommands)(nil).MilestonePushed), arg0, arg1, arg2, arg3) +} + +// OTPEmailSent mocks base method +func (m *MockCommands) OTPEmailSent(arg0 context.Context, arg1, arg2 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "OTPEmailSent", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// OTPEmailSent indicates an expected call of OTPEmailSent +func (mr *MockCommandsMockRecorder) OTPEmailSent(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OTPEmailSent", reflect.TypeOf((*MockCommands)(nil).OTPEmailSent), arg0, arg1, arg2) +} + +// OTPSMSSent mocks base method +func (m *MockCommands) OTPSMSSent(arg0 context.Context, arg1, arg2 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "OTPSMSSent", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// OTPSMSSent indicates an expected call of OTPSMSSent +func (mr *MockCommandsMockRecorder) OTPSMSSent(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OTPSMSSent", reflect.TypeOf((*MockCommands)(nil).OTPSMSSent), arg0, arg1, arg2) +} + +// PasswordChangeSent mocks base method +func (m *MockCommands) PasswordChangeSent(arg0 context.Context, arg1, arg2 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PasswordChangeSent", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// PasswordChangeSent indicates an expected call of PasswordChangeSent +func (mr *MockCommandsMockRecorder) PasswordChangeSent(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordChangeSent", reflect.TypeOf((*MockCommands)(nil).PasswordChangeSent), arg0, arg1, arg2) +} + +// PasswordCodeSent mocks base method +func (m *MockCommands) PasswordCodeSent(arg0 context.Context, arg1, arg2 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PasswordCodeSent", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// PasswordCodeSent indicates an expected call of PasswordCodeSent +func (mr *MockCommandsMockRecorder) PasswordCodeSent(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordCodeSent", reflect.TypeOf((*MockCommands)(nil).PasswordCodeSent), arg0, arg1, arg2) +} + +// UsageNotificationSent mocks base method +func (m *MockCommands) UsageNotificationSent(arg0 context.Context, arg1 *quota.NotificationDueEvent) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UsageNotificationSent", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// UsageNotificationSent indicates an expected call of UsageNotificationSent +func (mr *MockCommandsMockRecorder) UsageNotificationSent(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UsageNotificationSent", reflect.TypeOf((*MockCommands)(nil).UsageNotificationSent), arg0, arg1) +} + +// UserDomainClaimedSent mocks base method +func (m *MockCommands) UserDomainClaimedSent(arg0 context.Context, arg1, arg2 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UserDomainClaimedSent", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// UserDomainClaimedSent indicates an expected call of UserDomainClaimedSent +func (mr *MockCommandsMockRecorder) UserDomainClaimedSent(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserDomainClaimedSent", reflect.TypeOf((*MockCommands)(nil).UserDomainClaimedSent), arg0, arg1, arg2) +} diff --git a/internal/notification/handlers/mock/queries.mock.go b/internal/notification/handlers/mock/queries.mock.go new file mode 100644 index 0000000000..f9c2b2d895 --- /dev/null +++ b/internal/notification/handlers/mock/queries.mock.go @@ -0,0 +1,226 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/zitadel/zitadel/internal/notification/handlers (interfaces: Queries) + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + gomock "github.com/golang/mock/gomock" + domain "github.com/zitadel/zitadel/internal/domain" + query "github.com/zitadel/zitadel/internal/query" + language "golang.org/x/text/language" + reflect "reflect" +) + +// MockQueries is a mock of Queries interface +type MockQueries struct { + ctrl *gomock.Controller + recorder *MockQueriesMockRecorder +} + +// MockQueriesMockRecorder is the mock recorder for MockQueries +type MockQueriesMockRecorder struct { + mock *MockQueries +} + +// NewMockQueries creates a new mock instance +func NewMockQueries(ctrl *gomock.Controller) *MockQueries { + mock := &MockQueries{ctrl: ctrl} + mock.recorder = &MockQueriesMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockQueries) EXPECT() *MockQueriesMockRecorder { + return m.recorder +} + +// ActiveLabelPolicyByOrg mocks base method +func (m *MockQueries) ActiveLabelPolicyByOrg(arg0 context.Context, arg1 string, arg2 bool) (*query.LabelPolicy, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ActiveLabelPolicyByOrg", arg0, arg1, arg2) + ret0, _ := ret[0].(*query.LabelPolicy) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ActiveLabelPolicyByOrg indicates an expected call of ActiveLabelPolicyByOrg +func (mr *MockQueriesMockRecorder) ActiveLabelPolicyByOrg(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ActiveLabelPolicyByOrg", reflect.TypeOf((*MockQueries)(nil).ActiveLabelPolicyByOrg), arg0, arg1, arg2) +} + +// CustomTextListByTemplate mocks base method +func (m *MockQueries) CustomTextListByTemplate(arg0 context.Context, arg1, arg2 string, arg3 bool) (*query.CustomTexts, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CustomTextListByTemplate", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(*query.CustomTexts) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CustomTextListByTemplate indicates an expected call of CustomTextListByTemplate +func (mr *MockQueriesMockRecorder) CustomTextListByTemplate(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CustomTextListByTemplate", reflect.TypeOf((*MockQueries)(nil).CustomTextListByTemplate), arg0, arg1, arg2, arg3) +} + +// GetDefaultLanguage mocks base method +func (m *MockQueries) GetDefaultLanguage(arg0 context.Context) language.Tag { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetDefaultLanguage", arg0) + ret0, _ := ret[0].(language.Tag) + return ret0 +} + +// GetDefaultLanguage indicates an expected call of GetDefaultLanguage +func (mr *MockQueriesMockRecorder) GetDefaultLanguage(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDefaultLanguage", reflect.TypeOf((*MockQueries)(nil).GetDefaultLanguage), arg0) +} + +// GetNotifyUserByID mocks base method +func (m *MockQueries) GetNotifyUserByID(arg0 context.Context, arg1 bool, arg2 string, arg3 bool, arg4 ...query.SearchQuery) (*query.NotifyUser, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1, arg2, arg3} + for _, a := range arg4 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "GetNotifyUserByID", varargs...) + ret0, _ := ret[0].(*query.NotifyUser) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNotifyUserByID indicates an expected call of GetNotifyUserByID +func (mr *MockQueriesMockRecorder) GetNotifyUserByID(arg0, arg1, arg2, arg3 interface{}, arg4 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1, arg2, arg3}, arg4...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotifyUserByID", reflect.TypeOf((*MockQueries)(nil).GetNotifyUserByID), varargs...) +} + +// MailTemplateByOrg mocks base method +func (m *MockQueries) MailTemplateByOrg(arg0 context.Context, arg1 string, arg2 bool) (*query.MailTemplate, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MailTemplateByOrg", arg0, arg1, arg2) + ret0, _ := ret[0].(*query.MailTemplate) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// MailTemplateByOrg indicates an expected call of MailTemplateByOrg +func (mr *MockQueriesMockRecorder) MailTemplateByOrg(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MailTemplateByOrg", reflect.TypeOf((*MockQueries)(nil).MailTemplateByOrg), arg0, arg1, arg2) +} + +// NotificationPolicyByOrg mocks base method +func (m *MockQueries) NotificationPolicyByOrg(arg0 context.Context, arg1 bool, arg2 string, arg3 bool) (*query.NotificationPolicy, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NotificationPolicyByOrg", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(*query.NotificationPolicy) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// NotificationPolicyByOrg indicates an expected call of NotificationPolicyByOrg +func (mr *MockQueriesMockRecorder) NotificationPolicyByOrg(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationPolicyByOrg", reflect.TypeOf((*MockQueries)(nil).NotificationPolicyByOrg), arg0, arg1, arg2, arg3) +} + +// NotificationProviderByIDAndType mocks base method +func (m *MockQueries) NotificationProviderByIDAndType(arg0 context.Context, arg1 string, arg2 domain.NotificationProviderType) (*query.DebugNotificationProvider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NotificationProviderByIDAndType", arg0, arg1, arg2) + ret0, _ := ret[0].(*query.DebugNotificationProvider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// NotificationProviderByIDAndType indicates an expected call of NotificationProviderByIDAndType +func (mr *MockQueriesMockRecorder) NotificationProviderByIDAndType(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationProviderByIDAndType", reflect.TypeOf((*MockQueries)(nil).NotificationProviderByIDAndType), arg0, arg1, arg2) +} + +// SMSProviderConfig mocks base method +func (m *MockQueries) SMSProviderConfig(arg0 context.Context, arg1 ...query.SearchQuery) (*query.SMSConfig, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "SMSProviderConfig", varargs...) + ret0, _ := ret[0].(*query.SMSConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SMSProviderConfig indicates an expected call of SMSProviderConfig +func (mr *MockQueriesMockRecorder) SMSProviderConfig(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SMSProviderConfig", reflect.TypeOf((*MockQueries)(nil).SMSProviderConfig), varargs...) +} + +// SMTPConfigByAggregateID mocks base method +func (m *MockQueries) SMTPConfigByAggregateID(arg0 context.Context, arg1 string) (*query.SMTPConfig, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SMTPConfigByAggregateID", arg0, arg1) + ret0, _ := ret[0].(*query.SMTPConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SMTPConfigByAggregateID indicates an expected call of SMTPConfigByAggregateID +func (mr *MockQueriesMockRecorder) SMTPConfigByAggregateID(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SMTPConfigByAggregateID", reflect.TypeOf((*MockQueries)(nil).SMTPConfigByAggregateID), arg0, arg1) +} + +// SearchInstanceDomains mocks base method +func (m *MockQueries) SearchInstanceDomains(arg0 context.Context, arg1 *query.InstanceDomainSearchQueries) (*query.InstanceDomains, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SearchInstanceDomains", arg0, arg1) + ret0, _ := ret[0].(*query.InstanceDomains) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SearchInstanceDomains indicates an expected call of SearchInstanceDomains +func (mr *MockQueriesMockRecorder) SearchInstanceDomains(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchInstanceDomains", reflect.TypeOf((*MockQueries)(nil).SearchInstanceDomains), arg0, arg1) +} + +// SearchMilestones mocks base method +func (m *MockQueries) SearchMilestones(arg0 context.Context, arg1 []string, arg2 *query.MilestonesSearchQueries) (*query.Milestones, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SearchMilestones", arg0, arg1, arg2) + ret0, _ := ret[0].(*query.Milestones) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SearchMilestones indicates an expected call of SearchMilestones +func (mr *MockQueriesMockRecorder) SearchMilestones(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchMilestones", reflect.TypeOf((*MockQueries)(nil).SearchMilestones), arg0, arg1, arg2) +} + +// SessionByID mocks base method +func (m *MockQueries) SessionByID(arg0 context.Context, arg1 bool, arg2, arg3 string) (*query.Session, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SessionByID", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(*query.Session) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SessionByID indicates an expected call of SessionByID +func (mr *MockQueriesMockRecorder) SessionByID(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SessionByID", reflect.TypeOf((*MockQueries)(nil).SessionByID), arg0, arg1, arg2, arg3) +} diff --git a/internal/notification/handlers/origin.go b/internal/notification/handlers/origin.go index 2e8549a18b..a807edd2d3 100644 --- a/internal/notification/handlers/origin.go +++ b/internal/notification/handlers/origin.go @@ -2,27 +2,56 @@ package handlers import ( "context" + "fmt" + "net/url" "github.com/zitadel/zitadel/internal/api/authz" http_utils "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/query" ) -func (n *NotificationQueries) Origin(ctx context.Context) (context.Context, string, error) { +type OriginEvent interface { + eventstore.Event + TriggerOrigin() string +} + +func (n *NotificationQueries) Origin(ctx context.Context, e eventstore.Event) (context.Context, error) { + originEvent, ok := e.(OriginEvent) + if !ok { + return ctx, errors.ThrowInternal(fmt.Errorf("event of type %T doesn't implement OriginEvent", e), "NOTIF-3m9fs", "Errors.Internal") + } + origin := originEvent.TriggerOrigin() + if origin != "" { + originURL, err := url.Parse(origin) + if err != nil { + return ctx, err + } + return enrichCtx(ctx, originURL.Hostname(), origin), nil + } primary, err := query.NewInstanceDomainPrimarySearchQuery(true) if err != nil { - return ctx, "", err + return ctx, err } domains, err := n.SearchInstanceDomains(ctx, &query.InstanceDomainSearchQueries{ Queries: []query.SearchQuery{primary}, }) if err != nil { - return ctx, "", err + return ctx, err } if len(domains.Domains) < 1 { - return ctx, "", errors.ThrowInternal(nil, "NOTIF-Ef3r1", "Errors.Notification.NoDomain") + return ctx, errors.ThrowInternal(nil, "NOTIF-Ef3r1", "Errors.Notification.NoDomain") } - ctx = authz.WithRequestedDomain(ctx, domains.Domains[0].Domain) - return ctx, http_utils.BuildHTTP(domains.Domains[0].Domain, n.externalPort, n.externalSecure), nil + return enrichCtx( + ctx, + domains.Domains[0].Domain, + http_utils.BuildHTTP(domains.Domains[0].Domain, n.externalPort, n.externalSecure), + ), nil +} + +func enrichCtx(ctx context.Context, host, origin string) context.Context { + ctx = authz.WithRequestedDomain(ctx, host) + ctx = http_utils.WithComposedOrigin(ctx, origin) + return ctx } diff --git a/internal/notification/handlers/queries.go b/internal/notification/handlers/queries.go index 96880445bf..8e4154e12d 100644 --- a/internal/notification/handlers/queries.go +++ b/internal/notification/handlers/queries.go @@ -1,16 +1,34 @@ package handlers import ( + "context" "net/http" + "golang.org/x/text/language" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" - _ "github.com/zitadel/zitadel/internal/notification/statik" "github.com/zitadel/zitadel/internal/query" ) +type Queries interface { + ActiveLabelPolicyByOrg(ctx context.Context, orgID string, withOwnerRemoved bool) (*query.LabelPolicy, error) + MailTemplateByOrg(ctx context.Context, orgID string, withOwnerRemoved bool) (*query.MailTemplate, error) + GetNotifyUserByID(ctx context.Context, shouldTriggered bool, userID string, withOwnerRemoved bool, queries ...query.SearchQuery) (*query.NotifyUser, error) + CustomTextListByTemplate(ctx context.Context, aggregateID, template string, withOwnerRemoved bool) (*query.CustomTexts, error) + SearchInstanceDomains(ctx context.Context, queries *query.InstanceDomainSearchQueries) (*query.InstanceDomains, error) + SessionByID(ctx context.Context, shouldTriggerBulk bool, id, sessionToken string) (*query.Session, error) + NotificationPolicyByOrg(ctx context.Context, shouldTriggerBulk bool, orgID string, withOwnerRemoved bool) (*query.NotificationPolicy, error) + SearchMilestones(ctx context.Context, instanceIDs []string, queries *query.MilestonesSearchQueries) (*query.Milestones, error) + NotificationProviderByIDAndType(ctx context.Context, aggID string, providerType domain.NotificationProviderType) (*query.DebugNotificationProvider, error) + SMSProviderConfig(ctx context.Context, queries ...query.SearchQuery) (*query.SMSConfig, error) + SMTPConfigByAggregateID(ctx context.Context, aggregateID string) (*query.SMTPConfig, error) + GetDefaultLanguage(ctx context.Context) language.Tag +} + type NotificationQueries struct { - *query.Queries + Queries es *eventstore.Eventstore externalDomain string externalPort uint16 @@ -23,7 +41,7 @@ type NotificationQueries struct { } func NewNotificationQueries( - baseQueries *query.Queries, + baseQueries Queries, es *eventstore.Eventstore, externalDomain string, externalPort uint16, diff --git a/internal/notification/handlers/quota_notifier.go b/internal/notification/handlers/quota_notifier.go index 21daa92ffc..b8f59a70e1 100644 --- a/internal/notification/handlers/quota_notifier.go +++ b/internal/notification/handlers/quota_notifier.go @@ -22,10 +22,9 @@ const ( type quotaNotifier struct { crdb.StatementHandler - commands *command.Commands - queries *NotificationQueries - metricSuccessfulDeliveriesJSON string - metricFailedDeliveriesJSON string + commands *command.Commands + queries *NotificationQueries + channels types.ChannelChains } func NewQuotaNotifier( @@ -33,8 +32,7 @@ func NewQuotaNotifier( config crdb.StatementHandlerConfig, commands *command.Commands, queries *NotificationQueries, - metricSuccessfulDeliveriesJSON, - metricFailedDeliveriesJSON string, + channels types.ChannelChains, ) *quotaNotifier { p := new(quotaNotifier) config.ProjectionName = QuotaNotificationsProjectionTable @@ -42,8 +40,7 @@ func NewQuotaNotifier( p.StatementHandler = crdb.NewStatementHandler(ctx, config) p.commands = commands p.queries = queries - p.metricSuccessfulDeliveriesJSON = metricSuccessfulDeliveriesJSON - p.metricFailedDeliveriesJSON = metricFailedDeliveriesJSON + p.channels = channels projection.NotificationsQuotaProjection = p return p } @@ -75,19 +72,7 @@ func (u *quotaNotifier) reduceNotificationDue(event eventstore.Event) (*handler. if alreadyHandled { return crdb.NewNoOpStatement(e), nil } - err = types.SendJSON( - ctx, - webhook.Config{ - CallURL: e.CallURL, - Method: http.MethodPost, - }, - u.queries.GetFileSystemProvider, - u.queries.GetLogProvider, - e, - e, - u.metricSuccessfulDeliveriesJSON, - u.metricFailedDeliveriesJSON, - ).WithoutTemplate() + err = types.SendJSON(ctx, webhook.Config{CallURL: e.CallURL, Method: http.MethodPost}, u.channels, e, e).WithoutTemplate() if err != nil { return nil, err } diff --git a/internal/notification/handlers/telemetry_pusher.go b/internal/notification/handlers/telemetry_pusher.go index 837399b028..5b802a050e 100644 --- a/internal/notification/handlers/telemetry_pusher.go +++ b/internal/notification/handlers/telemetry_pusher.go @@ -38,11 +38,10 @@ type TelemetryPusherConfig struct { type telemetryPusher struct { crdb.StatementHandler - cfg TelemetryPusherConfig - commands *command.Commands - queries *NotificationQueries - metricSuccessfulDeliveriesJSON string - metricFailedDeliveriesJSON string + cfg TelemetryPusherConfig + commands *command.Commands + queries *NotificationQueries + channels types.ChannelChains } func NewTelemetryPusher( @@ -51,8 +50,7 @@ func NewTelemetryPusher( handlerCfg crdb.StatementHandlerConfig, commands *command.Commands, queries *NotificationQueries, - metricSuccessfulDeliveriesJSON, - metricFailedDeliveriesJSON string, + channels types.ChannelChains, ) *telemetryPusher { p := new(telemetryPusher) handlerCfg.ProjectionName = TelemetryProjectionTable @@ -62,8 +60,7 @@ func NewTelemetryPusher( p.StatementHandler = crdb.NewStatementHandler(ctx, handlerCfg) p.commands = commands p.queries = queries - p.metricSuccessfulDeliveriesJSON = metricSuccessfulDeliveriesJSON - p.metricFailedDeliveriesJSON = metricFailedDeliveriesJSON + p.channels = channels projection.TelemetryPusherProjection = p return p } @@ -132,8 +129,7 @@ func (t *telemetryPusher) pushMilestone(ctx context.Context, event *pseudo.Sched Method: http.MethodPost, Headers: t.cfg.Headers, }, - t.queries.GetFileSystemProvider, - t.queries.GetLogProvider, + t.channels, &struct { InstanceID string `json:"instanceId"` ExternalDomain string `json:"externalDomain"` @@ -148,8 +144,6 @@ func (t *telemetryPusher) pushMilestone(ctx context.Context, event *pseudo.Sched ReachedDate: ms.ReachedDate, }, event, - t.metricSuccessfulDeliveriesJSON, - t.metricFailedDeliveriesJSON, ).WithoutTemplate(); err != nil { return err } diff --git a/internal/notification/handlers/user_notifier.go b/internal/notification/handlers/user_notifier.go index 7b1d407662..1c60fea8c4 100644 --- a/internal/notification/handlers/user_notifier.go +++ b/internal/notification/handlers/user_notifier.go @@ -5,9 +5,8 @@ import ( "strings" "time" - "github.com/zitadel/zitadel/internal/api/authz" + http_util "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/ui/login" - "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/errors" @@ -27,27 +26,19 @@ const ( type userNotifier struct { crdb.StatementHandler - commands *command.Commands + commands Commands queries *NotificationQueries - assetsPrefix func(context.Context) string + channels types.ChannelChains otpEmailTmpl string - metricSuccessfulDeliveriesEmail, - metricFailedDeliveriesEmail, - metricSuccessfulDeliveriesSMS, - metricFailedDeliveriesSMS string } func NewUserNotifier( ctx context.Context, config crdb.StatementHandlerConfig, - commands *command.Commands, + commands Commands, queries *NotificationQueries, - assetsPrefix func(context.Context) string, + channels types.ChannelChains, otpEmailTmpl string, - metricSuccessfulDeliveriesEmail, - metricFailedDeliveriesEmail, - metricSuccessfulDeliveriesSMS, - metricFailedDeliveriesSMS string, ) *userNotifier { p := new(userNotifier) config.ProjectionName = UserNotificationsProjectionTable @@ -55,12 +46,8 @@ func NewUserNotifier( p.StatementHandler = crdb.NewStatementHandler(ctx, config) p.commands = commands p.queries = queries - p.assetsPrefix = assetsPrefix + p.channels = channels p.otpEmailTmpl = otpEmailTmpl - p.metricSuccessfulDeliveriesEmail = metricSuccessfulDeliveriesEmail - p.metricFailedDeliveriesEmail = metricFailedDeliveriesEmail - p.metricSuccessfulDeliveriesSMS = metricSuccessfulDeliveriesSMS - p.metricFailedDeliveriesSMS = metricFailedDeliveriesSMS projection.NotificationsProjection = p return p } @@ -177,25 +164,12 @@ func (u *userNotifier) reduceInitCodeAdded(event eventstore.Event) (*handler.Sta if err != nil { return nil, err } - - ctx, origin, err := u.queries.Origin(ctx) + ctx, err = u.queries.Origin(ctx, e) if err != nil { return nil, err } - err = types.SendEmail( - ctx, - string(template.Template), - translator, - notifyUser, - u.queries.GetSMTPConfig, - u.queries.GetFileSystemProvider, - u.queries.GetLogProvider, - colors, - u.assetsPrefix(ctx), - e, - u.metricSuccessfulDeliveriesEmail, - u.metricFailedDeliveriesEmail, - ).SendUserInitCode(notifyUser, origin, code) + err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e). + SendUserInitCode(ctx, notifyUser, code) if err != nil { return nil, err } @@ -247,25 +221,12 @@ func (u *userNotifier) reduceEmailCodeAdded(event eventstore.Event) (*handler.St if err != nil { return nil, err } - - ctx, origin, err := u.queries.Origin(ctx) + ctx, err = u.queries.Origin(ctx, e) if err != nil { return nil, err } - err = types.SendEmail( - ctx, - string(template.Template), - translator, - notifyUser, - u.queries.GetSMTPConfig, - u.queries.GetFileSystemProvider, - u.queries.GetLogProvider, - colors, - u.assetsPrefix(ctx), - e, - u.metricSuccessfulDeliveriesEmail, - u.metricFailedDeliveriesEmail, - ).SendEmailVerificationCode(notifyUser, origin, code, e.URLTemplate) + err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e). + SendEmailVerificationCode(ctx, notifyUser, code, e.URLTemplate) if err != nil { return nil, err } @@ -316,41 +277,15 @@ func (u *userNotifier) reducePasswordCodeAdded(event eventstore.Event) (*handler if err != nil { return nil, err } - - ctx, origin, err := u.queries.Origin(ctx) + ctx, err = u.queries.Origin(ctx, e) if err != nil { return nil, err } - notify := types.SendEmail( - ctx, - string(template.Template), - translator, - notifyUser, - u.queries.GetSMTPConfig, - u.queries.GetFileSystemProvider, - u.queries.GetLogProvider, - colors, - u.assetsPrefix(ctx), - e, - u.metricSuccessfulDeliveriesEmail, - u.metricFailedDeliveriesEmail, - ) + notify := types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e) if e.NotificationType == domain.NotificationTypeSms { - notify = types.SendSMSTwilio( - ctx, - translator, - notifyUser, - u.queries.GetTwilioConfig, - u.queries.GetFileSystemProvider, - u.queries.GetLogProvider, - colors, - u.assetsPrefix(ctx), - e, - u.metricSuccessfulDeliveriesSMS, - u.metricFailedDeliveriesSMS, - ) + notify = types.SendSMSTwilio(ctx, u.channels, translator, notifyUser, colors, e) } - err = notify.SendPasswordCode(notifyUser, origin, code, e.URLTemplate) + err = notify.SendPasswordCode(ctx, notifyUser, code, e.URLTemplate) if err != nil { return nil, err } @@ -437,25 +372,12 @@ func (u *userNotifier) reduceOTPSMS( if err != nil { return nil, err } - - ctx, origin, err := u.queries.Origin(ctx) + ctx, err = u.queries.Origin(ctx, event) if err != nil { return nil, err } - notify := types.SendSMSTwilio( - ctx, - translator, - notifyUser, - u.queries.GetTwilioConfig, - u.queries.GetFileSystemProvider, - u.queries.GetLogProvider, - colors, - u.assetsPrefix(ctx), - event, - u.metricSuccessfulDeliveriesSMS, - u.metricFailedDeliveriesSMS, - ) - err = notify.SendOTPSMSCode(authz.GetInstance(ctx).RequestedDomain(), origin, plainCode, expiry) + notify := types.SendSMSTwilio(ctx, u.channels, translator, notifyUser, colors, event) + err = notify.SendOTPSMSCode(ctx, plainCode, expiry) if err != nil { return nil, err } @@ -568,30 +490,16 @@ func (u *userNotifier) reduceOTPEmail( if err != nil { return nil, err } - - ctx, origin, err := u.queries.Origin(ctx) + ctx, err = u.queries.Origin(ctx, event) if err != nil { return nil, err } - url, err := urlTmpl(plainCode, origin, notifyUser) + url, err := urlTmpl(plainCode, http_util.ComposedOrigin(ctx), notifyUser) if err != nil { return nil, err } - notify := types.SendEmail( - ctx, - string(template.Template), - translator, - notifyUser, - u.queries.GetSMTPConfig, - u.queries.GetFileSystemProvider, - u.queries.GetLogProvider, - colors, - u.assetsPrefix(ctx), - event, - u.metricSuccessfulDeliveriesEmail, - u.metricFailedDeliveriesEmail, - ) - err = notify.SendOTPEmailCode(notifyUser, url, authz.GetInstance(ctx).RequestedDomain(), origin, plainCode, expiry) + notify := types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, event) + err = notify.SendOTPEmailCode(ctx, url, plainCode, expiry) if err != nil { return nil, err } @@ -634,25 +542,12 @@ func (u *userNotifier) reduceDomainClaimed(event eventstore.Event) (*handler.Sta if err != nil { return nil, err } - - ctx, origin, err := u.queries.Origin(ctx) + ctx, err = u.queries.Origin(ctx, e) if err != nil { return nil, err } - err = types.SendEmail( - ctx, - string(template.Template), - translator, - notifyUser, - u.queries.GetSMTPConfig, - u.queries.GetFileSystemProvider, - u.queries.GetLogProvider, - colors, - u.assetsPrefix(ctx), - e, - u.metricSuccessfulDeliveriesEmail, - u.metricFailedDeliveriesEmail, - ).SendDomainClaimed(notifyUser, origin, e.UserName) + err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e). + SendDomainClaimed(ctx, notifyUser, e.UserName) if err != nil { return nil, err } @@ -701,25 +596,12 @@ func (u *userNotifier) reducePasswordlessCodeRequested(event eventstore.Event) ( if err != nil { return nil, err } - - ctx, origin, err := u.queries.Origin(ctx) + ctx, err = u.queries.Origin(ctx, e) if err != nil { return nil, err } - err = types.SendEmail( - ctx, - string(template.Template), - translator, - notifyUser, - u.queries.GetSMTPConfig, - u.queries.GetFileSystemProvider, - u.queries.GetLogProvider, - colors, - u.assetsPrefix(ctx), - e, - u.metricSuccessfulDeliveriesEmail, - u.metricFailedDeliveriesEmail, - ).SendPasswordlessRegistrationLink(notifyUser, origin, code, e.ID, e.URLTemplate) + err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e). + SendPasswordlessRegistrationLink(ctx, notifyUser, code, e.ID, e.URLTemplate) if err != nil { return nil, err } @@ -771,25 +653,12 @@ func (u *userNotifier) reducePasswordChanged(event eventstore.Event) (*handler.S if err != nil { return nil, err } - - ctx, origin, err := u.queries.Origin(ctx) + ctx, err = u.queries.Origin(ctx, e) if err != nil { return nil, err } - err = types.SendEmail( - ctx, - string(template.Template), - translator, - notifyUser, - u.queries.GetSMTPConfig, - u.queries.GetFileSystemProvider, - u.queries.GetLogProvider, - colors, - u.assetsPrefix(ctx), - e, - u.metricSuccessfulDeliveriesEmail, - u.metricFailedDeliveriesEmail, - ).SendPasswordChange(notifyUser, origin) + err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e). + SendPasswordChange(ctx, notifyUser) if err != nil { return nil, err } @@ -836,24 +705,12 @@ func (u *userNotifier) reducePhoneCodeAdded(event eventstore.Event) (*handler.St if err != nil { return nil, err } - - ctx, origin, err := u.queries.Origin(ctx) + ctx, err = u.queries.Origin(ctx, e) if err != nil { return nil, err } - err = types.SendSMSTwilio( - ctx, - translator, - notifyUser, - u.queries.GetTwilioConfig, - u.queries.GetFileSystemProvider, - u.queries.GetLogProvider, - colors, - u.assetsPrefix(ctx), - e, - u.metricSuccessfulDeliveriesSMS, - u.metricFailedDeliveriesSMS, - ).SendPhoneVerificationCode(notifyUser, origin, code, authz.GetInstance(ctx).RequestedDomain()) + err = types.SendSMSTwilio(ctx, u.channels, translator, notifyUser, colors, e). + SendPhoneVerificationCode(ctx, code) if err != nil { return nil, err } diff --git a/internal/notification/handlers/user_notifier_test.go b/internal/notification/handlers/user_notifier_test.go new file mode 100644 index 0000000000..db3569ad2b --- /dev/null +++ b/internal/notification/handlers/user_notifier_test.go @@ -0,0 +1,1361 @@ +package handlers + +import ( + "context" + "database/sql" + "fmt" + "net/http" + "testing" + "time" + + "github.com/zitadel/zitadel/internal/repository/session" + + "github.com/zitadel/zitadel/internal/notification/messages" + + "github.com/golang/mock/gomock" + statik_fs "github.com/rakyll/statik/fs" + "github.com/stretchr/testify/assert" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/repository" + es_repo_mock "github.com/zitadel/zitadel/internal/eventstore/repository/mock" + channel_mock "github.com/zitadel/zitadel/internal/notification/channels/mock" + "github.com/zitadel/zitadel/internal/notification/channels/smtp" + "github.com/zitadel/zitadel/internal/notification/channels/twilio" + "github.com/zitadel/zitadel/internal/notification/channels/webhook" + "github.com/zitadel/zitadel/internal/notification/handlers/mock" + "github.com/zitadel/zitadel/internal/notification/senders" + "github.com/zitadel/zitadel/internal/notification/types" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/repository/user" +) + +const ( + orgID = "org1" + policyID = "policy1" + userID = "user1" + codeID = "event1" + logoURL = "logo.png" + eventOrigin = "https://triggered.here" + assetsPath = "/assets/v1" + preferredLoginName = "loginName1" + lastEmail = "last@email.com" + verifiedEmail = "verified@email.com" + instancePrimaryDomain = "primary.domain" + externalDomain = "external.domain" + externalPort = 3000 + externalSecure = false + externalProtocol = "http" + defaultOTPEmailTemplate = "/otp/verify?loginName={{.LoginName}}&code={{.Code}}" +) + +func Test_userNotifier_reduceInitCodeAdded(t *testing.T) { + expectMailSubject := "Initialize User" + tests := []struct { + name string + test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fields, args, want) + }{{ + name: "asset url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, "testcode") + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().HumanInitCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanInitialCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "asset url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, "testcode") + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().HumanInitCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanInitialCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + }, + }, w + }, + }, { + name: "button url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.URL}}" + testCode := "testcode" + expectContent := fmt.Sprintf("%s/ui/login/user/init?userID=%s&loginname=%s&code=%s&orgID=%s&passwordset=%t", eventOrigin, userID, preferredLoginName, testCode, orgID, false) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().HumanInitCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanInitialCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "button url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.URL}}" + testCode := "testcode" + expectContent := fmt.Sprintf("%s://%s:%d/ui/login/user/init?userID=%s&loginname=%s&code=%s&orgID=%s&passwordset=%t", externalProtocol, instancePrimaryDomain, externalPort, userID, preferredLoginName, testCode, orgID, false) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().HumanInitCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanInitialCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + }, + }, w + }, + }} + // TODO: Why don't we have an url template on user.HumanInitialCodeAddedEvent? + fs, err := statik_fs.NewWithNamespace("notification") + assert.NoError(t, err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + queries := mock.NewMockQueries(ctrl) + commands := mock.NewMockCommands(ctrl) + f, a, w := tt.test(ctrl, queries, commands) + _, err = newUserNotifier(t, ctrl, queries, fs, f, a, w).reduceInitCodeAdded(a.event) + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func Test_userNotifier_reduceEmailCodeAdded(t *testing.T) { + expectMailSubject := "Verify email" + tests := []struct { + name string + test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fields, args, want) + }{{ + name: "asset url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, "testcode") + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().HumanEmailVerificationCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanEmailCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "asset url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, "testcode") + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().HumanEmailVerificationCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanEmailCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + }, + }, w + }, + }, { + name: "button url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.URL}}" + testCode := "testcode" + expectContent := fmt.Sprintf("%s/ui/login/mail/verification?userID=%s&code=%s&orgID=%s", eventOrigin, userID, testCode, orgID) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().HumanEmailVerificationCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + SMSTokenCrypto: nil, + }, args{ + event: &user.HumanEmailCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "button url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.URL}}" + testCode := "testcode" + expectContent := fmt.Sprintf("%s://%s:%d/ui/login/mail/verification?userID=%s&code=%s&orgID=%s", externalProtocol, instancePrimaryDomain, externalPort, userID, testCode, orgID) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().HumanEmailVerificationCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanEmailCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + }, + }, w + }, + }, { + name: "button url with url template and event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.URL}}" + urlTemplate := "https://my.custom.url/org/{{.OrgID}}/user/{{.UserID}}/verify/{{.Code}}" + testCode := "testcode" + expectContent := fmt.Sprintf("https://my.custom.url/org/%s/user/%s/verify/%s", orgID, userID, testCode) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().HumanEmailVerificationCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + SMSTokenCrypto: nil, + }, args{ + event: &user.HumanEmailCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + URLTemplate: urlTemplate, + CodeReturned: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }} + fs, err := statik_fs.NewWithNamespace("notification") + assert.NoError(t, err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + queries := mock.NewMockQueries(ctrl) + commands := mock.NewMockCommands(ctrl) + f, a, w := tt.test(ctrl, queries, commands) + _, err = newUserNotifier(t, ctrl, queries, fs, f, a, w).reduceEmailCodeAdded(a.event) + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func Test_userNotifier_reducePasswordCodeAdded(t *testing.T) { + expectMailSubject := "Reset password" + tests := []struct { + name string + test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fields, args, want) + }{{ + name: "asset url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, "testcode") + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanPasswordCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "asset url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, "testcode") + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanPasswordCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + }, + }, w + }, + }, { + name: "button url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.URL}}" + testCode := "testcode" + expectContent := fmt.Sprintf("%s/ui/login/password/init?userID=%s&code=%s&orgID=%s", eventOrigin, userID, testCode, orgID) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + SMSTokenCrypto: nil, + }, args{ + event: &user.HumanPasswordCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "button url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.URL}}" + testCode := "testcode" + expectContent := fmt.Sprintf("%s://%s:%d/ui/login/password/init?userID=%s&code=%s&orgID=%s", externalProtocol, instancePrimaryDomain, externalPort, userID, testCode, orgID) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanPasswordCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + }, + }, w + }, + }, { + name: "button url with url template and event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.URL}}" + urlTemplate := "https://my.custom.url/org/{{.OrgID}}/user/{{.UserID}}/verify/{{.Code}}" + testCode := "testcode" + expectContent := fmt.Sprintf("https://my.custom.url/org/%s/user/%s/verify/%s", orgID, userID, testCode) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + SMSTokenCrypto: nil, + }, args{ + event: &user.HumanPasswordCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + URLTemplate: urlTemplate, + CodeReturned: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }} + fs, err := statik_fs.NewWithNamespace("notification") + assert.NoError(t, err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + queries := mock.NewMockQueries(ctrl) + commands := mock.NewMockCommands(ctrl) + f, a, w := tt.test(ctrl, queries, commands) + _, err = newUserNotifier(t, ctrl, queries, fs, f, a, w).reducePasswordCodeAdded(a.event) + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func Test_userNotifier_reduceDomainClaimed(t *testing.T) { + expectMailSubject := "Domain has been claimed" + tests := []struct { + name string + test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fields, args, want) + }{{ + name: "asset url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().UserDomainClaimedSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + }, args{ + event: &user.DomainClaimedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "asset url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().UserDomainClaimedSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + }, args{ + event: &user.DomainClaimedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + }, + }, w + }, + }} + fs, err := statik_fs.NewWithNamespace("notification") + assert.NoError(t, err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + queries := mock.NewMockQueries(ctrl) + commands := mock.NewMockCommands(ctrl) + f, a, w := tt.test(ctrl, queries, commands) + _, err = newUserNotifier(t, ctrl, queries, fs, f, a, w).reduceDomainClaimed(a.event) + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func Test_userNotifier_reducePasswordlessCodeRequested(t *testing.T) { + expectMailSubject := "Add Passwordless Login" + tests := []struct { + name string + test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fields, args, want) + }{{ + name: "asset url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, "testcode") + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().HumanPasswordlessInitCodeSent(gomock.Any(), userID, orgID, codeID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanPasswordlessInitCodeRequestedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + ID: codeID, + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "asset url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, "testcode") + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().HumanPasswordlessInitCodeSent(gomock.Any(), userID, orgID, codeID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanPasswordlessInitCodeRequestedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + ID: codeID, + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + }, + }, w + }, + }, { + name: "button url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.URL}}" + testCode := "testcode" + codeAlg, code := cryptoValue(t, ctrl, testCode) + expectContent := fmt.Sprintf("%s/ui/login/login/passwordless/init?userID=%s&orgID=%s&codeID=%s&code=%s", eventOrigin, userID, orgID, codeID, testCode) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().HumanPasswordlessInitCodeSent(gomock.Any(), userID, orgID, codeID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + SMSTokenCrypto: nil, + }, args{ + event: &user.HumanPasswordlessInitCodeRequestedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + ID: codeID, + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "button url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.URL}}" + testCode := "testcode" + codeAlg, code := cryptoValue(t, ctrl, testCode) + expectContent := fmt.Sprintf("%s://%s:%d/ui/login/login/passwordless/init?userID=%s&orgID=%s&codeID=%s&code=%s", externalProtocol, instancePrimaryDomain, externalPort, userID, orgID, codeID, testCode) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().HumanPasswordlessInitCodeSent(gomock.Any(), userID, orgID, codeID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanPasswordlessInitCodeRequestedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + ID: codeID, + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + }, + }, w + }, + }, { + name: "button url with url template and event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.URL}}" + urlTemplate := "https://my.custom.url/org/{{.OrgID}}/user/{{.UserID}}/verify/{{.Code}}" + testCode := "testcode" + expectContent := fmt.Sprintf("https://my.custom.url/org/%s/user/%s/verify/%s", orgID, userID, testCode) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().HumanPasswordlessInitCodeSent(gomock.Any(), userID, orgID, codeID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + SMSTokenCrypto: nil, + }, args{ + event: &user.HumanPasswordlessInitCodeRequestedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + ID: codeID, + Code: code, + Expiry: time.Hour, + URLTemplate: urlTemplate, + CodeReturned: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }} + fs, err := statik_fs.NewWithNamespace("notification") + assert.NoError(t, err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + queries := mock.NewMockQueries(ctrl) + commands := mock.NewMockCommands(ctrl) + f, a, w := tt.test(ctrl, queries, commands) + _, err = newUserNotifier(t, ctrl, queries, fs, f, a, w).reducePasswordlessCodeRequested(a.event) + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func Test_userNotifier_reducePasswordChanged(t *testing.T) { + expectMailSubject := "Password of user has changed" + tests := []struct { + name string + test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fields, args, want) + }{{ + name: "asset url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + queries.EXPECT().NotificationPolicyByOrg(gomock.Any(), gomock.Any(), orgID, gomock.Any()).Return(&query.NotificationPolicy{ + PasswordChange: true, + }, nil) + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().PasswordChangeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + }, args{ + event: &user.HumanPasswordChangedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "asset url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) + w.message = messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + queries.EXPECT().NotificationPolicyByOrg(gomock.Any(), gomock.Any(), orgID, gomock.Any()).Return(&query.NotificationPolicy{ + PasswordChange: true, + }, nil) + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().PasswordChangeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + }, args{ + event: &user.HumanPasswordChangedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + }, + }, w + }, + }} + fs, err := statik_fs.NewWithNamespace("notification") + assert.NoError(t, err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + queries := mock.NewMockQueries(ctrl) + commands := mock.NewMockCommands(ctrl) + f, a, w := tt.test(ctrl, queries, commands) + _, err = newUserNotifier(t, ctrl, queries, fs, f, a, w).reducePasswordChanged(a.event) + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func Test_userNotifier_reduceOTPEmailChallenged(t *testing.T) { + expectMailSubject := "Verify One-Time Password" + tests := []struct { + name string + test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fields, args, want) + }{{ + name: "asset url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) + w.message = messages.Email{ + Recipients: []string{verifiedEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, "testcode") + expectTemplateQueries(queries, givenTemplate) + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) + commands.EXPECT().OTPEmailSent(gomock.Any(), userID, orgID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + }, args{ + event: &session.OTPEmailChallengedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + URLTmpl: "", + ReturnCode: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "asset url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) + w.message = messages.Email{ + Recipients: []string{verifiedEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, "testcode") + expectTemplateQueries(queries, givenTemplate) + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + commands.EXPECT().OTPEmailSent(gomock.Any(), userID, orgID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + }, args{ + event: &session.OTPEmailChallengedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + URLTmpl: "", + ReturnCode: false, + }, + }, w + }, + }, { + name: "button url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.URL}}" + testCode := "testcode" + expectContent := fmt.Sprintf("%s/otp/verify?loginName=%s&code=%s", eventOrigin, preferredLoginName, testCode) + w.message = messages.Email{ + Recipients: []string{verifiedEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + expectTemplateQueries(queries, givenTemplate) + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) + commands.EXPECT().OTPEmailSent(gomock.Any(), userID, orgID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + SMSTokenCrypto: nil, + }, args{ + event: &session.OTPEmailChallengedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + URLTmpl: "", + ReturnCode: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "button url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.URL}}" + testCode := "testcode" + expectContent := fmt.Sprintf("%s://%s:%d/otp/verify?loginName=%s&code=%s", externalProtocol, instancePrimaryDomain, externalPort, preferredLoginName, testCode) + w.message = messages.Email{ + Recipients: []string{verifiedEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + expectTemplateQueries(queries, givenTemplate) + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) + commands.EXPECT().OTPEmailSent(gomock.Any(), userID, orgID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + }, args{ + event: &session.OTPEmailChallengedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + ReturnCode: false, + }, + }, w + }, + }, { + name: "button url with url template and event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.URL}}" + urlTemplate := "https://my.custom.url/user/{{.LoginName}}/verify" + testCode := "testcode" + expectContent := fmt.Sprintf("https://my.custom.url/user/%s/verify", preferredLoginName) + w.message = messages.Email{ + Recipients: []string{verifiedEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + expectTemplateQueries(queries, givenTemplate) + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) + commands.EXPECT().OTPEmailSent(gomock.Any(), userID, orgID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(eventstore.TestConfig( + es_repo_mock.NewRepo(t).ExpectFilterEvents(), + )), + userDataCrypto: codeAlg, + SMSTokenCrypto: nil, + }, args{ + event: &session.OTPEmailChallengedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + ReturnCode: false, + URLTmpl: urlTemplate, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }} + fs, err := statik_fs.NewWithNamespace("notification") + assert.NoError(t, err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + queries := mock.NewMockQueries(ctrl) + commands := mock.NewMockCommands(ctrl) + f, a, w := tt.test(ctrl, queries, commands) + _, err = newUserNotifier(t, ctrl, queries, fs, f, a, w).reduceSessionOTPEmailChallenged(a.event) + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +type fields struct { + queries *mock.MockQueries + commands *mock.MockCommands + es *eventstore.Eventstore + userDataCrypto crypto.EncryptionAlgorithm + SMSTokenCrypto crypto.EncryptionAlgorithm +} +type args struct { + event eventstore.Event +} +type want struct { + message messages.Email + err assert.ErrorAssertionFunc +} + +func newUserNotifier(t *testing.T, ctrl *gomock.Controller, queries *mock.MockQueries, fs http.FileSystem, f fields, a args, w want) *userNotifier { + queries.EXPECT().NotificationProviderByIDAndType(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes().Return(&query.DebugNotificationProvider{}, nil) + smtpAlg, _ := cryptoValue(t, ctrl, "smtppw") + channel := channel_mock.NewMockNotificationChannel(ctrl) + if w.err == nil { + w.message.TriggeringEvent = a.event + channel.EXPECT().HandleMessage(&w.message).Return(nil) + } + return &userNotifier{ + commands: f.commands, + queries: NewNotificationQueries( + f.queries, + f.es, + externalDomain, + externalPort, + externalSecure, + "", + f.userDataCrypto, + smtpAlg, + f.SMSTokenCrypto, + fs, + ), + otpEmailTmpl: defaultOTPEmailTemplate, + channels: &channels{Chain: *senders.ChainChannels(channel)}, + } +} + +var _ types.ChannelChains = (*channels)(nil) + +type channels struct { + senders.Chain +} + +func (c *channels) Email(context.Context) (*senders.Chain, *smtp.Config, error) { + return &c.Chain, nil, nil +} + +func (c *channels) SMS(context.Context) (*senders.Chain, *twilio.Config, error) { + return &c.Chain, nil, nil +} + +func (c *channels) Webhook(context.Context, webhook.Config) (*senders.Chain, error) { + return &c.Chain, nil +} + +func expectTemplateQueries(queries *mock.MockQueries, template string) { + queries.EXPECT().ActiveLabelPolicyByOrg(gomock.Any(), gomock.Any(), gomock.Any()).Return(&query.LabelPolicy{ + ID: policyID, + Light: query.Theme{ + LogoURL: logoURL, + }, + }, nil) + queries.EXPECT().MailTemplateByOrg(gomock.Any(), gomock.Any(), gomock.Any()).Return(&query.MailTemplate{Template: []byte(template)}, nil) + queries.EXPECT().GetNotifyUserByID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(&query.NotifyUser{ + ID: userID, + ResourceOwner: orgID, + LastEmail: lastEmail, + VerifiedEmail: verifiedEmail, + PreferredLoginName: preferredLoginName, + }, nil) + queries.EXPECT().GetDefaultLanguage(gomock.Any()).Return(language.English) + queries.EXPECT().CustomTextListByTemplate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(2).Return(&query.CustomTexts{}, nil) +} + +func cryptoValue(t *testing.T, ctrl *gomock.Controller, value string) (*crypto.MockEncryptionAlgorithm, *crypto.CryptoValue) { + encAlg := crypto.NewMockEncryptionAlgorithm(ctrl) + encAlg.EXPECT().Algorithm().AnyTimes().Return("enc") + encAlg.EXPECT().EncryptionKeyID().AnyTimes().Return("id") + encAlg.EXPECT().DecryptionKeyIDs().AnyTimes().Return([]string{"id"}) + encAlg.EXPECT().DecryptString(gomock.Any(), gomock.Any()).AnyTimes().Return(value, nil) + encAlg.EXPECT().Encrypt(gomock.Any()).AnyTimes().Return(make([]byte, 0), nil) + code, err := crypto.Encrypt(nil, encAlg) + assert.NoError(t, err) + return encAlg, code +} diff --git a/internal/notification/projections.go b/internal/notification/projections.go index 380775254f..f647a8c41b 100644 --- a/internal/notification/projections.go +++ b/internal/notification/projections.go @@ -13,16 +13,6 @@ import ( _ "github.com/zitadel/zitadel/internal/notification/statik" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/query/projection" - "github.com/zitadel/zitadel/internal/telemetry/metrics" -) - -const ( - metricSuccessfulDeliveriesEmail = "successful_deliveries_email" - metricFailedDeliveriesEmail = "failed_deliveries_email" - metricSuccessfulDeliveriesSMS = "successful_deliveries_sms" - metricFailedDeliveriesSMS = "failed_deliveries_sms" - metricSuccessfulDeliveriesJSON = "successful_deliveries_json" - metricFailedDeliveriesJSON = "failed_deliveries_json" ) func Start( @@ -35,55 +25,17 @@ func Start( commands *command.Commands, queries *query.Queries, es *eventstore.Eventstore, - assetsPrefix func(context.Context) string, otpEmailTmpl string, fileSystemPath string, userEncryption, smtpEncryption, smsEncryption crypto.EncryptionAlgorithm, ) { statikFS, err := statik_fs.NewWithNamespace("notification") logging.OnError(err).Panic("unable to start listener") - err = metrics.RegisterCounter(metricSuccessfulDeliveriesEmail, "Successfully delivered emails") - logging.WithFields("metric", metricSuccessfulDeliveriesEmail).OnError(err).Panic("unable to register counter") - err = metrics.RegisterCounter(metricFailedDeliveriesEmail, "Failed email deliveries") - logging.WithFields("metric", metricFailedDeliveriesEmail).OnError(err).Panic("unable to register counter") - err = metrics.RegisterCounter(metricSuccessfulDeliveriesSMS, "Successfully delivered SMS") - logging.WithFields("metric", metricSuccessfulDeliveriesSMS).OnError(err).Panic("unable to register counter") - err = metrics.RegisterCounter(metricFailedDeliveriesSMS, "Failed SMS deliveries") - logging.WithFields("metric", metricFailedDeliveriesSMS).OnError(err).Panic("unable to register counter") - err = metrics.RegisterCounter(metricSuccessfulDeliveriesJSON, "Successfully delivered JSON messages") - logging.WithFields("metric", metricSuccessfulDeliveriesJSON).OnError(err).Panic("unable to register counter") - err = metrics.RegisterCounter(metricFailedDeliveriesJSON, "Failed JSON message deliveries") - logging.WithFields("metric", metricFailedDeliveriesJSON).OnError(err).Panic("unable to register counter") q := handlers.NewNotificationQueries(queries, es, externalDomain, externalPort, externalSecure, fileSystemPath, userEncryption, smtpEncryption, smsEncryption, statikFS) - handlers.NewUserNotifier( - ctx, - projection.ApplyCustomConfig(userHandlerCustomConfig), - commands, - q, - assetsPrefix, - otpEmailTmpl, - metricSuccessfulDeliveriesEmail, - metricFailedDeliveriesEmail, - metricSuccessfulDeliveriesSMS, - metricFailedDeliveriesSMS, - ).Start() - handlers.NewQuotaNotifier( - ctx, - projection.ApplyCustomConfig(quotaHandlerCustomConfig), - commands, - q, - metricSuccessfulDeliveriesJSON, - metricFailedDeliveriesJSON, - ).Start() + c := newChannels(q) + handlers.NewUserNotifier(ctx, projection.ApplyCustomConfig(userHandlerCustomConfig), commands, q, c, otpEmailTmpl).Start() + handlers.NewQuotaNotifier(ctx, projection.ApplyCustomConfig(quotaHandlerCustomConfig), commands, q, c).Start() if telemetryCfg.Enabled { - handlers.NewTelemetryPusher( - ctx, - telemetryCfg, - projection.ApplyCustomConfig(telemetryHandlerCustomConfig), - commands, - q, - metricSuccessfulDeliveriesJSON, - metricFailedDeliveriesJSON, - ).Start() + handlers.NewTelemetryPusher(ctx, telemetryCfg, projection.ApplyCustomConfig(telemetryHandlerCustomConfig), commands, q, c).Start() } } diff --git a/internal/notification/senders/chain.go b/internal/notification/senders/chain.go index 8241bc0523..626437f0f8 100644 --- a/internal/notification/senders/chain.go +++ b/internal/notification/senders/chain.go @@ -8,12 +8,12 @@ type Chain struct { channels []channels.NotificationChannel } -func chainChannels(channel ...channels.NotificationChannel) *Chain { +func ChainChannels(channel ...channels.NotificationChannel) *Chain { return &Chain{channels: channel} } // HandleMessage returns a non nil error from a provider immediately if any occurs -// messages are sent to channels in the same order they were provided to chainChannels() +// messages are sent to channels in the same order they were provided to ChainChannels() func (c *Chain) HandleMessage(message channels.Message) error { for i := range c.channels { if err := c.channels[i].HandleMessage(message); err != nil { diff --git a/internal/notification/senders/email.go b/internal/notification/senders/email.go index ddaa5a5c2c..ea93c0911e 100644 --- a/internal/notification/senders/email.go +++ b/internal/notification/senders/email.go @@ -17,14 +17,14 @@ const smtpSpanName = "smtp.NotificationChannel" func EmailChannels( ctx context.Context, - emailConfig func(ctx context.Context) (*smtp.Config, error), + emailConfig *smtp.Config, getFileSystemProvider func(ctx context.Context) (*fs.Config, error), getLogProvider func(ctx context.Context) (*log.Config, error), successMetricName, failureMetricName string, ) (chain *Chain, err error) { channels := make([]channels.NotificationChannel, 0, 3) - p, err := smtp.InitChannel(ctx, emailConfig) + p, err := smtp.InitChannel(emailConfig) logging.WithFields( "instance", authz.GetInstance(ctx).InstanceID(), ).OnError(err).Debug("initializing SMTP channel failed") @@ -41,5 +41,5 @@ func EmailChannels( ) } channels = append(channels, debugChannels(ctx, getFileSystemProvider, getLogProvider)...) - return chainChannels(channels...), nil + return ChainChannels(channels...), nil } diff --git a/internal/notification/senders/sms.go b/internal/notification/senders/sms.go index 22d4a47202..361fc56509 100644 --- a/internal/notification/senders/sms.go +++ b/internal/notification/senders/sms.go @@ -34,5 +34,5 @@ func SMSChannels( ) } channels = append(channels, debugChannels(ctx, getFileSystemProvider, getLogProvider)...) - return chainChannels(channels...), nil + return ChainChannels(channels...), nil } diff --git a/internal/notification/senders/json.go b/internal/notification/senders/webhook.go similarity index 95% rename from internal/notification/senders/json.go rename to internal/notification/senders/webhook.go index 73f6a92ac8..d677f9a80e 100644 --- a/internal/notification/senders/json.go +++ b/internal/notification/senders/webhook.go @@ -15,7 +15,7 @@ import ( const webhookSpanName = "webhook.NotificationChannel" -func JSONChannels( +func WebhookChannels( ctx context.Context, webhookConfig webhook.Config, getFileSystemProvider func(ctx context.Context) (*fs.Config, error), @@ -45,5 +45,5 @@ func JSONChannels( ) } channels = append(channels, debugChannels(ctx, getFileSystemProvider, getLogProvider)...) - return chainChannels(channels...), nil + return ChainChannels(channels...), nil } diff --git a/internal/notification/types/domain_claimed.go b/internal/notification/types/domain_claimed.go index 69ab8413ca..975cc74db4 100644 --- a/internal/notification/types/domain_claimed.go +++ b/internal/notification/types/domain_claimed.go @@ -1,15 +1,17 @@ package types import ( + "context" "strings" + http_utils "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/ui/login" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" ) -func (notify Notify) SendDomainClaimed(user *query.NotifyUser, origin, username string) error { - url := login.LoginLink(origin, user.ResourceOwner) +func (notify Notify) SendDomainClaimed(ctx context.Context, user *query.NotifyUser, username string) error { + url := login.LoginLink(http_utils.ComposedOrigin(ctx), user.ResourceOwner) index := strings.LastIndex(user.LastEmail, "@") args := make(map[string]interface{}) args["TempUsername"] = username diff --git a/internal/notification/types/email_verification_code.go b/internal/notification/types/email_verification_code.go index 112da75ddf..b2f34acd6c 100644 --- a/internal/notification/types/email_verification_code.go +++ b/internal/notification/types/email_verification_code.go @@ -1,17 +1,19 @@ package types import ( + "context" "strings" + http_utils "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/ui/login" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" ) -func (notify Notify) SendEmailVerificationCode(user *query.NotifyUser, origin, code string, urlTmpl string) error { +func (notify Notify) SendEmailVerificationCode(ctx context.Context, user *query.NotifyUser, code string, urlTmpl string) error { var url string if urlTmpl == "" { - url = login.MailVerificationLink(origin, user.ID, code, user.ResourceOwner) + url = login.MailVerificationLink(http_utils.ComposedOrigin(ctx), user.ID, code, user.ResourceOwner) } else { var buf strings.Builder if err := domain.RenderConfirmURLTemplate(&buf, urlTmpl, user.ID, code, user.ResourceOwner); err != nil { diff --git a/internal/notification/types/email_verification_code_test.go b/internal/notification/types/email_verification_code_test.go index 71cebaca58..ab1b4e1810 100644 --- a/internal/notification/types/email_verification_code_test.go +++ b/internal/notification/types/email_verification_code_test.go @@ -1,11 +1,13 @@ package types import ( + "context" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + http_utils "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/domain" caos_errs "github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/query" @@ -78,7 +80,7 @@ func TestNotify_SendEmailVerificationCode(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, notify := mockNotify() - err := notify.SendEmailVerificationCode(tt.args.user, tt.args.origin, tt.args.code, tt.args.urlTmpl) + err := notify.SendEmailVerificationCode(http_utils.WithComposedOrigin(context.Background(), tt.args.origin), tt.args.user, tt.args.code, tt.args.urlTmpl) require.ErrorIs(t, err, tt.wantErr) assert.Equal(t, tt.want, got) }) diff --git a/internal/notification/types/init_code.go b/internal/notification/types/init_code.go index 10d68cf346..0e30e991d0 100644 --- a/internal/notification/types/init_code.go +++ b/internal/notification/types/init_code.go @@ -1,13 +1,16 @@ package types import ( + "context" + + http_utils "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/ui/login" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" ) -func (notify Notify) SendUserInitCode(user *query.NotifyUser, origin, code string) error { - url := login.InitUserLink(origin, user.ID, user.PreferredLoginName, code, user.ResourceOwner, user.PasswordSet) +func (notify Notify) SendUserInitCode(ctx context.Context, user *query.NotifyUser, code string) error { + url := login.InitUserLink(http_utils.ComposedOrigin(ctx), user.ID, user.PreferredLoginName, code, user.ResourceOwner, user.PasswordSet) args := make(map[string]interface{}) args["Code"] = code return notify(url, args, domain.InitCodeMessageType, true) diff --git a/internal/notification/types/json.go b/internal/notification/types/json.go deleted file mode 100644 index 8c5e181661..0000000000 --- a/internal/notification/types/json.go +++ /dev/null @@ -1,40 +0,0 @@ -package types - -import ( - "context" - - "github.com/zitadel/zitadel/internal/eventstore" - "github.com/zitadel/zitadel/internal/notification/channels/fs" - "github.com/zitadel/zitadel/internal/notification/channels/log" - "github.com/zitadel/zitadel/internal/notification/channels/webhook" - "github.com/zitadel/zitadel/internal/notification/messages" - "github.com/zitadel/zitadel/internal/notification/senders" -) - -func handleJSON( - ctx context.Context, - webhookConfig webhook.Config, - getFileSystemProvider func(ctx context.Context) (*fs.Config, error), - getLogProvider func(ctx context.Context) (*log.Config, error), - serializable interface{}, - triggeringEvent eventstore.Event, - successMetricName, - failureMetricName string, -) error { - message := &messages.JSON{ - Serializable: serializable, - TriggeringEvent: triggeringEvent, - } - channelChain, err := senders.JSONChannels( - ctx, - webhookConfig, - getFileSystemProvider, - getLogProvider, - successMetricName, - failureMetricName, - ) - if err != nil { - return err - } - return channelChain.HandleMessage(message) -} diff --git a/internal/notification/types/notification.go b/internal/notification/types/notification.go index 7c4fc3c14b..1a5ccebb21 100644 --- a/internal/notification/types/notification.go +++ b/internal/notification/types/notification.go @@ -5,11 +5,10 @@ import ( "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/i18n" - "github.com/zitadel/zitadel/internal/notification/channels/fs" - "github.com/zitadel/zitadel/internal/notification/channels/log" "github.com/zitadel/zitadel/internal/notification/channels/smtp" "github.com/zitadel/zitadel/internal/notification/channels/twilio" "github.com/zitadel/zitadel/internal/notification/channels/webhook" + "github.com/zitadel/zitadel/internal/notification/senders" "github.com/zitadel/zitadel/internal/notification/templates" "github.com/zitadel/zitadel/internal/query" ) @@ -21,19 +20,20 @@ type Notify func( allowUnverifiedNotificationChannel bool, ) error +type ChannelChains interface { + Email(context.Context) (*senders.Chain, *smtp.Config, error) + SMS(context.Context) (*senders.Chain, *twilio.Config, error) + Webhook(context.Context, webhook.Config) (*senders.Chain, error) +} + func SendEmail( ctx context.Context, + channels ChannelChains, mailhtml string, translator *i18n.Translator, user *query.NotifyUser, - emailConfig func(ctx context.Context) (*smtp.Config, error), - getFileSystemProvider func(ctx context.Context) (*fs.Config, error), - getLogProvider func(ctx context.Context) (*log.Config, error), colors *query.LabelPolicy, - assetsPrefix string, triggeringEvent eventstore.Event, - successMetricName, - failureMetricName string, ) Notify { return func( url string, @@ -42,39 +42,30 @@ func SendEmail( allowUnverifiedNotificationChannel bool, ) error { args = mapNotifyUserToArgs(user, args) - data := GetTemplateData(translator, args, assetsPrefix, url, messageType, user.PreferredLanguage.String(), colors) + data := GetTemplateData(ctx, translator, args, url, messageType, user.PreferredLanguage.String(), colors) template, err := templates.GetParsedTemplate(mailhtml, data) if err != nil { return err } return generateEmail( ctx, + channels, user, data.Subject, template, - emailConfig, - getFileSystemProvider, - getLogProvider, allowUnverifiedNotificationChannel, triggeringEvent, - successMetricName, - failureMetricName, ) } } func SendSMSTwilio( ctx context.Context, + channels ChannelChains, translator *i18n.Translator, user *query.NotifyUser, - twilioConfig func(ctx context.Context) (*twilio.Config, error), - getFileSystemProvider func(ctx context.Context) (*fs.Config, error), - getLogProvider func(ctx context.Context) (*log.Config, error), colors *query.LabelPolicy, - assetsPrefix string, triggeringEvent eventstore.Event, - successMetricName, - failureMetricName string, ) Notify { return func( url string, @@ -83,18 +74,14 @@ func SendSMSTwilio( allowUnverifiedNotificationChannel bool, ) error { args = mapNotifyUserToArgs(user, args) - data := GetTemplateData(translator, args, assetsPrefix, url, messageType, user.PreferredLanguage.String(), colors) + data := GetTemplateData(ctx, translator, args, url, messageType, user.PreferredLanguage.String(), colors) return generateSms( ctx, + channels, user, data.Text, - twilioConfig, - getFileSystemProvider, - getLogProvider, allowUnverifiedNotificationChannel, triggeringEvent, - successMetricName, - failureMetricName, ) } } @@ -102,23 +89,17 @@ func SendSMSTwilio( func SendJSON( ctx context.Context, webhookConfig webhook.Config, - getFileSystemProvider func(ctx context.Context) (*fs.Config, error), - getLogProvider func(ctx context.Context) (*log.Config, error), + channels ChannelChains, serializable interface{}, triggeringEvent eventstore.Event, - successMetricName, - failureMetricName string, ) Notify { return func(_ string, _ map[string]interface{}, _ string, _ bool) error { - return handleJSON( + return handleWebhook( ctx, webhookConfig, - getFileSystemProvider, - getLogProvider, + channels, serializable, triggeringEvent, - successMetricName, - failureMetricName, ) } } diff --git a/internal/notification/types/otp.go b/internal/notification/types/otp.go index 2079ecce01..7919984f7b 100644 --- a/internal/notification/types/otp.go +++ b/internal/notification/types/otp.go @@ -1,27 +1,31 @@ package types import ( + "context" "time" + http_utils "github.com/zitadel/zitadel/internal/api/http" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/query" ) -func (notify Notify) SendOTPSMSCode(requestedDomain, origin, code string, expiry time.Duration) error { - args := otpArgs(code, origin, requestedDomain, expiry) +func (notify Notify) SendOTPSMSCode(ctx context.Context, code string, expiry time.Duration) error { + args := otpArgs(ctx, code, expiry) return notify("", args, domain.VerifySMSOTPMessageType, false) } -func (notify Notify) SendOTPEmailCode(user *query.NotifyUser, url, requestedDomain, origin, code string, expiry time.Duration) error { - args := otpArgs(code, origin, requestedDomain, expiry) +func (notify Notify) SendOTPEmailCode(ctx context.Context, url, code string, expiry time.Duration) error { + args := otpArgs(ctx, code, expiry) return notify(url, args, domain.VerifyEmailOTPMessageType, false) } -func otpArgs(code, origin, requestedDomain string, expiry time.Duration) map[string]interface{} { +func otpArgs(ctx context.Context, code string, expiry time.Duration) map[string]interface{} { args := make(map[string]interface{}) args["OTP"] = code - args["Origin"] = origin - args["Domain"] = requestedDomain + args["Origin"] = http_utils.ComposedOrigin(ctx) + args["Domain"] = authz.GetInstance(ctx).RequestedDomain() args["Expiry"] = expiry return args } diff --git a/internal/notification/types/password_change.go b/internal/notification/types/password_change.go index 7b963c2d09..61483f0471 100644 --- a/internal/notification/types/password_change.go +++ b/internal/notification/types/password_change.go @@ -1,13 +1,16 @@ package types import ( + "context" + + http_utils "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/ui/console" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" ) -func (notify Notify) SendPasswordChange(user *query.NotifyUser, origin string) error { - url := console.LoginHintLink(origin, user.PreferredLoginName) +func (notify Notify) SendPasswordChange(ctx context.Context, user *query.NotifyUser) error { + url := console.LoginHintLink(http_utils.ComposedOrigin(ctx), user.PreferredLoginName) args := make(map[string]interface{}) return notify(url, args, domain.PasswordChangeMessageType, true) } diff --git a/internal/notification/types/password_code.go b/internal/notification/types/password_code.go index 2285ba4395..b1121f9d14 100644 --- a/internal/notification/types/password_code.go +++ b/internal/notification/types/password_code.go @@ -1,17 +1,19 @@ package types import ( + "context" "strings" + http_utils "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/ui/login" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" ) -func (notify Notify) SendPasswordCode(user *query.NotifyUser, origin, code, urlTmpl string) error { +func (notify Notify) SendPasswordCode(ctx context.Context, user *query.NotifyUser, code, urlTmpl string) error { var url string if urlTmpl == "" { - url = login.InitPasswordLink(origin, user.ID, code, user.ResourceOwner) + url = login.InitPasswordLink(http_utils.ComposedOrigin(ctx), user.ID, code, user.ResourceOwner) } else { var buf strings.Builder if err := domain.RenderConfirmURLTemplate(&buf, urlTmpl, user.ID, code, user.ResourceOwner); err != nil { diff --git a/internal/notification/types/passwordless_registration_link.go b/internal/notification/types/passwordless_registration_link.go index 7f4ab00402..940aeaec0a 100644 --- a/internal/notification/types/passwordless_registration_link.go +++ b/internal/notification/types/passwordless_registration_link.go @@ -1,17 +1,19 @@ package types import ( + "context" "strings" + http_utils "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/ui/login" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" ) -func (notify Notify) SendPasswordlessRegistrationLink(user *query.NotifyUser, origin, code, codeID, urlTmpl string) error { +func (notify Notify) SendPasswordlessRegistrationLink(ctx context.Context, user *query.NotifyUser, code, codeID, urlTmpl string) error { var url string if urlTmpl == "" { - url = domain.PasswordlessInitCodeLink(origin+login.HandlerPrefix+login.EndpointPasswordlessRegistration, user.ID, user.ResourceOwner, codeID, code) + url = domain.PasswordlessInitCodeLink(http_utils.ComposedOrigin(ctx)+login.HandlerPrefix+login.EndpointPasswordlessRegistration, user.ID, user.ResourceOwner, codeID, code) } else { var buf strings.Builder if err := domain.RenderPasskeyURLTemplate(&buf, urlTmpl, user.ID, user.ResourceOwner, codeID, code); err != nil { @@ -19,6 +21,5 @@ func (notify Notify) SendPasswordlessRegistrationLink(user *query.NotifyUser, or } url = buf.String() } - return notify(url, nil, domain.PasswordlessRegistrationMessageType, true) } diff --git a/internal/notification/types/passwordless_registration_link_test.go b/internal/notification/types/passwordless_registration_link_test.go index eb13b94a49..489a505675 100644 --- a/internal/notification/types/passwordless_registration_link_test.go +++ b/internal/notification/types/passwordless_registration_link_test.go @@ -1,11 +1,13 @@ package types import ( + "context" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + http_utils "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/domain" caos_errs "github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/query" @@ -80,7 +82,7 @@ func TestNotify_SendPasswordlessRegistrationLink(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, notify := mockNotify() - err := notify.SendPasswordlessRegistrationLink(tt.args.user, tt.args.origin, tt.args.code, tt.args.codeID, tt.args.urlTmpl) + err := notify.SendPasswordlessRegistrationLink(http_utils.WithComposedOrigin(context.Background(), tt.args.origin), tt.args.user, tt.args.code, tt.args.codeID, tt.args.urlTmpl) require.ErrorIs(t, err, tt.wantErr) assert.Equal(t, tt.want, got) }) diff --git a/internal/notification/types/phone_verification_code.go b/internal/notification/types/phone_verification_code.go index f833aeb33d..4b583c1e4f 100644 --- a/internal/notification/types/phone_verification_code.go +++ b/internal/notification/types/phone_verification_code.go @@ -1,13 +1,15 @@ package types import ( + "context" + + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/query" ) -func (notify Notify) SendPhoneVerificationCode(user *query.NotifyUser, origin, code, requestedDomain string) error { +func (notify Notify) SendPhoneVerificationCode(ctx context.Context, code string) error { args := make(map[string]interface{}) args["Code"] = code - args["Domain"] = requestedDomain + args["Domain"] = authz.GetInstance(ctx).RequestedDomain() return notify("", args, domain.VerifyPhoneMessageType, true) } diff --git a/internal/notification/types/templateData.go b/internal/notification/types/templateData.go index 465e17555c..d020d977cf 100644 --- a/internal/notification/types/templateData.go +++ b/internal/notification/types/templateData.go @@ -1,15 +1,20 @@ package types import ( + "context" "fmt" "strings" + http_util "github.com/zitadel/zitadel/internal/api/http" + + "github.com/zitadel/zitadel/internal/api/assets" "github.com/zitadel/zitadel/internal/i18n" "github.com/zitadel/zitadel/internal/notification/templates" "github.com/zitadel/zitadel/internal/query" ) -func GetTemplateData(translator *i18n.Translator, translateArgs map[string]interface{}, assetsPrefix, href, msgType, lang string, policy *query.LabelPolicy) templates.TemplateData { +func GetTemplateData(ctx context.Context, translator *i18n.Translator, translateArgs map[string]interface{}, href, msgType, lang string, policy *query.LabelPolicy) templates.TemplateData { + assetsPrefix := http_util.ComposedOrigin(ctx) + assets.HandlerPrefix templateData := templates.TemplateData{ URL: href, PrimaryColor: templates.DefaultPrimaryColor, @@ -28,9 +33,6 @@ func GetTemplateData(translator *i18n.Translator, translateArgs map[string]inter if policy.Light.FontColor != "" { templateData.FontColor = policy.Light.FontColor } - if assetsPrefix == "" { - return templateData - } if policy.Light.LogoURL != "" { templateData.LogoURL = fmt.Sprintf("%s/%s/%s", assetsPrefix, policy.ID, policy.Light.LogoURL) } diff --git a/internal/notification/types/user_email.go b/internal/notification/types/user_email.go index 916844d76e..7cb3498e4d 100644 --- a/internal/notification/types/user_email.go +++ b/internal/notification/types/user_email.go @@ -6,26 +6,18 @@ import ( "github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/eventstore" - "github.com/zitadel/zitadel/internal/notification/channels/fs" - "github.com/zitadel/zitadel/internal/notification/channels/log" - "github.com/zitadel/zitadel/internal/notification/channels/smtp" "github.com/zitadel/zitadel/internal/notification/messages" - "github.com/zitadel/zitadel/internal/notification/senders" "github.com/zitadel/zitadel/internal/query" ) func generateEmail( ctx context.Context, + channels ChannelChains, user *query.NotifyUser, subject, content string, - smtpConfig func(ctx context.Context) (*smtp.Config, error), - getFileSystemProvider func(ctx context.Context) (*fs.Config, error), - getLogProvider func(ctx context.Context) (*log.Config, error), lastEmail bool, triggeringEvent eventstore.Event, - successMetricName, - failureMetricName string, ) error { content = html.UnescapeString(content) message := &messages.Email{ @@ -37,23 +29,14 @@ func generateEmail( if lastEmail { message.Recipients = []string{user.LastEmail} } - - channelChain, err := senders.EmailChannels( - ctx, - smtpConfig, - getFileSystemProvider, - getLogProvider, - successMetricName, - failureMetricName, - ) + emailChannels, _, err := channels.Email(ctx) if err != nil { return err } - - if channelChain.Len() == 0 { + if emailChannels.Len() == 0 { return errors.ThrowPreconditionFailed(nil, "MAIL-83nof", "Errors.Notification.Channels.NotPresent") } - return channelChain.HandleMessage(message) + return emailChannels.HandleMessage(message) } func mapNotifyUserToArgs(user *query.NotifyUser, args map[string]interface{}) map[string]interface{} { diff --git a/internal/notification/types/user_phone.go b/internal/notification/types/user_phone.go index a3407119aa..50b7f86375 100644 --- a/internal/notification/types/user_phone.go +++ b/internal/notification/types/user_phone.go @@ -4,31 +4,26 @@ import ( "context" "github.com/zitadel/logging" - "github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/eventstore" - "github.com/zitadel/zitadel/internal/notification/channels/fs" - "github.com/zitadel/zitadel/internal/notification/channels/log" - "github.com/zitadel/zitadel/internal/notification/channels/twilio" "github.com/zitadel/zitadel/internal/notification/messages" - "github.com/zitadel/zitadel/internal/notification/senders" "github.com/zitadel/zitadel/internal/query" ) func generateSms( ctx context.Context, + channels ChannelChains, user *query.NotifyUser, content string, - getTwilioProvider func(ctx context.Context) (*twilio.Config, error), - getFileSystemProvider func(ctx context.Context) (*fs.Config, error), - getLogProvider func(ctx context.Context) (*log.Config, error), lastPhone bool, triggeringEvent eventstore.Event, - successMetricName, - failureMetricName string, ) error { number := "" - twilioConfig, err := getTwilioProvider(ctx) + smsChannels, twilioConfig, err := channels.SMS(ctx) + logging.OnError(err).Error("could not create sms channel") + if smsChannels.Len() == 0 { + return errors.ThrowPreconditionFailed(nil, "PHONE-w8nfow", "Errors.Notification.Channels.NotPresent") + } if err == nil { number = twilioConfig.SenderNumber } @@ -41,19 +36,5 @@ func generateSms( if lastPhone { message.RecipientPhoneNumber = user.LastPhone } - - channelChain, err := senders.SMSChannels( - ctx, - twilioConfig, - getFileSystemProvider, - getLogProvider, - successMetricName, - failureMetricName, - ) - logging.OnError(err).Error("could not create sms channel") - - if channelChain.Len() == 0 { - return errors.ThrowPreconditionFailed(nil, "PHONE-w8nfow", "Errors.Notification.Channels.NotPresent") - } - return channelChain.HandleMessage(message) + return smsChannels.HandleMessage(message) } diff --git a/internal/notification/types/webhook.go b/internal/notification/types/webhook.go new file mode 100644 index 0000000000..465be289d1 --- /dev/null +++ b/internal/notification/types/webhook.go @@ -0,0 +1,27 @@ +package types + +import ( + "context" + + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/notification/channels/webhook" + "github.com/zitadel/zitadel/internal/notification/messages" +) + +func handleWebhook( + ctx context.Context, + webhookConfig webhook.Config, + channels ChannelChains, + serializable interface{}, + triggeringEvent eventstore.Event, +) error { + message := &messages.JSON{ + Serializable: serializable, + TriggeringEvent: triggeringEvent, + } + webhookChannels, err := channels.Webhook(ctx, webhookConfig) + if err != nil { + return err + } + return webhookChannels.HandleMessage(message) +} diff --git a/internal/repository/session/session.go b/internal/repository/session/session.go index 76f4984d1d..c34ef52424 100644 --- a/internal/repository/session/session.go +++ b/internal/repository/session/session.go @@ -5,6 +5,7 @@ import ( "encoding/json" "time" + "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/errors" @@ -409,10 +410,11 @@ func NewOTPSMSCheckedEvent( type OTPEmailChallengedEvent struct { eventstore.BaseEvent `json:"-"` - Code *crypto.CryptoValue `json:"code"` - Expiry time.Duration `json:"expiry"` - ReturnCode bool `json:"returnCode,omitempty"` - URLTmpl string `json:"urlTmpl,omitempty"` + Code *crypto.CryptoValue `json:"code"` + Expiry time.Duration `json:"expiry"` + ReturnCode bool `json:"returnCode,omitempty"` + URLTmpl string `json:"urlTmpl,omitempty"` + TriggeredAtOrigin string `json:"triggerOrigin,omitempty"` } func (e *OTPEmailChallengedEvent) Data() interface{} { @@ -427,6 +429,10 @@ func (e *OTPEmailChallengedEvent) SetBaseEvent(base *eventstore.BaseEvent) { e.BaseEvent = *base } +func (e *OTPEmailChallengedEvent) TriggerOrigin() string { + return e.TriggeredAtOrigin +} + func NewOTPEmailChallengedEvent( ctx context.Context, aggregate *eventstore.Aggregate, @@ -441,10 +447,11 @@ func NewOTPEmailChallengedEvent( aggregate, OTPEmailChallengedType, ), - Code: code, - Expiry: expiry, - ReturnCode: returnCode, - URLTmpl: urlTmpl, + Code: code, + Expiry: expiry, + ReturnCode: returnCode, + URLTmpl: urlTmpl, + TriggeredAtOrigin: http.ComposedOrigin(ctx), } } diff --git a/internal/repository/user/human.go b/internal/repository/user/human.go index 075fd55cac..f314cd8df8 100644 --- a/internal/repository/user/human.go +++ b/internal/repository/user/human.go @@ -7,6 +7,7 @@ import ( "golang.org/x/text/language" + "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/errors" @@ -244,6 +245,7 @@ type HumanInitialCodeAddedEvent struct { eventstore.BaseEvent `json:"-"` Code *crypto.CryptoValue `json:"code,omitempty"` Expiry time.Duration `json:"expiry,omitempty"` + TriggeredAtOrigin string `json:"triggerOrigin,omitempty"` } func (e *HumanInitialCodeAddedEvent) Data() interface{} { @@ -254,6 +256,10 @@ func (e *HumanInitialCodeAddedEvent) UniqueConstraints() []*eventstore.EventUniq return nil } +func (e *HumanInitialCodeAddedEvent) TriggerOrigin() string { + return e.TriggeredAtOrigin +} + func NewHumanInitialCodeAddedEvent( ctx context.Context, aggregate *eventstore.Aggregate, @@ -266,8 +272,9 @@ func NewHumanInitialCodeAddedEvent( aggregate, HumanInitialCodeAddedType, ), - Code: code, - Expiry: expiry, + Code: code, + Expiry: expiry, + TriggeredAtOrigin: http.ComposedOrigin(ctx), } } diff --git a/internal/repository/user/human_email.go b/internal/repository/user/human_email.go index a6a5b8a18e..51081dc6e5 100644 --- a/internal/repository/user/human_email.go +++ b/internal/repository/user/human_email.go @@ -5,6 +5,7 @@ import ( "encoding/json" "time" + "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/errors" @@ -122,10 +123,11 @@ func HumanEmailVerificationFailedEventMapper(event *repository.Event) (eventstor type HumanEmailCodeAddedEvent struct { eventstore.BaseEvent `json:"-"` - Code *crypto.CryptoValue `json:"code,omitempty"` - Expiry time.Duration `json:"expiry,omitempty"` - URLTemplate string `json:"url_template,omitempty"` - CodeReturned bool `json:"code_returned,omitempty"` + Code *crypto.CryptoValue `json:"code,omitempty"` + Expiry time.Duration `json:"expiry,omitempty"` + URLTemplate string `json:"url_template,omitempty"` + CodeReturned bool `json:"code_returned,omitempty"` + TriggeredAtOrigin string `json:"triggerOrigin,omitempty"` } func (e *HumanEmailCodeAddedEvent) Data() interface{} { @@ -136,6 +138,10 @@ func (e *HumanEmailCodeAddedEvent) UniqueConstraints() []*eventstore.EventUnique return nil } +func (e *HumanEmailCodeAddedEvent) TriggerOrigin() string { + return e.TriggeredAtOrigin +} + func NewHumanEmailCodeAddedEvent( ctx context.Context, aggregate *eventstore.Aggregate, @@ -159,10 +165,11 @@ func NewHumanEmailCodeAddedEventV2( aggregate, HumanEmailCodeAddedType, ), - Code: code, - Expiry: expiry, - URLTemplate: urlTemplate, - CodeReturned: codeReturned, + Code: code, + Expiry: expiry, + URLTemplate: urlTemplate, + CodeReturned: codeReturned, + TriggeredAtOrigin: http.ComposedOrigin(ctx), } } diff --git a/internal/repository/user/human_mfa_passwordless.go b/internal/repository/user/human_mfa_passwordless.go index 1ffa14af3f..abdee0c3d4 100644 --- a/internal/repository/user/human_mfa_passwordless.go +++ b/internal/repository/user/human_mfa_passwordless.go @@ -5,6 +5,7 @@ import ( "encoding/json" "time" + "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/errors" @@ -317,11 +318,12 @@ func HumanPasswordlessInitCodeAddedEventMapper(event *repository.Event) (eventst type HumanPasswordlessInitCodeRequestedEvent struct { eventstore.BaseEvent `json:"-"` - ID string `json:"id"` - Code *crypto.CryptoValue `json:"code"` - Expiry time.Duration `json:"expiry"` - URLTemplate string `json:"url_template,omitempty"` - CodeReturned bool `json:"code_returned,omitempty"` + ID string `json:"id"` + Code *crypto.CryptoValue `json:"code"` + Expiry time.Duration `json:"expiry"` + URLTemplate string `json:"url_template,omitempty"` + CodeReturned bool `json:"code_returned,omitempty"` + TriggeredAtOrigin string `json:"triggerOrigin,omitempty"` } func (e *HumanPasswordlessInitCodeRequestedEvent) Data() interface{} { @@ -332,6 +334,10 @@ func (e *HumanPasswordlessInitCodeRequestedEvent) UniqueConstraints() []*eventst return nil } +func (e *HumanPasswordlessInitCodeRequestedEvent) TriggerOrigin() string { + return e.TriggeredAtOrigin +} + func NewHumanPasswordlessInitCodeRequestedEvent( ctx context.Context, aggregate *eventstore.Aggregate, @@ -347,11 +353,12 @@ func NewHumanPasswordlessInitCodeRequestedEvent( aggregate, HumanPasswordlessInitCodeRequestedType, ), - ID: id, - Code: code, - Expiry: expiry, - URLTemplate: urlTmpl, - CodeReturned: codeReturned, + ID: id, + Code: code, + Expiry: expiry, + URLTemplate: urlTmpl, + CodeReturned: codeReturned, + TriggeredAtOrigin: http.ComposedOrigin(ctx), } } diff --git a/internal/repository/user/human_password.go b/internal/repository/user/human_password.go index 909da24261..0519145174 100644 --- a/internal/repository/user/human_password.go +++ b/internal/repository/user/human_password.go @@ -5,11 +5,11 @@ import ( "encoding/json" "time" - "github.com/zitadel/zitadel/internal/eventstore" - + "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/repository" ) @@ -29,10 +29,11 @@ type HumanPasswordChangedEvent struct { // New events only use EncodedHash. However, the secret field // is preserved to handle events older than the switch to Passwap. - Secret *crypto.CryptoValue `json:"secret,omitempty"` - EncodedHash string `json:"encodedHash,omitempty"` - ChangeRequired bool `json:"changeRequired"` - UserAgentID string `json:"userAgentID,omitempty"` + Secret *crypto.CryptoValue `json:"secret,omitempty"` + EncodedHash string `json:"encodedHash,omitempty"` + ChangeRequired bool `json:"changeRequired"` + UserAgentID string `json:"userAgentID,omitempty"` + TriggeredAtOrigin string `json:"triggerOrigin,omitempty"` } func (e *HumanPasswordChangedEvent) Data() interface{} { @@ -43,6 +44,10 @@ func (e *HumanPasswordChangedEvent) UniqueConstraints() []*eventstore.EventUniqu return nil } +func (e *HumanPasswordChangedEvent) TriggerOrigin() string { + return e.TriggeredAtOrigin +} + func NewHumanPasswordChangedEvent( ctx context.Context, aggregate *eventstore.Aggregate, @@ -56,9 +61,10 @@ func NewHumanPasswordChangedEvent( aggregate, HumanPasswordChangedType, ), - EncodedHash: encodeHash, - ChangeRequired: changeRequired, - UserAgentID: userAgentID, + EncodedHash: encodeHash, + ChangeRequired: changeRequired, + UserAgentID: userAgentID, + TriggeredAtOrigin: http.ComposedOrigin(ctx), } } @@ -77,11 +83,12 @@ func HumanPasswordChangedEventMapper(event *repository.Event) (eventstore.Event, type HumanPasswordCodeAddedEvent struct { eventstore.BaseEvent `json:"-"` - Code *crypto.CryptoValue `json:"code,omitempty"` - Expiry time.Duration `json:"expiry,omitempty"` - NotificationType domain.NotificationType `json:"notificationType,omitempty"` - URLTemplate string `json:"url_template,omitempty"` - CodeReturned bool `json:"code_returned,omitempty"` + Code *crypto.CryptoValue `json:"code,omitempty"` + Expiry time.Duration `json:"expiry,omitempty"` + NotificationType domain.NotificationType `json:"notificationType,omitempty"` + URLTemplate string `json:"url_template,omitempty"` + CodeReturned bool `json:"code_returned,omitempty"` + TriggeredAtOrigin string `json:"triggerOrigin,omitempty"` } func (e *HumanPasswordCodeAddedEvent) Data() interface{} { @@ -92,6 +99,10 @@ func (e *HumanPasswordCodeAddedEvent) UniqueConstraints() []*eventstore.EventUni return nil } +func (e *HumanPasswordCodeAddedEvent) TriggerOrigin() string { + return e.TriggeredAtOrigin +} + func NewHumanPasswordCodeAddedEvent( ctx context.Context, aggregate *eventstore.Aggregate, @@ -117,11 +128,12 @@ func NewHumanPasswordCodeAddedEventV2( aggregate, HumanPasswordCodeAddedType, ), - Code: code, - Expiry: expiry, - NotificationType: notificationType, - URLTemplate: urlTemplate, - CodeReturned: codeReturned, + Code: code, + Expiry: expiry, + NotificationType: notificationType, + URLTemplate: urlTemplate, + CodeReturned: codeReturned, + TriggeredAtOrigin: http.ComposedOrigin(ctx), } } diff --git a/internal/repository/user/user.go b/internal/repository/user/user.go index a35ebe8095..1e6e7fbe65 100644 --- a/internal/repository/user/user.go +++ b/internal/repository/user/user.go @@ -5,10 +5,10 @@ import ( "encoding/json" "time" + "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/eventstore" - "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/repository" ) @@ -315,6 +315,7 @@ type DomainClaimedEvent struct { eventstore.BaseEvent `json:"-"` UserName string `json:"userName"` + TriggeredAtOrigin string `json:"triggerOrigin,omitempty"` oldUserName string userLoginMustBeDomain bool } @@ -330,6 +331,10 @@ func (e *DomainClaimedEvent) UniqueConstraints() []*eventstore.EventUniqueConstr } } +func (e *DomainClaimedEvent) TriggerOrigin() string { + return e.TriggeredAtOrigin +} + func NewDomainClaimedEvent( ctx context.Context, aggregate *eventstore.Aggregate, @@ -346,6 +351,7 @@ func NewDomainClaimedEvent( UserName: userName, oldUserName: oldUserName, userLoginMustBeDomain: userLoginMustBeDomain, + TriggeredAtOrigin: http.ComposedOrigin(ctx), } } diff --git a/internal/static/i18n/bg.yaml b/internal/static/i18n/bg.yaml index 3588c25312..ea8f06409f 100644 --- a/internal/static/i18n/bg.yaml +++ b/internal/static/i18n/bg.yaml @@ -62,7 +62,7 @@ Errors: DomainNotAllowedAsUsername: Домейнът вече е резервиран и не може да се използва AlreadyInactive: Потребителят вече е неактивен NotInactive: Потребителят не е неактивен - CantDeactivateInitial: 'Потребител с начално състояние може да бъде изтрит, но не и деактивиран' + CantDeactivateInitial: "Потребител с начално състояние може да бъде изтрит, но не и деактивиран" ShouldBeActiveOrInitial: Потребителят не е активен или начален AlreadyInitialised: Потребителят вече е инициализиран NotInitialised: Потребителят все още не е инициализиран @@ -184,6 +184,11 @@ Errors: DomainVerificationTypeInvalid: Типът проверка на домейна е невалиден DomainVerificationMissing: Проверката на домейна все още не е започнала DomainVerificationFailed: Неуспешна проверка на домейна + DomainVerificationTXTNotFound: TXT записът _zitadel-challenge не беше намерен за вашия домейн. Проверете дали сте го добавили към вашия DNS сървър или изчакайте, докато новият запис бъде разпространен + DomainVerificationTXTNoMatch: TXT записът _zitadel-challenge е намерен за вашия домейн, но не съдържа правилния текст на токена. Проверете дали сте добавили правилния токен към вашия DNS сървър или изчакайте, докато новият запис бъде разпространен + DomainVerificationHTTPNotFound: Файлът, съдържащ предизвикателството, не е намерен в очаквания URL адрес. Проверете дали сте качили файла на правилното място с разрешения за четене + DomainVerificationHTTPNoMatch: Файлът, съдържащ предизвикателството, е намерен в очаквания URL адрес, но не съдържа правилния текст на токена. Проверете съдържанието му + DomainVerificationTimeout: Имаше изчакване при запитване до DNS сървъра PrimaryDomainNotDeletable: Основният домейн не трябва да се изтрива DomainNotFound: Домейнът не е намерен MemberIDMissing: Липсва ID на член @@ -198,7 +203,7 @@ Errors: IdpIsNotOIDC: IDP конфигурацията не е от тип oidc Domain: AlreadyExists: Домейнът вече съществува - InvalidCharacter: 'Само буквено-цифрови знаци, . ' + InvalidCharacter: "Само буквено-цифрови знаци, . " IDP: InvalidSearchQuery: Невалидна заявка за търсене ClientIDMissing: Липсва ClientID @@ -838,7 +843,7 @@ EventTypes: added: Вторият фактор е добавен към правилата за влизане removed: Вторият фактор е премахнат от правилата за влизане multifactor: - added: 'Много фактор, добавен към правилата за влизане' + added: "Много фактор, добавен към правилата за влизане" removed: Мултифакторът е премахнат от правилата за влизане password: complexity: diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index 731d63962b..6bf7115477 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -182,6 +182,11 @@ Errors: DomainVerificationTypeInvalid: Verifikationstyp der Domäne ist ungültig DomainVerificationMissing: Verifikation der Domäne noch nicht erstellt DomainVerificationFailed: Verifikation der Domäne ist fehlgeschlagen + DomainVerificationTXTNotFound: Der TXT-Eintrag _zitadel-challenge wurde für Ihre Domain nicht gefunden. Überprüfen Sie, ob Sie ihn zu Ihrem DNS-Server hinzugefügt haben, oder warten Sie, bis der neue Eintrag verbreitet wird + DomainVerificationTXTNoMatch: Der TXT-Eintrag _zitadel-challenge wurde für Ihre Domain gefunden, enthält jedoch nicht den richtigen Token-Text. Überprüfen Sie, ob Sie das richtige Token zu Ihrem DNS-Server hinzugefügt haben, oder warten Sie, bis der neue Eintrag verbreitet wird + DomainVerificationHTTPNotFound: Das File der Challenge wurde unter der erwarteten URL nicht gefunden. Überprüfen Sie, ob Sie die Datei mit Leseberechtigungen an der richtigen Stelle hochgeladen haben + DomainVerificationHTTPNoMatch: Das File der Challenge wurde in der erwarteten URL gefunden, enthält jedoch nicht den richtigen Token-Text. Überprüfen Sie den Inhalt + DomainVerificationTimeout: There was a timeout querying the DNS server. PrimaryDomainNotDeletable: Primäre Domäne kann nicht gelöscht werden DomainNotFound: Domäne konnte nicht gefunden werden MemberIDMissing: Member ID fehlt @@ -527,7 +532,7 @@ EventTypes: user: added: Benutzer hinzugefügt selfregistered: Benutzer hat sich selbst registriert - initialization: + initialization: code: added: Initialisierungscode generiert sent: Initialisierungscode versendet @@ -569,7 +574,7 @@ EventTypes: avatar: added: Avatar hinzugefügt removed: Avatar entfernt - initialization: + initialization: code: added: Initialisierungscode generiert sent: Initialisierungscode versendet @@ -1225,9 +1230,9 @@ Action: Flow: Type: Unspecified: Unspezifiziert - ExternalAuthentication: Externe Authentifizierung + ExternalAuthentication: Externe Authentifizierung CustomiseToken: Token ergänzen - InternalAuthentication: Interne Authentifizierung + InternalAuthentication: Interne Authentifizierung CustomizeSAMLResponse: SAMLResponse ergänzen TriggerType: Unspecified: Unspezifiziert diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index a05c418c3d..03a7e16b7c 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -182,6 +182,11 @@ Errors: DomainVerificationTypeInvalid: Domain verification type is invalid DomainVerificationMissing: Domain verification not yet started DomainVerificationFailed: Domain verification failed + DomainVerificationTXTNotFound: The _zitadel-challenge TXT record was not found for your domain. Check that you've added it to your DNS server or wait till the new record is propagated + DomainVerificationTXTNoMatch: The _zitadel-challenge TXT record has been found for your domain but it doesn't contain the right token text. Check that you've added the right token to your DNS server or wait till the new record is propagated + DomainVerificationHTTPNotFound: The file containing the challenge was not found in the expected URL. Check that you've uploaded the file in the right place with read permissions + DomainVerificationHTTPNoMatch: The file containing the challenge has been found in the expected URL but it doesn't contain the right token text. Check its content + DomainVerificationTimeout: There was a timeout querying the DNS server PrimaryDomainNotDeletable: Primary domain must not be deleted DomainNotFound: Domain not found MemberIDMissing: Member ID missing diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index ceb0312735..a6783a1706 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -182,6 +182,11 @@ Errors: DomainVerificationTypeInvalid: El tipo verificación del dominio no es válido DomainVerificationMissing: La verificación del dominio no ha comenzado DomainVerificationFailed: La verificación del dominio falló + DomainVerificationTXTNotFound: No se encontró el registro TXT _zitadel-challenge para su dominio. Verifique que lo haya agregado a su servidor DNS o espere hasta que se propague el nuevo registro + DomainVerificationTXTNoMatch: Se encontró el registro TXT _zitadel-challenge para su dominio, pero no contiene el texto del token correcto. Verifique que haya agregado el token correcto a su servidor DNS o espere hasta que se propague el nuevo registro + DomainVerificationHTTPNotFound: El archivo que contiene el desafío no se encontró en la URL esperada. Comprueba que has subido el archivo en el lugar correcto con permisos de lectura. + DomainVerificationHTTPNoMatch: El archivo que contiene el desafío se encontró en la URL esperada, pero no contiene el texto del token correcto. Consulta su contenido + DomainVerificationTimeout: Se superó el tiempo de espera al consultar el servidor DNS. PrimaryDomainNotDeletable: El dominio primario no debe borrarse DomainNotFound: Dominio no encontrado MemberIDMissing: Falta el ID del miembro diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index d3b5bf7eea..2e9592fe31 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -182,6 +182,11 @@ Errors: DomainVerificationTypeInvalid: Le type de vérification du domaine n'est pas valide DomainVerificationMissing: La vérification du domaine n'a pas encore commencé DomainVerificationFailed: La vérification du domaine a échoué + DomainVerificationTXTNotFound: L'enregistrement TXT _zitadel-challenge est introuvable pour votre domaine. Vérifiez que vous l'avez ajouté à votre serveur DNS ou attendez que le nouvel enregistrement se propage + DomainVerificationTXTNoMatch: L'enregistrement TXT _zitadel-challenge a été trouvé pour votre domaine mais il ne contient pas le bon texte de jeton. Vérifiez que vous avez ajouté le bon token à votre serveur DNS ou attendez que le nouvel enregistrement se propage + DomainVerificationHTTPNotFound: Le fichier contenant le défi n'a pas été trouvé dans l'URL attendue. Vérifiez que vous avez téléchargé le fichier au bon endroit avec les autorisations de lecture + DomainVerificationHTTPNoMatch: Le fichier contenant le défi a été trouvé dans l'URL attendue mais il ne contient pas le bon texte de token. Vérifiez son contenu + DomainVerificationTimeout: Il y a eu un délai d'attente lors de l'interrogation du serveur DNS PrimaryDomainNotDeletable: Le domaine primaire ne doit pas être supprimé DomainNotFound: Domaine non trouvé MemberIDMissing: ID du membre manquant diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index e8f09e0e95..9a3e48af74 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -182,6 +182,11 @@ Errors: DomainVerificationTypeInvalid: Il tipo di verifica del dominio non è valido DomainVerificationMissing: La verifica del dominio non è ancora iniziata DomainVerificationFailed: Verifica del dominio fallita + DomainVerificationTXTNotFound: Il record TXT _zitadel-challenge non è stato trovato per il tuo dominio. Verifica di averlo aggiunto al tuo server DNS o attendi la propagazione del nuovo record + DomainVerificationTXTNoMatch: È stato trovato il record TXT _zitadel-challenge per il tuo dominio ma non contiene il testo del token corretto. Verifica di aver aggiunto il token corretto al tuo server DNS o attendi la propagazione del nuovo record + DomainVerificationHTTPNotFound: Il file della challenge non è stato trovato nell'URL previsto. Verifica di aver caricato il file nel posto giusto con permessi di lettura + DomainVerificationHTTPNoMatch: Il file della challenge è stato trovato nell'URL previsto ma non contiene il testo del token corretto. Controlla il suo contenuto + DomainVerificationTimeout: Si è verificato un timeout nella richiesta del server DNS PrimaryDomainNotDeletable: Il dominio primario non deve essere cancellato DomainNotFound: Dominio non trovato MemberIDMissing: ID membro mancante diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index d302596104..3e2960dc27 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -174,6 +174,11 @@ Errors: DomainVerificationTypeInvalid: ドメイン認証タイプが無効です DomainVerificationMissing: ドメイン認証はまだ開始されていません DomainVerificationFailed: ドメイン認証に失敗しました + DomainVerificationTXTNotFound: _zitadel-challenge TXT レコードがドメインで見つかりませんでした。 DNS サーバーに追加したことを確認するか、新しいレコードが伝播されるまで待ちます + DomainVerificationTXTNoMatch: ドメインの _zitadel-challenge TXT レコードが見つかりましたが、正しいトークン テキストが含まれていません。 DNS サーバーに正しいトークンを追加したかどうかを確認するか、新しいレコードが伝播されるまで待ちます + DomainVerificationHTTPNotFound: チャレンジを含むファイルが予期された URL に見つかりませんでした。読み取り権限のある適切な場所にファイルがアップロードされていることを確認してください + DomainVerificationHTTPNoMatch: チャレンジを含むファイルが予期された URL で見つかりましたが、正しいトークン テキストが含まれていません。内容を確認してください + DomainVerificationTimeout: DNSサーバーへのクエリでタイムアウトが発生しました PrimaryDomainNotDeletable: プライマリドメインは削除できません DomainNotFound: ドメインが見つかりません MemberIDMissing: メンバーIDがありません diff --git a/internal/static/i18n/mk.yaml b/internal/static/i18n/mk.yaml index a11d1fb9ff..f613eebb2f 100644 --- a/internal/static/i18n/mk.yaml +++ b/internal/static/i18n/mk.yaml @@ -182,6 +182,11 @@ Errors: DomainVerificationTypeInvalid: Типот на верификација на доменот е невалиден DomainVerificationMissing: Верификацијата на доменот сè уште не е започната DomainVerificationFailed: Верификацијата на доменот не успеа + DomainVerificationTXTNotFound: Записот _zitadel-challenge TXT не беше пронајден за вашиот домен. Проверете дали сте го додале на вашиот DNS сервер или почекајте додека не се пропагира новиот запис + DomainVerificationTXTNoMatch: Записот _zitadel-challenge TXT е пронајден за вашиот домен, но не го содржи вистинскиот токен текст. Проверете дали сте го додале вистинскиот токен на вашиот DNS сервер или почекајте додека не се пропагира новиот запис + DomainVerificationHTTPNotFound: Датотеката што го содржи предизвикот не беше пронајдена во очекуваната URL-адреса. Проверете дали сте ја подигнале датотеката на вистинското место со дозволи за читање + DomainVerificationHTTPNoMatch: Датотеката што го содржи предизвикот е пронајдена во очекуваната URL-адреса, но не го содржи вистинскиот токен текст. Проверете ја неговата содржина + DomainVerificationTimeout: Имаше истек на барање на DNS-серверот PrimaryDomainNotDeletable: Примарниот домен не смее да биде избришан DomainNotFound: Доменот не е пронајден MemberIDMissing: Недостасува ID на членот diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index 6808de2eb6..2e0c3a8244 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -182,6 +182,11 @@ Errors: DomainVerificationTypeInvalid: Typ weryfikacji domeny jest nieprawidłowy DomainVerificationMissing: Weryfikacja domeny nie została jeszcze rozpoczęta DomainVerificationFailed: Weryfikacja domeny nie powiodła się + DomainVerificationTXTNotFound: Nie znaleziono rekordu TXT _zitadel-challenge dla Twojej domeny. Sprawdź, czy dodałeś go do swojego serwera DNS lub poczekaj, aż nowy rekord zostanie rozpropagowany + DomainVerificationTXTNoMatch: Znaleziono rekord TXT _zitadel-challenge dla Twojej domeny, ale nie zawiera on prawidłowego tekstu tokena. Sprawdź, czy dodałeś właściwy token do swojego serwera DNS lub poczekaj, aż nowy rekord zostanie rozpropagowany + DomainVerificationHTTPNotFound: Pod oczekiwanym adresem URL nie znaleziono pliku zawierającego wyzwanie. Sprawdź, czy przesłałeś plik we właściwe miejsce z uprawnieniami do odczytu + DomainVerificationHTTPNoMatch: Znaleziono plik zawierający wyzwanie pod oczekiwanym adresem URL, ale nie zawiera on prawidłowego tekstu tokena. Sprawdź jego zawartość + DomainVerificationTimeout: Upłynął limit czasu podczas wysyłania zapytania do serwera DNS PrimaryDomainNotDeletable: Domena główna nie może być usunięta DomainNotFound: Domena nie znaleziona MemberIDMissing: Brak identyfikatora członka diff --git a/internal/static/i18n/pt.yaml b/internal/static/i18n/pt.yaml index 9888a2a494..7017e12ccf 100644 --- a/internal/static/i18n/pt.yaml +++ b/internal/static/i18n/pt.yaml @@ -180,6 +180,11 @@ Errors: DomainVerificationTypeInvalid: O tipo de verificação do domínio é inválido DomainVerificationMissing: A verificação do domínio ainda não foi iniciada DomainVerificationFailed: Falha na verificação do domínio + DomainVerificationTXTNotFound: O registro TXT _zitadel-challenge não foi encontrado para seu domínio. Verifique se você o adicionou ao seu servidor DNS ou espere até que o novo registro seja propagado + DomainVerificationTXTNoMatch: O registro TXT _zitadel-challenge foi encontrado para seu domínio, mas não contém o texto do token correto. Verifique se você adicionou o token correto ao seu servidor DNS ou espere até que o novo registro seja propagado + DomainVerificationHTTPNotFound: O arquivo que contém o desafio não foi encontrado na URL esperada. Verifique se você carregou o arquivo no lugar certo com permissões de leitura + DomainVerificationHTTPNoMatch: O arquivo que contém o desafio foi encontrado na URL esperada, mas não contém o texto do token correto. Verifique seu conteúdo + DomainVerificationTimeout: Houve um tempo limite na consulta do servidor DNS PrimaryDomainNotDeletable: O domínio principal não pode ser excluído DomainNotFound: Domínio não encontrado MemberIDMissing: ID do membro ausente diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index 83b7b18398..27d719a828 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -182,6 +182,11 @@ Errors: DomainVerificationTypeInvalid: 域名验证类型无效 DomainVerificationMissing: 域名验证尚未开始 DomainVerificationFailed: 域名验证失败 + DomainVerificationTXTNotFound: 未找到您的域的 _zitadel-challenge TXT 记录。检查您是否已将其添加到 DNS 服务器或等待新记录传播 + DomainVerificationTXTNoMatch: 已找到您的域的 _zitadel-challenge TXT 记录,但它不包含正确的令牌文本。检查您是否已将正确的令牌添加到 DNS 服务器或等待新记录传播 + DomainVerificationHTTPNotFound: 在预期的 URL 中找不到包含质询的文件。检查您是否已将文件上传到正确的位置并具有读取权限 + DomainVerificationHTTPNoMatch: 已在预期 URL 中找到包含质询的文件,但它不包含正确的标记文本。检查其内容 + DomainVerificationTimeout: 查询 DNS 服务器超时 PrimaryDomainNotDeletable: 不得删除主域名 DomainNotFound: 未找到域名 MemberIDMissing: 成员 ID 丢失 @@ -623,7 +628,7 @@ EventTypes: removed: 删除 MFA OTP check: succeeded: 验证 MFA OTP 成功 - failed: 验证 MFA OTP 失败 + failed: 验证 MFA OTP 失败 sms: added: 添加了多因素 OTP 短信 removed: 删除了多因素 OTP 短信 diff --git a/release-channels.yaml b/release-channels.yaml index 31ac38ef9c..850bcf35d7 100644 --- a/release-channels.yaml +++ b/release-channels.yaml @@ -1 +1 @@ -stable: "v2.35.1" +stable: "v2.37.3"