1
0
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:
Vicki League
2026-02-27 10:56:25 -05:00
809 changed files with 34107 additions and 9716 deletions

View File

@@ -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}

View File

@@ -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",

View File

@@ -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);

View File

@@ -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,
});
}

View File

@@ -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>
}

View File

@@ -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) {

View File

@@ -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");

View File

@@ -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");

View File

@@ -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>

View File

@@ -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,

View File

@@ -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" />

View File

@@ -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(),

View File

@@ -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>

View File

@@ -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,
});
});
});

View File

@@ -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(),

View File

@@ -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

View File

@@ -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);
};
}

View File

@@ -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>

View File

@@ -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);
};
}

View File

@@ -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>

View File

@@ -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,
);

View File

@@ -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 {}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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";

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"

View File

@@ -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,
);
});
});
});

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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";

View File

@@ -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">

View File

@@ -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>

View File

@@ -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 -->

View File

@@ -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,
],
})

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,

View File

@@ -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>

View File

@@ -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,

View File

@@ -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>

View File

@@ -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">

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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(

View File

@@ -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">

View File

@@ -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);

View File

@@ -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>

View File

@@ -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(

View File

@@ -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;
},

View File

@@ -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 }}

View File

@@ -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",
});
});
});
});

View File

@@ -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

View File

@@ -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 =

View File

@@ -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();
};
}

View File

@@ -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,
})

View File

@@ -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: {

View File

@@ -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

View File

@@ -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">

View File

@@ -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) }}

View File

@@ -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>
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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));
}
}
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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;

View File

@@ -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>
}

View File

@@ -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();
});

View File

@@ -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>
`,
}),
};

View File

@@ -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

View File

@@ -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>);
}
}

View File

@@ -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: {

View File

@@ -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

View File

@@ -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))),
);
}
}

View File

@@ -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>

View File

@@ -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",

View File

@@ -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: [

View File

@@ -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>

View File

@@ -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],
})
/**

View File

@@ -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>
}

View File

@@ -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();
}
}
}

View File

@@ -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">

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>
}
}

View File

@@ -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" },
});
}
}
}

View File

@@ -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>
}

View File

@@ -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();
}

View File

@@ -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" });

View File

@@ -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();

View File

@@ -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>

View File

@@ -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