mirror of
https://github.com/bitwarden/browser
synced 2026-02-28 18:43:26 +00:00
Merge branch 'main' into uif/cl-958/avatar
This commit is contained in:
@@ -9,18 +9,23 @@ COPY package*.json ./
|
||||
COPY . .
|
||||
RUN npm ci
|
||||
|
||||
# Remove commercial packages if LICENSE_TYPE is not 'commercial'
|
||||
ARG LICENSE_TYPE=oss
|
||||
RUN if [ "${LICENSE_TYPE}" != "commercial" ] ; then \
|
||||
rm -rf node_modules/@bitwarden/commercial-sdk-internal ; \
|
||||
fi
|
||||
|
||||
# Override SDK if custom artifacts are present
|
||||
RUN if [ -d "sdk-internal" ]; then \
|
||||
echo "Overriding SDK with custom artifacts from sdk-internal" ; \
|
||||
npm link ./sdk-internal ; \
|
||||
fi
|
||||
|
||||
RUN if [ -d "commercial-sdk-internal" ]; then \
|
||||
echo "Overriding Commercial SDK with custom artifacts from commercial-sdk-internal" ; \
|
||||
npm link ./commercial-sdk-internal ; \
|
||||
fi
|
||||
|
||||
# Remove commercial packages if LICENSE_TYPE is not 'commercial'
|
||||
ARG LICENSE_TYPE=oss
|
||||
RUN if [ "${LICENSE_TYPE}" != "commercial" ] ; then \
|
||||
rm -rf node_modules/@bitwarden/commercial-sdk-internal ; \
|
||||
fi
|
||||
|
||||
WORKDIR /source/apps/web
|
||||
ARG NPM_COMMAND=dist:bit:selfhost
|
||||
RUN npm run ${NPM_COMMAND}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@bitwarden/web-vault",
|
||||
"version": "2026.2.0",
|
||||
"version": "2026.2.1",
|
||||
"scripts": {
|
||||
"build:oss": "webpack",
|
||||
"build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Directive, OnDestroy } from "@angular/core";
|
||||
import { Directive, OnDestroy, signal } from "@angular/core";
|
||||
import { FormControl, FormGroup } from "@angular/forms";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { combineLatest, filter, map, Observable, Subject, switchMap, takeUntil } from "rxjs";
|
||||
@@ -22,9 +22,9 @@ import { EventExportService } from "../../tools/event-export";
|
||||
|
||||
@Directive()
|
||||
export abstract class BaseEventsComponent implements OnDestroy {
|
||||
loading = true;
|
||||
loaded = false;
|
||||
events: EventView[];
|
||||
readonly loading = signal(true);
|
||||
readonly loaded = signal(false);
|
||||
readonly events = signal<EventView[]>([]);
|
||||
dirtyDates = true;
|
||||
continuationToken: string;
|
||||
canUseSM = false;
|
||||
@@ -115,7 +115,7 @@ export abstract class BaseEventsComponent implements OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.loading.set(true);
|
||||
|
||||
const dates = this.parseDates();
|
||||
if (dates == null) {
|
||||
@@ -131,7 +131,7 @@ export abstract class BaseEventsComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
promise = null;
|
||||
this.loading = false;
|
||||
this.loading.set(false);
|
||||
};
|
||||
|
||||
loadEvents = async (clearExisting: boolean) => {
|
||||
@@ -140,7 +140,7 @@ export abstract class BaseEventsComponent implements OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.loading.set(true);
|
||||
let events: EventView[] = [];
|
||||
let promise: Promise<any>;
|
||||
promise = this.loadAndParseEvents(
|
||||
@@ -153,14 +153,16 @@ export abstract class BaseEventsComponent implements OnDestroy {
|
||||
this.continuationToken = result.continuationToken;
|
||||
events = result.events;
|
||||
|
||||
if (!clearExisting && this.events != null && this.events.length > 0) {
|
||||
this.events = this.events.concat(events);
|
||||
if (!clearExisting && this.events() != null && this.events().length > 0) {
|
||||
this.events.update((current) => {
|
||||
return [...current, ...events];
|
||||
});
|
||||
} else {
|
||||
this.events = events;
|
||||
this.events.set(events);
|
||||
}
|
||||
|
||||
this.dirtyDates = false;
|
||||
this.loading = false;
|
||||
this.loading.set(false);
|
||||
promise = null;
|
||||
};
|
||||
|
||||
@@ -227,7 +229,7 @@ export abstract class BaseEventsComponent implements OnDestroy {
|
||||
|
||||
private async export(start: string, end: string) {
|
||||
let continuationToken = this.continuationToken;
|
||||
let events = [].concat(this.events);
|
||||
let events = [].concat(this.events());
|
||||
|
||||
while (continuationToken != null) {
|
||||
const result = await this.loadAndParseEvents(start, end, continuationToken);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { ActivatedRoute, Params, Router } from "@angular/router";
|
||||
import { ActivatedRoute, NavigationExtras, Params, Router } from "@angular/router";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
@@ -472,7 +472,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
collections,
|
||||
filter.collectionId,
|
||||
);
|
||||
searchableCollectionNodes = selectedCollection.children ?? [];
|
||||
searchableCollectionNodes = selectedCollection?.children ?? [];
|
||||
}
|
||||
|
||||
let collectionsToReturn: CollectionAdminView[] = [];
|
||||
@@ -588,7 +588,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
queryParamsHandling: "merge",
|
||||
replaceUrl: true,
|
||||
state: {
|
||||
focusMainAfterNav: false,
|
||||
focusAfterNav: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -812,7 +812,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
|
||||
async editCipherAttachments(cipher: CipherView) {
|
||||
if (cipher.reprompt !== 0 && !(await this.passwordRepromptService.showPasswordPrompt())) {
|
||||
this.go({ cipherId: null, itemId: null });
|
||||
this.go({ cipherId: null, itemId: null }, this.configureRouterFocusToCipher(cipher.id));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -869,7 +869,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
!(await this.passwordRepromptService.showPasswordPrompt())
|
||||
) {
|
||||
// didn't pass password prompt, so don't open add / edit modal
|
||||
this.go({ cipherId: null, itemId: null });
|
||||
this.go({ cipherId: null, itemId: null }, this.configureRouterFocusToCipher(cipher.id));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -893,7 +893,10 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
!(await this.passwordRepromptService.showPasswordPrompt())
|
||||
) {
|
||||
// Didn't pass password prompt, so don't open add / edit modal.
|
||||
await this.go({ cipherId: null, itemId: null, action: null });
|
||||
await this.go(
|
||||
{ cipherId: null, itemId: null, action: null },
|
||||
this.configureRouterFocusToCipher(cipher.id),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -943,7 +946,10 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
// Clear the query params when the dialog closes
|
||||
await this.go({ cipherId: null, itemId: null, action: null });
|
||||
await this.go(
|
||||
{ cipherId: null, itemId: null, action: null },
|
||||
this.configureRouterFocusToCipher(formConfig.originalCipher?.id),
|
||||
);
|
||||
}
|
||||
|
||||
async cloneCipher(cipher: CipherView) {
|
||||
@@ -962,10 +968,10 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
await this.editCipher(cipher, true);
|
||||
}
|
||||
|
||||
restore = async (c: CipherViewLike): Promise<boolean> => {
|
||||
restore = async (c: CipherViewLike): Promise<void> => {
|
||||
const organization = await firstValueFrom(this.organization$);
|
||||
if (!CipherViewLikeUtils.isDeleted(c)) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -974,11 +980,11 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
!organization.allowAdminAccessToAllCollectionItems
|
||||
) {
|
||||
this.showMissingPermissionsError();
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await this.repromptCipher([c]))) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow restore of an Unassigned Item
|
||||
@@ -996,10 +1002,10 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
message: this.i18nService.t("restoredItem"),
|
||||
});
|
||||
this.refresh();
|
||||
return true;
|
||||
return;
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1422,7 +1428,25 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private go(queryParams: any = null) {
|
||||
/**
|
||||
* Helper function to set up the `state.focusAfterNav` property for dialog router navigation if
|
||||
* the cipherId exists. If it doesn't exist, returns undefined.
|
||||
*
|
||||
* This ensures that when the routed dialog is closed, the focus returns to the cipher button in
|
||||
* the vault table, which allows keyboard users to continue navigating uninterrupted.
|
||||
*
|
||||
* @param cipherId id of cipher
|
||||
* @returns Partial<NavigationExtras>, specifically the state.focusAfterNav property, or undefined
|
||||
*/
|
||||
private configureRouterFocusToCipher(cipherId?: string): Partial<NavigationExtras> | undefined {
|
||||
if (cipherId) {
|
||||
return {
|
||||
state: { focusAfterNav: `#cipher-btn-${cipherId}` },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private go(queryParams: any = null, navigateOptions?: NavigationExtras) {
|
||||
if (queryParams == null) {
|
||||
queryParams = {
|
||||
type: this.activeFilter.cipherType,
|
||||
@@ -1436,6 +1460,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
queryParams: queryParams,
|
||||
queryParamsHandling: "merge",
|
||||
replaceUrl: true,
|
||||
...navigateOptions,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
@let usePlaceHolderEvents = !organization?.useEvents;
|
||||
|
||||
<app-header>
|
||||
<span
|
||||
bitBadge
|
||||
variant="primary"
|
||||
slot="title-suffix"
|
||||
class="tw-ml-2 tw-mt-1.5 tw-inline-flex tw-items-center"
|
||||
*ngIf="usePlaceHolderEvents"
|
||||
>
|
||||
{{ "upgrade" | i18n }}
|
||||
</span>
|
||||
@if (usePlaceHolderEvents) {
|
||||
<span
|
||||
bitBadge
|
||||
variant="primary"
|
||||
slot="title-suffix"
|
||||
class="tw-ml-2 tw-mt-1.5 tw-inline-flex tw-items-center"
|
||||
>
|
||||
{{ "upgrade" | i18n }}
|
||||
</span>
|
||||
}
|
||||
</app-header>
|
||||
<div class="tw-mb-4" [formGroup]="eventsForm">
|
||||
<div class="tw-mt-4 tw-flex tw-items-center">
|
||||
@@ -61,79 +63,87 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<bit-callout
|
||||
type="info"
|
||||
[title]="'upgradeEventLogTitleMessage' | i18n"
|
||||
*ngIf="loaded && usePlaceHolderEvents"
|
||||
>
|
||||
{{ "upgradeEventLogMessage" | i18n }}
|
||||
</bit-callout>
|
||||
<ng-container *ngIf="!loaded">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="loaded">
|
||||
@let displayedEvents = organization?.useEvents ? events : placeholderEvents;
|
||||
@if (loaded() && usePlaceHolderEvents) {
|
||||
<bit-callout type="info" [title]="'upgradeEventLogTitleMessage' | i18n">
|
||||
{{ "upgradeEventLogMessage" | i18n }}
|
||||
</bit-callout>
|
||||
}
|
||||
|
||||
<p *ngIf="!displayedEvents || !displayedEvents.length">{{ "noEventsInList" | i18n }}</p>
|
||||
<bit-table *ngIf="displayedEvents && displayedEvents.length">
|
||||
<ng-container header>
|
||||
<tr>
|
||||
<th bitCell>{{ "timestamp" | i18n }}</th>
|
||||
<th bitCell>{{ "client" | i18n }}</th>
|
||||
<th bitCell>{{ "member" | i18n }}</th>
|
||||
<th bitCell>{{ "event" | i18n }}</th>
|
||||
</tr>
|
||||
</ng-container>
|
||||
<ng-template body>
|
||||
<tr bitRow *ngFor="let e of displayedEvents; index as i" alignContent="top">
|
||||
<td bitCell class="tw-whitespace-nowrap">
|
||||
{{ i > 4 && usePlaceHolderEvents ? "******" : (e.date | date: "medium") }}
|
||||
</td>
|
||||
<td bitCell>
|
||||
<span title="{{ e.appName }}, {{ e.ip }}">{{ e.appName }}</span>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<span title="{{ e.userEmail }}">{{ e.userName }}</span>
|
||||
</td>
|
||||
<td bitCell [innerHTML]="e.message"></td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</bit-table>
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
[bitAction]="loadMoreEvents"
|
||||
*ngIf="continuationToken"
|
||||
>
|
||||
{{ "loadMore" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
@if (!loaded()) {
|
||||
<ng-container>
|
||||
<bit-icon
|
||||
class="bwi-lg bwi-spin tw-text-muted"
|
||||
name="bwi-spinner"
|
||||
aria-hidden="true"
|
||||
></bit-icon>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</ng-container>
|
||||
}
|
||||
@if (loaded()) {
|
||||
<ng-container>
|
||||
@let displayedEvents = organization?.useEvents ? events() : placeholderEvents;
|
||||
|
||||
<ng-container *ngIf="loaded && usePlaceHolderEvents">
|
||||
<div
|
||||
class="tw-relative tw--top-72 tw-bg-background tw-bg-opacity-90 tw-pb-5 tw-flex tw-items-center tw-justify-center tw-h-[19rem]"
|
||||
>
|
||||
<div
|
||||
class="tw-bg-background tw-max-w-xl tw-flex-col tw-justify-center tw-text-center tw-p-5 tw-px-10 tw-rounded tw-border-0 tw-border-b tw-border-secondary-300 tw-border-solid tw-mt-5"
|
||||
>
|
||||
<i class="bwi bwi-2x bwi-business tw-text-primary-600"></i>
|
||||
@if (!displayedEvents || !displayedEvents.length) {
|
||||
<p>{{ "noEventsInList" | i18n }}</p>
|
||||
}
|
||||
|
||||
<p class="tw-font-medium tw-mt-2">
|
||||
{{ "upgradeEventLogTitleMessage" | i18n }}
|
||||
</p>
|
||||
<p>
|
||||
{{ "upgradeForFullEventsMessage" | i18n }}
|
||||
</p>
|
||||
|
||||
<button type="button" class="tw-mt-1" bitButton buttonType="primary" (click)="changePlan()">
|
||||
{{ "changeBillingPlan" | i18n }}
|
||||
@if (displayedEvents && displayedEvents.length) {
|
||||
<bit-table data-testid="events-table">
|
||||
<ng-container header>
|
||||
<tr>
|
||||
<th bitCell>{{ "timestamp" | i18n }}</th>
|
||||
<th bitCell>{{ "client" | i18n }}</th>
|
||||
<th bitCell>{{ "member" | i18n }}</th>
|
||||
<th bitCell>{{ "event" | i18n }}</th>
|
||||
</tr>
|
||||
</ng-container>
|
||||
<ng-template body>
|
||||
@for (e of displayedEvents; track i; let i = $index) {
|
||||
<tr bitRow alignContent="top">
|
||||
<td bitCell class="tw-whitespace-nowrap">
|
||||
{{ i > 4 && usePlaceHolderEvents ? "******" : (e.date | date: "medium") }}
|
||||
</td>
|
||||
<td bitCell>
|
||||
<span title="{{ e.appName }}, {{ e.ip }}">{{ e.appName }}</span>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<span title="{{ e.userEmail }}">{{ e.userName }}</span>
|
||||
</td>
|
||||
<td bitCell [innerHTML]="e.message"></td>
|
||||
</tr>
|
||||
}
|
||||
</ng-template>
|
||||
</bit-table>
|
||||
}
|
||||
@if (continuationToken) {
|
||||
<button type="button" bitButton buttonType="primary" [bitAction]="loadMoreEvents">
|
||||
{{ "loadMore" | i18n }}
|
||||
</button>
|
||||
}
|
||||
</ng-container>
|
||||
}
|
||||
|
||||
@if (loaded() && usePlaceHolderEvents) {
|
||||
<ng-container>
|
||||
<div
|
||||
class="tw-relative tw--top-72 tw-bg-background tw-bg-opacity-90 tw-pb-5 tw-flex tw-items-center tw-justify-center tw-h-[19rem]"
|
||||
>
|
||||
<div
|
||||
class="tw-bg-background tw-max-w-xl tw-flex-col tw-justify-center tw-text-center tw-p-5 tw-px-10 tw-rounded tw-border-0 tw-border-b tw-border-secondary-300 tw-border-solid tw-mt-5"
|
||||
>
|
||||
<i class="bwi bwi-2x bwi-business tw-text-primary-600"></i>
|
||||
|
||||
<p class="tw-font-medium tw-mt-2">
|
||||
{{ "upgradeEventLogTitleMessage" | i18n }}
|
||||
</p>
|
||||
<p>
|
||||
{{ "upgradeForFullEventsMessage" | i18n }}
|
||||
</p>
|
||||
|
||||
<button type="button" class="tw-mt-1" bitButton buttonType="primary" (click)="changePlan()">
|
||||
{{ "changeBillingPlan" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { Component, OnDestroy, OnInit, ChangeDetectionStrategy } from "@angular/core";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { concatMap, firstValueFrom, lastValueFrom, map, of, switchMap, takeUntil, tap } from "rxjs";
|
||||
|
||||
@@ -44,11 +44,11 @@ const EVENT_SYSTEM_USER_TO_TRANSLATION: Record<EventSystemUser, string> = {
|
||||
[EventSystemUser.SCIM]: null, // SCIM acronym not able to be translated so just display SCIM
|
||||
[EventSystemUser.DomainVerification]: "domainVerification",
|
||||
[EventSystemUser.PublicApi]: "publicApi",
|
||||
[EventSystemUser.BitwardenPortal]: "system",
|
||||
};
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: "events.component.html",
|
||||
imports: [SharedModule, HeaderModule],
|
||||
})
|
||||
@@ -167,7 +167,7 @@ export class EventsComponent extends BaseEventsComponent implements OnInit, OnDe
|
||||
}
|
||||
}
|
||||
await this.refreshEvents();
|
||||
this.loaded = true;
|
||||
this.loaded.set(true);
|
||||
}
|
||||
|
||||
protected requestEvents(startDate: string, endDate: string, continuationToken: string) {
|
||||
|
||||
@@ -81,7 +81,7 @@ export class OrganizationUserResetPasswordService implements UserKeyRotationKeyR
|
||||
|
||||
if (
|
||||
!trustedPublicKeys.some(
|
||||
(key) => Utils.fromBufferToHex(key) === Utils.fromBufferToHex(publicKey),
|
||||
(key) => Utils.fromArrayToHex(key) === Utils.fromArrayToHex(publicKey),
|
||||
)
|
||||
) {
|
||||
throw new Error("Untrusted public key");
|
||||
|
||||
@@ -6,8 +6,10 @@ import { Constructor } from "type-fest";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
|
||||
import { VNextSavePolicyRequest } from "@bitwarden/common/admin-console/models/request/v-next-save-policy.request";
|
||||
import { PolicyStatusResponse } from "@bitwarden/common/admin-console/models/response/policy-status.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { OrgKey } from "@bitwarden/common/types/key";
|
||||
import { DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
|
||||
|
||||
import type { PolicyEditDialogData, PolicyEditDialogResult } from "./policy-edit-dialog.component";
|
||||
@@ -56,7 +58,7 @@ export abstract class BasePolicyEditDefinition {
|
||||
* If true, the {@link description} will be reused in the policy edit modal. Set this to false if you
|
||||
* have more complex requirements that you will implement in your template instead.
|
||||
**/
|
||||
showDescription: boolean = false;
|
||||
showDescription: boolean = true;
|
||||
|
||||
/**
|
||||
* A method that determines whether to display this policy in the Admin Console Policies page.
|
||||
@@ -103,6 +105,19 @@ export abstract class BasePolicyEditComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
async buildVNextRequest(orgKey: OrgKey): Promise<VNextSavePolicyRequest> {
|
||||
if (!this.policy) {
|
||||
throw new Error("Policy was not found");
|
||||
}
|
||||
|
||||
const request: VNextSavePolicyRequest = {
|
||||
policy: await this.buildRequest(),
|
||||
metadata: null,
|
||||
};
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
buildRequest() {
|
||||
if (!this.policy) {
|
||||
throw new Error("Policy was not found");
|
||||
|
||||
@@ -19,14 +19,12 @@
|
||||
@if (p.display$(organization, configService) | async) {
|
||||
<tr bitRow>
|
||||
<td bitCell ngPreserveWhitespaces>
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
<button type="button" bitLink (click)="edit(p, organizationId)">
|
||||
{{ p.name | i18n }}
|
||||
</button>
|
||||
@if (policiesEnabledMap.get(p.type)) {
|
||||
<span bitBadge variant="success">{{ "on" | i18n }}</span>
|
||||
}
|
||||
</div>
|
||||
<button type="button" bitLink (click)="edit(p, organizationId)">
|
||||
{{ p.name | i18n }}
|
||||
</button>
|
||||
@if (policiesEnabledMap.get(p.type)) {
|
||||
<span bitBadge variant="success">{{ "on" | i18n }}</span>
|
||||
}
|
||||
<small class="tw-text-muted tw-block">{{ p.description | i18n }}</small>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef, OnDestroy } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { combineLatest, Observable, of, switchMap, first, map, shareReplay } from "rxjs";
|
||||
@@ -14,7 +14,7 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { getById } from "@bitwarden/common/platform/misc";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { DialogRef, DialogService } from "@bitwarden/components";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { safeProvider } from "@bitwarden/ui-common";
|
||||
|
||||
import { HeaderModule } from "../../../layouts/header/header.module";
|
||||
@@ -37,8 +37,7 @@ import { POLICY_EDIT_REGISTER } from "./policy-register-token";
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PoliciesComponent implements OnDestroy {
|
||||
private myDialogRef?: DialogRef;
|
||||
export class PoliciesComponent {
|
||||
private userId$: Observable<UserId> = this.accountService.activeAccount$.pipe(getUserId);
|
||||
|
||||
protected organizationId$: Observable<OrganizationId> = this.route.params.pipe(
|
||||
@@ -99,10 +98,6 @@ export class PoliciesComponent implements OnDestroy {
|
||||
this.handleLaunchEvent();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.myDialogRef?.close();
|
||||
}
|
||||
|
||||
// Handle policies component launch from Event message
|
||||
private handleLaunchEvent() {
|
||||
combineLatest([
|
||||
@@ -136,7 +131,7 @@ export class PoliciesComponent implements OnDestroy {
|
||||
edit(policy: BasePolicyEditDefinition, organizationId: OrganizationId) {
|
||||
const dialogComponent: PolicyDialogComponent =
|
||||
policy.editDialogComponent ?? PolicyEditDialogComponent;
|
||||
this.myDialogRef = dialogComponent.open(this.dialogService, {
|
||||
dialogComponent.open(this.dialogService, {
|
||||
data: {
|
||||
policy: policy,
|
||||
organizationId: organizationId,
|
||||
|
||||
@@ -13,24 +13,29 @@
|
||||
<bit-label>{{ "enforceOnLoginDesc" | i18n }}</bit-label>
|
||||
</bit-form-control>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "minComplexityScore" | i18n }}</bit-label>
|
||||
<bit-select formControlName="minComplexity" id="minComplexity">
|
||||
<bit-option *ngFor="let o of passwordScores" [value]="o.value" [label]="o.name"></bit-option>
|
||||
</bit-select>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "minLength" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="number"
|
||||
formControlName="minLength"
|
||||
id="minLength"
|
||||
[min]="MinPasswordLength"
|
||||
[max]="MaxPasswordLength"
|
||||
/>
|
||||
</bit-form-field>
|
||||
<div class="tw-flex tw-space-x-4">
|
||||
<bit-form-field class="tw-flex-auto">
|
||||
<bit-label>{{ "minComplexityScore" | i18n }}</bit-label>
|
||||
<bit-select formControlName="minComplexity" id="minComplexity">
|
||||
<bit-option
|
||||
*ngFor="let o of passwordScores"
|
||||
[value]="o.value"
|
||||
[label]="o.name"
|
||||
></bit-option>
|
||||
</bit-select>
|
||||
</bit-form-field>
|
||||
<bit-form-field class="tw-flex-auto">
|
||||
<bit-label>{{ "minLength" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="number"
|
||||
formControlName="minLength"
|
||||
id="minLength"
|
||||
[min]="MinPasswordLength"
|
||||
[max]="MaxPasswordLength"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
|
||||
<bit-form-control class="!tw-mb-2">
|
||||
<input type="checkbox" bitCheckbox formControlName="requireUpper" id="requireUpper" />
|
||||
|
||||
@@ -3,7 +3,7 @@ import { lastValueFrom, map, Observable } from "rxjs";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
|
||||
import { VNextSavePolicyRequest } from "@bitwarden/common/admin-console/models/request/v-next-save-policy.request";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
@@ -15,12 +15,9 @@ import { EncString } from "@bitwarden/sdk-internal";
|
||||
import { SharedModule } from "../../../../shared";
|
||||
import { BasePolicyEditDefinition, BasePolicyEditComponent } from "../base-policy-edit.component";
|
||||
|
||||
export interface VNextPolicyRequest {
|
||||
policy: PolicyRequest;
|
||||
metadata: {
|
||||
defaultUserCollectionName: string;
|
||||
};
|
||||
}
|
||||
type VNextSaveOrganizationDataOwnershipPolicyRequest = VNextSavePolicyRequest<{
|
||||
defaultUserCollectionName: string;
|
||||
}>;
|
||||
|
||||
export class OrganizationDataOwnershipPolicy extends BasePolicyEditDefinition {
|
||||
name = "organizationDataOwnership";
|
||||
@@ -69,14 +66,16 @@ export class OrganizationDataOwnershipPolicyComponent
|
||||
return true;
|
||||
}
|
||||
|
||||
async buildVNextRequest(orgKey: OrgKey): Promise<VNextPolicyRequest> {
|
||||
async buildVNextRequest(
|
||||
orgKey: OrgKey,
|
||||
): Promise<VNextSaveOrganizationDataOwnershipPolicyRequest> {
|
||||
if (!this.policy) {
|
||||
throw new Error("Policy was not found");
|
||||
}
|
||||
|
||||
const defaultUserCollectionName = await this.getEncryptedDefaultUserCollectionName(orgKey);
|
||||
|
||||
const request: VNextPolicyRequest = {
|
||||
const request: VNextSaveOrganizationDataOwnershipPolicyRequest = {
|
||||
policy: {
|
||||
enabled: this.enabled.value ?? false,
|
||||
data: this.buildRequestData(),
|
||||
|
||||
@@ -4,50 +4,56 @@
|
||||
<bit-label>{{ "turnOn" | i18n }}</bit-label>
|
||||
</bit-form-control>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "passwordTypePolicyOverride" | i18n }}</bit-label>
|
||||
<bit-select formControlName="overridePasswordType" id="overrideType">
|
||||
<bit-option
|
||||
*ngFor="let o of overridePasswordTypeOptions"
|
||||
[value]="o.value"
|
||||
[label]="o.name"
|
||||
></bit-option>
|
||||
</bit-select>
|
||||
</bit-form-field>
|
||||
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
|
||||
<bit-form-field class="tw-col-span-6 tw-mb-0">
|
||||
<bit-label>{{ "overridePasswordTypePolicy" | i18n }}</bit-label>
|
||||
<bit-select formControlName="overridePasswordType" id="overrideType">
|
||||
<bit-option
|
||||
*ngFor="let o of overridePasswordTypeOptions"
|
||||
[value]="o.value"
|
||||
[label]="o.name"
|
||||
></bit-option>
|
||||
</bit-select>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
|
||||
<!-- password-specific policies -->
|
||||
<div *ngIf="showPasswordPolicies$ | async">
|
||||
<h3 bitTypography="h3" class="tw-mt-4">{{ "password" | i18n }}</h3>
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "minLength" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="number"
|
||||
[min]="minLengthMin"
|
||||
[max]="minLengthMax"
|
||||
formControlName="minLength"
|
||||
/>
|
||||
</bit-form-field>
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "minNumbers" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="number"
|
||||
[min]="minNumbersMin"
|
||||
[max]="minNumbersMax"
|
||||
formControlName="minNumbers"
|
||||
/>
|
||||
</bit-form-field>
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "minSpecial" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="number"
|
||||
[min]="minSpecialMin"
|
||||
[max]="minSpecialMax"
|
||||
formControlName="minSpecial"
|
||||
/>
|
||||
</bit-form-field>
|
||||
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
|
||||
<bit-form-field class="tw-col-span-6">
|
||||
<bit-label>{{ "minLength" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="number"
|
||||
[min]="minLengthMin"
|
||||
[max]="minLengthMax"
|
||||
formControlName="minLength"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
|
||||
<bit-form-field class="tw-col-span-6">
|
||||
<bit-label>{{ "minNumbers" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="number"
|
||||
[min]="minNumbersMin"
|
||||
[max]="minNumbersMax"
|
||||
formControlName="minNumbers"
|
||||
/>
|
||||
</bit-form-field>
|
||||
<bit-form-field class="tw-col-span-6">
|
||||
<bit-label>{{ "minSpecial" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="number"
|
||||
[min]="minSpecialMin"
|
||||
[max]="minSpecialMax"
|
||||
formControlName="minSpecial"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<bit-form-control>
|
||||
<input type="checkbox" bitCheckbox formControlName="useUpper" id="useUpper" />
|
||||
<bit-label>{{ "uppercaseLabel" | i18n }}</bit-label>
|
||||
@@ -73,16 +79,18 @@
|
||||
<!-- passphrase-specific policies -->
|
||||
<div *ngIf="showPassphrasePolicies$ | async">
|
||||
<h3 bitTypography="h3" class="tw-mt-4">{{ "passphrase" | i18n }}</h3>
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "minimumNumberOfWords" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="number"
|
||||
[min]="minNumberWordsMin"
|
||||
[max]="minNumberWordsMax"
|
||||
formControlName="minNumberWords"
|
||||
/>
|
||||
</bit-form-field>
|
||||
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
|
||||
<bit-form-field class="tw-col-span-6">
|
||||
<bit-label>{{ "minimumNumberOfWords" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="number"
|
||||
[min]="minNumberWordsMin"
|
||||
[max]="minNumberWordsMax"
|
||||
formControlName="minNumberWords"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<bit-form-control>
|
||||
<input type="checkbox" bitCheckbox formControlName="capitalize" id="capitalize" />
|
||||
<bit-label>{{ "capitalize" | i18n }}</bit-label>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { mock } from "jest-mock-extended";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { PolicyStatusResponse } from "@bitwarden/common/admin-console/models/response/policy-status.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { OrgKey } from "@bitwarden/common/types/key";
|
||||
|
||||
import {
|
||||
RemoveUnlockWithPinPolicy,
|
||||
@@ -96,4 +97,27 @@ describe("RemoveUnlockWithPinPolicyComponent", () => {
|
||||
expect(bitLabelElement).not.toBeNull();
|
||||
expect(bitLabelElement.nativeElement.textContent.trim()).toBe("Turn on");
|
||||
});
|
||||
|
||||
it("buildVNextRequest should delegate to buildRequest and wrap with null metadata", async () => {
|
||||
component.policy = new RemoveUnlockWithPinPolicy();
|
||||
component.policyResponse = new PolicyStatusResponse({
|
||||
organizationId: "org1",
|
||||
type: PolicyType.RemoveUnlockWithPin,
|
||||
enabled: true,
|
||||
});
|
||||
component.ngOnInit();
|
||||
|
||||
const buildRequestSpy = jest.spyOn(component, "buildRequest");
|
||||
|
||||
const result = await component.buildVNextRequest(mock<OrgKey>());
|
||||
|
||||
expect(buildRequestSpy).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
policy: {
|
||||
enabled: true,
|
||||
data: null,
|
||||
},
|
||||
metadata: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Observable } from "rxjs";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
|
||||
import { VNextSavePolicyRequest } from "@bitwarden/common/admin-console/models/request/v-next-save-policy.request";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
@@ -24,12 +24,9 @@ import { SharedModule } from "../../../../shared";
|
||||
import { BasePolicyEditDefinition, BasePolicyEditComponent } from "../base-policy-edit.component";
|
||||
import { OrganizationDataOwnershipPolicyDialogComponent } from "../policy-edit-dialogs";
|
||||
|
||||
export interface VNextPolicyRequest {
|
||||
policy: PolicyRequest;
|
||||
metadata: {
|
||||
defaultUserCollectionName: string;
|
||||
};
|
||||
}
|
||||
type VNextSaveOrganizationDataOwnershipPolicyRequest = VNextSavePolicyRequest<{
|
||||
defaultUserCollectionName: string;
|
||||
}>;
|
||||
|
||||
export class vNextOrganizationDataOwnershipPolicy extends BasePolicyEditDefinition {
|
||||
name = "centralizeDataOwnership";
|
||||
@@ -67,14 +64,16 @@ export class vNextOrganizationDataOwnershipPolicyComponent
|
||||
|
||||
protected steps = [this.policyForm, this.warningContent];
|
||||
|
||||
async buildVNextRequest(orgKey: OrgKey): Promise<VNextPolicyRequest> {
|
||||
async buildVNextRequest(
|
||||
orgKey: OrgKey,
|
||||
): Promise<VNextSaveOrganizationDataOwnershipPolicyRequest> {
|
||||
if (!this.policy) {
|
||||
throw new Error("Policy was not found");
|
||||
}
|
||||
|
||||
const defaultUserCollectionName = await this.getEncryptedDefaultUserCollectionName(orgKey);
|
||||
|
||||
const request: VNextPolicyRequest = {
|
||||
const request: VNextSaveOrganizationDataOwnershipPolicyRequest = {
|
||||
policy: {
|
||||
enabled: this.enabled.value ?? false,
|
||||
data: this.buildRequestData(),
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit" class="tw-h-full tw-flex tw-flex-col">
|
||||
<bit-dialog dialogSize="default" [loading]="loading">
|
||||
<ng-container bitDialogTitle>
|
||||
<span class="tw-flex tw-items-center tw-gap-2">
|
||||
{{ policy.name | i18n }}
|
||||
@if (isPolicyEnabled) {
|
||||
<span bitBadge variant="success">{{ "on" | i18n }}</span>
|
||||
}
|
||||
</span>
|
||||
</ng-container>
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
<bit-dialog [loading]="loading" [title]="'editPolicy' | i18n" [subtitle]="policy.name | i18n">
|
||||
<ng-container bitDialogContent>
|
||||
<div *ngIf="loading">
|
||||
<i
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Observable, map, firstValueFrom, switchMap, filter, of } from "rxjs";
|
||||
|
||||
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { VNextSavePolicyRequest } from "@bitwarden/common/admin-console/models/request/v-next-save-policy.request";
|
||||
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
@@ -29,7 +30,6 @@ import { KeyService } from "@bitwarden/key-management";
|
||||
import { SharedModule } from "../../../shared";
|
||||
|
||||
import { BasePolicyEditDefinition, BasePolicyEditComponent } from "./base-policy-edit.component";
|
||||
import { VNextPolicyRequest } from "./policy-edit-definitions/organization-data-ownership.component";
|
||||
|
||||
export type PolicyEditDialogData = {
|
||||
/**
|
||||
@@ -81,17 +81,13 @@ export class PolicyEditDialogComponent implements AfterViewInit {
|
||||
return this.data.policy;
|
||||
}
|
||||
|
||||
get isPolicyEnabled(): boolean {
|
||||
return this.policyComponent?.policyResponse?.enabled ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if the policy component has the buildVNextRequest method.
|
||||
*/
|
||||
private hasVNextRequest(
|
||||
component: BasePolicyEditComponent,
|
||||
): component is BasePolicyEditComponent & {
|
||||
buildVNextRequest: (orgKey: OrgKey) => Promise<VNextPolicyRequest>;
|
||||
buildVNextRequest: (orgKey: OrgKey) => Promise<VNextSavePolicyRequest>;
|
||||
} {
|
||||
return "buildVNextRequest" in component && typeof component.buildVNextRequest === "function";
|
||||
}
|
||||
@@ -145,11 +141,7 @@ export class PolicyEditDialogComponent implements AfterViewInit {
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.hasVNextRequest(this.policyComponent)) {
|
||||
await this.handleVNextSubmission(this.policyComponent);
|
||||
} else {
|
||||
await this.handleStandardSubmission();
|
||||
}
|
||||
await this.handleVNextSubmission(this.policyComponent);
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
@@ -164,20 +156,7 @@ export class PolicyEditDialogComponent implements AfterViewInit {
|
||||
}
|
||||
};
|
||||
|
||||
private async handleStandardSubmission(): Promise<void> {
|
||||
if (!this.policyComponent) {
|
||||
throw new Error("PolicyComponent not initialized.");
|
||||
}
|
||||
|
||||
const request = await this.policyComponent.buildRequest();
|
||||
await this.policyApiService.putPolicy(this.data.organizationId, this.data.policy.type, request);
|
||||
}
|
||||
|
||||
private async handleVNextSubmission(
|
||||
policyComponent: BasePolicyEditComponent & {
|
||||
buildVNextRequest: (orgKey: OrgKey) => Promise<VNextPolicyRequest>;
|
||||
},
|
||||
): Promise<void> {
|
||||
private async handleVNextSubmission(policyComponent: BasePolicyEditComponent): Promise<void> {
|
||||
const orgKey = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
@@ -200,9 +179,6 @@ export class PolicyEditDialogComponent implements AfterViewInit {
|
||||
);
|
||||
}
|
||||
static open = (dialogService: DialogService, config: DialogConfig<PolicyEditDialogData>) => {
|
||||
return dialogService.openDrawer<PolicyEditDialogResult, PolicyEditDialogData>(
|
||||
PolicyEditDialogComponent,
|
||||
config,
|
||||
);
|
||||
return dialogService.open<PolicyEditDialogResult>(PolicyEditDialogComponent, config);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit" class="tw-h-full tw-flex tw-flex-col">
|
||||
<bit-dialog dialogSize="large" [loading]="loading">
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
<bit-dialog [loading]="loading">
|
||||
<ng-container bitDialogTitle>
|
||||
@let title = (multiStepSubmit | async)[currentStep()]?.titleContent();
|
||||
@if (title) {
|
||||
@@ -40,16 +40,13 @@
|
||||
@if (showBadge) {
|
||||
<span bitBadge variant="info" class="tw-w-[99px] tw-my-2"> {{ "availableNow" | i18n }}</span>
|
||||
}
|
||||
<span class="tw-flex tw-items-center tw-gap-2">
|
||||
<span>
|
||||
{{ (showBadge ? "autoConfirm" : "editPolicy") | i18n }}
|
||||
@if (!showBadge) {
|
||||
<span class="tw-text-muted tw-font-normal tw-text-sm">
|
||||
{{ policy.name | i18n }}
|
||||
</span>
|
||||
}
|
||||
@if (isPolicyEnabled) {
|
||||
<span bitBadge variant="success">{{ "on" | i18n }}</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@@ -287,9 +287,6 @@ export class AutoConfirmPolicyDialogComponent
|
||||
dialogService: DialogService,
|
||||
config: DialogConfig<AutoConfirmPolicyDialogData>,
|
||||
) => {
|
||||
return dialogService.openDrawer<PolicyEditDialogResult, AutoConfirmPolicyDialogData>(
|
||||
AutoConfirmPolicyDialogComponent,
|
||||
config,
|
||||
);
|
||||
return dialogService.open<PolicyEditDialogResult>(AutoConfirmPolicyDialogComponent, config);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit" class="tw-h-full tw-flex tw-flex-col">
|
||||
<bit-dialog dialogSize="large" [loading]="loading">
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
<bit-dialog [loading]="loading">
|
||||
<ng-container bitDialogTitle>
|
||||
@let title = multiStepSubmit()[currentStep()]?.titleContent();
|
||||
@if (title) {
|
||||
@@ -35,12 +35,7 @@
|
||||
</form>
|
||||
|
||||
<ng-template #step0Title>
|
||||
<span class="tw-flex tw-items-center tw-gap-2">
|
||||
{{ policy.name | i18n }}
|
||||
@if (isPolicyEnabled) {
|
||||
<span bitBadge variant="success">{{ "on" | i18n }}</span>
|
||||
}
|
||||
</span>
|
||||
{{ policy.name | i18n }}
|
||||
</ng-template>
|
||||
|
||||
<ng-template #step1Title>
|
||||
|
||||
@@ -216,7 +216,7 @@ export class OrganizationDataOwnershipPolicyDialogComponent
|
||||
};
|
||||
|
||||
static open = (dialogService: DialogService, config: DialogConfig<PolicyEditDialogData>) => {
|
||||
return dialogService.openDrawer<PolicyEditDialogResult, PolicyEditDialogData>(
|
||||
return dialogService.open<PolicyEditDialogResult>(
|
||||
OrganizationDataOwnershipPolicyDialogComponent,
|
||||
config,
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { OverlayModule } from "@angular/cdk/overlay";
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { ReportsSharedModule } from "../../../dirt/reports";
|
||||
@@ -8,7 +9,13 @@ import { OrganizationReportingRoutingModule } from "./organization-reporting-rou
|
||||
import { ReportsHomeComponent } from "./reports-home.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [SharedModule, ReportsSharedModule, OrganizationReportingRoutingModule, HeaderModule],
|
||||
imports: [
|
||||
SharedModule,
|
||||
OverlayModule,
|
||||
ReportsSharedModule,
|
||||
OrganizationReportingRoutingModule,
|
||||
HeaderModule,
|
||||
],
|
||||
declarations: [ReportsHomeComponent],
|
||||
})
|
||||
export class OrganizationReportingModule {}
|
||||
|
||||
@@ -8,9 +8,26 @@
|
||||
|
||||
<router-outlet></router-outlet>
|
||||
|
||||
<div class="tw-mt-4">
|
||||
<a bitButton routerLink="./" *ngIf="!(homepage$ | async)">
|
||||
<i class="bwi bwi-angle-left" aria-hidden="true"></i>
|
||||
{{ "backToReports" | i18n }}
|
||||
</a>
|
||||
</div>
|
||||
@if (!(homepage$ | async)) {
|
||||
<!-- Focus bridge: redirects Tab to the overlay back button (which is outside cdkTrapFocus) -->
|
||||
<span
|
||||
tabindex="0"
|
||||
aria-hidden="true"
|
||||
class="tw-sr-only"
|
||||
(keydown.tab)="focusOverlayButton($event)"
|
||||
></span>
|
||||
}
|
||||
|
||||
<ng-template #backButtonTemplate>
|
||||
<div class="tw-p-3 tw-rounded-lg tw-opacity-90">
|
||||
<a
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
routerLink="./"
|
||||
tabindex="0"
|
||||
(keydown.tab)="returnFocusToPage($event)"
|
||||
>
|
||||
{{ "backToReports" | i18n }}
|
||||
</a>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
import { Overlay, OverlayRef } from "@angular/cdk/overlay";
|
||||
import { TemplatePortal } from "@angular/cdk/portal";
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
inject,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
TemplateRef,
|
||||
viewChild,
|
||||
ViewContainerRef,
|
||||
} from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { ActivatedRoute, NavigationEnd, Router } from "@angular/router";
|
||||
import { filter, map, Observable, startWith, concatMap, firstValueFrom } from "rxjs";
|
||||
|
||||
@@ -21,16 +33,30 @@ import { ReportVariant, reports, ReportType, ReportEntry } from "../../../dirt/r
|
||||
templateUrl: "reports-home.component.html",
|
||||
standalone: false,
|
||||
})
|
||||
export class ReportsHomeComponent implements OnInit {
|
||||
export class ReportsHomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
reports$: Observable<ReportEntry[]>;
|
||||
homepage$: Observable<boolean>;
|
||||
|
||||
private readonly backButtonTemplate =
|
||||
viewChild.required<TemplateRef<unknown>>("backButtonTemplate");
|
||||
|
||||
private overlayRef: OverlayRef | null = null;
|
||||
private overlay = inject(Overlay);
|
||||
private viewContainerRef = inject(ViewContainerRef);
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private organizationService: OrganizationService,
|
||||
private accountService: AccountService,
|
||||
private router: Router,
|
||||
) {}
|
||||
) {
|
||||
this.router.events
|
||||
.pipe(
|
||||
takeUntilDestroyed(),
|
||||
filter((event) => event instanceof NavigationEnd),
|
||||
)
|
||||
.subscribe(() => this.updateOverlay());
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
this.homepage$ = this.router.events.pipe(
|
||||
@@ -51,6 +77,46 @@ export class ReportsHomeComponent implements OnInit {
|
||||
);
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.updateOverlay();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.overlayRef?.dispose();
|
||||
}
|
||||
|
||||
returnFocusToPage(event: Event): void {
|
||||
if ((event as KeyboardEvent).shiftKey) {
|
||||
return; // Allow natural Shift+Tab behavior
|
||||
}
|
||||
event.preventDefault();
|
||||
const firstFocusable = document.querySelector(
|
||||
"[cdktrapfocus] a:not([tabindex='-1'])",
|
||||
) as HTMLElement;
|
||||
firstFocusable?.focus();
|
||||
}
|
||||
|
||||
focusOverlayButton(event: Event): void {
|
||||
if ((event as KeyboardEvent).shiftKey) {
|
||||
return; // Allow natural Shift+Tab behavior
|
||||
}
|
||||
event.preventDefault();
|
||||
const button = this.overlayRef?.overlayElement?.querySelector("a") as HTMLElement;
|
||||
button?.focus();
|
||||
}
|
||||
|
||||
private updateOverlay(): void {
|
||||
if (this.isReportsHomepageRouteUrl(this.router.url)) {
|
||||
this.overlayRef?.dispose();
|
||||
this.overlayRef = null;
|
||||
} else if (!this.overlayRef) {
|
||||
this.overlayRef = this.overlay.create({
|
||||
positionStrategy: this.overlay.position().global().bottom("20px").right("32px"),
|
||||
});
|
||||
this.overlayRef.attach(new TemplatePortal(this.backButtonTemplate(), this.viewContainerRef));
|
||||
}
|
||||
}
|
||||
|
||||
private buildReports(productType: ProductTierType): ReportEntry[] {
|
||||
const reportRequiresUpgrade =
|
||||
productType == ProductTierType.Free ? ReportVariant.RequiresUpgrade : ReportVariant.Enabled;
|
||||
|
||||
@@ -4,9 +4,9 @@ import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/pre
|
||||
import { ItemModule } from "@bitwarden/components";
|
||||
|
||||
import { DangerZoneComponent } from "../../../auth/settings/account/danger-zone.component";
|
||||
import { AccountFingerprintComponent } from "../../../key-management/account-fingerprint/account-fingerprint.component";
|
||||
import { HeaderModule } from "../../../layouts/header/header.module";
|
||||
import { SharedModule } from "../../../shared";
|
||||
import { AccountFingerprintComponent } from "../../../shared/components/account-fingerprint/account-fingerprint.component";
|
||||
|
||||
import { AccountComponent } from "./account.component";
|
||||
import { OrganizationSettingsRoutingModule } from "./organization-settings-routing.module";
|
||||
|
||||
@@ -25,7 +25,7 @@ const render: Story["render"] = (args) => ({
|
||||
...args,
|
||||
},
|
||||
template: `
|
||||
<bit-dialog [dialogSize]="dialogSize" [disablePadding]="disablePadding" disableAnimations>
|
||||
<bit-dialog disableAnimations>
|
||||
<span bitDialogTitle>Access selector</span>
|
||||
<span bitDialogContent>
|
||||
<bit-access-selector
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<div class="tw-mt-5 tw-flex tw-justify-center" *ngIf="loading">
|
||||
<div>
|
||||
<p class="tw-text-center">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
<bit-icon
|
||||
name="bwi-spinner"
|
||||
class="bwi-spin bwi-2x tw-text-muted"
|
||||
[ariaLabel]="'loading' | i18n"
|
||||
></bit-icon>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
<div>
|
||||
<img src="../../images/logo-dark@2x.png" class="logo" alt="Bitwarden" />
|
||||
<p class="tw-text-center tw-my-4">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
<bit-icon
|
||||
name="bwi-spinner"
|
||||
class="bwi-spin bwi-2x tw-text-muted"
|
||||
[ariaLabel]="'loading' | i18n"
|
||||
></bit-icon>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<bit-dialog dialogSize="large" [title]="'customizeAvatar' | i18n">
|
||||
<ng-container bitDialogContent>
|
||||
<div class="tw-text-center" *ngIf="loading">
|
||||
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-spinner" class="bwi-spin" [ariaLabel]="'loading' | i18n"></bit-icon>
|
||||
{{ "loading" | i18n }}
|
||||
</div>
|
||||
<p class="tw-text-lg">{{ "pickAnAvatarColor" | i18n }}</p>
|
||||
@@ -30,10 +30,11 @@
|
||||
class="tw-relative tw-flex tw-size-16 tw-cursor-pointer tw-place-content-center tw-rounded-full tw-border tw-border-solid tw-border-secondary-600 tw-outline tw-outline-0 tw-outline-offset-1 hover:tw-outline-1 hover:tw-outline-primary-300 focus:tw-outline-2 focus:tw-outline-primary-600"
|
||||
[style.background-color]="customColor$ | async"
|
||||
>
|
||||
<i
|
||||
<bit-icon
|
||||
name="bwi-pencil"
|
||||
[style.color]="customTextColor$ | async"
|
||||
class="bwi bwi-pencil !tw-text-muted tw-m-auto tw-text-3xl"
|
||||
></i>
|
||||
class="!tw-text-muted tw-m-auto tw-text-3xl"
|
||||
></bit-icon>
|
||||
<input
|
||||
tabindex="-1"
|
||||
class="tw-absolute tw-bottom-0 tw-right-0 tw-size-px tw-border-none tw-bg-transparent tw-opacity-0"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
|
||||
import mock, { MockProxy } from "jest-mock-extended/lib/Mock";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
|
||||
import { TwoFactorProviderResponse } from "@bitwarden/common/auth/models/response/two-factor-provider.response";
|
||||
import { ChangeEmailService } from "@bitwarden/common/auth/services/change-email/change-email.service";
|
||||
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@@ -14,7 +14,6 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
|
||||
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
|
||||
import { ChangeEmailComponent } from "@bitwarden/web-vault/app/auth/settings/account/change-email.component";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
|
||||
@@ -22,31 +21,25 @@ describe("ChangeEmailComponent", () => {
|
||||
let component: ChangeEmailComponent;
|
||||
let fixture: ComponentFixture<ChangeEmailComponent>;
|
||||
|
||||
let apiService: MockProxy<ApiService>;
|
||||
let changeEmailService: MockProxy<ChangeEmailService>;
|
||||
let twoFactorService: MockProxy<TwoFactorService>;
|
||||
let accountService: FakeAccountService;
|
||||
let keyService: MockProxy<KeyService>;
|
||||
let kdfConfigService: MockProxy<KdfConfigService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
apiService = mock<ApiService>();
|
||||
changeEmailService = mock<ChangeEmailService>();
|
||||
twoFactorService = mock<TwoFactorService>();
|
||||
keyService = mock<KeyService>();
|
||||
kdfConfigService = mock<KdfConfigService>();
|
||||
accountService = mockAccountServiceWith("UserId" as UserId);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ReactiveFormsModule, SharedModule, ChangeEmailComponent],
|
||||
providers: [
|
||||
{ provide: AccountService, useValue: accountService },
|
||||
{ provide: ApiService, useValue: apiService },
|
||||
{ provide: TwoFactorService, useValue: twoFactorService },
|
||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||
{ provide: KeyService, useValue: keyService },
|
||||
{ provide: MessagingService, useValue: mock<MessagingService>() },
|
||||
{ provide: KdfConfigService, useValue: kdfConfigService },
|
||||
{ provide: ToastService, useValue: mock<ToastService>() },
|
||||
{ provide: FormBuilder, useClass: FormBuilder },
|
||||
{ provide: ToastService, useValue: mock<ToastService>() },
|
||||
{ provide: ChangeEmailService, useValue: changeEmailService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
@@ -87,17 +80,11 @@ describe("ChangeEmailComponent", () => {
|
||||
|
||||
describe("submit", () => {
|
||||
beforeEach(() => {
|
||||
component.userId = "UserId" as UserId;
|
||||
component.formGroup.controls.step1.setValue({
|
||||
masterPassword: "password",
|
||||
newEmail: "test@example.com",
|
||||
});
|
||||
|
||||
keyService.getOrDeriveMasterKey
|
||||
.calledWith("password", "UserId" as UserId)
|
||||
.mockResolvedValue("getOrDeriveMasterKey" as any);
|
||||
keyService.hashMasterKey
|
||||
.calledWith("password", "getOrDeriveMasterKey" as any)
|
||||
.mockResolvedValue("existingHash");
|
||||
});
|
||||
|
||||
it("throws if userId is null on submit", async () => {
|
||||
@@ -115,16 +102,17 @@ describe("ChangeEmailComponent", () => {
|
||||
|
||||
await component.submit();
|
||||
|
||||
expect(apiService.postEmailToken).not.toHaveBeenCalled();
|
||||
expect(changeEmailService.requestEmailToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends email token in step 1 if tokenSent is false", async () => {
|
||||
await component.submit();
|
||||
|
||||
expect(apiService.postEmailToken).toHaveBeenCalledWith({
|
||||
newEmail: "test@example.com",
|
||||
masterPasswordHash: "existingHash",
|
||||
});
|
||||
expect(changeEmailService.requestEmailToken).toHaveBeenCalledWith(
|
||||
"password",
|
||||
"test@example.com",
|
||||
"UserId" as UserId,
|
||||
);
|
||||
// should activate step 2
|
||||
expect(component.tokenSent).toBe(true);
|
||||
expect(component.formGroup.controls.step1.disabled).toBe(true);
|
||||
@@ -138,23 +126,6 @@ describe("ChangeEmailComponent", () => {
|
||||
component.formGroup.controls.step1.disable();
|
||||
component.formGroup.controls.token.enable();
|
||||
component.formGroup.controls.token.setValue("token");
|
||||
|
||||
kdfConfigService.getKdfConfig$
|
||||
.calledWith("UserId" as any)
|
||||
.mockReturnValue(of("kdfConfig" as any));
|
||||
keyService.userKey$.calledWith("UserId" as any).mockReturnValue(of("userKey" as any));
|
||||
|
||||
keyService.makeMasterKey
|
||||
.calledWith("password", "test@example.com", "kdfConfig" as any)
|
||||
.mockResolvedValue("newMasterKey" as any);
|
||||
keyService.hashMasterKey
|
||||
.calledWith("password", "newMasterKey" as any)
|
||||
.mockResolvedValue("newMasterKeyHash");
|
||||
|
||||
// Important: make sure this is called with new master key, not existing
|
||||
keyService.encryptUserKeyWithMasterKey
|
||||
.calledWith("newMasterKey" as any, "userKey" as any)
|
||||
.mockResolvedValue(["userKey" as any, { encryptedString: "newEncryptedUserKey" } as any]);
|
||||
});
|
||||
|
||||
it("does not post email if token is missing on submit", async () => {
|
||||
@@ -162,38 +133,18 @@ describe("ChangeEmailComponent", () => {
|
||||
|
||||
await component.submit();
|
||||
|
||||
expect(apiService.postEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws if kdfConfig is missing on submit", async () => {
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(null));
|
||||
|
||||
await expect(component.submit()).rejects.toThrow("Missing kdf config");
|
||||
});
|
||||
|
||||
it("throws if userKey can't be found", async () => {
|
||||
keyService.userKey$.mockReturnValue(of(null));
|
||||
|
||||
await expect(component.submit()).rejects.toThrow("Can't find UserKey");
|
||||
});
|
||||
|
||||
it("throws if encryptedUserKey is missing", async () => {
|
||||
keyService.encryptUserKeyWithMasterKey.mockResolvedValue(["userKey" as any, null as any]);
|
||||
|
||||
await expect(component.submit()).rejects.toThrow("Missing Encrypted User Key");
|
||||
expect(changeEmailService.confirmEmailChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submits if step 2 is valid", async () => {
|
||||
await component.submit();
|
||||
|
||||
// validate that hashes are correct
|
||||
expect(apiService.postEmail).toHaveBeenCalledWith({
|
||||
masterPasswordHash: "existingHash",
|
||||
newMasterPasswordHash: "newMasterKeyHash",
|
||||
token: "token",
|
||||
newEmail: "test@example.com",
|
||||
key: "newEncryptedUserKey",
|
||||
});
|
||||
expect(changeEmailService.confirmEmailChange).toHaveBeenCalledWith(
|
||||
"password",
|
||||
"test@example.com",
|
||||
"token",
|
||||
"UserId" as UserId,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,18 +2,16 @@ import { Component, OnInit } from "@angular/core";
|
||||
import { FormBuilder, Validators } from "@angular/forms";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
|
||||
import { EmailTokenRequest } from "@bitwarden/common/auth/models/request/email-token.request";
|
||||
import { EmailRequest } from "@bitwarden/common/auth/models/request/email.request";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { ChangeEmailService } from "@bitwarden/common/auth/services/change-email/change-email.service";
|
||||
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
|
||||
import { assertNonNullish } from "@bitwarden/common/auth/utils";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
|
||||
@@ -39,14 +37,12 @@ export class ChangeEmailComponent implements OnInit {
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private apiService: ApiService,
|
||||
private twoFactorService: TwoFactorService,
|
||||
private i18nService: I18nService,
|
||||
private keyService: KeyService,
|
||||
private messagingService: MessagingService,
|
||||
private formBuilder: FormBuilder,
|
||||
private kdfConfigService: KdfConfigService,
|
||||
private toastService: ToastService,
|
||||
private changeEmailService: ChangeEmailService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -79,53 +75,25 @@ export class ChangeEmailComponent implements OnInit {
|
||||
const newEmail = step1Value.newEmail?.trim().toLowerCase();
|
||||
const masterPassword = step1Value.masterPassword;
|
||||
|
||||
if (newEmail == null || masterPassword == null) {
|
||||
throw new Error("Missing email or password");
|
||||
}
|
||||
|
||||
const existingHash = await this.keyService.hashMasterKey(
|
||||
masterPassword,
|
||||
await this.keyService.getOrDeriveMasterKey(masterPassword, this.userId),
|
||||
);
|
||||
const ctx = "Could not update email.";
|
||||
assertNonNullish(newEmail, "email", ctx);
|
||||
assertNonNullish(masterPassword, "password", ctx);
|
||||
|
||||
if (!this.tokenSent) {
|
||||
const request = new EmailTokenRequest();
|
||||
request.newEmail = newEmail;
|
||||
request.masterPasswordHash = existingHash;
|
||||
await this.apiService.postEmailToken(request);
|
||||
await this.changeEmailService.requestEmailToken(masterPassword, newEmail, this.userId);
|
||||
this.activateStep2();
|
||||
} else {
|
||||
const token = this.formGroup.value.token;
|
||||
if (token == null) {
|
||||
throw new Error("Missing token");
|
||||
}
|
||||
const request = new EmailRequest();
|
||||
request.token = token;
|
||||
request.newEmail = newEmail;
|
||||
request.masterPasswordHash = existingHash;
|
||||
|
||||
const kdfConfig = await firstValueFrom(this.kdfConfigService.getKdfConfig$(this.userId));
|
||||
if (kdfConfig == null) {
|
||||
throw new Error("Missing kdf config");
|
||||
}
|
||||
const newMasterKey = await this.keyService.makeMasterKey(masterPassword, newEmail, kdfConfig);
|
||||
request.newMasterPasswordHash = await this.keyService.hashMasterKey(
|
||||
await this.changeEmailService.confirmEmailChange(
|
||||
masterPassword,
|
||||
newMasterKey,
|
||||
newEmail,
|
||||
token,
|
||||
this.userId,
|
||||
);
|
||||
|
||||
const userKey = await firstValueFrom(this.keyService.userKey$(this.userId));
|
||||
if (userKey == null) {
|
||||
throw new Error("Can't find UserKey");
|
||||
}
|
||||
const newUserKey = await this.keyService.encryptUserKeyWithMasterKey(newMasterKey, userKey);
|
||||
const encryptedUserKey = newUserKey[1]?.encryptedString;
|
||||
if (encryptedUserKey == null) {
|
||||
throw new Error("Missing Encrypted User Key");
|
||||
}
|
||||
request.key = encryptedUserKey;
|
||||
|
||||
await this.apiService.postEmail(request);
|
||||
this.reset();
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<div *ngIf="loading">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
<bit-icon
|
||||
name="bwi-spinner"
|
||||
class="bwi-spin tw-text-muted"
|
||||
[ariaLabel]="'loading' | i18n"
|
||||
></bit-icon>
|
||||
</div>
|
||||
<form *ngIf="profile && !loading" [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
<div class="tw-grid tw-grid-cols-12 tw-gap-6">
|
||||
@@ -32,8 +31,8 @@
|
||||
appStopProp
|
||||
[bitAction]="openChangeAvatar"
|
||||
>
|
||||
<i class="bwi bwi-lg bwi-pencil-square" aria-hidden="true"></i>
|
||||
Customize
|
||||
<bit-icon name="bwi-pencil-square" class="bwi-lg"></bit-icon>
|
||||
{{ "customize" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
<div *ngIf="managingOrganization$ | async as managingOrganization">
|
||||
@@ -43,7 +42,7 @@
|
||||
rel="noopener noreferrer"
|
||||
href="https://bitwarden.com/help/claimed-accounts"
|
||||
>
|
||||
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-question-circle"></bit-icon>
|
||||
</a>
|
||||
</div>
|
||||
@if (fingerprintMaterial && userPublicKey) {
|
||||
|
||||
@@ -18,8 +18,8 @@ import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { DynamicAvatarComponent } from "../../../components/dynamic-avatar.component";
|
||||
import { AccountFingerprintComponent } from "../../../key-management/account-fingerprint/account-fingerprint.component";
|
||||
import { SharedModule } from "../../../shared";
|
||||
import { AccountFingerprintComponent } from "../../../shared/components/account-fingerprint/account-fingerprint.component";
|
||||
|
||||
import { ChangeAvatarDialogComponent } from "./change-avatar-dialog.component";
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
href="https://bitwarden.com/help/emergency-access/#user-access"
|
||||
slot="end"
|
||||
>
|
||||
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-question-circle"></bit-icon>
|
||||
</a>
|
||||
</bit-label>
|
||||
<bit-radio-button id="emergencyTypeView" [value]="emergencyAccessType.View">
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
[bitAction]="invite"
|
||||
[disabled]="!(canAccessPremium$ | async)"
|
||||
>
|
||||
<i aria-hidden="true" class="bwi bwi-plus bwi-fw"></i>
|
||||
<bit-icon name="bwi-plus" class="bwi-fw"></bit-icon>
|
||||
{{ "addEmergencyContact" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -99,7 +99,7 @@
|
||||
*ngIf="c.status === emergencyAccessStatusType.Invited"
|
||||
(click)="reinvite(c)"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-envelope" class="bwi-fw"></bit-icon>
|
||||
{{ "resendInvitation" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
@@ -108,7 +108,7 @@
|
||||
*ngIf="c.status === emergencyAccessStatusType.Accepted"
|
||||
(click)="confirm(c)"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-check" class="bwi-fw"></bit-icon>
|
||||
{{ "confirm" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
@@ -117,7 +117,7 @@
|
||||
*ngIf="c.status === emergencyAccessStatusType.RecoveryInitiated"
|
||||
(click)="approve(c)"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-check" class="bwi-fw"></bit-icon>
|
||||
{{ "approve" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
@@ -129,11 +129,11 @@
|
||||
"
|
||||
(click)="reject(c)"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-close" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-close" class="bwi-fw"></bit-icon>
|
||||
{{ "reject" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem (click)="remove(c)">
|
||||
<i class="bwi bwi-fw bwi-close" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-close" class="bwi-fw"></bit-icon>
|
||||
{{ "remove" | i18n }}
|
||||
</button>
|
||||
</bit-menu>
|
||||
@@ -144,12 +144,11 @@
|
||||
<ng-container *ngIf="!trustedContacts || !trustedContacts.length">
|
||||
<p bitTypography="body1" class="tw-mt-2" *ngIf="loaded">{{ "noTrustedContacts" | i18n }}</p>
|
||||
<ng-container *ngIf="!loaded">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
<bit-icon
|
||||
name="bwi-spinner"
|
||||
class="bwi-spin tw-text-muted"
|
||||
[ariaLabel]="'loading' | i18n"
|
||||
></bit-icon>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</bit-section>
|
||||
@@ -221,7 +220,7 @@
|
||||
*ngIf="c.status === emergencyAccessStatusType.Confirmed"
|
||||
(click)="requestAccess(c)"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-envelope" class="bwi-fw"></bit-icon>
|
||||
{{ "requestAccess" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
@@ -233,7 +232,7 @@
|
||||
"
|
||||
(click)="takeover(c)"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-key" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-key" class="bwi-fw"></bit-icon>
|
||||
{{ "takeover" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
@@ -245,11 +244,11 @@
|
||||
"
|
||||
[routerLink]="c.id"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-eye" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-eye" class="bwi-fw"></bit-icon>
|
||||
{{ "view" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem (click)="remove(c)">
|
||||
<i class="bwi bwi-fw bwi-close" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-close" class="bwi-fw"></bit-icon>
|
||||
{{ "remove" | i18n }}
|
||||
</button>
|
||||
</bit-menu>
|
||||
@@ -260,12 +259,11 @@
|
||||
<ng-container *ngIf="!grantedContacts || !grantedContacts.length">
|
||||
<p bitTypography="body1" *ngIf="loaded">{{ "noGrantedAccess" | i18n }}</p>
|
||||
<ng-container *ngIf="!loaded">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
<bit-icon
|
||||
name="bwi-spinner"
|
||||
class="bwi-spin tw-text-muted"
|
||||
[ariaLabel]="'loading' | i18n"
|
||||
></bit-icon>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</bit-section>
|
||||
|
||||
@@ -7,12 +7,11 @@
|
||||
<div bitDialogContent>
|
||||
@if (initializing) {
|
||||
<div class="tw-flex tw-items-center tw-justify-center">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
<bit-icon
|
||||
name="bwi-spinner"
|
||||
class="bwi-spin bwi-2x tw-text-muted"
|
||||
[ariaLabel]="'loading' | i18n"
|
||||
></bit-icon>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- TODO: PM-22237 -->
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
DialogModule,
|
||||
DialogRef,
|
||||
DialogService,
|
||||
IconModule,
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
@@ -59,6 +60,7 @@ export type EmergencyAccessTakeoverDialogResultType =
|
||||
CommonModule,
|
||||
DialogModule,
|
||||
I18nPipe,
|
||||
IconModule,
|
||||
InputPasswordComponent,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -18,22 +18,20 @@
|
||||
>{{ currentCipher.name }}</a
|
||||
>
|
||||
<ng-container *ngIf="currentCipher.organizationId">
|
||||
<i
|
||||
class="bwi bwi-collection-shared tw-ml-1"
|
||||
<bit-icon
|
||||
name="bwi-collection-shared"
|
||||
class="tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'shared' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
|
||||
[ariaLabel]="'shared' | i18n"
|
||||
></bit-icon>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="currentCipher.hasAttachments">
|
||||
<i
|
||||
class="bwi bwi-paperclip tw-ml-1"
|
||||
<bit-icon
|
||||
name="bwi-paperclip"
|
||||
class="tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'attachments' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
|
||||
[ariaLabel]="'attachments' | i18n"
|
||||
></bit-icon>
|
||||
</ng-container>
|
||||
<br />
|
||||
<small class="tw-text-xs">{{ currentCipher.subTitle }}</small>
|
||||
@@ -43,11 +41,10 @@
|
||||
</bit-table>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="!loaded">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
<bit-icon
|
||||
name="bwi-spinner"
|
||||
class="bwi-spin tw-text-muted"
|
||||
[ariaLabel]="'loading' | i18n"
|
||||
></bit-icon>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
<hr *ngIf="enabled" />
|
||||
<p class="tw-text-center tw-mb-0">
|
||||
<ng-container *ngIf="qrScriptError" class="tw-mt-2">
|
||||
<i class="bwi bwi-error tw-text-3xl tw-text-danger" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-error" class="tw-text-3xl tw-text-danger"></bit-icon>
|
||||
<p>
|
||||
{{ "twoStepAuthenticatorQRCanvasError" | i18n }}
|
||||
</p>
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
DialogRef,
|
||||
DialogService,
|
||||
FormFieldModule,
|
||||
IconModule,
|
||||
SvgModule,
|
||||
InputModule,
|
||||
LinkModule,
|
||||
@@ -63,6 +64,7 @@ declare global {
|
||||
ReactiveFormsModule,
|
||||
DialogModule,
|
||||
FormFieldModule,
|
||||
IconModule,
|
||||
InputModule,
|
||||
LinkModule,
|
||||
TypographyModule,
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<ul class="bwi-ul">
|
||||
<li *ngFor="let k of keys; let i = index" #removeKeyBtn [appApiAction]="k.removePromise">
|
||||
<ng-container *ngIf="k.configured">
|
||||
<i class="bwi bwi-li bwi-key"></i>
|
||||
<bit-icon name="bwi-key" class="bwi-li"></bit-icon>
|
||||
<span *ngIf="k.configured" bitTypography="body1" class="tw-font-medium">
|
||||
{{ k.name || ("unnamedKey" | i18n) }}
|
||||
</span>
|
||||
@@ -27,12 +27,12 @@
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="keysConfiguredCount > 1 && k.configured">
|
||||
<i
|
||||
class="bwi bwi-spin bwi-spinner tw-text-muted bwi-fw"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
<bit-icon
|
||||
name="bwi-spinner"
|
||||
class="bwi-spin tw-text-muted bwi-fw"
|
||||
[ariaLabel]="'loading' | i18n"
|
||||
*ngIf="$any(removeKeyBtn).loading"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
></bit-icon>
|
||||
-
|
||||
<a bitLink href="#" appStopClick (click)="remove(k)">{{ "remove" | i18n }}</a>
|
||||
</ng-container>
|
||||
@@ -68,19 +68,27 @@
|
||||
{{ "readKey" | i18n }}
|
||||
</button>
|
||||
<ng-container *ngIf="$any(readKeyBtn).loading()">
|
||||
<i class="bwi bwi-spinner bwi-spin tw-text-muted" aria-hidden="true"></i>
|
||||
<bit-icon
|
||||
name="bwi-spinner"
|
||||
class="bwi-spin tw-text-muted"
|
||||
[ariaLabel]="'loading' | i18n"
|
||||
></bit-icon>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="!$any(readKeyBtn).loading()">
|
||||
<ng-container *ngIf="webAuthnListening">
|
||||
<i class="bwi bwi-spinner bwi-spin tw-text-muted" aria-hidden="true"></i>
|
||||
<bit-icon
|
||||
name="bwi-spinner"
|
||||
class="bwi-spin tw-text-muted"
|
||||
[ariaLabel]="'loading' | i18n"
|
||||
></bit-icon>
|
||||
{{ "twoFactorU2fWaiting" | i18n }}...
|
||||
</ng-container>
|
||||
<ng-container *ngIf="webAuthnResponse">
|
||||
<i class="bwi bwi-check-circle tw-text-success" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-check-circle" class="tw-text-success"></bit-icon>
|
||||
{{ "twoFactorU2fClickSave" | i18n }}
|
||||
</ng-container>
|
||||
<ng-container *ngIf="webAuthnError">
|
||||
<i class="bwi bwi-exclamation-triangle tw-text-danger" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-exclamation-triangle" class="tw-text-danger"></bit-icon>
|
||||
{{ "twoFactorU2fProblemReadingTryAgain" | i18n }}
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
DialogRef,
|
||||
DialogService,
|
||||
FormFieldModule,
|
||||
IconModule,
|
||||
LinkModule,
|
||||
ToastService,
|
||||
TypographyModule,
|
||||
@@ -56,6 +57,7 @@ interface Key {
|
||||
DialogModule,
|
||||
FormFieldModule,
|
||||
I18nPipe,
|
||||
IconModule,
|
||||
JslibModule,
|
||||
LinkModule,
|
||||
ReactiveFormsModule,
|
||||
|
||||
@@ -34,12 +34,11 @@
|
||||
<h2 [ngClass]="{ 'mt-5': !organizationId }">
|
||||
{{ "providers" | i18n }}
|
||||
<small *ngIf="loading">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin bwi-fw tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
<bit-icon
|
||||
name="bwi-spinner"
|
||||
class="bwi-spin bwi-fw tw-text-muted"
|
||||
[ariaLabel]="'loading' | i18n"
|
||||
></bit-icon>
|
||||
</small>
|
||||
</h2>
|
||||
<bit-callout type="warning" *ngIf="showPolicyWarning">
|
||||
@@ -59,12 +58,11 @@
|
||||
{{ p.name }}
|
||||
</div>
|
||||
<ng-container *ngIf="p.enabled">
|
||||
<i
|
||||
class="bwi bwi-check tw-text-success-600 bwi-fw tw-ml-2"
|
||||
title="{{ 'enabled' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "enabled" | i18n }}</span>
|
||||
<bit-icon
|
||||
name="bwi-check"
|
||||
class="tw-text-success-600 bwi-fw tw-ml-2"
|
||||
[ariaLabel]="'enabled' | i18n"
|
||||
></bit-icon>
|
||||
</ng-container>
|
||||
<app-premium-badge class="tw-ml-2" *ngIf="p.premium"></app-premium-badge>
|
||||
</h3>
|
||||
|
||||
@@ -8,7 +8,11 @@
|
||||
</span>
|
||||
<ng-container bitDialogContent>
|
||||
<ng-container *ngIf="!credential">
|
||||
<i class="bwi bwi-spinner bwi-spin tw-ml-1" aria-hidden="true"></i>
|
||||
<bit-icon
|
||||
name="bwi-spinner"
|
||||
class="bwi-spin tw-ml-1"
|
||||
[ariaLabel]="'loading' | i18n"
|
||||
></bit-icon>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="credential">
|
||||
|
||||
@@ -8,7 +8,11 @@
|
||||
</span>
|
||||
<ng-container bitDialogContent>
|
||||
<ng-container *ngIf="!credential">
|
||||
<i class="bwi bwi-spinner bwi-spin tw-ml-1" aria-hidden="true"></i>
|
||||
<bit-icon
|
||||
name="bwi-spinner"
|
||||
class="bwi-spin tw-ml-1"
|
||||
[ariaLabel]="'loading' | i18n"
|
||||
></bit-icon>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="credential">
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</ng-container>
|
||||
</span>
|
||||
<ng-container *ngIf="loading">
|
||||
<i class="bwi bwi-spinner bwi-spin tw-ml-1" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-spinner" class="bwi-spin tw-ml-1" [ariaLabel]="'loading' | i18n"></bit-icon>
|
||||
</ng-container>
|
||||
</h2>
|
||||
<p bitTypography="body1">
|
||||
@@ -36,7 +36,7 @@
|
||||
<td class="tw-p-2 tw-pl-0 tw-font-medium">{{ credential.name }}</td>
|
||||
<td class="tw-p-2 tw-pr-10 tw-text-left">
|
||||
<ng-container *ngIf="credential.prfStatus === WebauthnLoginCredentialPrfStatus.Enabled">
|
||||
<i class="bwi bwi-lock-encrypted"></i>
|
||||
<bit-icon name="bwi-lock-encrypted"></bit-icon>
|
||||
<span bitTypography="body1" class="tw-text-muted">{{ "usedForEncryption" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="credential.prfStatus === WebauthnLoginCredentialPrfStatus.Supported">
|
||||
@@ -47,7 +47,7 @@
|
||||
[attr.aria-label]="('enablePasskeyEncryption' | i18n) + ' ' + credential.name"
|
||||
(click)="enableEncryption(credential.id)"
|
||||
>
|
||||
<i class="bwi bwi-lock-encrypted"></i>
|
||||
<bit-icon name="bwi-lock-encrypted"></bit-icon>
|
||||
{{ "enablePasskeyEncryption" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
{{ "sendCode" | i18n }}
|
||||
</button>
|
||||
<span class="tw-ml-2 tw-text-success" role="alert" @sent *ngIf="sentCode">
|
||||
<i class="bwi bwi-check-circle" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-check-circle"></bit-icon>
|
||||
{{ "codeSent" | i18n }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<div class="tw-p-8 tw-flex">
|
||||
<img class="new-logo-themed" alt="Bitwarden" />
|
||||
<div class="spinner-container tw-justify-center">
|
||||
<i class="bwi bwi-spinner bwi-spin bwi-3x tw-text-muted" title="Loading" aria-hidden="true"></i>
|
||||
<bit-icon
|
||||
name="bwi-spinner"
|
||||
class="bwi-spin bwi-3x tw-text-muted"
|
||||
[ariaLabel]="'loading' | i18n"
|
||||
></bit-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,11 +12,14 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
|
||||
import { SharedModule } from "../shared/shared.module";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "app-verify-email-token",
|
||||
templateUrl: "verify-email-token.component.html",
|
||||
imports: [SharedModule],
|
||||
})
|
||||
export class VerifyEmailTokenComponent implements OnInit {
|
||||
constructor(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="tw-max-w-3xl tw-mx-auto">
|
||||
<bit-section *ngIf="shouldShowNewDesign$ | async">
|
||||
<bit-section>
|
||||
<div class="tw-text-center">
|
||||
<div class="tw-mt-8 tw-mb-6">
|
||||
<span bitBadge variant="secondary" [truncate]="false">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Component, DestroyRef, inject } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import {
|
||||
catchError,
|
||||
combineLatest,
|
||||
firstValueFrom,
|
||||
from,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
} from "@bitwarden/components";
|
||||
import { PricingCardComponent } from "@bitwarden/pricing";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
import { AccountBillingClient } from "@bitwarden/web-vault/app/billing/clients";
|
||||
|
||||
import { BitwardenSubscriber, mapAccountToSubscriber } from "../../types";
|
||||
import {
|
||||
@@ -63,11 +65,12 @@ const RouteParamValues = {
|
||||
I18nPipe,
|
||||
PricingCardComponent,
|
||||
],
|
||||
providers: [AccountBillingClient],
|
||||
})
|
||||
export class CloudHostedPremiumComponent {
|
||||
protected hasPremiumFromAnyOrganization$: Observable<boolean>;
|
||||
protected hasPremiumPersonally$: Observable<boolean>;
|
||||
protected shouldShowNewDesign$: Observable<boolean>;
|
||||
protected hasSubscription$: Observable<boolean>;
|
||||
protected shouldShowUpgradeDialogOnInit$: Observable<boolean>;
|
||||
protected personalPricingTiers$: Observable<PersonalSubscriptionPricingTier[]>;
|
||||
protected premiumCardData$: Observable<{
|
||||
@@ -84,6 +87,7 @@ export class CloudHostedPremiumComponent {
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
constructor(
|
||||
private accountBillingClient: AccountBillingClient,
|
||||
private accountService: AccountService,
|
||||
private apiService: ApiService,
|
||||
private dialogService: DialogService,
|
||||
@@ -109,27 +113,32 @@ export class CloudHostedPremiumComponent {
|
||||
),
|
||||
);
|
||||
|
||||
this.hasSubscription$ = this.accountService.activeAccount$.pipe(
|
||||
switchMap((account) =>
|
||||
account
|
||||
? from(this.accountBillingClient.getSubscription()).pipe(
|
||||
map((subscription) => !!subscription),
|
||||
catchError(() => of(false)),
|
||||
)
|
||||
: of(false),
|
||||
),
|
||||
);
|
||||
|
||||
this.accountService.activeAccount$
|
||||
.pipe(mapAccountToSubscriber, takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((subscriber) => {
|
||||
this.subscriber = subscriber;
|
||||
});
|
||||
|
||||
this.shouldShowNewDesign$ = combineLatest([
|
||||
this.hasPremiumFromAnyOrganization$,
|
||||
this.hasPremiumPersonally$,
|
||||
]).pipe(map(([hasOrgPremium, hasPersonalPremium]) => !hasOrgPremium && !hasPersonalPremium));
|
||||
|
||||
// redirect to user subscription page if they already have premium personally
|
||||
// redirect to individual vault if they already have premium from an org
|
||||
combineLatest([this.hasPremiumFromAnyOrganization$, this.hasPremiumPersonally$])
|
||||
combineLatest([this.hasSubscription$, this.hasPremiumFromAnyOrganization$])
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
switchMap(([hasPremiumFromOrg, hasPremiumPersonally]) => {
|
||||
if (hasPremiumPersonally) {
|
||||
take(1),
|
||||
switchMap(([hasSubscription, hasPremiumFromAnyOrganization]) => {
|
||||
if (hasSubscription) {
|
||||
return from(this.navigateToSubscriptionPage());
|
||||
}
|
||||
if (hasPremiumFromOrg) {
|
||||
if (hasPremiumFromAnyOrganization) {
|
||||
return from(this.navigateToIndividualVault());
|
||||
}
|
||||
return of(true);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<app-header>
|
||||
@if (!selfHosted) {
|
||||
<bit-tab-nav-bar slot="tabs">
|
||||
<bit-tab-link [route]="(hasPremium$ | async) ? 'user-subscription' : 'premium'">{{
|
||||
"subscription" | i18n
|
||||
}}</bit-tab-link>
|
||||
<bit-tab-link
|
||||
[route]="(showSubscriptionPageLink$ | async) ? 'user-subscription' : 'premium'"
|
||||
>{{ "subscription" | i18n }}</bit-tab-link
|
||||
>
|
||||
<bit-tab-link route="payment-details">{{ "paymentDetails" | i18n }}</bit-tab-link>
|
||||
<bit-tab-link route="billing-history">{{ "billingHistory" | i18n }}</bit-tab-link>
|
||||
</bit-tab-nav-bar>
|
||||
|
||||
@@ -19,7 +19,7 @@ import { AccountBillingClient } from "../clients/account-billing.client";
|
||||
providers: [AccountBillingClient],
|
||||
})
|
||||
export class SubscriptionComponent implements OnInit {
|
||||
hasPremium$: Observable<boolean>;
|
||||
showSubscriptionPageLink$: Observable<boolean>;
|
||||
selfHosted: boolean;
|
||||
|
||||
constructor(
|
||||
@@ -27,9 +27,9 @@ export class SubscriptionComponent implements OnInit {
|
||||
billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
accountService: AccountService,
|
||||
configService: ConfigService,
|
||||
private accountBillingClient: AccountBillingClient,
|
||||
accountBillingClient: AccountBillingClient,
|
||||
) {
|
||||
this.hasPremium$ = combineLatest([
|
||||
this.showSubscriptionPageLink$ = combineLatest([
|
||||
configService.getFeatureFlag$(FeatureFlag.PM29594_UpdateIndividualSubscriptionPage),
|
||||
accountService.activeAccount$,
|
||||
]).pipe(
|
||||
|
||||
@@ -89,18 +89,34 @@ export class AccountSubscriptionComponent {
|
||||
{ initialValue: false },
|
||||
);
|
||||
|
||||
readonly hasPremiumFromAnyOrganization = toSignal(
|
||||
this.accountService.activeAccount$.pipe(
|
||||
switchMap((account) => {
|
||||
if (!account) {
|
||||
return of(false);
|
||||
}
|
||||
return this.billingAccountProfileStateService.hasPremiumFromAnyOrganization$(account.id);
|
||||
}),
|
||||
),
|
||||
{ initialValue: false },
|
||||
);
|
||||
|
||||
readonly subscription = resource({
|
||||
loader: async () => {
|
||||
const redirectToPremiumPage = async (): Promise<null> => {
|
||||
params: () => ({
|
||||
account: this.account(),
|
||||
}),
|
||||
loader: async ({ params: { account } }) => {
|
||||
if (!account) {
|
||||
await this.router.navigate(["/settings/subscription/premium"]);
|
||||
return null;
|
||||
};
|
||||
if (!this.account()) {
|
||||
return await redirectToPremiumPage();
|
||||
}
|
||||
const subscription = await this.accountBillingClient.getSubscription();
|
||||
if (!subscription) {
|
||||
return await redirectToPremiumPage();
|
||||
const hasPremiumFromAnyOrganization = this.hasPremiumFromAnyOrganization();
|
||||
await this.router.navigate([
|
||||
hasPremiumFromAnyOrganization ? "/vault" : "/settings/subscription/premium",
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
return subscription;
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
@let changingPayment = isChangingPaymentMethod();
|
||||
<bit-dialog dialogSize="large" [loading]="loading()">
|
||||
<span bitDialogTitle class="tw-font-medium">{{ upgradeToMessage() }}</span>
|
||||
|
||||
@@ -17,20 +16,18 @@
|
||||
|
||||
<div class="tw-pb-8 !tw-mx-0">
|
||||
<app-display-payment-method-inline
|
||||
#paymentMethodComponent
|
||||
[subscriber]="subscriber()"
|
||||
[paymentMethod]="paymentMethod()"
|
||||
(updated)="handlePaymentMethodUpdate($event)"
|
||||
(changingStateChanged)="handlePaymentMethodChangingStateChange($event)"
|
||||
[externalFormGroup]="formGroup.controls.paymentMethodForm"
|
||||
>
|
||||
</app-display-payment-method-inline>
|
||||
@if (!changingPayment) {
|
||||
<h5 bitTypography="h5" class="tw-pt-4 tw-pb-2">{{ "billingAddress" | i18n }}</h5>
|
||||
<app-enter-billing-address
|
||||
[group]="formGroup.controls.billingAddress"
|
||||
[scenario]="{ type: 'checkout', supportsTaxId: false }"
|
||||
>
|
||||
</app-enter-billing-address>
|
||||
}
|
||||
<h5 bitTypography="h5" class="tw-pt-4 tw-pb-2">{{ "billingAddress" | i18n }}</h5>
|
||||
<app-enter-billing-address
|
||||
[group]="formGroup.controls.billingAddress"
|
||||
[scenario]="{ type: 'checkout', supportsTaxId: false }"
|
||||
>
|
||||
</app-enter-billing-address>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
@@ -46,7 +43,7 @@
|
||||
bitButton
|
||||
bitFormButton
|
||||
buttonType="primary"
|
||||
[disabled]="loading() || !formGroup.valid"
|
||||
[disabled]="loading() || !isFormValid()"
|
||||
type="submit"
|
||||
>
|
||||
{{ "upgrade" | i18n }}
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
ChangeDetectionStrategy,
|
||||
CUSTOM_ELEMENTS_SCHEMA,
|
||||
signal,
|
||||
output,
|
||||
} from "@angular/core";
|
||||
import { Component, input, ChangeDetectionStrategy, signal, output } from "@angular/core";
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing";
|
||||
import { FormControl, FormGroup, Validators } from "@angular/forms";
|
||||
import { FormControl, FormGroup } from "@angular/forms";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
@@ -34,6 +27,7 @@ import { SubscriberBillingClient } from "../../../clients/subscriber-billing.cli
|
||||
import {
|
||||
EnterBillingAddressComponent,
|
||||
DisplayPaymentMethodInlineComponent,
|
||||
EnterPaymentMethodComponent,
|
||||
} from "../../../payment/components";
|
||||
|
||||
import {
|
||||
@@ -46,8 +40,7 @@ import { PremiumOrgUpgradeService } from "./services/premium-org-upgrade.service
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
selector: "billing-cart-summary",
|
||||
template: `<h1>Mock Cart Summary</h1>`,
|
||||
providers: [{ provide: CartSummaryComponent, useClass: MockCartSummaryComponent }],
|
||||
template: "",
|
||||
})
|
||||
class MockCartSummaryComponent {
|
||||
readonly cart = input.required<any>();
|
||||
@@ -59,52 +52,17 @@ class MockCartSummaryComponent {
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
selector: "app-display-payment-method-inline",
|
||||
template: `<h1>Mock Display Payment Method</h1>`,
|
||||
providers: [
|
||||
{
|
||||
provide: DisplayPaymentMethodInlineComponent,
|
||||
useClass: MockDisplayPaymentMethodInlineComponent,
|
||||
},
|
||||
],
|
||||
template: "",
|
||||
})
|
||||
class MockDisplayPaymentMethodInlineComponent {
|
||||
readonly subscriber = input.required<any>();
|
||||
readonly paymentMethod = input<any>();
|
||||
readonly externalFormGroup = input<any>();
|
||||
readonly updated = output<any>();
|
||||
readonly changePaymentMethodClicked = output<void>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
selector: "app-enter-billing-address",
|
||||
template: `<h1>Mock Enter Billing Address</h1>`,
|
||||
providers: [
|
||||
{
|
||||
provide: EnterBillingAddressComponent,
|
||||
useClass: MockEnterBillingAddressComponent,
|
||||
},
|
||||
],
|
||||
})
|
||||
class MockEnterBillingAddressComponent {
|
||||
readonly scenario = input.required<any>();
|
||||
readonly group = input.required<any>();
|
||||
|
||||
static getFormGroup = () =>
|
||||
new FormGroup({
|
||||
country: new FormControl<string>("", {
|
||||
nonNullable: true,
|
||||
validators: [Validators.required],
|
||||
}),
|
||||
postalCode: new FormControl<string>("", {
|
||||
nonNullable: true,
|
||||
validators: [Validators.required],
|
||||
}),
|
||||
line1: new FormControl<string | null>(null),
|
||||
line2: new FormControl<string | null>(null),
|
||||
city: new FormControl<string | null>(null),
|
||||
state: new FormControl<string | null>(null),
|
||||
taxId: new FormControl<string | null>(null),
|
||||
});
|
||||
isChangingPayment = jest.fn().mockReturnValue(false);
|
||||
getTokenizedPaymentMethod = jest.fn().mockResolvedValue({ token: "test-token" });
|
||||
}
|
||||
|
||||
describe("PremiumOrgUpgradePaymentComponent", () => {
|
||||
@@ -169,15 +127,14 @@ describe("PremiumOrgUpgradePaymentComponent", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
mockAccountBillingClient.upgradePremiumToOrganization.mockResolvedValue(undefined);
|
||||
mockPremiumOrgUpgradeService.upgradeToOrganization.mockResolvedValue(undefined);
|
||||
mockPremiumOrgUpgradeService.previewProratedInvoice.mockResolvedValue({
|
||||
tax: 5.0,
|
||||
total: 53.0,
|
||||
credit: 10.0,
|
||||
newPlanProratedMonths: 1,
|
||||
});
|
||||
mockOrganizationService.organizations$.mockReturnValue(of([]));
|
||||
|
||||
// Set up minimal mocks needed for component initialization
|
||||
mockSubscriptionPricingService.getBusinessSubscriptionPricingTiers$.mockReturnValue(
|
||||
of([mockTeamsPlan]),
|
||||
);
|
||||
mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue(
|
||||
of([mockFamiliesPlan]),
|
||||
);
|
||||
mockAccountService.activeAccount$ = of(mockAccount);
|
||||
mockSubscriberBillingClient.getPaymentMethod.mockResolvedValue({
|
||||
type: "card",
|
||||
@@ -185,18 +142,46 @@ describe("PremiumOrgUpgradePaymentComponent", () => {
|
||||
last4: "4242",
|
||||
expiration: "12/2025",
|
||||
});
|
||||
mockOrganizationService.organizations$.mockReturnValue(of([]));
|
||||
mockPremiumOrgUpgradeService.previewProratedInvoice.mockResolvedValue({
|
||||
tax: 5.0,
|
||||
total: 53.0,
|
||||
credit: 10.0,
|
||||
newPlanProratedMonths: 1,
|
||||
});
|
||||
|
||||
mockSubscriptionPricingService.getBusinessSubscriptionPricingTiers$.mockReturnValue(
|
||||
of([mockTeamsPlan]),
|
||||
// Mock static form group methods (required for component creation)
|
||||
jest.spyOn(EnterPaymentMethodComponent, "getFormGroup").mockReturnValue(
|
||||
new FormGroup({
|
||||
type: new FormControl<string>("card", { nonNullable: true }),
|
||||
bankAccount: new FormGroup({
|
||||
routingNumber: new FormControl<string>("", { nonNullable: true }),
|
||||
accountNumber: new FormControl<string>("", { nonNullable: true }),
|
||||
accountHolderName: new FormControl<string>("", { nonNullable: true }),
|
||||
accountHolderType: new FormControl<string>("", { nonNullable: true }),
|
||||
}),
|
||||
billingAddress: new FormGroup({
|
||||
country: new FormControl<string>("", { nonNullable: true }),
|
||||
postalCode: new FormControl<string>("", { nonNullable: true }),
|
||||
}),
|
||||
}) as any,
|
||||
);
|
||||
mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue(
|
||||
of([mockFamiliesPlan]),
|
||||
|
||||
jest.spyOn(EnterBillingAddressComponent, "getFormGroup").mockReturnValue(
|
||||
new FormGroup({
|
||||
country: new FormControl<string>("", { nonNullable: true }),
|
||||
postalCode: new FormControl<string>("", { nonNullable: true }),
|
||||
line1: new FormControl<string | null>(null),
|
||||
line2: new FormControl<string | null>(null),
|
||||
city: new FormControl<string | null>(null),
|
||||
state: new FormControl<string | null>(null),
|
||||
taxId: new FormControl<string | null>(null),
|
||||
}),
|
||||
);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PremiumOrgUpgradePaymentComponent],
|
||||
providers: [
|
||||
{ provide: PremiumOrgUpgradeService, useValue: mockPremiumOrgUpgradeService },
|
||||
{
|
||||
provide: SubscriptionPricingServiceAbstraction,
|
||||
useValue: mockSubscriptionPricingService,
|
||||
@@ -209,33 +194,23 @@ describe("PremiumOrgUpgradePaymentComponent", () => {
|
||||
{ provide: SubscriberBillingClient, useValue: mockSubscriberBillingClient },
|
||||
{ provide: AccountService, useValue: mockAccountService },
|
||||
{ provide: ApiService, useValue: mockApiService },
|
||||
{ provide: OrganizationService, useValue: mockOrganizationService },
|
||||
{
|
||||
provide: KeyService,
|
||||
useValue: {
|
||||
makeOrgKey: jest.fn().mockResolvedValue(["encrypted-key", "decrypted-key"]),
|
||||
},
|
||||
useValue: { makeOrgKey: jest.fn().mockResolvedValue(["encrypted-key", "decrypted-key"]) },
|
||||
},
|
||||
{
|
||||
provide: SyncService,
|
||||
useValue: { fullSync: jest.fn().mockResolvedValue(undefined) },
|
||||
},
|
||||
{ provide: OrganizationService, useValue: mockOrganizationService },
|
||||
{ provide: SyncService, useValue: { fullSync: jest.fn().mockResolvedValue(undefined) } },
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
})
|
||||
.overrideComponent(PremiumOrgUpgradePaymentComponent, {
|
||||
add: {
|
||||
imports: [
|
||||
MockEnterBillingAddressComponent,
|
||||
MockDisplayPaymentMethodInlineComponent,
|
||||
MockCartSummaryComponent,
|
||||
],
|
||||
},
|
||||
remove: {
|
||||
imports: [
|
||||
EnterBillingAddressComponent,
|
||||
DisplayPaymentMethodInlineComponent,
|
||||
CartSummaryComponent,
|
||||
imports: [DisplayPaymentMethodInlineComponent, CartSummaryComponent],
|
||||
providers: [PremiumOrgUpgradeService],
|
||||
},
|
||||
add: {
|
||||
imports: [MockDisplayPaymentMethodInlineComponent, MockCartSummaryComponent],
|
||||
providers: [
|
||||
{ provide: PremiumOrgUpgradeService, useValue: mockPremiumOrgUpgradeService },
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -248,7 +223,6 @@ describe("PremiumOrgUpgradePaymentComponent", () => {
|
||||
fixture.componentRef.setInput("account", mockAccount);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Wait for ngOnInit to complete
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
@@ -262,53 +236,66 @@ describe("PremiumOrgUpgradePaymentComponent", () => {
|
||||
expect(component["upgradeToMessage"]()).toContain("upgradeToTeams");
|
||||
});
|
||||
|
||||
it("should handle invalid plan id that doesn't exist in pricing tiers", async () => {
|
||||
// Create a fresh component with an invalid plan ID from the start
|
||||
const newFixture = TestBed.createComponent(PremiumOrgUpgradePaymentComponent);
|
||||
const newComponent = newFixture.componentInstance;
|
||||
describe("Component Initialization with Different Plans", () => {
|
||||
it("should handle invalid plan id that doesn't exist in pricing tiers", async () => {
|
||||
// Create a fresh component with an invalid plan ID from the start
|
||||
const newFixture = TestBed.createComponent(PremiumOrgUpgradePaymentComponent);
|
||||
const newComponent = newFixture.componentInstance;
|
||||
|
||||
newFixture.componentRef.setInput(
|
||||
"selectedPlanId",
|
||||
"non-existent-plan" as BusinessSubscriptionPricingTierId,
|
||||
);
|
||||
newFixture.componentRef.setInput("account", mockAccount);
|
||||
newFixture.detectChanges();
|
||||
newFixture.componentRef.setInput(
|
||||
"selectedPlanId",
|
||||
"non-existent-plan" as BusinessSubscriptionPricingTierId,
|
||||
);
|
||||
newFixture.componentRef.setInput("account", mockAccount);
|
||||
newFixture.detectChanges();
|
||||
|
||||
await newFixture.whenStable();
|
||||
await newFixture.whenStable();
|
||||
|
||||
expect(newComponent["selectedPlan"]()).toBeNull();
|
||||
expect(newComponent["selectedPlan"]()).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle invoice preview errors gracefully", fakeAsync(() => {
|
||||
mockPremiumOrgUpgradeService.previewProratedInvoice.mockRejectedValue(
|
||||
new Error("Network error"),
|
||||
);
|
||||
|
||||
// Component should still render and be usable even when invoice preview fails
|
||||
fixture = TestBed.createComponent(PremiumOrgUpgradePaymentComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput("selectedPlanId", "teams" as BusinessSubscriptionPricingTierId);
|
||||
fixture.componentRef.setInput("account", mockAccount);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component).toBeTruthy();
|
||||
expect(component["selectedPlan"]()).not.toBeNull();
|
||||
expect(mockToastService.showToast).not.toHaveBeenCalled();
|
||||
}));
|
||||
});
|
||||
|
||||
it("should handle invoice preview errors gracefully", fakeAsync(() => {
|
||||
mockPremiumOrgUpgradeService.previewProratedInvoice.mockRejectedValue(
|
||||
new Error("Network error"),
|
||||
);
|
||||
|
||||
// Component should still render and be usable even when invoice preview fails
|
||||
fixture = TestBed.createComponent(PremiumOrgUpgradePaymentComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput("selectedPlanId", "teams" as BusinessSubscriptionPricingTierId);
|
||||
fixture.componentRef.setInput("account", mockAccount);
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(component).toBeTruthy();
|
||||
expect(component["selectedPlan"]()).not.toBeNull();
|
||||
expect(mockToastService.showToast).not.toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
describe("submit", () => {
|
||||
beforeEach(() => {
|
||||
// Set up upgrade service mock for submit tests
|
||||
mockPremiumOrgUpgradeService.upgradeToOrganization.mockResolvedValue("new-org-id");
|
||||
});
|
||||
|
||||
it("should successfully upgrade to organization", async () => {
|
||||
const completeSpy = jest.spyOn(component["complete"], "emit");
|
||||
|
||||
// Mock processUpgrade to bypass form validation
|
||||
jest.spyOn(component as any, "processUpgrade").mockResolvedValue({
|
||||
status: PremiumOrgUpgradePaymentStatus.UpgradedToTeams,
|
||||
organizationId: null,
|
||||
});
|
||||
|
||||
component["formGroup"].setValue({
|
||||
organizationName: "My New Org",
|
||||
paymentMethodForm: {
|
||||
type: "card",
|
||||
bankAccount: {
|
||||
routingNumber: "",
|
||||
accountNumber: "",
|
||||
accountHolderName: "",
|
||||
accountHolderType: "",
|
||||
},
|
||||
billingAddress: {
|
||||
country: "",
|
||||
postalCode: "",
|
||||
},
|
||||
},
|
||||
billingAddress: {
|
||||
country: "US",
|
||||
postalCode: "90210",
|
||||
@@ -322,13 +309,25 @@ describe("PremiumOrgUpgradePaymentComponent", () => {
|
||||
|
||||
await component["submit"]();
|
||||
|
||||
expect(mockPremiumOrgUpgradeService.upgradeToOrganization).toHaveBeenCalledWith(
|
||||
mockAccount,
|
||||
"My New Org",
|
||||
component["selectedPlan"](),
|
||||
expect.objectContaining({
|
||||
country: "US",
|
||||
postalCode: "90210",
|
||||
line1: "123 Main St",
|
||||
city: "Beverly Hills",
|
||||
state: "CA",
|
||||
}),
|
||||
);
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "success",
|
||||
message: "plansUpdated",
|
||||
});
|
||||
expect(completeSpy).toHaveBeenCalledWith({
|
||||
status: PremiumOrgUpgradePaymentStatus.UpgradedToTeams,
|
||||
organizationId: null,
|
||||
organizationId: "new-org-id",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -340,6 +339,19 @@ describe("PremiumOrgUpgradePaymentComponent", () => {
|
||||
|
||||
component["formGroup"].setValue({
|
||||
organizationName: "My New Org",
|
||||
paymentMethodForm: {
|
||||
type: "card",
|
||||
bankAccount: {
|
||||
routingNumber: "",
|
||||
accountNumber: "",
|
||||
accountHolderName: "",
|
||||
accountHolderType: "",
|
||||
},
|
||||
billingAddress: {
|
||||
country: "",
|
||||
postalCode: "",
|
||||
},
|
||||
},
|
||||
billingAddress: {
|
||||
country: "US",
|
||||
postalCode: "90210",
|
||||
@@ -469,7 +481,8 @@ describe("PremiumOrgUpgradePaymentComponent", () => {
|
||||
|
||||
describe("processUpgrade", () => {
|
||||
beforeEach(() => {
|
||||
// Set paymentMethod signal for these tests
|
||||
// Set up mocks specific to processUpgrade tests
|
||||
mockPremiumOrgUpgradeService.upgradeToOrganization.mockResolvedValue("org-id-123");
|
||||
component["paymentMethod"].set({
|
||||
type: "card",
|
||||
brand: "visa",
|
||||
@@ -501,6 +514,133 @@ describe("PremiumOrgUpgradePaymentComponent", () => {
|
||||
|
||||
await expect(component["processUpgrade"]()).rejects.toThrow("Organization name is required");
|
||||
});
|
||||
|
||||
it("should update payment method when isChangingPayment returns true", async () => {
|
||||
const mockPaymentMethodComponent = {
|
||||
isChangingPayment: jest.fn().mockReturnValue(true),
|
||||
getTokenizedPaymentMethod: jest.fn().mockResolvedValue({ token: "new-token-123" }),
|
||||
};
|
||||
jest
|
||||
.spyOn(component, "paymentMethodComponent")
|
||||
.mockReturnValue(mockPaymentMethodComponent as any);
|
||||
|
||||
const mockSubscriber = { id: "subscriber-123" };
|
||||
component["subscriber"].set(mockSubscriber as any);
|
||||
component["selectedPlan"].set({
|
||||
tier: "teams" as BusinessSubscriptionPricingTierId,
|
||||
details: mockTeamsPlan,
|
||||
cost: 48,
|
||||
});
|
||||
|
||||
component["formGroup"].patchValue({
|
||||
organizationName: "Test Organization",
|
||||
billingAddress: {
|
||||
country: "US",
|
||||
postalCode: "12345",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await component["processUpgrade"]();
|
||||
|
||||
expect(mockPaymentMethodComponent.isChangingPayment).toHaveBeenCalled();
|
||||
expect(mockPaymentMethodComponent.getTokenizedPaymentMethod).toHaveBeenCalled();
|
||||
expect(mockSubscriberBillingClient.updatePaymentMethod).toHaveBeenCalledWith(
|
||||
mockSubscriber,
|
||||
{ token: "new-token-123" },
|
||||
expect.objectContaining({
|
||||
country: "US",
|
||||
postalCode: "12345",
|
||||
}),
|
||||
);
|
||||
expect(mockPremiumOrgUpgradeService.upgradeToOrganization).toHaveBeenCalledWith(
|
||||
mockAccount,
|
||||
"Test Organization",
|
||||
expect.objectContaining({ tier: "teams" }),
|
||||
expect.objectContaining({
|
||||
country: "US",
|
||||
postalCode: "12345",
|
||||
}),
|
||||
);
|
||||
expect(result.organizationId).toBe("org-id-123");
|
||||
});
|
||||
|
||||
it("should not update payment method when isChangingPayment returns false", async () => {
|
||||
const mockPaymentMethodComponent = {
|
||||
isChangingPayment: jest.fn().mockReturnValue(false),
|
||||
getTokenizedPaymentMethod: jest.fn(),
|
||||
};
|
||||
jest
|
||||
.spyOn(component, "paymentMethodComponent")
|
||||
.mockReturnValue(mockPaymentMethodComponent as any);
|
||||
component["selectedPlan"].set({
|
||||
tier: "teams" as BusinessSubscriptionPricingTierId,
|
||||
details: mockTeamsPlan,
|
||||
cost: 48,
|
||||
});
|
||||
|
||||
component["formGroup"].patchValue({
|
||||
organizationName: "Test Organization",
|
||||
billingAddress: {
|
||||
country: "US",
|
||||
postalCode: "12345",
|
||||
},
|
||||
});
|
||||
|
||||
await component["processUpgrade"]();
|
||||
|
||||
expect(mockPaymentMethodComponent.isChangingPayment).toHaveBeenCalled();
|
||||
expect(mockPaymentMethodComponent.getTokenizedPaymentMethod).not.toHaveBeenCalled();
|
||||
expect(mockSubscriberBillingClient.updatePaymentMethod).not.toHaveBeenCalled();
|
||||
expect(mockPremiumOrgUpgradeService.upgradeToOrganization).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle null paymentMethodComponent gracefully", async () => {
|
||||
jest.spyOn(component, "paymentMethodComponent").mockReturnValue(null as any);
|
||||
component["selectedPlan"].set({
|
||||
tier: "teams" as BusinessSubscriptionPricingTierId,
|
||||
details: mockTeamsPlan,
|
||||
cost: 48,
|
||||
});
|
||||
|
||||
component["formGroup"].patchValue({
|
||||
organizationName: "Test Organization",
|
||||
billingAddress: {
|
||||
country: "US",
|
||||
postalCode: "12345",
|
||||
},
|
||||
});
|
||||
|
||||
await component["processUpgrade"]();
|
||||
|
||||
expect(mockSubscriberBillingClient.updatePaymentMethod).not.toHaveBeenCalled();
|
||||
expect(mockPremiumOrgUpgradeService.upgradeToOrganization).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw error when payment method is null and user is not changing payment", async () => {
|
||||
const mockPaymentMethodComponent = {
|
||||
isChangingPayment: jest.fn().mockReturnValue(false),
|
||||
getTokenizedPaymentMethod: jest.fn(),
|
||||
};
|
||||
jest
|
||||
.spyOn(component, "paymentMethodComponent")
|
||||
.mockReturnValue(mockPaymentMethodComponent as any);
|
||||
component["paymentMethod"].set(null);
|
||||
component["selectedPlan"].set({
|
||||
tier: "teams" as BusinessSubscriptionPricingTierId,
|
||||
details: mockTeamsPlan,
|
||||
cost: 48,
|
||||
});
|
||||
|
||||
component["formGroup"].patchValue({
|
||||
organizationName: "Test Organization",
|
||||
billingAddress: {
|
||||
country: "US",
|
||||
postalCode: "12345",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(component["processUpgrade"]()).rejects.toThrow("Payment method is required");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Plan Membership Messages", () => {
|
||||
@@ -545,6 +685,19 @@ describe("PremiumOrgUpgradePaymentComponent", () => {
|
||||
|
||||
component["formGroup"].setValue({
|
||||
organizationName: "My New Org",
|
||||
paymentMethodForm: {
|
||||
type: "card",
|
||||
bankAccount: {
|
||||
routingNumber: "",
|
||||
accountNumber: "",
|
||||
accountHolderName: "",
|
||||
accountHolderType: "",
|
||||
},
|
||||
billingAddress: {
|
||||
country: "",
|
||||
postalCode: "",
|
||||
},
|
||||
},
|
||||
billingAddress: {
|
||||
country: "US",
|
||||
postalCode: "90210",
|
||||
@@ -573,4 +726,25 @@ describe("PremiumOrgUpgradePaymentComponent", () => {
|
||||
expect(goBackSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Payment Method Initialization", () => {
|
||||
it("should set subscriber and payment method signals on init", async () => {
|
||||
const subscriber = component["subscriber"]();
|
||||
expect(subscriber).toEqual(
|
||||
expect.objectContaining({
|
||||
type: "account",
|
||||
data: expect.objectContaining({
|
||||
id: mockAccount.id,
|
||||
email: mockAccount.email,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(component["paymentMethod"]()).toEqual({
|
||||
type: "card",
|
||||
brand: "visa",
|
||||
last4: "4242",
|
||||
expiration: "12/2025",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
from,
|
||||
defer,
|
||||
map,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
@@ -48,6 +47,7 @@ import {
|
||||
EnterBillingAddressComponent,
|
||||
getBillingAddressFromForm,
|
||||
DisplayPaymentMethodInlineComponent,
|
||||
EnterPaymentMethodComponent,
|
||||
} from "../../../payment/components";
|
||||
import { MaskedPaymentMethod } from "../../../payment/types";
|
||||
import { BitwardenSubscriber, mapAccountToSubscriber } from "../../../types";
|
||||
@@ -110,13 +110,17 @@ export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit
|
||||
PersonalSubscriptionPricingTierId | BusinessSubscriptionPricingTierId
|
||||
>();
|
||||
protected readonly account = input.required<Account>();
|
||||
|
||||
protected goBack = output<void>();
|
||||
protected complete = output<PremiumOrgUpgradePaymentResult>();
|
||||
|
||||
readonly cartSummaryComponent = viewChild.required(CartSummaryComponent);
|
||||
readonly cartSummaryComponent = viewChild.required<CartSummaryComponent>("cartSummaryComponent");
|
||||
readonly paymentMethodComponent =
|
||||
viewChild.required<DisplayPaymentMethodInlineComponent>("paymentMethodComponent");
|
||||
|
||||
protected formGroup = new FormGroup({
|
||||
organizationName: new FormControl<string>("", [Validators.required]),
|
||||
paymentMethodForm: EnterPaymentMethodComponent.getFormGroup(),
|
||||
billingAddress: EnterBillingAddressComponent.getFormGroup(),
|
||||
});
|
||||
|
||||
@@ -127,12 +131,6 @@ export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit
|
||||
// Signals for payment method
|
||||
protected readonly paymentMethod = signal<MaskedPaymentMethod | null>(null);
|
||||
protected readonly subscriber = signal<BitwardenSubscriber | null>(null);
|
||||
/**
|
||||
* Indicates whether the payment method is currently being changed.
|
||||
* This is used to disable the submit button while a payment method change is in progress.
|
||||
* or to hide other UI elements as needed.
|
||||
*/
|
||||
protected readonly isChangingPaymentMethod = signal(false);
|
||||
|
||||
protected readonly planMembershipMessage = computed<string>(
|
||||
() => this.PLAN_MEMBERSHIP_MESSAGES[this.selectedPlanId()] ?? "",
|
||||
@@ -252,14 +250,13 @@ export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit
|
||||
map((paymentMethod) => ({ subscriber, paymentMethod })),
|
||||
),
|
||||
),
|
||||
tap(({ subscriber, paymentMethod }) => {
|
||||
this.subscriber.set(subscriber);
|
||||
this.paymentMethod.set(paymentMethod);
|
||||
this.loading.set(false);
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe();
|
||||
.subscribe(({ subscriber, paymentMethod }) => {
|
||||
this.subscriber.set(subscriber);
|
||||
this.paymentMethod.set(paymentMethod);
|
||||
this.loading.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
@@ -267,24 +264,8 @@ export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit
|
||||
cartSummaryComponent.isExpanded.set(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the payment method when changed through the DisplayPaymentMethodComponent.
|
||||
* @param newPaymentMethod The updated payment method details
|
||||
*/
|
||||
handlePaymentMethodUpdate(newPaymentMethod: MaskedPaymentMethod) {
|
||||
this.paymentMethod.set(newPaymentMethod);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles changes to the payment method changing state.
|
||||
* @param isChanging Whether the payment method is currently being changed
|
||||
*/
|
||||
handlePaymentMethodChangingStateChange(isChanging: boolean) {
|
||||
this.isChangingPaymentMethod.set(isChanging);
|
||||
}
|
||||
|
||||
protected submit = async (): Promise<void> => {
|
||||
if (!this.formGroup.valid) {
|
||||
if (!this.isFormValid()) {
|
||||
this.formGroup.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
@@ -312,11 +293,24 @@ export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit
|
||||
private async processUpgrade(): Promise<PremiumOrgUpgradePaymentResult> {
|
||||
const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress);
|
||||
const organizationName = this.formGroup.value?.organizationName;
|
||||
|
||||
if (!billingAddress.country || !billingAddress.postalCode) {
|
||||
throw new Error("Billing address is incomplete");
|
||||
}
|
||||
|
||||
const paymentMethodComponent = this.paymentMethodComponent();
|
||||
// If the user is changing their payment method, process that first
|
||||
if (paymentMethodComponent && paymentMethodComponent.isChangingPayment()) {
|
||||
const newPaymentMethod = await paymentMethodComponent.getTokenizedPaymentMethod();
|
||||
await this.subscriberBillingClient.updatePaymentMethod(
|
||||
this.subscriber()!,
|
||||
newPaymentMethod,
|
||||
billingAddress,
|
||||
);
|
||||
} else if (!this.paymentMethod()) {
|
||||
// If user is not changing payment method but has no payment method on file
|
||||
throw new Error("Payment method is required");
|
||||
}
|
||||
|
||||
if (!organizationName) {
|
||||
throw new Error("Organization name is required");
|
||||
}
|
||||
@@ -440,11 +434,28 @@ export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the form is valid.
|
||||
*/
|
||||
protected isFormValid(): boolean {
|
||||
const isParentFormValid =
|
||||
this.formGroup.controls.organizationName.valid &&
|
||||
this.formGroup.controls.billingAddress.valid;
|
||||
|
||||
const paymentMethodComponent = this.paymentMethodComponent();
|
||||
const isChangingPayment = paymentMethodComponent?.isChangingPayment();
|
||||
if (paymentMethodComponent && isChangingPayment) {
|
||||
return isParentFormValid && paymentMethodComponent.isFormValid();
|
||||
}
|
||||
|
||||
return isParentFormValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the invoice preview based on the current form state.
|
||||
*/
|
||||
private refreshInvoicePreview$(): Observable<InvoicePreview> {
|
||||
if (this.formGroup.invalid || !this.selectedPlan()) {
|
||||
if (!this.isFormValid()) {
|
||||
return of(this.getEmptyInvoicePreview());
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -113,8 +113,6 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() currentPlan: PlanResponse;
|
||||
|
||||
selectedFile: File;
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input()
|
||||
@@ -675,9 +673,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
|
||||
const collectionCt = collection.encryptedString;
|
||||
const orgKeys = await this.keyService.makeKeyPair(orgKey[1]);
|
||||
|
||||
orgId = this.selfHosted
|
||||
? await this.createSelfHosted(key, collectionCt, orgKeys)
|
||||
: await this.createCloudHosted(key, collectionCt, orgKeys, orgKey[1], activeUserId);
|
||||
orgId = await this.createCloudHosted(key, collectionCt, orgKeys, orgKey[1], activeUserId);
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
@@ -953,27 +949,6 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private async createSelfHosted(key: string, collectionCt: string, orgKeys: [string, EncString]) {
|
||||
if (!this.selectedFile) {
|
||||
throw new Error(this.i18nService.t("selectFile"));
|
||||
}
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append("license", this.selectedFile);
|
||||
fd.append("key", key);
|
||||
fd.append("collectionName", collectionCt);
|
||||
const response = await this.organizationApiService.createLicense(fd);
|
||||
const orgId = response.id;
|
||||
|
||||
await this.apiService.refreshIdentityToken();
|
||||
|
||||
// Org Keys live outside of the OrganizationLicense - add the keys to the org here
|
||||
const request = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString);
|
||||
await this.organizationApiService.updateKeys(orgId, request);
|
||||
|
||||
return orgId;
|
||||
}
|
||||
|
||||
private billingSubLabelText(): string {
|
||||
const selectedPlan = this.selectedPlan;
|
||||
const price =
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
signal,
|
||||
viewChild,
|
||||
} from "@angular/core";
|
||||
import { FormGroup } from "@angular/forms";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { ToastService, IconComponent } from "@bitwarden/components";
|
||||
@@ -90,26 +91,28 @@ import { EnterPaymentMethodComponent } from "./enter-payment-method.component";
|
||||
} @else {
|
||||
<app-enter-payment-method
|
||||
#enterPaymentMethodComponent
|
||||
[includeBillingAddress]="true"
|
||||
[includeBillingAddress]="false"
|
||||
[group]="formGroup"
|
||||
[showBankAccount]="true"
|
||||
[showAccountCredit]="false"
|
||||
>
|
||||
</app-enter-payment-method>
|
||||
<div class="tw-mt-4 tw-flex tw-gap-2">
|
||||
<button
|
||||
bitLink
|
||||
linkType="default"
|
||||
type="button"
|
||||
(click)="submit()"
|
||||
[disabled]="formGroup.invalid"
|
||||
>
|
||||
{{ "save" | i18n }}
|
||||
</button>
|
||||
<button bitLink linkType="subtle" type="button" (click)="cancel()">
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
@if (showFormButtons()) {
|
||||
<div class="tw-mt-4 tw-flex tw-gap-2">
|
||||
<button
|
||||
bitLink
|
||||
linkType="default"
|
||||
type="button"
|
||||
(click)="submit()"
|
||||
[disabled]="formGroup.invalid"
|
||||
>
|
||||
{{ "save" | i18n }}
|
||||
</button>
|
||||
<button bitLink linkType="subtle" type="button" (click)="cancel()">
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</bit-section>
|
||||
`,
|
||||
@@ -120,51 +123,93 @@ import { EnterPaymentMethodComponent } from "./enter-payment-method.component";
|
||||
export class DisplayPaymentMethodInlineComponent {
|
||||
readonly subscriber = input.required<BitwardenSubscriber>();
|
||||
readonly paymentMethod = input.required<MaskedPaymentMethod | null>();
|
||||
readonly updated = output<MaskedPaymentMethod>();
|
||||
readonly changingStateChanged = output<boolean>();
|
||||
readonly externalFormGroup = input<FormGroup | null>(null);
|
||||
|
||||
protected formGroup = EnterPaymentMethodComponent.getFormGroup();
|
||||
readonly updated = output<MaskedPaymentMethod>();
|
||||
|
||||
protected formGroup: FormGroup;
|
||||
|
||||
private readonly enterPaymentMethodComponent = viewChild<EnterPaymentMethodComponent>(
|
||||
EnterPaymentMethodComponent,
|
||||
);
|
||||
|
||||
protected readonly isChangingPayment = signal(false);
|
||||
readonly isChangingPayment = signal(false);
|
||||
|
||||
protected readonly cardBrandIcon = computed(() => getCardBrandIcon(this.paymentMethod()));
|
||||
|
||||
// Show submit buttons only when component is managing its own form (no external form provided)
|
||||
protected readonly showFormButtons = computed(() => this.externalFormGroup() === null);
|
||||
|
||||
private readonly billingClient = inject(SubscriberBillingClient);
|
||||
private readonly i18nService = inject(I18nService);
|
||||
private readonly toastService = inject(ToastService);
|
||||
private readonly logService = inject(LogService);
|
||||
|
||||
constructor() {
|
||||
// Use external form group if provided, otherwise create our own
|
||||
this.formGroup = this.externalFormGroup() ?? EnterPaymentMethodComponent.getFormGroup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates the payment method change process by displaying the inline form.
|
||||
*/
|
||||
protected changePaymentMethod = async (): Promise<void> => {
|
||||
this.isChangingPayment.set(true);
|
||||
this.changingStateChanged.emit(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Public method to get tokenized payment method data.
|
||||
* Use this when parent component handles submission.
|
||||
* Parent is responsible for handling billing address separately.
|
||||
* @returns Promise with tokenized payment method
|
||||
*/
|
||||
async getTokenizedPaymentMethod(): Promise<any> {
|
||||
if (!this.formGroup.valid) {
|
||||
this.formGroup.markAllAsTouched();
|
||||
throw new Error("Form is invalid");
|
||||
}
|
||||
|
||||
const component = this.enterPaymentMethodComponent();
|
||||
if (!component) {
|
||||
throw new Error("Payment method component not found");
|
||||
}
|
||||
|
||||
const paymentMethod = await component.tokenize();
|
||||
if (!paymentMethod) {
|
||||
throw new Error("Failed to tokenize payment method");
|
||||
}
|
||||
|
||||
return paymentMethod;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the form and returns whether it's ready for submission.
|
||||
* Used when parent component handles submission to determine button state.
|
||||
*/
|
||||
isFormValid(): boolean {
|
||||
const enterPaymentMethodComponent = this.enterPaymentMethodComponent();
|
||||
if (enterPaymentMethodComponent) {
|
||||
return this.enterPaymentMethodComponent()!.validate();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public method to reset the form and exit edit mode.
|
||||
* Use this after parent successfully handles the update.
|
||||
*/
|
||||
resetForm(): void {
|
||||
this.formGroup.reset();
|
||||
this.isChangingPayment.set(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits the payment method update form.
|
||||
* Validates the form, tokenizes the payment method, and sends the update request.
|
||||
*/
|
||||
protected submit = async (): Promise<void> => {
|
||||
try {
|
||||
if (!this.formGroup.valid) {
|
||||
this.formGroup.markAllAsTouched();
|
||||
throw new Error("Form is invalid");
|
||||
}
|
||||
|
||||
const component = this.enterPaymentMethodComponent();
|
||||
if (!component) {
|
||||
throw new Error("Payment method component not found");
|
||||
}
|
||||
|
||||
const paymentMethod = await component.tokenize();
|
||||
if (!paymentMethod) {
|
||||
throw new Error("Failed to tokenize payment method");
|
||||
}
|
||||
const paymentMethod = await this.getTokenizedPaymentMethod();
|
||||
|
||||
const billingAddress =
|
||||
this.formGroup.value.type !== TokenizablePaymentMethods.payPal
|
||||
@@ -201,9 +246,7 @@ export class DisplayPaymentMethodInlineComponent {
|
||||
message: this.i18nService.t("paymentMethodUpdated"),
|
||||
});
|
||||
this.updated.emit(result.value);
|
||||
this.isChangingPayment.set(false);
|
||||
this.changingStateChanged.emit(false);
|
||||
this.formGroup.reset();
|
||||
this.resetForm();
|
||||
break;
|
||||
}
|
||||
case "error": {
|
||||
@@ -223,8 +266,6 @@ export class DisplayPaymentMethodInlineComponent {
|
||||
* Cancels the inline editing and resets the form.
|
||||
*/
|
||||
protected cancel = (): void => {
|
||||
this.formGroup.reset();
|
||||
this.changingStateChanged.emit(false);
|
||||
this.isChangingPayment.set(false);
|
||||
this.resetForm();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,16 +44,9 @@ import {
|
||||
InternalUserDecryptionOptionsServiceAbstraction,
|
||||
LoginEmailService,
|
||||
} from "@bitwarden/auth/common";
|
||||
import {
|
||||
AutomaticUserConfirmationService,
|
||||
DefaultAutomaticUserConfirmationService,
|
||||
} from "@bitwarden/auto-confirm";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import {
|
||||
InternalOrganizationServiceAbstraction,
|
||||
OrganizationService,
|
||||
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
|
||||
import {
|
||||
InternalPolicyService,
|
||||
@@ -67,6 +60,8 @@ import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/ma
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction";
|
||||
import { NoopAuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/noop-auth-request-answering.service";
|
||||
import { ChangeEmailService } from "@bitwarden/common/auth/services/change-email/change-email.service";
|
||||
import { DefaultChangeEmailService } from "@bitwarden/common/auth/services/change-email/default-change-email.service";
|
||||
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
@@ -133,6 +128,7 @@ import {
|
||||
SessionTimeoutSettingsComponentService,
|
||||
} from "@bitwarden/key-management-ui";
|
||||
import { SerializedMemoryStorageService } from "@bitwarden/storage-core";
|
||||
import { UserCryptoManagementModule } from "@bitwarden/user-crypto-management";
|
||||
import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault";
|
||||
import { WebOrganizationInviteService } from "@bitwarden/web-vault/app/auth/core/services/organization-invite/web-organization-invite.service";
|
||||
import { WebVaultPremiumUpgradePromptService } from "@bitwarden/web-vault/app/vault/services/web-premium-upgrade-prompt.service";
|
||||
@@ -373,19 +369,6 @@ const safeProviders: SafeProvider[] = [
|
||||
I18nServiceAbstraction,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: AutomaticUserConfirmationService,
|
||||
useClass: DefaultAutomaticUserConfirmationService,
|
||||
deps: [
|
||||
ConfigService,
|
||||
ApiService,
|
||||
OrganizationUserService,
|
||||
StateProvider,
|
||||
InternalOrganizationServiceAbstraction,
|
||||
OrganizationUserApiService,
|
||||
PolicyService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: SdkLoadService,
|
||||
useClass: flagEnabled("sdk") ? WebSdkLoadService : NoopSdkLoadService,
|
||||
@@ -513,11 +496,22 @@ const safeProviders: SafeProvider[] = [
|
||||
ConfigService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: ChangeEmailService,
|
||||
useClass: DefaultChangeEmailService,
|
||||
deps: [
|
||||
ConfigService,
|
||||
InternalMasterPasswordServiceAbstraction,
|
||||
KdfConfigService,
|
||||
ApiService,
|
||||
KeyServiceAbstraction,
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
declarations: [],
|
||||
imports: [CommonModule, JslibServicesModule, GeneratorServicesModule],
|
||||
imports: [CommonModule, JslibServicesModule, UserCryptoManagementModule, GeneratorServicesModule],
|
||||
// Do not register your dependency here! Add it to the typesafeProviders array using the helper function
|
||||
providers: safeProviders,
|
||||
})
|
||||
|
||||
@@ -355,6 +355,13 @@ export class EventService {
|
||||
this.getShortId(ev.organizationUserId),
|
||||
);
|
||||
break;
|
||||
case EventType.OrganizationUser_AutomaticallyConfirmed:
|
||||
msg = this.i18nService.t("automaticallyConfirmedUserId", this.formatOrgUserId(ev));
|
||||
humanReadableMsg = this.i18nService.t(
|
||||
"automaticallyConfirmedUserId",
|
||||
this.getShortId(ev.organizationUserId),
|
||||
);
|
||||
break;
|
||||
// Org
|
||||
case EventType.Organization_Updated:
|
||||
msg = humanReadableMsg = this.i18nService.t("editedOrgSettings");
|
||||
@@ -458,6 +465,18 @@ export class EventService {
|
||||
case EventType.Organization_ItemOrganization_Declined:
|
||||
msg = humanReadableMsg = this.i18nService.t("userDeclinedTransfer");
|
||||
break;
|
||||
case EventType.Organization_AutoConfirmEnabled_Admin:
|
||||
msg = humanReadableMsg = this.i18nService.t("autoConfirmEnabledByAdmin");
|
||||
break;
|
||||
case EventType.Organization_AutoConfirmDisabled_Admin:
|
||||
msg = humanReadableMsg = this.i18nService.t("autoConfirmDisabledByAdmin");
|
||||
break;
|
||||
case EventType.Organization_AutoConfirmEnabled_Portal:
|
||||
msg = humanReadableMsg = this.i18nService.t("autoConfirmEnabledByPortal");
|
||||
break;
|
||||
case EventType.Organization_AutoConfirmDisabled_Portal:
|
||||
msg = humanReadableMsg = this.i18nService.t("autoConfirmDisabledByPortal");
|
||||
break;
|
||||
|
||||
// Policies
|
||||
case EventType.Policy_Updated: {
|
||||
|
||||
@@ -43,16 +43,16 @@
|
||||
></bit-chip-select>
|
||||
}
|
||||
}
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="54">
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="54" layout="fixed">
|
||||
<ng-container header>
|
||||
<th bitCell></th>
|
||||
<th bitCell class="tw-w-12"></th>
|
||||
<th bitCell bitSortable="name">{{ "name" | i18n }}</th>
|
||||
@if (!isAdminConsoleActive) {
|
||||
<th bitCell bitSortable="organizationId">
|
||||
<th bitCell bitSortable="organizationId" class="tw-w-1/4">
|
||||
{{ "owner" | i18n }}
|
||||
</th>
|
||||
}
|
||||
<th bitCell class="tw-text-right" bitSortable="exposedXTimes">
|
||||
<th bitCell class="tw-w-1/4 tw-text-right" bitSortable="exposedXTimes">
|
||||
{{ "timesExposed" | i18n }}
|
||||
</th>
|
||||
</ng-container>
|
||||
@@ -60,7 +60,7 @@
|
||||
<td bitCell>
|
||||
<app-vault-icon [cipher]="row"></app-vault-icon>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<td bitCell class="tw-truncate tw-max-w-0">
|
||||
@if (!organization || canManageCipher(row)) {
|
||||
<a
|
||||
bitLink
|
||||
@@ -72,7 +72,7 @@
|
||||
{{ row.name }}
|
||||
</a>
|
||||
} @else {
|
||||
<span>{{ row.name }}</span>
|
||||
<span title="{{ row.name }}">{{ row.name }}</span>
|
||||
}
|
||||
@if (!organization && row.organizationId) {
|
||||
<i
|
||||
|
||||
@@ -45,20 +45,20 @@
|
||||
></bit-chip-select>
|
||||
}
|
||||
}
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
|
||||
@if (!isAdminConsoleActive) {
|
||||
<ng-container header>
|
||||
<th bitCell></th>
|
||||
<th bitCell>{{ "name" | i18n }}</th>
|
||||
<th bitCell>{{ "owner" | i18n }}</th>
|
||||
<th bitCell></th>
|
||||
</ng-container>
|
||||
}
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75" layout="fixed">
|
||||
<ng-container header>
|
||||
<th bitCell class="tw-w-12"></th>
|
||||
<th bitCell>{{ "name" | i18n }}</th>
|
||||
@if (!isAdminConsoleActive) {
|
||||
<th bitCell class="tw-w-1/4">{{ "owner" | i18n }}</th>
|
||||
}
|
||||
<th bitCell class="tw-w-1/4"></th>
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>
|
||||
<app-vault-icon [cipher]="row"></app-vault-icon>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<td bitCell class="tw-truncate tw-max-w-0">
|
||||
@if (!organization || canManageCipher(row)) {
|
||||
<a
|
||||
bitLink
|
||||
@@ -69,7 +69,7 @@
|
||||
>{{ row.name }}</a
|
||||
>
|
||||
} @else {
|
||||
<span>{{ row.name }}</span>
|
||||
<span title="{{ row.name }}">{{ row.name }}</span>
|
||||
}
|
||||
@if (!organization && row.organizationId) {
|
||||
<i
|
||||
@@ -92,16 +92,20 @@
|
||||
<br />
|
||||
<small>{{ row.subTitle }}</small>
|
||||
</td>
|
||||
<td bitCell>
|
||||
@if (!organization) {
|
||||
<app-org-badge
|
||||
[disabled]="disabled"
|
||||
[organizationId]="row.organizationId"
|
||||
[organizationName]="row.organizationId | orgNameFromId: (organizations$ | async)"
|
||||
appStopProp
|
||||
/>
|
||||
}
|
||||
</td>
|
||||
@if (!isAdminConsoleActive) {
|
||||
<td bitCell>
|
||||
@if (!organization) {
|
||||
<app-org-badge
|
||||
[disabled]="disabled"
|
||||
[organizationId]="row.organizationId"
|
||||
[organizationName]="
|
||||
row.organizationId | orgNameFromId: (organizations$ | async)
|
||||
"
|
||||
appStopProp
|
||||
/>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
<td bitCell class="tw-text-right">
|
||||
@if (cipherDocs.has(row.id)) {
|
||||
<a bitBadge href="{{ cipherDocs.get(row.id) }}" target="_blank" rel="noreferrer">
|
||||
|
||||
@@ -45,20 +45,20 @@
|
||||
></bit-chip-select>
|
||||
}
|
||||
}
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
|
||||
@if (!isAdminConsoleActive) {
|
||||
<ng-container header>
|
||||
<th bitCell></th>
|
||||
<th bitCell>{{ "name" | i18n }}</th>
|
||||
<th bitCell>{{ "owner" | i18n }}</th>
|
||||
<th bitCell class="tw-text-right">{{ "timesReused" | i18n }}</th>
|
||||
</ng-container>
|
||||
}
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75" layout="fixed">
|
||||
<ng-container header>
|
||||
<th bitCell class="tw-w-12"></th>
|
||||
<th bitCell>{{ "name" | i18n }}</th>
|
||||
@if (!isAdminConsoleActive) {
|
||||
<th bitCell class="tw-w-1/4">{{ "owner" | i18n }}</th>
|
||||
}
|
||||
<th bitCell class="tw-w-1/4 tw-text-right">{{ "timesReused" | i18n }}</th>
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>
|
||||
<app-vault-icon [cipher]="row"></app-vault-icon>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<td bitCell class="tw-truncate tw-max-w-0">
|
||||
@if (!organization || canManageCipher(row)) {
|
||||
<a
|
||||
bitLink
|
||||
@@ -69,7 +69,7 @@
|
||||
>{{ row.name }}</a
|
||||
>
|
||||
} @else {
|
||||
<span>{{ row.name }}</span>
|
||||
<span title="{{ row.name }}">{{ row.name }}</span>
|
||||
}
|
||||
@if (!organization && row.organizationId) {
|
||||
<i
|
||||
@@ -92,17 +92,21 @@
|
||||
<br />
|
||||
<small>{{ row.subTitle }}</small>
|
||||
</td>
|
||||
<td bitCell>
|
||||
@if (!organization) {
|
||||
<app-org-badge
|
||||
[disabled]="disabled"
|
||||
[organizationId]="row.organizationId"
|
||||
[organizationName]="row.organizationId | orgNameFromId: (organizations$ | async)"
|
||||
appStopProp
|
||||
>
|
||||
</app-org-badge>
|
||||
}
|
||||
</td>
|
||||
@if (!isAdminConsoleActive) {
|
||||
<td bitCell>
|
||||
@if (!organization) {
|
||||
<app-org-badge
|
||||
[disabled]="disabled"
|
||||
[organizationId]="row.organizationId"
|
||||
[organizationName]="
|
||||
row.organizationId | orgNameFromId: (organizations$ | async)
|
||||
"
|
||||
appStopProp
|
||||
>
|
||||
</app-org-badge>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
<td bitCell class="tw-text-right">
|
||||
<span bitBadge variant="warning">
|
||||
{{ "reusedXTimes" | i18n: passwordUseMap.get(row.login.password) }}
|
||||
|
||||
@@ -45,19 +45,19 @@
|
||||
></bit-chip-select>
|
||||
}
|
||||
}
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
|
||||
@if (!isAdminConsoleActive) {
|
||||
<ng-container header>
|
||||
<th bitCell></th>
|
||||
<th bitCell>{{ "name" | i18n }}</th>
|
||||
<th bitCell>{{ "owner" | i18n }}</th>
|
||||
</ng-container>
|
||||
}
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75" layout="fixed">
|
||||
<ng-container header>
|
||||
<th bitCell class="tw-w-12"></th>
|
||||
<th bitCell>{{ "name" | i18n }}</th>
|
||||
@if (!isAdminConsoleActive) {
|
||||
<th bitCell class="tw-w-1/3">{{ "owner" | i18n }}</th>
|
||||
}
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>
|
||||
<app-vault-icon [cipher]="row"></app-vault-icon>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<td bitCell class="tw-truncate tw-max-w-0">
|
||||
@if (!organization || canManageCipher(row)) {
|
||||
<a
|
||||
bitLink
|
||||
@@ -68,7 +68,7 @@
|
||||
>{{ row.name }}</a
|
||||
>
|
||||
} @else {
|
||||
<span>{{ row.name }}</span>
|
||||
<span title="{{ row.name }}">{{ row.name }}</span>
|
||||
}
|
||||
@if (!organization && row.organizationId) {
|
||||
<i
|
||||
@@ -91,17 +91,21 @@
|
||||
<br />
|
||||
<small>{{ row.subTitle }}</small>
|
||||
</td>
|
||||
<td bitCell>
|
||||
@if (!organization) {
|
||||
<app-org-badge
|
||||
[disabled]="disabled"
|
||||
[organizationId]="row.organizationId"
|
||||
[organizationName]="row.organizationId | orgNameFromId: (organizations$ | async)"
|
||||
appStopProp
|
||||
>
|
||||
</app-org-badge>
|
||||
}
|
||||
</td>
|
||||
@if (!isAdminConsoleActive) {
|
||||
<td bitCell>
|
||||
@if (!organization) {
|
||||
<app-org-badge
|
||||
[disabled]="disabled"
|
||||
[organizationId]="row.organizationId"
|
||||
[organizationName]="
|
||||
row.organizationId | orgNameFromId: (organizations$ | async)
|
||||
"
|
||||
appStopProp
|
||||
>
|
||||
</app-org-badge>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
}
|
||||
|
||||
@@ -45,12 +45,12 @@
|
||||
></bit-chip-select>
|
||||
}
|
||||
}
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="54">
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="54" layout="fixed">
|
||||
<ng-container header>
|
||||
<th bitCell></th>
|
||||
<th bitCell class="tw-w-12"></th>
|
||||
<th bitCell bitSortable="name">{{ "name" | i18n }}</th>
|
||||
@if (!isAdminConsoleActive) {
|
||||
<th bitCell bitSortable="organizationId">
|
||||
<th bitCell bitSortable="organizationId" class="tw-w-1/4">
|
||||
{{ "owner" | i18n }}
|
||||
</th>
|
||||
}
|
||||
@@ -62,7 +62,7 @@
|
||||
<td bitCell>
|
||||
<app-vault-icon [cipher]="row"></app-vault-icon>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<td bitCell class="tw-truncate tw-max-w-0">
|
||||
@if (!organization || canManageCipher(row)) {
|
||||
<a
|
||||
bitLink
|
||||
@@ -73,7 +73,7 @@
|
||||
>{{ row.name }}</a
|
||||
>
|
||||
} @else {
|
||||
<span>{{ row.name }}</span>
|
||||
<span title="{{ row.name }}">{{ row.name }}</span>
|
||||
}
|
||||
@if (!organization && row.organizationId) {
|
||||
<i
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
<router-outlet></router-outlet>
|
||||
|
||||
<div class="tw-flex tw-flex-wrap tw-gap-4 tw-mt-4">
|
||||
<div class="tw-w-full">
|
||||
@if (!homepage) {
|
||||
<a bitButton routerLink="./">
|
||||
{{ "backToReports" | i18n }}
|
||||
</a>
|
||||
}
|
||||
@if (!homepage) {
|
||||
<!-- Focus bridge: redirects Tab to the overlay back button (which is outside cdkTrapFocus) -->
|
||||
<span
|
||||
tabindex="0"
|
||||
aria-hidden="true"
|
||||
class="tw-sr-only"
|
||||
(keydown.tab)="focusOverlayButton($event)"
|
||||
></span>
|
||||
}
|
||||
|
||||
<ng-template #backButtonTemplate>
|
||||
<div class="tw-p-3 tw-rounded-lg tw-opacity-90">
|
||||
<a
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
routerLink="./"
|
||||
tabindex="0"
|
||||
(keydown.tab)="returnFocusToPage($event)"
|
||||
>
|
||||
{{ "backToReports" | i18n }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { Overlay, OverlayRef } from "@angular/cdk/overlay";
|
||||
import { TemplatePortal } from "@angular/cdk/portal";
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
inject,
|
||||
OnDestroy,
|
||||
TemplateRef,
|
||||
viewChild,
|
||||
ViewContainerRef,
|
||||
} from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { NavigationEnd, Router } from "@angular/router";
|
||||
import { filter } from "rxjs/operators";
|
||||
@@ -10,20 +20,65 @@ import { filter } from "rxjs/operators";
|
||||
templateUrl: "reports-layout.component.html",
|
||||
standalone: false,
|
||||
})
|
||||
export class ReportsLayoutComponent {
|
||||
export class ReportsLayoutComponent implements AfterViewInit, OnDestroy {
|
||||
homepage = true;
|
||||
|
||||
constructor(router: Router) {
|
||||
const reportsHomeRoute = "/reports";
|
||||
private readonly backButtonTemplate =
|
||||
viewChild.required<TemplateRef<unknown>>("backButtonTemplate");
|
||||
|
||||
this.homepage = router.url === reportsHomeRoute;
|
||||
router.events
|
||||
private overlayRef: OverlayRef | null = null;
|
||||
private overlay = inject(Overlay);
|
||||
private viewContainerRef = inject(ViewContainerRef);
|
||||
private router = inject(Router);
|
||||
|
||||
constructor() {
|
||||
this.router.events
|
||||
.pipe(
|
||||
takeUntilDestroyed(),
|
||||
filter((event) => event instanceof NavigationEnd),
|
||||
)
|
||||
.subscribe((event) => {
|
||||
this.homepage = (event as NavigationEnd).url == reportsHomeRoute;
|
||||
.subscribe(() => this.updateOverlay());
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.updateOverlay();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.overlayRef?.dispose();
|
||||
}
|
||||
|
||||
returnFocusToPage(event: Event): void {
|
||||
if ((event as KeyboardEvent).shiftKey) {
|
||||
return; // Allow natural Shift+Tab behavior
|
||||
}
|
||||
event.preventDefault();
|
||||
const firstFocusable = document.querySelector(
|
||||
"[cdktrapfocus] a:not([tabindex='-1'])",
|
||||
) as HTMLElement;
|
||||
firstFocusable?.focus();
|
||||
}
|
||||
|
||||
focusOverlayButton(event: Event): void {
|
||||
if ((event as KeyboardEvent).shiftKey) {
|
||||
return; // Allow natural Shift+Tab behavior
|
||||
}
|
||||
event.preventDefault();
|
||||
const button = this.overlayRef?.overlayElement?.querySelector("a") as HTMLElement;
|
||||
button?.focus();
|
||||
}
|
||||
|
||||
private updateOverlay(): void {
|
||||
if (this.router.url === "/reports") {
|
||||
this.homepage = true;
|
||||
this.overlayRef?.dispose();
|
||||
this.overlayRef = null;
|
||||
} else if (!this.overlayRef) {
|
||||
this.homepage = false;
|
||||
this.overlayRef = this.overlay.create({
|
||||
positionStrategy: this.overlay.position().global().bottom("20px").right("32px"),
|
||||
});
|
||||
this.overlayRef.attach(new TemplatePortal(this.backButtonTemplate(), this.viewContainerRef));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { OverlayModule } from "@angular/cdk/overlay";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
@@ -29,6 +30,7 @@ import { ReportsSharedModule } from "./shared";
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
OverlayModule,
|
||||
SharedModule,
|
||||
ReportsSharedModule,
|
||||
ReportsRoutingModule,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Component, Input, OnInit } from "@angular/core";
|
||||
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { SharedModule } from "../../shared.module";
|
||||
import { SharedModule } from "../../shared/shared.module";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
KeyRotationTrustInfoComponent,
|
||||
} from "@bitwarden/key-management-ui";
|
||||
import { BitwardenClient, PureCrypto } from "@bitwarden/sdk-internal";
|
||||
import { UserKeyRotationServiceAbstraction } from "@bitwarden/user-crypto-management";
|
||||
|
||||
import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service";
|
||||
import { WebauthnLoginAdminService } from "../../auth";
|
||||
@@ -287,6 +288,7 @@ describe("KeyRotationService", () => {
|
||||
let mockSdkClientFactory: MockProxy<SdkClientFactory>;
|
||||
let mockSecurityStateService: MockProxy<SecurityStateService>;
|
||||
let mockMasterPasswordService: MockProxy<MasterPasswordServiceAbstraction>;
|
||||
let mockSdkUserKeyRotationService: MockProxy<UserKeyRotationServiceAbstraction>;
|
||||
|
||||
const mockUser = {
|
||||
id: "mockUserId" as UserId,
|
||||
@@ -348,6 +350,7 @@ describe("KeyRotationService", () => {
|
||||
mockDialogService = mock<DialogService>();
|
||||
mockCryptoFunctionService = mock<CryptoFunctionService>();
|
||||
mockKdfConfigService = mock<KdfConfigService>();
|
||||
mockSdkUserKeyRotationService = mock<UserKeyRotationServiceAbstraction>();
|
||||
mockSdkClientFactory = mock<SdkClientFactory>();
|
||||
mockSdkClientFactory.createSdkClient.mockResolvedValue({
|
||||
crypto: () => {
|
||||
@@ -358,6 +361,7 @@ describe("KeyRotationService", () => {
|
||||
} as any;
|
||||
},
|
||||
} as BitwardenClient);
|
||||
|
||||
mockSecurityStateService = mock<SecurityStateService>();
|
||||
mockMasterPasswordService = mock<MasterPasswordServiceAbstraction>();
|
||||
|
||||
@@ -384,6 +388,7 @@ describe("KeyRotationService", () => {
|
||||
mockSdkClientFactory,
|
||||
mockSecurityStateService,
|
||||
mockMasterPasswordService,
|
||||
mockSdkUserKeyRotationService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -509,7 +514,12 @@ describe("KeyRotationService", () => {
|
||||
);
|
||||
mockKeyService.userSigningKey$.mockReturnValue(new BehaviorSubject(null));
|
||||
mockSecurityStateService.accountSecurityState$.mockReturnValue(new BehaviorSubject(null));
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(true);
|
||||
mockConfigService.getFeatureFlag.mockImplementation(async (flag: FeatureFlag) => {
|
||||
if (flag === FeatureFlag.EnrollAeadOnKeyRotation) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const spy = jest.spyOn(keyRotationService, "getRotatedAccountKeysFlagged").mockResolvedValue({
|
||||
userKey: TEST_VECTOR_USER_KEY_V2,
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
KeyRotationTrustInfoComponent,
|
||||
} from "@bitwarden/key-management-ui";
|
||||
import { PureCrypto, TokenProvider } from "@bitwarden/sdk-internal";
|
||||
import { UserKeyRotationServiceAbstraction } from "@bitwarden/user-crypto-management";
|
||||
|
||||
import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service";
|
||||
import { WebauthnLoginAdminService } from "../../auth/core";
|
||||
@@ -101,6 +102,7 @@ export class UserKeyRotationService {
|
||||
private sdkClientFactory: SdkClientFactory,
|
||||
private securityStateService: SecurityStateService,
|
||||
private masterPasswordService: MasterPasswordServiceAbstraction,
|
||||
private sdkUserKeyRotationService: UserKeyRotationServiceAbstraction,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -116,6 +118,28 @@ export class UserKeyRotationService {
|
||||
user: Account,
|
||||
newMasterPasswordHint?: string,
|
||||
): Promise<void> {
|
||||
const useSdkKeyRotation = await this.configService.getFeatureFlag(FeatureFlag.SdkKeyRotation);
|
||||
if (useSdkKeyRotation) {
|
||||
this.logService.info(
|
||||
"[UserKey Rotation] Using SDK-based key rotation service from user-crypto-management",
|
||||
);
|
||||
await this.sdkUserKeyRotationService.changePasswordAndRotateUserKey(
|
||||
currentMasterPassword,
|
||||
newMasterPassword,
|
||||
newMasterPasswordHint,
|
||||
asUuid(user.id),
|
||||
);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: this.i18nService.t("rotationCompletedTitle"),
|
||||
message: this.i18nService.t("rotationCompletedDesc"),
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
await this.logoutService.logout(user.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Key-rotation uses the SDK, so we need to ensure that the SDK is loaded / the WASM initialized.
|
||||
await SdkLoadService.Ready;
|
||||
|
||||
|
||||
@@ -1,67 +1,66 @@
|
||||
<div class="tw-mt-auto">
|
||||
@let accessibleProducts = accessibleProducts$ | async;
|
||||
@if (accessibleProducts && accessibleProducts.length > 1) {
|
||||
<!-- [attr.icon] is used to keep the icon attribute on the bit-nav-item after prod mode is enabled. Matches other navigation items and assists in automated testing. -->
|
||||
<bit-nav-item
|
||||
*ngFor="let product of accessibleProducts"
|
||||
[icon]="product.icon"
|
||||
[text]="product.name"
|
||||
[route]="product.appRoute"
|
||||
[attr.icon]="product.icon"
|
||||
[forceActiveStyles]="product.isActive"
|
||||
>
|
||||
</bit-nav-item>
|
||||
}
|
||||
@let accessibleProducts = accessibleProducts$ | async;
|
||||
@if (accessibleProducts && accessibleProducts.length > 1) {
|
||||
<!-- [attr.icon] is used to keep the icon attribute on the bit-nav-item after prod mode is enabled. Matches other navigation items and assists in automated testing. -->
|
||||
<bit-nav-item
|
||||
*ngFor="let product of accessibleProducts"
|
||||
[icon]="product.icon"
|
||||
[text]="product.name"
|
||||
[route]="product.appRoute"
|
||||
[attr.icon]="product.icon"
|
||||
[forceActiveStyles]="product.isActive"
|
||||
focusAfterNavTarget="body"
|
||||
>
|
||||
</bit-nav-item>
|
||||
}
|
||||
|
||||
@if (shouldShowPremiumUpgradeButton$ | async) {
|
||||
<app-upgrade-nav-button></app-upgrade-nav-button>
|
||||
}
|
||||
@if (shouldShowPremiumUpgradeButton$ | async) {
|
||||
<app-upgrade-nav-button></app-upgrade-nav-button>
|
||||
}
|
||||
|
||||
@let moreProducts = moreProducts$ | async;
|
||||
@if (moreProducts && moreProducts.length > 0) {
|
||||
<section class="tw-mt-2 tw-flex tw-w-full tw-flex-col tw-gap-2 tw-border-0">
|
||||
<span class="tw-text-xs !tw-text-alt2 tw-p-2 tw-pb-0">{{ "moreFromBitwarden" | i18n }}</span>
|
||||
<ng-container *ngFor="let more of moreProducts">
|
||||
<div class="tw-ps-2 tw-pe-2">
|
||||
<!-- <a> for when the marketing route is external -->
|
||||
<a
|
||||
*ngIf="more.marketingRoute.external"
|
||||
[href]="more.marketingRoute.route"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="tw-flex tw-px-3 tw-py-2 tw-rounded-md tw-font-medium !tw-text-alt2 !tw-no-underline hover:tw-bg-hover-contrast [&>:not(.bwi)]:hover:tw-underline"
|
||||
>
|
||||
<i class="bwi bwi-fw {{ more.icon }} tw-mt-1 tw-mx-1"></i>
|
||||
<div>
|
||||
{{ more.otherProductOverrides?.name ?? more.name }}
|
||||
<div
|
||||
*ngIf="more.otherProductOverrides?.supportingText"
|
||||
class="tw-text-xs tw-font-normal"
|
||||
>
|
||||
{{ more.otherProductOverrides.supportingText }}
|
||||
</div>
|
||||
@let moreProducts = moreProducts$ | async;
|
||||
@if (moreProducts && moreProducts.length > 0) {
|
||||
<section class="tw-mt-2 tw-flex tw-w-full tw-flex-col tw-gap-2 tw-border-0">
|
||||
<span class="tw-text-xs !tw-text-alt2 tw-p-2 tw-pb-0">{{ "moreFromBitwarden" | i18n }}</span>
|
||||
<ng-container *ngFor="let more of moreProducts">
|
||||
<div class="tw-ps-2 tw-pe-2">
|
||||
<!-- <a> for when the marketing route is external -->
|
||||
<a
|
||||
*ngIf="more.marketingRoute.external"
|
||||
[href]="more.marketingRoute.route"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="tw-flex tw-px-3 tw-py-2 tw-rounded-md tw-font-medium !tw-text-alt2 !tw-no-underline hover:tw-bg-hover-contrast [&>:not(.bwi)]:hover:tw-underline"
|
||||
>
|
||||
<i class="bwi bwi-fw {{ more.icon }} tw-mt-1 tw-mx-1"></i>
|
||||
<div>
|
||||
{{ more.otherProductOverrides?.name ?? more.name }}
|
||||
<div
|
||||
*ngIf="more.otherProductOverrides?.supportingText"
|
||||
class="tw-text-xs tw-font-normal"
|
||||
>
|
||||
{{ more.otherProductOverrides.supportingText }}
|
||||
</div>
|
||||
</a>
|
||||
<!-- <a> for when the marketing route is internal, it needs to use [routerLink] instead of [href] like the external <a> uses. -->
|
||||
<a
|
||||
*ngIf="!more.marketingRoute.external"
|
||||
[routerLink]="more.marketingRoute.route"
|
||||
rel="noreferrer"
|
||||
class="tw-flex tw-px-3 tw-py-2 tw-rounded-md tw-font-medium !tw-text-alt2 !tw-no-underline hover:tw-bg-hover-contrast [&>:not(.bwi)]:hover:tw-underline"
|
||||
>
|
||||
<i class="bwi bwi-fw {{ more.icon }} tw-mt-1 tw-mx-1"></i>
|
||||
<div>
|
||||
{{ more.otherProductOverrides?.name ?? more.name }}
|
||||
<div
|
||||
*ngIf="more.otherProductOverrides?.supportingText"
|
||||
class="tw-text-xs tw-font-normal"
|
||||
>
|
||||
{{ more.otherProductOverrides.supportingText }}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<!-- <a> for when the marketing route is internal, it needs to use [routerLink] instead of [href] like the external <a> uses. -->
|
||||
<a
|
||||
*ngIf="!more.marketingRoute.external"
|
||||
[routerLink]="more.marketingRoute.route"
|
||||
rel="noreferrer"
|
||||
class="tw-flex tw-px-3 tw-py-2 tw-rounded-md tw-font-medium !tw-text-alt2 !tw-no-underline hover:tw-bg-hover-contrast [&>:not(.bwi)]:hover:tw-underline"
|
||||
>
|
||||
<i class="bwi bwi-fw {{ more.icon }} tw-mt-1 tw-mx-1"></i>
|
||||
<div>
|
||||
{{ more.otherProductOverrides?.name ?? more.name }}
|
||||
<div
|
||||
*ngIf="more.otherProductOverrides?.supportingText"
|
||||
class="tw-text-xs tw-font-normal"
|
||||
>
|
||||
{{ more.otherProductOverrides.supportingText }}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</ng-container>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</ng-container>
|
||||
</section>
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { BehaviorSubject } from "rxjs";
|
||||
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { FakeGlobalStateProvider } from "@bitwarden/common/spec";
|
||||
import { IconButtonModule, NavigationModule } from "@bitwarden/components";
|
||||
import { IconButtonModule, NavigationModule, SideNavService } from "@bitwarden/components";
|
||||
// FIXME: remove `src` and fix import
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { NavItemComponent } from "@bitwarden/components/src/navigation/nav-item.component";
|
||||
@@ -86,6 +86,9 @@ describe("NavigationProductSwitcherComponent", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(NavigationProductSwitcherComponent);
|
||||
// SideNavService.open starts false (managed by LayoutComponent's ResizeObserver in a real
|
||||
// app). Set it to true so NavItemComponent renders text labels (used in text-content checks).
|
||||
TestBed.inject(SideNavService).open.set(true);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Component, Directive, importProvidersFrom, Input } from "@angular/core";
|
||||
import { provideNoopAnimations } from "@angular/platform-browser/animations";
|
||||
import { RouterModule } from "@angular/router";
|
||||
import { applicationConfig, Meta, moduleMetadata, StoryObj } from "@storybook/angular";
|
||||
import { BehaviorSubject, Observable, of } from "rxjs";
|
||||
|
||||
import { PasswordManagerLogo } from "@bitwarden/assets/svg";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
|
||||
@@ -17,13 +19,13 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import {
|
||||
I18nMockService,
|
||||
LayoutComponent,
|
||||
NavigationModule,
|
||||
StorybookGlobalStateProvider,
|
||||
} from "@bitwarden/components";
|
||||
// FIXME: remove `src` and fix import
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { I18nMockService } from "@bitwarden/components/src/utils/i18n-mock.service";
|
||||
import { positionFixedWrapperDecorator } from "@bitwarden/components/src/stories/storybook-decorators";
|
||||
import { GlobalStateProvider } from "@bitwarden/state";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
@@ -105,19 +107,10 @@ class MockBillingAccountProfileStateService implements Partial<BillingAccountPro
|
||||
|
||||
class MockConfigService implements Partial<ConfigService> {
|
||||
getFeatureFlag$<Flag extends FeatureFlag>(key: Flag): Observable<FeatureFlagValueType<Flag>> {
|
||||
return of(false);
|
||||
return of(false as FeatureFlagValueType<Flag>);
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "story-layout",
|
||||
template: `<ng-content></ng-content>`,
|
||||
standalone: false,
|
||||
})
|
||||
class StoryLayoutComponent {}
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
@@ -132,17 +125,23 @@ const translations: Record<string, string> = {
|
||||
secureYourInfrastructure: "Secure your infrastructure",
|
||||
protectYourFamilyOrBusiness: "Protect your family or business",
|
||||
skipToContent: "Skip to content",
|
||||
toggleSideNavigation: "Toggle side navigation",
|
||||
resizeSideNavigation: "Resize side navigation",
|
||||
submenu: "submenu",
|
||||
toggleCollapse: "toggle collapse",
|
||||
close: "Close",
|
||||
loading: "Loading",
|
||||
};
|
||||
|
||||
export default {
|
||||
title: "Web/Navigation Product Switcher",
|
||||
decorators: [
|
||||
positionFixedWrapperDecorator(),
|
||||
moduleMetadata({
|
||||
declarations: [
|
||||
NavigationProductSwitcherComponent,
|
||||
MockOrganizationService,
|
||||
MockProviderService,
|
||||
StoryLayoutComponent,
|
||||
StoryContentComponent,
|
||||
],
|
||||
imports: [NavigationModule, RouterModule, LayoutComponent, I18nPipe],
|
||||
@@ -174,19 +173,11 @@ export default {
|
||||
}),
|
||||
applicationConfig({
|
||||
providers: [
|
||||
provideNoopAnimations(),
|
||||
importProvidersFrom(
|
||||
RouterModule.forRoot([
|
||||
{
|
||||
path: "",
|
||||
component: StoryLayoutComponent,
|
||||
children: [
|
||||
{
|
||||
path: "**",
|
||||
component: StoryContentComponent,
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
RouterModule.forRoot([{ path: "**", component: StoryContentComponent }], {
|
||||
useHash: true,
|
||||
}),
|
||||
),
|
||||
{
|
||||
provide: GlobalStateProvider,
|
||||
@@ -203,12 +194,47 @@ type Story = StoryObj<
|
||||
|
||||
const Template: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
props: { ...args, logo: PasswordManagerLogo },
|
||||
template: `
|
||||
<router-outlet [mockOrgs]="mockOrgs" [mockProviders]="mockProviders"></router-outlet>
|
||||
<div class="tw-bg-background-alt3 tw-w-60">
|
||||
<navigation-product-switcher></navigation-product-switcher>
|
||||
</div>
|
||||
<bit-layout>
|
||||
<bit-side-nav>
|
||||
<bit-nav-logo [openIcon]="logo" route="." label="Bitwarden"></bit-nav-logo>
|
||||
<bit-nav-item text="Vault" icon="bwi-lock"></bit-nav-item>
|
||||
<bit-nav-item text="Send" icon="bwi-send"></bit-nav-item>
|
||||
<bit-nav-group text="Tools" icon="bwi-key" [open]="true">
|
||||
<bit-nav-item text="Generator"></bit-nav-item>
|
||||
<bit-nav-item text="Import"></bit-nav-item>
|
||||
<bit-nav-item text="Export"></bit-nav-item>
|
||||
</bit-nav-group>
|
||||
<bit-nav-group text="Organizations" icon="bwi-business" [open]="true">
|
||||
<bit-nav-item text="Acme Corp" icon="bwi-collection-shared"></bit-nav-item>
|
||||
<bit-nav-item text="Acme Corp — Vault" variant="tree"></bit-nav-item>
|
||||
<bit-nav-item text="Acme Corp — Members" variant="tree"></bit-nav-item>
|
||||
<bit-nav-item text="Acme Corp — Settings" variant="tree"></bit-nav-item>
|
||||
<bit-nav-item text="My Family" icon="bwi-collection-shared"></bit-nav-item>
|
||||
<bit-nav-item text="My Family — Vault" variant="tree"></bit-nav-item>
|
||||
<bit-nav-item text="My Family — Members" variant="tree"></bit-nav-item>
|
||||
<bit-nav-item text="Initech" icon="bwi-collection-shared"></bit-nav-item>
|
||||
<bit-nav-item text="Initech — Vault" variant="tree"></bit-nav-item>
|
||||
<bit-nav-item text="Initech — Members" variant="tree"></bit-nav-item>
|
||||
<bit-nav-item text="Initech — Settings" variant="tree"></bit-nav-item>
|
||||
<bit-nav-item text="Umbrella Corp" icon="bwi-collection-shared"></bit-nav-item>
|
||||
<bit-nav-item text="Umbrella Corp — Vault" variant="tree"></bit-nav-item>
|
||||
<bit-nav-item text="Umbrella Corp — Members" variant="tree"></bit-nav-item>
|
||||
<bit-nav-item text="Umbrella Corp — Settings" variant="tree"></bit-nav-item>
|
||||
<bit-nav-item text="Stark Industries" icon="bwi-collection-shared"></bit-nav-item>
|
||||
<bit-nav-item text="Stark Industries — Vault" variant="tree"></bit-nav-item>
|
||||
<bit-nav-item text="Stark Industries — Members" variant="tree"></bit-nav-item>
|
||||
<bit-nav-item text="Stark Industries — Settings" variant="tree"></bit-nav-item>
|
||||
</bit-nav-group>
|
||||
<bit-nav-item text="Settings" icon="bwi-cog"></bit-nav-item>
|
||||
<ng-container slot="product-switcher">
|
||||
<bit-nav-divider></bit-nav-divider>
|
||||
<navigation-product-switcher [mockOrgs]="mockOrgs" [mockProviders]="mockProviders"></navigation-product-switcher>
|
||||
</ng-container>
|
||||
</bit-side-nav>
|
||||
<router-outlet></router-outlet>
|
||||
</bit-layout>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
"
|
||||
class="tw-group/product-link tw-flex tw-h-24 tw-w-28 tw-flex-col tw-items-center tw-justify-center tw-rounded tw-p-1 tw-text-primary-600 tw-outline-none hover:tw-bg-background-alt hover:tw-text-primary-700 hover:tw-no-underline focus-visible:!tw-ring-2 focus-visible:!tw-ring-primary-700"
|
||||
ariaCurrentWhenActive="page"
|
||||
[state]="{
|
||||
focusAfterNav: 'body',
|
||||
}"
|
||||
>
|
||||
<i class="bwi {{ product.icon }} tw-text-4xl !tw-m-0 !tw-mb-1"></i>
|
||||
<span
|
||||
|
||||
@@ -100,7 +100,7 @@ class MockBillingAccountProfileStateService implements Partial<BillingAccountPro
|
||||
|
||||
class MockConfigService implements Partial<ConfigService> {
|
||||
getFeatureFlag$<Flag extends FeatureFlag>(key: Flag): Observable<FeatureFlagValueType<Flag>> {
|
||||
return of(false);
|
||||
return of(false as FeatureFlagValueType<Flag>);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -159,16 +159,14 @@ export class ProductSwitcherService {
|
||||
this.userHasSingleOrgPolicy$,
|
||||
this.route.paramMap,
|
||||
this.triggerProductUpdate$,
|
||||
this.configService.getFeatureFlag$(FeatureFlag.SM1719_RemoveSecretsManagerAds),
|
||||
]).pipe(
|
||||
map(
|
||||
([orgs, providers, userHasSingleOrgPolicy, paramMap, , removeSecretsManagerAdsFlag]: [
|
||||
([orgs, providers, userHasSingleOrgPolicy, paramMap]: [
|
||||
Organization[],
|
||||
Provider[],
|
||||
boolean,
|
||||
ParamMap,
|
||||
void,
|
||||
boolean,
|
||||
]) => {
|
||||
// Sort orgs by name to match the order within the sidebar
|
||||
orgs.sort((a, b) => a.name.localeCompare(b.name));
|
||||
@@ -215,13 +213,11 @@ export class ProductSwitcherService {
|
||||
};
|
||||
|
||||
// Check if SM ads should be disabled for any organization
|
||||
// SM ads are only disabled if the feature flag is enabled AND
|
||||
// the user is a regular User (not Admin or Owner) in an organization that has useDisableSMAdsForUsers enabled
|
||||
const shouldDisableSMAds =
|
||||
removeSecretsManagerAdsFlag &&
|
||||
orgs.some(
|
||||
(org) => org.useDisableSMAdsForUsers === true && org.type === OrganizationUserType.User,
|
||||
);
|
||||
// SM ads are disabled if the user is a regular User (not Admin or Owner)
|
||||
// in an organization that has useDisableSMAdsForUsers enabled
|
||||
const shouldDisableSMAds = orgs.some(
|
||||
(org) => org.useDisableSMAdsForUsers === true && org.type === OrganizationUserType.User,
|
||||
);
|
||||
|
||||
const products = {
|
||||
pm: {
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
<bit-nav-logo [openIcon]="logo" route="." [label]="'passwordManager' | i18n"></bit-nav-logo>
|
||||
|
||||
<bit-nav-item icon="bwi-vault" [text]="'vaults' | i18n" route="vault"></bit-nav-item>
|
||||
<bit-nav-item icon="bwi-send" [text]="'send' | i18n" route="sends"></bit-nav-item>
|
||||
@if (sendEnabled$ | async) {
|
||||
<bit-nav-item icon="bwi-send" [text]="'send' | i18n" route="sends"></bit-nav-item>
|
||||
}
|
||||
<bit-nav-group icon="bwi-wrench" [text]="'tools' | i18n" route="tools">
|
||||
<bit-nav-item [text]="'generator' | i18n" route="tools/generator"></bit-nav-item>
|
||||
<bit-nav-item [text]="'importNoun' | i18n" route="tools/import"></bit-nav-item>
|
||||
@@ -18,11 +20,10 @@
|
||||
} @else {
|
||||
<bit-nav-item [text]="'preferences' | i18n" route="settings/preferences"></bit-nav-item>
|
||||
}
|
||||
<bit-nav-item
|
||||
[text]="'subscription' | i18n"
|
||||
route="settings/subscription"
|
||||
*ngIf="showSubscription$ | async"
|
||||
></bit-nav-item>
|
||||
@let subscriptionRoute = subscriptionRoute$ | async;
|
||||
@if (subscriptionRoute) {
|
||||
<bit-nav-item [text]="'subscription' | i18n" [route]="subscriptionRoute"></bit-nav-item>
|
||||
}
|
||||
<bit-nav-item [text]="'domainRules' | i18n" route="settings/domain-rules"></bit-nav-item>
|
||||
@if (showEmergencyAccess()) {
|
||||
<bit-nav-item
|
||||
|
||||
@@ -4,12 +4,13 @@ import { CommonModule } from "@angular/common";
|
||||
import { Component, OnInit, Signal } from "@angular/core";
|
||||
import { toSignal } from "@angular/core/rxjs-interop";
|
||||
import { RouterModule } from "@angular/router";
|
||||
import { Observable, switchMap } from "rxjs";
|
||||
import { catchError, combineLatest, from, map, Observable, of, switchMap } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { PasswordManagerLogo } from "@bitwarden/assets/svg";
|
||||
import { canAccessEmergencyAccess } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
@@ -17,6 +18,8 @@ import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { SvgModule } from "@bitwarden/components";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
import { AccountBillingClient } from "@bitwarden/web-vault/app/billing/clients";
|
||||
|
||||
import { BillingFreeFamiliesNavItemComponent } from "../billing/shared/billing-free-families-nav-item.component";
|
||||
|
||||
@@ -35,14 +38,20 @@ import { WebLayoutModule } from "./web-layout.module";
|
||||
SvgModule,
|
||||
BillingFreeFamiliesNavItemComponent,
|
||||
],
|
||||
providers: [AccountBillingClient],
|
||||
})
|
||||
export class UserLayoutComponent implements OnInit {
|
||||
protected readonly logo = PasswordManagerLogo;
|
||||
protected readonly showEmergencyAccess: Signal<boolean>;
|
||||
protected hasFamilySponsorshipAvailable$: Observable<boolean>;
|
||||
protected showSponsoredFamilies$: Observable<boolean>;
|
||||
protected showSubscription$: Observable<boolean>;
|
||||
protected readonly sendEnabled$: Observable<boolean> = this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) => this.policyService.policyAppliesToUser$(PolicyType.DisableSend, userId)),
|
||||
map((isDisabled) => !isDisabled),
|
||||
);
|
||||
protected consolidatedSessionTimeoutComponent$: Observable<boolean>;
|
||||
protected hasPremiumFromAnyOrganization$: Observable<boolean>;
|
||||
protected hasSubscription$: Observable<boolean>;
|
||||
protected subscriptionRoute$: Observable<string | null>;
|
||||
|
||||
constructor(
|
||||
private syncService: SyncService,
|
||||
@@ -50,13 +59,8 @@ export class UserLayoutComponent implements OnInit {
|
||||
private accountService: AccountService,
|
||||
private policyService: PolicyService,
|
||||
private configService: ConfigService,
|
||||
private accountBillingClient: AccountBillingClient,
|
||||
) {
|
||||
this.showSubscription$ = this.accountService.activeAccount$.pipe(
|
||||
switchMap((account) =>
|
||||
this.billingAccountProfileStateService.canViewSubscription$(account.id),
|
||||
),
|
||||
);
|
||||
|
||||
this.showEmergencyAccess = toSignal(
|
||||
this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
@@ -69,10 +73,41 @@ export class UserLayoutComponent implements OnInit {
|
||||
this.consolidatedSessionTimeoutComponent$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.ConsolidatedSessionTimeoutComponent,
|
||||
);
|
||||
|
||||
this.hasPremiumFromAnyOrganization$ = this.ifAccountExistsCheck((userId) =>
|
||||
this.billingAccountProfileStateService.hasPremiumFromAnyOrganization$(userId),
|
||||
);
|
||||
|
||||
this.hasSubscription$ = this.ifAccountExistsCheck(() =>
|
||||
from(this.accountBillingClient.getSubscription()).pipe(
|
||||
map((subscription) => !!subscription),
|
||||
catchError(() => of(false)),
|
||||
),
|
||||
);
|
||||
|
||||
this.subscriptionRoute$ = combineLatest([
|
||||
this.hasSubscription$,
|
||||
this.hasPremiumFromAnyOrganization$,
|
||||
]).pipe(
|
||||
map(([hasSubscription, hasPremiumFromAnyOrganization]) => {
|
||||
if (!hasPremiumFromAnyOrganization || hasSubscription) {
|
||||
return hasSubscription
|
||||
? "settings/subscription/user-subscription"
|
||||
: "settings/subscription/premium";
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
document.body.classList.remove("layout_frontend");
|
||||
await this.syncService.fullSync(false);
|
||||
}
|
||||
|
||||
private ifAccountExistsCheck(predicate$: (userId: UserId) => Observable<boolean>) {
|
||||
return this.accountService.activeAccount$.pipe(
|
||||
switchMap((account) => (account ? predicate$(account.id) : of(false))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
<bit-side-nav [variant]="variant">
|
||||
<ng-content></ng-content>
|
||||
|
||||
<ng-container slot="footer">
|
||||
<ng-container slot="product-switcher">
|
||||
<bit-nav-divider></bit-nav-divider>
|
||||
<navigation-product-switcher></navigation-product-switcher>
|
||||
</ng-container>
|
||||
|
||||
<ng-container slot="footer">
|
||||
<app-toggle-width></app-toggle-width>
|
||||
</ng-container>
|
||||
</bit-side-nav>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
import { Route, RouterModule, Routes } from "@angular/router";
|
||||
import { map } from "rxjs";
|
||||
|
||||
import { organizationPolicyGuard } from "@bitwarden/angular/admin-console/guards";
|
||||
import { AuthenticationTimeoutComponent } from "@bitwarden/angular/auth/components/authentication-timeout.component";
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
NewDeviceVerificationComponent,
|
||||
} from "@bitwarden/auth/angular";
|
||||
import { canAccessEmergencyAccess } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components";
|
||||
import { LockComponent, RemovePasswordComponent } from "@bitwarden/key-management-ui";
|
||||
@@ -641,6 +643,13 @@ const routes: Routes = [
|
||||
path: "sends",
|
||||
component: SendComponent,
|
||||
data: { titleId: "send" } satisfies RouteDataProperties,
|
||||
canActivate: [
|
||||
organizationPolicyGuard((userId, _configService, policyService) =>
|
||||
policyService
|
||||
.policyAppliesToUser$(PolicyType.DisableSend, userId)
|
||||
.pipe(map((policyApplies) => !policyApplies)),
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "sm-landing",
|
||||
|
||||
@@ -35,11 +35,10 @@ import {
|
||||
} from "@bitwarden/components";
|
||||
|
||||
/**
|
||||
* This NgModule should contain the most basic shared directives, pipes, and components. They
|
||||
* should be widely used by other modules to be considered for adding to this module. If in doubt
|
||||
* do not add to this module.
|
||||
* @deprecated Please directly import the relevant directive/pipe/component.
|
||||
*
|
||||
* See: https://angular.io/guide/module-types#shared-ngmodules
|
||||
* This module is overly large and adds many unrelated modules to your dependency tree.
|
||||
* https://angular.dev/guide/ngmodules/overview recommends not using `NgModule`s for new code.
|
||||
*/
|
||||
@NgModule({
|
||||
imports: [
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
<button bitButton [bitMenuTriggerFor]="itemOptions" buttonType="primary" type="button">
|
||||
<i *ngIf="!hideIcon" class="bwi bwi-plus tw-me-2" aria-hidden="true"></i>
|
||||
@if (!hideIcon) {
|
||||
<bit-icon name="bwi-plus" class="tw-me-2" slot="start"></bit-icon>
|
||||
}
|
||||
{{ (hideIcon ? "createSend" : "new") | i18n }}
|
||||
</button>
|
||||
<bit-menu #itemOptions>
|
||||
<a bitMenuItem (click)="createSend(sendType.Text)">
|
||||
<i class="bwi bwi-file-text" slot="start" aria-hidden="true"></i>
|
||||
<button type="button" bitMenuItem (click)="createSend(sendType.Text)">
|
||||
<bit-icon name="bwi-file-text" slot="start"></bit-icon>
|
||||
{{ "sendTypeText" | i18n }}
|
||||
</a>
|
||||
<a bitMenuItem (click)="createSend(sendType.File)">
|
||||
<i class="bwi bwi-file" slot="start" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button type="button" bitMenuItem (click)="createSend(sendType.File)">
|
||||
<bit-icon name="bwi-file" slot="start"></bit-icon>
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
{{ "sendTypeFile" | i18n }}
|
||||
<app-premium-badge></app-premium-badge>
|
||||
</div>
|
||||
</a>
|
||||
</button>
|
||||
</bit-menu>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Input } from "@angular/core";
|
||||
import { firstValueFrom, Observable, of, switchMap, lastValueFrom } from "rxjs";
|
||||
|
||||
@@ -9,7 +8,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
|
||||
import { ButtonModule, DialogService, MenuModule } from "@bitwarden/components";
|
||||
import { ButtonModule, DialogService, IconComponent, MenuModule } from "@bitwarden/components";
|
||||
import {
|
||||
DefaultSendFormConfigService,
|
||||
SendAddEditDialogComponent,
|
||||
@@ -23,7 +22,7 @@ import { SendSuccessDrawerDialogComponent } from "../shared";
|
||||
@Component({
|
||||
selector: "tools-new-send-dropdown",
|
||||
templateUrl: "new-send-dropdown.component.html",
|
||||
imports: [JslibModule, CommonModule, ButtonModule, MenuModule, PremiumBadgeComponent],
|
||||
imports: [JslibModule, ButtonModule, MenuModule, PremiumBadgeComponent, IconComponent],
|
||||
providers: [DefaultSendFormConfigService],
|
||||
})
|
||||
/**
|
||||
|
||||
@@ -20,16 +20,19 @@
|
||||
<bit-label>{{ "verificationCode" | i18n }}</bit-label>
|
||||
<input bitInput type="text" [formControl]="otp" required appInputVerbatim appAutofocus />
|
||||
</bit-form-field>
|
||||
<div class="tw-flex">
|
||||
<button
|
||||
bitButton
|
||||
bitFormButton
|
||||
type="submit"
|
||||
buttonType="primary"
|
||||
[loading]="loading()"
|
||||
[block]="true"
|
||||
>
|
||||
<span>{{ "viewSend" | i18n }} </span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
bitButton
|
||||
bitFormButton
|
||||
type="submit"
|
||||
buttonType="primary"
|
||||
[loading]="loading()"
|
||||
[block]="true"
|
||||
[disabled]="!otp?.value"
|
||||
class="tw-mb-3"
|
||||
>
|
||||
<span>{{ "viewSend" | i18n }} </span>
|
||||
</button>
|
||||
<button bitButton type="button" buttonType="secondary" [block]="true" (click)="onBackClick()">
|
||||
<span>{{ "back" | i18n }}</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { ChangeDetectionStrategy, Component, input, OnDestroy, OnInit } from "@angular/core";
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
effect,
|
||||
input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
output,
|
||||
} from "@angular/core";
|
||||
import { FormControl, FormGroup, Validators } from "@angular/forms";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
@@ -18,18 +26,45 @@ export class SendAccessEmailComponent implements OnInit, OnDestroy {
|
||||
protected otp: FormControl;
|
||||
|
||||
readonly loading = input.required<boolean>();
|
||||
readonly backToEmail = output<void>();
|
||||
|
||||
constructor() {}
|
||||
|
||||
ngOnInit() {
|
||||
this.email = new FormControl("", Validators.required);
|
||||
this.otp = new FormControl("", Validators.required);
|
||||
this.otp = new FormControl("");
|
||||
this.formGroup().addControl("email", this.email);
|
||||
this.formGroup().addControl("otp", this.otp);
|
||||
}
|
||||
|
||||
// Update validators when enterOtp changes
|
||||
effect(() => {
|
||||
const isOtpMode = this.enterOtp();
|
||||
if (isOtpMode) {
|
||||
// In OTP mode: email is not required (already entered), otp is required
|
||||
this.email.clearValidators();
|
||||
this.otp.setValidators([Validators.required]);
|
||||
} else {
|
||||
// In email mode: email is required, otp is not required
|
||||
this.email.setValidators([Validators.required]);
|
||||
this.otp.clearValidators();
|
||||
}
|
||||
this.email.updateValueAndValidity();
|
||||
this.otp.updateValueAndValidity();
|
||||
});
|
||||
}
|
||||
ngOnDestroy() {
|
||||
this.formGroup().removeControl("email");
|
||||
this.formGroup().removeControl("otp");
|
||||
}
|
||||
|
||||
onBackClick() {
|
||||
this.backToEmail.emit();
|
||||
if (this.otp) {
|
||||
this.otp.clearValidators();
|
||||
this.otp.setValue("");
|
||||
this.otp.setErrors(null);
|
||||
this.otp.markAsUntouched();
|
||||
this.otp.markAsPristine();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<p bitTypography="body1">{{ "sendProtectedPassword" | i18n }}</p>
|
||||
<p bitTypography="body1">{{ "sendProtectedPasswordDontKnow" | i18n }}</p>
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "password" | i18n }}</bit-label>
|
||||
<input bitInput type="password" [formControl]="password" required appInputVerbatim appAutofocus />
|
||||
<bit-hint>{{ "sendProtectedPasswordDontKnow" | i18n }}</bit-hint>
|
||||
<button type="button" bitIconButton bitSuffix bitPasswordInputToggle></button>
|
||||
</bit-form-field>
|
||||
<div class="tw-flex">
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
<bit-callout *ngIf="send.text.hidden" type="info">{{ "sendHiddenByDefault" | i18n }}</bit-callout>
|
||||
<bit-form-field [formGroup]="formGroup">
|
||||
<textarea id="text" bitInput rows="8" name="Text" formControlName="sendText" readonly></textarea>
|
||||
</bit-form-field>
|
||||
<div class="tw-mb-3">
|
||||
@if (send.text.hidden) {
|
||||
<button bitButton type="button" buttonType="secondary" [block]="true" (click)="toggleText()">
|
||||
<bit-icon class="bwi-lg" [name]="showText ? 'bwi-eye' : 'bwi-eye-slash'"></bit-icon>
|
||||
{{ "toggleVisibility" | i18n }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="tw-mb-3">
|
||||
<button
|
||||
bitButton
|
||||
type="button"
|
||||
buttonType="secondary"
|
||||
buttonType="primary"
|
||||
[block]="true"
|
||||
(click)="toggleText()"
|
||||
*ngIf="send.text.hidden"
|
||||
(click)="copyText()"
|
||||
startIcon="bwi-clone"
|
||||
>
|
||||
<i
|
||||
class="bwi bwi-lg"
|
||||
aria-hidden="true"
|
||||
[ngClass]="{ 'bwi-eye': !showText, 'bwi-eye-slash': showText }"
|
||||
></i>
|
||||
{{ "toggleVisibility" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="tw-mb-3">
|
||||
<button bitButton type="button" buttonType="primary" [block]="true" (click)="copyText()">
|
||||
<i class="bwi bwi-clone" aria-hidden="true"></i> {{ "copyValue" | i18n }}
|
||||
{{ "copyValue" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { FormBuilder } from "@angular/forms";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SendAccessView } from "@bitwarden/common/tools/send/models/view/send-access.view";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
import { IconModule, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
|
||||
@@ -15,7 +15,7 @@ import { SharedModule } from "../../../shared";
|
||||
@Component({
|
||||
selector: "app-send-access-text",
|
||||
templateUrl: "send-access-text.component.html",
|
||||
imports: [SharedModule],
|
||||
imports: [SharedModule, IconModule],
|
||||
})
|
||||
export class SendAccessTextComponent {
|
||||
private _send: SendAccessView = null;
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
@if (loading()) {
|
||||
<div class="tw-text-center">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</div>
|
||||
}
|
||||
<form [formGroup]="sendAccessForm" (ngSubmit)="onSubmit()">
|
||||
@if (error()) {
|
||||
<div class="tw-text-main tw-text-center">
|
||||
@@ -31,6 +21,7 @@
|
||||
[formGroup]="sendAccessForm"
|
||||
[enterOtp]="enterOtp()"
|
||||
[loading]="loading()"
|
||||
(backToEmail)="onBackToEmail()"
|
||||
></app-send-access-email>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response
|
||||
import { SEND_KDF_ITERATIONS } from "@bitwarden/common/tools/send/send-kdf";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
import { AnonLayoutWrapperDataService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
|
||||
@@ -52,6 +52,7 @@ export class SendAuthComponent implements OnInit {
|
||||
authType = AuthType;
|
||||
|
||||
private expiredAuthAttempts = 0;
|
||||
private otpSubmitted = false;
|
||||
|
||||
readonly loading = signal<boolean>(false);
|
||||
readonly error = signal<boolean>(false);
|
||||
@@ -69,6 +70,7 @@ export class SendAuthComponent implements OnInit {
|
||||
private formBuilder: FormBuilder,
|
||||
private configService: ConfigService,
|
||||
private sendTokenService: SendTokenService,
|
||||
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
@@ -79,13 +81,22 @@ export class SendAuthComponent implements OnInit {
|
||||
this.loading.set(true);
|
||||
this.unavailable.set(false);
|
||||
this.error.set(false);
|
||||
const sendEmailOtp = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP);
|
||||
if (sendEmailOtp) {
|
||||
await this.attemptV2Access();
|
||||
} else {
|
||||
await this.attemptV1Access();
|
||||
try {
|
||||
const sendEmailOtp = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP);
|
||||
if (sendEmailOtp) {
|
||||
await this.attemptV2Access();
|
||||
} else {
|
||||
await this.attemptV1Access();
|
||||
}
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
this.loading.set(false);
|
||||
}
|
||||
|
||||
onBackToEmail() {
|
||||
this.enterOtp.set(false);
|
||||
this.otpSubmitted = false;
|
||||
this.updatePageTitle();
|
||||
}
|
||||
|
||||
private async attemptV1Access() {
|
||||
@@ -103,7 +114,27 @@ export class SendAuthComponent implements OnInit {
|
||||
} catch (e) {
|
||||
if (e instanceof ErrorResponse) {
|
||||
if (e.statusCode === 401) {
|
||||
if (this.sendAuthType() === AuthType.Password) {
|
||||
// Password was already required, so this is an invalid password error
|
||||
const passwordControl = this.sendAccessForm.get("password");
|
||||
if (passwordControl) {
|
||||
passwordControl.setErrors({
|
||||
invalidPassword: { message: this.i18nService.t("sendPasswordInvalidAskOwner") },
|
||||
});
|
||||
passwordControl.markAsTouched();
|
||||
}
|
||||
}
|
||||
// Set auth type to Password (either first time or refresh)
|
||||
this.sendAuthType.set(AuthType.Password);
|
||||
} else if (e.statusCode === 400 && this.sendAuthType() === AuthType.Password) {
|
||||
// Server returns 400 for SendAccessResult.PasswordInvalid
|
||||
const passwordControl = this.sendAccessForm.get("password");
|
||||
if (passwordControl) {
|
||||
passwordControl.setErrors({
|
||||
invalidPassword: { message: this.i18nService.t("sendPasswordInvalidAskOwner") },
|
||||
});
|
||||
passwordControl.markAsTouched();
|
||||
}
|
||||
} else if (e.statusCode === 404) {
|
||||
this.unavailable.set(true);
|
||||
} else {
|
||||
@@ -160,22 +191,32 @@ export class SendAuthComponent implements OnInit {
|
||||
this.expiredAuthAttempts = 0;
|
||||
if (emailRequired(response.error)) {
|
||||
this.sendAuthType.set(AuthType.Email);
|
||||
this.updatePageTitle();
|
||||
} else if (emailAndOtpRequired(response.error)) {
|
||||
this.enterOtp.set(true);
|
||||
if (this.otpSubmitted) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("invalidEmailOrVerificationCode"),
|
||||
});
|
||||
}
|
||||
this.otpSubmitted = true;
|
||||
this.updatePageTitle();
|
||||
} else if (otpInvalid(response.error)) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("invalidVerificationCode"),
|
||||
message: this.i18nService.t("invalidEmailOrVerificationCode"),
|
||||
});
|
||||
} else if (passwordHashB64Required(response.error)) {
|
||||
this.sendAuthType.set(AuthType.Password);
|
||||
this.updatePageTitle();
|
||||
} else if (passwordHashB64Invalid(response.error)) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("invalidSendPassword"),
|
||||
this.sendAccessForm.controls.password?.setErrors({
|
||||
invalidPassword: { message: this.i18nService.t("sendPasswordInvalidAskOwner") },
|
||||
});
|
||||
this.sendAccessForm.controls.password?.markAsTouched();
|
||||
} else if (sendIdInvalid(response.error)) {
|
||||
this.unavailable.set(true);
|
||||
} else {
|
||||
@@ -207,4 +248,26 @@ export class SendAuthComponent implements OnInit {
|
||||
);
|
||||
return Utils.fromBufferToB64(passwordHash) as SendHashedPasswordB64;
|
||||
}
|
||||
|
||||
private updatePageTitle(): void {
|
||||
const authType = this.sendAuthType();
|
||||
|
||||
if (authType === AuthType.Email) {
|
||||
if (this.enterOtp()) {
|
||||
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
|
||||
pageTitle: { key: "enterTheCodeSentToYourEmail" },
|
||||
pageSubtitle: this.sendAccessForm.value.email ?? null,
|
||||
});
|
||||
} else {
|
||||
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
|
||||
pageTitle: { key: "verifyYourEmailToViewThisSend" },
|
||||
pageSubtitle: null,
|
||||
});
|
||||
}
|
||||
} else if (authType === AuthType.Password) {
|
||||
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
|
||||
pageTitle: { key: "sendAccessPasswordTitle" },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,7 @@
|
||||
|
||||
@if (loading()) {
|
||||
<div class="tw-text-center">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
<bit-spinner></bit-spinner>
|
||||
</div>
|
||||
} @else {
|
||||
@if (unavailable()) {
|
||||
@@ -47,7 +42,11 @@
|
||||
}
|
||||
}
|
||||
@if (expirationDate()) {
|
||||
<p class="tw-text-center tw-text-muted">Expires: {{ expirationDate() | date: "medium" }}</p>
|
||||
@let formattedExpirationTime = expirationDate() | date: "shortTime";
|
||||
@let formattedExpirationDate = expirationDate() | date: "mediumDate";
|
||||
<p class="tw-text-center tw-text-muted tw-text-sm">
|
||||
{{ "sendExpiresOn" | i18n: formattedExpirationTime : formattedExpirationDate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -21,7 +21,11 @@ import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response
|
||||
import { SendAccessView } from "@bitwarden/common/tools/send/models/view/send-access.view";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
|
||||
import { AnonLayoutWrapperDataService, ToastService } from "@bitwarden/components";
|
||||
import {
|
||||
AnonLayoutWrapperDataService,
|
||||
SpinnerComponent,
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
@@ -32,7 +36,7 @@ import { SendAccessTextComponent } from "./send-access-text.component";
|
||||
@Component({
|
||||
selector: "app-send-view",
|
||||
templateUrl: "send-view.component.html",
|
||||
imports: [SendAccessFileComponent, SendAccessTextComponent, SharedModule],
|
||||
imports: [SendAccessFileComponent, SendAccessTextComponent, SharedModule, SpinnerComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SendViewComponent implements OnInit {
|
||||
@@ -69,6 +73,9 @@ export class SendViewComponent implements OnInit {
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.layoutWrapperDataService.setAnonLayoutWrapperData({
|
||||
pageTitle: { key: "sendAccessContentTitle" },
|
||||
});
|
||||
void this.load();
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,6 @@ describe("VaultItemDialogComponent", () => {
|
||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||
{ provide: DIALOG_DATA, useValue: { ...baseParams } },
|
||||
{ provide: DialogRef, useValue: {} },
|
||||
{ provide: DialogService, useValue: {} },
|
||||
{
|
||||
provide: ToastService,
|
||||
useValue: {
|
||||
@@ -173,7 +172,9 @@ describe("VaultItemDialogComponent", () => {
|
||||
{ provide: SyncService, useValue: {} },
|
||||
{ provide: CipherRiskService, useValue: {} },
|
||||
],
|
||||
}).compileComponents();
|
||||
})
|
||||
.overrideProvider(DialogService, { useValue: {} })
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(TestVaultItemDialogComponent);
|
||||
component = fixture.componentInstance;
|
||||
@@ -374,6 +375,29 @@ describe("VaultItemDialogComponent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("disableEdit", () => {
|
||||
it("returns false when formConfig mode is partial-edit even if canEdit is false", () => {
|
||||
component["canEdit"] = false;
|
||||
component.setTestFormConfig({ ...baseFormConfig, mode: "partial-edit" });
|
||||
|
||||
expect(component["disableEdit"]).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when canEdit is false and formConfig mode is not partial-edit", () => {
|
||||
component["canEdit"] = false;
|
||||
component.setTestFormConfig({ ...baseFormConfig, mode: "edit" });
|
||||
|
||||
expect(component["disableEdit"]).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when canEdit is true regardless of formConfig mode", () => {
|
||||
component["canEdit"] = true;
|
||||
component.setTestFormConfig({ ...baseFormConfig, mode: "edit" });
|
||||
|
||||
expect(component["disableEdit"]).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("changeMode", () => {
|
||||
beforeEach(() => {
|
||||
component.setTestCipher({ type: CipherType.Login, id: "cipher-id" });
|
||||
|
||||
@@ -100,7 +100,7 @@ export interface VaultItemDialogParams {
|
||||
/**
|
||||
* Function to restore a cipher from the trash.
|
||||
*/
|
||||
restore?: (c: CipherViewLike) => Promise<boolean>;
|
||||
restore?: (c: CipherViewLike) => Promise<void>;
|
||||
}
|
||||
|
||||
export const VaultItemDialogResult = {
|
||||
@@ -273,7 +273,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
protected get disableEdit() {
|
||||
return !this.canEdit;
|
||||
return !this.canEdit && this.formConfig.mode !== "partial-edit";
|
||||
}
|
||||
|
||||
protected get showEdit() {
|
||||
@@ -396,7 +396,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
|
||||
// If user cannot edit and dialog opened in form mode, force to view mode
|
||||
if (!this.canEdit && this.params.mode === "form") {
|
||||
if (!this.canEdit && this.formConfig.mode !== "partial-edit" && this.params.mode === "form") {
|
||||
this.params.mode = "view";
|
||||
this.loadForm = false;
|
||||
this.updateTitle();
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
</td>
|
||||
<td bitCell [ngClass]="RowHeightClass" class="tw-truncate">
|
||||
<div class="tw-inline-flex tw-w-full">
|
||||
<!-- Opt out of router focus manager via [state] input, since the dialog will handle focus -->
|
||||
<button
|
||||
bitLink
|
||||
class="tw-overflow-hidden tw-text-ellipsis tw-text-start tw-leading-snug"
|
||||
@@ -27,6 +28,10 @@
|
||||
type="button"
|
||||
appStopProp
|
||||
aria-haspopup="dialog"
|
||||
id="cipher-btn-{{ cipher.id }}"
|
||||
[state]="{
|
||||
focusAfterNav: false,
|
||||
}"
|
||||
>
|
||||
{{ cipher.name }}
|
||||
</button>
|
||||
|
||||
@@ -16,9 +16,11 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
|
||||
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
import { IconButtonModule, MenuModule } from "@bitwarden/components";
|
||||
import { CopyCipherFieldDirective, CopyCipherFieldService } from "@bitwarden/vault";
|
||||
|
||||
import { OrganizationNameBadgeComponent } from "../../individual-vault/organization-badge/organization-name-badge.component";
|
||||
import {
|
||||
CopyCipherFieldDirective,
|
||||
CopyCipherFieldService,
|
||||
OrganizationNameBadgeComponent,
|
||||
} from "@bitwarden/vault";
|
||||
|
||||
import { VaultCipherRowComponent } from "./vault-cipher-row.component";
|
||||
|
||||
@@ -45,7 +47,7 @@ describe("VaultCipherRowComponent", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [VaultCipherRowComponent, OrganizationNameBadgeComponent],
|
||||
declarations: [VaultCipherRowComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule.forRoot([]),
|
||||
@@ -53,6 +55,7 @@ describe("VaultCipherRowComponent", () => {
|
||||
IconButtonModule,
|
||||
JslibModule,
|
||||
CopyCipherFieldDirective,
|
||||
OrganizationNameBadgeComponent,
|
||||
],
|
||||
providers: [
|
||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user