mirror of
https://github.com/bitwarden/browser.git
synced 2024-12-22 16:29:09 +01:00
Auth/PM-6198 - Registration with Email Verification - Call email clicked endpoint (#10139)
* PM-6198 - Majority of client work done; WIP on registration finish comp * PM-6198 - Registration Finish - Add registerVerificationEmailClicked logic * PM-6198 - RegistrationLinkExpired component; added translations on other clients just in case we use the component on other clients in the future. * PM-6198 - Clean up comment
This commit is contained in:
parent
158da35008
commit
56f5dba444
@ -660,6 +660,21 @@
|
|||||||
"loginExpired": {
|
"loginExpired": {
|
||||||
"message": "Your login session has expired."
|
"message": "Your login session has expired."
|
||||||
},
|
},
|
||||||
|
"logIn": {
|
||||||
|
"message": "Log in"
|
||||||
|
},
|
||||||
|
"restartRegistration": {
|
||||||
|
"message": "Restart registration"
|
||||||
|
},
|
||||||
|
"expiredLink": {
|
||||||
|
"message": "Expired link"
|
||||||
|
},
|
||||||
|
"pleaseRestartRegistrationOrTryLoggingIn": {
|
||||||
|
"message": "Please restart registration or try logging in."
|
||||||
|
},
|
||||||
|
"youMayAlreadyHaveAnAccount": {
|
||||||
|
"message": "You may already have an account"
|
||||||
|
},
|
||||||
"logOutConfirmation": {
|
"logOutConfirmation": {
|
||||||
"message": "Are you sure you want to log out?"
|
"message": "Are you sure you want to log out?"
|
||||||
},
|
},
|
||||||
|
@ -792,6 +792,18 @@
|
|||||||
"loginExpired": {
|
"loginExpired": {
|
||||||
"message": "Your login session has expired."
|
"message": "Your login session has expired."
|
||||||
},
|
},
|
||||||
|
"restartRegistration": {
|
||||||
|
"message": "Restart registration"
|
||||||
|
},
|
||||||
|
"expiredLink": {
|
||||||
|
"message": "Expired link"
|
||||||
|
},
|
||||||
|
"pleaseRestartRegistrationOrTryLoggingIn": {
|
||||||
|
"message": "Please restart registration or try logging in."
|
||||||
|
},
|
||||||
|
"youMayAlreadyHaveAnAccount": {
|
||||||
|
"message": "You may already have an account"
|
||||||
|
},
|
||||||
"logOutConfirmation": {
|
"logOutConfirmation": {
|
||||||
"message": "Are you sure you want to log out?"
|
"message": "Are you sure you want to log out?"
|
||||||
},
|
},
|
||||||
|
@ -18,6 +18,7 @@ import {
|
|||||||
RegistrationStartSecondaryComponent,
|
RegistrationStartSecondaryComponent,
|
||||||
RegistrationStartSecondaryComponentData,
|
RegistrationStartSecondaryComponentData,
|
||||||
LockIcon,
|
LockIcon,
|
||||||
|
RegistrationLinkExpiredComponent,
|
||||||
} from "@bitwarden/auth/angular";
|
} from "@bitwarden/auth/angular";
|
||||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
|
|
||||||
@ -205,6 +206,22 @@ const routes: Routes = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "signup-link-expired",
|
||||||
|
canActivate: [canAccessFeature(FeatureFlag.EmailVerification), unauthGuardFn()],
|
||||||
|
data: {
|
||||||
|
pageTitle: "expiredLink",
|
||||||
|
} satisfies AnonLayoutWrapperData,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "",
|
||||||
|
component: RegistrationLinkExpiredComponent,
|
||||||
|
data: {
|
||||||
|
loginRoute: "/login",
|
||||||
|
} satisfies RegistrationStartSecondaryComponentData,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "sso",
|
path: "sso",
|
||||||
canActivate: [unauthGuardFn()],
|
canActivate: [unauthGuardFn()],
|
||||||
|
@ -627,6 +627,18 @@
|
|||||||
"loginExpired": {
|
"loginExpired": {
|
||||||
"message": "Your login session has expired."
|
"message": "Your login session has expired."
|
||||||
},
|
},
|
||||||
|
"restartRegistration": {
|
||||||
|
"message": "Restart registration"
|
||||||
|
},
|
||||||
|
"expiredLink": {
|
||||||
|
"message": "Expired link"
|
||||||
|
},
|
||||||
|
"pleaseRestartRegistrationOrTryLoggingIn": {
|
||||||
|
"message": "Please restart registration or try logging in."
|
||||||
|
},
|
||||||
|
"youMayAlreadyHaveAnAccount": {
|
||||||
|
"message": "You may already have an account"
|
||||||
|
},
|
||||||
"logOutConfirmation": {
|
"logOutConfirmation": {
|
||||||
"message": "Are you sure you want to log out?"
|
"message": "Are you sure you want to log out?"
|
||||||
},
|
},
|
||||||
|
@ -0,0 +1,20 @@
|
|||||||
|
import { svgIcon } from "@bitwarden/components";
|
||||||
|
|
||||||
|
export const RegistrationExpiredLinkIcon = svgIcon`
|
||||||
|
<svg width="64" height="64" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_8198_6255)">
|
||||||
|
<path
|
||||||
|
class="tw-fill-primary-600"
|
||||||
|
d="M2.70609 5.38455C3.71495 3.18341 5.95991 1.65214 8.56633 1.65214C12.1167 1.65214 14.9948 4.4927 14.9948 7.99672C14.9948 11.5007 12.1167 14.3413 8.56633 14.3413C5.90396 14.3413 3.61848 12.7438 2.64241 10.4652C2.53502 10.2144 2.24202 10.0971 1.98799 10.2031C1.73396 10.3091 1.61509 10.5983 1.72248 10.849C2.8493 13.4796 5.48864 15.327 8.56633 15.327C12.6683 15.327 15.9936 12.0451 15.9936 7.99672C15.9936 3.9483 12.6683 0.666412 8.56633 0.666412C5.41721 0.666412 2.72675 2.60033 1.64603 5.33002L0.858226 3.92917C0.734524 3.70921 0.455929 3.63117 0.235967 3.75487C0.0160044 3.87858 -0.0620299 4.15717 0.0616722 4.37713L1.35147 6.6706C1.47517 6.89056 1.75377 6.9686 1.97373 6.8449L4.2672 5.5551C4.48716 5.4314 4.5652 5.1528 4.44149 4.93284C4.31779 4.71288 4.0392 4.63484 3.81923 4.75854L2.70609 5.38455Z"
|
||||||
|
fill="#175DDC" />
|
||||||
|
<path
|
||||||
|
class="tw-fill-primary-600"
|
||||||
|
d="M10.7225 10.2926C10.6408 10.3474 10.5437 10.3767 10.4447 10.3767C10.318 10.3767 10.1962 10.3283 10.1041 10.2418L8.14837 8.41125C8.09985 8.36578 8.06123 8.31053 8.03449 8.24991C8.00775 8.18928 7.99389 8.12376 7.99389 8.05727V3.49271C7.99389 3.36314 8.04588 3.23896 8.13896 3.14704C8.23204 3.05512 8.3578 3.00378 8.48901 3.00378C8.62021 3.00378 8.74597 3.05561 8.83905 3.14704C8.93213 3.23847 8.98412 3.36314 8.98412 3.49271V7.84605L10.7853 9.53284C10.8571 9.60031 10.9071 9.68734 10.9284 9.78268C10.9497 9.87802 10.9413 9.97776 10.9047 10.0687C10.868 10.1596 10.8042 10.2379 10.7225 10.2926Z"
|
||||||
|
fill="#175DDC" />
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_8198_6255">
|
||||||
|
<rect width="15.9952" height="15.9952" fill="white" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>`;
|
@ -20,6 +20,7 @@ export * from "./user-verification/user-verification-form-input.component";
|
|||||||
// registration
|
// registration
|
||||||
export * from "./registration/registration-start/registration-start.component";
|
export * from "./registration/registration-start/registration-start.component";
|
||||||
export * from "./registration/registration-finish/registration-finish.component";
|
export * from "./registration/registration-finish/registration-finish.component";
|
||||||
|
export * from "./registration/registration-link-expired/registration-link-expired.component";
|
||||||
export * from "./registration/registration-start/registration-start-secondary.component";
|
export * from "./registration/registration-start/registration-start-secondary.component";
|
||||||
export * from "./registration/registration-env-selector/registration-env-selector.component";
|
export * from "./registration/registration-env-selector/registration-env-selector.component";
|
||||||
export * from "./registration/registration-finish/registration-finish.service";
|
export * from "./registration/registration-finish/registration-finish.service";
|
||||||
|
@ -1,10 +1,14 @@
|
|||||||
import { CommonModule } from "@angular/common";
|
import { CommonModule } from "@angular/common";
|
||||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||||
import { ActivatedRoute, Params, Router, RouterModule } from "@angular/router";
|
import { ActivatedRoute, Params, Router, RouterModule } from "@angular/router";
|
||||||
import { Subject, takeUntil } from "rxjs";
|
import { Subject, from, switchMap, takeUntil, tap } from "rxjs";
|
||||||
|
|
||||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||||
|
import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service";
|
||||||
|
import { RegisterVerificationEmailClickedRequest } from "@bitwarden/common/auth/models/request/registration/register-verification-email-clicked.request";
|
||||||
|
import { HttpStatusCode } from "@bitwarden/common/enums";
|
||||||
|
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||||
import { ToastService } from "@bitwarden/components";
|
import { ToastService } from "@bitwarden/components";
|
||||||
@ -41,33 +45,43 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy {
|
|||||||
private i18nService: I18nService,
|
private i18nService: I18nService,
|
||||||
private registrationFinishService: RegistrationFinishService,
|
private registrationFinishService: RegistrationFinishService,
|
||||||
private validationService: ValidationService,
|
private validationService: ValidationService,
|
||||||
|
private accountApiService: AccountApiService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
this.listenForQueryParamChanges();
|
this.listenForQueryParamChanges();
|
||||||
this.masterPasswordPolicyOptions =
|
this.masterPasswordPolicyOptions =
|
||||||
await this.registrationFinishService.getMasterPasswordPolicyOptsFromOrgInvite();
|
await this.registrationFinishService.getMasterPasswordPolicyOptsFromOrgInvite();
|
||||||
this.loading = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private listenForQueryParamChanges() {
|
private listenForQueryParamChanges() {
|
||||||
this.activatedRoute.queryParams.pipe(takeUntil(this.destroy$)).subscribe((qParams: Params) => {
|
this.activatedRoute.queryParams
|
||||||
if (qParams.email != null && qParams.email.indexOf("@") > -1) {
|
.pipe(
|
||||||
this.email = qParams.email;
|
tap((qParams: Params) => {
|
||||||
}
|
if (qParams.email != null && qParams.email.indexOf("@") > -1) {
|
||||||
|
this.email = qParams.email;
|
||||||
|
}
|
||||||
|
|
||||||
if (qParams.token != null) {
|
if (qParams.token != null) {
|
||||||
this.emailVerificationToken = qParams.token;
|
this.emailVerificationToken = qParams.token;
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
|
switchMap((qParams: Params) => {
|
||||||
|
if (
|
||||||
|
qParams.fromEmail &&
|
||||||
|
qParams.fromEmail === "true" &&
|
||||||
|
this.email &&
|
||||||
|
this.emailVerificationToken
|
||||||
|
) {
|
||||||
|
return from(
|
||||||
|
this.registerVerificationEmailClicked(this.email, this.emailVerificationToken),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
if (qParams.fromEmail && qParams.fromEmail === "true") {
|
takeUntil(this.destroy$),
|
||||||
this.toastService.showToast({
|
)
|
||||||
title: null,
|
.subscribe();
|
||||||
message: this.i18nService.t("emailVerifiedV2"),
|
|
||||||
variant: "success",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) {
|
async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) {
|
||||||
@ -94,6 +108,48 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy {
|
|||||||
await this.router.navigate(["/login"], { queryParams: { email: this.email } });
|
await this.router.navigate(["/login"], { queryParams: { email: this.email } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async registerVerificationEmailClicked(email: string, emailVerificationToken: string) {
|
||||||
|
const request = new RegisterVerificationEmailClickedRequest(email, emailVerificationToken);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.accountApiService.registerVerificationEmailClicked(request);
|
||||||
|
|
||||||
|
if (result == null) {
|
||||||
|
this.toastService.showToast({
|
||||||
|
title: null,
|
||||||
|
message: this.i18nService.t("emailVerifiedV2"),
|
||||||
|
variant: "success",
|
||||||
|
});
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
await this.handleRegisterVerificationEmailClickedError(e);
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleRegisterVerificationEmailClickedError(e: unknown) {
|
||||||
|
if (e instanceof ErrorResponse) {
|
||||||
|
const errorResponse = e as ErrorResponse;
|
||||||
|
switch (errorResponse.statusCode) {
|
||||||
|
case HttpStatusCode.BadRequest: {
|
||||||
|
if (errorResponse.message.includes("Expired link")) {
|
||||||
|
await this.router.navigate(["/signup-link-expired"]);
|
||||||
|
} else {
|
||||||
|
this.validationService.showError(errorResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
this.validationService.showError(errorResponse);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.validationService.showError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.destroy$.next();
|
this.destroy$.next();
|
||||||
this.destroy$.complete();
|
this.destroy$.complete();
|
||||||
|
@ -0,0 +1,27 @@
|
|||||||
|
<div class="tw-flex tw-flex-col tw-items-center tw-justify-center">
|
||||||
|
<bit-icon [icon]="Icons.RegistrationExpiredLinkIcon" class="tw-mb-6"></bit-icon>
|
||||||
|
|
||||||
|
<p
|
||||||
|
bitTypography="body1"
|
||||||
|
class="tw-text-center tw-mb-3 tw-text-main"
|
||||||
|
id="restart_registration_body"
|
||||||
|
>
|
||||||
|
{{ "pleaseRestartRegistrationOrTryLoggingIn" | i18n }}<br />
|
||||||
|
{{ "youMayAlreadyHaveAnAccount" | i18n }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<a
|
||||||
|
[block]="true"
|
||||||
|
type="button"
|
||||||
|
buttonType="primary"
|
||||||
|
bitButton
|
||||||
|
class="tw-mb-3"
|
||||||
|
routerLink="/signup"
|
||||||
|
>
|
||||||
|
{{ "restartRegistration" | i18n }}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a [block]="true" type="button" buttonType="secondary" bitButton [routerLink]="loginRoute">
|
||||||
|
{{ "logIn" | i18n }}
|
||||||
|
</a>
|
||||||
|
</div>
|
@ -0,0 +1,44 @@
|
|||||||
|
import { CommonModule } from "@angular/common";
|
||||||
|
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||||
|
import { ActivatedRoute, RouterModule } from "@angular/router";
|
||||||
|
import { Subject, firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
|
import { ButtonModule, IconModule } from "@bitwarden/components";
|
||||||
|
|
||||||
|
import { RegistrationExpiredLinkIcon } from "../../icons/registration-expired-link.icon";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RegistrationLinkExpiredComponentData
|
||||||
|
* @loginRoute: string - The client specific route to the login page - configured at the app-routing.module level.
|
||||||
|
*/
|
||||||
|
export interface RegistrationLinkExpiredComponentData {
|
||||||
|
loginRoute: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
standalone: true,
|
||||||
|
selector: "auth-registration-link-expired",
|
||||||
|
templateUrl: "./registration-link-expired.component.html",
|
||||||
|
imports: [CommonModule, JslibModule, RouterModule, IconModule, ButtonModule],
|
||||||
|
})
|
||||||
|
export class RegistrationLinkExpiredComponent implements OnInit, OnDestroy {
|
||||||
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
|
loginRoute: string;
|
||||||
|
|
||||||
|
readonly Icons = { RegistrationExpiredLinkIcon };
|
||||||
|
|
||||||
|
constructor(private activatedRoute: ActivatedRoute) {}
|
||||||
|
|
||||||
|
async ngOnInit() {
|
||||||
|
const routeData = await firstValueFrom(this.activatedRoute.data);
|
||||||
|
|
||||||
|
this.loginRoute = routeData["loginRoute"];
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,6 @@
|
|||||||
import { RegisterFinishRequest } from "../models/request/registration/register-finish.request";
|
import { RegisterFinishRequest } from "../models/request/registration/register-finish.request";
|
||||||
import { RegisterSendVerificationEmailRequest } from "../models/request/registration/register-send-verification-email.request";
|
import { RegisterSendVerificationEmailRequest } from "../models/request/registration/register-send-verification-email.request";
|
||||||
|
import { RegisterVerificationEmailClickedRequest } from "../models/request/registration/register-verification-email-clicked.request";
|
||||||
import { Verification } from "../types/verification";
|
import { Verification } from "../types/verification";
|
||||||
|
|
||||||
export abstract class AccountApiService {
|
export abstract class AccountApiService {
|
||||||
@ -26,6 +27,19 @@ export abstract class AccountApiService {
|
|||||||
request: RegisterSendVerificationEmailRequest,
|
request: RegisterSendVerificationEmailRequest,
|
||||||
): Promise<null | string>;
|
): Promise<null | string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raises a server event to identify when users click the email verification link and land
|
||||||
|
* on the registration finish screen.
|
||||||
|
*
|
||||||
|
* @param request - The request object containing the email verification token and the
|
||||||
|
* user's email address (which is required to validate the token)
|
||||||
|
* @returns A promise that resolves when the event is logged on the server succcessfully or a bad
|
||||||
|
* request if the token is invalid for any reason.
|
||||||
|
*/
|
||||||
|
abstract registerVerificationEmailClicked(
|
||||||
|
request: RegisterVerificationEmailClickedRequest,
|
||||||
|
): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Completes the registration process.
|
* Completes the registration process.
|
||||||
*
|
*
|
||||||
|
@ -0,0 +1,6 @@
|
|||||||
|
export class RegisterVerificationEmailClickedRequest {
|
||||||
|
constructor(
|
||||||
|
public email: string,
|
||||||
|
public emailVerificationToken: string,
|
||||||
|
) {}
|
||||||
|
}
|
@ -9,6 +9,7 @@ import { InternalAccountService } from "../abstractions/account.service";
|
|||||||
import { UserVerificationService } from "../abstractions/user-verification/user-verification.service.abstraction";
|
import { UserVerificationService } from "../abstractions/user-verification/user-verification.service.abstraction";
|
||||||
import { RegisterFinishRequest } from "../models/request/registration/register-finish.request";
|
import { RegisterFinishRequest } from "../models/request/registration/register-finish.request";
|
||||||
import { RegisterSendVerificationEmailRequest } from "../models/request/registration/register-send-verification-email.request";
|
import { RegisterSendVerificationEmailRequest } from "../models/request/registration/register-send-verification-email.request";
|
||||||
|
import { RegisterVerificationEmailClickedRequest } from "../models/request/registration/register-verification-email-clicked.request";
|
||||||
import { Verification } from "../types/verification";
|
import { Verification } from "../types/verification";
|
||||||
|
|
||||||
export class AccountApiServiceImplementation implements AccountApiService {
|
export class AccountApiServiceImplementation implements AccountApiService {
|
||||||
@ -60,6 +61,28 @@ export class AccountApiServiceImplementation implements AccountApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async registerVerificationEmailClicked(
|
||||||
|
request: RegisterVerificationEmailClickedRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
const env = await firstValueFrom(this.environmentService.environment$);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.apiService.send(
|
||||||
|
"POST",
|
||||||
|
"/accounts/register/verification-email-clicked",
|
||||||
|
request,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
env.getIdentityUrl(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (e: unknown) {
|
||||||
|
this.logService.error(e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async registerFinish(request: RegisterFinishRequest): Promise<string> {
|
async registerFinish(request: RegisterFinishRequest): Promise<string> {
|
||||||
const env = await firstValueFrom(this.environmentService.environment$);
|
const env = await firstValueFrom(this.environmentService.environment$);
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user