1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-27 18:13:29 +00:00

Merge branch 'main' into km/sdk-key-rotation

This commit is contained in:
Bernd Schoolmann
2026-02-17 10:48:59 +01:00
299 changed files with 2935 additions and 3741 deletions

View File

@@ -1,245 +0,0 @@
import { Directive } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl } from "@angular/forms";
import { firstValueFrom, lastValueFrom, debounceTime, combineLatest, BehaviorSubject } from "rxjs";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
import {
OrganizationUserStatusType,
OrganizationUserType,
ProviderUserStatusType,
ProviderUserType,
} from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProviderUserUserDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user.response";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { OrganizationUserView } from "../organizations/core/views/organization-user.view";
import { UserConfirmComponent } from "../organizations/manage/user-confirm.component";
import { MemberActionResult } from "../organizations/members/services/member-actions/member-actions.service";
import { PeopleTableDataSource, peopleFilter } from "./people-table-data-source";
export type StatusType = OrganizationUserStatusType | ProviderUserStatusType;
export type UserViewTypes = ProviderUserUserDetailsResponse | OrganizationUserView;
/**
* A refactored copy of BasePeopleComponent, using the component library table and other modern features.
* This will replace BasePeopleComponent once all subclasses have been changed over to use this class.
*/
@Directive()
export abstract class BaseMembersComponent<UserView extends UserViewTypes> {
/**
* Shows a banner alerting the admin that users need to be confirmed.
*/
get showConfirmUsers(): boolean {
return (
this.dataSource.activeUserCount > 1 &&
this.dataSource.confirmedUserCount > 0 &&
this.dataSource.confirmedUserCount < 3 &&
this.dataSource.acceptedUserCount > 0
);
}
get showBulkConfirmUsers(): boolean {
return this.dataSource
.getCheckedUsers()
.every((member) => member.status == this.userStatusType.Accepted);
}
get showBulkReinviteUsers(): boolean {
return this.dataSource
.getCheckedUsers()
.every((member) => member.status == this.userStatusType.Invited);
}
abstract userType: typeof OrganizationUserType | typeof ProviderUserType;
abstract userStatusType: typeof OrganizationUserStatusType | typeof ProviderUserStatusType;
protected abstract dataSource: PeopleTableDataSource<UserView>;
firstLoaded: boolean = false;
/**
* The currently selected status filter, or undefined to show all active users.
*/
status?: StatusType;
/**
* The currently executing promise - used to avoid multiple user actions executing at once.
*/
actionPromise?: Promise<MemberActionResult>;
protected searchControl = new FormControl("", { nonNullable: true });
protected statusToggle = new BehaviorSubject<StatusType | undefined>(undefined);
constructor(
protected apiService: ApiService,
protected i18nService: I18nService,
protected keyService: KeyService,
protected validationService: ValidationService,
protected logService: LogService,
protected userNamePipe: UserNamePipe,
protected dialogService: DialogService,
protected organizationManagementPreferencesService: OrganizationManagementPreferencesService,
protected toastService: ToastService,
) {
// Connect the search input and status toggles to the table dataSource filter
combineLatest([this.searchControl.valueChanges.pipe(debounceTime(200)), this.statusToggle])
.pipe(takeUntilDestroyed())
.subscribe(
([searchText, status]) => (this.dataSource.filter = peopleFilter(searchText, status)),
);
}
abstract edit(user: UserView, organization?: Organization): void;
abstract getUsers(organization?: Organization): Promise<ListResponse<UserView> | UserView[]>;
abstract removeUser(id: string, organization?: Organization): Promise<MemberActionResult>;
abstract reinviteUser(id: string, organization?: Organization): Promise<MemberActionResult>;
abstract confirmUser(
user: UserView,
publicKey: Uint8Array,
organization?: Organization,
): Promise<MemberActionResult>;
abstract invite(organization?: Organization): void;
async load(organization?: Organization) {
// Load new users from the server
const response = await this.getUsers(organization);
// GetUsers can return a ListResponse or an Array
if (response instanceof ListResponse) {
this.dataSource.data = response.data != null && response.data.length > 0 ? response.data : [];
} else if (Array.isArray(response)) {
this.dataSource.data = response;
}
this.firstLoaded = true;
}
protected async removeUserConfirmationDialog(user: UserView) {
return this.dialogService.openSimpleDialog({
title: this.userNamePipe.transform(user),
content: { key: "removeUserConfirmation" },
type: "warning",
});
}
async remove(user: UserView, organization?: Organization) {
const confirmed = await this.removeUserConfirmationDialog(user);
if (!confirmed) {
return false;
}
this.actionPromise = this.removeUser(user.id, organization);
try {
const result = await this.actionPromise;
if (result.success) {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("removedUserId", this.userNamePipe.transform(user)),
});
this.dataSource.removeUser(user);
} else {
throw new Error(result.error);
}
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = undefined;
}
async reinvite(user: UserView, organization?: Organization) {
if (this.actionPromise != null) {
return;
}
this.actionPromise = this.reinviteUser(user.id, organization);
try {
const result = await this.actionPromise;
if (result.success) {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("hasBeenReinvited", this.userNamePipe.transform(user)),
});
} else {
throw new Error(result.error);
}
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = undefined;
}
async confirm(user: UserView, organization?: Organization) {
const confirmUser = async (publicKey: Uint8Array) => {
try {
this.actionPromise = this.confirmUser(user, publicKey, organization);
const result = await this.actionPromise;
if (result.success) {
user.status = this.userStatusType.Confirmed;
this.dataSource.replaceUser(user);
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("hasBeenConfirmed", this.userNamePipe.transform(user)),
});
} else {
throw new Error(result.error);
}
} catch (e) {
this.validationService.showError(e);
throw e;
} finally {
this.actionPromise = undefined;
}
};
if (this.actionPromise != null) {
return;
}
try {
const publicKeyResponse = await this.apiService.getUserPublicKey(user.userId);
const publicKey = Utils.fromB64ToArray(publicKeyResponse.publicKey);
const autoConfirm = await firstValueFrom(
this.organizationManagementPreferencesService.autoConfirmFingerPrints.state$,
);
if (user == null) {
throw new Error("Cannot confirm null user.");
}
if (autoConfirm == null || !autoConfirm) {
const dialogRef = UserConfirmComponent.open(this.dialogService, {
data: {
name: this.userNamePipe.transform(user),
userId: user.userId,
publicKey: publicKey,
confirmUser: () => confirmUser(publicKey),
},
});
await lastValueFrom(dialogRef.closed);
return;
}
try {
const fingerprint = await this.keyService.getFingerprint(user.userId, publicKey);
this.logService.info(`User's fingerprint: ${fingerprint.join("-")}`);
} catch (e) {
this.logService.error(e);
}
await confirmUser(publicKey);
} catch (e) {
this.logService.error(`Handled exception: ${e}`);
}
}
}

View File

@@ -472,7 +472,7 @@ export class VaultComponent implements OnInit, OnDestroy {
collections,
filter.collectionId,
);
searchableCollectionNodes = selectedCollection.children ?? [];
searchableCollectionNodes = selectedCollection?.children ?? [];
}
let collectionsToReturn: CollectionAdminView[] = [];
@@ -962,10 +962,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 +974,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 +996,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;
}
};

View File

@@ -2,11 +2,8 @@
// @ts-strict-ignore
import { Component, Inject, OnInit } from "@angular/core";
import { FormControl, FormGroup } from "@angular/forms";
import { firstValueFrom } from "rxjs";
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
@@ -17,8 +14,6 @@ export type UserConfirmDialogData = {
name: string;
userId: string;
publicKey: Uint8Array;
// @TODO remove this when doing feature flag cleanup for members component refactor.
confirmUser?: (publicKey: Uint8Array) => Promise<void>;
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
@@ -46,7 +41,6 @@ export class UserConfirmComponent implements OnInit {
private keyService: KeyService,
private logService: LogService,
private organizationManagementPreferencesService: OrganizationManagementPreferencesService,
private configService: ConfigService,
) {
this.name = data.name;
this.userId = data.userId;
@@ -76,13 +70,6 @@ export class UserConfirmComponent implements OnInit {
await this.organizationManagementPreferencesService.autoConfirmFingerPrints.set(true);
}
const membersComponentRefactorEnabled = await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.MembersComponentRefactor),
);
if (!membersComponentRefactorEnabled) {
await this.data.confirmUser(this.publicKey);
}
this.dialogRef.close(true);
};

View File

