diff --git a/console/src/app/app.module.ts b/console/src/app/app.module.ts index 8466833aa7..4796d7acb8 100644 --- a/console/src/app/app.module.ts +++ b/console/src/app/app.module.ts @@ -69,6 +69,7 @@ import { StatehandlerService, StatehandlerServiceImpl } from './services/stateha import { StorageService } from './services/storage.service'; import { ThemeService } from './services/theme.service'; import { ToastService } from './services/toast.service'; +import { LanguagesService } from './services/languages.service'; registerLocaleData(localeDe); i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/de.json')); @@ -228,6 +229,7 @@ const authConfig: AuthConfig = { AssetService, ToastService, NavigationService, + LanguagesService, { provide: 'windowObject', useValue: window }, ], bootstrap: [AppComponent], diff --git a/console/src/app/modules/policies/general-settings/general-settings.component.ts b/console/src/app/modules/policies/general-settings/general-settings.component.ts index d549b74fe4..a3c8c399d7 100644 --- a/console/src/app/modules/policies/general-settings/general-settings.component.ts +++ b/console/src/app/modules/policies/general-settings/general-settings.component.ts @@ -26,7 +26,7 @@ export class GeneralSettingsComponent implements OnInit { this.service.getDefaultLanguage().then((langResp) => { this.defaultLanguage = langResp.language; }); - this.service.getSupportedLanguages().then((supportedResp) => { + this.service.getAllowedLanguages().then((supportedResp) => { this.defaultLanguageOptions = supportedResp.languagesList; }); } diff --git a/console/src/app/modules/policies/login-texts/login-texts.component.html b/console/src/app/modules/policies/login-texts/login-texts.component.html index 81421cb28b..de819d5f8d 100644 --- a/console/src/app/modules/policies/login-texts/login-texts.component.html +++ b/console/src/app/modules/policies/login-texts/login-texts.component.html @@ -1,5 +1,8 @@

{{ 'POLICY.LOGIN_TEXTS.TITLE' | translate }}

{{ 'POLICY.LOGIN_TEXTS.DESCRIPTION' | translate }}

+ + {{ 'POLICY.LOGIN_TEXTS.ACTIVE_LANGUAGE_NOT_ALLOWED' | translate }}
@@ -24,7 +27,7 @@
-
+ {{ 'POLICY.LOGIN_TEXTS.KEYNAME' | translate }} @@ -35,18 +38,30 @@ - {{ 'POLICY.LOGIN_TEXTS.LOCALE' | translate }} - - + {{ 'POLICY.LOGIN_TEXTS.LANGUAGE' | translate }} + +
{{ loc }} + >{{ lang }} | {{ 'POLICY.LOGIN_TEXTS.LOCALES.' + loc | translate }}| {{ 'POLICY.LOGIN_TEXTS.LANGUAGES.' + lang | translate }}
+ + +
+ {{ lang }} + | {{ 'POLICY.LOGIN_TEXTS.LANGUAGES.' + lang | translate }} +
+
+
diff --git a/console/src/app/modules/policies/login-texts/login-texts.component.ts b/console/src/app/modules/policies/login-texts/login-texts.component.ts index 26a3b04a58..7d8df91f39 100644 --- a/console/src/app/modules/policies/login-texts/login-texts.component.ts +++ b/console/src/app/modules/policies/login-texts/login-texts.component.ts @@ -1,8 +1,8 @@ import { Component, Injector, Input, OnDestroy, OnInit, Type } from '@angular/core'; -import { UntypedFormControl, UntypedFormGroup } from '@angular/forms'; +import { FormControl, UntypedFormGroup } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; import { Timestamp } from 'google-protobuf/google/protobuf/timestamp_pb'; -import { BehaviorSubject, from, interval, Observable, of, Subject, Subscription } from 'rxjs'; +import { BehaviorSubject, from, interval, Observable, of, Subject, Subscription, switchMap, take, tap } from 'rxjs'; import { map, pairwise, startWith, takeUntil } from 'rxjs/operators'; import { GetCustomLoginTextsRequest as AdminGetCustomLoginTextsRequest, @@ -19,11 +19,11 @@ import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; import { ManagementService } from 'src/app/services/mgmt.service'; import { ToastService } from 'src/app/services/toast.service'; -import { supportedLanguages } from 'src/app/utils/language'; import { InfoSectionType } from '../../info-section/info-section.component'; import { WarnDialogComponent } from '../../warn-dialog/warn-dialog.component'; import { PolicyComponentServiceType } from '../policy-component-types.enum'; import { mapRequestValues } from './helper'; +import { LanguagesService } from '../../../services/languages.service'; const MIN_INTERVAL_SECONDS = 10; // if the difference of a newer version to the current exceeds this time, a refresh button is shown. @@ -110,7 +110,6 @@ export class LoginTextsComponent implements OnInit, OnDestroy { @Input() public serviceType: PolicyComponentServiceType = PolicyComponentServiceType.MGMT; public KeyNamesArray: string[] = KeyNamesArray; - public LOCALES: string[] = supportedLanguages; private sub: Subscription = new Subscription(); @@ -119,9 +118,15 @@ export class LoginTextsComponent implements OnInit, OnDestroy { public destroy$: Subject = new Subject(); public InfoSectionType: any = InfoSectionType; public form: UntypedFormGroup = new UntypedFormGroup({ - currentSubMap: new UntypedFormControl('emailVerificationDoneText'), - locale: new UntypedFormControl('en'), + currentSubMap: new FormControl('emailVerificationDoneText'), + language: new FormControl('en'), }); + public allowed$: Observable = this.langSvc.allowed$.pipe( + take(1), + tap(([firstAllowed]) => { + this.form.get('language')?.setValue(firstAllowed); + }), + ); public isDefault: boolean = false; @@ -137,9 +142,10 @@ export class LoginTextsComponent implements OnInit, OnDestroy { private injector: Injector, private dialog: MatDialog, private toast: ToastService, + public langSvc: LanguagesService, ) { this.form.valueChanges - .pipe(startWith({ currentSubMap: 'emailVerificationDoneText', locale: 'en' }), pairwise(), takeUntil(this.destroy$)) + .pipe(startWith({ currentSubMap: 'emailVerificationDoneText', language: 'en' }), pairwise(), takeUntil(this.destroy$)) .subscribe((pair) => { this.checkForUnsaved(pair[0].currentSubMap).then((wantsToSave) => { if (wantsToSave) { @@ -162,21 +168,9 @@ export class LoginTextsComponent implements OnInit, OnDestroy { switch (this.serviceType) { case PolicyComponentServiceType.MGMT: this.service = this.injector.get(ManagementService as Type); - - this.service.getSupportedLanguages().then((lang) => { - this.LOCALES = lang.languagesList; - }); - - this.loadData(); break; case PolicyComponentServiceType.ADMIN: this.service = this.injector.get(AdminService as Type); - - this.service.getSupportedLanguages().then((lang) => { - this.LOCALES = lang.languagesList; - }); - - this.loadData(); break; } @@ -215,10 +209,10 @@ export class LoginTextsComponent implements OnInit, OnDestroy { public async loadData(): Promise { this.loading = true; const reqDefaultInit = REQUESTMAP[this.serviceType].getDefault; - reqDefaultInit.setLanguage(this.locale); + reqDefaultInit.setLanguage(this.language); this.getDefaultInitMessageTextMap$ = from(this.getDefaultValues(reqDefaultInit)).pipe(map((m) => m[this.currentSubMap])); - const reqCustomInit = REQUESTMAP[this.serviceType].get.setLanguage(this.locale); + const reqCustomInit = REQUESTMAP[this.serviceType].get.setLanguage(this.language); return this.getCurrentValues(reqCustomInit) .then((policy) => { this.loading = false; @@ -236,14 +230,14 @@ export class LoginTextsComponent implements OnInit, OnDestroy { } private async patchSingleCurrentMap(): Promise { - const reqCustomInit = REQUESTMAP[this.serviceType].get.setLanguage(this.locale); + const reqCustomInit = REQUESTMAP[this.serviceType].get.setLanguage(this.language); this.getCurrentValues(reqCustomInit).then((policy) => { this.getCustomInitMessageTextMap$.next(policy[this.currentSubMap]); }); } public checkForChanges(): void { - const reqCustomInit = REQUESTMAP[this.serviceType].get.setLanguage(this.locale); + const reqCustomInit = REQUESTMAP[this.serviceType].get.setLanguage(this.language); (this.service as ManagementService).getCustomLoginTexts(reqCustomInit).then((policy) => { this.newerPolicyChangeDate = policy.customText?.details?.changeDate; @@ -282,7 +276,7 @@ export class LoginTextsComponent implements OnInit, OnDestroy { this.totalCustomPolicy[this.currentSubMap] = values; this.updateRequest = setFcn(this.totalCustomPolicy); - this.updateRequest.setLanguage(this.locale); + this.updateRequest.setLanguage(this.language); } } @@ -350,7 +344,7 @@ export class LoginTextsComponent implements OnInit, OnDestroy { if (resp) { if (this.serviceType === PolicyComponentServiceType.MGMT) { (this.service as ManagementService) - .resetCustomLoginTextToDefault(this.locale) + .resetCustomLoginTextToDefault(this.language) .then(() => { this.updateCurrentPolicyDate(); this.isDefault = true; @@ -363,7 +357,7 @@ export class LoginTextsComponent implements OnInit, OnDestroy { }); } else if (this.serviceType === PolicyComponentServiceType.ADMIN) { (this.service as AdminService) - .resetCustomLoginTextToDefault(this.locale) + .resetCustomLoginTextToDefault(this.language) .then(() => { this.updateCurrentPolicyDate(); setTimeout(() => { @@ -397,8 +391,12 @@ export class LoginTextsComponent implements OnInit, OnDestroy { } } - public get locale(): string { - return this.form.get('locale')?.value; + public get language(): string { + return this.form.get('language')?.value; + } + + public set language(lang: string) { + this.form.get('language')?.setValue(lang); } public get currentSubMap(): string { diff --git a/console/src/app/modules/policies/message-texts/message-texts.component.html b/console/src/app/modules/policies/message-texts/message-texts.component.html index 5644fe7663..2c857c3ac4 100644 --- a/console/src/app/modules/policies/message-texts/message-texts.component.html +++ b/console/src/app/modules/policies/message-texts/message-texts.component.html @@ -1,70 +1,86 @@

{{ 'POLICY.MESSAGE_TEXTS.TITLE' | translate }}

{{ 'POLICY.MESSAGE_TEXTS.DESCRIPTION' | translate }}

+ + {{ 'POLICY.LOGIN_TEXTS.ACTIVE_LANGUAGE_NOT_ALLOWED' | translate }}
-
- - {{ 'POLICY.MESSAGE_TEXTS.TYPE' | translate }} - - - {{ 'POLICY.MESSAGE_TEXTS.TYPES.' + type.value | translate }} - - - +
+
+ + {{ 'POLICY.MESSAGE_TEXTS.TYPE' | translate }} + + + {{ 'POLICY.MESSAGE_TEXTS.TYPES.' + type.value | translate }} + + + + + {{ 'POLICY.LOGIN_TEXTS.LANGUAGE' | translate }} + + +
+ {{ lang }} + | {{ 'POLICY.LOGIN_TEXTS.LANGUAGES.' + lang | translate }} +
+
+ + +
+ {{ lang }} + | {{ 'POLICY.LOGIN_TEXTS.LANGUAGES.' + lang | translate }} +
+
+
+
+
+
- - {{ 'POLICY.LOGIN_TEXTS.LOCALE' | translate }} - - -
- {{ loc }} - | {{ 'POLICY.LOGIN_TEXTS.LOCALES.' + loc | translate }} -
-
-
-
-
+
+ +
-
- -
- -
- - +
+ + +
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 6e576a64a2..a5b90ba067 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 @@ -1,7 +1,7 @@ import { Component, Injector, Input, OnDestroy, OnInit, Type } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { MatSelectChange } from '@angular/material/select'; -import { BehaviorSubject, from, Observable, of, Subscription } from 'rxjs'; +import { BehaviorSubject, from, Observable, of, Subscription, switchMap, take, tap } from 'rxjs'; import { GetDefaultDomainClaimedMessageTextRequest as AdminGetDefaultDomainClaimedMessageTextRequest, GetDefaultInitMessageTextRequest as AdminGetDefaultInitMessageTextRequest, @@ -57,10 +57,11 @@ import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; import { ManagementService } from 'src/app/services/mgmt.service'; import { ToastService } from 'src/app/services/toast.service'; -import { supportedLanguages } from 'src/app/utils/language'; import { InfoSectionType } from '../../info-section/info-section.component'; import { WarnDialogComponent } from '../../warn-dialog/warn-dialog.component'; import { PolicyComponentServiceType } from '../policy-component-types.enum'; +import { map } from 'rxjs/operators'; +import { LanguagesService } from '../../../services/languages.service'; enum MESSAGETYPES { INIT = 'INIT', @@ -537,8 +538,15 @@ export class MessageTextsComponent implements OnInit, OnDestroy { ], }; - public locale: string = 'en'; - public LOCALES: string[] = supportedLanguages; + public language: string = 'en'; + public allowed$: Observable = this.langSvc.allowed$.pipe( + take(1), + tap(([firstAllowed]) => { + this.language = firstAllowed; + this.loadData(this.currentType); + }), + ); + private sub: Subscription = new Subscription(); public canWrite$: Observable = this.authService.isAllowed([ this.serviceType === PolicyComponentServiceType.ADMIN @@ -553,23 +561,16 @@ export class MessageTextsComponent implements OnInit, OnDestroy { private toast: ToastService, private injector: Injector, private dialog: MatDialog, + public langSvc: LanguagesService, ) {} ngOnInit(): void { switch (this.serviceType) { case PolicyComponentServiceType.MGMT: this.service = this.injector.get(ManagementService as Type); - this.service.getSupportedLanguages().then((lang) => { - this.LOCALES = lang.languagesList; - }); - this.loadData(this.currentType); break; case PolicyComponentServiceType.ADMIN: this.service = this.injector.get(AdminService as Type); - this.service.getSupportedLanguages().then((lang) => { - this.LOCALES = lang.languagesList; - }); - this.loadData(this.currentType); break; } } @@ -623,7 +624,7 @@ export class MessageTextsComponent implements OnInit, OnDestroy { } public changeLocale(selection: MatSelectChange): void { - this.locale = selection.value; + this.language = selection.value; this.loadData(this.currentType); } @@ -631,11 +632,11 @@ export class MessageTextsComponent implements OnInit, OnDestroy { if (this.serviceType === PolicyComponentServiceType.MGMT) { const reqDefaultInit = REQUESTMAP[this.serviceType][type].getDefault; - reqDefaultInit.setLanguage(this.locale); + reqDefaultInit.setLanguage(this.language); this.getDefaultMessageTextMap$ = from(this.getDefaultValues(type, reqDefaultInit)); } - const reqCustomInit = REQUESTMAP[this.serviceType][type].get.setLanguage(this.locale); + const reqCustomInit = REQUESTMAP[this.serviceType][type].get.setLanguage(this.language); this.loading = true; return this.getCurrentValues(type, reqCustomInit) ?.then((data) => { @@ -652,7 +653,7 @@ export class MessageTextsComponent implements OnInit, OnDestroy { const req = REQUESTMAP[this.serviceType][this.currentType].setFcn; const mappedValues = req(values); this.updateRequest = mappedValues; - this.updateRequest.setLanguage(this.locale); + this.updateRequest.setLanguage(this.language); } public saveCurrentMessage(): any { @@ -741,23 +742,23 @@ export class MessageTextsComponent implements OnInit, OnDestroy { switch (this.currentType) { case MESSAGETYPES.INIT: - return handler(this.service.resetCustomInitMessageTextToDefault(this.locale)); + return handler(this.service.resetCustomInitMessageTextToDefault(this.language)); case MESSAGETYPES.VERIFYPHONE: - return handler(this.service.resetCustomVerifyPhoneMessageTextToDefault(this.locale)); + return handler(this.service.resetCustomVerifyPhoneMessageTextToDefault(this.language)); case MESSAGETYPES.VERIFYSMSOTP: - return handler(this.service.resetCustomVerifySMSOTPMessageTextToDefault(this.locale)); + return handler(this.service.resetCustomVerifySMSOTPMessageTextToDefault(this.language)); case MESSAGETYPES.VERIFYEMAILOTP: - return handler(this.service.resetCustomVerifyEmailOTPMessageTextToDefault(this.locale)); + return handler(this.service.resetCustomVerifyEmailOTPMessageTextToDefault(this.language)); case MESSAGETYPES.VERIFYEMAIL: - return handler(this.service.resetCustomVerifyEmailMessageTextToDefault(this.locale)); + return handler(this.service.resetCustomVerifyEmailMessageTextToDefault(this.language)); case MESSAGETYPES.PASSWORDRESET: - return handler(this.service.resetCustomPasswordResetMessageTextToDefault(this.locale)); + return handler(this.service.resetCustomPasswordResetMessageTextToDefault(this.language)); case MESSAGETYPES.DOMAINCLAIMED: - return handler(this.service.resetCustomDomainClaimedMessageTextToDefault(this.locale)); + return handler(this.service.resetCustomDomainClaimedMessageTextToDefault(this.language)); case MESSAGETYPES.PASSWORDLESS: - return handler(this.service.resetCustomPasswordlessRegistrationMessageTextToDefault(this.locale)); + return handler(this.service.resetCustomPasswordlessRegistrationMessageTextToDefault(this.language)); case MESSAGETYPES.PASSWORDCHANGE: - return handler(this.service.resetCustomPasswordChangeMessageTextToDefault(this.locale)); + return handler(this.service.resetCustomPasswordChangeMessageTextToDefault(this.language)); default: return Promise.reject(); } diff --git a/console/src/app/pages/org-create/org-create.component.html b/console/src/app/pages/org-create/org-create.component.html index 1672159ca0..003e85f235 100644 --- a/console/src/app/pages/org-create/org-create.component.html +++ b/console/src/app/pages/org-create/org-create.component.html @@ -88,7 +88,7 @@ {{ 'USER.PROFILE.PREFERRED_LANGUAGE' | translate }} - + {{ 'LANGUAGES.' + language | translate }} diff --git a/console/src/app/pages/org-create/org-create.component.ts b/console/src/app/pages/org-create/org-create.component.ts index 7b0fc73e7b..96a946b195 100644 --- a/console/src/app/pages/org-create/org-create.component.ts +++ b/console/src/app/pages/org-create/org-create.component.ts @@ -20,7 +20,7 @@ import { AdminService } from 'src/app/services/admin.service'; import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service'; import { ManagementService } from 'src/app/services/mgmt.service'; import { ToastService } from 'src/app/services/toast.service'; -import { supportedLanguages } from 'src/app/utils/language'; +import { LanguagesService } from '../../services/languages.service'; @Component({ selector: 'cnsl-org-create', @@ -46,7 +46,6 @@ export class OrgCreateComponent { public pwdForm?: UntypedFormGroup; public genders: Gender[] = [Gender.GENDER_FEMALE, Gender.GENDER_MALE, Gender.GENDER_UNSPECIFIED]; - public languages: string[] = supportedLanguages; public policy?: PasswordComplexityPolicy.AsObject; public usePassword: boolean = false; @@ -60,6 +59,7 @@ export class OrgCreateComponent { private _location: Location, private fb: UntypedFormBuilder, private mgmtService: ManagementService, + public langSvc: LanguagesService, breadcrumbService: BreadcrumbService, ) { const instanceBread = new Breadcrumb({ @@ -70,10 +70,6 @@ export class OrgCreateComponent { breadcrumbService.setBreadcrumb([instanceBread]); this.initForm(); - - this.adminService.getSupportedLanguages().then((supportedResp) => { - this.languages = supportedResp.languagesList; - }); } public createSteps: number = 2; diff --git a/console/src/app/pages/users/user-create/user-create.component.html b/console/src/app/pages/users/user-create/user-create.component.html index 24f751be89..94a42cdc98 100644 --- a/console/src/app/pages/users/user-create/user-create.component.html +++ b/console/src/app/pages/users/user-create/user-create.component.html @@ -91,7 +91,7 @@ {{ 'USER.PROFILE.PREFERRED_LANGUAGE' | translate }} - + {{ 'LANGUAGES.' + language | translate }} diff --git a/console/src/app/pages/users/user-create/user-create.component.ts b/console/src/app/pages/users/user-create/user-create.component.ts index 9d43c07a73..ce6cf60a75 100644 --- a/console/src/app/pages/users/user-create/user-create.component.ts +++ b/console/src/app/pages/users/user-create/user-create.component.ts @@ -2,7 +2,7 @@ import { Location } from '@angular/common'; import { ChangeDetectorRef, Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { AbstractControl, UntypedFormBuilder, UntypedFormGroup, ValidatorFn, Validators } from '@angular/forms'; import { Router } from '@angular/router'; -import { Subject, debounceTime } from 'rxjs'; +import { Subject, debounceTime, Observable } from 'rxjs'; import { AddHumanUserRequest } from 'src/app/proto/generated/zitadel/management_pb'; import { Domain } from 'src/app/proto/generated/zitadel/org_pb'; import { PasswordComplexityPolicy } from 'src/app/proto/generated/zitadel/policy_pb'; @@ -13,7 +13,6 @@ import { ToastService } from 'src/app/services/toast.service'; import { CountryCallingCodesService, CountryPhoneCode } from 'src/app/services/country-calling-codes.service'; import { formatPhone } from 'src/app/utils/formatPhone'; -import { supportedLanguages } from 'src/app/utils/language'; import { containsLowerCaseValidator, containsNumberValidator, @@ -25,6 +24,7 @@ import { phoneValidator, requiredValidator, } from '../../../modules/form-field/validators/validators'; +import { LanguagesService } from '../../../services/languages.service'; @Component({ selector: 'cnsl-user-create', @@ -34,7 +34,6 @@ import { export class UserCreateComponent implements OnInit, OnDestroy { public user: AddHumanUserRequest.AsObject = new AddHumanUserRequest().toObject(); public genders: Gender[] = [Gender.GENDER_FEMALE, Gender.GENDER_MALE, Gender.GENDER_UNSPECIFIED]; - public languages: string[] = supportedLanguages; public selected: CountryPhoneCode | undefined = { countryCallingCode: '1', countryCode: 'US', @@ -61,6 +60,7 @@ export class UserCreateComponent implements OnInit, OnDestroy { private changeDetRef: ChangeDetectorRef, private _location: Location, private countryCallingCodesService: CountryCallingCodesService, + public langSvc: LanguagesService, breadcrumbService: BreadcrumbService, ) { breadcrumbService.setBreadcrumb([ @@ -69,7 +69,6 @@ export class UserCreateComponent implements OnInit, OnDestroy { routerLink: ['/org'], }), ]); - this.loading = true; this.loadOrg(); this.mgmtService @@ -88,10 +87,6 @@ export class UserCreateComponent implements OnInit, OnDestroy { this.loading = false; this.changeDetRef.detectChanges(); }); - - this.mgmtService.getSupportedLanguages().then((lang) => { - this.languages = lang.languagesList; - }); } public close(): void { diff --git a/console/src/app/pages/users/user-detail/auth-user-detail/auth-user-detail.component.html b/console/src/app/pages/users/user-detail/auth-user-detail/auth-user-detail.component.html index 8fae04a306..20fc3d3412 100644 --- a/console/src/app/pages/users/user-detail/auth-user-detail/auth-user-detail.component.html +++ b/console/src/app/pages/users/user-detail/auth-user-detail/auth-user-detail.component.html @@ -19,7 +19,12 @@
- + { const { id } = params; if (id) { + this.cleanupTranslation(); this.currentSetting = id; } }); @@ -97,10 +99,6 @@ export class AuthUserDetailComponent implements OnDestroy { this.loading = true; this.refreshUser(); - this.userService.getSupportedLanguages().then((lang) => { - this.languages = lang.languagesList; - }); - this.userService.getMyLoginPolicy().then((policy) => { if (policy.policy) { this.loginPolicy = policy.policy; @@ -109,6 +107,7 @@ export class AuthUserDetailComponent implements OnDestroy { } private changeSelection(small: boolean): void { + this.cleanupTranslation(); if (small) { this.currentSetting = undefined; } else { @@ -138,6 +137,7 @@ export class AuthUserDetailComponent implements OnDestroy { }), ]); } + this.savedLanguage = resp.user?.human?.profile?.preferredLanguage; this.loading = false; }) .catch((error) => { @@ -147,9 +147,22 @@ export class AuthUserDetailComponent implements OnDestroy { } public ngOnDestroy(): void { + this.cleanupTranslation(); this.subscription.unsubscribe(); } + public settingChanged(): void { + this.cleanupTranslation(); + } + + private cleanupTranslation(): void { + if (this?.savedLanguage) { + this.translate.use(this?.savedLanguage); + } else { + this.translate.use(this.translate.defaultLang); + } + } + public changeUsername(): void { const dialogRef = this.dialog.open(EditDialogComponent, { data: { @@ -193,6 +206,7 @@ export class AuthUserDetailComponent implements OnDestroy { ) .then(() => { this.toast.showInfo('USER.TOAST.SAVED', true); + this.savedLanguage = this.user?.human?.profile?.preferredLanguage; this.refreshChanges$.emit(); }) .catch((error) => { diff --git a/console/src/app/pages/users/user-detail/user-detail/user-detail.component.html b/console/src/app/pages/users/user-detail/user-detail/user-detail.component.html index f59cc6dc7c..6153ccccad 100644 --- a/console/src/app/pages/users/user-detail/user-detail/user-detail.component.html +++ b/console/src/app/pages/users/user-detail/user-detail/user-detail.component.html @@ -83,7 +83,7 @@ [preferredLoginName]="user.preferredLoginName" [disabled]="(canWrite$ | async) === false" [genders]="genders" - [languages]="languages" + [languages]="(langSvc.supported$ | async) || []" [username]="user.userName" [user]="user.human" (submitData)="saveProfile($event)" diff --git a/console/src/app/pages/users/user-detail/user-detail/user-detail.component.ts b/console/src/app/pages/users/user-detail/user-detail/user-detail.component.ts index 91fe3cf2e9..2418baafc4 100644 --- a/console/src/app/pages/users/user-detail/user-detail/user-detail.component.ts +++ b/console/src/app/pages/users/user-detail/user-detail/user-detail.component.ts @@ -22,10 +22,11 @@ import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/ import { ManagementService } from 'src/app/services/mgmt.service'; import { ToastService } from 'src/app/services/toast.service'; import { formatPhone } from 'src/app/utils/formatPhone'; -import { supportedLanguages } from 'src/app/utils/language'; import { EditDialogComponent, EditDialogType } from '../auth-user-detail/edit-dialog/edit-dialog.component'; import { ResendEmailDialogComponent } from '../auth-user-detail/resend-email-dialog/resend-email-dialog.component'; import { MachineSecretDialogComponent } from './machine-secret-dialog/machine-secret-dialog.component'; +import { Observable } from 'rxjs'; +import { LanguagesService } from '../../../../services/languages.service'; const GENERAL: SidenavSetting = { id: 'general', i18nKey: 'USER.SETTINGS.GENERAL' }; const GRANTS: SidenavSetting = { id: 'grants', i18nKey: 'USER.SETTINGS.USERGRANTS' }; @@ -45,7 +46,6 @@ export class UserDetailComponent implements OnInit { public user!: User.AsObject; public metadata: Metadata.AsObject[] = []; public genders: Gender[] = [Gender.GENDER_MALE, Gender.GENDER_FEMALE, Gender.GENDER_DIVERSE]; - public languages: string[] = supportedLanguages; public ChangeType: any = ChangeType; @@ -76,6 +76,7 @@ export class UserDetailComponent implements OnInit { private router: Router, activatedRoute: ActivatedRoute, private mediaMatcher: MediaMatcher, + public langSvc: LanguagesService, breadcrumbService: BreadcrumbService, ) { activatedRoute.queryParams.pipe(take(1)).subscribe((params: Params) => { @@ -100,10 +101,6 @@ export class UserDetailComponent implements OnInit { this.mediaMatcher.matchMedia(mediaq).onchange = (small) => { this.changeSelection(small.matches); }; - - this.mgmtUserService.getSupportedLanguages().then((lang) => { - this.languages = lang.languagesList; - }); } private changeSelection(small: boolean): void { diff --git a/console/src/app/services/admin.service.ts b/console/src/app/services/admin.service.ts index 1a67b50ffd..42ba0dae0b 100644 --- a/console/src/app/services/admin.service.ts +++ b/console/src/app/services/admin.service.ts @@ -54,6 +54,8 @@ import { DeactivateSMSProviderResponse, DeleteProviderRequest, DeleteProviderResponse, + GetAllowedLanguagesRequest, + GetAllowedLanguagesResponse, GetCustomDomainClaimedMessageTextRequest, GetCustomDomainClaimedMessageTextResponse, GetCustomDomainPolicyRequest, @@ -433,6 +435,11 @@ export class AdminService { return this.grpcService.admin.getSupportedLanguages(req, null).then((resp) => resp.toObject()); } + public getAllowedLanguages(): Promise { + const req = new GetAllowedLanguagesRequest(); + return this.grpcService.admin.getAllowedLanguages(req, null).then((resp) => resp.toObject()); + } + public getDefaultLoginTexts(req: GetDefaultLoginTextsRequest): Promise { return this.grpcService.admin.getDefaultLoginTexts(req, null).then((resp) => resp.toObject()); } diff --git a/console/src/app/services/grpc-auth.service.ts b/console/src/app/services/grpc-auth.service.ts index 6ebf448332..ace6ed22b3 100644 --- a/console/src/app/services/grpc-auth.service.ts +++ b/console/src/app/services/grpc-auth.service.ts @@ -32,8 +32,6 @@ import { GetMyProfileResponse, GetMyUserRequest, GetMyUserResponse, - GetSupportedLanguagesRequest, - GetSupportedLanguagesResponse, ListMyAuthFactorsRequest, ListMyAuthFactorsResponse, ListMyLinkedIDPsRequest, @@ -494,11 +492,6 @@ export class GrpcAuthService { return this.grpcService.auth.resendMyEmailVerification(req, null).then((resp) => resp.toObject()); } - public getSupportedLanguages(): Promise { - const req = new GetSupportedLanguagesRequest(); - return this.grpcService.auth.getSupportedLanguages(req, null).then((resp) => resp.toObject()); - } - public getMyLoginPolicy(): Promise { const req = new GetMyLoginPolicyRequest(); return this.grpcService.auth.getMyLoginPolicy(req, null).then((resp) => resp.toObject()); diff --git a/console/src/app/services/languages.service.ts b/console/src/app/services/languages.service.ts new file mode 100644 index 0000000000..42cd84c29c --- /dev/null +++ b/console/src/app/services/languages.service.ts @@ -0,0 +1,47 @@ +import { forkJoin, Observable, ReplaySubject, Subscription } from 'rxjs'; +import { map, withLatestFrom } from 'rxjs/operators'; +import { Injectable } from '@angular/core'; +import { AdminService } from './admin.service'; + +@Injectable({ + providedIn: 'root', +}) +export class LanguagesService { + private supportedSubject$ = new ReplaySubject(1); + public supported$: Observable = this.supportedSubject$.asObservable(); + private allowedSubject$ = new ReplaySubject(1); + public allowed$: Observable = this.allowedSubject$.asObservable(); + public notAllowed$: Observable = this.allowed$.pipe( + withLatestFrom(this.supported$), + map(([allowed, supported]) => { + return supported.filter((s) => !allowed.includes(s)); + }), + ); + public restricted$: Observable = this.notAllowed$.pipe( + map((notallowed) => { + return notallowed.length > 0; + }), + ); + + constructor(private adminSvc: AdminService) { + const sub: Subscription = forkJoin([ + this.adminSvc.getSupportedLanguages(), + this.adminSvc.getAllowedLanguages(), + ]).subscribe({ + next: ([{ languagesList: supported }, { languagesList: allowed }]) => { + this.supportedSubject$.next(supported); + this.allowedSubject$.next(allowed); + }, + complete: () => sub.unsubscribe(), + }); + } + + // TODO: call this in https://github.com/zitadel/zitadel/pull/6965 + public newAllowed(languages: string[]) { + this.allowedSubject$.next(languages); + } + + public isNotAllowed(language: string): Observable { + return this.notAllowed$.pipe(map((notAllowed) => notAllowed.includes(language))); + } +} diff --git a/console/src/app/services/mgmt.service.ts b/console/src/app/services/mgmt.service.ts index aaf9398b9e..ae975a228d 100644 --- a/console/src/app/services/mgmt.service.ts +++ b/console/src/app/services/mgmt.service.ts @@ -551,11 +551,6 @@ export class ManagementService { constructor(private readonly grpcService: GrpcService) {} - public getSupportedLanguages(): Promise { - const req = new GetSupportedLanguagesRequest(); - return this.grpcService.mgmt.getSupportedLanguages(req, null).then((resp) => resp.toObject()); - } - public getDefaultLoginTexts(req: GetDefaultLoginTextsRequest): Promise { return this.grpcService.mgmt.getDefaultLoginTexts(req, null).then((resp) => resp.toObject()); } diff --git a/console/src/assets/i18n/bg.json b/console/src/assets/i18n/bg.json index e2821f14c6..e3a1e7876d 100644 --- a/console/src/assets/i18n/bg.json +++ b/console/src/assets/i18n/bg.json @@ -1255,8 +1255,10 @@ "RESET_DESCRIPTION": "На път сте да възстановите всички стойности по подразбиране. ", "UNSAVED_TITLE": "Продължаване без запазване?", "UNSAVED_DESCRIPTION": "Направихте промени без да запазите. ", - "LOCALE": "Локален код", - "LOCALES": { + "ACTIVE_LANGUAGE_NOT_ALLOWED": "Избрахте език, който не е разрешен. Можете да продължите да променяте текстовете. Но ако искате вашите потребители да могат да използват този език, променете ограниченията на вашите екземпляри.", + "LANGUAGES_NOT_ALLOWED": "Не е разрешено:", + "LANGUAGE": "Език", + "LANGUAGES": { "de": "Deutsch", "en": "Английски", "es": "Español", diff --git a/console/src/assets/i18n/cs.json b/console/src/assets/i18n/cs.json index b734a58f64..ead54bb53c 100644 --- a/console/src/assets/i18n/cs.json +++ b/console/src/assets/i18n/cs.json @@ -1262,8 +1262,10 @@ "RESET_DESCRIPTION": "Chystáte se obnovit všechny výchozí hodnoty. Všechny vaše změny budou trvale smazány. Opravdu chcete pokračovat?", "UNSAVED_TITLE": "Pokračovat bez uložení?", "UNSAVED_DESCRIPTION": "Provedli jste změny bez uložení. Chcete je nyní uložit?", - "LOCALE": "Kód jazyka", - "LOCALES": { + "ACTIVE_LANGUAGE_NOT_ALLOWED": "Vybrali jste jazyk, který není povolen. Můžete pokračovat v úpravách textů. Ale pokud chcete, aby vaši uživatelé mohli tento jazyk skutečně používat, změňte omezení vašich instancí.", + "LANGUAGES_NOT_ALLOWED": "Nepovolené jazyky:", + "LANGUAGE": "Jazyk", + "LANGUAGES": { "de": "Deutsch", "en": "English", "es": "Español", diff --git a/console/src/assets/i18n/de.json b/console/src/assets/i18n/de.json index 8685ee4bb7..1afe195e08 100644 --- a/console/src/assets/i18n/de.json +++ b/console/src/assets/i18n/de.json @@ -1261,8 +1261,10 @@ "RESET_DESCRIPTION": "Sie sind im Begriff alle Standardwerte wiederherzustellen. Alle von Ihnen gesetzten Änderungen werden unwiderruflich gelöscht. Wollen Sie fortfahren?", "UNSAVED_TITLE": "Ohne speichern fortfahren?", "UNSAVED_DESCRIPTION": "Sie haben Änderungen vorgenommen ohne zu speichern. Möchten Sie jetzt speichern?", - "LOCALE": "Sprachcode", - "LOCALES": { + "ACTIVE_LANGUAGE_NOT_ALLOWED": "Sie haben eine Sprache ausgewählt, die nicht erlaubt ist. Sie können weiterhin die Texte ändern. Wenn Sie jedoch möchten, dass Ihre Benutzer diese Sprache tatsächlich verwenden können, ändern Sie die Einschränkungen Ihrer Instanz.", + "LANGUAGES_NOT_ALLOWED": "Nicht erlaubt:", + "LANGUAGE": "Sprache", + "LANGUAGES": { "de": "Deutsch", "en": "English", "es": "Español", diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json index 0af6455fe7..5b96e6e8da 100644 --- a/console/src/assets/i18n/en.json +++ b/console/src/assets/i18n/en.json @@ -1262,8 +1262,10 @@ "RESET_DESCRIPTION": "You are about to restore all default values. All changes you have made will be permanently deleted. Do you really want to continue?", "UNSAVED_TITLE": "Continue without saving?", "UNSAVED_DESCRIPTION": "You have made changes without saving. Do you want to save now?", - "LOCALE": "Locale Code", - "LOCALES": { + "ACTIVE_LANGUAGE_NOT_ALLOWED": "You selected a language that is not allowed. You can go on modifying the texts. But if you want your users to actually be able to use this language, change your instances restrictions.", + "LANGUAGES_NOT_ALLOWED": "Not allowed:", + "LANGUAGE": "Language", + "LANGUAGES": { "de": "Deutsch", "en": "English", "es": "Español", diff --git a/console/src/assets/i18n/es.json b/console/src/assets/i18n/es.json index 7b4a338a00..7be679000b 100644 --- a/console/src/assets/i18n/es.json +++ b/console/src/assets/i18n/es.json @@ -1262,8 +1262,10 @@ "RESET_DESCRIPTION": "Estás a punto de restaurar todos los valores por defecto. Todos los cambios que has hecho serán borrados permanentemente. ¿Estás seguro de que quieres continuar?", "UNSAVED_TITLE": "¿Continuar sin guardar?", "UNSAVED_DESCRIPTION": "Has hecho cambios sin guardar. ¿Quieres guardarlos ahora?", - "LOCALE": "Código de idioma", - "LOCALES": { + "ACTIVE_LANGUAGE_NOT_ALLOWED": "Has seleccionado un idioma que no está permitido. Puedes seguir modificando los textos. Pero si quieres que tus usuarios realmente puedan usar este idioma, cambia las restricciones de tus instancias.", + "LANGUAGES_NOT_ALLOWED": "No permitido:", + "LANGUAGE": "Idioma", + "LANGUAGES": { "de": "Deutsch", "en": "English", "es": "Español", diff --git a/console/src/assets/i18n/fr.json b/console/src/assets/i18n/fr.json index aa293ba4bf..3659d9000e 100644 --- a/console/src/assets/i18n/fr.json +++ b/console/src/assets/i18n/fr.json @@ -1261,8 +1261,10 @@ "RESET_DESCRIPTION": "Vous êtes sur le point de restaurer toutes les valeurs par défaut. Toutes les modifications que vous avez apportées seront définitivement supprimées. Voulez-vous vraiment continuer ?", "UNSAVED_TITLE": "Continuer sans sauvegarder ?", "UNSAVED_DESCRIPTION": "Vous avez apporté des modifications sans les sauvegarder. Voulez-vous les enregistrer maintenant ?", - "LOCALE": "Code Locale", - "LOCALES": { + "ACTIVE_LANGUAGE_NOT_ALLOWED": "Vous avez sélectionné une langue qui n'est pas autorisée. Vous pouvez continuer à modifier les textes. Mais si vous voulez que vos utilisateurs puissent réellement utiliser cette langue, modifiez les restrictions de vos instances.", + "LANGUAGES_NOT_ALLOWED": "Non autorisé:", + "LANGUAGE": "Langue", + "LANGUAGES": { "de": "Deutsch", "en": "English", "es": "Español", diff --git a/console/src/assets/i18n/it.json b/console/src/assets/i18n/it.json index caca5f6fc7..97c890eed3 100644 --- a/console/src/assets/i18n/it.json +++ b/console/src/assets/i18n/it.json @@ -1261,8 +1261,10 @@ "RESET_DESCRIPTION": "Stai per ripristinare tutti i valori predefiniti. Tutte le modifiche che hai fatto saranno cancellate in modo permanente. Vuoi davvero continuare?", "UNSAVED_TITLE": "Continuare senza salvare?", "UNSAVED_DESCRIPTION": "Hai fatto delle modifiche senza salvare. Vuoi salvare ora?", - "LOCALE": "Codice locale", - "LOCALES": { + "ACTIVE_LANGUAGE_NOT_ALLOWED": "Hai selezionato una lingua non consentita. Puoi continuare a modificare i testi. Ma se vuoi che i tuoi utenti possano effettivamente utilizzare questa lingua, cambia le restrizioni delle tue istanze.", + "LANGUAGE": "Lingua", + "LANGUAGES_NOT_ALLOWED": "Non consentito:", + "LANGUAGES": { "de": "Deutsch", "en": "English", "es": "Español", diff --git a/console/src/assets/i18n/ja.json b/console/src/assets/i18n/ja.json index 527bf018ff..0977ea2be2 100644 --- a/console/src/assets/i18n/ja.json +++ b/console/src/assets/i18n/ja.json @@ -1257,8 +1257,10 @@ "RESET_DESCRIPTION": "すべてのデフォルト値を復元しようとしています。ユーザーが行ったすべての変更は完全に削除されます。本当によろしいですか?", "UNSAVED_TITLE": "保存せずに続行しますか?", "UNSAVED_DESCRIPTION": "あなたは保存せずに変更を加えました。今すぐ保存しますか?", - "LOCALE": "ロケールコード", - "LOCALES": { + "ACTIVE_LANGUAGE_NOT_ALLOWED": "許可されていない言語を選択しました。テキストを変更し続けることはできますが、実際にこの言語を使用できるようにするには、インスタンスの制限を変更してください。", + "LANGUAGES_NOT_ALLOWED": "許可されていない言語:", + "LANGUAGE": "言語", + "LANGUAGES": { "de": "Deutsch", "en": "English", "es": "Español", diff --git a/console/src/assets/i18n/mk.json b/console/src/assets/i18n/mk.json index 4f6df0710c..34810be3c3 100644 --- a/console/src/assets/i18n/mk.json +++ b/console/src/assets/i18n/mk.json @@ -1263,8 +1263,10 @@ "RESET_DESCRIPTION": "Се подготвувате да ги вратите сите стандардни вредности. Сите промени што ги направивте ќе бидат трајно избришани. Дали сте сигурни дека сакате да продолжите?", "UNSAVED_TITLE": "Дали сакате да продолжите без зачувување?", "UNSAVED_DESCRIPTION": "Имате направено промени без зачувување. Дали сакате да ги зачувате сега?", - "LOCALE": "Locale Code", - "LOCALES": { + "ACTIVE_LANGUAGE_NOT_ALLOWED": "Избравте јазик кој не е дозволен. Можете да продолжите да ги менувате текстовите. Но, ако сакате вашите корисници да можат да го користат овој јазик, променете ги ограничувањата на вашата инстанца.", + "LANGUAGES_NOT_ALLOWED": "Не е дозволено:", + "LANGUAGE": "Јазик", + "LANGUAGES": { "de": "Deutsch", "en": "English", "es": "Español", diff --git a/console/src/assets/i18n/nl.json b/console/src/assets/i18n/nl.json index db071c2f45..cb8c5dc555 100644 --- a/console/src/assets/i18n/nl.json +++ b/console/src/assets/i18n/nl.json @@ -1261,9 +1261,11 @@ "RESET_TITLE": "Herstel Standaard Waarden", "RESET_DESCRIPTION": "U staat op het punt om alle standaardwaarden te herstellen. Alle wijzigingen die u heeft gemaakt zullen permanent worden verwijderd. Weet u zeker dat u wilt doorgaan?", "UNSAVED_TITLE": "Doorgaan zonder opslaan?", - "UNSAAVED_DESCRIPTION": "U heeft wijzigingen gemaakt zonder op te slaan. Wilt u nu opslaan?", - "LOCALE": "Locale Code", - "LOCALES": { + "UNSAVED_DESCRIPTION": "U heeft wijzigingen gemaakt zonder op te slaan. Wilt u nu opslaan?", + "ACTIVE_LANGUAGE_NOT_ALLOWED": "U heeft een taal geselecteerd die niet is toegestaan. U kunt doorgaan met het wijzigen van de teksten. Maar als u wilt dat uw gebruikers deze taal daadwerkelijk kunnen gebruiken, wijzig dan de beperkingen van uw instantie.", + "LANGUAGES_NOT_ALLOWED": "Niet toegestaan:", + "LANGUAGE": "Taal", + "LANGUAGES": { "de": "Deutsch", "en": "English", "es": "Español", diff --git a/console/src/assets/i18n/pl.json b/console/src/assets/i18n/pl.json index 39068f2481..aa48a434a7 100644 --- a/console/src/assets/i18n/pl.json +++ b/console/src/assets/i18n/pl.json @@ -1261,8 +1261,10 @@ "RESET_DESCRIPTION": "Masz zamiar przywrócić domyślne linki dla TOS i polityki prywatności. Czy na pewno chcesz kontynuować?", "UNSAVED_TITLE": "Kontynuuj bez zapisywania?", "UNSAVED_DESCRIPTION": "Wprowadziłeś zmiany bez zapisywania. Czy chcesz zapisać teraz?", - "LOCALE": "Kod Języka", - "LOCALES": { + "ACTIVE_LANGUAGE_NOT_ALLOWED": "Wybrałeś język, który nie jest dozwolony. Możesz kontynuować modyfikowanie tekstów. Ale jeśli chcesz, aby twoi użytkownicy mogli faktycznie używać tego języka, zmień ograniczenia swoich instancji.", + "LANGUAGES_NOT_ALLOWED": "Niedozwolone:", + "LANGUAGE": "Język", + "LANGUAGES": { "de": "Deutsch", "en": "English", "es": "Español", diff --git a/console/src/assets/i18n/pt.json b/console/src/assets/i18n/pt.json index 21e4c67a39..69984728b2 100644 --- a/console/src/assets/i18n/pt.json +++ b/console/src/assets/i18n/pt.json @@ -1263,8 +1263,10 @@ "RESET_DESCRIPTION": "Você está prestes a restaurar todos os valores padrão. Todas as alterações que você fez serão excluídas permanentemente. Deseja realmente continuar?", "UNSAVED_TITLE": "Continuar sem salvar?", "UNSAVED_DESCRIPTION": "Você fez alterações sem salvar. Deseja salvar agora?", - "LOCALE": "Código de localidade", - "LOCALES": { + "ACTIVE_LANGUAGE_NOT_ALLOWED": "Você selecionou um idioma que não é permitido. Você pode continuar modificando os textos. Mas se deseja que seus usuários realmente possam usar este idioma, altere as restrições de suas instâncias.", + "LANGUAGES_NOT_ALLOWED": "Não permitido:", + "LANGUAGE": "Idioma", + "LANGUAGES": { "de": "Deutsch", "en": "English", "es": "Español", diff --git a/console/src/assets/i18n/ru.json b/console/src/assets/i18n/ru.json index e4f826021b..02c023f5b7 100644 --- a/console/src/assets/i18n/ru.json +++ b/console/src/assets/i18n/ru.json @@ -1248,8 +1248,10 @@ "RESET_DESCRIPTION": "Вы собираетесь восстановить все значения по умолчанию. Все внесенные вами изменения будут безвозвратно удалены. Вы действительно хотите продолжить?", "UNSAVED_TITLE": "Продолжить без сохранения?", "UNSAVED_DESCRIPTION": "Вы внесли изменения без сохранения. Вы хотите сохранить сейчас?", - "LOCALE": "Код региона", - "LOCALES": { + "ACTIVE_LANGUAGE_NOT_ALLOWED": "Вы выбрали язык, который не разрешен. Вы можете продолжить изменять тексты. Но если вы хотите, чтобы ваши пользователи могли фактически использовать этот язык, измените ограничения ваших экземпляров.", + "LANGUAGES_NOT_ALLOWED": "Не разрешено:", + "LANGUAGE": "Язык", + "LANGUAGES": { "de": "Deutsch", "en": "English", "es": "Español", @@ -1261,6 +1263,7 @@ "bg": "Български", "pt": "Portuguese", "mk": "Македонски", + "cs": "Čeština", "ru": "Русский", "nl": "Nederlands" }, diff --git a/console/src/assets/i18n/zh.json b/console/src/assets/i18n/zh.json index 9697037c14..d1eb3551d5 100644 --- a/console/src/assets/i18n/zh.json +++ b/console/src/assets/i18n/zh.json @@ -1260,8 +1260,10 @@ "RESET_DESCRIPTION": "您即将恢复所有默认值。您所做的所有更改都将被永久删除。你真的要继续吗?", "UNSAVED_TITLE": "继续但不保存?", "UNSAVED_DESCRIPTION": "您在未保存的情况下进行了更改。您现在要保存吗?", - "LOCALE": "本地化", - "LOCALES": { + "ACTIVE_LANGUAGE_NOT_ALLOWED": "您选择了不允许的语言。您可以继续修改文本。但是,如果您希望您的用户实际上能够使用此语言,请更改您的实例限制。", + "LANGUAGES_NOT_ALLOWED": "不允许:", + "LANGUAGE": "语言", + "LANGUAGES": { "de": "Deutsch", "en": "English", "es": "Español", diff --git a/docs/docs/self-hosting/manage/database/_postgres.mdx b/docs/docs/self-hosting/manage/database/_postgres.mdx index bb87a7a1c4..ec2d7861ae 100644 --- a/docs/docs/self-hosting/manage/database/_postgres.mdx +++ b/docs/docs/self-hosting/manage/database/_postgres.mdx @@ -17,6 +17,7 @@ Database: Port: 5432 Database: zitadel MaxOpenConns: 25 + MaxIdleConns: 10 MaxConnLifetime: 1h MaxConnIdleTime: 5m Options: @@ -54,4 +55,4 @@ GRANT CONNECT, CREATE ON DATABASE zitadel TO zitadel; Don't forget to adjust `pg_hba.conf` and set a password for the zitadel user. With the setup done, follow the [phases guide](/docs/self-hosting/manage/updating_scaling#separating-init-and-setup-from-the-runtime) -to run the init and then setup phase to get all necessary tables and data set up. \ No newline at end of file +to run the init and then setup phase to get all necessary tables and data set up. diff --git a/internal/api/grpc/admin/language.go b/internal/api/grpc/admin/language.go index dc3ca055b3..eecf32d4ed 100644 --- a/internal/api/grpc/admin/language.go +++ b/internal/api/grpc/admin/language.go @@ -2,7 +2,6 @@ package admin import ( "context" - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/object" "github.com/zitadel/zitadel/internal/domain" @@ -31,3 +30,15 @@ func (s *Server) SetDefaultLanguage(ctx context.Context, req *admin_pb.SetDefaul func (s *Server) GetDefaultLanguage(ctx context.Context, _ *admin_pb.GetDefaultLanguageRequest) (*admin_pb.GetDefaultLanguageResponse, error) { return &admin_pb.GetDefaultLanguageResponse{Language: authz.GetInstance(ctx).DefaultLanguage().String()}, nil } + +func (s *Server) GetAllowedLanguages(ctx context.Context, _ *admin_pb.GetAllowedLanguagesRequest) (*admin_pb.GetAllowedLanguagesResponse, error) { + restrictions, err := s.query.GetInstanceRestrictions(ctx) + if err != nil { + return nil, err + } + allowed := restrictions.AllowedLanguages + if len(allowed) == 0 { + allowed = i18n.SupportedLanguages() + } + return &admin_pb.GetAllowedLanguagesResponse{Languages: domain.LanguagesToStrings(allowed)}, nil +} diff --git a/internal/api/grpc/admin/restrictions_integration_allow_public_org_registrations_test.go b/internal/api/grpc/admin/restrictions_integration_allow_public_org_registrations_test.go index b6e075ae39..92707df9d6 100644 --- a/internal/api/grpc/admin/restrictions_integration_allow_public_org_registrations_test.go +++ b/internal/api/grpc/admin/restrictions_integration_allow_public_org_registrations_test.go @@ -22,7 +22,7 @@ import ( func TestServer_Restrictions_DisallowPublicOrgRegistration(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() - domain, _, iamOwnerCtx := Tester.UseIsolatedInstance(ctx, SystemCTX) + domain, _, iamOwnerCtx := Tester.UseIsolatedInstance(t, ctx, SystemCTX) regOrgUrl, err := url.Parse("http://" + domain + ":8080/ui/login/register/org") require.NoError(t, err) // The CSRF cookie must be sent with every request. @@ -68,7 +68,7 @@ func awaitPubOrgRegDisallowed(t *testing.T, ctx context.Context, client *http.Cl // awaitGetSSRGetResponse cuts the CSRF token from the response body if it exists func awaitGetSSRGetResponse(t *testing.T, ctx context.Context, client *http.Client, parsedURL *url.URL, expectCode int) string { var csrfToken []byte - await(t, ctx, func() bool { + await(t, ctx, func(tt *assert.CollectT) { resp, err := client.Get(parsedURL.String()) require.NoError(t, err) body, err := io.ReadAll(resp.Body) @@ -78,18 +78,18 @@ func awaitGetSSRGetResponse(t *testing.T, ctx context.Context, client *http.Clie if hasCsrfToken { csrfToken, _, _ = bytes.Cut(after, []byte(`">`)) } - return assert.Equal(NoopAssertionT, resp.StatusCode, expectCode) + assert.Equal(tt, resp.StatusCode, expectCode) }) return string(csrfToken) } // awaitPostFormResponse needs a valid CSRF token to make it to the actual endpoint implementation and get the expected status code func awaitPostFormResponse(t *testing.T, ctx context.Context, client *http.Client, parsedURL *url.URL, expectCode int, csrfToken string) { - await(t, ctx, func() bool { + await(t, ctx, func(tt *assert.CollectT) { resp, err := client.PostForm(parsedURL.String(), url.Values{ "gorilla.csrf.Token": {csrfToken}, }) require.NoError(t, err) - return assert.Equal(NoopAssertionT, resp.StatusCode, expectCode) + assert.Equal(tt, resp.StatusCode, expectCode) }) } diff --git a/internal/api/grpc/admin/restrictions_integration_allowed_languages_test.go b/internal/api/grpc/admin/restrictions_integration_allowed_languages_test.go index bfe9f0031c..277375f525 100644 --- a/internal/api/grpc/admin/restrictions_integration_allowed_languages_test.go +++ b/internal/api/grpc/admin/restrictions_integration_allowed_languages_test.go @@ -5,20 +5,21 @@ package admin_test import ( "context" "encoding/json" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/zitadel/zitadel/internal/integration" - "github.com/zitadel/zitadel/pkg/grpc/admin" - "github.com/zitadel/zitadel/pkg/grpc/management" - "github.com/zitadel/zitadel/pkg/grpc/text" - "github.com/zitadel/zitadel/pkg/grpc/user" - "golang.org/x/text/language" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" "io" "net/http" "testing" "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/text/language" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/zitadel/zitadel/pkg/grpc/admin" + "github.com/zitadel/zitadel/pkg/grpc/management" + "github.com/zitadel/zitadel/pkg/grpc/text" + "github.com/zitadel/zitadel/pkg/grpc/user" ) func TestServer_Restrictions_AllowedLanguages(t *testing.T) { @@ -29,11 +30,10 @@ func TestServer_Restrictions_AllowedLanguages(t *testing.T) { defaultAndAllowedLanguage = language.German supportedLanguagesStr = []string{language.German.String(), language.English.String(), language.Japanese.String()} disallowedLanguage = language.Spanish - unsupportedLanguage1 = language.Afrikaans - unsupportedLanguage2 = language.Albanian + unsupportedLanguage = language.Afrikaans ) - domain, _, iamOwnerCtx := Tester.UseIsolatedInstance(ctx, SystemCTX) + domain, _, iamOwnerCtx := Tester.UseIsolatedInstance(t, ctx, SystemCTX) t.Run("assumed defaults are correct", func(tt *testing.T) { tt.Run("languages are not restricted by default", func(ttt *testing.T) { restrictions, err := Tester.Client.Admin.GetRestrictions(iamOwnerCtx, &admin.GetRestrictionsRequest{}) @@ -46,7 +46,7 @@ func TestServer_Restrictions_AllowedLanguages(t *testing.T) { require.Equal(ttt, language.Make(defaultLang.Language), language.English) }) tt.Run("the discovery endpoint returns all supported languages", func(ttt *testing.T) { - checkDiscoveryEndpoint(ttt, domain, supportedLanguagesStr, nil) + awaitDiscoveryEndpoint(ttt, domain, supportedLanguagesStr, nil) }) }) t.Run("restricting the default language fails", func(tt *testing.T) { @@ -67,6 +67,14 @@ func TestServer_Restrictions_AllowedLanguages(t *testing.T) { t.Run("restricting allowed languages works", func(tt *testing.T) { setAndAwaitAllowedLanguages(iamOwnerCtx, tt, []string{defaultAndAllowedLanguage.String()}) }) + t.Run("GetAllowedLanguage returns only the allowed languages", func(tt *testing.T) { + expectContains, expectNotContains := []string{defaultAndAllowedLanguage.String()}, []string{disallowedLanguage.String()} + adminResp, err := Tester.Client.Admin.GetAllowedLanguages(iamOwnerCtx, &admin.GetAllowedLanguagesRequest{}) + require.NoError(t, err) + langs := adminResp.GetLanguages() + assert.Condition(t, contains(langs, expectContains)) + assert.Condition(t, not(contains(langs, expectNotContains))) + }) t.Run("setting the default language to a disallowed language fails", func(tt *testing.T) { _, err := Tester.Client.Admin.SetDefaultLanguage(iamOwnerCtx, &admin.SetDefaultLanguageRequest{Language: disallowedLanguage.String()}) expectStatus, ok := status.FromError(err) @@ -79,29 +87,31 @@ func TestServer_Restrictions_AllowedLanguages(t *testing.T) { require.Condition(tt, contains(supported.GetLanguages(), supportedLanguagesStr)) }) t.Run("the disallowed language is not listed in the discovery endpoint", func(tt *testing.T) { - checkDiscoveryEndpoint(tt, domain, []string{defaultAndAllowedLanguage.String()}, []string{disallowedLanguage.String()}) + awaitDiscoveryEndpoint(tt, domain, []string{defaultAndAllowedLanguage.String()}, []string{disallowedLanguage.String()}) }) t.Run("the login ui is rendered in the default language", func(tt *testing.T) { - checkLoginUILanguage(tt, domain, disallowedLanguage, defaultAndAllowedLanguage, "Allgemeine Geschäftsbedingungen und Datenschutz") + awaitLoginUILanguage(tt, domain, disallowedLanguage, defaultAndAllowedLanguage, "Allgemeine Geschäftsbedingungen und Datenschutz") }) t.Run("preferred languages are not restricted by the supported languages", func(tt *testing.T) { - var importedUser *management.ImportHumanUserResponse - tt.Run("import user", func(ttt *testing.T) { - var err error - importedUser, err = importUser(iamOwnerCtx, unsupportedLanguage1) - require.NoError(ttt, err) - }) tt.Run("change user profile", func(ttt *testing.T) { - _, err := Tester.Client.Mgmt.UpdateHumanProfile(iamOwnerCtx, &management.UpdateHumanProfileRequest{ - UserId: importedUser.GetUserId(), - FirstName: "hodor", - LastName: "hodor", - NickName: integration.RandString(5), - DisplayName: "hodor", - PreferredLanguage: unsupportedLanguage2.String(), - Gender: user.Gender_GENDER_MALE, - }) + resp, err := Tester.Client.Mgmt.ListUsers(iamOwnerCtx, &management.ListUsersRequest{Queries: []*user.SearchQuery{{Query: &user.SearchQuery_UserNameQuery{UserNameQuery: &user.UserNameQuery{ + UserName: "zitadel-admin@zitadel.localhost"}}, + }}}) require.NoError(ttt, err) + require.Len(ttt, resp.GetResult(), 1) + humanAdmin := resp.GetResult()[0] + profile := humanAdmin.GetHuman().GetProfile() + require.NotEqual(ttt, unsupportedLanguage.String(), profile.GetPreferredLanguage()) + _, updateErr := Tester.Client.Mgmt.UpdateHumanProfile(iamOwnerCtx, &management.UpdateHumanProfileRequest{ + PreferredLanguage: unsupportedLanguage.String(), + UserId: humanAdmin.GetId(), + FirstName: profile.GetFirstName(), + LastName: profile.GetLastName(), + NickName: profile.GetNickName(), + DisplayName: profile.GetDisplayName(), + Gender: profile.GetGender(), + }) + require.NoError(ttt, updateErr) }) }) t.Run("custom texts are only restricted by the supported languages", func(tt *testing.T) { @@ -137,11 +147,11 @@ func TestServer_Restrictions_AllowedLanguages(t *testing.T) { }) t.Run("allowing the language makes it usable again", func(tt *testing.T) { - tt.Run("the disallowed language is listed in the discovery endpoint again", func(ttt *testing.T) { - checkDiscoveryEndpoint(ttt, domain, []string{defaultAndAllowedLanguage.String()}, []string{disallowedLanguage.String()}) + tt.Run("the previously disallowed language is listed in the discovery endpoint again", func(ttt *testing.T) { + awaitDiscoveryEndpoint(ttt, domain, []string{disallowedLanguage.String()}, nil) }) - tt.Run("the login ui is rendered in the allowed language", func(ttt *testing.T) { - checkLoginUILanguage(ttt, domain, disallowedLanguage, disallowedLanguage, "Términos y condiciones") + tt.Run("the login ui is rendered in the previously disallowed language", func(ttt *testing.T) { + awaitLoginUILanguage(ttt, domain, disallowedLanguage, disallowedLanguage, "Términos y condiciones") }) }) } @@ -151,14 +161,14 @@ func setAndAwaitAllowedLanguages(ctx context.Context, t *testing.T, selectLangua require.NoError(t, err) awaitCtx, awaitCancel := context.WithTimeout(ctx, 10*time.Second) defer awaitCancel() - await(t, awaitCtx, func() bool { + await(t, awaitCtx, func(tt *assert.CollectT) { restrictions, getErr := Tester.Client.Admin.GetRestrictions(awaitCtx, &admin.GetRestrictionsRequest{}) expectLanguages := selectLanguages if len(selectLanguages) == 0 { expectLanguages = nil } - return assert.NoError(NoopAssertionT, getErr) && - assert.Equal(NoopAssertionT, expectLanguages, restrictions.GetAllowedLanguages()) + assert.NoError(tt, getErr) + assert.Equal(tt, expectLanguages, restrictions.GetAllowedLanguages()) }) } @@ -167,66 +177,57 @@ func setAndAwaitDefaultLanguage(ctx context.Context, t *testing.T, lang language require.NoError(t, err) awaitCtx, awaitCancel := context.WithTimeout(ctx, 10*time.Second) defer awaitCancel() - await(t, awaitCtx, func() bool { + await(t, awaitCtx, func(tt *assert.CollectT) { defaultLang, getErr := Tester.Client.Admin.GetDefaultLanguage(awaitCtx, &admin.GetDefaultLanguageRequest{}) - return assert.NoError(NoopAssertionT, getErr) && - assert.Equal(NoopAssertionT, lang.String(), defaultLang.GetLanguage()) + assert.NoError(tt, getErr) + assert.Equal(tt, lang.String(), defaultLang.GetLanguage()) }) } -func importUser(ctx context.Context, preferredLanguage language.Tag) (*management.ImportHumanUserResponse, error) { - random := integration.RandString(5) - return Tester.Client.Mgmt.ImportHumanUser(ctx, &management.ImportHumanUserRequest{ - UserName: "integration-test-user_" + random, - Profile: &management.ImportHumanUserRequest_Profile{ - FirstName: "hodor", - LastName: "hodor", - NickName: "hodor", - PreferredLanguage: preferredLanguage.String(), - }, - Email: &management.ImportHumanUserRequest_Email{ - Email: random + "@hodor.hodor", - IsEmailVerified: true, - }, - PasswordChangeRequired: false, - Password: "Password1!", +func awaitDiscoveryEndpoint(t *testing.T, domain string, containsUILocales, notContainsUILocales []string) { + awaitCtx, awaitCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer awaitCancel() + await(t, awaitCtx, func(tt *assert.CollectT) { + req, err := http.NewRequestWithContext(awaitCtx, http.MethodGet, "http://"+domain+":8080/.well-known/openid-configuration", nil) + require.NoError(tt, err) + resp, err := http.DefaultClient.Do(req) + require.NoError(tt, err) + require.Equal(tt, http.StatusOK, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + defer func() { + require.NoError(tt, resp.Body.Close()) + }() + require.NoError(tt, err) + doc := struct { + UILocalesSupported []string `json:"ui_locales_supported"` + }{} + require.NoError(tt, json.Unmarshal(body, &doc)) + if containsUILocales != nil { + assert.Condition(tt, contains(doc.UILocalesSupported, containsUILocales)) + } + if notContainsUILocales != nil { + assert.Condition(tt, not(contains(doc.UILocalesSupported, notContainsUILocales))) + } }) } -func checkDiscoveryEndpoint(t *testing.T, domain string, containsUILocales, notContainsUILocales []string) { - resp, err := http.Get("http://" + domain + ":8080/.well-known/openid-configuration") - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - body, err := io.ReadAll(resp.Body) - defer func() { - require.NoError(t, resp.Body.Close()) - }() - require.NoError(t, err) - doc := struct { - UILocalesSupported []string `json:"ui_locales_supported"` - }{} - require.NoError(t, json.Unmarshal(body, &doc)) - if containsUILocales != nil { - assert.Condition(NoopAssertionT, contains(doc.UILocalesSupported, containsUILocales)) - } - if notContainsUILocales != nil { - assert.Condition(NoopAssertionT, not(contains(doc.UILocalesSupported, notContainsUILocales))) - } -} - -func checkLoginUILanguage(t *testing.T, domain string, acceptLanguage language.Tag, expectLang language.Tag, containsText string) { - req, err := http.NewRequest(http.MethodGet, "http://"+domain+":8080/ui/login/register", nil) - req.Header.Set("Accept-Language", acceptLanguage.String()) - require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - body, err := io.ReadAll(resp.Body) - defer func() { - require.NoError(t, resp.Body.Close()) - }() - require.NoError(t, err) - assert.Containsf(t, string(body), containsText, "login ui language is in "+expectLang.String()) +func awaitLoginUILanguage(t *testing.T, domain string, acceptLanguage language.Tag, expectLang language.Tag, containsText string) { + awaitCtx, awaitCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer awaitCancel() + await(t, awaitCtx, func(tt *assert.CollectT) { + req, err := http.NewRequestWithContext(awaitCtx, http.MethodGet, "http://"+domain+":8080/ui/login/register", nil) + req.Header.Set("Accept-Language", acceptLanguage.String()) + require.NoError(t, err) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + defer func() { + require.NoError(t, resp.Body.Close()) + }() + require.NoError(t, err) + assert.Containsf(t, string(body), containsText, "login ui language is in "+expectLang.String()) + }) } // We would love to use assert.Contains here, but it doesn't work with slices of strings diff --git a/internal/api/grpc/admin/server_integration_test.go b/internal/api/grpc/admin/server_integration_test.go index de1b24b4b0..f3907dddb0 100644 --- a/internal/api/grpc/admin/server_integration_test.go +++ b/internal/api/grpc/admin/server_integration_test.go @@ -17,8 +17,6 @@ import ( var ( AdminCTX, SystemCTX context.Context Tester *integration.Tester - // NoopAssertionT is useful in combination with assert.Eventuallyf to use testify assertions in a callback - NoopAssertionT = new(noopAssertionT) ) func TestMain(m *testing.M) { @@ -36,17 +34,17 @@ func TestMain(m *testing.M) { }()) } -func await(t *testing.T, ctx context.Context, cb func() bool) { +func await(t *testing.T, ctx context.Context, cb func(*assert.CollectT)) { deadline, ok := ctx.Deadline() require.True(t, ok, "context must have deadline") - assert.Eventuallyf( + require.EventuallyWithT( t, - func() bool { + func(tt *assert.CollectT) { defer func() { // Panics are not recovered and don't mark the test as failed, so we need to do that ourselves require.Nil(t, recover(), "panic in await callback") }() - return cb() + cb(tt) }, time.Until(deadline), 100*time.Millisecond, diff --git a/internal/api/grpc/management/language.go b/internal/api/grpc/management/language.go index 4b13ba5c4c..fbcfdc9fbe 100644 --- a/internal/api/grpc/management/language.go +++ b/internal/api/grpc/management/language.go @@ -4,7 +4,6 @@ import ( "context" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/i18n" - mgmt_pb "github.com/zitadel/zitadel/pkg/grpc/management" ) diff --git a/internal/api/grpc/system/instance_integration_test.go b/internal/api/grpc/system/instance_integration_test.go index f874ac79f0..a04118f59f 100644 --- a/internal/api/grpc/system/instance_integration_test.go +++ b/internal/api/grpc/system/instance_integration_test.go @@ -13,7 +13,7 @@ import ( ) func TestServer_ListInstances(t *testing.T) { - domain, instanceID, _ := Tester.UseIsolatedInstance(CTX, SystemCTX) + domain, instanceID, _ := Tester.UseIsolatedInstance(t, CTX, SystemCTX) tests := []struct { name string diff --git a/internal/api/grpc/system/limits_integration_test.go b/internal/api/grpc/system/limits_integration_test.go index e2480d0c0c..2557d72c22 100644 --- a/internal/api/grpc/system/limits_integration_test.go +++ b/internal/api/grpc/system/limits_integration_test.go @@ -20,7 +20,7 @@ import ( ) func TestServer_Limits_AuditLogRetention(t *testing.T) { - _, instanceID, iamOwnerCtx := Tester.UseIsolatedInstance(CTX, SystemCTX) + _, instanceID, iamOwnerCtx := Tester.UseIsolatedInstance(t, CTX, SystemCTX) userID, projectID, appID, projectGrantID := seedObjects(iamOwnerCtx, t) beforeTime := time.Now() zeroCounts := &eventCounts{} diff --git a/internal/api/grpc/system/quota_integration_test.go b/internal/api/grpc/system/quota_integration_test.go index ba0e2cb4d7..7b9403377a 100644 --- a/internal/api/grpc/system/quota_integration_test.go +++ b/internal/api/grpc/system/quota_integration_test.go @@ -23,7 +23,7 @@ import ( var callURL = "http://localhost:" + integration.PortQuotaServer func TestServer_QuotaNotification_Limit(t *testing.T) { - _, instanceID, iamOwnerCtx := Tester.UseIsolatedInstance(CTX, SystemCTX) + _, instanceID, iamOwnerCtx := Tester.UseIsolatedInstance(t, CTX, SystemCTX) amount := 10 percent := 50 percentAmount := amount * percent / 100 @@ -67,7 +67,7 @@ func TestServer_QuotaNotification_Limit(t *testing.T) { } func TestServer_QuotaNotification_NoLimit(t *testing.T) { - _, instanceID, iamOwnerCtx := Tester.UseIsolatedInstance(CTX, SystemCTX) + _, instanceID, iamOwnerCtx := Tester.UseIsolatedInstance(t, CTX, SystemCTX) amount := 10 percent := 50 percentAmount := amount * percent / 100 @@ -149,7 +149,7 @@ func awaitNotification(t *testing.T, bodies chan []byte, unit quota.Unit, percen } func TestServer_AddAndRemoveQuota(t *testing.T) { - _, instanceID, _ := Tester.UseIsolatedInstance(CTX, SystemCTX) + _, instanceID, _ := Tester.UseIsolatedInstance(t, CTX, SystemCTX) got, err := Tester.Client.System.SetQuota(SystemCTX, &system.SetQuotaRequest{ InstanceId: instanceID, diff --git a/internal/api/oidc/client.go b/internal/api/oidc/client.go index 6514583564..246da37361 100644 --- a/internal/api/oidc/client.go +++ b/internal/api/oidc/client.go @@ -927,6 +927,12 @@ func (s *Server) VerifyClient(ctx context.Context, r *op.Request[op.ClientCreden if client.State != domain.AppStateActive { return nil, oidc.ErrInvalidClient().WithDescription("client is not active") } + if client.Settings == nil { + client.Settings = &query.OIDCSettings{ + AccessTokenLifetime: s.defaultAccessTokenLifetime, + IdTokenLifetime: s.defaultIdTokenLifetime, + } + } switch client.AuthMethodType { case domain.OIDCAuthMethodTypeBasic, domain.OIDCAuthMethodTypePost: diff --git a/internal/api/oidc/client_converter.go b/internal/api/oidc/client_converter.go index 1a6c5ed7c1..e6085926ae 100644 --- a/internal/api/oidc/client_converter.go +++ b/internal/api/oidc/client_converter.go @@ -92,11 +92,11 @@ func (c *Client) RestrictAdditionalAccessTokenScopes() func(scopes []string) []s } func (c *Client) AccessTokenLifetime() time.Duration { - return c.client.AccessTokenLifetime + return c.client.Settings.AccessTokenLifetime } func (c *Client) IDTokenLifetime() time.Duration { - return c.client.IDTokenLifetime + return c.client.Settings.IdTokenLifetime } func (c *Client) AccessTokenType() op.AccessTokenType { diff --git a/internal/api/oidc/op.go b/internal/api/oidc/op.go index 1f34f62fb0..f4020d3d87 100644 --- a/internal/api/oidc/op.go +++ b/internal/api/oidc/op.go @@ -122,19 +122,21 @@ func NewServer( } server := &Server{ - LegacyServer: op.NewLegacyServer(provider, endpoints(config.CustomEndpoints)), - features: config.Features, - repo: repo, - query: query, - command: command, - keySet: newKeySet(context.TODO(), time.Hour, query.GetActivePublicKeyByID), - defaultLoginURL: fmt.Sprintf("%s%s?%s=", login.HandlerPrefix, login.EndpointLogin, login.QueryAuthRequestID), - defaultLoginURLV2: config.DefaultLoginURLV2, - defaultLogoutURLV2: config.DefaultLogoutURLV2, - fallbackLogger: fallbackLogger, - hashAlg: crypto.NewBCrypt(10), // as we are only verifying in oidc, the cost is already part of the hash string and the config here is irrelevant. - signingKeyAlgorithm: config.SigningKeyAlgorithm, - assetAPIPrefix: assets.AssetAPI(externalSecure), + LegacyServer: op.NewLegacyServer(provider, endpoints(config.CustomEndpoints)), + features: config.Features, + repo: repo, + query: query, + command: command, + keySet: newKeySet(context.TODO(), time.Hour, query.GetActivePublicKeyByID), + defaultLoginURL: fmt.Sprintf("%s%s?%s=", login.HandlerPrefix, login.EndpointLogin, login.QueryAuthRequestID), + defaultLoginURLV2: config.DefaultLoginURLV2, + defaultLogoutURLV2: config.DefaultLogoutURLV2, + defaultAccessTokenLifetime: config.DefaultAccessTokenLifetime, + defaultIdTokenLifetime: config.DefaultIdTokenLifetime, + fallbackLogger: fallbackLogger, + hashAlg: crypto.NewBCrypt(10), // as we are only verifying in oidc, the cost is already part of the hash string and the config here is irrelevant. + signingKeyAlgorithm: config.SigningKeyAlgorithm, + assetAPIPrefix: assets.AssetAPI(externalSecure), } metricTypes := []metrics.MetricType{metrics.MetricTypeRequestCount, metrics.MetricTypeStatusCode, metrics.MetricTypeTotalCount} server.Handler = op.RegisterLegacyServer(server, op.WithHTTPMiddleware( diff --git a/internal/api/oidc/server.go b/internal/api/oidc/server.go index 1782966e9c..8ba186dd7b 100644 --- a/internal/api/oidc/server.go +++ b/internal/api/oidc/server.go @@ -3,6 +3,7 @@ package oidc import ( "context" "net/http" + "time" "github.com/zitadel/logging" "github.com/zitadel/oidc/v3/pkg/oidc" @@ -27,9 +28,11 @@ type Server struct { command *command.Commands keySet *keySetCache - defaultLoginURL string - defaultLoginURLV2 string - defaultLogoutURLV2 string + defaultLoginURL string + defaultLoginURLV2 string + defaultLogoutURLV2 string + defaultAccessTokenLifetime time.Duration + defaultIdTokenLifetime time.Duration fallbackLogger *slog.Logger hashAlg crypto.HashAlgorithm diff --git a/internal/integration/client.go b/internal/integration/client.go index 4e5f2677dc..f8a6580f75 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -8,6 +8,7 @@ import ( crewjam_saml "github.com/crewjam/saml" "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zitadel/logging" "github.com/zitadel/oidc/v3/pkg/oidc" @@ -59,7 +60,7 @@ func newClient(cc *grpc.ClientConn) Client { } } -func (t *Tester) UseIsolatedInstance(iamOwnerCtx, systemCtx context.Context) (primaryDomain, instanceId string, authenticatedIamOwnerCtx context.Context) { +func (t *Tester) UseIsolatedInstance(tt *testing.T, iamOwnerCtx, systemCtx context.Context) (primaryDomain, instanceId string, authenticatedIamOwnerCtx context.Context) { primaryDomain = RandString(5) + ".integration.localhost" instance, err := t.Client.System.CreateInstance(systemCtx, &system.CreateInstanceRequest{ InstanceName: "testinstance", @@ -80,7 +81,27 @@ func (t *Tester) UseIsolatedInstance(iamOwnerCtx, systemCtx context.Context) (pr t.Users.Set(instanceId, IAMOwner, &User{ Token: instance.GetPat(), }) - return primaryDomain, instanceId, t.WithInstanceAuthorization(iamOwnerCtx, IAMOwner, instanceId) + newCtx := t.WithInstanceAuthorization(iamOwnerCtx, IAMOwner, instanceId) + // the following serves two purposes: + // 1. it ensures that the instance is ready to be used + // 2. it enables a normal login with the default admin user credentials + require.EventuallyWithT(tt, func(collectT *assert.CollectT) { + _, importErr := t.Client.Mgmt.ImportHumanUser(newCtx, &mgmt.ImportHumanUserRequest{ + UserName: "zitadel-admin@zitadel.localhost", + Email: &mgmt.ImportHumanUserRequest_Email{ + Email: "zitadel-admin@zitadel.localhost", + IsEmailVerified: true, + }, + Password: "Password1!", + Profile: &mgmt.ImportHumanUserRequest_Profile{ + FirstName: "hodor", + LastName: "hodor", + NickName: "hodor", + }, + }) + assert.NoError(collectT, importErr) + }, 2*time.Minute, 100*time.Millisecond, "instance not ready") + return primaryDomain, instanceId, newCtx } func (s *Tester) CreateHumanUser(ctx context.Context) *user.AddHumanUserResponse { diff --git a/internal/notification/handlers/quota_notifier_test.go b/internal/notification/handlers/quota_notifier_test.go index 059d4cf041..72991019da 100644 --- a/internal/notification/handlers/quota_notifier_test.go +++ b/internal/notification/handlers/quota_notifier_test.go @@ -21,7 +21,7 @@ import ( ) func TestServer_QuotaNotification_Limit(t *testing.T) { - _, instanceID, iamOwnerCtx := Tester.UseIsolatedInstance(CTX, SystemCTX) + _, instanceID, iamOwnerCtx := Tester.UseIsolatedInstance(t, CTX, SystemCTX) amount := 10 percent := 50 percentAmount := amount * percent / 100 @@ -67,7 +67,7 @@ func TestServer_QuotaNotification_Limit(t *testing.T) { } func TestServer_QuotaNotification_NoLimit(t *testing.T) { - _, instanceID, iamOwnerCtx := Tester.UseIsolatedInstance(CTX, SystemCTX) + _, instanceID, iamOwnerCtx := Tester.UseIsolatedInstance(t, CTX, SystemCTX) amount := 10 percent := 50 percentAmount := amount * percent / 100 diff --git a/internal/notification/handlers/telemetry_pusher_integration_test.go b/internal/notification/handlers/telemetry_pusher_integration_test.go index f0d46b3613..9520253ade 100644 --- a/internal/notification/handlers/telemetry_pusher_integration_test.go +++ b/internal/notification/handlers/telemetry_pusher_integration_test.go @@ -13,7 +13,7 @@ import ( ) func TestServer_TelemetryPushMilestones(t *testing.T) { - primaryDomain, instanceID, iamOwnerCtx := Tester.UseIsolatedInstance(CTX, SystemCTX) + primaryDomain, instanceID, iamOwnerCtx := Tester.UseIsolatedInstance(t, CTX, SystemCTX) t.Log("testing against instance with primary domain", primaryDomain) awaitMilestone(t, Tester.MilestoneChan, primaryDomain, "InstanceCreated") project, err := Tester.Client.Mgmt.AddProject(iamOwnerCtx, &management.AddProjectRequest{Name: "integration"}) diff --git a/internal/query/embed/oidc_client_by_id.sql b/internal/query/embed/oidc_client_by_id.sql index 64986a42da..c2fa73c3e2 100644 --- a/internal/query/embed/oidc_client_by_id.sql +++ b/internal/query/embed/oidc_client_by_id.sql @@ -29,18 +29,18 @@ keys as ( group by identifier ), settings as ( - select instance_id, access_token_lifetime, id_token_lifetime + select instance_id, json_build_object('access_token_lifetime', access_token_lifetime, 'id_token_lifetime', id_token_lifetime) as settings from projections.oidc_settings2 where aggregate_id = $1 and instance_id = $1 ) select row_to_json(r) as client from ( - select c.*, r.project_role_keys, k.public_keys, s.access_token_lifetime, s.id_token_lifetime + select c.*, r.project_role_keys, k.public_keys, s.settings from client c left join roles r on r.project_id = c.project_id left join keys k on k.client_id = c.client_id - join settings s on s.instance_id = s.instance_id + left join settings s on s.instance_id = s.instance_id ) r; --execute q('230690539048009730', '236647088211951618@tests', true); \ No newline at end of file diff --git a/internal/query/oidc_client.go b/internal/query/oidc_client.go index 60ecf25174..ac685fab5d 100644 --- a/internal/query/oidc_client.go +++ b/internal/query/oidc_client.go @@ -37,8 +37,7 @@ type OIDCClient struct { PublicKeys map[string][]byte `json:"public_keys,omitempty"` ProjectID string `json:"project_id,omitempty"` ProjectRoleKeys []string `json:"project_role_keys,omitempty"` - AccessTokenLifetime time.Duration `json:"access_token_lifetime,omitempty"` - IDTokenLifetime time.Duration `json:"id_token_lifetime,omitempty"` + Settings *OIDCSettings `json:"settings,omitempty"` } //go:embed embed/oidc_client_by_id.sql diff --git a/internal/query/oidc_client_test.go b/internal/query/oidc_client_test.go index 0593e1ce03..d32e9208b4 100644 --- a/internal/query/oidc_client_test.go +++ b/internal/query/oidc_client_test.go @@ -24,6 +24,8 @@ var ( testdataOidcClientPublic string //go:embed testdata/oidc_client_secret.json testdataOidcClientSecret string + //go:embed testdata/oidc_client_no_settings.json + testdataOidcClientNoSettings string ) func TestQueries_GetOIDCClientByID(t *testing.T) { @@ -81,8 +83,10 @@ low2kyJov38V4Uk2I8kuXpLcnrpw5Tio2ooiUE27b0vHZqBKOei9Uo88qCrn3EKx ProjectID: "236645808328409090", PublicKeys: map[string][]byte{"236647201860747266": []byte(pubkey)}, ProjectRoleKeys: []string{"role1", "role2"}, - AccessTokenLifetime: 43200000000000, - IDTokenLifetime: 43200000000000, + Settings: &OIDCSettings{ + AccessTokenLifetime: 43200000000000, + IdTokenLifetime: 43200000000000, + }, }, }, { @@ -110,8 +114,10 @@ low2kyJov38V4Uk2I8kuXpLcnrpw5Tio2ooiUE27b0vHZqBKOei9Uo88qCrn3EKx PublicKeys: nil, ProjectID: "236645808328409090", ProjectRoleKeys: []string{"role1", "role2"}, - AccessTokenLifetime: 43200000000000, - IDTokenLifetime: 43200000000000, + Settings: &OIDCSettings{ + AccessTokenLifetime: 43200000000000, + IdTokenLifetime: 43200000000000, + }, }, }, { @@ -143,8 +149,43 @@ low2kyJov38V4Uk2I8kuXpLcnrpw5Tio2ooiUE27b0vHZqBKOei9Uo88qCrn3EKx PublicKeys: nil, ProjectID: "236645808328409090", ProjectRoleKeys: []string{"role1", "role2"}, - AccessTokenLifetime: 43200000000000, - IDTokenLifetime: 43200000000000, + Settings: &OIDCSettings{ + AccessTokenLifetime: 43200000000000, + IdTokenLifetime: 43200000000000, + }, + }, + }, + { + name: "no oidc settings", + mock: mockQuery(expQuery, cols, []driver.Value{testdataOidcClientNoSettings}, "instanceID", "clientID", true), + want: &OIDCClient{ + InstanceID: "239520764275982338", + AppID: "239520764276441090", + State: domain.AppStateActive, + ClientID: "239520764779364354@zitadel", + ClientSecret: nil, + RedirectURIs: []string{ + "http://test2-qucuh5.localhost:9000/ui/console/auth/callback", + "http://test.localhost.com:9000/ui/console/auth/callback"}, + ResponseTypes: []domain.OIDCResponseType{0}, + GrantTypes: []domain.OIDCGrantType{0}, + ApplicationType: domain.OIDCApplicationTypeUserAgent, + AuthMethodType: domain.OIDCAuthMethodTypeNone, + PostLogoutRedirectURIs: []string{ + "http://test2-qucuh5.localhost:9000/ui/console/signedout", + "http://test.localhost.com:9000/ui/console/signedout", + }, + IsDevMode: true, + AccessTokenType: domain.OIDCTokenTypeBearer, + AccessTokenRoleAssertion: false, + IDTokenRoleAssertion: false, + IDTokenUserinfoAssertion: false, + ClockSkew: 0, + AdditionalOrigins: nil, + PublicKeys: nil, + ProjectID: "239520764276178946", + ProjectRoleKeys: nil, + Settings: nil, }, }, } diff --git a/internal/query/oidc_settings.go b/internal/query/oidc_settings.go index 7e48ff43a7..a5d9052d4c 100644 --- a/internal/query/oidc_settings.go +++ b/internal/query/oidc_settings.go @@ -69,10 +69,10 @@ type OIDCSettings struct { ResourceOwner string Sequence uint64 - AccessTokenLifetime time.Duration - IdTokenLifetime time.Duration - RefreshTokenIdleExpiration time.Duration - RefreshTokenExpiration time.Duration + AccessTokenLifetime time.Duration `json:"access_token_lifetime,omitempty"` + IdTokenLifetime time.Duration `json:"id_token_lifetime,omitempty"` + RefreshTokenIdleExpiration time.Duration `json:"refresh_token_idle_expiration,omitempty"` + RefreshTokenExpiration time.Duration `json:"refresh_token_expiration,omitempty"` } func (q *Queries) OIDCSettingsByAggID(ctx context.Context, aggregateID string) (settings *OIDCSettings, err error) { diff --git a/internal/query/testdata/oidc_client_jwt.json b/internal/query/testdata/oidc_client_jwt.json index d32e5a5110..df871815dd 100644 --- a/internal/query/testdata/oidc_client_jwt.json +++ b/internal/query/testdata/oidc_client_jwt.json @@ -22,6 +22,8 @@ "public_keys": { "236647201860747266": "LS0tLS1CRUdJTiBSU0EgUFVCTElDIEtFWS0tLS0tCk1JSUJJakFOQmdrcWhraUc5dzBCQVFFRkFB\nT0NBUThBTUlJQkNnS0NBUUVBMnVmQUwxYjcyYkl5MWFyK1dzNmIKR29oSkpRRkI3ZGZSYXBEcWVx\nTThVa3A2Q1ZkUHpxL3BPejF2aUFxNTB5eldaSnJ5Risyd3NoRkFLR0Y5QTIvQgoyWWY5YkpYUFov\nS2JrRnJZVDNOVHZZRGt2bGFTVGw5bU1uenJVMjlzNDhGMVBUV0tmQitDM2FNc09FRzFCdWZWCnM2\nM3FGNG5yRVBqU2JobGpJY285RlpxNFhwcEl6aE1RMGZEZEEvK1h5Z0NKcXZ1YUwwTGliTTFLcmxV\nZG51NzEKWWVraFNKakVQbnZPaXNYSWs0SVh5d29HSU93dGp4a0R2Tkl0UXZhTVZsZHI0L2tiNnV2\nYmdkV3dxNUV3QlpYcQpsb3cya3lKb3YzOFY0VWsySThrdVhwTGNucnB3NVRpbzJvb2lVRTI3YjB2\nSFpxQktPZWk5VW84OHFDcm4zRUt4CjZRSURBUUFCCi0tLS0tRU5EIFJTQSBQVUJMSUMgS0VZLS0t\nLS0K" }, - "access_token_lifetime": 43200000000000, - "id_token_lifetime": 43200000000000 + "settings": { + "access_token_lifetime": 43200000000000, + "id_token_lifetime": 43200000000000 + } } diff --git a/internal/query/testdata/oidc_client_no_settings.json b/internal/query/testdata/oidc_client_no_settings.json new file mode 100644 index 0000000000..83d810d669 --- /dev/null +++ b/internal/query/testdata/oidc_client_no_settings.json @@ -0,0 +1,30 @@ +{ + "instance_id": "239520764275982338", + "app_id": "239520764276441090", + "client_id": "239520764779364354@zitadel", + "client_secret": null, + "redirect_uris": [ + "http://test2-qucuh5.localhost:9000/ui/console/auth/callback", + "http://test.localhost.com:9000/ui/console/auth/callback" + ], + "response_types": [0], + "grant_types": [0], + "application_type": 1, + "auth_method_type": 2, + "post_logout_redirect_uris": [ + "http://test2-qucuh5.localhost:9000/ui/console/signedout", + "http://test.localhost.com:9000/ui/console/signedout" + ], + "is_dev_mode": true, + "access_token_type": 0, + "access_token_role_assertion": false, + "id_token_role_assertion": false, + "id_token_userinfo_assertion": false, + "clock_skew": 0, + "additional_origins": null, + "project_id": "239520764276178946", + "state": 1, + "project_role_keys": null, + "public_keys": null, + "settings": null +} diff --git a/internal/query/testdata/oidc_client_public.json b/internal/query/testdata/oidc_client_public.json index ba23c95351..47cf750c8b 100644 --- a/internal/query/testdata/oidc_client_public.json +++ b/internal/query/testdata/oidc_client_public.json @@ -20,6 +20,8 @@ "state": 1, "project_role_keys": ["role1", "role2"], "public_keys": null, - "access_token_lifetime": 43200000000000, - "id_token_lifetime": 43200000000000 + "settings": { + "access_token_lifetime": 43200000000000, + "id_token_lifetime": 43200000000000 + } } diff --git a/internal/query/testdata/oidc_client_secret.json b/internal/query/testdata/oidc_client_secret.json index 43b3256bb6..e7d5926f7f 100644 --- a/internal/query/testdata/oidc_client_secret.json +++ b/internal/query/testdata/oidc_client_secret.json @@ -25,6 +25,8 @@ "state": 1, "project_role_keys": ["role1", "role2"], "public_keys": null, - "access_token_lifetime": 43200000000000, - "id_token_lifetime": 43200000000000 + "settings": { + "access_token_lifetime": 43200000000000, + "id_token_lifetime": 43200000000000 + } } diff --git a/proto/zitadel/admin.proto b/proto/zitadel/admin.proto index 3e1d56fdda..dc0dc962e9 100644 --- a/proto/zitadel/admin.proto +++ b/proto/zitadel/admin.proto @@ -230,7 +230,7 @@ service AdminService { }; option (zitadel.v1.auth_option) = { - permission: "iam.read"; + permission: "authenticated"; }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { @@ -240,6 +240,22 @@ service AdminService { }; } + rpc GetAllowedLanguages(GetAllowedLanguagesRequest) returns (GetAllowedLanguagesResponse) { + option (google.api.http) = { + get: "/languages/allowed"; + }; + + option (zitadel.v1.auth_option) = { + permission: "authenticated"; + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Allowed Languages"; + description: "If the languages are restricted, only those are returned. Else, all supported languages are returned." + tags: "Restrictions"; + }; + } + rpc SetDefaultLanguage(SetDefaultLanguageRequest) returns (SetDefaultLanguageResponse) { option (google.api.http) = { put: "/languages/default/{language}"; @@ -3868,6 +3884,17 @@ message GetSupportedLanguagesResponse { ]; } +//This is an empty request +message GetAllowedLanguagesRequest {} + +message GetAllowedLanguagesResponse { + repeated string languages = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"en\", \"de\", \"it\"]" + } + ]; +} + message SetDefaultLanguageRequest { string language = 1 [ (validate.rules).string = {min_len: 1, max_len: 10}, diff --git a/proto/zitadel/auth.proto b/proto/zitadel/auth.proto index 5fd8772edc..58767f9fe9 100644 --- a/proto/zitadel/auth.proto +++ b/proto/zitadel/auth.proto @@ -143,10 +143,10 @@ service AuthService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { summary: "Supported Languages"; - description: "The supported/default languages of the system will be returned by the language abbreviation." + description: "Use GetSupportedLanguages on the admin service instead." + deprecated: true; tags: "General"; }; - } rpc GetMyUser(GetMyUserRequest) returns (GetMyUserResponse) { @@ -996,7 +996,6 @@ message HealthzResponse {} //This is an empty request message GetSupportedLanguagesRequest {} -//This is an empty response message GetSupportedLanguagesResponse { repeated string languages = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index 37f7c12b20..9bb2e1efb3 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -268,7 +268,8 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { summary: "Supported Languages"; - description: "The supported/default languages of the system will be returned by the language abbreviation." + description: "Use GetSupportedLanguages on the admin service instead." + deprecated: true; tags: "General"; responses: { key: "200"