diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index f36932b02..a81a1ff12 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -712,6 +712,13 @@ DefaultInstance: IncludeUpperLetters: false # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_OTPEMAIL_INCLUDEUPPERLETTERS IncludeDigits: true # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_OTPEMAIL_INCLUDEDIGITS IncludeSymbols: false # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_OTPEMAIL_INCLUDESYMBOLS + InviteCode: + Length: 6 # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_INITIALIZEUSERCODE_LENGTH + Expiry: "72h" # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_INITIALIZEUSERCODE_EXPIRY + IncludeLowerLetters: false # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_INITIALIZEUSERCODE_INCLUDELOWERLETTERS + IncludeUpperLetters: true # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_INITIALIZEUSERCODE_INCLUDEUPPERLETTERS + IncludeDigits: true # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_INITIALIZEUSERCODE_INCLUDEDIGITS + IncludeSymbols: false # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_INITIALIZEUSERCODE_INCLUDESYMBOLS PasswordComplexityPolicy: MinLength: 8 # ZITADEL_DEFAULTINSTANCE_PASSWORDCOMPLEXITYPOLICY_MINLENGTH HasLowercase: true # ZITADEL_DEFAULTINSTANCE_PASSWORDCOMPLEXITYPOLICY_HASLOWERCASE diff --git a/console/src/app/modules/policies/message-texts/message-texts.component.ts b/console/src/app/modules/policies/message-texts/message-texts.component.ts index 204213039..b3be74c17 100644 --- a/console/src/app/modules/policies/message-texts/message-texts.component.ts +++ b/console/src/app/modules/policies/message-texts/message-texts.component.ts @@ -7,6 +7,7 @@ import { GetDefaultInitMessageTextRequest as AdminGetDefaultInitMessageTextRequest, GetDefaultPasswordChangeMessageTextRequest as AdminGetDefaultPasswordChangeMessageTextRequest, GetDefaultPasswordlessRegistrationMessageTextRequest as AdminGetDefaultPasswordlessRegistrationMessageTextRequest, + GetDefaultInviteUserMessageTextRequest as AdminGetDefaultInviteUserMessageTextRequest, GetDefaultPasswordResetMessageTextRequest as AdminGetDefaultPasswordResetMessageTextRequest, GetDefaultVerifyEmailMessageTextRequest as AdminGetDefaultVerifyEmailMessageTextRequest, GetDefaultVerifyEmailOTPMessageTextRequest as AdminGetDefaultVerifyEmailOTPMessageTextRequest, @@ -16,6 +17,7 @@ import { SetDefaultInitMessageTextRequest, SetDefaultPasswordChangeMessageTextRequest, SetDefaultPasswordlessRegistrationMessageTextRequest, + SetDefaultInviteUserMessageTextRequest, SetDefaultPasswordResetMessageTextRequest, SetDefaultVerifyEmailMessageTextRequest, SetDefaultVerifyEmailOTPMessageTextRequest, @@ -27,6 +29,7 @@ import { GetCustomInitMessageTextRequest, GetCustomPasswordChangeMessageTextRequest, GetCustomPasswordlessRegistrationMessageTextRequest, + GetCustomInviteUserMessageTextRequest, GetCustomPasswordResetMessageTextRequest, GetCustomVerifyEmailMessageTextRequest, GetCustomVerifyEmailOTPMessageTextRequest, @@ -36,6 +39,7 @@ import { GetDefaultInitMessageTextRequest, GetDefaultPasswordChangeMessageTextRequest, GetDefaultPasswordlessRegistrationMessageTextRequest, + GetDefaultInviteUserMessageTextRequest, GetDefaultPasswordResetMessageTextRequest, GetDefaultVerifyEmailMessageTextRequest, GetDefaultVerifyEmailOTPMessageTextRequest, @@ -45,6 +49,7 @@ import { SetCustomInitMessageTextRequest, SetCustomPasswordChangeMessageTextRequest, SetCustomPasswordlessRegistrationMessageTextRequest, + SetCustomInviteUserMessageTextRequest, SetCustomPasswordResetMessageTextRequest, SetCustomVerifyEmailMessageTextRequest, SetCustomVerifyEmailOTPMessageTextRequest, @@ -73,6 +78,7 @@ enum MESSAGETYPES { PASSWORDCHANGE = 'PC', VERIFYSMSOTP = 'VSO', VERIFYEMAILOTP = 'VEO', + INVITEUSER = 'IU', } const REQUESTMAP = { @@ -226,6 +232,23 @@ const REQUESTMAP = { req.setText(map.text ?? ''); req.setTitle(map.title ?? ''); + return req; + }, + }, + [MESSAGETYPES.INVITEUSER]: { + get: new GetCustomInviteUserMessageTextRequest(), + set: new SetCustomInviteUserMessageTextRequest(), + getDefault: new GetDefaultInviteUserMessageTextRequest(), + setFcn: (map: Partial): SetCustomInviteUserMessageTextRequest => { + const req = new SetCustomInviteUserMessageTextRequest(); + req.setButtonText(map.buttonText ?? ''); + req.setFooterText(map.footerText ?? ''); + req.setGreeting(map.greeting ?? ''); + req.setPreHeader(map.preHeader ?? ''); + req.setSubject(map.subject ?? ''); + req.setText(map.text ?? ''); + req.setTitle(map.title ?? ''); + return req; }, }, @@ -371,6 +394,22 @@ const REQUESTMAP = { req.setText(map.text ?? ''); req.setTitle(map.title ?? ''); + return req; + }, + }, + [MESSAGETYPES.INVITEUSER]: { + get: new AdminGetDefaultInviteUserMessageTextRequest(), + set: new SetDefaultInviteUserMessageTextRequest(), + setFcn: (map: Partial): SetDefaultInviteUserMessageTextRequest => { + const req = new SetDefaultInviteUserMessageTextRequest(); + req.setButtonText(map.buttonText ?? ''); + req.setFooterText(map.footerText ?? ''); + req.setGreeting(map.greeting ?? ''); + req.setPreHeader(map.preHeader ?? ''); + req.setSubject(map.subject ?? ''); + req.setText(map.text ?? ''); + req.setTitle(map.title ?? ''); + return req; }, }, @@ -540,6 +579,21 @@ export class MessageTextsComponent implements OnInit, OnDestroy { { key: 'POLICY.MESSAGE_TEXTS.CHIPS.loginnames', value: '{{.LoginNames}}' }, { key: 'POLICY.MESSAGE_TEXTS.CHIPS.changedate', value: '{{.ChangeDate}}' }, ], + [MESSAGETYPES.INVITEUSER]: [ + { key: 'POLICY.MESSAGE_TEXTS.CHIPS.preferredLoginName', value: '{{.PreferredLoginName}}' }, + { key: 'POLICY.MESSAGE_TEXTS.CHIPS.username', value: '{{.UserName}}' }, + { key: 'POLICY.MESSAGE_TEXTS.CHIPS.firstname', value: '{{.FirstName}}' }, + { key: 'POLICY.MESSAGE_TEXTS.CHIPS.lastname', value: '{{.LastName}}' }, + { key: 'POLICY.MESSAGE_TEXTS.CHIPS.nickName', value: '{{.NickName}}' }, + { key: 'POLICY.MESSAGE_TEXTS.CHIPS.displayName', value: '{{.DisplayName}}' }, + { key: 'POLICY.MESSAGE_TEXTS.CHIPS.lastEmail', value: '{{.LastEmail}}' }, + { key: 'POLICY.MESSAGE_TEXTS.CHIPS.verifiedEmail', value: '{{.VerifiedEmail}}' }, + { key: 'POLICY.MESSAGE_TEXTS.CHIPS.lastPhone', value: '{{.LastPhone}}' }, + { key: 'POLICY.MESSAGE_TEXTS.CHIPS.verifiedPhone', value: '{{.VerifiedPhone}}' }, + { key: 'POLICY.MESSAGE_TEXTS.CHIPS.loginnames', value: '{{.LoginNames}}' }, + { key: 'POLICY.MESSAGE_TEXTS.CHIPS.changedate', value: '{{.ChangeDate}}' }, + { key: 'POLICY.MESSAGE_TEXTS.CHIPS.applicationName', value: '{{.ApplicationName}}' }, + ], }; public language: string = 'en'; @@ -599,6 +653,8 @@ export class MessageTextsComponent implements OnInit, OnDestroy { return this.stripEmail(this.service.getDefaultPasswordlessRegistrationMessageText(req)); case MESSAGETYPES.PASSWORDCHANGE: return this.stripEmail(this.service.getDefaultPasswordChangeMessageText(req)); + case MESSAGETYPES.INVITEUSER: + return this.stripEmail(this.service.getDefaultInviteUserMessageText(req)); } } @@ -622,6 +678,8 @@ export class MessageTextsComponent implements OnInit, OnDestroy { return this.stripEmail(this.service.getCustomPasswordlessRegistrationMessageText(req)); case MESSAGETYPES.PASSWORDCHANGE: return this.stripEmail(this.service.getCustomPasswordChangeMessageText(req)); + case MESSAGETYPES.INVITEUSER: + return this.stripEmail(this.service.getCustomInviteUserMessageText(req)); default: return undefined; } @@ -690,6 +748,8 @@ export class MessageTextsComponent implements OnInit, OnDestroy { ); case MESSAGETYPES.PASSWORDCHANGE: return handler((this.service as ManagementService).setCustomPasswordChangeMessageText(this.updateRequest)); + case MESSAGETYPES.INVITEUSER: + return handler((this.service as ManagementService).setCustomInviteUserMessageText(this.updateRequest)); } } else if (this.serviceType === PolicyComponentServiceType.ADMIN) { switch (this.currentType) { @@ -711,6 +771,8 @@ export class MessageTextsComponent implements OnInit, OnDestroy { return handler((this.service as AdminService).setDefaultPasswordlessRegistrationMessageText(this.updateRequest)); case MESSAGETYPES.PASSWORDCHANGE: return handler((this.service as AdminService).setDefaultPasswordChangeMessageText(this.updateRequest)); + case MESSAGETYPES.INVITEUSER: + return handler((this.service as AdminService).setDefaultInviteUserMessageText(this.updateRequest)); } } } @@ -763,6 +825,8 @@ export class MessageTextsComponent implements OnInit, OnDestroy { return handler(this.service.resetCustomPasswordlessRegistrationMessageTextToDefault(this.language)); case MESSAGETYPES.PASSWORDCHANGE: return handler(this.service.resetCustomPasswordChangeMessageTextToDefault(this.language)); + case MESSAGETYPES.INVITEUSER: + return handler(this.service.resetCustomInviteUserMessageTextToDefault(this.language)); default: return Promise.reject(); } diff --git a/console/src/app/services/admin.service.ts b/console/src/app/services/admin.service.ts index 791fc2bb7..59c125380 100644 --- a/console/src/app/services/admin.service.ts +++ b/console/src/app/services/admin.service.ts @@ -72,6 +72,8 @@ import { GetCustomPasswordChangeMessageTextResponse, GetCustomPasswordlessRegistrationMessageTextRequest, GetCustomPasswordlessRegistrationMessageTextResponse, + GetCustomInviteUserMessageTextRequest, + GetCustomInviteUserMessageTextResponse, GetCustomPasswordResetMessageTextRequest, GetCustomPasswordResetMessageTextResponse, GetCustomVerifyEmailMessageTextRequest, @@ -96,6 +98,8 @@ import { GetDefaultPasswordChangeMessageTextResponse, GetDefaultPasswordlessRegistrationMessageTextRequest, GetDefaultPasswordlessRegistrationMessageTextResponse, + GetDefaultInviteUserMessageTextRequest, + GetDefaultInviteUserMessageTextResponse, GetDefaultPasswordResetMessageTextRequest, GetDefaultPasswordResetMessageTextResponse, GetDefaultVerifyEmailMessageTextRequest, @@ -224,6 +228,8 @@ import { SetDefaultPasswordChangeMessageTextResponse, SetDefaultPasswordlessRegistrationMessageTextRequest, SetDefaultPasswordlessRegistrationMessageTextResponse, + SetDefaultInviteUserMessageTextRequest, + SetDefaultInviteUserMessageTextResponse, SetDefaultPasswordResetMessageTextRequest, SetDefaultPasswordResetMessageTextResponse, SetDefaultVerifyEmailMessageTextRequest, @@ -311,6 +317,8 @@ import { ResetCustomPasswordChangeMessageTextToDefaultResponse, ResetCustomPasswordlessRegistrationMessageTextToDefaultRequest, ResetCustomPasswordlessRegistrationMessageTextToDefaultResponse, + ResetCustomInviteUserMessageTextToDefaultRequest, + ResetCustomInviteUserMessageTextToDefaultResponse, ResetCustomPasswordResetMessageTextToDefaultRequest, ResetCustomPasswordResetMessageTextToDefaultResponse, ResetCustomVerifyEmailMessageTextToDefaultRequest, @@ -722,6 +730,32 @@ export class AdminService { return this.grpcService.admin.resetCustomPasswordChangeMessageTextToDefault(req, null).then((resp) => resp.toObject()); } + public getDefaultInviteUserMessageText( + req: GetDefaultInviteUserMessageTextRequest, + ): Promise { + return this.grpcService.admin.getDefaultInviteUserMessageText(req, null).then((resp) => resp.toObject()); + } + + public getCustomInviteUserMessageText( + req: GetCustomInviteUserMessageTextRequest, + ): Promise { + return this.grpcService.admin.getCustomInviteUserMessageText(req, null).then((resp) => resp.toObject()); + } + + public setDefaultInviteUserMessageText( + req: SetDefaultInviteUserMessageTextRequest, + ): Promise { + return this.grpcService.admin.setDefaultInviteUserMessageText(req, null).then((resp) => resp.toObject()); + } + + public resetCustomInviteUserMessageTextToDefault( + lang: string, + ): Promise { + const req = new ResetCustomInviteUserMessageTextToDefaultRequest(); + req.setLanguage(lang); + return this.grpcService.admin.resetCustomInviteUserMessageTextToDefault(req, null).then((resp) => resp.toObject()); + } + public SetUpOrg(org: SetUpOrgRequest.Org, human: SetUpOrgRequest.Human): Promise { const req = new SetUpOrgRequest(); diff --git a/console/src/app/services/mgmt.service.ts b/console/src/app/services/mgmt.service.ts index b167799b2..cb92e9843 100644 --- a/console/src/app/services/mgmt.service.ts +++ b/console/src/app/services/mgmt.service.ts @@ -133,6 +133,8 @@ import { GetCustomLoginTextsResponse, GetCustomPasswordChangeMessageTextRequest, GetCustomPasswordChangeMessageTextResponse, + GetCustomInviteUserMessageTextRequest, + GetCustomInviteUserMessageTextResponse, GetCustomPasswordlessRegistrationMessageTextRequest, GetCustomPasswordlessRegistrationMessageTextResponse, GetCustomPasswordResetMessageTextRequest, @@ -155,6 +157,8 @@ import { GetDefaultLoginTextsResponse, GetDefaultPasswordChangeMessageTextRequest, GetDefaultPasswordChangeMessageTextResponse, + GetDefaultInviteUserMessageTextRequest, + GetDefaultInviteUserMessageTextResponse, GetDefaultPasswordComplexityPolicyRequest, GetDefaultPasswordComplexityPolicyResponse, GetDefaultPasswordlessRegistrationMessageTextRequest, @@ -386,6 +390,8 @@ import { ResetCustomLoginTextsToDefaultResponse, ResetCustomPasswordChangeMessageTextToDefaultRequest, ResetCustomPasswordChangeMessageTextToDefaultResponse, + ResetCustomInviteUserMessageTextToDefaultRequest, + ResetCustomInviteUserMessageTextToDefaultResponse, ResetCustomPasswordlessRegistrationMessageTextToDefaultRequest, ResetCustomPasswordlessRegistrationMessageTextToDefaultResponse, ResetCustomPasswordResetMessageTextToDefaultRequest, @@ -423,6 +429,8 @@ import { SetCustomLoginTextsResponse, SetCustomPasswordChangeMessageTextRequest, SetCustomPasswordChangeMessageTextResponse, + SetCustomInviteUserMessageTextRequest, + SetCustomInviteUserMessageTextResponse, SetCustomPasswordlessRegistrationMessageTextRequest, SetCustomPasswordlessRegistrationMessageTextResponse, SetCustomPasswordResetMessageTextRequest, @@ -804,6 +812,32 @@ export class ManagementService { return this.grpcService.mgmt.resetCustomPasswordChangeMessageTextToDefault(req, null).then((resp) => resp.toObject()); } + public getDefaultInviteUserMessageText( + req: GetDefaultInviteUserMessageTextRequest, + ): Promise { + return this.grpcService.mgmt.getDefaultInviteUserMessageText(req, null).then((resp) => resp.toObject()); + } + + public getCustomInviteUserMessageText( + req: GetCustomInviteUserMessageTextRequest, + ): Promise { + return this.grpcService.mgmt.getCustomInviteUserMessageText(req, null).then((resp) => resp.toObject()); + } + + public setCustomInviteUserMessageText( + req: SetCustomInviteUserMessageTextRequest, + ): Promise { + return this.grpcService.mgmt.setCustomInviteUserMessageCustomText(req, null).then((resp) => resp.toObject()); + } + + public resetCustomInviteUserMessageTextToDefault( + lang: string, + ): Promise { + const req = new ResetCustomInviteUserMessageTextToDefaultRequest(); + req.setLanguage(lang); + return this.grpcService.mgmt.resetCustomInviteUserMessageTextToDefault(req, null).then((resp) => resp.toObject()); + } + public updateUserName(userId: string, username: string): Promise { const req = new UpdateUserNameRequest(); req.setUserId(userId); diff --git a/console/src/assets/i18n/bg.json b/console/src/assets/i18n/bg.json index dd048242c..88cc18662 100644 --- a/console/src/assets/i18n/bg.json +++ b/console/src/assets/i18n/bg.json @@ -197,7 +197,8 @@ "VE": "Когато потребител промени своя имейл адрес, той ще получи имейл с връзка за верифициране на новия адрес.", "VP": "Когато потребител промени своя телефонен номер, той ще получи SMS с код за верификация на новия номер.", "VEO": "Когато потребител добави метод за One-Time Password чрез имейл, трябва да го активира, като въведе код, изпратен на неговия имейл адрес.", - "VSO": "Когато потребител добави метод за One-Time Password чрез SMS, трябва да го активира, като въведе код, изпратен на неговия телефонен номер." + "VSO": "Когато потребител добави метод за One-Time Password чрез SMS, трябва да го активира, като въведе код, изпратен на неговия телефонен номер.", + "IU": "Когато се създаде покана за потребител, те ще получат имейл с връзка за задаване на своя метод за удостоверяване." } }, "LOGIN_TEXTS": { @@ -1666,7 +1667,8 @@ "PR": "Нулиране на парола", "DC": "Заявка за домейн", "PL": "Без парола", - "PC": "Промяна на паролата" + "PC": "Промяна на паролата", + "IU": "Покана за потребител" }, "CHIPS": { "firstname": "Първо име", @@ -1686,7 +1688,8 @@ "tempUsername": "Временно потребителско име", "otp": "Еднократна парола", "verifyUrl": "URL за потвърждаване на еднократна парола", - "expiry": "Изтичане" + "expiry": "Изтичане", + "applicationName": "Името на приложението" }, "TOAST": { "UPDATED": "Персонализираните текстове са запазени." diff --git a/console/src/assets/i18n/cs.json b/console/src/assets/i18n/cs.json index fd50d9702..c49d2ce87 100644 --- a/console/src/assets/i18n/cs.json +++ b/console/src/assets/i18n/cs.json @@ -197,7 +197,8 @@ "VE": "Když uživatel změní svou e-mailovou adresu, obdrží e-mail s odkazem na ověření nové adresy.", "VP": "Když uživatel změní své telefonní číslo, obdrží SMS s kódem pro ověření nového čísla.", "VEO": "Když uživatel přidá metodu jednorázového hesla přes e-mail, musí ji aktivovat zadáním kódu poslaného na jeho e-mailovou adresu.", - "VSO": "Když uživatel přidá metodu jednorázového hesla přes SMS, musí ji aktivovat zadáním kódu poslaného na jeho telefonní číslo." + "VSO": "Když uživatel přidá metodu jednorázového hesla přes SMS, musí ji aktivovat zadáním kódu poslaného na jeho telefonní číslo.", + "IU": "Když se vytvoří pozvánka pro uživatele, obdrží e-mail s odkazem na nastavení své metody ověřování." } }, "LOGIN_TEXTS": { @@ -1667,7 +1668,8 @@ "PR": "Reset hesla", "DC": "Nárok na doménu", "PL": "Bezheslový", - "PC": "Změna hesla" + "PC": "Změna hesla", + "IU": "Pozvat uživatele" }, "CHIPS": { "firstname": "Křestní jméno", @@ -1687,7 +1689,8 @@ "tempUsername": "Dočasné uživatelské jméno", "otp": "Jednorázové heslo", "verifyUrl": "Ověřovací URL jednorázového hesla", - "expiry": "Expirace" + "expiry": "Expirace", + "applicationName": "Název aplikace" }, "TOAST": { "UPDATED": "Vlastní texty uloženy." diff --git a/console/src/assets/i18n/de.json b/console/src/assets/i18n/de.json index e284375a4..f0f01e468 100644 --- a/console/src/assets/i18n/de.json +++ b/console/src/assets/i18n/de.json @@ -197,7 +197,8 @@ "VE": "Wenn ein Benutzer seine E-Mail-Adresse ändert, erhält er eine E-Mail mit einem Link zur Verifizierung der neuen Adresse.", "VP": "Wenn ein Benutzer seine Telefonnummer ändert, erhält er eine SMS mit einem Code zur Verifizierung der neuen Nummer.", "VEO": "Wenn ein Benutzer eine Einmalpasswort-Methode per E-Mail hinzufügt, muss er sie aktivieren, indem er einen Code eingibt, der an seine E-Mail-Adresse gesendet wurde.", - "VSO": "Wenn ein Benutzer eine Einmalpasswort-Methode per SMS hinzufügt, muss er sie aktivieren, indem er einen Code eingibt, der an seine Telefonnummer gesendet wurde." + "VSO": "Wenn ein Benutzer eine Einmalpasswort-Methode per SMS hinzufügt, muss er sie aktivieren, indem er einen Code eingibt, der an seine Telefonnummer gesendet wurde.", + "IU": "Wenn ein Benutzer-Einladungscode erstellt wird, erhält er eine E-Mail mit einem Link zur Einstellung seiner Authentifizierungsmethode." } }, "LOGIN_TEXTS": { @@ -1667,7 +1668,8 @@ "PR": "Passwort Wiederherstellung", "DC": "Domainbeanspruchung", "PL": "Passwortlos", - "PC": "Passwordwechsel" + "PC": "Passwordwechsel", + "IU": "Benutzer einladen" }, "CHIPS": { "firstname": "Vorname", @@ -1687,7 +1689,8 @@ "tempUsername": "Temp. Username", "otp": "Einmalpasswort", "verifyUrl": "URL zur Überprüfung des Einmalpassworts", - "expiry": "Ablauf" + "expiry": "Ablauf", + "applicationName": "Anwendungsname" }, "TOAST": { "UPDATED": "Benutzerdefinierte Texte gespeichert." diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json index 8d59aab44..63b07aba7 100644 --- a/console/src/assets/i18n/en.json +++ b/console/src/assets/i18n/en.json @@ -197,7 +197,8 @@ "VE": "When a user changes their email address, they will receive an email with a link to verify the new address.", "VP": "When a user changes their phone number, they will receive an SMS with a code to verify the new number.", "VEO": "When a user adds a One-Time Password via email method, they need to activate it by entering a code sent to their email address.", - "VSO": "When a user adds a One-Time Password via SMS method, they need to activate it by entering a code sent to their phone number." + "VSO": "When a user adds a One-Time Password via SMS method, they need to activate it by entering a code sent to their phone number.", + "IU": "When a user invite code is created, they will receive an email with a link to set their authentication method." } }, "LOGIN_TEXTS": { @@ -1667,7 +1668,8 @@ "PR": "Password Reset", "DC": "Domain Claim", "PL": "Passwordless", - "PC": "Password Change" + "PC": "Password Change", + "IU": "Invite User" }, "CHIPS": { "firstname": "Given name", @@ -1687,7 +1689,8 @@ "tempUsername": "Temp username", "otp": "One-time password", "verifyUrl": "Verify One-time-password URL", - "expiry": "Expiry" + "expiry": "Expiry", + "applicationName": "Application name" }, "TOAST": { "UPDATED": "Custom Texts saved." diff --git a/console/src/assets/i18n/es.json b/console/src/assets/i18n/es.json index bc2cc37d9..979541eb4 100644 --- a/console/src/assets/i18n/es.json +++ b/console/src/assets/i18n/es.json @@ -197,7 +197,8 @@ "VE": "Cuando un usuario cambia su dirección de correo electrónico, recibirá un correo electrónico con un enlace para verificar la nueva dirección.", "VP": "Cuando un usuario cambia su número de teléfono, recibirá un SMS con un código para verificar el nuevo número.", "VEO": "Cuando un usuario agrega una Contraseña de Un Solo Uso mediante correo electrónico, necesita activarla ingresando un código enviado a su dirección de correo electrónico.", - "VSO": "Cuando un usuario agrega una Contraseña de Un Solo Uso mediante SMS, necesita activarla ingresando un código enviado a su número de teléfono." + "VSO": "Cuando un usuario agrega una Contraseña de Un Solo Uso mediante SMS, necesita activarla ingresando un código enviado a su número de teléfono.", + "IU": "Cuando se crea un código de invitación de usuario, recibirán un correo electrónico con un enlace para configurar su método de autenticación." } }, "LOGIN_TEXTS": { @@ -1668,7 +1669,8 @@ "PR": "Restablecimiento de contraseña", "DC": "Reclamar un dominio", "PL": "Acceso sin contraseña", - "PC": "Cambio de contraseña" + "PC": "Cambio de contraseña", + "IU": "Invitar usuario" }, "CHIPS": { "firstname": "Nombre", @@ -1688,7 +1690,8 @@ "tempUsername": "Nombre de usuario temporal", "otp": "Contraseña de un solo uso", "verifyUrl": "URL para verificar la contraseña de un solo uso", - "expiry": "Expiración" + "expiry": "Expiración", + "applicationName": "Nombre de la aplicación" }, "TOAST": { "UPDATED": "Textos personalizados guardados." diff --git a/console/src/assets/i18n/fr.json b/console/src/assets/i18n/fr.json index 5fb378f0b..e7f99670a 100644 --- a/console/src/assets/i18n/fr.json +++ b/console/src/assets/i18n/fr.json @@ -197,7 +197,8 @@ "VE": "Lorsqu'un utilisateur change son adresse e-mail, il recevra un e-mail avec un lien pour vérifier la nouvelle adresse.", "VP": "Lorsqu'un utilisateur change son numéro de téléphone, il recevra un SMS avec un code pour vérifier le nouveau numéro.", "VEO": "Lorsqu'un utilisateur ajoute un Mot de Passe à Usage Unique via e-mail, il doit l'activer en entrant un code envoyé à son adresse e-mail.", - "VSO": "Lorsqu'un utilisateur ajoute un Mot de Passe à Usage Unique via SMS, il doit l'activer en entrant un code envoyé à son numéro de téléphone." + "VSO": "Lorsqu'un utilisateur ajoute un Mot de Passe à Usage Unique via SMS, il doit l'activer en entrant un code envoyé à son numéro de téléphone.", + "IU": "Lorsqu'un code d'invitation d'utilisateur est créé, il recevra un e-mail avec un lien pour configurer sa méthode d'authentification." } }, "LOGIN_TEXTS": { @@ -1667,7 +1668,8 @@ "PR": "Réinitialisation du mot de passe", "DC": "Réclamation de domaine", "PL": "Sans mot de passe", - "PC": "Modification du mot de passe" + "PC": "Modification du mot de passe", + "IU": "Inviter un utilisateur" }, "CHIPS": { "firstname": "Prénom", @@ -1687,7 +1689,8 @@ "tempUsername": "Nom d'utilisateur temporaire", "otp": "Mot de passe à usage unique", "verifyUrl": "URL pour vérifier le mot de passe à usage unique", - "expiry": "Expiration" + "expiry": "Expiration", + "applicationName": "Nom de l'application" }, "TOAST": { "UPDATED": "Textes personnalisés enregistrés." diff --git a/console/src/assets/i18n/id.json b/console/src/assets/i18n/id.json index a17004047..256983501 100644 --- a/console/src/assets/i18n/id.json +++ b/console/src/assets/i18n/id.json @@ -185,7 +185,8 @@ "VE": "Saat pengguna mengubah alamat emailnya, mereka akan menerima email berisi tautan untuk memverifikasi alamat baru.", "VP": "Saat pengguna mengganti nomor teleponnya, mereka akan menerima SMS berisi kode untuk memverifikasi nomor baru.", "VEO": "Ketika pengguna menambahkan Kata Sandi Sekali Pakai melalui metode email, mereka perlu mengaktifkannya dengan memasukkan kode yang dikirimkan ke alamat email mereka.", - "VSO": "Ketika pengguna menambahkan One-Time Password melalui metode SMS, mereka perlu mengaktifkannya dengan memasukkan kode yang dikirimkan ke nomor telepon mereka." + "VSO": "Ketika pengguna menambahkan One-Time Password melalui metode SMS, mereka perlu mengaktifkannya dengan memasukkan kode yang dikirimkan ke nomor telepon mereka.", + "IU": "Ketika kode undangan pengguna dibuat, mereka akan menerima email dengan tautan untuk mengatur metode otentikasi mereka." } }, "LOGIN_TEXTS": { @@ -1533,7 +1534,8 @@ "PR": "Reset Kata Sandi", "DC": "Klaim Domain", "PL": "Tanpa kata sandi", - "PC": "Perubahan Kata Sandi" + "PC": "Perubahan Kata Sandi", + "IU": "Mengundang Pengguna" }, "CHIPS": { "firstname": "Nama yang diberikan", @@ -1553,7 +1555,8 @@ "tempUsername": "Nama pengguna sementara", "otp": "Kata sandi satu kali", "verifyUrl": "Verifikasi URL kata sandi satu kali", - "expiry": "Kedaluwarsa" + "expiry": "Kedaluwarsa", + "applicationName": "Nama aplikasi" }, "TOAST": { "UPDATED": "Teks Khusus disimpan." } }, diff --git a/console/src/assets/i18n/it.json b/console/src/assets/i18n/it.json index c368862af..cf793e78b 100644 --- a/console/src/assets/i18n/it.json +++ b/console/src/assets/i18n/it.json @@ -197,7 +197,8 @@ "VE": "Quando un utente cambia il suo indirizzo email, riceverà un'email con un link per verificare il nuovo indirizzo.", "VP": "Quando un utente cambia il suo numero di telefono, riceverà un SMS con un codice per verificare il nuovo numero.", "VEO": "Quando un utente aggiunge una Password Monouso tramite metodo email, deve attivarla inserendo un codice inviato al suo indirizzo email.", - "VSO": "Quando un utente aggiunge una Password Monouso tramite metodo SMS, deve attivarla inserendo un codice inviato al suo numero di telefono." + "VSO": "Quando un utente aggiunge una Password Monouso tramite metodo SMS, deve attivarla inserendo un codice inviato al suo numero di telefono.", + "IU": "Quando viene creato un codice di invito per un utente, riceverà un'e-mail con un collegamento per impostare il suo metodo di autenticazione." } }, "LOGIN_TEXTS": { @@ -1667,7 +1668,8 @@ "PR": "Ripristino della password", "DC": "Rivendicazione del dominio", "PL": "Autenticazione Passwordless", - "PC": "Cambiamento della password" + "PC": "Cambiamento della password", + "IU": "Invita utente" }, "CHIPS": { "firstname": "Nome", @@ -1687,7 +1689,8 @@ "tempUsername": "Nome utente temporaneo", "otp": "Password monouso", "verifyUrl": "URL per verificare la password monouso", - "expiry": "Scadenza" + "expiry": "Scadenza", + "applicationName": "Nome dell'applicazione" }, "TOAST": { "UPDATED": "Testi personalizzati salvati." diff --git a/console/src/assets/i18n/ja.json b/console/src/assets/i18n/ja.json index 387df3bb1..4ba6a69d7 100644 --- a/console/src/assets/i18n/ja.json +++ b/console/src/assets/i18n/ja.json @@ -197,7 +197,8 @@ "VE": "ユーザーがメールアドレスを変更すると、新しいアドレスを検証するリンクが記載されたメールを受け取ります。", "VP": "ユーザーが電話番号を変更すると、新しい番号を検証するコードが記載されたSMSを受け取ります。", "VEO": "ユーザーがメール経由でワンタイムパスワードの方法を追加すると、それをアクティブにするためにメールに送信されたコードを入力する必要があります。", - "VSO": "ユーザーがSMS経由でワンタイムパスワードの方法を追加すると、それをアクティブにするために電話番号に送信されたコードを入力する必要があります。" + "VSO": "ユーザーがSMS経由でワンタイムパスワードの方法を追加すると、それをアクティブにするために電話番号に送信されたコードを入力する必要があります。", + "IU": "ユーザー招待コードが作成されると、認証方法を設定するためのリンクを含むメールが送信されます。" } }, "LOGIN_TEXTS": { @@ -1683,7 +1684,8 @@ "tempUsername": "一時ユーザー名", "otp": "ワンタイムパスワード", "verifyUrl": "ワンタイムパスワードを確認するURL", - "expiry": "有効期限" + "expiry": "有効期限", + "applicationName": "アプリケーション名" }, "TOAST": { "UPDATED": "カスタムテキストが保存されました。" diff --git a/console/src/assets/i18n/mk.json b/console/src/assets/i18n/mk.json index c0723fc2a..ecf038eac 100644 --- a/console/src/assets/i18n/mk.json +++ b/console/src/assets/i18n/mk.json @@ -197,7 +197,8 @@ "VE": "Кога корисник ја менува својата е-маил адреса, тој ќе добие е-маил со врска за верификација на новата адреса.", "VP": "Кога корисник ја менува својата телефонска бројка, тој ќе добие SMS со код за верификација на новиот број.", "VEO": "Кога корисник додава метод за еднократна лозинка преку е-маил, потребно е да го активира со внесување на кодот испратен на нивната е-маил адреса.", - "VSO": "Кога корисник додава метод за еднократна лозинка преку SMS, потребно е да го активира со внесување на кодот испратен на нивниот телефонски број." + "VSO": "Кога корисник додава метод за еднократна лозинка преку SMS, потребно е да го активира со внесување на кодот испратен на нивниот телефонски број.", + "IU": "Кога се создаде покана за корисникот, тие ќе добијат имејл со врска за поставување на нивниот метод за автентикација." } }, "LOGIN_TEXTS": { @@ -1668,7 +1669,8 @@ "PR": "Ресетирање на лозинка", "DC": "Зафатница на домен", "PL": "Лозинка без лозинка", - "PC": "Промена на лозинка" + "PC": "Промена на лозинка", + "IU": "Покана за корисникот" }, "CHIPS": { "firstname": "Име", @@ -1688,7 +1690,8 @@ "tempUsername": "Привремено корисничко име", "otp": "Еднократна лозинка", "verifyUrl": "URL за потврдување на еднократна лозинка", - "expiry": "Истекување" + "expiry": "Истекување", + "applicationName": "Името на апликацијата" }, "TOAST": { "UPDATED": "Прилагодените текстови се зачувани." diff --git a/console/src/assets/i18n/nl.json b/console/src/assets/i18n/nl.json index 9c592cda9..525113e0b 100644 --- a/console/src/assets/i18n/nl.json +++ b/console/src/assets/i18n/nl.json @@ -197,7 +197,8 @@ "VE": "Wanneer een gebruiker zijn e-mailadres wijzigt, ontvangt hij een e-mail met een link om het nieuwe adres te verifiëren.", "VP": "Wanneer een gebruiker zijn telefoonnummer wijzigt, ontvangt hij een SMS met een code om het nieuwe nummer te verifiëren.", "VEO": "Wanneer een gebruiker een eenmalig wachtwoord via e-mailmethode toevoegt, moeten ze dit activeren door een code in te voeren die naar hun e-mailadres is verzonden.", - "VSO": "Wanneer een gebruiker een eenmalig wachtwoord via SMS-methode toevoegt, moeten ze dit activeren door een code in te voeren die naar hun telefoonnummer is verzonden." + "VSO": "Wanneer een gebruiker een eenmalig wachtwoord via SMS-methode toevoegt, moeten ze dit activeren door een code in te voeren die naar hun telefoonnummer is verzonden.", + "IU": "Wanneer een uitnodigingscode voor gebruikers wordt gemaakt, ontvangt de gebruiker een e-mail met een link om zijn verificatiemethode in te stellen." } }, "LOGIN_TEXTS": { @@ -1666,7 +1667,8 @@ "PR": "Wachtwoord Reset", "DC": "Domein Claim", "PL": "Wachtwoordloos", - "PC": "Wachtwoord Verandering" + "PC": "Wachtwoord Verandering", + "IU": "Gebruiker uitnodigen" }, "CHIPS": { "firstname": "Voornaam", @@ -1686,7 +1688,8 @@ "tempUsername": "Tijdelijke gebruikersnaam", "otp": "Eenmalig wachtwoord", "verifyUrl": "Verifieer Eenmalig-wachtwoord URL", - "expiry": "Vervaldatum" + "expiry": "Vervaldatum", + "applicationName": "Toepassingsnaam" }, "TOAST": { "UPDATED": "Aangepaste Teksten opgeslagen." diff --git a/console/src/assets/i18n/pl.json b/console/src/assets/i18n/pl.json index 2592828fe..a739bc08d 100644 --- a/console/src/assets/i18n/pl.json +++ b/console/src/assets/i18n/pl.json @@ -197,7 +197,8 @@ "VE": "Gdy użytkownik zmieni swój adres e-mail, otrzyma e-mail z linkiem do weryfikacji nowego adresu.", "VP": "Gdy użytkownik zmieni swój numer telefonu, otrzyma SMS z kodem do weryfikacji nowego numeru.", "VEO": "Gdy użytkownik doda metodę jednorazowego hasła przez e-mail, musi ją aktywować, wprowadzając kod wysłany na jego adres e-mail.", - "VSO": "Gdy użytkownik doda metodę jednorazowego hasła przez SMS, musi ją aktywować, wprowadzając kod wysłany na jego numer telefonu." + "VSO": "Gdy użytkownik doda metodę jednorazowego hasła przez SMS, musi ją aktywować, wprowadzając kod wysłany na jego numer telefonu.", + "IU": "Kiedy zostanie utworzony kod zaproszenia użytkownika, otrzyma on e-mail z linkiem do ustawienia swojej metody uwierzytelniania." } }, "LOGIN_TEXTS": { @@ -1666,7 +1667,8 @@ "PR": "Resetowanie hasła", "DC": "Rejestracja domeny", "PL": "Bez hasła", - "PC": "Zmiana hasła" + "PC": "Zmiana hasła", + "IU": "Zaproś użytkownika" }, "CHIPS": { "firstname": "Imię", @@ -1686,7 +1688,8 @@ "tempUsername": "Tymczasowa nazwa użytkownika", "otp": "Hasło jednorazowe", "verifyUrl": "URL do weryfikacji hasła jednorazowego", - "expiry": "Wygaśnięcie" + "expiry": "Wygaśnięcie", + "applicationName": "Nazwa aplikacji" }, "TOAST": { "UPDATED": "Teksty niestandardowe zapisane." diff --git a/console/src/assets/i18n/pt.json b/console/src/assets/i18n/pt.json index 93739f187..1bd825e7a 100644 --- a/console/src/assets/i18n/pt.json +++ b/console/src/assets/i18n/pt.json @@ -197,7 +197,8 @@ "VE": "Quando um usuário muda seu endereço de e-mail, ele receberá um e-mail com um link para verificar o novo endereço.", "VP": "Quando um usuário muda seu número de telefone, ele receberá um SMS com um código para verificar o novo número.", "VEO": "Quando um usuário adiciona um método de Senha Única via e-mail, ele precisa ativá-lo inserindo um código enviado para seu endereço de e-mail.", - "VSO": "Quando um usuário adiciona um método de Senha Única via SMS, ele precisa ativá-lo inserindo um código enviado para seu número de telefone." + "VSO": "Quando um usuário adiciona um método de Senha Única via SMS, ele precisa ativá-lo inserindo um código enviado para seu número de telefone.", + "IU": "Quando um código de convite de usuário é criado, eles receberão um e-mail com um link para configurar seu método de autenticação." } }, "LOGIN_TEXTS": { @@ -1668,7 +1669,8 @@ "PR": "Redefinição de Senha", "DC": "Reivindicação de Domínio", "PL": "Sem senha", - "PC": "Alteração de Senha" + "PC": "Alteração de Senha", + "IU": "Convidar usuário" }, "CHIPS": { "firstname": "Nome próprio", @@ -1688,7 +1690,8 @@ "tempUsername": "Nome de usuário temporário", "otp": "Senha de uso único", "verifyUrl": "URL para verificar a senha de uso único", - "expiry": "Data de expiração" + "expiry": "Data de expiração", + "applicationName": "Nome do aplicativo" }, "TOAST": { "UPDATED": "Textos personalizados salvos." diff --git a/console/src/assets/i18n/ru.json b/console/src/assets/i18n/ru.json index a2ed9ff36..292280af3 100644 --- a/console/src/assets/i18n/ru.json +++ b/console/src/assets/i18n/ru.json @@ -197,7 +197,8 @@ "VE": "Когда пользователь меняет свой адрес электронной почты, он получает электронное письмо со ссылкой для подтверждения нового адреса.", "VP": "Когда пользователь меняет свой телефонный номер, он получает SMS с кодом для подтверждения нового номера.", "VEO": "Когда пользователь добавляет метод одноразового пароля по электронной почте, ему необходимо активировать его, введя код, отправленный на его адрес электронной почты.", - "VSO": "Когда пользователь добавляет метод одноразового пароля по SMS, ему необходимо активировать его, введя код, отправленный на его телефонный номер." + "VSO": "Когда пользователь добавляет метод одноразового пароля по SMS, ему необходимо активировать его, введя код, отправленный на его телефонный номер.", + "IU": "Когда создается код приглашения пользователя, он получит электронное письмо со ссылкой для настройки своего метода аутентификации." } }, "LOGIN_TEXTS": { @@ -1735,7 +1736,8 @@ "PR": "Восстановление пароля", "DC": "Утверждение домена", "PL": "Без пароля", - "PC": "Изменение пароля" + "PC": "Изменение пароля", + "IU": "Пригласить пользователя" }, "CHIPS": { "firstname": "Имя", @@ -1755,7 +1757,8 @@ "tempUsername": "Временное имя пользователя", "otp": "Одноразовый пароль", "verifyUrl": "Проверка URL-адреса с одноразовым паролем", - "expiry": "Срок действия" + "expiry": "Срок действия", + "applicationName": "Имя приложения" }, "TOAST": { "UPDATED": "Тексты сохранены." diff --git a/console/src/assets/i18n/sv.json b/console/src/assets/i18n/sv.json index cc7bc2259..061cbb6bb 100644 --- a/console/src/assets/i18n/sv.json +++ b/console/src/assets/i18n/sv.json @@ -197,7 +197,8 @@ "VE": "När en användare ändrar sin e-postadress, kommer de att få ett mail med en länk för att verifiera den nya adressen.", "VP": "När en användare ändrar sitt telefonnummer, kommer de att få ett SMS med en kod för att verifiera det nya numret.", "VEO": "När en användare lägger till en engångslösenord via e-postmetod, måste de aktivera den genom att ange en kod som skickas till deras e-postadress.", - "VSO": "När en användare lägger till en engångslösenord via SMS-metod, måste de aktivera den genom att ange en kod som skickas till deras telefonnummer." + "VSO": "När en användare lägger till en engångslösenord via SMS-metod, måste de aktivera den genom att ange en kod som skickas till deras telefonnummer.", + "IU": "När en inbjudningskod för användare skapas, får de ett e-mail med en länk för att ställa in sin autentiseringsmetod." } }, "LOGIN_TEXTS": { @@ -1671,7 +1672,8 @@ "PR": "Återställ Lösenord", "DC": "Domänkrav", "PL": "lösenordsfri", - "PC": "Lösenordsändring" + "PC": "Lösenordsändring", + "IU": "Bjud in användare" }, "CHIPS": { "firstname": "Förnamn", @@ -1691,7 +1693,8 @@ "tempUsername": "Tillfälligt användarnamn", "otp": "Engångslösenord", "verifyUrl": "Verifiera Engångslösenord URL", - "expiry": "Utgångsdatum" + "expiry": "Utgångsdatum", + "applicationName": "Applikationsnamn" }, "TOAST": { "UPDATED": "Anpassade Texter sparade." diff --git a/console/src/assets/i18n/zh.json b/console/src/assets/i18n/zh.json index 061f7faac..48f7ae849 100644 --- a/console/src/assets/i18n/zh.json +++ b/console/src/assets/i18n/zh.json @@ -197,7 +197,8 @@ "VE": "当用户更改其电子邮件地址时,他们将收到一封带有验证新地址链接的电子邮件。", "VP": "当用户更改其电话号码时,他们将收到一条带有验证新号码的代码的短信。", "VEO": "当用户通过电子邮件方式添加一次性密码时,他们需要通过输入发送到其电子邮件地址的代码来激活它。", - "VSO": "当用户通过短信方式添加一次性密码时,他们需要通过输入发送到其电话号码的代码来激活它。" + "VSO": "当用户通过短信方式添加一次性密码时,他们需要通过输入发送到其电话号码的代码来激活它。", + "IU": "当创建用户邀请代码时,他们将收到一封包含设置其身份验证方法的链接的电子邮件。" } }, "LOGIN_TEXTS": { @@ -1666,7 +1667,8 @@ "PR": "重置密码", "DC": "域名声明", "PL": "无密码身份验证", - "PC": "修改密码" + "PC": "修改密码", + "IU": "邀请用户" }, "CHIPS": { "firstname": "名", @@ -1686,7 +1688,8 @@ "tempUsername": "临时用户名", "otp": "一次性密码", "verifyUrl": "验证一次性密码的URL", - "expiry": "过期时间" + "expiry": "过期时间", + "applicationName": "应用程序名称" }, "TOAST": { "UPDATED": "自定义文本已保存。" diff --git a/internal/api/grpc/admin/custom_text.go b/internal/api/grpc/admin/custom_text.go index 7a24c709b..0fe664542 100644 --- a/internal/api/grpc/admin/custom_text.go +++ b/internal/api/grpc/admin/custom_text.go @@ -396,6 +396,54 @@ func (s *Server) ResetCustomPasswordChangeMessageTextToDefault(ctx context.Conte }, nil } +func (s *Server) GetDefaultInviteUserMessageText(ctx context.Context, req *admin_pb.GetDefaultInviteUserMessageTextRequest) (*admin_pb.GetDefaultInviteUserMessageTextResponse, error) { + msg, err := s.query.DefaultMessageTextByTypeAndLanguageFromFileSystem(ctx, domain.InviteUserMessageType, req.Language) + if err != nil { + return nil, err + } + return &admin_pb.GetDefaultInviteUserMessageTextResponse{ + CustomText: text_grpc.ModelCustomMessageTextToPb(msg), + }, nil +} + +func (s *Server) GetCustomInviteUserMessageText(ctx context.Context, req *admin_pb.GetCustomInviteUserMessageTextRequest) (*admin_pb.GetCustomInviteUserMessageTextResponse, error) { + msg, err := s.query.CustomMessageTextByTypeAndLanguage(ctx, authz.GetInstance(ctx).InstanceID(), domain.InviteUserMessageType, req.Language, false) + if err != nil { + return nil, err + } + return &admin_pb.GetCustomInviteUserMessageTextResponse{ + CustomText: text_grpc.ModelCustomMessageTextToPb(msg), + }, nil +} + +func (s *Server) SetDefaultInviteUserMessageText(ctx context.Context, req *admin_pb.SetDefaultInviteUserMessageTextRequest) (*admin_pb.SetDefaultInviteUserMessageTextResponse, error) { + result, err := s.command.SetDefaultMessageText(ctx, authz.GetInstance(ctx).InstanceID(), SetInviteUserCustomTextToDomain(req)) + if err != nil { + return nil, err + } + return &admin_pb.SetDefaultInviteUserMessageTextResponse{ + Details: object.ChangeToDetailsPb( + result.Sequence, + result.EventDate, + result.ResourceOwner, + ), + }, nil +} + +func (s *Server) ResetCustomInviteUserMessageTextToDefault(ctx context.Context, req *admin_pb.ResetCustomInviteUserMessageTextToDefaultRequest) (*admin_pb.ResetCustomInviteUserMessageTextToDefaultResponse, error) { + result, err := s.command.RemoveInstanceMessageTexts(ctx, domain.InviteUserMessageType, language.Make(req.Language)) + if err != nil { + return nil, err + } + return &admin_pb.ResetCustomInviteUserMessageTextToDefaultResponse{ + Details: object.ChangeToDetailsPb( + result.Sequence, + result.EventDate, + result.ResourceOwner, + ), + }, nil +} + func (s *Server) GetDefaultPasswordlessRegistrationMessageText(ctx context.Context, req *admin_pb.GetDefaultPasswordlessRegistrationMessageTextRequest) (*admin_pb.GetDefaultPasswordlessRegistrationMessageTextResponse, error) { msg, err := s.query.DefaultMessageTextByTypeAndLanguageFromFileSystem(ctx, domain.PasswordlessRegistrationMessageType, req.Language) if err != nil { diff --git a/internal/api/grpc/admin/custom_text_converter.go b/internal/api/grpc/admin/custom_text_converter.go index a7471525d..07cb9792c 100644 --- a/internal/api/grpc/admin/custom_text_converter.go +++ b/internal/api/grpc/admin/custom_text_converter.go @@ -122,6 +122,21 @@ func SetPasswordChangeCustomTextToDomain(msg *admin_pb.SetDefaultPasswordChangeM } } +func SetInviteUserCustomTextToDomain(msg *admin_pb.SetDefaultInviteUserMessageTextRequest) *domain.CustomMessageText { + langTag := language.Make(msg.Language) + return &domain.CustomMessageText{ + MessageTextType: domain.InviteUserMessageType, + Language: langTag, + Title: msg.Title, + PreHeader: msg.PreHeader, + Subject: msg.Subject, + Greeting: msg.Greeting, + Text: msg.Text, + ButtonText: msg.ButtonText, + FooterText: msg.FooterText, + } +} + func SetPasswordlessRegistrationCustomTextToDomain(msg *admin_pb.SetDefaultPasswordlessRegistrationMessageTextRequest) *domain.CustomMessageText { langTag := language.Make(msg.Language) return &domain.CustomMessageText{ diff --git a/internal/api/grpc/admin/import.go b/internal/api/grpc/admin/import.go index 7c20e7617..6a8c1a929 100644 --- a/internal/api/grpc/admin/import.go +++ b/internal/api/grpc/admin/import.go @@ -793,6 +793,7 @@ func importResources(ctx context.Context, s *Server, errors *[]*admin_pb.ImportD importVerifyPhoneMessageTexts(ctx, s, errors, org) importDomainClaimedMessageTexts(ctx, s, errors, org) importPasswordlessRegistrationMessageTexts(ctx, s, errors, org) + importInviteUserMessageTexts(ctx, s, errors, org) if err := importHumanUsers(ctx, s, errors, successOrg, org, count, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator, passwordlessInitCode); err != nil { return err } @@ -975,6 +976,21 @@ func importPasswordlessRegistrationMessageTexts(ctx context.Context, s *Server, } } +func importInviteUserMessageTexts(ctx context.Context, s *Server, errors *[]*admin_pb.ImportDataError, org *admin_pb.DataOrg) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.End() }() + + if org.PasswordlessRegistrationMessages == nil { + return + } + for _, message := range org.GetInviteUserMessages() { + _, err := s.command.SetOrgMessageText(ctx, authz.GetCtxData(ctx).OrgID, management.SetInviteUserCustomTextToDomain(message)) + if err != nil { + *errors = append(*errors, &admin_pb.ImportDataError{Type: "invite_user_messages", Id: org.GetOrgId() + "_" + message.Language, Message: err.Error()}) + } + } +} + func importOrg2(ctx context.Context, s *Server, errors *[]*admin_pb.ImportDataError, success *admin_pb.ImportDataSuccess, count *counts, org *admin_pb.DataOrg) (err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -1236,6 +1252,7 @@ func (s *Server) dataOrgsV1ToDataOrgs(ctx context.Context, dataOrgs *v1_pb.Impor VerifyPhoneMessages: orgV1.GetVerifyPhoneMessages(), DomainClaimedMessages: orgV1.GetDomainClaimedMessages(), PasswordlessRegistrationMessages: orgV1.GetPasswordlessRegistrationMessages(), + InviteUserMessages: orgV1.GetInviteUserMessages(), OidcIdps: orgV1.GetOidcIdps(), JwtIdps: orgV1.GetJwtIdps(), UserLinks: orgV1.GetUserLinks(), diff --git a/internal/api/grpc/management/custom_text.go b/internal/api/grpc/management/custom_text.go index d18b12676..8bd91f13c 100644 --- a/internal/api/grpc/management/custom_text.go +++ b/internal/api/grpc/management/custom_text.go @@ -396,6 +396,54 @@ func (s *Server) ResetCustomPasswordChangeMessageTextToDefault(ctx context.Conte }, nil } +func (s *Server) GetCustomInviteUserMessageText(ctx context.Context, req *mgmt_pb.GetCustomInviteUserMessageTextRequest) (*mgmt_pb.GetCustomInviteUserMessageTextResponse, error) { + msg, err := s.query.CustomMessageTextByTypeAndLanguage(ctx, authz.GetCtxData(ctx).OrgID, domain.InviteUserMessageType, req.Language, false) + if err != nil { + return nil, err + } + return &mgmt_pb.GetCustomInviteUserMessageTextResponse{ + CustomText: text_grpc.ModelCustomMessageTextToPb(msg), + }, nil +} + +func (s *Server) GetDefaultInviteUserMessageText(ctx context.Context, req *mgmt_pb.GetDefaultInviteUserMessageTextRequest) (*mgmt_pb.GetDefaultInviteUserMessageTextResponse, error) { + msg, err := s.query.IAMMessageTextByTypeAndLanguage(ctx, domain.InviteUserMessageType, req.Language) + if err != nil { + return nil, err + } + return &mgmt_pb.GetDefaultInviteUserMessageTextResponse{ + CustomText: text_grpc.ModelCustomMessageTextToPb(msg), + }, nil +} + +func (s *Server) SetCustomInviteUserMessageCustomText(ctx context.Context, req *mgmt_pb.SetCustomInviteUserMessageTextRequest) (*mgmt_pb.SetCustomInviteUserMessageTextResponse, error) { + result, err := s.command.SetOrgMessageText(ctx, authz.GetCtxData(ctx).OrgID, SetInviteUserCustomTextToDomain(req)) + if err != nil { + return nil, err + } + return &mgmt_pb.SetCustomInviteUserMessageTextResponse{ + Details: object.ChangeToDetailsPb( + result.Sequence, + result.EventDate, + result.ResourceOwner, + ), + }, nil +} + +func (s *Server) ResetCustomInviteUserMessageTextToDefault(ctx context.Context, req *mgmt_pb.ResetCustomInviteUserMessageTextToDefaultRequest) (*mgmt_pb.ResetCustomInviteUserMessageTextToDefaultResponse, error) { + result, err := s.command.RemoveOrgMessageTexts(ctx, authz.GetCtxData(ctx).OrgID, domain.InviteUserMessageType, language.Make(req.Language)) + if err != nil { + return nil, err + } + return &mgmt_pb.ResetCustomInviteUserMessageTextToDefaultResponse{ + Details: object.ChangeToDetailsPb( + result.Sequence, + result.EventDate, + result.ResourceOwner, + ), + }, nil +} + func (s *Server) GetCustomPasswordlessRegistrationMessageText(ctx context.Context, req *mgmt_pb.GetCustomPasswordlessRegistrationMessageTextRequest) (*mgmt_pb.GetCustomPasswordlessRegistrationMessageTextResponse, error) { msg, err := s.query.CustomMessageTextByTypeAndLanguage(ctx, authz.GetCtxData(ctx).OrgID, domain.PasswordlessRegistrationMessageType, req.Language, false) if err != nil { diff --git a/internal/api/grpc/management/custom_text_converter.go b/internal/api/grpc/management/custom_text_converter.go index 06dfed6a8..20770f366 100644 --- a/internal/api/grpc/management/custom_text_converter.go +++ b/internal/api/grpc/management/custom_text_converter.go @@ -122,6 +122,21 @@ func SetPasswordChangeCustomTextToDomain(msg *mgmt_pb.SetCustomPasswordChangeMes } } +func SetInviteUserCustomTextToDomain(msg *mgmt_pb.SetCustomInviteUserMessageTextRequest) *domain.CustomMessageText { + langTag := language.Make(msg.Language) + return &domain.CustomMessageText{ + MessageTextType: domain.InviteUserMessageType, + Language: langTag, + Title: msg.Title, + PreHeader: msg.PreHeader, + Subject: msg.Subject, + Greeting: msg.Greeting, + Text: msg.Text, + ButtonText: msg.ButtonText, + FooterText: msg.FooterText, + } +} + func SetPasswordlessRegistrationCustomTextToDomain(msg *mgmt_pb.SetCustomPasswordlessRegistrationMessageTextRequest) *domain.CustomMessageText { langTag := language.Make(msg.Language) return &domain.CustomMessageText{ diff --git a/internal/api/grpc/user/v2/integration_test/user_test.go b/internal/api/grpc/user/v2/integration_test/user_test.go index cab073c61..2fc2d05b0 100644 --- a/internal/api/grpc/user/v2/integration_test/user_test.go +++ b/internal/api/grpc/user/v2/integration_test/user_test.go @@ -2437,3 +2437,310 @@ func TestServer_ListAuthenticationMethodTypes(t *testing.T) { }) } } + +func TestServer_CreateInviteCode(t *testing.T) { + type args struct { + ctx context.Context + req *user.CreateInviteCodeRequest + prepare func(request *user.CreateInviteCodeRequest) error + } + tests := []struct { + name string + args args + want *user.CreateInviteCodeResponse + wantErr bool + }{ + { + name: "create, not existing", + args: args{ + CTX, + &user.CreateInviteCodeRequest{ + UserId: "notexisting", + }, + func(request *user.CreateInviteCodeRequest) error { return nil }, + }, + wantErr: true, + }, + { + name: "create, ok", + args: args{ + ctx: CTX, + req: &user.CreateInviteCodeRequest{}, + prepare: func(request *user.CreateInviteCodeRequest) error { + resp := Instance.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + return nil + }, + }, + want: &user.CreateInviteCodeResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.DefaultOrg.Id, + }, + }, + }, + { + name: "create, invalid template", + args: args{ + ctx: CTX, + req: &user.CreateInviteCodeRequest{ + Verification: &user.CreateInviteCodeRequest_SendCode{ + SendCode: &user.SendInviteCode{ + UrlTemplate: gu.Ptr("{{"), + }, + }, + }, + prepare: func(request *user.CreateInviteCodeRequest) error { + resp := Instance.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + return nil + }, + }, + wantErr: true, + }, + { + name: "create, valid template", + args: args{ + ctx: CTX, + req: &user.CreateInviteCodeRequest{ + Verification: &user.CreateInviteCodeRequest_SendCode{ + SendCode: &user.SendInviteCode{ + UrlTemplate: gu.Ptr("https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}"), + ApplicationName: gu.Ptr("TestApp"), + }, + }, + }, + prepare: func(request *user.CreateInviteCodeRequest) error { + resp := Instance.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + return nil + }, + }, + want: &user.CreateInviteCodeResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.DefaultOrg.Id, + }, + }, + }, + { + name: "create, return code, ok", + args: args{ + ctx: CTX, + req: &user.CreateInviteCodeRequest{ + Verification: &user.CreateInviteCodeRequest_ReturnCode{ + ReturnCode: &user.ReturnInviteCode{}, + }, + }, + prepare: func(request *user.CreateInviteCodeRequest) error { + resp := Instance.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + return nil + }, + }, + want: &user.CreateInviteCodeResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.DefaultOrg.Id, + }, + InviteCode: gu.Ptr("something"), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.args.prepare(tt.args.req) + require.NoError(t, err) + + got, err := Client.CreateInviteCode(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + integration.AssertDetails(t, tt.want, got) + if tt.want.GetInviteCode() != "" { + assert.NotEmpty(t, got.GetInviteCode()) + } else { + assert.Empty(t, got.GetInviteCode()) + } + }) + } +} + +func TestServer_ResendInviteCode(t *testing.T) { + type args struct { + ctx context.Context + req *user.ResendInviteCodeRequest + prepare func(request *user.ResendInviteCodeRequest) error + } + tests := []struct { + name string + args args + want *user.ResendInviteCodeResponse + wantErr bool + }{ + { + name: "user not existing", + args: args{ + CTX, + &user.ResendInviteCodeRequest{ + UserId: "notexisting", + }, + func(request *user.ResendInviteCodeRequest) error { return nil }, + }, + wantErr: true, + }, + { + name: "code not existing", + args: args{ + ctx: CTX, + req: &user.ResendInviteCodeRequest{}, + prepare: func(request *user.ResendInviteCodeRequest) error { + resp := Instance.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + return nil + }, + }, + wantErr: true, + }, + { + name: "code not sent before", + args: args{ + ctx: CTX, + req: &user.ResendInviteCodeRequest{}, + prepare: func(request *user.ResendInviteCodeRequest) error { + userResp := Instance.CreateHumanUser(CTX) + request.UserId = userResp.GetUserId() + Instance.CreateInviteCode(CTX, userResp.GetUserId()) + return nil + }, + }, + wantErr: true, + }, + { + name: "resend, ok", + args: args{ + ctx: CTX, + req: &user.ResendInviteCodeRequest{}, + prepare: func(request *user.ResendInviteCodeRequest) error { + resp := Instance.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + _, err := Instance.Client.UserV2.CreateInviteCode(CTX, &user.CreateInviteCodeRequest{ + UserId: resp.GetUserId(), + }) + return err + }, + }, + want: &user.ResendInviteCodeResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.DefaultOrg.Id, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.args.prepare(tt.args.req) + require.NoError(t, err) + + got, err := Client.ResendInviteCode(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_VerifyInviteCode(t *testing.T) { + type args struct { + ctx context.Context + req *user.VerifyInviteCodeRequest + prepare func(request *user.VerifyInviteCodeRequest) error + } + tests := []struct { + name string + args args + want *user.VerifyInviteCodeResponse + wantErr bool + }{ + { + name: "user not existing", + args: args{ + CTX, + &user.VerifyInviteCodeRequest{ + UserId: "notexisting", + }, + func(request *user.VerifyInviteCodeRequest) error { return nil }, + }, + wantErr: true, + }, + { + name: "code not existing", + args: args{ + ctx: CTX, + req: &user.VerifyInviteCodeRequest{}, + prepare: func(request *user.VerifyInviteCodeRequest) error { + resp := Instance.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + return nil + }, + }, + wantErr: true, + }, + { + name: "invalid code", + args: args{ + ctx: CTX, + req: &user.VerifyInviteCodeRequest{ + VerificationCode: "invalid", + }, + prepare: func(request *user.VerifyInviteCodeRequest) error { + userResp := Instance.CreateHumanUser(CTX) + request.UserId = userResp.GetUserId() + Instance.CreateInviteCode(CTX, userResp.GetUserId()) + return nil + }, + }, + wantErr: true, + }, + { + name: "verify, ok", + args: args{ + ctx: CTX, + req: &user.VerifyInviteCodeRequest{}, + prepare: func(request *user.VerifyInviteCodeRequest) error { + userResp := Instance.CreateHumanUser(CTX) + request.UserId = userResp.GetUserId() + codeResp := Instance.CreateInviteCode(CTX, userResp.GetUserId()) + request.VerificationCode = codeResp.GetInviteCode() + return nil + }, + }, + want: &user.VerifyInviteCodeResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.DefaultOrg.Id, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.args.prepare(tt.args.req) + require.NoError(t, err) + + got, err := Client.VerifyInviteCode(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + integration.AssertDetails(t, tt.want, got) + }) + } +} diff --git a/internal/api/grpc/user/v2/user.go b/internal/api/grpc/user/v2/user.go index e46f6d3cb..13072e7c7 100644 --- a/internal/api/grpc/user/v2/user.go +++ b/internal/api/grpc/user/v2/user.go @@ -45,14 +45,6 @@ func AddUserRequestToAddHuman(req *user.AddHumanUserRequest) (*command.AddHuman, if username == "" { username = req.GetEmail().GetEmail() } - var urlTemplate string - if req.GetEmail().GetSendCode() != nil { - urlTemplate = req.GetEmail().GetSendCode().GetUrlTemplate() - // test the template execution so the async notification will not fail because of it and the user won't realize - if err := domain.RenderConfirmURLTemplate(io.Discard, urlTemplate, req.GetUserId(), "code", "orgID"); err != nil { - return nil, err - } - } passwordChangeRequired := req.GetPassword().GetChangeRequired() || req.GetHashedPassword().GetChangeRequired() metadata := make([]*command.AddMetadataEntry, len(req.Metadata)) for i, metadataEntry := range req.Metadata { @@ -69,6 +61,10 @@ func AddUserRequestToAddHuman(req *user.AddHumanUserRequest) (*command.AddHuman, DisplayName: link.GetUserName(), } } + email, err := addUserRequestEmailToCommand(req.GetEmail()) + if err != nil { + return nil, err + } return &command.AddHuman{ ID: req.GetUserId(), Username: username, @@ -76,12 +72,7 @@ func AddUserRequestToAddHuman(req *user.AddHumanUserRequest) (*command.AddHuman, LastName: req.GetProfile().GetFamilyName(), NickName: req.GetProfile().GetNickName(), DisplayName: req.GetProfile().GetDisplayName(), - Email: command.Email{ - Address: domain.EmailAddress(req.GetEmail().GetEmail()), - Verified: req.GetEmail().GetIsVerified(), - ReturnCode: req.GetEmail().GetReturnCode() != nil, - URLTemplate: urlTemplate, - }, + Email: email, Phone: command.Phone{ Number: domain.PhoneNumber(req.GetPhone().GetPhone()), Verified: req.GetPhone().GetIsVerified(), @@ -100,6 +91,25 @@ func AddUserRequestToAddHuman(req *user.AddHumanUserRequest) (*command.AddHuman, }, nil } +func addUserRequestEmailToCommand(email *user.SetHumanEmail) (command.Email, error) { + address := domain.EmailAddress(email.GetEmail()) + switch v := email.GetVerification().(type) { + case *user.SetHumanEmail_ReturnCode: + return command.Email{Address: address, ReturnCode: true}, nil + case *user.SetHumanEmail_SendCode: + urlTemplate := v.SendCode.GetUrlTemplate() + // test the template execution so the async notification will not fail because of it and the user won't realize + if err := domain.RenderConfirmURLTemplate(io.Discard, urlTemplate, "userID", "code", "orgID"); err != nil { + return command.Email{}, err + } + return command.Email{Address: address, URLTemplate: urlTemplate}, nil + case *user.SetHumanEmail_IsVerified: + return command.Email{Address: address, Verified: v.IsVerified, NoEmailVerification: true}, nil + default: + return command.Email{Address: address}, nil + } +} + func genderToDomain(gender user.Gender) domain.Gender { switch gender { case user.Gender_GENDER_UNSPECIFIED: @@ -617,3 +627,54 @@ func authMethodTypeToPb(methodType domain.UserAuthMethodType) user.Authenticatio return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_UNSPECIFIED } } + +func (s *Server) CreateInviteCode(ctx context.Context, req *user.CreateInviteCodeRequest) (*user.CreateInviteCodeResponse, error) { + invite, err := createInviteCodeRequestToCommand(req) + if err != nil { + return nil, err + } + details, code, err := s.command.CreateInviteCode(ctx, invite) + if err != nil { + return nil, err + } + return &user.CreateInviteCodeResponse{ + Details: object.DomainToDetailsPb(details), + InviteCode: code, + }, nil +} + +func (s *Server) ResendInviteCode(ctx context.Context, req *user.ResendInviteCodeRequest) (*user.ResendInviteCodeResponse, error) { + details, err := s.command.ResendInviteCode(ctx, req.GetUserId(), "", "") + if err != nil { + return nil, err + } + return &user.ResendInviteCodeResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} + +func (s *Server) VerifyInviteCode(ctx context.Context, req *user.VerifyInviteCodeRequest) (*user.VerifyInviteCodeResponse, error) { + details, err := s.command.VerifyInviteCode(ctx, req.GetUserId(), req.GetVerificationCode()) + if err != nil { + return nil, err + } + return &user.VerifyInviteCodeResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} + +func createInviteCodeRequestToCommand(req *user.CreateInviteCodeRequest) (*command.CreateUserInvite, error) { + switch v := req.GetVerification().(type) { + case *user.CreateInviteCodeRequest_SendCode: + urlTemplate := v.SendCode.GetUrlTemplate() + // test the template execution so the async notification will not fail because of it and the user won't realize + if err := domain.RenderConfirmURLTemplate(io.Discard, urlTemplate, req.GetUserId(), "code", "orgID"); err != nil { + return nil, err + } + return &command.CreateUserInvite{UserID: req.GetUserId(), URLTemplate: urlTemplate, ApplicationName: v.SendCode.GetApplicationName()}, nil + case *user.CreateInviteCodeRequest_ReturnCode: + return &command.CreateUserInvite{UserID: req.GetUserId(), ReturnCode: true}, nil + default: + return &command.CreateUserInvite{UserID: req.GetUserId()}, nil + } +} diff --git a/internal/api/ui/login/invite_user_handler.go b/internal/api/ui/login/invite_user_handler.go new file mode 100644 index 000000000..3141af2d7 --- /dev/null +++ b/internal/api/ui/login/invite_user_handler.go @@ -0,0 +1,154 @@ +package login + +import ( + "net/http" + "net/url" + + http_mw "github.com/zitadel/zitadel/internal/api/http/middleware" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/zerrors" +) + +const ( + queryInviteUserCode = "code" + queryInviteUserUserID = "userID" + queryInviteUserLoginName = "loginname" + + tmplInviteUser = "inviteuser" +) + +type inviteUserFormData struct { + Code string `schema:"code"` + LoginName string `schema:"loginname"` + Password string `schema:"password"` + PasswordConfirm string `schema:"passwordconfirm"` + UserID string `schema:"userID"` + OrgID string `schema:"orgID"` + Resend bool `schema:"resend"` +} + +type inviteUserData struct { + baseData + profileData + Code string + LoginName string + UserID string + MinLength uint64 + HasUppercase string + HasLowercase string + HasNumber string + HasSymbol string +} + +func InviteUserLink(origin, userID, loginName, code, orgID string, authRequestID string) string { + v := url.Values{} + v.Set(queryInviteUserUserID, userID) + v.Set(queryInviteUserLoginName, loginName) + v.Set(queryInviteUserCode, code) + v.Set(queryOrgID, orgID) + v.Set(QueryAuthRequestID, authRequestID) + return externalLink(origin) + EndpointInviteUser + "?" + v.Encode() +} + +func (l *Login) handleInviteUser(w http.ResponseWriter, r *http.Request) { + authReq := l.checkOptionalAuthRequestOfEmailLinks(r) + userID := r.FormValue(queryInviteUserUserID) + orgID := r.FormValue(queryOrgID) + code := r.FormValue(queryInviteUserCode) + loginName := r.FormValue(queryInviteUserLoginName) + l.renderInviteUser(w, r, authReq, userID, orgID, loginName, code, nil) +} + +func (l *Login) handleInviteUserCheck(w http.ResponseWriter, r *http.Request) { + data := new(inviteUserFormData) + authReq, err := l.getAuthRequestAndParseData(r, data) + if err != nil { + l.renderError(w, r, nil, err) + return + } + + if data.Resend { + l.resendUserInvite(w, r, authReq, data.UserID, data.OrgID, data.LoginName) + return + } + l.checkUserInviteCode(w, r, authReq, data) +} + +func (l *Login) checkUserInviteCode(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, data *inviteUserFormData) { + if data.Password != data.PasswordConfirm { + err := zerrors.ThrowInvalidArgument(nil, "VIEW-KJS3h", "Errors.User.Password.ConfirmationWrong") + l.renderInviteUser(w, r, authReq, data.UserID, data.OrgID, data.LoginName, data.Code, err) + return + } + userOrgID := "" + if authReq != nil { + userOrgID = authReq.UserOrgID + } + userAgentID, _ := http_mw.UserAgentIDFromCtx(r.Context()) + _, err := l.command.VerifyInviteCodeSetPassword(setUserContext(r.Context(), data.UserID, userOrgID), data.UserID, data.Code, data.Password, userAgentID) + if err != nil { + l.renderInviteUser(w, r, authReq, data.UserID, data.OrgID, data.LoginName, "", err) + return + } + if authReq == nil { + l.defaultRedirect(w, r) + return + } + l.renderNextStep(w, r, authReq) +} + +func (l *Login) resendUserInvite(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, userID, orgID, loginName string) { + var userOrgID, authRequestID string + if authReq != nil { + userOrgID = authReq.UserOrgID + authRequestID = authReq.ID + } + _, err := l.command.ResendInviteCode(setUserContext(r.Context(), userID, userOrgID), userID, userOrgID, authRequestID) + l.renderInviteUser(w, r, authReq, userID, orgID, loginName, "", err) +} + +func (l *Login) renderInviteUser(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, userID, orgID, loginName string, code string, err error) { + var errID, errMessage string + if err != nil { + errID, errMessage = l.getErrorMessage(r, err) + } + if authReq != nil { + userID = authReq.UserID + orgID = authReq.UserOrgID + } + + translator := l.getTranslator(r.Context(), authReq) + data := inviteUserData{ + baseData: l.getBaseData(r, authReq, translator, "InviteUser.Title", "InviteUser.Description", errID, errMessage), + profileData: l.getProfileData(authReq), + UserID: userID, + Code: code, + } + // if the user clicked on the link in the mail, we need to make sure the loginName is rendered + if authReq == nil { + data.LoginName = loginName + data.UserName = loginName + } + policy := l.getPasswordComplexityPolicyByUserID(r, userID) + if policy != nil { + data.MinLength = policy.MinLength + if policy.HasUppercase { + data.HasUppercase = UpperCaseRegex + } + if policy.HasLowercase { + data.HasLowercase = LowerCaseRegex + } + if policy.HasSymbol { + data.HasSymbol = SymbolRegex + } + if policy.HasNumber { + data.HasNumber = NumberRegex + } + } + if authReq == nil { + if err == nil { + l.customTexts(r.Context(), translator, orgID) + } + } + l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplInviteUser], data, nil) +} diff --git a/internal/api/ui/login/renderer.go b/internal/api/ui/login/renderer.go index 3cfcb60cf..cb05f7832 100644 --- a/internal/api/ui/login/renderer.go +++ b/internal/api/ui/login/renderer.go @@ -68,6 +68,7 @@ func CreateRenderer(pathPrefix string, staticStorage static.Storage, cookieName tmplInitPasswordDone: "init_password_done.html", tmplInitUser: "init_user.html", tmplInitUserDone: "init_user_done.html", + tmplInviteUser: "invite_user.html", tmplPasswordResetDone: "password_reset_done.html", tmplChangePassword: "change_password.html", tmplChangePasswordDone: "change_password_done.html", @@ -193,6 +194,9 @@ func CreateRenderer(pathPrefix string, staticStorage static.Storage, cookieName "initUserUrl": func() string { return path.Join(r.pathPrefix, EndpointInitUser) }, + "inviteUserUrl": func() string { + return path.Join(r.pathPrefix, EndpointInviteUser) + }, "changePasswordUrl": func() string { return path.Join(r.pathPrefix, EndpointChangePassword) }, @@ -329,6 +333,8 @@ func (l *Login) chooseNextStep(w http.ResponseWriter, r *http.Request, authReq * l.renderInternalError(w, r, authReq, zerrors.ThrowPreconditionFailed(nil, "APP-asb43", "Errors.User.GrantRequired")) case *domain.ProjectRequiredStep: l.renderInternalError(w, r, authReq, zerrors.ThrowPreconditionFailed(nil, "APP-m92d", "Errors.User.ProjectRequired")) + case *domain.VerifyInviteStep: + l.renderInviteUser(w, r, authReq, "", "", "", "", nil) default: l.renderInternalError(w, r, authReq, zerrors.ThrowInternal(nil, "APP-ds3QF", "step no possible")) } diff --git a/internal/api/ui/login/router.go b/internal/api/ui/login/router.go index a815784af..6e346c9da 100644 --- a/internal/api/ui/login/router.go +++ b/internal/api/ui/login/router.go @@ -30,6 +30,7 @@ const ( EndpointChangePassword = "/password/change" EndpointPasswordReset = "/password/reset" EndpointInitUser = "/user/init" + EndpointInviteUser = "/user/invite" EndpointMFAVerify = "/mfa/verify" EndpointMFAPrompt = "/mfa/prompt" EndpointMFAInitVerify = "/mfa/init/verify" @@ -94,6 +95,8 @@ func CreateRouter(login *Login, interceptors ...mux.MiddlewareFunc) *mux.Router router.HandleFunc(EndpointPasswordReset, login.handlePasswordReset).Methods(http.MethodGet) router.HandleFunc(EndpointInitUser, login.handleInitUser).Methods(http.MethodGet) router.HandleFunc(EndpointInitUser, login.handleInitUserCheck).Methods(http.MethodPost) + router.HandleFunc(EndpointInviteUser, login.handleInviteUser).Methods(http.MethodGet) + router.HandleFunc(EndpointInviteUser, login.handleInviteUserCheck).Methods(http.MethodPost) router.HandleFunc(EndpointMFAVerify, login.handleMFAVerify).Methods(http.MethodPost) router.HandleFunc(EndpointMFAPrompt, login.handleMFAPromptSelection).Methods(http.MethodGet) router.HandleFunc(EndpointMFAPrompt, login.handleMFAPrompt).Methods(http.MethodPost) diff --git a/internal/api/ui/login/static/i18n/bg.yaml b/internal/api/ui/login/static/i18n/bg.yaml index 8b23a236c..4702792bf 100644 --- a/internal/api/ui/login/static/i18n/bg.yaml +++ b/internal/api/ui/login/static/i18n/bg.yaml @@ -81,6 +81,14 @@ InitUserDone: Description: Имейлът е потвърден и паролата е успешно зададена NextButtonText: следващия CancelButtonText: анулиране +InviteUser: + Title: Активиране на потребителя + Description: Проверете своя имейл с кода по-долу и задайте паролата си. + CodeLabel: Код + NewPasswordLabel: Нова парола + NewPasswordConfirm: Потвърди парола + NextButtonText: Напред + ResendButtonText: Изпрати отново код InitMFAPrompt: Title: 2-факторна настройка Description: >- diff --git a/internal/api/ui/login/static/i18n/cs.yaml b/internal/api/ui/login/static/i18n/cs.yaml index f93753de1..14e5dcc1f 100644 --- a/internal/api/ui/login/static/i18n/cs.yaml +++ b/internal/api/ui/login/static/i18n/cs.yaml @@ -86,6 +86,15 @@ InitUserDone: NextButtonText: Další CancelButtonText: Zrušit +InviteUser: + Title: Aktivace uživatele + Description: Ověřte svůj e-mail pomocí níže uvedeného kódu a nastavte si heslo. + CodeLabel: Kód + NewPasswordLabel: Nové heslo + NewPasswordConfirm: Potvrďte heslo + NextButtonText: Další + ResendButtonText: Odeslat kód znovu + InitMFAPrompt: Title: Nastavení 2-faktorové autentizace Description: 2-faktorová autentizace vám poskytuje další zabezpečení pro váš uživatelský účet. Tím je zajištěno, že k vašemu účtu máte přístup pouze vy. diff --git a/internal/api/ui/login/static/i18n/de.yaml b/internal/api/ui/login/static/i18n/de.yaml index 7e4a2bee4..29e7fc54d 100644 --- a/internal/api/ui/login/static/i18n/de.yaml +++ b/internal/api/ui/login/static/i18n/de.yaml @@ -86,6 +86,15 @@ InitUserDone: NextButtonText: Weiter CancelButtonText: Abbrechen +InviteUser: + Title: Benutzer aktivieren + Description: Bestätige deine E-Mail-Adresse mit dem unten stehenden Code und lege dein Passwort fest. + CodeLabel: Code + NewPasswordLabel: Neues Passwort + NewPasswordConfirm: Passwort bestätigen + NextButtonText: Weiter + ResendButtonText: Code erneut senden + InitMFAPrompt: Title: Zweitfaktor hinzufügen Description: Die Zwei-Faktor-Authentifizierung gibt dir eine zusätzliche Sicherheit für dein Benutzerkonto. Damit stellst du sicher, dass nur du Zugriff auf dein Konto hast. diff --git a/internal/api/ui/login/static/i18n/en.yaml b/internal/api/ui/login/static/i18n/en.yaml index 7c4cfdb99..bf0f3d0a5 100644 --- a/internal/api/ui/login/static/i18n/en.yaml +++ b/internal/api/ui/login/static/i18n/en.yaml @@ -86,6 +86,15 @@ InitUserDone: NextButtonText: Next CancelButtonText: Cancel +InviteUser: + Title: Activate User + Description: Verify your e-mail with the code below and set your password. + CodeLabel: Code + NewPasswordLabel: New Password + NewPasswordConfirm: Confirm Password + NextButtonText: Next + ResendButtonText: Resend Code + InitMFAPrompt: Title: 2-Factor Setup Description: 2-factor authentication gives you an additional security for your user account. This ensures that only you have access to your account. diff --git a/internal/api/ui/login/static/i18n/es.yaml b/internal/api/ui/login/static/i18n/es.yaml index 34121a936..b797b37a7 100644 --- a/internal/api/ui/login/static/i18n/es.yaml +++ b/internal/api/ui/login/static/i18n/es.yaml @@ -86,6 +86,15 @@ InitUserDone: NextButtonText: siguiente CancelButtonText: cancelar +InviteUser: + Title: Activar usuario + Description: Verifica tu email con el siguiente código y establece tu contraseña. + CodeLabel: Código + NewPasswordLabel: Nueva contraseña + NewPasswordConfirm: Confirmar contraseña + NextButtonText: siguiente + ResendButtonText: reenviar código + InitMFAPrompt: Title: Configuración de doble factor Description: La autenticación de doble factor te proporciona seguridad adicional para tu cuenta de usuario. Ésta asegura que solo tú tienes acceso a tu cuenta. diff --git a/internal/api/ui/login/static/i18n/fr.yaml b/internal/api/ui/login/static/i18n/fr.yaml index 7a7e71cff..080b3d7aa 100644 --- a/internal/api/ui/login/static/i18n/fr.yaml +++ b/internal/api/ui/login/static/i18n/fr.yaml @@ -86,6 +86,15 @@ InitUserDone: NextButtonText: Suivant CancelButtonText: Annuler +InviteUser: + Title: Activer l'utilisateur + Description: Vérifiez votre e-mail avec le code ci-dessous et définissez votre mot de passe. + CodeLabel: Code + NewPasswordLabel: Nouveau mot de passe + NewPasswordConfirm: Confirmer le mot de passe + NextButtonText: Suivant + ResendButtonText: Renvoyer le code + InitMFAPrompt: Title: Configuration authentification à 2 facteurs Description: L'authentification authentification à 2 facteurs vous offre une sécurité supplémentaire pour votre compte d'utilisateur. Vous êtes ainsi assuré d'être le seul à avoir accès à votre compte. diff --git a/internal/api/ui/login/static/i18n/id.yaml b/internal/api/ui/login/static/i18n/id.yaml index 7516ce898..e407ad59c 100644 --- a/internal/api/ui/login/static/i18n/id.yaml +++ b/internal/api/ui/login/static/i18n/id.yaml @@ -76,6 +76,14 @@ InitUserDone: Description: Email terverifikasi dan Kata Sandi berhasil ditetapkan NextButtonText: Berikutnya CancelButtonText: Membatalkan +InviteUser: + Title: Aktifkan Pengguna + Description: Verifikasi email Anda dengan kode di bawah ini dan atur kata sandi Anda. + CodeLabel: Kode + NewPasswordLabel: Kata Sandi Baru + NewPasswordConfirm: Konfirmasi Kata Sandi + NextButtonText: Selanjutnya + ResendButtonText: Kirim Ulang Kode InitMFAPrompt: Title: Pengaturan 2 Faktor Description: Otentikasi 2 faktor memberi Anda keamanan tambahan untuk akun pengguna Anda. diff --git a/internal/api/ui/login/static/i18n/it.yaml b/internal/api/ui/login/static/i18n/it.yaml index 3d447f8c7..02b354874 100644 --- a/internal/api/ui/login/static/i18n/it.yaml +++ b/internal/api/ui/login/static/i18n/it.yaml @@ -86,6 +86,15 @@ InitUserDone: NextButtonText: Avanti CancelButtonText: annulla +InviteUser: + Title: Attiva utente + Description: Verifica la tua email con il codice seguente e imposta la tua password. + CodeLabel: Codice + NewPasswordLabel: Nuova password + NewPasswordConfirm: Conferma password + NextButtonText: Avanti + ResendButtonText: Reinvia codice + InitMFAPrompt: Title: Impostazione a 2 fattori Description: L'autenticazione a due fattori offre un'ulteriore sicurezza al vostro account utente. Questo garantisce che solo voi possiate accedere al vostro account. diff --git a/internal/api/ui/login/static/i18n/ja.yaml b/internal/api/ui/login/static/i18n/ja.yaml index 6e6da7699..4b93968e9 100644 --- a/internal/api/ui/login/static/i18n/ja.yaml +++ b/internal/api/ui/login/static/i18n/ja.yaml @@ -79,6 +79,15 @@ InitUserDone: NextButtonText: 次へ CancelButtonText: キャンセル +InviteUser: + Title: ユーザーの有効化 + Description: 下のコードでメールアドレスを確認し、パスワードを設定してください。 + CodeLabel: コード + NewPasswordLabel: 新しいパスワード + NewPasswordConfirm: パスワードの確認 + NextButtonText: 次へ + ResendButtonText: コードを再送信 + InitMFAPrompt: Title: 二要素認証のセットアップ Description: 二要素認証でアカウントのセキュリティを強化します。 diff --git a/internal/api/ui/login/static/i18n/mk.yaml b/internal/api/ui/login/static/i18n/mk.yaml index cb5b94b65..120ff0660 100644 --- a/internal/api/ui/login/static/i18n/mk.yaml +++ b/internal/api/ui/login/static/i18n/mk.yaml @@ -86,6 +86,15 @@ InitUserDone: NextButtonText: следно CancelButtonText: откажи +InviteUser: + Title: Активирање на корисникот + Description: Проверете го вашиот имејл со кодот подолу и поставете ја вашата лозинка. + CodeLabel: Код + NewPasswordLabel: Нова лозинка + NewPasswordConfirm: Потврди лозинка + NextButtonText: Следно + ResendButtonText: Повторно испрати код + InitMFAPrompt: Title: Подесување на 2-факторска автентикација Description: 2-факторската автентикација ви дава дополнителна безбедност за вашата корисничка сметка. Ова обезбедува само вие да имате пристап до вашата сметка. diff --git a/internal/api/ui/login/static/i18n/nl.yaml b/internal/api/ui/login/static/i18n/nl.yaml index c6739bdbf..b7efd0044 100644 --- a/internal/api/ui/login/static/i18n/nl.yaml +++ b/internal/api/ui/login/static/i18n/nl.yaml @@ -86,6 +86,15 @@ InitUserDone: NextButtonText: Volgende CancelButtonText: Annuleren +InviteUser: + Title: Gebruiker activeren + Description: Verifieer uw e-mail met de onderstaande code en stel uw wachtwoord in. + CodeLabel: Code + NewPasswordLabel: Nieuw wachtwoord + NewPasswordConfirm: Wachtwoord bevestigen + NextButtonText: Volgende + ResendButtonText: Code opnieuw verzenden + InitMFAPrompt: Title: 2-Factor Setup Description: 2-factor authenticatie geeft u extra beveiliging voor uw gebruikersaccount. Hierdoor bent u de enige die toegang heeft tot uw account. diff --git a/internal/api/ui/login/static/i18n/pl.yaml b/internal/api/ui/login/static/i18n/pl.yaml index 4361dfd28..4feca3ede 100644 --- a/internal/api/ui/login/static/i18n/pl.yaml +++ b/internal/api/ui/login/static/i18n/pl.yaml @@ -86,6 +86,15 @@ InitUserDone: NextButtonText: dalej CancelButtonText: anuluj +InviteUser: + Title: Aktywuj użytkownika + Description: Zweryfikuj swój adres e-mail za pomocą poniższego kodu i ustaw swoje hasło. + CodeLabel: Kod + NewPasswordLabel: Nowe hasło + NewPasswordConfirm: Potwierdź hasło + NextButtonText: Dalej + ResendButtonText: Wyślij ponownie kod + InitMFAPrompt: Title: Konfiguracja 2-etapowego uwierzytelniania Description: 2-etapowe uwierzytelnianie daje Ci dodatkową ochronę dla Twojego konta użytkownika. Dzięki temu masz pewność, że tylko Ty masz dostęp do swojego konta. diff --git a/internal/api/ui/login/static/i18n/pt.yaml b/internal/api/ui/login/static/i18n/pt.yaml index 3ab1923c0..048c842c6 100644 --- a/internal/api/ui/login/static/i18n/pt.yaml +++ b/internal/api/ui/login/static/i18n/pt.yaml @@ -86,6 +86,15 @@ InitUserDone: NextButtonText: próximo CancelButtonText: cancelar +InviteUser: + Title: Ativar usuário + Description: Verifique seu e-mail com o código abaixo e defina sua senha. + CodeLabel: Código + NewPasswordLabel: Nova senha + NewPasswordConfirm: Confirmar senha + NextButtonText: Próximo + ResendButtonText: Reenviar código + InitMFAPrompt: Title: Configuração de 2 fatores Description: A autenticação de 2 fatores fornece uma segurança adicional para sua conta de usuário. Isso garante que apenas você tenha acesso à sua conta. diff --git a/internal/api/ui/login/static/i18n/ru.yaml b/internal/api/ui/login/static/i18n/ru.yaml index b738d735b..9f562ce52 100644 --- a/internal/api/ui/login/static/i18n/ru.yaml +++ b/internal/api/ui/login/static/i18n/ru.yaml @@ -85,6 +85,15 @@ InitUserDone: NextButtonText: далее CancelButtonText: отмена +InviteUser: + Title: Активировать пользователя + Description: Проверьте свой адрес электронной почты с помощью кода ниже и установите свой пароль. + CodeLabel: Код + NewPasswordLabel: Новый пароль + NewPasswordConfirm: Подтвердить пароль + NextButtonText: Далее + ResendButtonText: Отправить код повторно + InitMFAPrompt: Title: Установка двухфакторной аутентификации Description: Двухфакторная аутентификация обеспечивает дополнительную защиту вашей учётной записи. diff --git a/internal/api/ui/login/static/i18n/sv.yaml b/internal/api/ui/login/static/i18n/sv.yaml index 22dacb7f5..ae91b8ded 100644 --- a/internal/api/ui/login/static/i18n/sv.yaml +++ b/internal/api/ui/login/static/i18n/sv.yaml @@ -86,6 +86,15 @@ InitUserDone: NextButtonText: Fortsätt CancelButtonText: Avbryt +InviteUser: + Title: Aktivera användare + Description: Verifiera din e-post med koden nedan och sätt ditt lösenord. + CodeLabel: Kod + NewPasswordLabel: Nytt lösenord + NewPasswordConfirm: Bekräfta lösenord + NextButtonText: Nästa + ResendButtonText: Skicka koden igen + InitMFAPrompt: Title: tvåfaktorinställningar Description: 2-factor-identifiering ökar säkerheten för ditt konto. Enbart du som har tillgång till enheten kan logga in. diff --git a/internal/api/ui/login/static/i18n/zh.yaml b/internal/api/ui/login/static/i18n/zh.yaml index a3a5d5ab0..6131e62ce 100644 --- a/internal/api/ui/login/static/i18n/zh.yaml +++ b/internal/api/ui/login/static/i18n/zh.yaml @@ -86,6 +86,15 @@ InitUserDone: NextButtonText: 继续 CancelButtonText: 取消 +InviteUser: + Title: 激活用户 + Description: 使用以下代码验证您的电子邮件并设置您的密码。 + CodeLabel: 代码 + NewPasswordLabel: 新密码 + NewPasswordConfirm: 确认密码 + NextButtonText: 下一步 + ResendButtonText: 重新发送代码 + InitMFAPrompt: Title: 两步验证设置 Description: 两步验证为您的账户提供了额外的安全保障。这确保只有你能访问你的账户。 diff --git a/internal/api/ui/login/static/templates/invite_user.html b/internal/api/ui/login/static/templates/invite_user.html new file mode 100644 index 000000000..70baaa81e --- /dev/null +++ b/internal/api/ui/login/static/templates/invite_user.html @@ -0,0 +1,63 @@ +{{template "main-top" .}} + +
+