@@ -9,7 +9,6 @@ import {
} from "@bitwarden/common/admin-console/enums";
import { ProviderUserBulkResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user-bulk.response";
import { ProviderUserUserDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user.response";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { DIALOG_DATA, DialogConfig, DialogService } from "@bitwarden/components";
@@ -34,7 +33,7 @@ type BulkStatusEntry = {
type BulkStatusDialogData = {
users: Array<OrganizationUserView | ProviderUserUserDetailsResponse>;
filteredUsers: Array<OrganizationUserView | ProviderUserUserDetailsResponse>;
request: Promise<ListResponse<OrganizationUserBulkResponse | ProviderUserBulkResponse>>;
request: Promise<OrganizationUserBulkResponse[] | ProviderUserBulkResponse[]>;
successfulMessage: string;
};
@@ -63,7 +62,7 @@ export class BulkStatusComponent implements OnInit {
async showBulkStatus(data: BulkStatusDialogData) {
try {
const response = await data.request;
const keyedErrors: any = response.data
const keyedErrors: any = (response ?? [])
.filter((r) => r.error !== "")
.reduce((a, x) => ({ ...a, [x.id]: x.error }), {});
const keyedFilteredUsers: any = data.filteredUsers.reduce(

View File

@@ -195,9 +195,9 @@ export class MemberDialogComponent implements OnDestroy {
private accountService: AccountService,
organizationService: OrganizationService,
private toastService: ToastService,
private configService: ConfigService,
private deleteManagedMemberWarningService: DeleteManagedMemberWarningService,
private organizationUserService: OrganizationUserService,
private configService: ConfigService,
) {
this.organization$ = accountService.activeAccount$.pipe(
getUserId,

View File

@@ -1,495 +0,0 @@
@let organization = this.organization();
@if (organization) {
<app-organization-free-trial-warning
[organization]="organization"
(clicked)="billingConstraint.navigateToPaymentMethod(organization)"
>
</app-organization-free-trial-warning>
<app-header>
<bit-search
class="tw-grow"
[formControl]="searchControl"
[placeholder]="'searchMembers' | i18n"
></bit-search>
<button
type="button"
bitButton
buttonType="primary"
(click)="invite(organization)"
[disabled]="!firstLoaded"
*ngIf="showUserManagementControls()"
>
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "inviteMember" | i18n }}
</button>
</app-header>
<div class="tw-mb-4 tw-flex tw-flex-col tw-space-y-4">
<bit-toggle-group
[selected]="status"
(selectedChange)="statusToggle.next($event)"
[attr.aria-label]="'memberStatusFilter' | i18n"
*ngIf="showUserManagementControls()"
>
<bit-toggle [value]="null">
{{ "all" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.activeUserCount as allCount">{{
allCount
}}</span>
</bit-toggle>
<bit-toggle [value]="userStatusType.Invited">
{{ "invited" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.invitedUserCount as invitedCount">{{
invitedCount
}}</span>
</bit-toggle>
<bit-toggle [value]="userStatusType.Accepted">
{{ "needsConfirmation" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.acceptedUserCount as acceptedUserCount">{{
acceptedUserCount
}}</span>
</bit-toggle>
<bit-toggle [value]="userStatusType.Revoked">
{{ "revoked" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.revokedUserCount as revokedCount">{{
revokedCount
}}</span>
</bit-toggle>
</bit-toggle-group>
</div>
<ng-container *ngIf="!firstLoaded">
<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="firstLoaded">
<p *ngIf="!dataSource.filteredData.length">{{ "noMembersInList" | i18n }}</p>
<ng-container *ngIf="dataSource.filteredData.length">
<bit-callout
type="info"
title="{{ 'confirmUsers' | i18n }}"
icon="bwi-check-circle"
*ngIf="showConfirmUsers"
>
{{ "usersNeedConfirmed" | i18n }}
</bit-callout>
<!-- The padding on the bottom of the cdk-virtual-scroll-viewport element is required to prevent table row content
from overflowing the <main> element. -->
<cdk-virtual-scroll-viewport bitScrollLayout [itemSize]="rowHeight" class="tw-pb-8">
<bit-table [dataSource]="dataSource">
<ng-container header>
<tr>
<th bitCell class="tw-w-20" *ngIf="showUserManagementControls()">
<input
type="checkbox"
bitCheckbox
class="tw-mr-1"
(change)="dataSource.checkAllFilteredUsers($any($event.target).checked)"
id="selectAll"
/>
<label class="tw-mb-0 !tw-font-medium !tw-text-muted" for="selectAll">{{
"all" | i18n
}}</label>
</th>
<th bitCell bitSortable="email" default>{{ "name" | i18n }}</th>
<th bitCell>{{ (organization.useGroups ? "groups" : "collections") | i18n }}</th>
<th bitCell bitSortable="type">{{ "role" | i18n }}</th>
<th bitCell>{{ "policies" | i18n }}</th>
<th bitCell>
<div class="tw-flex tw-flex-row tw-items-center tw-justify-end tw-gap-2">
<button
type="button"
bitIconButton="bwi-download"
size="small"
[bitAction]="exportMembers"
[disabled]="!firstLoaded"
label="{{ 'export' | i18n }}"
></button>
<button
[bitMenuTriggerFor]="headerMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
label="{{ 'options' | i18n }}"
*ngIf="showUserManagementControls()"
></button>
</div>
<bit-menu #headerMenu>
<ng-container *ngIf="canUseSecretsManager()">
<button type="button" bitMenuItem (click)="bulkEnableSM(organization)">
{{ "activateSecretsManager" | i18n }}
</button>
<bit-menu-divider></bit-menu-divider>
</ng-container>
<button
type="button"
bitMenuItem
(click)="bulkReinvite(organization)"
*ngIf="showBulkReinviteUsers"
>
<i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
{{ (isSingleInvite ? "resendInvitation" : "reinviteSelected") | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="bulkConfirm(organization)"
*ngIf="showBulkConfirmUsers"
>
<span class="tw-text-success">
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
{{ "confirmSelected" | i18n }}
</span>
</button>
<button
type="button"
bitMenuItem
(click)="bulkRestore(organization)"
*ngIf="showBulkRestoreUsers"
>
<i class="bwi bwi-fw bwi-plus-circle" aria-hidden="true"></i>
{{ "restoreAccess" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="bulkRevoke(organization)"
*ngIf="showBulkRevokeUsers"
>
<i class="bwi bwi-fw bwi-minus-circle" aria-hidden="true"></i>
{{ "revokeAccess" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="bulkRemove(organization)"
*ngIf="showBulkRemoveUsers"
>
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-fw bwi-close"></i>
{{ "remove" | i18n }}
</span>
</button>
<button
type="button"
bitMenuItem
(click)="bulkDelete(organization)"
*ngIf="showBulkDeleteUsers"
>
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-fw bwi-trash"></i>
{{ "delete" | i18n }}
</span>
</button>
</bit-menu>
</th>
</tr>
</ng-container>
<ng-template body let-rows$>
<tr
bitRow
*cdkVirtualFor="let u of rows$"
alignContent="middle"
[ngClass]="rowHeightClass"
>
<td bitCell (click)="dataSource.checkUser(u)" *ngIf="showUserManagementControls()">
<input type="checkbox" bitCheckbox [(ngModel)]="$any(u).checked" />
</td>
<ng-container *ngIf="showUserManagementControls(); else readOnlyUserInfo">
<td bitCell (click)="edit(u, organization)" class="tw-cursor-pointer">
<div class="tw-flex tw-items-center">
<bit-avatar
size="small"
[text]="u | userName"
[id]="u.userId"
[color]="u.avatarColor"
class="tw-mr-3"
></bit-avatar>
<div class="tw-flex tw-flex-col">
<div class="tw-flex tw-flex-row tw-gap-2">
<button type="button" bitLink>
{{ u.name ?? u.email }}
</button>
<span
bitBadge
class="tw-text-xs"
variant="secondary"
*ngIf="u.status === userStatusType.Invited"
>
{{ "invited" | i18n }}
</span>
<span
bitBadge
class="tw-text-xs"
variant="warning"
*ngIf="u.status === userStatusType.Accepted"
>
{{ "needsConfirmation" | i18n }}
</span>
<span
bitBadge
class="tw-text-xs"
variant="secondary"
*ngIf="u.status === userStatusType.Revoked"
>
{{ "revoked" | i18n }}
</span>
</div>
<div class="tw-text-sm tw-text-muted" *ngIf="u.name">
{{ u.email }}
</div>
</div>
</div>
</td>
</ng-container>
<ng-template #readOnlyUserInfo>
<td bitCell>
<div class="tw-flex tw-items-center">
<bit-avatar
size="small"
[text]="u | userName"
[id]="u.userId"
[color]="u.avatarColor"
class="tw-mr-3"
></bit-avatar>
<div class="tw-flex tw-flex-col">
<div class="tw-flex tw-flex-row tw-gap-2">
<span>{{ u.name ?? u.email }}</span>
<span
bitBadge
class="tw-text-xs"
variant="secondary"
*ngIf="u.status === userStatusType.Invited"
>
{{ "invited" | i18n }}
</span>
<span
bitBadge
class="tw-text-xs"
variant="warning"
*ngIf="u.status === userStatusType.Accepted"
>
{{ "needsConfirmation" | i18n }}
</span>
<span
bitBadge
class="tw-text-xs"
variant="secondary"
*ngIf="u.status === userStatusType.Revoked"
>
{{ "revoked" | i18n }}
</span>
</div>
<div class="tw-text-sm tw-text-muted" *ngIf="u.name">
{{ u.email }}
</div>
</div>
</div>
</td>
</ng-template>
<ng-container *ngIf="showUserManagementControls(); else readOnlyGroupsCell">
<td
bitCell
(click)="
edit(
u,
organization,
organization.useGroups ? memberTab.Groups : memberTab.Collections
)
"
class="tw-cursor-pointer"
>
<bit-badge-list
[items]="organization.useGroups ? u.groupNames : u.collectionNames"
[maxItems]="3"
variant="secondary"
></bit-badge-list>
</td>
</ng-container>
<ng-template #readOnlyGroupsCell>
<td bitCell>
<bit-badge-list
[items]="organization.useGroups ? u.groupNames : u.collectionNames"
[maxItems]="3"
variant="secondary"
></bit-badge-list>
</td>
</ng-template>
<ng-container *ngIf="showUserManagementControls(); else readOnlyRoleCell">
<td
bitCell
(click)="edit(u, organization, memberTab.Role)"
class="tw-cursor-pointer tw-text-sm tw-text-muted"
>
{{ u.type | userType }}
</td>
</ng-container>
<ng-template #readOnlyRoleCell>
<td bitCell class="tw-text-sm tw-text-muted">
{{ u.type | userType }}
</td>
</ng-template>
<td bitCell class="tw-text-muted">
<ng-container *ngIf="u.twoFactorEnabled">
<i
class="bwi bwi-lock"
title="{{ 'userUsingTwoStep' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "userUsingTwoStep" | i18n }}</span>
</ng-container>
@let resetPasswordPolicyEnabled = resetPasswordPolicyEnabled$ | async;
<ng-container
*ngIf="showEnrolledStatus($any(u), organization, resetPasswordPolicyEnabled)"
>
<i
class="bwi bwi-key"
title="{{ 'enrolledAccountRecovery' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "enrolledAccountRecovery" | i18n }}</span>
</ng-container>
</td>
<td bitCell>
<div class="tw-flex tw-flex-row tw-items-center tw-justify-end tw-gap-2">
<div class="tw-w-[32px]"></div>
<button
[bitMenuTriggerFor]="rowMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
label="{{ 'options' | i18n }}"
></button>
</div>
<bit-menu #rowMenu>
<ng-container *ngIf="showUserManagementControls()">
<button
type="button"
bitMenuItem
(click)="reinvite(u, organization)"
*ngIf="u.status === userStatusType.Invited"
>
<i aria-hidden="true" class="bwi bwi-envelope"></i>
{{ "resendInvitation" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="confirm(u, organization)"
*ngIf="u.status === userStatusType.Accepted"
>
<span class="tw-text-success">
<i aria-hidden="true" class="bwi bwi-check"></i> {{ "confirm" | i18n }}
</span>
</button>
<bit-menu-divider
*ngIf="
u.status === userStatusType.Accepted || u.status === userStatusType.Invited
"
></bit-menu-divider>
<button
type="button"
bitMenuItem
(click)="edit(u, organization, memberTab.Role)"
>
<i aria-hidden="true" class="bwi bwi-user"></i> {{ "memberRole" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="edit(u, organization, memberTab.Groups)"
*ngIf="organization.useGroups"
>
<i aria-hidden="true" class="bwi bwi-users"></i> {{ "groups" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="edit(u, organization, memberTab.Collections)"
>
<i aria-hidden="true" class="bwi bwi-collection-shared"></i>
{{ "collections" | i18n }}
</button>
<bit-menu-divider></bit-menu-divider>
<button
type="button"
bitMenuItem
(click)="openEventsDialog(u, organization)"
*ngIf="organization.useEvents && u.status === userStatusType.Confirmed"
>
<i aria-hidden="true" class="bwi bwi-file-text"></i> {{ "eventLogs" | i18n }}
</button>
</ng-container>
<!-- Account recovery is available to all users with appropriate permissions -->
<button
type="button"
bitMenuItem
(click)="resetPassword(u, organization)"
*ngIf="allowResetPassword(u, organization, resetPasswordPolicyEnabled)"
>
<i aria-hidden="true" class="bwi bwi-key"></i> {{ "recoverAccount" | i18n }}
</button>
<ng-container *ngIf="showUserManagementControls()">
<button
type="button"
bitMenuItem
(click)="restore(u, organization)"
*ngIf="u.status === userStatusType.Revoked"
>
<i aria-hidden="true" class="bwi bwi-plus-circle"></i>
{{ "restoreAccess" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="revoke(u, organization)"
*ngIf="u.status !== userStatusType.Revoked"
>
<i aria-hidden="true" class="bwi bwi-minus-circle"></i>
{{ "revokeAccess" | i18n }}
</button>
<button
*ngIf="!u.managedByOrganization"
type="button"
bitMenuItem
(click)="remove(u, organization)"
>
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-close"></i> {{ "remove" | i18n }}
</span>
</button>
<button
*ngIf="u.managedByOrganization"
type="button"
bitMenuItem
(click)="deleteUser(u, organization)"
>
<span class="tw-text-danger">
<i class="bwi bwi-trash" aria-hidden="true"></i>
{{ "delete" | i18n }}
</span>
</button>
</ng-container>
</bit-menu>
</td>
</tr>
</ng-template>
</bit-table>
</cdk-virtual-scroll-viewport>
</ng-container>
</ng-container>
}

View File

@@ -1,624 +0,0 @@
import { Component, computed, Signal } from "@angular/core";
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
import { ActivatedRoute } from "@angular/router";
import {
combineLatest,
concatMap,
filter,
firstValueFrom,
from,
map,
merge,
Observable,
shareReplay,
switchMap,
take,
} from "rxjs";
import { OrganizationUserUserDetailsResponse } from "@bitwarden/admin-console/common";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import {
OrganizationUserStatusType,
OrganizationUserType,
PolicyType,
} from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { getById } from "@bitwarden/common/platform/misc";
import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { UserId } from "@bitwarden/user-core";
import { BillingConstraintService } from "@bitwarden/web-vault/app/billing/members/billing-constraint/billing-constraint.service";
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services";
import { BaseMembersComponent } from "../../common/base-members.component";
import {
CloudBulkReinviteLimit,
MaxCheckedCount,
PeopleTableDataSource,
} from "../../common/people-table-data-source";
import { OrganizationUserView } from "../core/views/organization-user.view";
import { AccountRecoveryDialogResultType } from "./components/account-recovery/account-recovery-dialog.component";
import { MemberDialogResult, MemberDialogTab } from "./components/member-dialog";
import {
MemberDialogManagerService,
MemberExportService,
OrganizationMembersService,
} from "./services";
import { DeleteManagedMemberWarningService } from "./services/delete-managed-member/delete-managed-member-warning.service";
import {
MemberActionsService,
MemberActionResult,
} from "./services/member-actions/member-actions.service";
class MembersTableDataSource extends PeopleTableDataSource<OrganizationUserView> {
protected statusType = OrganizationUserStatusType;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "deprecated_members.component.html",
standalone: false,
})
export class MembersComponent extends BaseMembersComponent<OrganizationUserView> {
userType = OrganizationUserType;
userStatusType = OrganizationUserStatusType;
memberTab = MemberDialogTab;
protected dataSource: MembersTableDataSource;
readonly organization: Signal<Organization | undefined>;
status: OrganizationUserStatusType | undefined;
private userId$: Observable<UserId> = this.accountService.activeAccount$.pipe(getUserId);
resetPasswordPolicyEnabled$: Observable<boolean>;
protected readonly canUseSecretsManager: Signal<boolean> = computed(
() => this.organization()?.useSecretsManager ?? false,
);
protected readonly showUserManagementControls: Signal<boolean> = computed(
() => this.organization()?.canManageUsers ?? false,
);
protected billingMetadata$: Observable<OrganizationBillingMetadataResponse>;
// Fixed sizes used for cdkVirtualScroll
protected rowHeight = 66;
protected rowHeightClass = `tw-h-[66px]`;
constructor(
apiService: ApiService,
i18nService: I18nService,
organizationManagementPreferencesService: OrganizationManagementPreferencesService,
keyService: KeyService,
validationService: ValidationService,
logService: LogService,
userNamePipe: UserNamePipe,
dialogService: DialogService,
toastService: ToastService,
private route: ActivatedRoute,
protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService,
private organizationWarningsService: OrganizationWarningsService,
private memberActionsService: MemberActionsService,
private memberDialogManager: MemberDialogManagerService,
protected billingConstraint: BillingConstraintService,
protected memberService: OrganizationMembersService,
private organizationService: OrganizationService,
private accountService: AccountService,
private policyService: PolicyService,
private policyApiService: PolicyApiServiceAbstraction,
private organizationMetadataService: OrganizationMetadataServiceAbstraction,
private memberExportService: MemberExportService,
private environmentService: EnvironmentService,
) {
super(
apiService,
i18nService,
keyService,
validationService,
logService,
userNamePipe,
dialogService,
organizationManagementPreferencesService,
toastService,
);
this.dataSource = new MembersTableDataSource(this.environmentService);
const organization$ = this.route.params.pipe(
concatMap((params) =>
this.userId$.pipe(
switchMap((userId) =>
this.organizationService.organizations$(userId).pipe(getById(params.organizationId)),
),
filter((organization): organization is Organization => organization != null),
shareReplay({ refCount: true, bufferSize: 1 }),
),
),
);
this.organization = toSignal(organization$);
const policies$ = combineLatest([this.userId$, organization$]).pipe(
switchMap(([userId, organization]) =>
organization.isProviderUser
? from(this.policyApiService.getPolicies(organization.id)).pipe(
map((response) => Policy.fromListResponse(response)),
)
: this.policyService.policies$(userId),
),
);
this.resetPasswordPolicyEnabled$ = combineLatest([organization$, policies$]).pipe(
map(
([organization, policies]) =>
policies
.filter((policy) => policy.type === PolicyType.ResetPassword)
.find((p) => p.organizationId === organization.id)?.enabled ?? false,
),
);
combineLatest([this.route.queryParams, organization$])
.pipe(
concatMap(async ([qParams, organization]) => {
await this.load(organization!);
this.searchControl.setValue(qParams.search);
if (qParams.viewEvents != null) {
const user = this.dataSource.data.filter((u) => u.id === qParams.viewEvents);
if (user.length > 0 && user[0].status === OrganizationUserStatusType.Confirmed) {
this.openEventsDialog(user[0], organization!);
}
}
}),
takeUntilDestroyed(),
)
.subscribe();
organization$
.pipe(
switchMap((organization) =>
merge(
this.organizationWarningsService.showInactiveSubscriptionDialog$(organization),
this.organizationWarningsService.showSubscribeBeforeFreeTrialEndsDialog$(organization),
),
),
takeUntilDestroyed(),
)
.subscribe();
this.billingMetadata$ = organization$.pipe(
switchMap((organization) =>
this.organizationMetadataService.getOrganizationMetadata$(organization.id),
),
shareReplay({ bufferSize: 1, refCount: false }),
);
// Stripe is slow, so kick this off in the background but without blocking page load.
// Anyone who needs it will still await the first emission.
this.billingMetadata$.pipe(take(1), takeUntilDestroyed()).subscribe();
}
override async load(organization: Organization) {
await super.load(organization);
}
async getUsers(organization: Organization): Promise<OrganizationUserView[]> {
return await this.memberService.loadUsers(organization);
}
async removeUser(id: string, organization: Organization): Promise<MemberActionResult> {
return await this.memberActionsService.removeUser(organization, id);
}
async revokeUser(id: string, organization: Organization): Promise<MemberActionResult> {
return await this.memberActionsService.revokeUser(organization, id);
}
async restoreUser(id: string, organization: Organization): Promise<MemberActionResult> {
return await this.memberActionsService.restoreUser(organization, id);
}
async reinviteUser(id: string, organization: Organization): Promise<MemberActionResult> {
return await this.memberActionsService.reinviteUser(organization, id);
}
async confirmUser(
user: OrganizationUserView,
publicKey: Uint8Array,
organization: Organization,
): Promise<MemberActionResult> {
return await this.memberActionsService.confirmUser(user, publicKey, organization);
}
async revoke(user: OrganizationUserView, organization: Organization) {
const confirmed = await this.revokeUserConfirmationDialog(user);
if (!confirmed) {
return false;
}
this.actionPromise = this.revokeUser(user.id, organization);
try {
const result = await this.actionPromise;
if (result.success) {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("revokedUserId", this.userNamePipe.transform(user)),
});
await this.load(organization);
} else {
throw new Error(result.error);
}
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = undefined;
}
async restore(user: OrganizationUserView, organization: Organization) {
this.actionPromise = this.restoreUser(user.id, organization);
try {
const result = await this.actionPromise;
if (result.success) {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("restoredUserId", this.userNamePipe.transform(user)),
});
await this.load(organization);
} else {
throw new Error(result.error);
}
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = undefined;
}
allowResetPassword(
orgUser: OrganizationUserView,
organization: Organization,
orgResetPasswordPolicyEnabled: boolean,
): boolean {
return this.memberActionsService.allowResetPassword(
orgUser,
organization,
orgResetPasswordPolicyEnabled,
);
}
showEnrolledStatus(
orgUser: OrganizationUserUserDetailsResponse,
organization: Organization,
orgResetPasswordPolicyEnabled: boolean,
): boolean {
return (
organization.useResetPassword &&
orgUser.resetPasswordEnrolled &&
orgResetPasswordPolicyEnabled
);
}
private async handleInviteDialog(organization: Organization) {
const billingMetadata = await firstValueFrom(this.billingMetadata$);
const allUserEmails = this.dataSource.data?.map((user) => user.email) ?? [];
const result = await this.memberDialogManager.openInviteDialog(
organization,
billingMetadata,
allUserEmails,
);
if (result === MemberDialogResult.Saved) {
await this.load(organization);
}
}
async invite(organization: Organization) {
const billingMetadata = await firstValueFrom(this.billingMetadata$);
const seatLimitResult = this.billingConstraint.checkSeatLimit(organization, billingMetadata);
if (!(await this.billingConstraint.seatLimitReached(seatLimitResult, organization))) {
await this.handleInviteDialog(organization);
this.organizationMetadataService.refreshMetadataCache();
}
}
async edit(
user: OrganizationUserView,
organization: Organization,
initialTab: MemberDialogTab = MemberDialogTab.Role,
) {
const billingMetadata = await firstValueFrom(this.billingMetadata$);
const result = await this.memberDialogManager.openEditDialog(
user,
organization,
billingMetadata,
initialTab,
);
switch (result) {
case MemberDialogResult.Deleted:
this.dataSource.removeUser(user);
break;
case MemberDialogResult.Saved:
case MemberDialogResult.Revoked:
case MemberDialogResult.Restored:
await this.load(organization);
break;
}
}
async bulkRemove(organization: Organization) {
if (this.actionPromise != null) {
return;
}
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
await this.memberDialogManager.openBulkRemoveDialog(organization, users);
this.organizationMetadataService.refreshMetadataCache();
await this.load(organization);
}
async bulkDelete(organization: Organization) {
if (this.actionPromise != null) {
return;
}
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
await this.memberDialogManager.openBulkDeleteDialog(organization, users);
await this.load(organization);
}
async bulkRevoke(organization: Organization) {
await this.bulkRevokeOrRestore(true, organization);
}
async bulkRestore(organization: Organization) {
await this.bulkRevokeOrRestore(false, organization);
}
async bulkRevokeOrRestore(isRevoking: boolean, organization: Organization) {
if (this.actionPromise != null) {
return;
}
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
await this.memberDialogManager.openBulkRestoreRevokeDialog(organization, users, isRevoking);
await this.load(organization);
}
async bulkReinvite(organization: Organization) {
if (this.actionPromise != null) {
return;
}
let users: OrganizationUserView[];
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
users = this.dataSource.getCheckedUsersInVisibleOrder();
} else {
users = this.dataSource.getCheckedUsers();
}
const allInvitedUsers = users.filter((u) => u.status === OrganizationUserStatusType.Invited);
// Capture the original count BEFORE enforcing the limit
const originalInvitedCount = allInvitedUsers.length;
// When feature flag is enabled, limit invited users and uncheck the excess
let filteredUsers: OrganizationUserView[];
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
filteredUsers = this.dataSource.limitAndUncheckExcess(
allInvitedUsers,
CloudBulkReinviteLimit,
);
} else {
filteredUsers = allInvitedUsers;
}
if (filteredUsers.length <= 0) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("noSelectedUsersApplicable"),
});
return;
}
try {
const result = await this.memberActionsService.bulkReinvite(organization, filteredUsers);
if (!result.successful) {
throw new Error();
}
// When feature flag is enabled, show toast instead of dialog
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
const selectedCount = originalInvitedCount;
const invitedCount = filteredUsers.length;
if (selectedCount > CloudBulkReinviteLimit) {
const excludedCount = selectedCount - CloudBulkReinviteLimit;
this.toastService.showToast({
variant: "success",
message: this.i18nService.t(
"bulkReinviteLimitedSuccessToast",
CloudBulkReinviteLimit.toLocaleString(),
selectedCount.toLocaleString(),
excludedCount.toLocaleString(),
),
});
} else {
this.toastService.showToast({
variant: "success",
message:
invitedCount === 1
? this.i18nService.t("reinviteSuccessToast")
: this.i18nService.t("bulkReinviteSentToast", invitedCount.toString()),
});
}
} else {
// Feature flag disabled - show legacy dialog
await this.memberDialogManager.openBulkStatusDialog(
users,
filteredUsers,
Promise.resolve(result.successful),
this.i18nService.t("bulkReinviteMessage"),
);
}
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = undefined;
}
async bulkConfirm(organization: Organization) {
if (this.actionPromise != null) {
return;
}
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
await this.memberDialogManager.openBulkConfirmDialog(organization, users);
await this.load(organization);
}
async bulkEnableSM(organization: Organization) {
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
await this.memberDialogManager.openBulkEnableSecretsManagerDialog(organization, users);
this.dataSource.uncheckAllUsers();
await this.load(organization);
}
openEventsDialog(user: OrganizationUserView, organization: Organization) {
this.memberDialogManager.openEventsDialog(user, organization);
}
async resetPassword(user: OrganizationUserView, organization: Organization) {
if (!user || !user.email || !user.id) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("orgUserDetailsNotFound"),
});
this.logService.error("Org user details not found when attempting account recovery");
return;
}
const result = await this.memberDialogManager.openAccountRecoveryDialog(user, organization);
if (result === AccountRecoveryDialogResultType.Ok) {
await this.load(organization);
}
return;
}
protected async removeUserConfirmationDialog(user: OrganizationUserView) {
return await this.memberDialogManager.openRemoveUserConfirmationDialog(user);
}
protected async revokeUserConfirmationDialog(user: OrganizationUserView) {
return await this.memberDialogManager.openRevokeUserConfirmationDialog(user);
}
async deleteUser(user: OrganizationUserView, organization: Organization) {
const confirmed = await this.memberDialogManager.openDeleteUserConfirmationDialog(
user,
organization,
);
if (!confirmed) {
return false;
}
this.actionPromise = this.memberActionsService.deleteUser(organization, user.id);
try {
const result = await this.actionPromise;
if (!result.success) {
throw new Error(result.error);
}
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("organizationUserDeleted", this.userNamePipe.transform(user)),
});
this.dataSource.removeUser(user);
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = undefined;
}
get showBulkRestoreUsers(): boolean {
return this.dataSource
.getCheckedUsers()
.every((member) => member.status == this.userStatusType.Revoked);
}
get showBulkRevokeUsers(): boolean {
return this.dataSource
.getCheckedUsers()
.every((member) => member.status != this.userStatusType.Revoked);
}
get showBulkRemoveUsers(): boolean {
return this.dataSource.getCheckedUsers().every((member) => !member.managedByOrganization);
}
get showBulkDeleteUsers(): boolean {
const validStatuses = [
this.userStatusType.Accepted,
this.userStatusType.Confirmed,
this.userStatusType.Revoked,
];
return this.dataSource
.getCheckedUsers()
.every((member) => member.managedByOrganization && validStatuses.includes(member.status));
}
get selectedInvitedCount(): number {
return this.dataSource
.getCheckedUsers()
.filter((member) => member.status === this.userStatusType.Invited).length;
}
get isSingleInvite(): boolean {
return this.selectedInvitedCount === 1;
}
exportMembers = () => {
const result = this.memberExportService.getMemberExport(this.dataSource.data);
if (result.success) {
this.toastService.showToast({
variant: "success",
title: undefined,
message: this.i18nService.t("dataExportSuccess"),
});
}
if (result.error != null) {
this.validationService.showError(result.error.message);
}
};
}

View File

@@ -1,30 +1,23 @@
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route";
import { canAccessMembersTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { FreeBitwardenFamiliesComponent } from "../../../billing/members/free-bitwarden-families.component";
import { organizationPermissionsGuard } from "../guards/org-permissions.guard";
import { canAccessSponsoredFamilies } from "./../../../billing/guards/can-access-sponsored-families.guard";
import { MembersComponent } from "./deprecated_members.component";
import { vNextMembersComponent } from "./members.component";
import { MembersComponent } from "./members.component";
const routes: Routes = [
...featureFlaggedRoute({
defaultComponent: MembersComponent,
flaggedComponent: vNextMembersComponent,
featureFlag: FeatureFlag.MembersComponentRefactor,
routeOptions: {
path: "",
canActivate: [organizationPermissionsGuard(canAccessMembersTab)],
data: {
titleId: "members",
},
{
path: "",
component: MembersComponent,
canActivate: [organizationPermissionsGuard(canAccessMembersTab)],
data: {
titleId: "members",
},
}),
},
{
path: "sponsored-families",
component: FreeBitwardenFamiliesComponent,

View File

@@ -36,7 +36,7 @@ import { OrganizationUserView } from "../core/views/organization-user.view";
import { AccountRecoveryDialogResultType } from "./components/account-recovery/account-recovery-dialog.component";
import { MemberDialogResult } from "./components/member-dialog";
import { vNextMembersComponent } from "./members.component";
import { MembersComponent } from "./members.component";
import {
MemberDialogManagerService,
MemberExportService,
@@ -48,9 +48,9 @@ import {
MemberActionResult,
} from "./services/member-actions/member-actions.service";
describe("vNextMembersComponent", () => {
let component: vNextMembersComponent;
let fixture: ComponentFixture<vNextMembersComponent>;
describe("MembersComponent", () => {
let component: MembersComponent;
let fixture: ComponentFixture<MembersComponent>;
let mockApiService: MockProxy<ApiService>;
let mockI18nService: MockProxy<I18nService>;
@@ -172,7 +172,7 @@ describe("vNextMembersComponent", () => {
mockFileDownloadService = mock<FileDownloadService>();
await TestBed.configureTestingModule({
declarations: [vNextMembersComponent],
declarations: [MembersComponent],
providers: [
{ provide: ApiService, useValue: mockApiService },
{ provide: I18nService, useValue: mockI18nService },
@@ -211,13 +211,13 @@ describe("vNextMembersComponent", () => {
],
schemas: [NO_ERRORS_SCHEMA],
})
.overrideComponent(vNextMembersComponent, {
.overrideComponent(MembersComponent, {
remove: { imports: [] },
add: { template: "<div></div>" },
})
.compileComponents();
fixture = TestBed.createComponent(vNextMembersComponent);
fixture = TestBed.createComponent(MembersComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
@@ -515,7 +515,7 @@ describe("vNextMembersComponent", () => {
};
jest.spyOn(component["dataSource"](), "isIncreasedBulkLimitEnabled").mockReturnValue(false);
jest.spyOn(component["dataSource"](), "getCheckedUsers").mockReturnValue([invitedUser]);
mockMemberActionsService.bulkReinvite.mockResolvedValue({ successful: true });
mockMemberActionsService.bulkReinvite.mockResolvedValue({ successful: [{}], failed: [] });
await component.bulkReinvite(mockOrg);
@@ -549,7 +549,7 @@ describe("vNextMembersComponent", () => {
jest.spyOn(component["dataSource"](), "isIncreasedBulkLimitEnabled").mockReturnValue(false);
jest.spyOn(component["dataSource"](), "getCheckedUsers").mockReturnValue([invitedUser]);
const error = new Error("Bulk reinvite failed");
mockMemberActionsService.bulkReinvite.mockResolvedValue({ successful: false, failed: error });
mockMemberActionsService.bulkReinvite.mockResolvedValue({ successful: [], failed: error });
await component.bulkReinvite(mockOrg);

View File

@@ -82,7 +82,7 @@ interface BulkMemberFlags {
templateUrl: "members.component.html",
standalone: false,
})
export class vNextMembersComponent {
export class MembersComponent {
protected i18nService = inject(I18nService);
protected validationService = inject(ValidationService);
protected logService = inject(LogService);
@@ -426,7 +426,7 @@ export class vNextMembersComponent {
const result = await this.memberActionsService.bulkReinvite(organization, filteredUsers);
if (!result.successful) {
if (result.successful.length === 0) {
this.validationService.showError(result.failed);
}

View File

@@ -19,9 +19,8 @@ import { BulkRemoveDialogComponent } from "./components/bulk/bulk-remove-dialog.
import { BulkRestoreRevokeComponent } from "./components/bulk/bulk-restore-revoke.component";
import { BulkStatusComponent } from "./components/bulk/bulk-status.component";
import { UserDialogModule } from "./components/member-dialog";
import { MembersComponent } from "./deprecated_members.component";
import { MembersRoutingModule } from "./members-routing.module";
import { vNextMembersComponent } from "./members.component";
import { MembersComponent } from "./members.component";
import { UserStatusPipe } from "./pipes";
import {
OrganizationMembersService,
@@ -52,7 +51,6 @@ import {
BulkProgressDialogComponent,
BulkReinviteFailureDialogComponent,
MembersComponent,
vNextMembersComponent,
BulkDeleteDialogComponent,
UserStatusPipe,
],

View File

@@ -507,7 +507,7 @@ describe("MemberActionsService", () => {
const result = await service.bulkReinvite(mockOrganization, users);
expect(result.successful).toBeUndefined();
expect(result.successful).toHaveLength(0);
expect(result.failed).toHaveLength(totalUsers);
expect(result.failed.every((f) => f.error === errorMessage)).toBe(true);
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(2);

View File

@@ -37,11 +37,7 @@ export interface MemberActionResult {
}
export class BulkActionResult {
constructor() {
this.failed = [];
}
successful?: OrganizationUserBulkResponse[];
successful: OrganizationUserBulkResponse[] = [];
failed: { id: string; error: string }[] = [];
}
@@ -316,7 +312,7 @@ export class MemberActionsService {
}
return {
successful: allSuccessful.length > 0 ? allSuccessful : undefined,
successful: allSuccessful,
failed: allFailed,
};
}

View File

@@ -1,8 +1,10 @@
import { Injectable, WritableSignal } from "@angular/core";
import { firstValueFrom, lastValueFrom } from "rxjs";
import { OrganizationUserBulkResponse } from "@bitwarden/admin-console/common";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProviderUserBulkResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user-bulk.response";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -197,7 +199,7 @@ export class MemberDialogManagerService {
async openBulkStatusDialog(
users: OrganizationUserView[],
filteredUsers: OrganizationUserView[],
request: Promise<any>,
request: Promise<OrganizationUserBulkResponse[] | ProviderUserBulkResponse[]>,
successMessage: string,
): Promise<void> {
const dialogRef = BulkStatusComponent.open(this.dialogService, {

View File

@@ -4,6 +4,8 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { BitwardenSubscriptionResponse } from "@bitwarden/common/billing/models/response/bitwarden-subscription.response";
import { SubscriptionCadence } from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { Maybe } from "@bitwarden/pricing";
import { BitwardenSubscription } from "@bitwarden/subscription";
import {
@@ -23,11 +25,18 @@ export class AccountBillingClient {
return this.apiService.send("GET", path, null, true, true);
};
getSubscription = async (): Promise<BitwardenSubscription> => {
getSubscription = async (): Promise<Maybe<BitwardenSubscription>> => {
const path = `${this.endpoint}/subscription`;
const json = await this.apiService.send("GET", path, null, true, true);
const response = new BitwardenSubscriptionResponse(json);
return response.toDomain();
try {
const json = await this.apiService.send("GET", path, null, true, true);
const response = new BitwardenSubscriptionResponse(json);
return response.toDomain();
} catch (error: any) {
if (error instanceof ErrorResponse && error.statusCode === 404) {
return null;
}
throw error;
}
};
purchaseSubscription = async (

View File

@@ -19,7 +19,7 @@ const routes: Routes = [
component: SubscriptionComponent,
data: { titleId: "subscription" },
children: [
{ path: "", pathMatch: "full", redirectTo: "premium" },
{ path: "", pathMatch: "full", redirectTo: "user-subscription" },
...featureFlaggedRoute({
defaultComponent: UserSubscriptionComponent,
flaggedComponent: AccountSubscriptionComponent,

View File

@@ -1,17 +1,22 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnInit } from "@angular/core";
import { Observable, switchMap } from "rxjs";
import { combineLatest, from, map, Observable, switchMap } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { AccountBillingClient } from "../clients/account-billing.client";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "subscription.component.html",
standalone: false,
providers: [AccountBillingClient],
})
export class SubscriptionComponent implements OnInit {
hasPremium$: Observable<boolean>;
@@ -21,9 +26,21 @@ export class SubscriptionComponent implements OnInit {
private platformUtilsService: PlatformUtilsService,
billingAccountProfileStateService: BillingAccountProfileStateService,
accountService: AccountService,
configService: ConfigService,
private accountBillingClient: AccountBillingClient,
) {
this.hasPremium$ = accountService.activeAccount$.pipe(
switchMap((account) => billingAccountProfileStateService.hasPremiumPersonally$(account.id)),
this.hasPremium$ = combineLatest([
configService.getFeatureFlag$(FeatureFlag.PM29594_UpdateIndividualSubscriptionPage),
accountService.activeAccount$,
]).pipe(
switchMap(([isFeatureFlagEnabled, account]) => {
if (isFeatureFlagEnabled) {
return from(accountBillingClient.getSubscription()).pipe(
map((subscription) => !!subscription),
);
}
return billingAccountProfileStateService.hasPremiumPersonally$(account.id);
}),
);
}

View File

@@ -34,6 +34,11 @@ import {
AdjustAccountSubscriptionStorageDialogComponent,
AdjustAccountSubscriptionStorageDialogParams,
} from "@bitwarden/web-vault/app/billing/individual/subscription/adjust-account-subscription-storage-dialog.component";
import {
UnifiedUpgradeDialogComponent,
UnifiedUpgradeDialogStatus,
UnifiedUpgradeDialogStep,
} from "@bitwarden/web-vault/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component";
import {
OffboardingSurveyDialogResultType,
openOffboardingSurvey,
@@ -93,10 +98,11 @@ export class AccountSubscriptionComponent {
if (!this.account()) {
return await redirectToPremiumPage();
}
if (!this.hasPremiumPersonally()) {
const subscription = await this.accountBillingClient.getSubscription();
if (!subscription) {
return await redirectToPremiumPage();
}
return await this.accountBillingClient.getSubscription();
return subscription;
},
});
@@ -106,6 +112,7 @@ export class AccountSubscriptionComponent {
const subscription = this.subscription.value();
if (subscription) {
return (
subscription.status === SubscriptionStatuses.Incomplete ||
subscription.status === SubscriptionStatuses.IncompleteExpired ||
subscription.status === SubscriptionStatuses.Canceled ||
subscription.status === SubscriptionStatuses.Unpaid
@@ -230,6 +237,27 @@ export class AccountSubscriptionComponent {
case SubscriptionCardActions.UpdatePayment:
await this.router.navigate(["../payment-details"], { relativeTo: this.activatedRoute });
break;
case SubscriptionCardActions.Resubscribe: {
const account = this.account();
if (!account) {
return;
}
const dialogRef = UnifiedUpgradeDialogComponent.open(this.dialogService, {
data: {
account,
initialStep: UnifiedUpgradeDialogStep.Payment,
selectedPlan: PersonalSubscriptionPricingTierIds.Premium,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result?.status === UnifiedUpgradeDialogStatus.UpgradedToPremium) {
this.subscription.reload();
}
break;
}
case SubscriptionCardActions.UpgradePlan:
await this.openUpgradeDialog();
break;

View File

@@ -7,11 +7,11 @@
region == currentRegion ? 'javascript:void(0)' : region.urls.webVault + routeAndParams
"
>
<i
class="bwi bwi-fw bwi-sm bwi-check"
aria-hidden="true"
<bit-icon
name="bwi-check"
class="bwi-fw bwi-sm"
[style.visibility]="region == currentRegion ? 'visible' : 'hidden'"
></i>
></bit-icon>
{{ region.domain }}
</a>
</bit-menu>
@@ -19,7 +19,7 @@
{{ "accessing" | i18n }}:
<a [routerLink]="[]" [bitMenuTriggerFor]="environmentOptions">
<b class="tw-text-primary-600 tw-font-medium">{{ currentRegion?.domain }}</b>
<i class="bwi bwi-fw bwi-sm bwi-angle-down" aria-hidden="true"></i>
<bit-icon name="bwi-angle-down" class="bwi-fw bwi-sm"></bit-icon>
</a>
</div>
</div>

View File

@@ -60,11 +60,11 @@
<bit-menu-divider></bit-menu-divider>
<a bitMenuItem routerLink="/settings/account">
<i class="bwi bwi-fw bwi-user" aria-hidden="true"></i>
<bit-icon name="bwi-user" class="bwi-fw"></bit-icon>
{{ "accountSettings" | i18n }}
</a>
<a bitMenuItem href="https://bitwarden.com/help/" target="_blank" rel="noreferrer">
<i class="bwi bwi-fw bwi-question-circle" aria-hidden="true"></i>
<bit-icon name="bwi-question-circle" class="bwi-fw"></bit-icon>
{{ "getHelp" | i18n }}
</a>
<a
@@ -73,18 +73,18 @@
target="_blank"
rel="noreferrer"
>
<i class="bwi bwi-fw bwi-download" aria-hidden="true"></i>
<bit-icon name="bwi-download" class="bwi-fw"></bit-icon>
{{ "getApps" | i18n }}
</a>
<bit-menu-divider></bit-menu-divider>
<button *ngIf="canLock$ | async" bitMenuItem type="button" (click)="lock()">
<i class="bwi bwi-fw bwi-lock" aria-hidden="true"></i>
<bit-icon name="bwi-lock" class="bwi-fw"></bit-icon>
{{ "lockNow" | i18n }}
</button>
<button bitMenuItem type="button" (click)="logout()">
<i class="bwi bwi-fw bwi-sign-out" aria-hidden="true"></i>
<bit-icon name="bwi-sign-out" class="bwi-fw"></bit-icon>
{{ "logOut" | i18n }}
</button>
</div>

View File

@@ -7,13 +7,14 @@
[routerLinkActiveOptions]="{ exact: true }"
[(open)]="open"
>
<i
<bit-icon
slot="end"
*ngIf="!activeOrganization.enabled"
class="bwi bwi-exclamation-triangle tw-my-auto"
[attr.aria-label]="'organizationIsDisabled' | i18n"
name="bwi-exclamation-triangle"
class="tw-my-auto"
[ariaLabel]="'organizationIsDisabled' | i18n"
appA11yTitle="{{ 'organizationIsDisabled' | i18n }}"
></i>
></bit-icon>
<ng-container *ngIf="organizations$ | async as organizations">
<bit-nav-item
*ngFor="let org of organizations"
@@ -23,13 +24,13 @@
(mainContentClicked)="toggle()"
[routerLinkActiveOptions]="{ exact: true }"
>
<i
<bit-icon
slot="end"
*ngIf="org.enabled == false"
class="bwi bwi-exclamation-triangle"
[attr.aria-label]="'organizationIsDisabled' | i18n"
name="bwi-exclamation-triangle"
[ariaLabel]="'organizationIsDisabled' | i18n"
appA11yTitle="{{ 'organizationIsDisabled' | i18n }}"
></i>
></bit-icon>
</bit-nav-item>
</ng-container>
<bit-nav-item

View File

@@ -8,7 +8,7 @@
rel="noreferrer"
appA11yTitle="{{ 'learnMoreAboutYourAccountFingerprintPhrase' | i18n }}"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i
<bit-icon name="bwi-question-circle"></bit-icon
></a>
<br />
<code class="tw-text-code">{{ fingerprint }}</code>

View File

@@ -1,10 +1,14 @@
<ng-template #content>
<i class="bwi bwi-fw !tw-mr-4" [ngClass]="completed ? 'bwi-check tw-text-success' : icon"></i
<bit-icon
[name]="completed ? 'bwi-check' : icon"
class="bwi-fw !tw-mr-4"
[ngClass]="completed ? 'tw-text-success' : ''"
></bit-icon
><span
[ngClass]="{
'tw-text-primary-700 tw-line-through tw-decoration-primary-700 tw-opacity-50': completed,
}"
>{{ title }}<i class="bwi bwi-angle-right tw-ml-1"></i
>{{ title }}<bit-icon name="bwi-angle-right" class="tw-ml-1"></bit-icon
></span>
</ng-template>

View File

@@ -2,6 +2,8 @@
// @ts-strict-ignore
import { Component, Input } from "@angular/core";
import { BitwardenIcon } from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
@@ -21,7 +23,7 @@ export class OnboardingTaskComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input()
icon = "bwi-info-circle";
icon: BitwardenIcon = "bwi-info-circle";
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals

View File

@@ -6,11 +6,7 @@
<span *ngIf="tasks.length > 0; else spinner">
{{ "complete" | i18n: amountCompleted : tasks.length }}
</span>
<i
class="bwi tw-my-auto"
[ngClass]="open ? 'bwi-angle-down' : 'bwi-angle-up'"
aria-hidden="true"
></i>
<bit-icon [name]="open ? 'bwi-angle-down' : 'bwi-angle-up'" class="tw-my-auto"></bit-icon>
</div>
</summary>
<ul class="tw-mb-0 tw-ml-6 tw-flex tw-flex-col tw-gap-4">
@@ -24,5 +20,5 @@
</details>
<ng-template #spinner>
<i class="bwi bwi-spinner bwi-spin"></i>
<bit-icon name="bwi-spinner" class="bwi-spin"></bit-icon>
</ng-template>

View File

@@ -4,7 +4,7 @@ import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/an
import { delay, of, startWith } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { LinkModule, SvgModule, ProgressModule } from "@bitwarden/components";
import { LinkModule, SvgModule, ProgressModule, IconModule } from "@bitwarden/components";
import { PreloadedEnglishI18nModule } from "../../../core/tests";
@@ -16,7 +16,7 @@ export default {
component: OnboardingComponent,
decorators: [
moduleMetadata({
imports: [JslibModule, RouterModule, LinkModule, SvgModule, ProgressModule],
imports: [JslibModule, RouterModule, LinkModule, IconModule, SvgModule, ProgressModule],
declarations: [OnboardingTaskComponent],
}),
applicationConfig({

View File

@@ -18,6 +18,7 @@ import {
DialogModule,
FormFieldModule,
IconButtonModule,
IconModule,
SvgModule,
LinkModule,
MenuModule,
@@ -63,6 +64,7 @@ import {
DialogModule,
FormFieldModule,
IconButtonModule,
IconModule,
SvgModule,
LinkModule,
MenuModule,
@@ -99,6 +101,7 @@ import {
DialogModule,
FormFieldModule,
IconButtonModule,
IconModule,
SvgModule,
LinkModule,
MenuModule,

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";
@@ -69,6 +69,7 @@ export class SendAuthComponent implements OnInit {
private formBuilder: FormBuilder,
private configService: ConfigService,
private sendTokenService: SendTokenService,
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
) {}
ngOnInit() {
@@ -160,8 +161,10 @@ 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);
this.updatePageTitle();
} else if (otpInvalid(response.error)) {
this.toastService.showToast({
variant: "error",
@@ -170,6 +173,7 @@ export class SendAuthComponent implements OnInit {
});
} else if (passwordHashB64Required(response.error)) {
this.sendAuthType.set(AuthType.Password);
this.updatePageTitle();
} else if (passwordHashB64Invalid(response.error)) {
this.toastService.showToast({
variant: "error",
@@ -207,4 +211,24 @@ 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" },
});
} else {
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
pageTitle: { key: "verifyYourEmailToViewThisSend" },
});
}
} else if (authType === AuthType.Password) {
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
pageTitle: { key: "sendAccessPasswordTitle" },
});
}
}
}

View File

@@ -69,6 +69,9 @@ export class SendViewComponent implements OnInit {
) {}
ngOnInit() {
this.layoutWrapperDataService.setAnonLayoutWrapperData({
pageTitle: { key: "sendAccessContentTitle" },
});
void this.load();
}

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 = {
@@ -616,7 +616,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("itemWasSentToArchive"),
message: this.i18nService.t("itemArchiveToast"),
});
} catch {
this.toastService.showToast({
@@ -638,7 +638,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("itemWasUnarchived"),
message: this.i18nService.t("itemUnarchivedToast"),
});
} catch {
this.toastService.showToast({

View File

@@ -1,15 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit, viewChild } from "@angular/core";
import { ActivatedRoute, Params, Router } from "@angular/router";
import {
BehaviorSubject,
combineLatest,
firstValueFrom,
lastValueFrom,
Observable,
Subject,
} from "rxjs";
import { combineLatest, firstValueFrom, lastValueFrom, Observable, of, Subject } from "rxjs";
import {
concatMap,
debounceTime,
@@ -18,6 +9,7 @@ import {
first,
map,
shareReplay,
startWith,
switchMap,
take,
takeUntil,
@@ -89,7 +81,6 @@ import { CipherListView } from "@bitwarden/sdk-internal";
import {
AddEditFolderDialogComponent,
AddEditFolderDialogResult,
AttachmentDialogCloseResult,
AttachmentDialogResult,
AttachmentsV2Component,
CipherFormConfig,
@@ -179,14 +170,10 @@ type EmptyStateMap = Record<EmptyStateType, EmptyStateItem>;
],
})
export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestroy {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild("vaultFilter", { static: true }) filterComponent: VaultFilterComponent;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild("vaultItems", { static: false }) vaultItemsComponent: VaultItemsComponent<C>;
readonly filterComponent = viewChild(VaultFilterComponent);
readonly vaultItemsComponent = viewChild(VaultItemsComponent);
trashCleanupWarning: string = null;
trashCleanupWarning: string = "";
activeFilter: VaultFilter = new VaultFilter();
protected deactivatedOrgIcon = DeactivatedOrg;
@@ -198,20 +185,20 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
protected refreshing = false;
protected processingEvent = false;
protected filter: RoutedVaultFilterModel = {};
protected showBulkMove: boolean;
protected canAccessPremium: boolean;
protected allCollections: CollectionView[];
protected showBulkMove: boolean = false;
protected canAccessPremium: boolean = false;
protected allCollections: CollectionView[] = [];
protected allOrganizations: Organization[] = [];
protected ciphers: C[];
protected collections: CollectionView[];
protected isEmpty: boolean;
protected ciphers: C[] = [];
protected collections: CollectionView[] = [];
protected isEmpty: boolean = false;
protected selectedCollection: TreeNode<CollectionView> | undefined;
protected canCreateCollections = false;
protected currentSearchText$: Observable<string> = this.route.queryParams.pipe(
map((queryParams) => queryParams.search),
);
private searchText$ = new Subject<string>();
private refresh$ = new BehaviorSubject<void>(null);
private refresh$ = new Subject<void>();
private destroy$ = new Subject<void>();
private vaultItemDialogRef?: DialogRef<VaultItemDialogResult> | undefined;
@@ -220,7 +207,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
organizations$ = this.accountService.activeAccount$
.pipe(map((a) => a?.id))
.pipe(switchMap((id) => this.organizationService.organizations$(id)));
.pipe(switchMap((id) => (id ? this.organizationService.organizations$(id) : of([]))));
emptyState$ = combineLatest([
this.currentSearchText$,
@@ -228,7 +215,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
this.organizations$,
]).pipe(
map(([searchText, filter, organizations]) => {
const selectedOrg = organizations?.find((org) => org.id === filter.organizationId);
const selectedOrg = organizations.find((org) => org.id === filter.organizationId);
const isOrgDisabled = selectedOrg && !selectedOrg.enabled;
if (isOrgDisabled) {
@@ -586,7 +573,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
firstSetup$
.pipe(
switchMap(() => this.refresh$),
switchMap(() => this.refresh$.pipe(startWith(undefined))),
tap(() => (this.refreshing = true)),
switchMap(() =>
combineLatest([
@@ -712,7 +699,6 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
async handleUnknownCipher() {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("unknownCipher"),
});
await this.router.navigate([], {
@@ -744,7 +730,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
await this.cipherArchiveService.archiveWithServer(cipher.id as CipherId, activeUserId);
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("itemWasSentToArchive"),
message: this.i18nService.t("itemArchiveToast"),
});
this.refresh();
} catch (e) {
@@ -801,7 +787,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("itemUnarchived"),
message: this.i18nService.t("itemUnarchivedToast"),
});
this.refresh();
@@ -842,9 +828,13 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
if (orgId == null) {
orgId = "MyVault";
}
const orgs = await firstValueFrom(this.filterComponent.filters.organizationFilter.data$);
const data = this.filterComponent()?.filters?.organizationFilter?.data$;
if (data == undefined) {
return;
}
const orgs = await firstValueFrom(data);
const orgNode = ServiceUtils.getTreeNodeObject(orgs, orgId) as TreeNode<OrganizationFilter>;
await this.filterComponent.filters?.organizationFilter?.action(orgNode);
await this.filterComponent()?.filters?.organizationFilter?.action(orgNode);
}
addFolder = (): void => {
@@ -912,7 +902,10 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
canEditCipher: cipher.edit,
});
const result: AttachmentDialogCloseResult = await lastValueFrom(dialogRef.closed);
const result = await lastValueFrom(dialogRef.closed);
if (result === undefined) {
return;
}
if (
result.action === AttachmentDialogResult.Uploaded ||
@@ -966,7 +959,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
*/
async addCipher(cipherType?: CipherType) {
const type = cipherType ?? this.activeFilter.cipherType;
const cipherFormConfig = await this.cipherFormConfigService.buildConfig("add", null, type);
const cipherFormConfig = await this.cipherFormConfigService.buildConfig("add", undefined, type);
const collectionId =
this.activeFilter.collectionId !== "AllCollections" && this.activeFilter.collectionId != null
? this.activeFilter.collectionId
@@ -994,7 +987,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
}
async editCipher(cipher: CipherView | CipherListView, cloneMode?: boolean) {
return this.editCipherId(uuidAsString(cipher?.id), cloneMode);
return this.editCipherId(uuidAsString(cipher.id), cloneMode);
}
/**
@@ -1088,6 +1081,9 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
},
});
const result = await lastValueFrom(dialog.closed);
if (result === undefined) {
return;
}
if (result.action === CollectionDialogAction.Saved) {
if (result.collection) {
// Update CollectionService with the new collection
@@ -1104,7 +1100,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
async editCollection(c: CollectionView, tab: CollectionDialogTabType): Promise<void> {
const dialog = openCollectionDialog(this.dialogService, {
data: {
collectionId: c?.id,
collectionId: c.id,
organizationId: c.organizationId,
initialTab: tab,
limitNestedCollections: true,
@@ -1112,6 +1108,9 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
});
const result = await lastValueFrom(dialog.closed);
if (result === undefined) {
return;
}
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
if (result.action === CollectionDialogAction.Saved) {
if (result.collection) {
@@ -1163,7 +1162,6 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("deletedCollectionId", collection.name),
});
if (navigateAway) {
@@ -1196,12 +1194,12 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
let availableCollections: CollectionView[] = [];
const orgId =
this.activeFilter.organizationId ||
ciphers.find((c) => c.organizationId !== null)?.organizationId;
ciphers.find((c) => c.organizationId !== undefined)?.organizationId;
if (orgId && orgId !== "MyVault") {
const organization = this.allOrganizations.find((o) => o.id === orgId);
availableCollections = this.allCollections.filter(
(c) => c.organizationId === organization.id,
(c) => c.organizationId === organization?.id,
);
}
@@ -1229,7 +1227,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
ciphers: ciphersToAssign,
organizationId: orgId as OrganizationId,
availableCollections,
activeCollection: this.activeFilter?.selectedCollectionNode?.node,
activeCollection: this.activeFilter.selectedCollectionNode?.node,
},
});
@@ -1255,7 +1253,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
await this.editCipher(cipher, true);
}
restore = async (c: C): Promise<boolean> => {
restore = async (c: CipherViewLike) => {
let toastMessage;
if (!CipherViewLikeUtils.isDeleted(c)) {
return;
@@ -1281,13 +1279,14 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
await this.cipherService.restoreWithServer(uuidAsString(c.id), activeUserId);
this.toastService.showToast({
variant: "success",
title: null,
message: toastMessage,
});
this.refresh();
} catch (e) {
this.logService.error(e);
return;
}
return;
};
async bulkRestore(ciphers: C[]) {
@@ -1311,7 +1310,6 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
if (selectedCipherIds.length === 0) {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("nothingSelected"),
});
return;
@@ -1321,23 +1319,24 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
await this.cipherService.restoreManyWithServer(selectedCipherIds, activeUserId);
this.toastService.showToast({
variant: "success",
title: null,
message: toastMessage,
});
this.refresh();
}
private async handleDeleteEvent(items: VaultItem<C>[]) {
const ciphers: C[] = items.filter((i) => i.collection === undefined).map((i) => i.cipher);
const collections = items.filter((i) => i.cipher === undefined).map((i) => i.collection);
const ciphers = items
.filter((i) => i.collection === undefined && i.cipher !== undefined)
.map((i) => i.cipher as C);
const collections = items
.filter((i) => i.collection !== undefined)
.map((i) => i.collection as CollectionView);
if (ciphers.length === 1 && collections.length === 0) {
await this.deleteCipher(ciphers[0]);
} else if (ciphers.length === 0 && collections.length === 1) {
await this.deleteCollection(collections[0]);
} else {
const orgIds = items
.filter((i) => i.cipher === undefined)
.map((i) => i.collection.organizationId);
const orgIds = collections.map((c) => c.organizationId);
const orgs = await firstValueFrom(
this.organizations$.pipe(map((orgs) => orgs.filter((o) => orgIds.includes(o.id)))),
);
@@ -1345,7 +1344,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
}
}
async deleteCipher(c: C): Promise<boolean> {
async deleteCipher(c: C) {
if (!(await this.repromptCipher([c]))) {
return;
}
@@ -1364,7 +1363,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
});
if (!confirmed) {
return false;
return;
}
try {
@@ -1373,7 +1372,6 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t(permanent ? "permanentlyDeletedItem" : "deletedItem"),
});
this.refresh();
@@ -1390,7 +1388,6 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
if (ciphers.length === 0 && collections.length === 0) {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("nothingSelected"),
});
return;
@@ -1430,7 +1427,6 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
if (selectedCipherIds.length === 0) {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("nothingSelected"),
});
return;
@@ -1454,11 +1450,8 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
const login = CipherViewLikeUtils.getLogin(cipher);
if (!login) {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("unexpectedError"),
});
this.showErrorToast();
return;
}
if (field === "username") {
@@ -1471,15 +1464,15 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
typeI18nKey = "password";
} else if (field === "totp") {
aType = "TOTP";
if (!login.totp) {
this.showErrorToast();
return;
}
const totpResponse = await firstValueFrom(this.totpService.getCode$(login.totp));
value = totpResponse.code;
typeI18nKey = "verificationCodeTotp";
} else {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("unexpectedError"),
});
this.showErrorToast();
return;
}
@@ -1494,10 +1487,13 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
return;
}
if (!value) {
this.showErrorToast();
return;
}
this.platformUtilsService.copyToClipboard(value, { window: window });
this.toastService.showToast({
variant: "info",
title: null,
message: this.i18nService.t("valueCopied", this.i18nService.t(typeI18nKey)),
});
@@ -1514,6 +1510,13 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
}
}
showErrorToast() {
this.toastService.showToast({
variant: "error",
message: this.i18nService.t("unexpectedError"),
});
}
/**
* Toggles the favorite status of the cipher and updates it on the server.
*/
@@ -1525,7 +1528,6 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t(
cipherFullView.favorite ? "itemAddedToFavorites" : "itemRemovedFromFavorites",
),
@@ -1540,15 +1542,15 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
: this.cipherService.softDeleteWithServer(id, userId);
}
protected async repromptCipher(ciphers: C[]) {
protected async repromptCipher(ciphers: CipherViewLike[]) {
const notProtected = !ciphers.find((cipher) => cipher.reprompt !== CipherRepromptType.None);
return notProtected || (await this.passwordRepromptService.showPasswordPrompt());
}
private refresh() {
this.refresh$.next();
this.vaultItemsComponent?.clearSelection();
this.refresh$.next(undefined);
this.vaultItemsComponent()?.clearSelection();
}
private async go(queryParams: any = null) {
@@ -1573,7 +1575,6 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
private showMissingPermissionsError() {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("missingPermissions"),
});
}
@@ -1584,13 +1585,13 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
*/
private async getPasswordFromCipherViewLike(cipher: C): Promise<string | undefined> {
if (!CipherViewLikeUtils.isCipherListView(cipher)) {
return Promise.resolve(cipher.login?.password);
return Promise.resolve(cipher?.login?.password);
}
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const _cipher = await this.cipherService.get(uuidAsString(cipher.id), activeUserId);
const cipherView = await this.cipherService.decrypt(_cipher, activeUserId);
return cipherView.login?.password;
return cipherView.login.password;
}
}