mirror of
https://github.com/bitwarden/browser
synced 2025-12-12 06:13:38 +00:00
Auth/PM-8367 - Email Verification - Integrate Registration Self Hosted Env Selector + new Self Hosted Env Settings Dialog into Registration Start (#9361)
* PM-8367 - WIP - initial comp creation * PM-8367 - Majority of new registration self hosted env config dialog working * PM-8367 - RegistrationEnvSelectorComponent - add method handleSelfHostedEnvConfigDialogResult and add toast for happy path. * PM-8367 - Add validation TODO * PM-8367 - RegistrationSelfHostedEnvConfigDialogComponent - Add validator * PM-8367 - RegEnvSelector - Only show self hosted if the client is browser or desktop since we will be using the selector on web as well. * PM-8367 - Registration start comp - add env selector * PM-8367 - Registration start - add proper import for standalone comps. * PM-8367 - Registration Start - get storybook fixed with registration env selector * PM-8367 - Add self hosted server to web translations only for storybook * PM-8367 - Add more storybook examples and update docs (WIP - need to test self hosted selection) * PM-8367 - Registration Start - update stories * PM-8367 - Env Selector now emits selected region so that parent comps can listen to it if needed. * PM-8367 - Registration Start - wire up handler for selectedRegionChange so that the parent comp can successfully track isSelfHost and hide / show the terms / privacy policy checkbox * PM-8367 - TODO cleanup * PM-8367 - Registration start docs - stage gate is two words. * PM-8367 - Per working session with Will, move top level provided services to app level instead of module level to solve dialog null injector errors. * PM-8367 - Storybook working for self hosted env dialog * PM-8367 - Add dialog scroll feature to bitDialog and implement in self hosted env dialog. * PM-8367 - Revert bit dialog changes and scroll implementation. * PM-8367 - Tweak registration start docs * PM-8367 - Remove unused changeDetectorRef * PM-8367 - Add docs per PR feedback
This commit is contained in:
@@ -1110,6 +1110,15 @@
|
|||||||
"selfHostedEnvironmentFooter": {
|
"selfHostedEnvironmentFooter": {
|
||||||
"message": "Specify the base URL of your on-premises hosted Bitwarden installation."
|
"message": "Specify the base URL of your on-premises hosted Bitwarden installation."
|
||||||
},
|
},
|
||||||
|
"selfHostedBaseUrlHint": {
|
||||||
|
"message": "Specify the base URL of your on-premises hosted Bitwarden installation. Example: https://bitwarden.company.com"
|
||||||
|
},
|
||||||
|
"selfHostedCustomEnvHeader" :{
|
||||||
|
"message": "For advanced configuration, you can specify the base URL of each service independently."
|
||||||
|
},
|
||||||
|
"selfHostedEnvFormInvalid" :{
|
||||||
|
"message": "You must add either the base Server URL or at least one custom environment."
|
||||||
|
},
|
||||||
"customEnvironment": {
|
"customEnvironment": {
|
||||||
"message": "Custom environment"
|
"message": "Custom environment"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -695,6 +695,15 @@
|
|||||||
"selfHostedEnvironmentFooter": {
|
"selfHostedEnvironmentFooter": {
|
||||||
"message": "Specify the base URL of your on-premises hosted Bitwarden installation."
|
"message": "Specify the base URL of your on-premises hosted Bitwarden installation."
|
||||||
},
|
},
|
||||||
|
"selfHostedBaseUrlHint": {
|
||||||
|
"message": "Specify the base URL of your on-premises hosted Bitwarden installation. Example: https://bitwarden.company.com"
|
||||||
|
},
|
||||||
|
"selfHostedCustomEnvHeader" :{
|
||||||
|
"message": "For advanced configuration, you can specify the base URL of each service independently."
|
||||||
|
},
|
||||||
|
"selfHostedEnvFormInvalid" :{
|
||||||
|
"message": "You must add either the base Server URL or at least one custom environment."
|
||||||
|
},
|
||||||
"customEnvironment": {
|
"customEnvironment": {
|
||||||
"message": "Custom environment"
|
"message": "Custom environment"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5595,6 +5595,39 @@
|
|||||||
"rotateBillingSyncTokenTitle": {
|
"rotateBillingSyncTokenTitle": {
|
||||||
"message": "Rotating the billing sync token will invalidate the previous token."
|
"message": "Rotating the billing sync token will invalidate the previous token."
|
||||||
},
|
},
|
||||||
|
"selfHostedServer": {
|
||||||
|
"message": "self-hosted"
|
||||||
|
},
|
||||||
|
"customEnvironment": {
|
||||||
|
"message": "Custom environment"
|
||||||
|
},
|
||||||
|
"selfHostedBaseUrlHint": {
|
||||||
|
"message": "Specify the base URL of your on-premises hosted Bitwarden installation. Example: https://bitwarden.company.com"
|
||||||
|
},
|
||||||
|
"selfHostedCustomEnvHeader" :{
|
||||||
|
"message": "For advanced configuration, you can specify the base URL of each service independently."
|
||||||
|
},
|
||||||
|
"selfHostedEnvFormInvalid" :{
|
||||||
|
"message": "You must add either the base Server URL or at least one custom environment."
|
||||||
|
},
|
||||||
|
"apiUrl": {
|
||||||
|
"message": "API server URL"
|
||||||
|
},
|
||||||
|
"webVaultUrl": {
|
||||||
|
"message": "Web vault server URL"
|
||||||
|
},
|
||||||
|
"identityUrl": {
|
||||||
|
"message": "Identity server URL"
|
||||||
|
},
|
||||||
|
"notificationsUrl": {
|
||||||
|
"message": "Notifications server URL"
|
||||||
|
},
|
||||||
|
"iconsUrl": {
|
||||||
|
"message": "Icons server URL"
|
||||||
|
},
|
||||||
|
"environmentSaved": {
|
||||||
|
"message": "Environment URLs saved"
|
||||||
|
},
|
||||||
"selfHostingTitle": {
|
"selfHostingTitle": {
|
||||||
"message": "Self-hosting"
|
"message": "Self-hosting"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
[label]="regionConfig.domain"
|
[label]="regionConfig.domain"
|
||||||
></bit-option>
|
></bit-option>
|
||||||
<bit-option
|
<bit-option
|
||||||
|
*ngIf="isDesktopOrBrowserExtension"
|
||||||
[value]="ServerEnvironmentType.SelfHosted"
|
[value]="ServerEnvironmentType.SelfHosted"
|
||||||
[label]="'selfHostedServer' | i18n"
|
[label]="'selfHostedServer' | i18n"
|
||||||
></bit-option>
|
></bit-option>
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
import { CommonModule } from "@angular/common";
|
import { CommonModule } from "@angular/common";
|
||||||
import { Component, EventEmitter, OnDestroy, OnInit, Output } from "@angular/core";
|
import { Component, EventEmitter, OnDestroy, OnInit, Output } from "@angular/core";
|
||||||
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms";
|
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||||
import { EMPTY, Subject, from, map, of, switchMap, takeUntil, tap } from "rxjs";
|
import { Subject, from, map, of, pairwise, startWith, switchMap, takeUntil, tap } from "rxjs";
|
||||||
|
|
||||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
|
import { ClientType } from "@bitwarden/common/enums";
|
||||||
import {
|
import {
|
||||||
Environment,
|
Environment,
|
||||||
EnvironmentService,
|
EnvironmentService,
|
||||||
Region,
|
Region,
|
||||||
RegionConfig,
|
RegionConfig,
|
||||||
} from "@bitwarden/common/platform/abstractions/environment.service";
|
} from "@bitwarden/common/platform/abstractions/environment.service";
|
||||||
import { FormFieldModule, SelectModule } from "@bitwarden/components";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
|
import { DialogService, FormFieldModule, SelectModule, ToastService } from "@bitwarden/components";
|
||||||
|
|
||||||
|
import { RegistrationSelfHostedEnvConfigDialogComponent } from "./registration-self-hosted-env-config-dialog.component";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component for selecting the environment to register with in the email verification registration flow.
|
||||||
|
* Outputs the selected region to the parent component so it can respond as necessary.
|
||||||
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
standalone: true,
|
standalone: true,
|
||||||
selector: "auth-registration-env-selector",
|
selector: "auth-registration-env-selector",
|
||||||
@@ -19,7 +28,7 @@ import { FormFieldModule, SelectModule } from "@bitwarden/components";
|
|||||||
imports: [CommonModule, JslibModule, ReactiveFormsModule, FormFieldModule, SelectModule],
|
imports: [CommonModule, JslibModule, ReactiveFormsModule, FormFieldModule, SelectModule],
|
||||||
})
|
})
|
||||||
export class RegistrationEnvSelectorComponent implements OnInit, OnDestroy {
|
export class RegistrationEnvSelectorComponent implements OnInit, OnDestroy {
|
||||||
@Output() onOpenSelfHostedSettings = new EventEmitter();
|
@Output() selectedRegionChange = new EventEmitter<RegionConfig | Region.SelfHosted | null>();
|
||||||
|
|
||||||
ServerEnvironmentType = Region;
|
ServerEnvironmentType = Region;
|
||||||
|
|
||||||
@@ -33,12 +42,24 @@ export class RegistrationEnvSelectorComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
availableRegionConfigs: RegionConfig[] = this.environmentService.availableRegions();
|
availableRegionConfigs: RegionConfig[] = this.environmentService.availableRegions();
|
||||||
|
|
||||||
|
private selectedRegionFromEnv: RegionConfig | Region.SelfHosted;
|
||||||
|
|
||||||
|
isDesktopOrBrowserExtension = false;
|
||||||
|
|
||||||
private destroy$ = new Subject<void>();
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private formBuilder: FormBuilder,
|
private formBuilder: FormBuilder,
|
||||||
private environmentService: EnvironmentService,
|
private environmentService: EnvironmentService,
|
||||||
) {}
|
private dialogService: DialogService,
|
||||||
|
private i18nService: I18nService,
|
||||||
|
private toastService: ToastService,
|
||||||
|
private platformUtilsService: PlatformUtilsService,
|
||||||
|
) {
|
||||||
|
const clientType = platformUtilsService.getClientType();
|
||||||
|
this.isDesktopOrBrowserExtension =
|
||||||
|
clientType === ClientType.Desktop || clientType === ClientType.Browser;
|
||||||
|
}
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
await this.initSelectedRegionAndListenForEnvChanges();
|
await this.initSelectedRegionAndListenForEnvChanges();
|
||||||
@@ -61,13 +82,17 @@ export class RegistrationEnvSelectorComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
return regionConfig;
|
return regionConfig;
|
||||||
}),
|
}),
|
||||||
tap((selectedRegionInitialValue: RegionConfig | Region.SelfHosted) => {
|
tap((selectedRegionFromEnv: RegionConfig | Region.SelfHosted) => {
|
||||||
// This inits the form control with the selected region, but
|
// Only set the value if it is different from the current value.
|
||||||
// it also sets the value to self hosted if the self hosted settings are saved successfully
|
if (selectedRegionFromEnv !== this.selectedRegion.value) {
|
||||||
// in the client specific implementation managed by the parent component.
|
// Don't emit to avoid triggering the selectedRegion valueChanges subscription
|
||||||
// It also resets the value to the previously selected region if the self hosted
|
// which could loop back to this code.
|
||||||
// settings are closed without saving. We don't emit the event to avoid a loop.
|
this.selectedRegion.setValue(selectedRegionFromEnv, { emitEvent: false });
|
||||||
this.selectedRegion.setValue(selectedRegionInitialValue, { emitEvent: false });
|
}
|
||||||
|
|
||||||
|
// Save this off so we can reset the value to the previously selected region
|
||||||
|
// if the self hosted settings are closed without saving.
|
||||||
|
this.selectedRegionFromEnv = selectedRegionFromEnv;
|
||||||
}),
|
}),
|
||||||
takeUntil(this.destroy$),
|
takeUntil(this.destroy$),
|
||||||
)
|
)
|
||||||
@@ -77,23 +102,66 @@ export class RegistrationEnvSelectorComponent implements OnInit, OnDestroy {
|
|||||||
private listenForSelectedRegionChanges() {
|
private listenForSelectedRegionChanges() {
|
||||||
this.selectedRegion.valueChanges
|
this.selectedRegion.valueChanges
|
||||||
.pipe(
|
.pipe(
|
||||||
switchMap((selectedRegionConfig: RegionConfig | Region.SelfHosted | null) => {
|
startWith(null), // required so that first user choice is not ignored
|
||||||
if (selectedRegionConfig === null) {
|
pairwise(),
|
||||||
return of(null);
|
switchMap(
|
||||||
}
|
([prevSelectedRegion, selectedRegion]: [
|
||||||
|
RegionConfig | Region.SelfHosted | null,
|
||||||
|
RegionConfig | Region.SelfHosted | null,
|
||||||
|
]) => {
|
||||||
|
if (selectedRegion === null) {
|
||||||
|
this.selectedRegionChange.emit(selectedRegion);
|
||||||
|
return of(null);
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedRegionConfig === Region.SelfHosted) {
|
if (selectedRegion === Region.SelfHosted) {
|
||||||
this.onOpenSelfHostedSettings.emit();
|
return from(
|
||||||
return EMPTY;
|
RegistrationSelfHostedEnvConfigDialogComponent.open(this.dialogService),
|
||||||
}
|
).pipe(
|
||||||
|
tap((result: boolean | undefined) =>
|
||||||
|
this.handleSelfHostedEnvConfigDialogResult(result, prevSelectedRegion),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return from(this.environmentService.setEnvironment(selectedRegionConfig.key));
|
this.selectedRegionChange.emit(selectedRegion);
|
||||||
}),
|
return from(this.environmentService.setEnvironment(selectedRegion.key));
|
||||||
|
},
|
||||||
|
),
|
||||||
takeUntil(this.destroy$),
|
takeUntil(this.destroy$),
|
||||||
)
|
)
|
||||||
.subscribe();
|
.subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleSelfHostedEnvConfigDialogResult(
|
||||||
|
result: boolean | undefined,
|
||||||
|
prevSelectedRegion: RegionConfig | Region.SelfHosted | null,
|
||||||
|
) {
|
||||||
|
if (result === true) {
|
||||||
|
this.selectedRegionChange.emit(Region.SelfHosted);
|
||||||
|
this.toastService.showToast({
|
||||||
|
variant: "success",
|
||||||
|
title: null,
|
||||||
|
message: this.i18nService.t("environmentSaved"),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the value to the previously selected region or the current env setting
|
||||||
|
// if the self hosted env settings dialog is closed without saving.
|
||||||
|
if (
|
||||||
|
(result === false || result === undefined) &&
|
||||||
|
prevSelectedRegion !== null &&
|
||||||
|
prevSelectedRegion !== Region.SelfHosted
|
||||||
|
) {
|
||||||
|
this.selectedRegionChange.emit(prevSelectedRegion);
|
||||||
|
this.selectedRegion.setValue(prevSelectedRegion, { emitEvent: false });
|
||||||
|
} else {
|
||||||
|
this.selectedRegionChange.emit(this.selectedRegionFromEnv);
|
||||||
|
this.selectedRegion.setValue(this.selectedRegionFromEnv, { emitEvent: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
this.destroy$.next();
|
this.destroy$.next();
|
||||||
this.destroy$.complete();
|
this.destroy$.complete();
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||||
|
<bit-dialog>
|
||||||
|
<span bitDialogTitle> Self-hosted environment</span>
|
||||||
|
<ng-container bitDialogContent>
|
||||||
|
<bit-form-field>
|
||||||
|
<bit-label>{{ "baseUrl" | i18n }}</bit-label>
|
||||||
|
<input
|
||||||
|
id="self_hosted_env_settings_form_input_base_url"
|
||||||
|
bitInput
|
||||||
|
type="text"
|
||||||
|
formControlName="baseUrl"
|
||||||
|
appAutofocus
|
||||||
|
appInputVerbatim
|
||||||
|
/>
|
||||||
|
<bit-hint>{{ "selfHostedBaseUrlHint" | i18n }}</bit-hint>
|
||||||
|
</bit-form-field>
|
||||||
|
|
||||||
|
<button bitLink linkType="primary" type="button" (click)="showCustomEnv = !showCustomEnv">
|
||||||
|
<i
|
||||||
|
class="bwi bwi-fw bwi-sm"
|
||||||
|
[ngClass]="{ 'bwi-angle-right': !showCustomEnv, 'bwi-angle-down': showCustomEnv }"
|
||||||
|
aria-hidden="true"
|
||||||
|
></i>
|
||||||
|
{{ "customEnvironment" | i18n }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ng-container *ngIf="showCustomEnv">
|
||||||
|
<p bitTypography="body1" class="tw-text-muted tw-mt-3">
|
||||||
|
{{ "selfHostedCustomEnvHeader" | i18n }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<bit-form-field>
|
||||||
|
<bit-label>{{ "webVaultUrl" | i18n }}</bit-label>
|
||||||
|
<input
|
||||||
|
id="self_hosted_env_settings_form_input_web_vault_url"
|
||||||
|
bitInput
|
||||||
|
type="text"
|
||||||
|
formControlName="webVaultUrl"
|
||||||
|
appInputVerbatim
|
||||||
|
/>
|
||||||
|
</bit-form-field>
|
||||||
|
|
||||||
|
<bit-form-field>
|
||||||
|
<bit-label>{{ "apiUrl" | i18n }}</bit-label>
|
||||||
|
<input
|
||||||
|
id="self_hosted_env_settings_form_input_api_url"
|
||||||
|
bitInput
|
||||||
|
type="text"
|
||||||
|
formControlName="apiUrl"
|
||||||
|
appInputVerbatim
|
||||||
|
/>
|
||||||
|
</bit-form-field>
|
||||||
|
|
||||||
|
<bit-form-field>
|
||||||
|
<bit-label>{{ "identityUrl" | i18n }}</bit-label>
|
||||||
|
<input
|
||||||
|
id="self_hosted_env_settings_form_input_identity_url"
|
||||||
|
bitInput
|
||||||
|
type="text"
|
||||||
|
formControlName="identityUrl"
|
||||||
|
appInputVerbatim
|
||||||
|
/>
|
||||||
|
</bit-form-field>
|
||||||
|
|
||||||
|
<bit-form-field>
|
||||||
|
<bit-label>{{ "notificationsUrl" | i18n }}</bit-label>
|
||||||
|
<input
|
||||||
|
id="self_hosted_env_settings_form_input_notifications_url"
|
||||||
|
bitInput
|
||||||
|
type="text"
|
||||||
|
formControlName="notificationsUrl"
|
||||||
|
appInputVerbatim
|
||||||
|
/>
|
||||||
|
</bit-form-field>
|
||||||
|
|
||||||
|
<bit-form-field>
|
||||||
|
<bit-label>{{ "iconsUrl" | i18n }}</bit-label>
|
||||||
|
<input
|
||||||
|
id="self_hosted_env_settings_form_input_icons_url"
|
||||||
|
bitInput
|
||||||
|
type="text"
|
||||||
|
formControlName="iconsUrl"
|
||||||
|
appInputVerbatim
|
||||||
|
/>
|
||||||
|
</bit-form-field>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<span
|
||||||
|
*ngIf="showErrorSummary"
|
||||||
|
class="tw-block tw-text-danger tw-mt-2"
|
||||||
|
aria-live="assertive"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<i class="bwi bwi-error"></i> {{ "selfHostedEnvFormInvalid" | i18n }}
|
||||||
|
</span>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container bitDialogFooter>
|
||||||
|
<button type="submit" bitButton bitFormButton buttonType="primary">
|
||||||
|
{{ "save" | i18n }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" bitButton bitFormButton buttonType="secondary" (click)="cancel()">
|
||||||
|
{{ "cancel" | i18n }}
|
||||||
|
</button>
|
||||||
|
</ng-container>
|
||||||
|
</bit-dialog>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { DialogRef } from "@angular/cdk/dialog";
|
||||||
|
import { CommonModule } from "@angular/common";
|
||||||
|
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||||
|
import {
|
||||||
|
AbstractControl,
|
||||||
|
FormBuilder,
|
||||||
|
FormControl,
|
||||||
|
FormGroup,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
ValidationErrors,
|
||||||
|
ValidatorFn,
|
||||||
|
} from "@angular/forms";
|
||||||
|
import { Subject, firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
|
import {
|
||||||
|
EnvironmentService,
|
||||||
|
Region,
|
||||||
|
} from "@bitwarden/common/platform/abstractions/environment.service";
|
||||||
|
import {
|
||||||
|
AsyncActionsModule,
|
||||||
|
ButtonModule,
|
||||||
|
DialogModule,
|
||||||
|
DialogService,
|
||||||
|
FormFieldModule,
|
||||||
|
LinkModule,
|
||||||
|
TypographyModule,
|
||||||
|
} from "@bitwarden/components";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validator for self-hosted environment settings form.
|
||||||
|
* It enforces that at least one URL is provided.
|
||||||
|
*/
|
||||||
|
function selfHostedEnvSettingsFormValidator(): ValidatorFn {
|
||||||
|
return (control: AbstractControl): ValidationErrors | null => {
|
||||||
|
const formGroup = control as FormGroup;
|
||||||
|
const baseUrl = formGroup.get("baseUrl")?.value;
|
||||||
|
const webVaultUrl = formGroup.get("webVaultUrl")?.value;
|
||||||
|
const apiUrl = formGroup.get("apiUrl")?.value;
|
||||||
|
const identityUrl = formGroup.get("identityUrl")?.value;
|
||||||
|
const iconsUrl = formGroup.get("iconsUrl")?.value;
|
||||||
|
const notificationsUrl = formGroup.get("notificationsUrl")?.value;
|
||||||
|
|
||||||
|
if (baseUrl || webVaultUrl || apiUrl || identityUrl || iconsUrl || notificationsUrl) {
|
||||||
|
return null; // valid
|
||||||
|
} else {
|
||||||
|
return { atLeastOneUrlIsRequired: true }; // invalid
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog for configuring self-hosted environment settings.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
standalone: true,
|
||||||
|
selector: "auth-registration-self-hosted-env-config-dialog",
|
||||||
|
templateUrl: "registration-self-hosted-env-config-dialog.component.html",
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
JslibModule,
|
||||||
|
DialogModule,
|
||||||
|
ButtonModule,
|
||||||
|
LinkModule,
|
||||||
|
TypographyModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
FormFieldModule,
|
||||||
|
AsyncActionsModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class RegistrationSelfHostedEnvConfigDialogComponent implements OnInit, OnDestroy {
|
||||||
|
/**
|
||||||
|
* Opens the dialog.
|
||||||
|
* @param dialogService - Dialog service.
|
||||||
|
* @returns Promise that resolves to true if the dialog was closed with a successful result, false otherwise.
|
||||||
|
*/
|
||||||
|
static async open(dialogService: DialogService): Promise<boolean> {
|
||||||
|
const dialogRef = dialogService.open<boolean>(RegistrationSelfHostedEnvConfigDialogComponent, {
|
||||||
|
disableClose: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dialogResult = await firstValueFrom(dialogRef.closed);
|
||||||
|
|
||||||
|
return dialogResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
formGroup = this.formBuilder.group(
|
||||||
|
{
|
||||||
|
baseUrl: [null],
|
||||||
|
webVaultUrl: [null],
|
||||||
|
apiUrl: [null],
|
||||||
|
identityUrl: [null],
|
||||||
|
iconsUrl: [null],
|
||||||
|
notificationsUrl: [null],
|
||||||
|
},
|
||||||
|
{ validators: selfHostedEnvSettingsFormValidator() },
|
||||||
|
);
|
||||||
|
|
||||||
|
get baseUrl(): FormControl {
|
||||||
|
return this.formGroup.get("baseUrl") as FormControl;
|
||||||
|
}
|
||||||
|
|
||||||
|
get webVaultUrl(): FormControl {
|
||||||
|
return this.formGroup.get("webVaultUrl") as FormControl;
|
||||||
|
}
|
||||||
|
|
||||||
|
get apiUrl(): FormControl {
|
||||||
|
return this.formGroup.get("apiUrl") as FormControl;
|
||||||
|
}
|
||||||
|
|
||||||
|
get identityUrl(): FormControl {
|
||||||
|
return this.formGroup.get("identityUrl") as FormControl;
|
||||||
|
}
|
||||||
|
|
||||||
|
get iconsUrl(): FormControl {
|
||||||
|
return this.formGroup.get("iconsUrl") as FormControl;
|
||||||
|
}
|
||||||
|
|
||||||
|
get notificationsUrl(): FormControl {
|
||||||
|
return this.formGroup.get("notificationsUrl") as FormControl;
|
||||||
|
}
|
||||||
|
|
||||||
|
showCustomEnv = false;
|
||||||
|
showErrorSummary = false;
|
||||||
|
|
||||||
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private dialogRef: DialogRef<boolean>,
|
||||||
|
private formBuilder: FormBuilder,
|
||||||
|
private environmentService: EnvironmentService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit() {}
|
||||||
|
|
||||||
|
submit = async () => {
|
||||||
|
this.showErrorSummary = false;
|
||||||
|
|
||||||
|
if (this.formGroup.invalid) {
|
||||||
|
this.showErrorSummary = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.environmentService.setEnvironment(Region.SelfHosted, {
|
||||||
|
base: this.baseUrl.value,
|
||||||
|
api: this.apiUrl.value,
|
||||||
|
identity: this.identityUrl.value,
|
||||||
|
webVault: this.webVaultUrl.value,
|
||||||
|
icons: this.iconsUrl.value,
|
||||||
|
notifications: this.notificationsUrl.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dialogRef.close(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
async cancel() {
|
||||||
|
this.dialogRef.close(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
<ng-container *ngIf="state === RegistrationStartState.USER_DATA_ENTRY">
|
<ng-container *ngIf="state === RegistrationStartState.USER_DATA_ENTRY">
|
||||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||||
|
<auth-registration-env-selector
|
||||||
|
(selectedRegionChange)="handleSelectedRegionChange($event)"
|
||||||
|
></auth-registration-env-selector>
|
||||||
|
|
||||||
<bit-form-field>
|
<bit-form-field>
|
||||||
<bit-label>{{ "emailAddress" | i18n }}</bit-label>
|
<bit-label>{{ "emailAddress" | i18n }}</bit-label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { ActivatedRoute } from "@angular/router";
|
|||||||
import { Subject, takeUntil } from "rxjs";
|
import { Subject, takeUntil } from "rxjs";
|
||||||
|
|
||||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
|
import { RegionConfig, Region } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
import {
|
import {
|
||||||
AsyncActionsModule,
|
AsyncActionsModule,
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
} from "@bitwarden/components";
|
} from "@bitwarden/components";
|
||||||
|
|
||||||
import { RegistrationCheckEmailIcon } from "../../icons/registration-check-email.icon";
|
import { RegistrationCheckEmailIcon } from "../../icons/registration-check-email.icon";
|
||||||
|
import { RegistrationEnvSelectorComponent } from "../registration-env-selector/registration-env-selector.component";
|
||||||
|
|
||||||
export enum RegistrationStartState {
|
export enum RegistrationStartState {
|
||||||
USER_DATA_ENTRY = "UserDataEntry",
|
USER_DATA_ENTRY = "UserDataEntry",
|
||||||
@@ -43,6 +45,7 @@ export enum RegistrationStartState {
|
|||||||
ButtonModule,
|
ButtonModule,
|
||||||
LinkModule,
|
LinkModule,
|
||||||
IconModule,
|
IconModule,
|
||||||
|
RegistrationEnvSelectorComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class RegistrationStartComponent implements OnInit, OnDestroy {
|
export class RegistrationStartComponent implements OnInit, OnDestroy {
|
||||||
@@ -84,6 +87,7 @@ export class RegistrationStartComponent implements OnInit, OnDestroy {
|
|||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private platformUtilsService: PlatformUtilsService,
|
private platformUtilsService: PlatformUtilsService,
|
||||||
) {
|
) {
|
||||||
|
// TODO: this needs to update if user selects self hosted
|
||||||
this.isSelfHost = platformUtilsService.isSelfHost();
|
this.isSelfHost = platformUtilsService.isSelfHost();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +120,10 @@ export class RegistrationStartComponent implements OnInit, OnDestroy {
|
|||||||
this.registrationStartStateChange.emit(this.state);
|
this.registrationStartStateChange.emit(this.state);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleSelectedRegionChange(region: RegionConfig | Region.SelfHosted | null) {
|
||||||
|
this.isSelfHost = region === Region.SelfHosted;
|
||||||
|
}
|
||||||
|
|
||||||
private validateForm(): boolean {
|
private validateForm(): boolean {
|
||||||
this.formGroup.markAllAsTouched();
|
this.formGroup.markAllAsTouched();
|
||||||
|
|
||||||
|
|||||||
@@ -7,22 +7,72 @@ import * as stories from "./registration-start.stories";
|
|||||||
# RegistrationStart Component
|
# RegistrationStart Component
|
||||||
|
|
||||||
The Auth-owned RegistrationStartComponent is to be used for the first step in the new email
|
The Auth-owned RegistrationStartComponent is to be used for the first step in the new email
|
||||||
verification stagegated registration process. It collects the user's email address (required) and
|
verification stage gated registration process. It collects the environment (required), the user's
|
||||||
optionally their name. On cloud environments, it requires acceptance of the terms of service and the
|
email address (required) and optionally their name. On cloud environments, it requires acceptance of
|
||||||
privacy policy; the checkbox is hidden on self hosted environments.
|
the terms of service and the privacy policy; the checkbox is hidden on self hosted environments.
|
||||||
|
|
||||||
### Cloud Example
|
## Web Examples
|
||||||
|
|
||||||
<Story of={stories.CloudExample} />
|
Note that the self hosted option is not present in the environment selector.
|
||||||
|
|
||||||
### Self Hosted Example
|
### US Region
|
||||||
|
|
||||||
<Story of={stories.SelfHostExample} />
|
<Story of={stories.WebUSRegionExample} />
|
||||||
|
|
||||||
### Query Param Example
|
### EU Region
|
||||||
|
|
||||||
|
<Story of={stories.WebEURegionExample} />
|
||||||
|
|
||||||
|
### Query Params
|
||||||
|
|
||||||
The component accepts two query parameters: `email` and `emailReadonly`. If an email is provided, it
|
The component accepts two query parameters: `email` and `emailReadonly`. If an email is provided, it
|
||||||
will be pre-filled in the email input field. If `emailReadonly` is set to `true`, the email input
|
will be pre-filled in the email input field. If `emailReadonly` is set to `true`, the email input
|
||||||
field will be set to readonly. `emailReadonly` is primarily for the organization invite flow.
|
field will be set to readonly. `emailReadonly` is primarily for the organization invite flow.
|
||||||
|
|
||||||
<Story of={stories.QueryParamsExample} />
|
<Story of={stories.WebUSRegionQueryParamsExample} />
|
||||||
|
|
||||||
|
## Desktop
|
||||||
|
|
||||||
|
Behavior to note:
|
||||||
|
|
||||||
|
- The self hosted option is present in the environment selector.
|
||||||
|
- If you go from non-self hosted to self hosted, the terms of service and privacy policy checkbox
|
||||||
|
will disappear.
|
||||||
|
|
||||||
|
### US Region
|
||||||
|
|
||||||
|
<Story of={stories.DesktopUSRegionExample} />
|
||||||
|
|
||||||
|
### EU Region
|
||||||
|
|
||||||
|
<Story of={stories.DesktopEURegionExample} />
|
||||||
|
|
||||||
|
### Self Hosted
|
||||||
|
|
||||||
|
Note the fact that the terms of service and privacy policy checkbox is not present when the
|
||||||
|
environment is self hosted.
|
||||||
|
|
||||||
|
<Story of={stories.DesktopSelfHostExample} />
|
||||||
|
|
||||||
|
## Browser Extension
|
||||||
|
|
||||||
|
Behavior to note:
|
||||||
|
|
||||||
|
- The self hosted option is present in the environment selector.
|
||||||
|
- If you go from non-self hosted to self hosted, the terms of service and privacy policy checkbox
|
||||||
|
will disappear.
|
||||||
|
|
||||||
|
### US Region
|
||||||
|
|
||||||
|
<Story of={stories.BrowserExtensionUSRegionExample} />
|
||||||
|
|
||||||
|
### EU Region
|
||||||
|
|
||||||
|
<Story of={stories.BrowserExtensionEURegionExample} />
|
||||||
|
|
||||||
|
### Self Hosted
|
||||||
|
|
||||||
|
Note the fact that the terms of service and privacy policy checkbox is not present when the
|
||||||
|
environment is self hosted.
|
||||||
|
|
||||||
|
<Story of={stories.BrowserExtensionSelfHostExample} />
|
||||||
|
|||||||
@@ -1,10 +1,30 @@
|
|||||||
import { importProvidersFrom } from "@angular/core";
|
import { importProvidersFrom } from "@angular/core";
|
||||||
|
import { ReactiveFormsModule } from "@angular/forms";
|
||||||
|
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
|
||||||
import { ActivatedRoute, Params } from "@angular/router";
|
import { ActivatedRoute, Params } from "@angular/router";
|
||||||
import { RouterTestingModule } from "@angular/router/testing";
|
import { RouterTestingModule } from "@angular/router/testing";
|
||||||
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
|
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
|
||||||
import { of } from "rxjs";
|
import { of } from "rxjs";
|
||||||
|
|
||||||
|
import { ClientType } from "@bitwarden/common/enums";
|
||||||
|
import {
|
||||||
|
Environment,
|
||||||
|
EnvironmentService,
|
||||||
|
Region,
|
||||||
|
Urls,
|
||||||
|
} from "@bitwarden/common/platform/abstractions/environment.service";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
|
import {
|
||||||
|
AsyncActionsModule,
|
||||||
|
ButtonModule,
|
||||||
|
DialogModule,
|
||||||
|
FormFieldModule,
|
||||||
|
LinkModule,
|
||||||
|
SelectModule,
|
||||||
|
ToastOptions,
|
||||||
|
ToastService,
|
||||||
|
TypographyModule,
|
||||||
|
} from "@bitwarden/components";
|
||||||
|
|
||||||
import { PreloadedEnglishI18nModule } from "../../../../../../apps/web/src/app/core/tests";
|
import { PreloadedEnglishI18nModule } from "../../../../../../apps/web/src/app/core/tests";
|
||||||
|
|
||||||
@@ -15,52 +35,70 @@ export default {
|
|||||||
component: RegistrationStartComponent,
|
component: RegistrationStartComponent,
|
||||||
} as Meta;
|
} as Meta;
|
||||||
|
|
||||||
const decorators = (options: { isSelfHost: boolean; queryParams: Params }) => {
|
const decorators = (options: {
|
||||||
|
isSelfHost?: boolean;
|
||||||
|
queryParams?: Params;
|
||||||
|
clientType?: ClientType;
|
||||||
|
defaultRegion?: Region;
|
||||||
|
}) => {
|
||||||
return [
|
return [
|
||||||
moduleMetadata({
|
moduleMetadata({
|
||||||
imports: [RouterTestingModule],
|
imports: [
|
||||||
|
RouterTestingModule,
|
||||||
|
DialogModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
FormFieldModule,
|
||||||
|
SelectModule,
|
||||||
|
ButtonModule,
|
||||||
|
LinkModule,
|
||||||
|
TypographyModule,
|
||||||
|
AsyncActionsModule,
|
||||||
|
BrowserAnimationsModule,
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: ActivatedRoute,
|
provide: ActivatedRoute,
|
||||||
useValue: { queryParams: of(options.queryParams) },
|
useValue: { queryParams: of(options.queryParams || {}) },
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: PlatformUtilsService,
|
|
||||||
useValue: {
|
|
||||||
isSelfHost: () => options.isSelfHost,
|
|
||||||
} as Partial<PlatformUtilsService>,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
applicationConfig({
|
applicationConfig({
|
||||||
providers: [importProvidersFrom(PreloadedEnglishI18nModule)],
|
providers: [
|
||||||
|
importProvidersFrom(PreloadedEnglishI18nModule),
|
||||||
|
{
|
||||||
|
provide: EnvironmentService,
|
||||||
|
useValue: {
|
||||||
|
environment$: of({
|
||||||
|
getRegion: () => options.defaultRegion || Region.US,
|
||||||
|
} as Partial<Environment>),
|
||||||
|
availableRegions: () => [
|
||||||
|
{ key: Region.US, domain: "bitwarden.com", urls: {} },
|
||||||
|
{ key: Region.EU, domain: "bitwarden.eu", urls: {} },
|
||||||
|
],
|
||||||
|
setEnvironment: (region: Region, urls?: Urls) => Promise.resolve({}),
|
||||||
|
} as Partial<EnvironmentService>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: PlatformUtilsService,
|
||||||
|
useValue: {
|
||||||
|
isSelfHost: () => options.isSelfHost || false,
|
||||||
|
getClientType: () => options.clientType || ClientType.Web,
|
||||||
|
} as Partial<PlatformUtilsService>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ToastService,
|
||||||
|
useValue: {
|
||||||
|
showToast: (options: ToastOptions) => {},
|
||||||
|
} as Partial<ToastService>,
|
||||||
|
},
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
type Story = StoryObj<RegistrationStartComponent>;
|
type Story = StoryObj<RegistrationStartComponent>;
|
||||||
|
|
||||||
export const CloudExample: Story = {
|
export const WebUSRegionExample: Story = {
|
||||||
render: (args) => ({
|
|
||||||
props: args,
|
|
||||||
template: `
|
|
||||||
<auth-registration-start></auth-registration-start>
|
|
||||||
`,
|
|
||||||
}),
|
|
||||||
decorators: decorators({ isSelfHost: false, queryParams: {} }),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SelfHostExample: Story = {
|
|
||||||
render: (args) => ({
|
|
||||||
props: args,
|
|
||||||
template: `
|
|
||||||
<auth-registration-start></auth-registration-start>
|
|
||||||
`,
|
|
||||||
}),
|
|
||||||
decorators: decorators({ isSelfHost: true, queryParams: {} }),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const QueryParamsExample: Story = {
|
|
||||||
render: (args) => ({
|
render: (args) => ({
|
||||||
props: args,
|
props: args,
|
||||||
template: `
|
template: `
|
||||||
@@ -68,7 +106,120 @@ export const QueryParamsExample: Story = {
|
|||||||
`,
|
`,
|
||||||
}),
|
}),
|
||||||
decorators: decorators({
|
decorators: decorators({
|
||||||
isSelfHost: false,
|
clientType: ClientType.Web,
|
||||||
|
queryParams: {},
|
||||||
|
defaultRegion: Region.US,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WebEURegionExample: Story = {
|
||||||
|
render: (args) => ({
|
||||||
|
props: args,
|
||||||
|
template: `
|
||||||
|
<auth-registration-start></auth-registration-start>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
decorators: decorators({
|
||||||
|
clientType: ClientType.Web,
|
||||||
|
queryParams: {},
|
||||||
|
defaultRegion: Region.EU,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WebUSRegionQueryParamsExample: Story = {
|
||||||
|
render: (args) => ({
|
||||||
|
props: args,
|
||||||
|
template: `
|
||||||
|
<auth-registration-start></auth-registration-start>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
decorators: decorators({
|
||||||
|
clientType: ClientType.Web,
|
||||||
|
defaultRegion: Region.US,
|
||||||
queryParams: { email: "jaredWasHere@bitwarden.com", emailReadonly: "true" },
|
queryParams: { email: "jaredWasHere@bitwarden.com", emailReadonly: "true" },
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const DesktopUSRegionExample: Story = {
|
||||||
|
render: (args) => ({
|
||||||
|
props: args,
|
||||||
|
template: `
|
||||||
|
<auth-registration-start></auth-registration-start>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
decorators: decorators({
|
||||||
|
clientType: ClientType.Desktop,
|
||||||
|
defaultRegion: Region.US,
|
||||||
|
isSelfHost: false,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DesktopEURegionExample: Story = {
|
||||||
|
render: (args) => ({
|
||||||
|
props: args,
|
||||||
|
template: `
|
||||||
|
<auth-registration-start></auth-registration-start>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
decorators: decorators({
|
||||||
|
clientType: ClientType.Desktop,
|
||||||
|
defaultRegion: Region.EU,
|
||||||
|
isSelfHost: false,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DesktopSelfHostExample: Story = {
|
||||||
|
render: (args) => ({
|
||||||
|
props: args,
|
||||||
|
template: `
|
||||||
|
<auth-registration-start></auth-registration-start>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
decorators: decorators({
|
||||||
|
clientType: ClientType.Desktop,
|
||||||
|
isSelfHost: true,
|
||||||
|
defaultRegion: Region.SelfHosted,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BrowserExtensionUSRegionExample: Story = {
|
||||||
|
render: (args) => ({
|
||||||
|
props: args,
|
||||||
|
template: `
|
||||||
|
<auth-registration-start></auth-registration-start>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
decorators: decorators({
|
||||||
|
clientType: ClientType.Browser,
|
||||||
|
defaultRegion: Region.US,
|
||||||
|
isSelfHost: false,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BrowserExtensionEURegionExample: Story = {
|
||||||
|
render: (args) => ({
|
||||||
|
props: args,
|
||||||
|
template: `
|
||||||
|
<auth-registration-start></auth-registration-start>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
decorators: decorators({
|
||||||
|
clientType: ClientType.Browser,
|
||||||
|
defaultRegion: Region.EU,
|
||||||
|
isSelfHost: false,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BrowserExtensionSelfHostExample: Story = {
|
||||||
|
render: (args) => ({
|
||||||
|
props: args,
|
||||||
|
template: `
|
||||||
|
<auth-registration-start></auth-registration-start>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
decorators: decorators({
|
||||||
|
clientType: ClientType.Browser,
|
||||||
|
isSelfHost: true,
|
||||||
|
defaultRegion: Region.SelfHosted,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user