{{t "InviteUser.Title"}}

+ + {{ template "user-profile" . }} + +

{{t "InviteUser.Description"}}

+
+ +
+ + {{ .CSRF }} + + + + + + +
+
+ + +
+ +
+ + +
+
+ + + {{ template "password-complexity-policy-description" . }} +
+
+ + {{ template "error-message" .}} + +
+ + + + + + + + + + +
+
+ + + + + +{{template "main-bottom" .}} diff --git a/internal/auth/repository/eventsourcing/eventstore/auth_request.go b/internal/auth/repository/eventsourcing/eventstore/auth_request.go index 9a9c52012..bec583385 100644 --- a/internal/auth/repository/eventsourcing/eventstore/auth_request.go +++ b/internal/auth/repository/eventsourcing/eventstore/auth_request.go @@ -106,6 +106,7 @@ type idpUserLinksProvider interface { type userEventProvider interface { UserEventsByID(ctx context.Context, id string, changeDate time.Time, eventTypes []eventstore.EventType) ([]eventstore.Event, error) PasswordCodeExists(ctx context.Context, userID string) (exists bool, err error) + InviteCodeExists(ctx context.Context, userID string) (exists bool, err error) } type userCommandProvider interface { @@ -1254,8 +1255,18 @@ func (repo *AuthRequestRepo) firstFactorChecked(ctx context.Context, request *do if user.PasswordInitRequired { if !user.IsEmailVerified { + // If the user was created through the user resource API, + // they can either have an invite code... + exists, err := repo.UserEventProvider.InviteCodeExists(ctx, user.ID) + logging.WithFields("userID", user.ID).OnError(err).Error("unable to check if invite code exists") + if err == nil && exists { + return &domain.VerifyInviteStep{} + } + // or were created with an explicit email verification mail return &domain.VerifyEMailStep{InitPassword: true} } + // If they were created with a verified mail, they might have never received mail to set their password, + // e.g. when created through a user resource API. In this case we'll just create and send one now. exists, err := repo.UserEventProvider.PasswordCodeExists(ctx, user.ID) logging.WithFields("userID", user.ID).OnError(err).Error("unable to check if password code exists") if err == nil && !exists { diff --git a/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go b/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go index 96a7fc8e4..276cb1fd0 100644 --- a/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go +++ b/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go @@ -110,8 +110,9 @@ func (m *mockViewNoUser) UserByID(context.Context, string, string) (*user_view_m } type mockEventUser struct { - Events []eventstore.Event - CodeExists bool + Events []eventstore.Event + PwCodeExists bool + InvitationCodeExists bool } func (m *mockEventUser) UserEventsByID(ctx context.Context, id string, changeDate time.Time, types []eventstore.EventType) ([]eventstore.Event, error) { @@ -119,7 +120,11 @@ func (m *mockEventUser) UserEventsByID(ctx context.Context, id string, changeDat } func (m *mockEventUser) PasswordCodeExists(ctx context.Context, userID string) (bool, error) { - return m.CodeExists, nil + return m.PwCodeExists, nil +} + +func (m *mockEventUser) InviteCodeExists(ctx context.Context, userID string) (bool, error) { + return m.InvitationCodeExists, nil } func (m *mockEventUser) GetLatestUserSessionSequence(ctx context.Context, instanceID string) (*query.CurrentState, error) { @@ -140,6 +145,10 @@ func (m *mockEventErrUser) PasswordCodeExists(ctx context.Context, userID string return false, zerrors.ThrowInternal(nil, "id", "internal error") } +func (m *mockEventErrUser) InviteCodeExists(ctx context.Context, userID string) (bool, error) { + return false, zerrors.ThrowInternal(nil, "id", "internal error") +} + type mockViewUser struct { InitRequired bool PasswordInitRequired bool @@ -1019,6 +1028,36 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { []domain.NextStep{&domain.VerifyEMailStep{}}, nil, }, + { + "password not set (email not verified), invite code exists, invite step", + fields{ + userSessionViewProvider: &mockViewUserSession{}, + userViewProvider: &mockViewUser{ + PasswordInitRequired: true, + }, + userEventProvider: &mockEventUser{ + InvitationCodeExists: true, + }, + lockoutPolicyProvider: &mockLockoutPolicy{ + policy: &query.LockoutPolicy{ + ShowFailures: true, + }, + }, + orgViewProvider: &mockViewOrg{State: domain.OrgStateActive}, + idpUserLinksProvider: &mockIDPUserLinks{}, + }, + args{ + &domain.AuthRequest{ + UserID: "UserID", + LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: true, + }, + }, + false, + }, + []domain.NextStep{&domain.VerifyInviteStep{}}, + nil, + }, { "password not set (email not verified), init password step", fields{ @@ -1056,7 +1095,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { IsEmailVerified: true, }, userEventProvider: &mockEventUser{ - CodeExists: true, + PwCodeExists: true, }, lockoutPolicyProvider: &mockLockoutPolicy{ policy: &query.LockoutPolicy{ @@ -1088,7 +1127,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { IsEmailVerified: true, }, userEventProvider: &mockEventUser{ - CodeExists: false, + PwCodeExists: false, }, lockoutPolicyProvider: &mockLockoutPolicy{ policy: &query.LockoutPolicy{ diff --git a/internal/auth/repository/eventsourcing/eventstore/user.go b/internal/auth/repository/eventsourcing/eventstore/user.go index 0a33199b0..b11f770d7 100644 --- a/internal/auth/repository/eventsourcing/eventstore/user.go +++ b/internal/auth/repository/eventsourcing/eventstore/user.go @@ -93,3 +93,41 @@ func (repo *UserRepo) PasswordCodeExists(ctx context.Context, userID string) (ex } return model.exists, nil } + +type inviteCodeCheck struct { + userID string + + exists bool + events int +} + +func (p *inviteCodeCheck) Reduce() error { + p.exists = p.events > 0 + return nil +} + +func (p *inviteCodeCheck) AppendEvents(events ...eventstore.Event) { + p.events += len(events) +} + +func (p *inviteCodeCheck) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + AddQuery(). + AggregateTypes(user.AggregateType). + AggregateIDs(p.userID). + EventTypes( + user.HumanInviteCodeAddedType, + user.HumanInviteCodeSentType). + Builder() +} + +func (repo *UserRepo) InviteCodeExists(ctx context.Context, userID string) (exists bool, err error) { + model := &inviteCodeCheck{ + userID: userID, + } + err = repo.Eventstore.FilterToQueryReducer(ctx, model) + if err != nil { + return false, zerrors.ThrowPermissionDenied(err, "EVENT-GJ2os", "Errors.Internal") + } + return model.exists, nil +} diff --git a/internal/command/crypto.go b/internal/command/crypto.go index 5db4764f2..45c597fd9 100644 --- a/internal/command/crypto.go +++ b/internal/command/crypto.go @@ -41,14 +41,6 @@ func newEncryptedCodeWithDefaultConfig(ctx context.Context, filter preparation.F }, nil } -func verifyEncryptedCode(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType, alg crypto.EncryptionAlgorithm, creation time.Time, expiry time.Duration, crypted *crypto.CryptoValue, plain string) error { - gen, _, err := encryptedCodeGenerator(ctx, filter, typ, alg, emptyConfig) - if err != nil { - return err - } - return crypto.VerifyCode(creation, expiry, crypted, plain, gen.Alg()) -} - func encryptedCodeGenerator(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType, alg crypto.EncryptionAlgorithm, defaultConfig *crypto.GeneratorConfig) (crypto.Generator, *crypto.GeneratorConfig, error) { config, err := cryptoGeneratorConfigWithDefault(ctx, filter, typ, defaultConfig) if err != nil { diff --git a/internal/command/crypto_test.go b/internal/command/crypto_test.go index 07db20e2e..815539120 100644 --- a/internal/command/crypto_test.go +++ b/internal/command/crypto_test.go @@ -123,78 +123,6 @@ func Test_newCryptoCode(t *testing.T) { } } -func Test_verifyCryptoCode(t *testing.T) { - es := eventstoreExpect(t, expectFilter( - eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypeVerifyEmailCode)), - )) - code, err := newEncryptedCode(context.Background(), es.Filter, domain.SecretGeneratorTypeVerifyEmailCode, crypto.CreateMockEncryptionAlg(gomock.NewController(t))) //nolint:staticcheck - require.NoError(t, err) - - type args struct { - typ domain.SecretGeneratorType - alg crypto.EncryptionAlgorithm - expiry time.Duration - crypted *crypto.CryptoValue - plain string - } - tests := []struct { - name string - eventsore *eventstore.Eventstore - args args - wantErr bool - }{ - { - name: "filter config error", - eventsore: eventstoreExpect(t, expectFilterError(io.ErrClosedPipe)), - args: args{ - typ: domain.SecretGeneratorTypeVerifyEmailCode, - alg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), - expiry: code.Expiry, - crypted: code.Crypted, - plain: code.Plain, - }, - wantErr: true, - }, - { - name: "success", - eventsore: eventstoreExpect(t, expectFilter( - eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypeVerifyEmailCode)), - )), - args: args{ - typ: domain.SecretGeneratorTypeVerifyEmailCode, - alg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), - expiry: code.Expiry, - crypted: code.Crypted, - plain: code.Plain, - }, - }, - { - name: "wrong plain", - eventsore: eventstoreExpect(t, expectFilter( - eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypeVerifyEmailCode)), - )), - args: args{ - typ: domain.SecretGeneratorTypeVerifyEmailCode, - alg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), - expiry: code.Expiry, - crypted: code.Crypted, - plain: "wrong", - }, - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := verifyEncryptedCode(context.Background(), tt.eventsore.Filter, tt.args.typ, tt.args.alg, time.Now(), tt.args.expiry, tt.args.crypted, tt.args.plain) //nolint:staticcheck - if tt.wantErr { - assert.Error(t, err) - return - } - require.NoError(t, err) - }) - } -} - func Test_cryptoCodeGenerator(t *testing.T) { type args struct { typ domain.SecretGeneratorType diff --git a/internal/command/email.go b/internal/command/email.go index bb7dfc3f7..85314f27b 100644 --- a/internal/command/email.go +++ b/internal/command/email.go @@ -12,6 +12,9 @@ type Email struct { Address domain.EmailAddress Verified bool + // NoEmailVerification is used Verified field is false + NoEmailVerification bool + // ReturnCode is used if the Verified field is false ReturnCode bool diff --git a/internal/command/instance.go b/internal/command/instance.go index a0cc77301..c70b7541f 100644 --- a/internal/command/instance.go +++ b/internal/command/instance.go @@ -145,6 +145,7 @@ type SecretGenerators struct { DomainVerification *crypto.GeneratorConfig OTPSMS *crypto.GeneratorConfig OTPEmail *crypto.GeneratorConfig + InviteCode *crypto.GeneratorConfig } type ZitadelConfig struct { diff --git a/internal/command/user.go b/internal/command/user.go index bb3c75775..6b65aa83e 100644 --- a/internal/command/user.go +++ b/internal/command/user.go @@ -388,6 +388,10 @@ func (c *Commands) newUserInitCode(ctx context.Context, filter preparation.Filte return c.newEncryptedCode(ctx, filter, domain.SecretGeneratorTypeInitCode, alg) } +func (c *Commands) newUserInviteCode(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*EncryptedCode, error) { + return c.newEncryptedCodeWithDefault(ctx, filter, domain.SecretGeneratorTypeInviteCode, alg, c.defaultSecretGenerators.InviteCode) +} + func userWriteModelByID(ctx context.Context, filter preparation.FilterToQueryReducer, userID, resourceOwner string) (*UserWriteModel, error) { user := NewUserWriteModel(userID, resourceOwner) events, err := filter(ctx, user.Query()) diff --git a/internal/command/user_human.go b/internal/command/user_human.go index b1abf50b3..120829615 100644 --- a/internal/command/user_human.go +++ b/internal/command/user_human.go @@ -284,6 +284,9 @@ func (c *Commands) addHumanCommandEmail(ctx context.Context, filter preparation. } return append(cmds, user.NewHumanInitialCodeAddedEvent(ctx, &a.Aggregate, initCode.Crypted, initCode.Expiry, human.AuthRequestID)), nil } + if human.Email.NoEmailVerification { + return cmds, nil + } if !human.Email.Verified { emailCode, err := c.newEmailCode(ctx, filter, codeAlg) if err != nil { diff --git a/internal/command/user_human_test.go b/internal/command/user_human_test.go index ffde2480c..468a27e8b 100644 --- a/internal/command/user_human_test.go +++ b/internal/command/user_human_test.go @@ -626,6 +626,67 @@ func TestCommandSide_AddHuman(t *testing.T) { wantEmailCode: "emailCode", }, }, + { + name: "add human (with password and unverified email), ok (no email code)", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + true, + true, + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + 1, + false, + false, + false, + false, + ), + ), + ), + expectPush( + newAddHumanEvent("$plain$x$password", false, true, "", AllowedLanguage), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + newCode: mockEncryptedCode("emailCode", time.Hour), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: "username", + Password: "password", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@test.ch", + Verified: false, + NoEmailVerification: true, + }, + PreferredLanguage: AllowedLanguage, + }, + secretGenerator: GetMockSecretGenerator(t), + allowInitMail: false, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + wantID: "user1", + }, + }, { name: "add human email verified, ok", fields: fields{ diff --git a/internal/command/user_model.go b/internal/command/user_model.go index 10c6dd2cf..ad625756f 100644 --- a/internal/command/user_model.go +++ b/internal/command/user_model.go @@ -1,6 +1,8 @@ package command import ( + "context" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/user" @@ -122,6 +124,10 @@ func UserAggregateFromWriteModel(wm *eventstore.WriteModel) *eventstore.Aggregat return eventstore.AggregateFromWriteModel(wm, user.AggregateType, user.AggregateVersion) } +func UserAggregateFromWriteModelCtx(ctx context.Context, wm *eventstore.WriteModel) *eventstore.Aggregate { + return eventstore.AggregateFromWriteModelCtx(ctx, wm, user.AggregateType, user.AggregateVersion) +} + func isUserStateExists(state domain.UserState) bool { return !hasUserState(state, domain.UserStateDeleted, domain.UserStateUnspecified) } diff --git a/internal/command/user_v2_invite.go b/internal/command/user_v2_invite.go new file mode 100644 index 000000000..78b46a530 --- /dev/null +++ b/internal/command/user_v2_invite.go @@ -0,0 +1,193 @@ +package command + +import ( + "context" + "strings" + + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/user" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type CreateUserInvite struct { + UserID string + URLTemplate string + ReturnCode bool + ApplicationName string +} + +func (c *Commands) CreateInviteCode(ctx context.Context, invite *CreateUserInvite) (details *domain.ObjectDetails, returnCode *string, err error) { + invite.UserID = strings.TrimSpace(invite.UserID) + if invite.UserID == "" { + return nil, nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-4jio3", "Errors.User.UserIDMissing") + } + wm, err := c.userInviteCodeWriteModel(ctx, invite.UserID, "") + if err != nil { + return nil, nil, err + } + if err := c.checkPermission(ctx, domain.PermissionUserWrite, wm.ResourceOwner, wm.AggregateID); err != nil { + return nil, nil, err + } + if !wm.UserState.Exists() { + return nil, nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Wgvn4", "Errors.User.NotFound") + } + if !wm.CreationAllowed() { + return nil, nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-EF34g", "Errors.User.AlreadyInitialised") + } + code, err := c.newUserInviteCode(ctx, c.eventstore.Filter, c.userEncryption) //nolint + if err != nil { + return nil, nil, err + } + err = c.pushAppendAndReduce(ctx, wm, user.NewHumanInviteCodeAddedEvent( + ctx, + UserAggregateFromWriteModelCtx(ctx, &wm.WriteModel), + code.Crypted, + code.Expiry, + invite.URLTemplate, + invite.ReturnCode, + invite.ApplicationName, + "", + )) + if err != nil { + return nil, nil, err + } + if invite.ReturnCode { + returnCode = &code.Plain + } + return writeModelToObjectDetails(&wm.WriteModel), returnCode, nil +} + +// ResendInviteCode resends the invite mail with a new code and an optional authRequestID. +// It will reuse the applicationName from the previous code. +func (c *Commands) ResendInviteCode(ctx context.Context, userID, resourceOwner, authRequestID string) (objectDetails *domain.ObjectDetails, err error) { + if userID == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-2n8vs", "Errors.User.UserIDMissing") + } + + existingCode, err := c.userInviteCodeWriteModel(ctx, userID, resourceOwner) + if err != nil { + return nil, err + } + if authz.GetCtxData(ctx).UserID != userID { + if err := c.checkPermission(ctx, domain.PermissionUserWrite, existingCode.ResourceOwner, userID); err != nil { + return nil, err + } + } + if !existingCode.UserState.Exists() { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-H3b2a", "Errors.User.NotFound") + } + if !existingCode.CreationAllowed() { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Gg42s", "Errors.User.AlreadyInitialised") + } + if existingCode.InviteCode == nil || existingCode.CodeReturned { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Wr3gq", "Errors.User.Code.NotFound") + } + code, err := c.newUserInviteCode(ctx, c.eventstore.Filter, c.userEncryption) //nolint + if err != nil { + return nil, err + } + if authRequestID == "" { + authRequestID = existingCode.AuthRequestID + } + err = c.pushAppendAndReduce(ctx, existingCode, + user.NewHumanInviteCodeAddedEvent( + ctx, + UserAggregateFromWriteModelCtx(ctx, &existingCode.WriteModel), + code.Crypted, + code.Expiry, + existingCode.URLTemplate, + false, + existingCode.ApplicationName, + authRequestID, + )) + if err != nil { + return nil, err + } + return writeModelToObjectDetails(&existingCode.WriteModel), nil +} + +func (c *Commands) InviteCodeSent(ctx context.Context, userID, orgID string) (err error) { + if userID == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-Sgf31", "Errors.User.UserIDMissing") + } + existingCode, err := c.userInviteCodeWriteModel(ctx, userID, orgID) + if err != nil { + return err + } + if !existingCode.UserState.Exists() { + return zerrors.ThrowPreconditionFailed(nil, "COMMAND-HN34a", "Errors.User.NotFound") + } + if existingCode.InviteCode == nil || existingCode.CodeReturned { + return zerrors.ThrowPreconditionFailed(nil, "COMMAND-Wr3gq", "Errors.User.Code.NotFound") + } + userAgg := UserAggregateFromWriteModelCtx(ctx, &existingCode.WriteModel) + _, err = c.eventstore.Push(ctx, user.NewHumanInviteCodeSentEvent(ctx, userAgg)) + return err +} + +func (c *Commands) VerifyInviteCode(ctx context.Context, userID, code string) (details *domain.ObjectDetails, err error) { + return c.VerifyInviteCodeSetPassword(ctx, userID, code, "", "") +} + +func (c *Commands) VerifyInviteCodeSetPassword(ctx context.Context, userID, code, password, userAgentID string) (details *domain.ObjectDetails, err error) { + if userID == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-Gk3f2", "Errors.User.UserIDMissing") + } + wm, err := c.userInviteCodeWriteModel(ctx, userID, "") + if err != nil { + return nil, err + } + if !wm.UserState.Exists() { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-F5g2h", "Errors.User.NotFound") + } + userAgg := UserAggregateFromWriteModelCtx(ctx, &wm.WriteModel) + err = crypto.VerifyCode(wm.InviteCodeCreationDate, wm.InviteCodeExpiry, wm.InviteCode, code, c.userEncryption) + if err != nil { + _, err = c.eventstore.Push(ctx, user.NewHumanInviteCheckFailedEvent(ctx, userAgg)) + logging.WithFields("userID", userAgg.ID).OnError(err).Error("NewHumanInviteCheckFailedEvent push failed") + return nil, zerrors.ThrowInvalidArgument(err, "COMMAND-Wgn4q", "Errors.User.Code.Invalid") + } + commands := []eventstore.Command{ + user.NewHumanInviteCheckSucceededEvent(ctx, userAgg), + user.NewHumanEmailVerifiedEvent(ctx, userAgg), + } + if password != "" { + passwordCommand, err := c.setPasswordCommand( + ctx, + userAgg, + wm.UserState, + password, + "", + userAgentID, + false, + nil, + ) + if err != nil { + return nil, err + } + commands = append(commands, passwordCommand) + } + err = c.pushAppendAndReduce(ctx, wm, commands...) + if err != nil { + return nil, err + } + return writeModelToObjectDetails(&wm.WriteModel), nil +} + +func (c *Commands) userInviteCodeWriteModel(ctx context.Context, userID, orgID string) (writeModel *UserV2InviteWriteModel, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + writeModel = newUserV2InviteWriteModel(userID, orgID) + err = c.eventstore.FilterToQueryReducer(ctx, writeModel) + if err != nil { + return nil, err + } + return writeModel, nil +} diff --git a/internal/command/user_v2_invite_model.go b/internal/command/user_v2_invite_model.go new file mode 100644 index 000000000..23f6322a1 --- /dev/null +++ b/internal/command/user_v2_invite_model.go @@ -0,0 +1,141 @@ +package command + +import ( + "time" + + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/user" +) + +type UserV2InviteWriteModel struct { + eventstore.WriteModel + + InviteCode *crypto.CryptoValue + InviteCodeCreationDate time.Time + InviteCodeExpiry time.Duration + InviteCheckFailureCount uint8 + + ApplicationName string + AuthRequestID string + URLTemplate string + CodeReturned bool + EmailVerified bool + AuthMethodSet bool + + UserState domain.UserState +} + +func (wm *UserV2InviteWriteModel) CreationAllowed() bool { + return !wm.EmailVerified && !wm.AuthMethodSet +} + +func newUserV2InviteWriteModel(userID, orgID string) *UserV2InviteWriteModel { + return &UserV2InviteWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: userID, + ResourceOwner: orgID, + }, + } +} + +func (wm *UserV2InviteWriteModel) Reduce() error { + for _, event := range wm.Events { + switch e := event.(type) { + case *user.HumanAddedEvent: + wm.UserState = domain.UserStateActive + wm.AuthMethodSet = crypto.SecretOrEncodedHash(e.Secret, e.EncodedHash) != "" + wm.EmptyInviteCode() + wm.ApplicationName = "" + wm.AuthRequestID = "" + case *user.HumanRegisteredEvent: + wm.UserState = domain.UserStateActive + wm.AuthMethodSet = crypto.SecretOrEncodedHash(e.Secret, e.EncodedHash) != "" + wm.EmptyInviteCode() + wm.ApplicationName = "" + wm.AuthRequestID = "" + case *user.HumanInviteCodeAddedEvent: + wm.SetInviteCode(e.Code, e.Expiry, e.CreationDate()) + wm.URLTemplate = e.URLTemplate + wm.CodeReturned = e.CodeReturned + wm.ApplicationName = e.ApplicationName + wm.AuthRequestID = e.AuthRequestID + case *user.HumanInviteCheckSucceededEvent: + wm.EmptyInviteCode() + case *user.HumanInviteCheckFailedEvent: + wm.InviteCheckFailureCount++ + if wm.InviteCheckFailureCount >= 3 { //TODO: config? + wm.UserState = domain.UserStateDeleted + } + case *user.HumanEmailVerifiedEvent: + wm.EmailVerified = true + wm.EmptyInviteCode() + case *user.UserLockedEvent: + wm.UserState = domain.UserStateLocked + case *user.UserUnlockedEvent: + wm.UserState = domain.UserStateActive + case *user.UserDeactivatedEvent: + wm.UserState = domain.UserStateInactive + case *user.UserReactivatedEvent: + wm.UserState = domain.UserStateActive + case *user.UserRemovedEvent: + wm.UserState = domain.UserStateDeleted + case *user.HumanPasswordChangedEvent: + wm.AuthMethodSet = true + case *user.UserIDPLinkAddedEvent: + wm.AuthMethodSet = true + case *user.HumanPasswordlessVerifiedEvent: + wm.AuthMethodSet = true + } + } + return wm.WriteModel.Reduce() +} + +func (wm *UserV2InviteWriteModel) SetInviteCode(code *crypto.CryptoValue, expiry time.Duration, creationDate time.Time) { + wm.InviteCode = code + wm.InviteCodeExpiry = expiry + wm.InviteCodeCreationDate = creationDate + wm.InviteCheckFailureCount = 0 +} + +func (wm *UserV2InviteWriteModel) EmptyInviteCode() { + wm.InviteCode = nil + wm.InviteCodeExpiry = 0 + wm.InviteCodeCreationDate = time.Time{} + wm.InviteCheckFailureCount = 0 +} +func (wm *UserV2InviteWriteModel) Query() *eventstore.SearchQueryBuilder { + query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + AddQuery(). + AggregateTypes(user.AggregateType). + AggregateIDs(wm.AggregateID). + EventTypes( + user.UserV1AddedType, + user.HumanAddedType, + user.UserV1RegisteredType, + user.HumanRegisteredType, + user.HumanInviteCodeAddedType, + user.HumanInviteCheckSucceededType, + user.HumanInviteCheckFailedType, + user.UserV1EmailVerifiedType, + user.HumanEmailVerifiedType, + user.UserLockedType, + user.UserUnlockedType, + user.UserDeactivatedType, + user.UserReactivatedType, + user.UserRemovedType, + user.HumanPasswordChangedType, + user.UserV1PasswordChangedType, + user.UserIDPLinkAddedType, + user.HumanPasswordlessTokenVerifiedType, + ).Builder() + if wm.ResourceOwner != "" { + query.ResourceOwner(wm.ResourceOwner) + } + return query +} + +func (wm *UserV2InviteWriteModel) Aggregate() *user.Aggregate { + return user.NewAggregate(wm.AggregateID, wm.ResourceOwner) +} diff --git a/internal/command/user_v2_invite_test.go b/internal/command/user_v2_invite_test.go new file mode 100644 index 000000000..efb57d86a --- /dev/null +++ b/internal/command/user_v2_invite_test.go @@ -0,0 +1,1207 @@ +package command + +import ( + "context" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/org" + "github.com/zitadel/zitadel/internal/repository/user" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestCommands_CreateInviteCode(t *testing.T) { + type fields struct { + checkPermission domain.PermissionCheck + newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc + eventstore func(*testing.T) *eventstore.Eventstore + defaultSecretGenerators *SecretGenerators + } + type args struct { + ctx context.Context + invite *CreateUserInvite + } + type want struct { + details *domain.ObjectDetails + returnCode *string + err error + } + tests := []struct { + name string + fields fields + args args + want want + }{ + { + "user id missing", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: context.Background(), + invite: &CreateUserInvite{ + UserID: "", + }, + }, + want{ + err: zerrors.ThrowInvalidArgument(nil, "COMMAND-4jio3", "Errors.User.UserIDMissing"), + }, + }, + { + "missing permission", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args{ + ctx: context.Background(), + invite: &CreateUserInvite{ + UserID: "userID", + }, + }, + want{ + err: zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"), + }, + }, + { + "user does not exist", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: context.Background(), + invite: &CreateUserInvite{ + UserID: "unknown", + }, + }, + want{ + err: zerrors.ThrowPreconditionFailed(nil, "COMMAND-Wgvn4", "Errors.User.NotFound"), + }, + }, + { + "create ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", + "lastName", + "nickName", + "displayName", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + ), + expectPush( + eventFromEventPusher( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + "", + false, + "", + "", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("code", time.Hour), + defaultSecretGenerators: &SecretGenerators{}, + }, + args{ + ctx: context.Background(), + invite: &CreateUserInvite{ + UserID: "userID", + }, + }, + want{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "userID", + }, + returnCode: nil, + }, + }, + { + "return ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", + "lastName", + "nickName", + "displayName", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + ), + expectPush( + eventFromEventPusher( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + "", + true, + "", + "", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("code", time.Hour), + defaultSecretGenerators: &SecretGenerators{}, + }, + args{ + ctx: context.Background(), + invite: &CreateUserInvite{ + UserID: "userID", + ReturnCode: true, + }, + }, + want{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "userID", + }, + returnCode: gu.Ptr("code"), + }, + }, + { + "with template and application name ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", + "lastName", + "nickName", + "displayName", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + ), + expectPush( + eventFromEventPusher( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + "https://example.com/invite?userID={{.UserID}}", + false, + "applicationName", + "", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("code", time.Hour), + defaultSecretGenerators: &SecretGenerators{}, + }, + args{ + ctx: context.Background(), + invite: &CreateUserInvite{ + UserID: "userID", + URLTemplate: "https://example.com/invite?userID={{.UserID}}", + ReturnCode: false, + ApplicationName: "applicationName", + }, + }, + want{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "userID", + }, + returnCode: nil, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + checkPermission: tt.fields.checkPermission, + newEncryptedCodeWithDefault: tt.fields.newEncryptedCodeWithDefault, + eventstore: tt.fields.eventstore(t), + defaultSecretGenerators: tt.fields.defaultSecretGenerators, + } + gotDetails, gotReturnCode, err := c.CreateInviteCode(tt.args.ctx, tt.args.invite) + + require.ErrorIs(t, err, tt.want.err) + assert.Equal(t, tt.want.details, gotDetails) + assert.Equal(t, tt.want.returnCode, gotReturnCode) + }) + } +} + +func TestCommands_ResendInviteCode(t *testing.T) { + type fields struct { + checkPermission domain.PermissionCheck + newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc + eventstore func(*testing.T) *eventstore.Eventstore + defaultSecretGenerators *SecretGenerators + } + type args struct { + ctx context.Context + userID string + orgID string + authRequestID string + } + type want struct { + details *domain.ObjectDetails + err error + } + tests := []struct { + name string + fields fields + args args + want want + }{ + { + "missing user id", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: context.Background(), + userID: "", + }, + want{ + err: zerrors.ThrowInvalidArgument(nil, "COMMAND-2n8vs", "Errors.User.UserIDMissing"), + }, + }, + { + "missing permission", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args{ + ctx: context.Background(), + userID: "userID", + }, + want{ + err: zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"), + }, + }, + { + "user does not exist", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: context.Background(), + userID: "unknown", + }, + want{ + err: zerrors.ThrowPreconditionFailed(nil, "COMMAND-H3b2a", "Errors.User.NotFound"), + }, + }, + { + "no previous code", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", + "lastName", + "nickName", + "displayName", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: context.Background(), + userID: "userID", + }, + want{ + err: zerrors.ThrowPreconditionFailed(nil, "COMMAND-Wr3gq", "Errors.User.Code.NotFound"), + }, + }, + { + "previous code returned", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", + "lastName", + "nickName", + "displayName", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + eventFromEventPusher( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + "", + true, + "", + "", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: context.Background(), + userID: "userID", + }, + want{ + err: zerrors.ThrowPreconditionFailed(nil, "COMMAND-Wr3gq", "Errors.User.Code.NotFound"), + }, + }, + { + "resend ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", + "lastName", + "nickName", + "displayName", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + eventFromEventPusher( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + "", + false, + "", + "authRequestID", + ), + ), + ), + expectPush( + eventFromEventPusher( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + "", + false, + "", + "authRequestID", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("code", time.Hour), + defaultSecretGenerators: &SecretGenerators{}, + }, + args{ + ctx: context.Background(), + userID: "userID", + }, + want{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "userID", + }, + }, + }, + { + "resend with new auth requestID ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", + "lastName", + "nickName", + "displayName", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + eventFromEventPusher( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + "", + false, + "", + "authRequestID", + ), + ), + ), + expectPush( + eventFromEventPusher( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + "", + false, + "", + "authRequestID2", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("code", time.Hour), + defaultSecretGenerators: &SecretGenerators{}, + }, + args{ + ctx: context.Background(), + userID: "userID", + authRequestID: "authRequestID2", + }, + want{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "userID", + }, + }, + }, + { + "resend with own user ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", + "lastName", + "nickName", + "displayName", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + eventFromEventPusher( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + "", + false, + "", + "authRequestID", + ), + ), + ), + expectPush( + eventFromEventPusher( + user.NewHumanInviteCodeAddedEvent(authz.NewMockContext("instanceID", "org1", "userID"), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + "", + false, + "", + "authRequestID2", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), // user does not have permission, is allowed in the own context + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("code", time.Hour), + defaultSecretGenerators: &SecretGenerators{}, + }, + args{ + ctx: authz.NewMockContext("instanceID", "org1", "userID"), + userID: "userID", + authRequestID: "authRequestID2", + }, + want{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "userID", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + checkPermission: tt.fields.checkPermission, + newEncryptedCodeWithDefault: tt.fields.newEncryptedCodeWithDefault, + eventstore: tt.fields.eventstore(t), + defaultSecretGenerators: tt.fields.defaultSecretGenerators, + } + details, err := c.ResendInviteCode(tt.args.ctx, tt.args.userID, tt.args.orgID, tt.args.authRequestID) + assert.ErrorIs(t, err, tt.want.err) + assert.Equal(t, tt.want.details, details) + }) + } +} + +func TestCommands_InviteCodeSent(t *testing.T) { + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + } + type args struct { + ctx context.Context + userID string + orgID string + } + tests := []struct { + name string + fields fields + args args + wantErr error + }{ + { + "missing user id", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: context.Background(), + userID: "", + }, + zerrors.ThrowInvalidArgument(nil, "COMMAND-Sgf31", "Errors.User.UserIDMissing"), + }, + { + "user does not exist", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args{ + ctx: context.Background(), + userID: "unknown", + }, + zerrors.ThrowPreconditionFailed(nil, "COMMAND-HN34a", "Errors.User.NotFound"), + }, + { + "code does not exist", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", + "lastName", + "nickName", + "displayName", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + ), + ), + }, + args{ + ctx: context.Background(), + userID: "unknown", + }, + zerrors.ThrowPreconditionFailed(nil, "COMMAND-Wr3gq", "Errors.User.Code.NotFound"), + }, + { + "sent ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", + "lastName", + "nickName", + "displayName", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + eventFromEventPusher( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + "", + false, + "", + "authRequestID", + ), + ), + ), + expectPush( + eventFromEventPusher( + user.NewHumanInviteCodeSentEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + ), + ), + ), + ), + }, + args{ + ctx: context.Background(), + userID: "userID", + }, + nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + } + err := c.InviteCodeSent(tt.args.ctx, tt.args.userID, tt.args.orgID) + assert.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestCommands_VerifyInviteCode(t *testing.T) { + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + userEncryption crypto.EncryptionAlgorithm + } + type args struct { + ctx context.Context + userID string + code string + } + type want struct { + details *domain.ObjectDetails + err error + } + tests := []struct { + name string + fields fields + args args + want want + }{ + { + "code ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", + "lastName", + "nickName", + "displayName", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + eventFromEventPusherWithCreationDateNow( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + "", + false, + "", + "authRequestID", + ), + ), + ), + expectPush( + eventFromEventPusher( + user.NewHumanInviteCheckSucceededEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + ), + ), + eventFromEventPusher( + user.NewHumanEmailVerifiedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + ), + ), + ), + ), + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args{ + ctx: context.Background(), + userID: "userID", + code: "code", + }, + want{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "userID", + }, + }, + }, + // all other cases are tested in TestCommands_VerifyInviteCodeSetPassword + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + userEncryption: tt.fields.userEncryption, + } + gotDetails, err := c.VerifyInviteCode(tt.args.ctx, tt.args.userID, tt.args.code) + assert.ErrorIs(t, err, tt.want.err) + assert.Equal(t, tt.want.details, gotDetails) + }) + } +} + +func TestCommands_VerifyInviteCodeSetPassword(t *testing.T) { + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + userEncryption crypto.EncryptionAlgorithm + userPasswordHasher *crypto.Hasher + } + type args struct { + ctx context.Context + userID string + code string + password string + userAgentID string + } + type want struct { + details *domain.ObjectDetails + err error + } + tests := []struct { + name string + fields fields + args args + want want + }{ + { + "missing user id", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: context.Background(), + userID: "", + }, + want{ + err: zerrors.ThrowInvalidArgument(nil, "COMMAND-Gk3f2", "Errors.User.UserIDMissing"), + }, + }, + { + "user does not exist", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args{ + ctx: context.Background(), + userID: "unknown", + }, + want{ + err: zerrors.ThrowPreconditionFailed(nil, "COMMAND-F5g2h", "Errors.User.NotFound"), + }, + }, + { + "invalid code", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", + "lastName", + "nickName", + "displayName", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + eventFromEventPusherWithCreationDateNow( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + "", + false, + "", + "authRequestID", + ), + ), + ), + expectPush( + eventFromEventPusher( + user.NewHumanInviteCheckFailedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + ), + ), + ), + ), + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args{ + ctx: context.Background(), + userID: "userID", + code: "invalid", + }, + want{ + err: zerrors.ThrowInvalidArgument(nil, "COMMAND-Wgn4q", "Errors.User.Code.Invalid"), + }, + }, + { + "code ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", + "lastName", + "nickName", + "displayName", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + eventFromEventPusherWithCreationDateNow( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + "", + false, + "", + "authRequestID", + ), + ), + ), + expectPush( + eventFromEventPusher( + user.NewHumanInviteCheckSucceededEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + ), + ), + eventFromEventPusher( + user.NewHumanEmailVerifiedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + ), + ), + ), + ), + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args{ + ctx: context.Background(), + userID: "userID", + code: "code", + }, + want{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "userID", + }, + }, + }, + { + "code ok, with password and user agent", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", + "lastName", + "nickName", + "displayName", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + eventFromEventPusherWithCreationDateNow( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + "", + false, + "", + "authRequestID", + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + 6, + true, + true, + true, + true, + ), + ), + ), + expectPush( + eventFromEventPusher( + user.NewHumanInviteCheckSucceededEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + ), + ), + eventFromEventPusher( + user.NewHumanEmailVerifiedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + ), + ), + eventFromEventPusher( + user.NewHumanPasswordChangedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "$plain$x$Password1!", + false, + "userAgentID", + ), + ), + ), + ), + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + userPasswordHasher: mockPasswordHasher("x"), + }, + args{ + ctx: context.Background(), + userID: "userID", + code: "code", + password: "Password1!", + userAgentID: "userAgentID", + }, + want{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "userID", + }, + }, + }, + { + "code ok, with non compliant password", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", + "lastName", + "nickName", + "displayName", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + eventFromEventPusherWithCreationDateNow( + user.NewHumanInviteCodeAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + time.Hour, + "", + false, + "", + "authRequestID", + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + 6, + true, + true, + true, + true, + ), + ), + ), + ), + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + userPasswordHasher: mockPasswordHasher("x"), + }, + args{ + ctx: context.Background(), + userID: "userID", + code: "code", + password: "pw", + userAgentID: "userAgentID", + }, + want{ + err: zerrors.ThrowInvalidArgument(nil, "DOMAIN-HuJf6", "Errors.User.PasswordComplexityPolicy.MinLength"), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + userEncryption: tt.fields.userEncryption, + userPasswordHasher: tt.fields.userPasswordHasher, + } + gotDetails, err := c.VerifyInviteCodeSetPassword(tt.args.ctx, tt.args.userID, tt.args.code, tt.args.password, tt.args.userAgentID) + assert.ErrorIs(t, err, tt.want.err) + assert.Equal(t, tt.want.details, gotDetails) + }) + } +} diff --git a/internal/command/user_v2_passkey.go b/internal/command/user_v2_passkey.go index 4d42d105d..897a1ab41 100644 --- a/internal/command/user_v2_passkey.go +++ b/internal/command/user_v2_passkey.go @@ -48,7 +48,7 @@ func (c *Commands) verifyUserPasskeyCode(ctx context.Context, userID, resourceOw if err != nil { return nil, err } - err = verifyEncryptedCode(ctx, c.eventstore.Filter, domain.SecretGeneratorTypePasswordlessInitCode, alg, wm.ChangeDate, wm.Expiration, wm.CryptoCode, code) //nolint:staticcheck + err = crypto.VerifyCode(wm.ChangeDate, wm.Expiration, wm.CryptoCode, code, alg) if err != nil || wm.State != domain.PasswordlessInitCodeStateActive { c.verifyUserPasskeyCodeFailed(ctx, wm) return nil, zerrors.ThrowInvalidArgument(err, "COMMAND-Eeb2a", "Errors.User.Code.Invalid") diff --git a/internal/command/user_v2_passkey_test.go b/internal/command/user_v2_passkey_test.go index aa2ded6d7..a6ba470d2 100644 --- a/internal/command/user_v2_passkey_test.go +++ b/internal/command/user_v2_passkey_test.go @@ -143,7 +143,7 @@ func TestCommands_RegisterUserPasskeyWithCode(t *testing.T) { require.NoError(t, err) userAgg := &user.NewAggregate("user1", "org1").Aggregate type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore idGenerator id.Generator } type args struct { @@ -163,7 +163,7 @@ func TestCommands_RegisterUserPasskeyWithCode(t *testing.T) { { name: "code verification error", fields: fields{ - eventstore: eventstoreExpect(t, + eventstore: expectEventstore( expectFilter( eventFromEventPusherWithCreationDateNow( user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(), @@ -174,7 +174,6 @@ func TestCommands_RegisterUserPasskeyWithCode(t *testing.T) { user.NewHumanPasswordlessInitCodeSentEvent(ctx, userAgg, "123"), ), ), - expectFilter(eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypePasswordlessInitCode))), expectPush( user.NewHumanPasswordlessInitCodeCheckFailedEvent(ctx, userAgg, "123"), ), @@ -192,7 +191,7 @@ func TestCommands_RegisterUserPasskeyWithCode(t *testing.T) { { name: "code verification ok, get human passwordless error", fields: fields{ - eventstore: eventstoreExpect(t, + eventstore: expectEventstore( expectFilter( eventFromEventPusherWithCreationDateNow( user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(), @@ -203,7 +202,6 @@ func TestCommands_RegisterUserPasskeyWithCode(t *testing.T) { user.NewHumanPasswordlessInitCodeSentEvent(ctx, userAgg, "123"), ), ), - expectFilter(eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypePasswordlessInitCode))), expectFilterError(io.ErrClosedPipe), ), }, @@ -220,7 +218,7 @@ func TestCommands_RegisterUserPasskeyWithCode(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), idGenerator: tt.fields.idGenerator, webauthnConfig: webauthnConfig, } @@ -242,7 +240,7 @@ func TestCommands_verifyUserPasskeyCode(t *testing.T) { userAgg := &user.NewAggregate("user1", "org1").Aggregate type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { userID string @@ -260,7 +258,7 @@ func TestCommands_verifyUserPasskeyCode(t *testing.T) { { name: "filter error", fields: fields{ - eventstore: eventstoreExpect(t, + eventstore: expectEventstore( expectFilterError(io.ErrClosedPipe), ), }, @@ -274,7 +272,7 @@ func TestCommands_verifyUserPasskeyCode(t *testing.T) { { name: "code verification error", fields: fields{ - eventstore: eventstoreExpect(t, + eventstore: expectEventstore( expectFilter( eventFromEventPusherWithCreationDateNow( user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(), @@ -285,7 +283,6 @@ func TestCommands_verifyUserPasskeyCode(t *testing.T) { user.NewHumanPasswordlessInitCodeSentEvent(ctx, userAgg, "123"), ), ), - expectFilter(eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypePasswordlessInitCode))), expectPush( user.NewHumanPasswordlessInitCodeCheckFailedEvent(ctx, userAgg, "123"), ), @@ -302,7 +299,7 @@ func TestCommands_verifyUserPasskeyCode(t *testing.T) { { name: "success", fields: fields{ - eventstore: eventstoreExpect(t, + eventstore: expectEventstore( expectFilter( eventFromEventPusherWithCreationDateNow( user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(), @@ -313,7 +310,6 @@ func TestCommands_verifyUserPasskeyCode(t *testing.T) { user.NewHumanPasswordlessInitCodeSentEvent(ctx, userAgg, "123"), ), ), - expectFilter(eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypePasswordlessInitCode))), ), }, args: args{ @@ -328,7 +324,7 @@ func TestCommands_verifyUserPasskeyCode(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } got, err := c.verifyUserPasskeyCode(ctx, tt.args.userID, tt.args.resourceOwner, tt.args.codeID, tt.args.code, alg) require.ErrorIs(t, err, tt.wantErr) diff --git a/internal/domain/custom_message_text.go b/internal/domain/custom_message_text.go index 4ae0a3925..aa7361545 100644 --- a/internal/domain/custom_message_text.go +++ b/internal/domain/custom_message_text.go @@ -17,6 +17,7 @@ const ( DomainClaimedMessageType = "DomainClaimed" PasswordlessRegistrationMessageType = "PasswordlessRegistration" PasswordChangeMessageType = "PasswordChange" + InviteUserMessageType = "InviteUser" MessageTitle = "Title" MessagePreHeader = "PreHeader" MessageSubject = "Subject" @@ -26,16 +27,6 @@ const ( MessageFooterText = "Footer" ) -type MessageTexts struct { - InitCode CustomMessageText - PasswordReset CustomMessageText - VerifyEmail CustomMessageText - VerifyPhone CustomMessageText - DomainClaimed CustomMessageText - PasswordlessRegistration CustomMessageText - PasswordChange CustomMessageText -} - type CustomMessageText struct { models.ObjectRoot @@ -71,5 +62,6 @@ func IsMessageTextType(textType string) bool { textType == VerifyEmailOTPMessageType || textType == DomainClaimedMessageType || textType == PasswordlessRegistrationMessageType || - textType == PasswordChangeMessageType + textType == PasswordChangeMessageType || + textType == InviteUserMessageType } diff --git a/internal/domain/next_step.go b/internal/domain/next_step.go index 009989956..7242d6eb1 100644 --- a/internal/domain/next_step.go +++ b/internal/domain/next_step.go @@ -29,6 +29,7 @@ const ( NextStepProjectRequired NextStepRedirectToExternalIDP NextStepLoginSucceeded + NextStepVerifyInvite ) type LoginStep struct{} @@ -191,3 +192,9 @@ type LoginSucceededStep struct{} func (s *LoginSucceededStep) Type() NextStepType { return NextStepLoginSucceeded } + +type VerifyInviteStep struct{} + +func (s *VerifyInviteStep) Type() NextStepType { + return NextStepVerifyInvite +} diff --git a/internal/domain/secret_generator.go b/internal/domain/secret_generator.go index 10afb774a..855e3447c 100644 --- a/internal/domain/secret_generator.go +++ b/internal/domain/secret_generator.go @@ -14,6 +14,7 @@ const ( SecretGeneratorTypeAppSecret SecretGeneratorTypeOTPSMS SecretGeneratorTypeOTPEmail + SecretGeneratorTypeInviteCode secretGeneratorTypeCount ) diff --git a/internal/domain/secretgeneratortype_enumer.go b/internal/domain/secretgeneratortype_enumer.go index 92e0ead33..f819bafc1 100644 --- a/internal/domain/secretgeneratortype_enumer.go +++ b/internal/domain/secretgeneratortype_enumer.go @@ -4,14 +4,11 @@ package domain import ( "fmt" - "strings" ) -const _SecretGeneratorTypeName = "unspecifiedinit_codeverify_email_codeverify_phone_codeverify_domainpassword_reset_codepasswordless_init_codeapp_secretotpsmsotp_emailsecret_generator_type_count" +const _SecretGeneratorTypeName = "unspecifiedinit_codeverify_email_codeverify_phone_codeverify_domainpassword_reset_codepasswordless_init_codeapp_secretotpsmsotp_emailinvite_codesecret_generator_type_count" -var _SecretGeneratorTypeIndex = [...]uint8{0, 11, 20, 37, 54, 67, 86, 108, 118, 124, 133, 160} - -const _SecretGeneratorTypeLowerName = "unspecifiedinit_codeverify_email_codeverify_phone_codeverify_domainpassword_reset_codepasswordless_init_codeapp_secretotpsmsotp_emailsecret_generator_type_count" +var _SecretGeneratorTypeIndex = [...]uint8{0, 11, 20, 37, 54, 67, 86, 108, 118, 124, 133, 144, 171} func (i SecretGeneratorType) String() string { if i < 0 || i >= SecretGeneratorType(len(_SecretGeneratorTypeIndex)-1) { @@ -20,62 +17,21 @@ func (i SecretGeneratorType) String() string { return _SecretGeneratorTypeName[_SecretGeneratorTypeIndex[i]:_SecretGeneratorTypeIndex[i+1]] } -// An "invalid array index" compiler error signifies that the constant values have changed. -// Re-run the stringer command to generate them again. -func _SecretGeneratorTypeNoOp() { - var x [1]struct{} - _ = x[SecretGeneratorTypeUnspecified-(0)] - _ = x[SecretGeneratorTypeInitCode-(1)] - _ = x[SecretGeneratorTypeVerifyEmailCode-(2)] - _ = x[SecretGeneratorTypeVerifyPhoneCode-(3)] - _ = x[SecretGeneratorTypeVerifyDomain-(4)] - _ = x[SecretGeneratorTypePasswordResetCode-(5)] - _ = x[SecretGeneratorTypePasswordlessInitCode-(6)] - _ = x[SecretGeneratorTypeAppSecret-(7)] - _ = x[SecretGeneratorTypeOTPSMS-(8)] - _ = x[SecretGeneratorTypeOTPEmail-(9)] - _ = x[secretGeneratorTypeCount-(10)] -} - -var _SecretGeneratorTypeValues = []SecretGeneratorType{SecretGeneratorTypeUnspecified, SecretGeneratorTypeInitCode, SecretGeneratorTypeVerifyEmailCode, SecretGeneratorTypeVerifyPhoneCode, SecretGeneratorTypeVerifyDomain, SecretGeneratorTypePasswordResetCode, SecretGeneratorTypePasswordlessInitCode, SecretGeneratorTypeAppSecret, SecretGeneratorTypeOTPSMS, SecretGeneratorTypeOTPEmail, secretGeneratorTypeCount} +var _SecretGeneratorTypeValues = []SecretGeneratorType{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11} var _SecretGeneratorTypeNameToValueMap = map[string]SecretGeneratorType{ - _SecretGeneratorTypeName[0:11]: SecretGeneratorTypeUnspecified, - _SecretGeneratorTypeLowerName[0:11]: SecretGeneratorTypeUnspecified, - _SecretGeneratorTypeName[11:20]: SecretGeneratorTypeInitCode, - _SecretGeneratorTypeLowerName[11:20]: SecretGeneratorTypeInitCode, - _SecretGeneratorTypeName[20:37]: SecretGeneratorTypeVerifyEmailCode, - _SecretGeneratorTypeLowerName[20:37]: SecretGeneratorTypeVerifyEmailCode, - _SecretGeneratorTypeName[37:54]: SecretGeneratorTypeVerifyPhoneCode, - _SecretGeneratorTypeLowerName[37:54]: SecretGeneratorTypeVerifyPhoneCode, - _SecretGeneratorTypeName[54:67]: SecretGeneratorTypeVerifyDomain, - _SecretGeneratorTypeLowerName[54:67]: SecretGeneratorTypeVerifyDomain, - _SecretGeneratorTypeName[67:86]: SecretGeneratorTypePasswordResetCode, - _SecretGeneratorTypeLowerName[67:86]: SecretGeneratorTypePasswordResetCode, - _SecretGeneratorTypeName[86:108]: SecretGeneratorTypePasswordlessInitCode, - _SecretGeneratorTypeLowerName[86:108]: SecretGeneratorTypePasswordlessInitCode, - _SecretGeneratorTypeName[108:118]: SecretGeneratorTypeAppSecret, - _SecretGeneratorTypeLowerName[108:118]: SecretGeneratorTypeAppSecret, - _SecretGeneratorTypeName[118:124]: SecretGeneratorTypeOTPSMS, - _SecretGeneratorTypeLowerName[118:124]: SecretGeneratorTypeOTPSMS, - _SecretGeneratorTypeName[124:133]: SecretGeneratorTypeOTPEmail, - _SecretGeneratorTypeLowerName[124:133]: SecretGeneratorTypeOTPEmail, - _SecretGeneratorTypeName[133:160]: secretGeneratorTypeCount, - _SecretGeneratorTypeLowerName[133:160]: secretGeneratorTypeCount, -} - -var _SecretGeneratorTypeNames = []string{ - _SecretGeneratorTypeName[0:11], - _SecretGeneratorTypeName[11:20], - _SecretGeneratorTypeName[20:37], - _SecretGeneratorTypeName[37:54], - _SecretGeneratorTypeName[54:67], - _SecretGeneratorTypeName[67:86], - _SecretGeneratorTypeName[86:108], - _SecretGeneratorTypeName[108:118], - _SecretGeneratorTypeName[118:124], - _SecretGeneratorTypeName[124:133], - _SecretGeneratorTypeName[133:160], + _SecretGeneratorTypeName[0:11]: 0, + _SecretGeneratorTypeName[11:20]: 1, + _SecretGeneratorTypeName[20:37]: 2, + _SecretGeneratorTypeName[37:54]: 3, + _SecretGeneratorTypeName[54:67]: 4, + _SecretGeneratorTypeName[67:86]: 5, + _SecretGeneratorTypeName[86:108]: 6, + _SecretGeneratorTypeName[108:118]: 7, + _SecretGeneratorTypeName[118:124]: 8, + _SecretGeneratorTypeName[124:133]: 9, + _SecretGeneratorTypeName[133:144]: 10, + _SecretGeneratorTypeName[144:171]: 11, } // SecretGeneratorTypeString retrieves an enum value from the enum constants string name. @@ -84,10 +40,6 @@ func SecretGeneratorTypeString(s string) (SecretGeneratorType, error) { if val, ok := _SecretGeneratorTypeNameToValueMap[s]; ok { return val, nil } - - if val, ok := _SecretGeneratorTypeNameToValueMap[strings.ToLower(s)]; ok { - return val, nil - } return 0, fmt.Errorf("%s does not belong to SecretGeneratorType values", s) } @@ -96,13 +48,6 @@ func SecretGeneratorTypeValues() []SecretGeneratorType { return _SecretGeneratorTypeValues } -// SecretGeneratorTypeStrings returns a slice of all String values of the enum -func SecretGeneratorTypeStrings() []string { - strs := make([]string, len(_SecretGeneratorTypeNames)) - copy(strs, _SecretGeneratorTypeNames) - return strs -} - // IsASecretGeneratorType returns "true" if the value is listed in the enum definition. "false" otherwise func (i SecretGeneratorType) IsASecretGeneratorType() bool { for _, v := range _SecretGeneratorTypeValues { diff --git a/internal/integration/client.go b/internal/integration/client.go index 404ec0162..82b8ab3b6 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -775,3 +775,12 @@ func (i *Instance) CreateSchemaUser(ctx context.Context, orgID string, schemaID logging.OnError(err).Fatal("create user") return user } + +func (i *Instance) CreateInviteCode(ctx context.Context, userID string) *user_v2.CreateInviteCodeResponse { + user, err := i.Client.UserV2.CreateInviteCode(ctx, &user_v2.CreateInviteCodeRequest{ + UserId: userID, + Verification: &user_v2.CreateInviteCodeRequest_ReturnCode{ReturnCode: &user_v2.ReturnInviteCode{}}, + }) + logging.OnError(err).Fatal("create invite code") + return user +} diff --git a/internal/notification/handlers/commands.go b/internal/notification/handlers/commands.go index 5308a27c4..3d8546f80 100644 --- a/internal/notification/handlers/commands.go +++ b/internal/notification/handlers/commands.go @@ -19,6 +19,7 @@ type Commands interface { 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 + InviteCodeSent(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/mock/commands.mock.go b/internal/notification/handlers/mock/commands.mock.go index 3105dcdf6..ab94eda2c 100644 --- a/internal/notification/handlers/mock/commands.mock.go +++ b/internal/notification/handlers/mock/commands.mock.go @@ -18,30 +18,30 @@ import ( gomock "go.uber.org/mock/gomock" ) -// MockCommands is a mock of Commands interface. +// MockCommands is a mock of Commands interface type MockCommands struct { ctrl *gomock.Controller recorder *MockCommandsMockRecorder } -// MockCommandsMockRecorder is the mock recorder for MockCommands. +// MockCommandsMockRecorder is the mock recorder for MockCommands type MockCommandsMockRecorder struct { mock *MockCommands } -// NewMockCommands creates a new mock instance. +// 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. +// EXPECT returns an object that allows the caller to indicate expected use func (m *MockCommands) EXPECT() *MockCommandsMockRecorder { return m.recorder } -// HumanEmailVerificationCodeSent mocks base method. +// 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) @@ -49,13 +49,13 @@ func (m *MockCommands) HumanEmailVerificationCodeSent(arg0 context.Context, arg1 return ret0 } -// HumanEmailVerificationCodeSent indicates an expected call of HumanEmailVerificationCodeSent. -func (mr *MockCommandsMockRecorder) HumanEmailVerificationCodeSent(arg0, arg1, arg2 any) *gomock.Call { +// 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. +// 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) @@ -63,13 +63,13 @@ func (m *MockCommands) HumanInitCodeSent(arg0 context.Context, arg1, arg2 string return ret0 } -// HumanInitCodeSent indicates an expected call of HumanInitCodeSent. -func (mr *MockCommandsMockRecorder) HumanInitCodeSent(arg0, arg1, arg2 any) *gomock.Call { +// 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. +// 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) @@ -77,13 +77,13 @@ func (m *MockCommands) HumanOTPEmailCodeSent(arg0 context.Context, arg1, arg2 st return ret0 } -// HumanOTPEmailCodeSent indicates an expected call of HumanOTPEmailCodeSent. -func (mr *MockCommandsMockRecorder) HumanOTPEmailCodeSent(arg0, arg1, arg2 any) *gomock.Call { +// 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. +// 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) @@ -91,13 +91,13 @@ func (m *MockCommands) HumanOTPSMSCodeSent(arg0 context.Context, arg1, arg2 stri return ret0 } -// HumanOTPSMSCodeSent indicates an expected call of HumanOTPSMSCodeSent. -func (mr *MockCommandsMockRecorder) HumanOTPSMSCodeSent(arg0, arg1, arg2 any) *gomock.Call { +// 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. +// 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) @@ -105,13 +105,13 @@ func (m *MockCommands) HumanPasswordlessInitCodeSent(arg0 context.Context, arg1, return ret0 } -// HumanPasswordlessInitCodeSent indicates an expected call of HumanPasswordlessInitCodeSent. -func (mr *MockCommandsMockRecorder) HumanPasswordlessInitCodeSent(arg0, arg1, arg2, arg3 any) *gomock.Call { +// 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. +// 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) @@ -119,13 +119,27 @@ func (m *MockCommands) HumanPhoneVerificationCodeSent(arg0 context.Context, arg1 return ret0 } -// HumanPhoneVerificationCodeSent indicates an expected call of HumanPhoneVerificationCodeSent. -func (mr *MockCommandsMockRecorder) HumanPhoneVerificationCodeSent(arg0, arg1, arg2 any) *gomock.Call { +// 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. +// InviteCodeSent mocks base method +func (m *MockCommands) InviteCodeSent(arg0 context.Context, arg1, arg2 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InviteCodeSent", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// InviteCodeSent indicates an expected call of InviteCodeSent +func (mr *MockCommandsMockRecorder) InviteCodeSent(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InviteCodeSent", reflect.TypeOf((*MockCommands)(nil).InviteCodeSent), 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) @@ -133,13 +147,13 @@ func (m *MockCommands) MilestonePushed(arg0 context.Context, arg1 milestone.Type return ret0 } -// MilestonePushed indicates an expected call of MilestonePushed. -func (mr *MockCommandsMockRecorder) MilestonePushed(arg0, arg1, arg2, arg3 any) *gomock.Call { +// 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. +// 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) @@ -147,13 +161,13 @@ func (m *MockCommands) OTPEmailSent(arg0 context.Context, arg1, arg2 string) err return ret0 } -// OTPEmailSent indicates an expected call of OTPEmailSent. -func (mr *MockCommandsMockRecorder) OTPEmailSent(arg0, arg1, arg2 any) *gomock.Call { +// 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. +// 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) @@ -161,13 +175,13 @@ func (m *MockCommands) OTPSMSSent(arg0 context.Context, arg1, arg2 string) error return ret0 } -// OTPSMSSent indicates an expected call of OTPSMSSent. -func (mr *MockCommandsMockRecorder) OTPSMSSent(arg0, arg1, arg2 any) *gomock.Call { +// 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. +// 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) @@ -175,13 +189,13 @@ func (m *MockCommands) PasswordChangeSent(arg0 context.Context, arg1, arg2 strin return ret0 } -// PasswordChangeSent indicates an expected call of PasswordChangeSent. -func (mr *MockCommandsMockRecorder) PasswordChangeSent(arg0, arg1, arg2 any) *gomock.Call { +// 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. +// 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) @@ -189,13 +203,13 @@ func (m *MockCommands) PasswordCodeSent(arg0 context.Context, arg1, arg2 string) return ret0 } -// PasswordCodeSent indicates an expected call of PasswordCodeSent. -func (mr *MockCommandsMockRecorder) PasswordCodeSent(arg0, arg1, arg2 any) *gomock.Call { +// 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. +// 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) @@ -203,13 +217,13 @@ func (m *MockCommands) UsageNotificationSent(arg0 context.Context, arg1 *quota.N return ret0 } -// UsageNotificationSent indicates an expected call of UsageNotificationSent. -func (mr *MockCommandsMockRecorder) UsageNotificationSent(arg0, arg1 any) *gomock.Call { +// 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. +// 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) @@ -217,8 +231,8 @@ func (m *MockCommands) UserDomainClaimedSent(arg0 context.Context, arg1, arg2 st return ret0 } -// UserDomainClaimedSent indicates an expected call of UserDomainClaimedSent. -func (mr *MockCommandsMockRecorder) UserDomainClaimedSent(arg0, arg1, arg2 any) *gomock.Call { +// 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/user_notifier.go b/internal/notification/handlers/user_notifier.go index 1826e9d79..57aa3a925 100644 --- a/internal/notification/handlers/user_notifier.go +++ b/internal/notification/handlers/user_notifier.go @@ -106,6 +106,10 @@ func (u *userNotifier) Reducers() []handler.AggregateReducer { Event: user.HumanOTPEmailCodeAddedType, Reduce: u.reduceOTPEmailCodeAdded, }, + { + Event: user.HumanInviteCodeAddedType, + Reduce: u.reduceInviteCodeAdded, + }, }, }, { @@ -718,6 +722,61 @@ func (u *userNotifier) reducePhoneCodeAdded(event eventstore.Event) (*handler.St }), nil } +func (u *userNotifier) reduceInviteCodeAdded(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*user.HumanInviteCodeAddedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Eeg3s", "reduce.wrong.event.type %s", user.HumanInviteCodeAddedType) + } + if e.CodeReturned { + return handler.NewNoOpStatement(e), nil + } + + return handler.NewStatement(event, func(ex handler.Executer, projectionName string) error { + ctx := HandlerContext(event.Aggregate()) + alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil, + user.HumanInviteCodeAddedType, user.HumanInviteCodeSentType) + if err != nil { + return err + } + if alreadyHandled { + return nil + } + code, err := crypto.DecryptString(e.Code, u.queries.UserDataCrypto) + if err != nil { + return err + } + colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false) + if err != nil { + return err + } + + template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false) + if err != nil { + return err + } + + notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID) + if err != nil { + return err + } + translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.InviteUserMessageType) + if err != nil { + return err + } + + ctx, err = u.queries.Origin(ctx, e) + if err != nil { + return err + } + notify := types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e) + err = notify.SendInviteCode(ctx, notifyUser, code, e.ApplicationName, e.URLTemplate, e.AuthRequestID) + if err != nil { + return err + } + return u.commands.InviteCodeSent(ctx, e.Aggregate().ID, e.Aggregate().ResourceOwner) + }), nil +} + func (u *userNotifier) checkIfCodeAlreadyHandledOrExpired(ctx context.Context, event eventstore.Event, expiry time.Duration, data map[string]interface{}, eventTypes ...eventstore.EventType) (bool, error) { if event.CreatedAt().Add(expiry).Before(time.Now().UTC()) { return true, nil diff --git a/internal/notification/static/i18n/bg.yaml b/internal/notification/static/i18n/bg.yaml index 7f67c24df..47bed1cb5 100644 --- a/internal/notification/static/i18n/bg.yaml +++ b/internal/notification/static/i18n/bg.yaml @@ -69,3 +69,10 @@ PasswordChange: Паролата на вашия потребител е променена, ако тази промяна не е направена от вас, моля, незабавно нулирайте паролата си. ButtonText: Влизам +InviteUser: + Title: Покана за {{.ApplicationName}} + PreHeader: Покана за {{.ApplicationName}} + Subject: Покана за {{.ApplicationName}} + Greeting: 'Здравейте {{.DisplayName}},' + Text: Вашият потребител е бил поканен за {{.ApplicationName}}. Моля, кликнете върху бутона по-долу, за да завършите процеса на покана. Ако не сте поискали този имейл, моля, игнорирайте го. + ButtonText: Приеми поканата \ No newline at end of file diff --git a/internal/notification/static/i18n/cs.yaml b/internal/notification/static/i18n/cs.yaml index b3d4cf18f..95e897998 100644 --- a/internal/notification/static/i18n/cs.yaml +++ b/internal/notification/static/i18n/cs.yaml @@ -59,3 +59,10 @@ PasswordChange: Greeting: Dobrý den, {{.DisplayName}}, Text: Heslo vašeho uživatele bylo změněno. Pokud tato změna nebyla provedena Vámi pak doporučujeme okamžitě resetovat/změnit vaše heslo. ButtonText: Přihlásit se +InviteUser: + Title: Pozvánka do {{.ApplicationName}} + PreHeader: Pozvánka do {{.ApplicationName}} + Subject: Pozvánka do {{.ApplicationName}} + Greeting: Dobrý den, {{.DisplayName}}, + Text: Váš uživatel byl pozván do {{.ApplicationName}}. Klikněte prosím na tlačítko níže, abyste dokončili proces pozvání. Pokud jste o tento e-mail nepožádali, prosím, ignorujte ho. + ButtonText: Přijmout pozvání \ No newline at end of file diff --git a/internal/notification/static/i18n/de.yaml b/internal/notification/static/i18n/de.yaml index 673b207ef..88ca69475 100644 --- a/internal/notification/static/i18n/de.yaml +++ b/internal/notification/static/i18n/de.yaml @@ -59,3 +59,10 @@ PasswordChange: Greeting: Hallo {{.DisplayName}}, Text: Dein Passwort wurde geändert. Wenn diese Änderung nicht von dir gemacht wurde, empfehlen wir das sofortige Zurücksetzen deines Passworts. ButtonText: Login +InviteUser: + Title: Einladung zu {{.ApplicationName}} + PreHeader: Einladung zu {{.ApplicationName}} + Subject: Einladung zu {{.ApplicationName}} + Greeting: Hallo {{.DisplayName}}, + Text: Ihr Benutzer wurde zu {{.ApplicationName}} eingeladen. Bitte klicken Sie auf die Schaltfläche unten, um den Einladungsprozess abzuschließen. Wenn Sie diese E-Mail nicht angefordert haben, ignorieren Sie sie bitte. + ButtonText: Einladung annehmen \ No newline at end of file diff --git a/internal/notification/static/i18n/en.yaml b/internal/notification/static/i18n/en.yaml index 20e187aa0..a431fc999 100644 --- a/internal/notification/static/i18n/en.yaml +++ b/internal/notification/static/i18n/en.yaml @@ -59,3 +59,10 @@ PasswordChange: Greeting: Hello {{.DisplayName}}, Text: The password of your user has changed. If this change was not done by you, please be advised to immediately reset your password. ButtonText: Login +InviteUser: + Title: Invitation to {{.ApplicationName}} + PreHeader: Invitation to {{.ApplicationName}} + Subject: Invitation to {{.ApplicationName}} + Greeting: Hello {{.DisplayName}}, + Text: Your user has been invited to {{.ApplicationName}}. Please click the button below to finish the invite process. If you didn't ask for this mail, please ignore it. + ButtonText: Accept invite \ No newline at end of file diff --git a/internal/notification/static/i18n/es.yaml b/internal/notification/static/i18n/es.yaml index 1e92e30be..caf138f1f 100644 --- a/internal/notification/static/i18n/es.yaml +++ b/internal/notification/static/i18n/es.yaml @@ -59,3 +59,10 @@ PasswordChange: Greeting: Hola {{.DisplayName}}, Text: La contraseña de tu usuario ha sido cambiada, si este cambio no fue hecho por ti, por favor proceder a restablecer inmediatamente tu contraseña. ButtonText: Iniciar sesión +InviteUser: + Title: Invitación a {{.ApplicationName}} + PreHeader: Invitación a {{.ApplicationName}} + Subject: Invitación a {{.ApplicationName}} + Greeting: Hola {{.DisplayName}}, + Text: Tu usuario ha sido invitado a {{.ApplicationName}}. Haz clic en el botón de abajo para finalizar el proceso de invitación. Si no solicitaste este correo electrónico, por favor ignóralo. + ButtonText: Aceptar invitación \ No newline at end of file diff --git a/internal/notification/static/i18n/fr.yaml b/internal/notification/static/i18n/fr.yaml index a313343ba..8fab61808 100644 --- a/internal/notification/static/i18n/fr.yaml +++ b/internal/notification/static/i18n/fr.yaml @@ -59,3 +59,10 @@ PasswordChange: Greeting: Bonjour {{.DisplayName}}, Text: Le mot de passe de votre utilisateur a changé, si ce changement n'a pas été fait par vous, nous vous conseillons de réinitialiser immédiatement votre mot de passe. ButtonText: Login +InviteUser: + Title: Invitation à {{.ApplicationName}} + PreHeader: Invitation à {{.ApplicationName}} + Subject: Invitation à {{.ApplicationName}} + Greeting: Bonjour {{.DisplayName}}, + Text: Votre utilisateur a été invité à {{.ApplicationName}}. Veuillez cliquer sur le bouton ci-dessous pour terminer le processus d'invitation. Si vous n'avez pas demandé cet e-mail, veuillez l'ignorer. + ButtonText: Accepter l'invitation \ No newline at end of file diff --git a/internal/notification/static/i18n/id.yaml b/internal/notification/static/i18n/id.yaml index e5bd60d21..928c94215 100644 --- a/internal/notification/static/i18n/id.yaml +++ b/internal/notification/static/i18n/id.yaml @@ -59,3 +59,10 @@ PasswordChange: Greeting: 'Halo {{.DisplayName}},' Text: 'Kata sandi pengguna Anda telah berubah. ' ButtonText: Login +InviteUser: + Title: Undangan ke {{.ApplicationName}} + PreHeader: Undangan ke {{.ApplicationName}} + Subject: Undangan ke {{.ApplicationName}} + Greeting: 'Halo {{.DisplayName}},' + Text: Pengguna Anda telah diundang ke {{.ApplicationName}}. Silakan klik tombol di bawah ini untuk menyelesaikan proses undangan. Jika Anda tidak meminta email ini, harap abaikan. + ButtonText: Terima undangan \ No newline at end of file diff --git a/internal/notification/static/i18n/it.yaml b/internal/notification/static/i18n/it.yaml index 58538b36b..6be2f16a5 100644 --- a/internal/notification/static/i18n/it.yaml +++ b/internal/notification/static/i18n/it.yaml @@ -59,3 +59,10 @@ PasswordChange: Greeting: Ciao {{.DisplayName}}, Text: La password del vostro utente è cambiata; se questa modifica non è stata fatta da voi, vi consigliamo di reimpostare immediatamente la vostra password. ButtonText: Login +InviteUser: + Title: Invito a {{.ApplicationName}} + PreHeader: Invito a {{.ApplicationName}} + Subject: Invito a {{.ApplicationName}} + Greeting: 'Ciao {{.DisplayName}},' + Text: Il tuo utente è stato invitato a {{.ApplicationName}}. Clicca sul pulsante qui sotto per completare il processo di invito. Se non hai richiesto questa email, ignorala. + ButtonText: Accetta invito \ No newline at end of file diff --git a/internal/notification/static/i18n/ja.yaml b/internal/notification/static/i18n/ja.yaml index 0696be71d..4419f51b8 100644 --- a/internal/notification/static/i18n/ja.yaml +++ b/internal/notification/static/i18n/ja.yaml @@ -59,3 +59,10 @@ PasswordChange: Greeting: こんにちは {{.DisplayName}} さん、 Text: ユーザーのパスワードが変更されました。この変更があなたによって行われなかった場合は、すぐにパスワードをリセットすることをお勧めします。 ButtonText: ログイン +InviteUser: + Title: '{{.ApplicationName}}への招待' + PreHeader: '{{.ApplicationName}}への招待' + Subject: '{{.ApplicationName}}への招待' + Greeting: こんにちは {{.DisplayName}} さん、 + Text: あなたのユーザーは{{.ApplicationName}}に招待されました。下のボタンをクリックして、招待プロセスを完了してください。このメールをリクエストしていない場合は、無視してください。 + ButtonText: 招待を受け入れる \ No newline at end of file diff --git a/internal/notification/static/i18n/mk.yaml b/internal/notification/static/i18n/mk.yaml index 1d9d19b71..a89b91227 100644 --- a/internal/notification/static/i18n/mk.yaml +++ b/internal/notification/static/i18n/mk.yaml @@ -59,3 +59,10 @@ PasswordChange: Greeting: Здраво {{.DisplayName}}, Text: Лозинката на вашиот корисник е променета. Ако оваа промена не е извршена од вас, ве молиме веднаш ресетирајте ја вашата лозинка. ButtonText: Најава +InviteUser: + Title: Покана за {{.ApplicationName}} + PreHeader: Покана за {{.ApplicationName}} + Subject: Покана за {{.ApplicationName}} + Greeting: Здраво {{.DisplayName}}, + Text: Вашиот корисник е бил поканет за {{.ApplicationName}}. Ве молиме кликнете на копчето подолу за да го завршите процесот на покана. Ако не сте побарале овој мејл, ве молиме игнорирајте го. + ButtonText: Прифати покана \ No newline at end of file diff --git a/internal/notification/static/i18n/nl.yaml b/internal/notification/static/i18n/nl.yaml index 2c2c3632a..46e247b64 100644 --- a/internal/notification/static/i18n/nl.yaml +++ b/internal/notification/static/i18n/nl.yaml @@ -59,3 +59,10 @@ PasswordChange: Greeting: Hallo {{.DisplayName}}, Text: Het wachtwoord van uw gebruiker is veranderd. Als deze wijziging niet door u is gedaan, wordt u geadviseerd om direct uw wachtwoord te resetten. ButtonText: Inloggen +InviteUser: + Title: Uitnodiging voor {{.ApplicationName}} + PreHeader: Uitnodiging voor {{.ApplicationName}} + Subject: Uitnodiging voor {{.ApplicationName}} + Greeting: Hallo {{.DisplayName}}, + Text: Uw gebruiker is uitgenodigd voor {{.ApplicationName}}. Klik op de onderstaande knop om het uitnodigingsproces te voltooien. Als u deze e-mail niet hebt aangevraagd, negeer deze dan. + ButtonText: Uitnodiging accepteren \ No newline at end of file diff --git a/internal/notification/static/i18n/pl.yaml b/internal/notification/static/i18n/pl.yaml index e67b178c2..b2e9f268b 100644 --- a/internal/notification/static/i18n/pl.yaml +++ b/internal/notification/static/i18n/pl.yaml @@ -59,3 +59,10 @@ PasswordChange: Greeting: Witaj {{.DisplayName}}, Text: Hasło Twojego użytkownika zostało zmienione, jeśli ta zmiana nie została dokonana przez Ciebie, zalecamy natychmiastowe zresetowanie hasła. ButtonText: Zaloguj się +InviteUser: + Title: Zaproszenie do {{.ApplicationName}} + PreHeader: Zaproszenie do {{.ApplicationName}} + Subject: Zaproszenie do {{.ApplicationName}} + Greeting: Witaj {{.DisplayName}}, + Text: Twój użytkownik został zaproszony do {{.ApplicationName}}. Kliknij poniższy przycisk, aby zakończyć proces zaproszenia. Jeśli nie zażądałeś tego e-maila, zignoruj go. + ButtonText: Akceptuj zaproszenie \ No newline at end of file diff --git a/internal/notification/static/i18n/pt.yaml b/internal/notification/static/i18n/pt.yaml index a7a2480b8..c45c20b8f 100644 --- a/internal/notification/static/i18n/pt.yaml +++ b/internal/notification/static/i18n/pt.yaml @@ -59,3 +59,10 @@ PasswordChange: Greeting: Olá {{.DisplayName}}, Text: A senha do seu usuário foi alterada. Se esta alteração não foi feita por você, recomendamos que você redefina sua senha imediatamente. ButtonText: Fazer login +InviteUser: + Title: Convite para {{.ApplicationName}} + PreHeader: Convite para {{.ApplicationName}} + Subject: Convite para {{.ApplicationName}} + Greeting: Olá {{.DisplayName}}, + Text: Seu usuário foi convidado para {{.ApplicationName}}. Clique no botão abaixo para concluir o processo de convite. Se você não solicitou este e-mail, por favor, ignore-o. + ButtonText: Aceitar convite \ No newline at end of file diff --git a/internal/notification/static/i18n/ru.yaml b/internal/notification/static/i18n/ru.yaml index ec187554d..af700dc97 100644 --- a/internal/notification/static/i18n/ru.yaml +++ b/internal/notification/static/i18n/ru.yaml @@ -2,28 +2,28 @@ InitCode: Title: Регистрация пользователя PreHeader: Регистрация пользователя Subject: Регистрация пользователя - Greeting: Здравствуйте {{.FirstName}} {{.LastName}}, + Greeting: Здравствуйте {{.DisplayName}}, Text: Используйте логин {{.PreferredLoginName}} для входа. Пожалуйста, нажмите кнопку ниже для завершения процесса регистрации. (Код {{.Code}}) Если вы не запрашивали это письмо, пожалуйста, проигнорируйте его. ButtonText: Завершить регистрацию PasswordReset: Title: Сброс пароля PreHeader: Сброс пароля Subject: Сброс пароля - Greeting: Здравствуйте {{.FirstName}} {{.LastName}}, + Greeting: Здравствуйте {{.DisplayName}}, Text: Мы получили запрос на сброс пароля. Пожалуйста, нажмите кнопку ниже для сброса вашего пароля. (Код {{.Code}}) Если вы не запрашивали это письмо, пожалуйста, проигнорируйте его. ButtonText: Сбросить пароль VerifyEmail: Title: Подтверждение email PreHeader: Подтверждение email Subject: Подтверждение email - Greeting: Здравствуйте {{.FirstName}} {{.LastName}}, + Greeting: Здравствуйте {{.DisplayName}}, Text: Добавлен новый email. Пожалуйста, нажмите кнопку ниже для подтверждения вашего email. (Код {{.Code}}) Если вы не запрашивали это письмо, пожалуйста, проигнорируйте его. ButtonText: Подтвердить email VerifyPhone: Title: Подтверждение телефона PreHeader: Подтверждение телефона Subject: Подтверждение телефона - Greeting: Здравствуйте {{.FirstName}} {{.LastName}}, + Greeting: Здравствуйте {{.DisplayName}}, Text: Добавлен новый номер телефона. Пожалуйста, используйте следующий код, чтобы подтвердить его. Код {{.Code}} ButtonText: Подтвердить телефон VerifyEmailOTP: @@ -42,20 +42,27 @@ DomainClaimed: Title: Утверждение домена PreHeader: Изменение email / логина Subject: Домен был утвержден - Greeting: Здравствуйте {{.FirstName}} {{.LastName}}, + Greeting: Здравствуйте {{.DisplayName}}, Text: Домен {{.Domain}} был утвержден организацией. Ваш текущий пользователь {{.Username}} не является частью этой организации. Вам необходимо изменить свой email при входе в систему. Мы создали временный логин ({{.TempUsername}}) для входа. ButtonText: Вход PasswordlessRegistration: Title: Добавление входа без пароля PreHeader: Добавление входа без пароля Subject: Добавление входа без пароля - Greeting: Здравствуйте {{.FirstName}} {{.LastName}}, + Greeting: Здравствуйте {{.DisplayName}}, Text: Мы получили запрос на добавление токена для входа без пароля. Пожалуйста, используйте кнопку ниже, чтобы добавить свой токен или устройство для входа без пароля. ButtonText: Добавить вход без пароля PasswordChange: Title: Смена пароля пользователя PreHeader: Смена пароля Subject: Пароль пользователя изменен - Greeting: Здравствуйте {{.FirstName}} {{.LastName}}, + Greeting: Здравствуйте {{.DisplayName}}, Text: Пароль пользователя был изменен. Если это изменение сделано не вами, советуем немедленно сбросить пароль. ButtonText: Вход +InviteUser: + Title: Приглашение в {{.ApplicationName}} + PreHeader: Приглашение в {{.ApplicationName}} + Subject: Приглашение в {{.ApplicationName}} + Greeting: Здравствуйте, {{.DisplayName}}, + Text: Ваш пользователь был приглашен в {{.ApplicationName}}. Пожалуйста, нажмите кнопку ниже, чтобы завершить процесс приглашения. Если вы не запрашивали это письмо, пожалуйста, игнорируйте его. + ButtonText: Принять приглашение \ No newline at end of file diff --git a/internal/notification/static/i18n/sv.yaml b/internal/notification/static/i18n/sv.yaml index 4788644c7..49c79a679 100644 --- a/internal/notification/static/i18n/sv.yaml +++ b/internal/notification/static/i18n/sv.yaml @@ -59,3 +59,10 @@ PasswordChange: Greeting: Hej {{.DisplayName}}, Text: Lösenordet för din användare har ändrats. Om denna ändring inte gjordes av dig, vänligen återställ ditt lösenord omedelbart. ButtonText: Logga in +InviteUser: + Title: Inbjudan till {{.ApplicationName}} + PreHeader: Inbjudan till {{.ApplicationName}} + Subject: Inbjudan till {{.ApplicationName}} + Greeting: Hej {{.DisplayName}}, + Text: Din användare har blivit inbjuden till {{.ApplicationName}}. Klicka på knappen nedan för att slutföra inbjudansprocessen. Om du inte har begärt detta e-postmeddelande, ignorera det. + ButtonText: Acceptera inbjudan \ No newline at end of file diff --git a/internal/notification/static/i18n/zh.yaml b/internal/notification/static/i18n/zh.yaml index b0dd21cad..4b83ebac7 100644 --- a/internal/notification/static/i18n/zh.yaml +++ b/internal/notification/static/i18n/zh.yaml @@ -59,3 +59,10 @@ PasswordChange: Greeting: 你好 {{.DisplayName}}, Text: 您的用户的密码已经改变,如果这个改变不是由您做的,请注意立即重新设置您的密码。 ButtonText: 登录 +InviteUser: + Title: '{{.ApplicationName}}邀请' + PreHeader: '{{.ApplicationName}}邀请' + Subject: '{{.ApplicationName}}邀请' + Greeting: 您好,{{.DisplayName}}, + Text: 您的用户已被邀请加入{{.ApplicationName}}。请点击下面的按钮完成邀请过程。如果您没有请求此邮件,请忽略它。 + ButtonText: 接受邀请 \ No newline at end of file diff --git a/internal/notification/types/invite_code.go b/internal/notification/types/invite_code.go new file mode 100644 index 000000000..953124a55 --- /dev/null +++ b/internal/notification/types/invite_code.go @@ -0,0 +1,31 @@ +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) SendInviteCode(ctx context.Context, user *query.NotifyUser, code, applicationName, urlTmpl, authRequestID string) error { + var url string + if applicationName == "" { + applicationName = "ZITADEL" + } + if urlTmpl == "" { + url = login.InviteUserLink(http_utils.DomainContext(ctx).Origin(), user.ID, user.PreferredLoginName, code, user.ResourceOwner, authRequestID) + } else { + var buf strings.Builder + if err := domain.RenderConfirmURLTemplate(&buf, urlTmpl, user.ID, code, user.ResourceOwner); err != nil { + return err + } + url = buf.String() + } + args := make(map[string]interface{}) + args["Code"] = code + args["ApplicationName"] = applicationName + return notify(url, args, domain.InviteUserMessageType, true) +} diff --git a/internal/query/message_text.go b/internal/query/message_text.go index dd2d9cd50..cb524d289 100644 --- a/internal/query/message_text.go +++ b/internal/query/message_text.go @@ -33,6 +33,7 @@ type MessageTexts struct { DomainClaimed MessageText PasswordlessRegistration MessageText PasswordChange MessageText + InviteUser MessageText } type MessageText struct { @@ -346,6 +347,8 @@ func (m *MessageTexts) GetMessageTextByType(msgType string) *MessageText { return &m.PasswordlessRegistration case domain.PasswordChangeMessageType: return &m.PasswordChange + case domain.InviteUserMessageType: + return &m.InviteUser } return nil } diff --git a/internal/query/projection/message_texts.go b/internal/query/projection/message_texts.go index a588e7e98..8a001db90 100644 --- a/internal/query/projection/message_texts.go +++ b/internal/query/projection/message_texts.go @@ -272,7 +272,8 @@ func isMessageTemplate(template string) bool { template == domain.VerifyEmailOTPMessageType || template == domain.DomainClaimedMessageType || template == domain.PasswordlessRegistrationMessageType || - template == domain.PasswordChangeMessageType + template == domain.PasswordChangeMessageType || + template == domain.InviteUserMessageType } func isTitle(key string) bool { return key == domain.MessageTitle diff --git a/internal/repository/user/eventstore.go b/internal/repository/user/eventstore.go index e04754e54..2b726d378 100644 --- a/internal/repository/user/eventstore.go +++ b/internal/repository/user/eventstore.go @@ -137,4 +137,8 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, MachineSecretCheckSucceededType, MachineSecretCheckSucceededEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, MachineSecretCheckFailedType, MachineSecretCheckFailedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, MachineSecretHashUpdatedType, eventstore.GenericEventMapper[MachineSecretHashUpdatedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, HumanInviteCodeAddedType, eventstore.GenericEventMapper[HumanInviteCodeAddedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, HumanInviteCodeSentType, eventstore.GenericEventMapper[HumanInviteCodeSentEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, HumanInviteCheckSucceededType, eventstore.GenericEventMapper[HumanInviteCheckSucceededEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, HumanInviteCheckFailedType, eventstore.GenericEventMapper[HumanInviteCheckFailedEvent]) } diff --git a/internal/repository/user/human.go b/internal/repository/user/human.go index e9fd49a35..ae1e9672e 100644 --- a/internal/repository/user/human.go +++ b/internal/repository/user/human.go @@ -21,6 +21,10 @@ const ( HumanInitialCodeSentType = humanEventPrefix + "initialization.code.sent" HumanInitializedCheckSucceededType = humanEventPrefix + "initialization.check.succeeded" HumanInitializedCheckFailedType = humanEventPrefix + "initialization.check.failed" + HumanInviteCodeAddedType = humanEventPrefix + "invite.code.added" + HumanInviteCodeSentType = humanEventPrefix + "invite.code.sent" + HumanInviteCheckSucceededType = humanEventPrefix + "invite.check.succeeded" + HumanInviteCheckFailedType = humanEventPrefix + "invite.check.failed" HumanSignedOutType = humanEventPrefix + "signed.out" ) @@ -379,6 +383,137 @@ func HumanInitializedCheckFailedEventMapper(event eventstore.Event) (eventstore. }, nil } +type HumanInviteCodeAddedEvent struct { + *eventstore.BaseEvent `json:"-"` + Code *crypto.CryptoValue `json:"code,omitempty"` + Expiry time.Duration `json:"expiry,omitempty"` + TriggeredAtOrigin string `json:"triggerOrigin,omitempty"` + URLTemplate string `json:"urlTemplate,omitempty"` + CodeReturned bool `json:"codeReturned,omitempty"` + ApplicationName string `json:"applicationName,omitempty"` + AuthRequestID string `json:"authRequestID,omitempty"` +} + +func (e *HumanInviteCodeAddedEvent) SetBaseEvent(b *eventstore.BaseEvent) { + e.BaseEvent = b +} + +func (e *HumanInviteCodeAddedEvent) Payload() interface{} { + return e +} + +func (e *HumanInviteCodeAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func (e *HumanInviteCodeAddedEvent) TriggerOrigin() string { + return e.TriggeredAtOrigin +} + +func NewHumanInviteCodeAddedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + code *crypto.CryptoValue, + expiry time.Duration, + urlTemplate string, + codeReturned bool, + applicationName string, + authRequestID string, +) *HumanInviteCodeAddedEvent { + return &HumanInviteCodeAddedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanInviteCodeAddedType, + ), + Code: code, + Expiry: expiry, + TriggeredAtOrigin: http.DomainContext(ctx).Origin(), + URLTemplate: urlTemplate, + CodeReturned: codeReturned, + ApplicationName: applicationName, + AuthRequestID: authRequestID, + } +} + +type HumanInviteCodeSentEvent struct { + *eventstore.BaseEvent `json:"-"` +} + +func (e *HumanInviteCodeSentEvent) SetBaseEvent(b *eventstore.BaseEvent) { + e.BaseEvent = b +} + +func (e *HumanInviteCodeSentEvent) Payload() interface{} { + return nil +} + +func (e *HumanInviteCodeSentEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewHumanInviteCodeSentEvent(ctx context.Context, aggregate *eventstore.Aggregate) *HumanInviteCodeSentEvent { + return &HumanInviteCodeSentEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanInviteCodeSentType, + ), + } +} + +type HumanInviteCheckSucceededEvent struct { + *eventstore.BaseEvent `json:"-"` +} + +func (e *HumanInviteCheckSucceededEvent) SetBaseEvent(b *eventstore.BaseEvent) { + e.BaseEvent = b +} + +func (e *HumanInviteCheckSucceededEvent) Payload() interface{} { + return nil +} + +func (e *HumanInviteCheckSucceededEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewHumanInviteCheckSucceededEvent(ctx context.Context, aggregate *eventstore.Aggregate) *HumanInviteCheckSucceededEvent { + return &HumanInviteCheckSucceededEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanInviteCheckSucceededType, + ), + } +} + +type HumanInviteCheckFailedEvent struct { + *eventstore.BaseEvent `json:"-"` +} + +func (e *HumanInviteCheckFailedEvent) SetBaseEvent(b *eventstore.BaseEvent) { + e.BaseEvent = b +} + +func (e *HumanInviteCheckFailedEvent) Payload() interface{} { + return nil +} + +func (e *HumanInviteCheckFailedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewHumanInviteCheckFailedEvent(ctx context.Context, aggregate *eventstore.Aggregate) *HumanInviteCheckFailedEvent { + return &HumanInviteCheckFailedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanInviteCheckFailedType, + ), + } +} + type HumanSignedOutEvent struct { eventstore.BaseEvent `json:"-"` diff --git a/internal/static/i18n/bg.yaml b/internal/static/i18n/bg.yaml index f89afe844..01582d0a1 100644 --- a/internal/static/i18n/bg.yaml +++ b/internal/static/i18n/bg.yaml @@ -704,6 +704,13 @@ EventTypes: check: succeeded: Проверката за инициализация е успешна failed: Проверката на инициализацията е неуспешна + invite: + code: + added: Генериран е код за покана + sent: Изпратен е код за покана + check: + succeeded: Проверката на поканата е успешна + failed: Проверката на поканата е неуспешна username: reserved: Потребителското име е запазено released: Потребителското име е освободено diff --git a/internal/static/i18n/cs.yaml b/internal/static/i18n/cs.yaml index 1d7afec91..effff5582 100644 --- a/internal/static/i18n/cs.yaml +++ b/internal/static/i18n/cs.yaml @@ -685,6 +685,13 @@ EventTypes: check: succeeded: Kontrola inicializace byla úspěšná failed: Kontrola inicializace selhala + invite: + code: + added: Vygenerován pozvánkový kód + sent: Pozvánkový kód byl odeslán + check: + succeeded: Kontrola pozvánky byla úspěšná + failed: Kontrola pozvánky selhala username: reserved: Uživatelské jméno rezervováno released: Uživatelské jméno uvolněno diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index 982693a49..21a863d18 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -687,6 +687,13 @@ EventTypes: check: succeeded: Benutzerinitialisierung erfolgreich failed: Benutzerinitialisierung fehlgeschlagen + invite: + code: + added: Einladungscode generiert + sent: Einladungscode gesendet + check: + succeeded: Einladungsprüfung erfolgreich + failed: Einladungsprüfung fehlgeschlagen username: reserved: Benutzername reserviert released: Benutzername freigegeben diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index ea15bd8a2..4f4e06fd7 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -687,6 +687,13 @@ EventTypes: check: succeeded: Initialization check succeeded failed: Initialization check failed + invite: + code: + added: Invitation code generated + sent: Invitation code sent + check: + succeeded: Invitation check succeeded + failed: Invitation check failed username: reserved: Username reserved released: Username released diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index dca302f73..48f7f3d33 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -687,6 +687,13 @@ EventTypes: check: succeeded: Comprobación exitosa de la inicialización failed: Fallo en la comprobación de la inicialización + invite: + code: + added: Código de invitación generado + sent: Código de invitación enviado + check: + succeeded: Comprobación de invitación correcta + failed: Comprobación de invitación fallida username: reserved: Nombre de usuario reservado released: Nombre de usuario liberado diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index 8d6e653e7..7f92dbea4 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -685,6 +685,13 @@ EventTypes: check: succeeded: Vérification de l'initialisation réussie failed: La vérification de l'initialisation a échoué + invite: + code: + added: Code d'invitation généré + sent: Code d'invitation envoyé + check: + succeeded: Vérification de l'invitation réussie + failed: Vérification de l'invitation échouée username: reserved: Nom d'utilisateur réservé released: Nom d'utilisateur libéré diff --git a/internal/static/i18n/id.yaml b/internal/static/i18n/id.yaml index 652759e6b..60e4c395a 100644 --- a/internal/static/i18n/id.yaml +++ b/internal/static/i18n/id.yaml @@ -680,6 +680,13 @@ EventTypes: check: succeeded: Pemeriksaan inisialisasi berhasil failed: Pemeriksaan inisialisasi gagal + invite: + code: + added: Kode undangan dihasilkan + sent: Kode undangan dikirim + check: + succeeded: Pemeriksaan undangan berhasil + failed: Pemeriksaan undangan gagal username: reserved: Nama pengguna dicadangkan released: Nama pengguna dirilis diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index b6c33023c..0d7d39d2d 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -686,6 +686,13 @@ EventTypes: check: succeeded: Controllo dell'inizializzazione riuscito failed: Controllo dell'inizializzazione fallito + invite: + code: + added: Codice invito generato + sent: Codice invito inviato + check: + succeeded: Controllo invito riuscito + failed: Controllo invito fallito username: reserved: Nome utente riservato released: Nome utente rilasciato diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index df23a56f5..f340b490b 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -676,6 +676,13 @@ EventTypes: check: succeeded: 初期化チェックの成功 failed: 初期化チェックの失敗 + invite: + code: + added: 招待コードが生成されました + sent: 招待コードが送信されました + check: + succeeded: 招待のチェックが成功しました + failed: 招待のチェックが失敗しました username: reserved: ユーザー名の予約 released: ユーザー名の解放 diff --git a/internal/static/i18n/mk.yaml b/internal/static/i18n/mk.yaml index cc27d3fb3..c23efe315 100644 --- a/internal/static/i18n/mk.yaml +++ b/internal/static/i18n/mk.yaml @@ -686,6 +686,13 @@ EventTypes: check: succeeded: Проверката на иницијализацијата е успешна failed: Проверката на иницијализацијата е неуспешна + invite: + code: + added: Генериран е код за покана + sent: Изпратен е код за покана + check: + succeeded: Проверката на поканата е успешна + failed: Проверката на поканата е неуспешна username: reserved: Корисничкото име е резервирано released: Корисничкото име е ослободено diff --git a/internal/static/i18n/nl.yaml b/internal/static/i18n/nl.yaml index 6d8b8014f..89477091d 100644 --- a/internal/static/i18n/nl.yaml +++ b/internal/static/i18n/nl.yaml @@ -687,6 +687,13 @@ EventTypes: check: succeeded: Initialisatiecontrole geslaagd failed: Initialisatiecontrole mislukt + invite: + code: + added: Uitnodigingscode gegenereerd + sent: Uitnodigingscode verzonden + check: + succeeded: Uitnodigingscontrole geslaagd + failed: Uitnodigingscontrole mislukt username: reserved: Gebruikersnaam gereserveerd released: Gebruikersnaam vrijgegeven diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index 9508fb801..ddd6df8be 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -687,6 +687,13 @@ EventTypes: check: succeeded: Sprawdzenie inicjalizacji zakończone powodzeniem failed: Sprawdzenie inicjalizacji nie powiodło się + invite: + code: + added: Wygenerowano kod zaproszenia + sent: Kod zaproszenia został wysłany + check: + succeeded: Kontrola zaproszenia zakończona sukcesem + failed: Kontrola zaproszenia zakończona niepowodzeniem username: reserved: Zarezerwowano nazwę użytkownika released: Zwolniono nazwę użytkownika diff --git a/internal/static/i18n/pt.yaml b/internal/static/i18n/pt.yaml index aa1a809f5..88e798ca0 100644 --- a/internal/static/i18n/pt.yaml +++ b/internal/static/i18n/pt.yaml @@ -682,6 +682,13 @@ EventTypes: check: succeeded: Verificação de inicialização bem-sucedida failed: Verificação de inicialização falhou + invite: + code: + added: Código de convite gerado + sent: Código de convite enviado + check: + succeeded: Verificação do convite bem-sucedida + failed: Verificação do convite falhou username: reserved: Nome de usuário reservado released: Nome de usuário liberado diff --git a/internal/static/i18n/ru.yaml b/internal/static/i18n/ru.yaml index eb846a687..e253b5a67 100644 --- a/internal/static/i18n/ru.yaml +++ b/internal/static/i18n/ru.yaml @@ -676,6 +676,13 @@ EventTypes: check: succeeded: Проверка инициализации прошла успешно failed: Проверка инициализации не удалась + invite: + code: + added: Сгенерирован код приглашения + sent: Код приглашения отправлен + check: + succeeded: Проверка приглашения успешно завершена + failed: Проверка приглашения завершилась неудачно username: reserved: Имя пользователя зарезервировано released: Имя пользователя опубликовано diff --git a/internal/static/i18n/sv.yaml b/internal/static/i18n/sv.yaml index da33e4226..91198335b 100644 --- a/internal/static/i18n/sv.yaml +++ b/internal/static/i18n/sv.yaml @@ -687,6 +687,13 @@ EventTypes: check: succeeded: Initialiseringskontroll lyckades failed: Initialiseringskontroll misslyckades + invite: + code: + added: Inbjudningskod genererad + sent: Inbjudningskod skickad + check: + succeeded: Inbjudningskontroll lyckad + failed: Inbjudningskontroll misslyckad username: reserved: Användarnamn reserverat released: Användarnamn släppt diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index 774232ea9..8e94bc262 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -686,6 +686,13 @@ EventTypes: check: succeeded: 初始化检查成功 failed: 初始化检查失败 + invite: + code: + added: 生成了邀请码 + sent: 发送了邀请码 + check: + succeeded: 邀请检查成功 + failed: 邀请检查失败 username: reserved: 保留用户名 released: 用户名已发布 diff --git a/proto/zitadel/admin.proto b/proto/zitadel/admin.proto index 41de3f845..c9b44a61e 100644 --- a/proto/zitadel/admin.proto +++ b/proto/zitadel/admin.proto @@ -3588,6 +3588,71 @@ service AdminService { }; } + rpc GetDefaultInviteUserMessageText(GetDefaultInviteUserMessageTextRequest) returns (GetDefaultInviteUserMessageTextResponse) { + option (google.api.http) = { + get: "/text/default/message/invite_user/{language}"; + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.policy.read"; + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Message Texts"; + summary: "Get Default Invite User Message Text"; + description: "Get the default text of the invite user message/email that is stored as translation files in ZITADEL itself. The text will be sent to the users of all organizations, that do not have a custom text configured. The message is sent when an invite code email is requested." + }; + } + + rpc GetCustomInviteUserMessageText(GetCustomInviteUserMessageTextRequest) returns (GetCustomInviteUserMessageTextResponse) { + option (google.api.http) = { + get: "/text/message/invite_user/{language}"; + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.policy.read"; + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Message Texts"; + summary: "Get Custom Invite User Message Text"; + description: "Get the custom text of the invite user message/email that is overwritten on the instance as settings/database. The text will be sent to the users of all organizations, that do not have a custom text configured. The message is sent when an invite code email is requested." + }; + } + + rpc SetDefaultInviteUserMessageText(SetDefaultInviteUserMessageTextRequest) returns (SetDefaultInviteUserMessageTextResponse) { + option (google.api.http) = { + put: "/text/message/invite_user/{language}"; + body: "*"; + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.policy.write"; + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Message Texts"; + summary: "Set Default Invite User Message Text"; + description: "Set the custom text of the invite user message/email that is overwritten on the instance as settings/database. The text will be sent to the users of all organizations, that do not have a custom text configured. The message is sent when an invite code email is requested. The Following Variables can be used: {{.UserName}} {{.FirstName}} {{.LastName}} {{.NickName}} {{.DisplayName}} {{.LastEmail}} {{.VerifiedEmail}} {{.LastPhone}} {{.VerifiedPhone}} {{.PreferredLoginName}} {{.LoginNames}} {{.ChangeDate}} {{.CreationDate}} {{.ApplicationName}}" + }; + } + + rpc ResetCustomInviteUserMessageTextToDefault(ResetCustomInviteUserMessageTextToDefaultRequest) returns (ResetCustomInviteUserMessageTextToDefaultResponse) { + option (google.api.http) = { + delete: "/text/message/invite_user/{language}" + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.policy.delete" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Message Texts"; + summary: "Reset Custom Invite User Message Text to Default"; + description: "Removes the custom text of the invite user message that is overwritten on the instance and triggers the text from the translation files stored in ZITADEL itself. The text will be sent to the users of all organizations, that do not have a custom text configured." + }; + } + rpc GetDefaultLoginTexts(GetDefaultLoginTextsRequest) returns (GetDefaultLoginTextsResponse) { option (google.api.http) = { get: "/text/default/login/{language}"; @@ -7789,6 +7854,89 @@ message ResetCustomPasswordChangeMessageTextToDefaultResponse { zitadel.v1.ObjectDetails details = 1; } +message GetDefaultInviteUserMessageTextRequest { + string language = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message GetDefaultInviteUserMessageTextResponse { + zitadel.text.v1.MessageCustomText custom_text = 1; +} + +message GetCustomInviteUserMessageTextRequest { + string language = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message GetCustomInviteUserMessageTextResponse { + zitadel.text.v1.MessageCustomText custom_text = 1; +} + +message SetDefaultInviteUserMessageTextRequest { + string language = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"de\""; + min_length: 1; + max_length: 200; + } + ]; + string title = 2 [ + (validate.rules).string = {max_bytes: 2000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Invitation to {{.ApplicationName}}\"" + max_length: 500; + } + ]; + string pre_header = 3 [ + (validate.rules).string = {max_bytes: 2000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Invitation to {{.ApplicationName}}\"" + max_length: 500; + } + ]; + string subject = 4 [ + (validate.rules).string = {max_bytes: 2000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Invitation to {{.ApplicationName}}\"" + max_length: 500; + } + ]; + string greeting = 5 [ + (validate.rules).string = {max_bytes: 4000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Hello {{.DisplayName}},\"" + max_length: 1000; + } + ]; + string text = 6 [ + (validate.rules).string = {max_bytes: 40000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Your user has been invited to {{.ApplicationName}}. Please click the button below to finish the invite process. If you didn't ask for this mail, please ignore it.\"" + max_length: 10000; + } + ]; + string button_text = 7 [ + (validate.rules).string = {max_bytes: 4000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Accept invite\"" + max_length: 1000; + } + ]; + string footer_text = 8 [(validate.rules).string = {max_len: 8000}]; +} + +message SetDefaultInviteUserMessageTextResponse { + zitadel.v1.ObjectDetails details = 1; +} + +message ResetCustomInviteUserMessageTextToDefaultRequest { + string language = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message ResetCustomInviteUserMessageTextToDefaultResponse { + zitadel.v1.ObjectDetails details = 1; +} + message GetDefaultPasswordlessRegistrationMessageTextRequest { string language = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; @@ -8239,6 +8387,7 @@ message DataOrg { repeated zitadel.management.v1.SetCustomVerifySMSOTPMessageTextRequest verify_sms_otp_messages = 37; repeated zitadel.management.v1.SetCustomVerifyEmailOTPMessageTextRequest verify_email_otp_messages = 38; + repeated zitadel.management.v1.SetCustomInviteUserMessageTextRequest invite_user_messages = 39; } message ImportDataResponse{ diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index 448e8b597..0eeaa29b0 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -6496,6 +6496,103 @@ service ManagementService { }; } + rpc GetCustomInviteUserMessageText(GetCustomInviteUserMessageTextRequest) returns (GetCustomInviteUserMessageTextResponse) { + option (google.api.http) = { + get: "/text/message/invite_user/{language}"; + }; + + option (zitadel.v1.auth_option) = { + permission: "policy.read"; + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Message Texts"; + summary: "Get Custom Invite User Message Text"; + description: "Get the custom text of the password-changed message/email that is configured on the organization. The message is sent when an invite code email is requested." + parameters: { + headers: { + name: "x-zitadel-orgid"; + description: "The default is always the organization of the requesting user. If you like to get/set a result of another organization include the header. Make sure the user has permission to access the requested data."; + type: STRING, + required: false; + }; + }; + }; + } + + rpc GetDefaultInviteUserMessageText(GetDefaultInviteUserMessageTextRequest) returns (GetDefaultInviteUserMessageTextResponse) { + option (google.api.http) = { + get: "/text/default/message/invite_user/{language}"; + }; + + option (zitadel.v1.auth_option) = { + permission: "policy.read"; + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Message Texts"; + summary: "Get Default Invite User Message Text"; + description: "Get the default text of the invite user message/email that is configured on the instance or as translation files in ZITADEL itself. The message is sent when an invite code email is requested." + parameters: { + headers: { + name: "x-zitadel-orgid"; + description: "The default is always the organization of the requesting user. If you like to get/set a result of another organization include the header. Make sure the user has permission to access the requested data."; + type: STRING, + required: false; + }; + }; + }; + } + + rpc SetCustomInviteUserMessageCustomText(SetCustomInviteUserMessageTextRequest) returns (SetCustomInviteUserMessageTextResponse) { + option (google.api.http) = { + put: "/text/message/invite_user/{language}"; + body: "*"; + }; + + option (zitadel.v1.auth_option) = { + permission: "policy.write"; + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Message Texts"; + summary: "Set Custom Invite User Message Text"; + description: "Set the custom text of the invite user message/email for the organization. The message is sent when an invite code email is requested. The Following Variables can be used: {{.UserName}} {{.FirstName}} {{.LastName}} {{.NickName}} {{.DisplayName}} {{.LastEmail}} {{.VerifiedEmail}} {{.LastPhone}} {{.VerifiedPhone}} {{.PreferredLoginName}} {{.LoginNames}} {{.ChangeDate}} {{.CreationDate}} {{.ApplicationName}}" + parameters: { + headers: { + name: "x-zitadel-orgid"; + description: "The default is always the organization of the requesting user. If you like to get/set a result of another organization include the header. Make sure the user has permission to access the requested data."; + type: STRING, + required: false; + }; + }; + }; + } + + rpc ResetCustomInviteUserMessageTextToDefault(ResetCustomInviteUserMessageTextToDefaultRequest) returns (ResetCustomInviteUserMessageTextToDefaultResponse) { + option (google.api.http) = { + delete: "/text/message/invite_user/{language}" + }; + + option (zitadel.v1.auth_option) = { + permission: "policy.delete" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Message Texts"; + summary: "Reset Custom Invite User Message Text to Default"; + description: "Removes the custom text of the invite user message from the organization and therefore the default texts from the instance or translation files will be triggered for the users." + parameters: { + headers: { + name: "x-zitadel-orgid"; + description: "The default is always the organization of the requesting user. If you like to get/set a result of another organization include the header. Make sure the user has permission to access the requested data."; + type: STRING, + required: false; + }; + }; + }; + } + rpc GetCustomLoginTexts(GetCustomLoginTextsRequest) returns (GetCustomLoginTextsResponse) { option (google.api.http) = { get: "/text/login/{language}"; @@ -11919,6 +12016,86 @@ message ResetCustomPasswordChangeMessageTextToDefaultResponse { zitadel.v1.ObjectDetails details = 1; } +message GetCustomInviteUserMessageTextRequest { + string language = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message GetCustomInviteUserMessageTextResponse { + zitadel.text.v1.MessageCustomText custom_text = 1; +} + +message GetDefaultInviteUserMessageTextRequest { + string language = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message GetDefaultInviteUserMessageTextResponse { + zitadel.text.v1.MessageCustomText custom_text = 1; +} + +message SetCustomInviteUserMessageTextRequest { + string language = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"de\"" + } + ]; + string title = 2 [ + (validate.rules).string = {max_bytes: 2000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Invitation to {{.ApplicationName}}\"" + max_length: 500; + } + ]; + string pre_header = 3 [ + (validate.rules).string = {max_bytes: 2000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Invitation to {{.ApplicationName}}\"" + max_length: 500; + } + ]; + string subject = 4 [ + (validate.rules).string = {max_bytes: 2000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Invitation to {{.ApplicationName}}\"" + max_length: 500; + } + ]; + string greeting = 5 [ + (validate.rules).string = {max_bytes: 4000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Hello {{.DisplayName}},\"" + max_length: 1000; + } + ]; + string text = 6 [ + (validate.rules).string = {max_bytes: 40000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Your user has been invited to {{.ApplicationName}}. Please click the button below to finish the invite process. If you didn't ask for this mail, please ignore it.\"" + max_length: 10000; + } + ]; + string button_text = 7 [ + (validate.rules).string = {max_bytes: 4000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Accept invite\"" + max_length: 500; + } + ]; + string footer_text = 8 [(validate.rules).string = {max_bytes: 8000}]; +} + +message SetCustomInviteUserMessageTextResponse { + zitadel.v1.ObjectDetails details = 1; +} + +message ResetCustomInviteUserMessageTextToDefaultRequest { + string language = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message ResetCustomInviteUserMessageTextToDefaultResponse { + zitadel.v1.ObjectDetails details = 1; +} + message GetOrgIDPByIDRequest { string id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; } diff --git a/proto/zitadel/user/v2/email.proto b/proto/zitadel/user/v2/email.proto index 2af5369fe..cbc07b876 100644 --- a/proto/zitadel/user/v2/email.proto +++ b/proto/zitadel/user/v2/email.proto @@ -23,7 +23,7 @@ message SetHumanEmail { oneof verification { SendEmailVerificationCode send_code = 2; ReturnEmailVerificationCode return_code = 3; - bool is_verified = 4 [(validate.rules).bool.const = true]; + bool is_verified = 4; } } diff --git a/proto/zitadel/user/v2/user.proto b/proto/zitadel/user/v2/user.proto index 9a0794197..e1e98eb03 100644 --- a/proto/zitadel/user/v2/user.proto +++ b/proto/zitadel/user/v2/user.proto @@ -282,3 +282,29 @@ enum AuthFactorState { AUTH_FACTOR_STATE_READY = 2; AUTH_FACTOR_STATE_REMOVED = 3; } + +message SendInviteCode { + // Optionally set a url_template, which will be used in the invite mail sent by ZITADEL to guide the user to your invitation page. + // If no template is set, the default ZITADEL url will be used. + optional string url_template = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"https://example.com/user/invite?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}\""; + } + ]; + // Optionally set an application name, which will be used in the invite mail sent by ZITADEL. + // If no application name is set, ZITADEL will be used as default. + optional string application_name = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"CustomerPortal\""; + } + ]; +} + +message ReturnInviteCode {} + diff --git a/proto/zitadel/user/v2/user_service.proto b/proto/zitadel/user/v2/user_service.proto index 214c600bd..9b82bfe29 100644 --- a/proto/zitadel/user/v2/user_service.proto +++ b/proto/zitadel/user/v2/user_service.proto @@ -1083,6 +1083,79 @@ service UserService { }; }; } + + // Create an invite code for a user + // + // Create an invite code for a user to initialize their first authentication method (password, passkeys, IdP) depending on the organization's available methods. + rpc CreateInviteCode (CreateInviteCodeRequest) returns (CreateInviteCodeResponse) { + option (google.api.http) = { + post: "/v2/users/{user_id}/invite_code" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + // Resend an invite code for a user + // + // Resend an invite code for a user to initialize their first authentication method (password, passkeys, IdP) depending on the organization's available methods. + // A resend is only possible if a code has been created previously and sent to the user. If there is no code or it was directly returned, an error will be returned. + rpc ResendInviteCode (ResendInviteCodeRequest) returns (ResendInviteCodeResponse) { + option (google.api.http) = { + post: "/v2/users/{user_id}/invite_code/resend" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + // Verify an invite code for a user + // + // Verify the invite code of a user previously issued. This will set their email to a verified state and + // allow the user to set up their first authentication method (password, passkeys, IdP) depending on the organization's available methods. + rpc VerifyInviteCode (VerifyInviteCodeRequest) returns (VerifyInviteCodeResponse) { + option (google.api.http) = { + post: "/v2/users/{user_id}/invite_code/verify" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } } message AddHumanUserRequest{ @@ -2076,3 +2149,67 @@ enum AuthenticationMethodType { AUTHENTICATION_METHOD_TYPE_OTP_SMS = 6; AUTHENTICATION_METHOD_TYPE_OTP_EMAIL = 7; } + +message CreateInviteCodeRequest { + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629026806489455\""; + } + ]; + // if no verification is specified, an email is sent with the default url and application name (ZITADEL) + oneof verification { + SendInviteCode send_code = 2; + ReturnInviteCode return_code = 3; + } +} + +message CreateInviteCodeResponse { + zitadel.object.v2.Details details = 1; + // The invite code is returned if the verification was set to return_code. + optional string invite_code = 2; +} +message ResendInviteCodeRequest { + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629026806489455\""; + } + ]; +} + +message ResendInviteCodeResponse { + zitadel.object.v2.Details details = 1; +} + +message VerifyInviteCodeRequest { + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629026806489455\""; + } + ]; + string verification_code = 2 [ + (validate.rules).string = {min_len: 1, max_len: 20}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 20; + example: "\"SKJd342k\""; + description: "\"the verification code generated during the invite code request\""; + } + ]; +} + +message VerifyInviteCodeResponse { + zitadel.object.v2.Details details = 1; +} \ No newline at end of file diff --git a/proto/zitadel/v1.proto b/proto/zitadel/v1.proto index 6b64f7db6..c186ea7d6 100644 --- a/proto/zitadel/v1.proto +++ b/proto/zitadel/v1.proto @@ -90,6 +90,8 @@ message DataOrg { repeated DataAppKey app_keys = 38; repeated DataMachineKey machine_keys = 39; + + repeated zitadel.management.v1.SetCustomInviteUserMessageTextRequest invite_user_messages = 40; } message DataOIDCIDP{