1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-20 03:13:55 +00:00

Merge branch 'main' into 202505-notifications-refactor

This commit is contained in:
Miles Blackwood
2025-08-13 11:28:19 -04:00
2628 changed files with 132200 additions and 56330 deletions

View File

@@ -1,23 +1,22 @@
###############################################
# Build stage 1 #
# Node.js build stage (alpine) #
###############################################
ARG NODE_VERSION=20
FROM --platform=$BUILDPLATFORM node:${NODE_VERSION} AS node-build
ARG NPM_COMMAND=dist:bit:selfhost
FROM --platform=$BUILDPLATFORM node:${NODE_VERSION}-alpine AS node-build
WORKDIR /source
COPY package*.json ./
COPY . .
RUN npm ci
WORKDIR /source/apps/web
ARG NPM_COMMAND=dist:bit:selfhost
RUN npm run ${NPM_COMMAND}
###############################################
# Build stage 2 #
###############################################
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine3.21 AS build
# Docker buildx supplies the value for this arg
ARG TARGETPLATFORM
@@ -25,11 +24,11 @@ ARG TARGETPLATFORM
# Determine proper runtime value for .NET
# We put the value in a file to be read by later layers.
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
RID=linux-x64 ; \
RID=linux-musl-x64 ; \
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
RID=linux-arm64 ; \
RID=linux-musl-arm64 ; \
elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \
RID=linux-arm ; \
RID=linux-musl-arm ; \
fi \
&& echo "RID=$RID" > /tmp/rid.txt
@@ -57,19 +56,18 @@ WORKDIR /app
###############################################
# App stage #
###############################################
FROM mcr.microsoft.com/dotnet/aspnet:8.0
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine3.21
ARG TARGETPLATFORM
LABEL com.bitwarden.product="bitwarden"
ENV ASPNETCORE_ENVIRONMENT=Production
ENV ASPNETCORE_URLS=http://+:5000
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
EXPOSE 5000
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
gosu \
curl \
&& rm -rf /var/lib/apt/lists/*
RUN apk add --no-cache curl \
icu-libs \
&& apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community gosu
# Copy app from the build stage
WORKDIR /bitwarden_server

View File

@@ -14,5 +14,5 @@ files:
zh-TW: zh_TW
en-GB: en_GB
en-IN: en_IN
sr-CY: sr_CY
sr: sr_CY
sr-CS: sr_CS

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/bin/sh
# Setup
@@ -22,11 +22,10 @@ fi
if [ "$(id -u)" = "0" ]; then
# Create user and group
groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 ||
groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1
useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 ||
usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1
mkhomedir_helper $USERNAME
addgroup -g "$LGID" -S "$GROUPNAME" 2>/dev/null || true
adduser -u "$LUID" -G "$GROUPNAME" -S -D -H "$USERNAME" 2>/dev/null || true
mkdir -p /home/$USERNAME
chown $USERNAME:$GROUPNAME /home/$USERNAME
# The rest...

View File

@@ -1,6 +1,6 @@
const { pathsToModuleNameMapper } = require("ts-jest");
const { compilerOptions } = require("./tsconfig");
const { compilerOptions } = require("../../tsconfig.base");
const sharedConfig = require("../../libs/shared/jest.config.angular");
@@ -15,11 +15,11 @@ module.exports = {
...pathsToModuleNameMapper(
{
// lets us use @bitwarden/common/spec in web tests
"@bitwarden/common/spec": ["../../libs/common/spec"],
"@bitwarden/common/spec": ["libs/common/spec"],
...(compilerOptions?.paths ?? {}),
},
{
prefix: "<rootDir>/",
prefix: "<rootDir>/../../",
},
),
},

View File

@@ -1,6 +1,6 @@
{
"name": "@bitwarden/web-vault",
"version": "2025.6.0",
"version": "2025.8.0",
"scripts": {
"build:oss": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" webpack",
"build:bit": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-web/webpack.config.js",

View File

@@ -86,7 +86,7 @@ export abstract class BaseMembersComponent<UserView extends UserViewTypes> {
protected i18nService: I18nService,
protected keyService: KeyService,
protected validationService: ValidationService,
private logService: LogService,
protected logService: LogService,
protected userNamePipe: UserNamePipe,
protected dialogService: DialogService,
protected organizationManagementPreferencesService: OrganizationManagementPreferencesService,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -54,7 +54,6 @@ export enum BulkCollectionsDialogResult {
imports: [SharedModule, AccessSelectorModule],
selector: "app-bulk-collections-dialog",
templateUrl: "bulk-collections-dialog.component.html",
standalone: true,
})
export class BulkCollectionsDialogComponent implements OnDestroy {
protected readonly PermissionMode = PermissionMode;

View File

@@ -12,7 +12,6 @@ const icon = svgIcon`<svg xmlns="http://www.w3.org/2000/svg" width="120" height=
@Component({
selector: "collection-access-restricted",
standalone: true,
imports: [SharedModule, ButtonModule, NoItemsModule],
template: `<bit-no-items [icon]="icon" class="tw-mt-2 tw-block">
<span slot="title" class="tw-mt-4 tw-block">{{ "youDoNotHavePermissions" | i18n }}</span>

View File

@@ -10,7 +10,6 @@ import { GetCollectionNameFromIdPipe } from "../pipes";
@Component({
selector: "app-collection-badge",
templateUrl: "collection-name-badge.component.html",
standalone: true,
imports: [SharedModule, GetCollectionNameFromIdPipe],
})
export class CollectionNameBadgeComponent {

View File

@@ -5,7 +5,6 @@ import { CollectionView } from "@bitwarden/admin-console/common";
@Pipe({
name: "collectionNameFromId",
pure: true,
standalone: true,
})
export class GetCollectionNameFromIdPipe implements PipeTransform {
transform(value: string, collections: CollectionView[]) {

View File

@@ -37,31 +37,6 @@ export function getNestedCollectionTree(
return nodes;
}
export function getNestedCollectionTree_vNext(
collections: (CollectionView | CollectionAdminView)[],
): TreeNode<CollectionView | CollectionAdminView>[] {
if (!collections) {
return [];
}
// Collections need to be cloned because ServiceUtils.nestedTraverse actively
// modifies the names of collections.
// These changes risk affecting collections store in StateService.
const clonedCollections = collections
.sort((a, b) => a.name.localeCompare(b.name))
.map(cloneCollection);
const nodes: TreeNode<CollectionView | CollectionAdminView>[] = [];
clonedCollections.forEach((collection) => {
const parts =
collection.name != null
? collection.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter)
: [];
ServiceUtils.nestedTraverse_vNext(nodes, 0, parts, collection, null, NestingDelimiter);
});
return nodes;
}
export function getFlatCollectionTree(
nodes: TreeNode<CollectionAdminView>[],
): CollectionAdminView[];
@@ -107,5 +82,7 @@ function cloneCollection(
cloned.organizationId = collection.organizationId;
cloned.readOnly = collection.readOnly;
cloned.manage = collection.manage;
cloned.type = collection.type;
return cloned;
}

View File

@@ -10,7 +10,9 @@ import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstract
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { VaultFilterComponent as BaseVaultFilterComponent } from "../../../../vault/individual-vault/vault-filter/components/vault-filter.component";
@@ -51,6 +53,8 @@ export class VaultFilterComponent
protected dialogService: DialogService,
protected configService: ConfigService,
protected accountService: AccountService,
protected restrictedItemTypesService: RestrictedItemTypesService,
protected cipherService: CipherService,
) {
super(
vaultFilterService,
@@ -62,6 +66,8 @@ export class VaultFilterComponent
dialogService,
configService,
accountService,
restrictedItemTypesService,
cipherService,
);
}
@@ -128,7 +134,7 @@ export class VaultFilterComponent
async buildAllFilters(): Promise<VaultFilterList> {
const builderFilter = {} as VaultFilterList;
builderFilter.typeFilter = await this.addTypeFilter(["favorites"]);
builderFilter.typeFilter = await this.addTypeFilter(["favorites"], this._organization?.id);
builderFilter.collectionFilter = await this.addCollectionFilter();
builderFilter.trashFilter = await this.addTrashFilter();
return builderFilter;

View File

@@ -5,6 +5,7 @@ import { CollectionAdminView, CollectionService } from "@bitwarden/admin-console
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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { StateProvider } from "@bitwarden/common/platform/state";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@@ -34,6 +35,7 @@ export class VaultFilterService extends BaseVaultFilterService implements OnDest
stateProvider: StateProvider,
collectionService: CollectionService,
accountService: AccountService,
configService: ConfigService,
) {
super(
organizationService,
@@ -44,6 +46,7 @@ export class VaultFilterService extends BaseVaultFilterService implements OnDest
stateProvider,
collectionService,
accountService,
configService,
);
}

View File

@@ -25,62 +25,67 @@
</bit-breadcrumbs>
<ng-container slot="title-suffix">
<ng-container
*ngIf="
collection != null && (canEditCollection || canDeleteCollection || canViewCollectionInfo)
"
>
<button
bitIconButton="bwi-angle-down"
[bitMenuTriggerFor]="editCollectionMenu"
size="small"
type="button"
></button>
<bit-menu #editCollectionMenu>
<ng-container *ngIf="canEditCollection">
@if (
collection != null && (canEditCollection || canDeleteCollection || canViewCollectionInfo)
) {
<ng-container>
<button
bitIconButton="bwi-angle-down"
[bitMenuTriggerFor]="editCollectionMenu"
size="small"
type="button"
></button>
<bit-menu #editCollectionMenu>
<ng-container *ngIf="canEditCollection">
<button
type="button"
bitMenuItem
(click)="editCollection(CollectionDialogTabType.Info, false)"
>
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "editInfo" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="editCollection(CollectionDialogTabType.Access, false)"
>
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "access" | i18n }}
</button>
</ng-container>
<ng-container *ngIf="!canEditCollection && canViewCollectionInfo">
<button
type="button"
bitMenuItem
(click)="editCollection(CollectionDialogTabType.Info, true)"
>
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "viewInfo" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="editCollection(CollectionDialogTabType.Access, true)"
>
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "viewAccess" | i18n }}
</button>
</ng-container>
<button
type="button"
*ngIf="canDeleteCollection"
bitMenuItem
(click)="editCollection(CollectionDialogTabType.Info, false)"
(click)="deleteCollection()"
>
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "editInfo" | i18n }}
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ "delete" | i18n }}
</span>
</button>
<button
type="button"
bitMenuItem
(click)="editCollection(CollectionDialogTabType.Access, false)"
>
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "access" | i18n }}
</button>
</ng-container>
<ng-container *ngIf="!canEditCollection && canViewCollectionInfo">
<button
type="button"
bitMenuItem
(click)="editCollection(CollectionDialogTabType.Info, true)"
>
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "viewInfo" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="editCollection(CollectionDialogTabType.Access, true)"
>
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "viewAccess" | i18n }}
</button>
</ng-container>
<button type="button" *ngIf="canDeleteCollection" bitMenuItem (click)="deleteCollection()">
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ "delete" | i18n }}
</span>
</button>
</bit-menu>
</ng-container>
</bit-menu>
</ng-container>
}
<small *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
@@ -103,48 +108,11 @@
*ngIf="filter.type !== 'trash' && filter.collectionId !== Unassigned && organization"
class="tw-shrink-0"
>
<!-- "New" menu is always shown unless the user cannot create a cipher and cannot create a collection-->
<ng-container *ngIf="canCreateCipher || canCreateCollection">
<div appListDropdown>
<button
bitButton
buttonType="primary"
type="button"
[bitMenuTriggerFor]="addOptions"
id="newItemDropdown"
appA11yTitle="{{ 'new' | i18n }}"
>
<i class="bwi bwi-plus" aria-hidden="true"></i>
{{ "new" | i18n }}<i class="bwi tw-ml-2" aria-hidden="true"></i>
</button>
<bit-menu #addOptions aria-labelledby="newItemDropdown">
<ng-container *ngIf="canCreateCipher">
<button type="button" bitMenuItem (click)="addCipher(CipherType.Login)">
<i class="bwi bwi-globe" slot="start" aria-hidden="true"></i>
{{ "typeLogin" | i18n }}
</button>
<button type="button" bitMenuItem (click)="addCipher(CipherType.Card)">
<i class="bwi bwi-credit-card" slot="start" aria-hidden="true"></i>
{{ "typeCard" | i18n }}
</button>
<button type="button" bitMenuItem (click)="addCipher(CipherType.Identity)">
<i class="bwi bwi-id-card" slot="start" aria-hidden="true"></i>
{{ "typeIdentity" | i18n }}
</button>
<button type="button" bitMenuItem (click)="addCipher(CipherType.SecureNote)">
<i class="bwi bwi-sticky-note" slot="start" aria-hidden="true"></i>
{{ "note" | i18n }}
</button>
</ng-container>
<ng-container *ngIf="canCreateCollection">
<bit-menu-divider *ngIf="canCreateCipher"></bit-menu-divider>
<button type="button" bitMenuItem (click)="addCollection()">
<i class="bwi bwi-fw bwi-collection-shared" aria-hidden="true"></i>
{{ "collection" | i18n }}
</button>
</ng-container>
</bit-menu>
</div>
</ng-container>
<vault-new-cipher-menu
[canCreateCipher]="canCreateCipher"
[canCreateCollection]="canCreateCollection"
(cipherAdded)="addCipher($event)"
(collectionAdded)="addCollection()"
/>
</div>
</app-header>

View File

@@ -5,7 +5,7 @@
import { CommonModule } from "@angular/common";
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { firstValueFrom, switchMap } from "rxjs";
import {
CollectionAdminService,
@@ -14,6 +14,8 @@ import {
} from "@bitwarden/admin-console/common";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherType } from "@bitwarden/common/vault/enums";
@@ -25,6 +27,7 @@ import {
SearchModule,
SimpleDialogOptions,
} from "@bitwarden/components";
import { NewCipherMenuComponent } from "@bitwarden/vault";
import { HeaderModule } from "../../../../layouts/header/header.module";
import { SharedModule } from "../../../../shared";
@@ -35,7 +38,6 @@ import {
import { CollectionDialogTabType } from "../../shared/components/collection-dialog";
@Component({
standalone: true,
selector: "app-org-vault-header",
templateUrl: "./vault-header.component.html",
imports: [
@@ -46,6 +48,7 @@ import { CollectionDialogTabType } from "../../shared/components/collection-dial
HeaderModule,
SearchModule,
JslibModule,
NewCipherMenuComponent,
],
})
export class VaultHeaderComponent {
@@ -98,6 +101,7 @@ export class VaultHeaderComponent {
private dialogService: DialogService,
private collectionAdminService: CollectionAdminService,
private router: Router,
private accountService: AccountService,
) {}
get title() {
@@ -198,7 +202,14 @@ export class VaultHeaderComponent {
async addCollection() {
if (this.organization.productTierType === ProductTierType.Free) {
const collections = await this.collectionAdminService.getAll(this.organization.id);
const collections = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.collectionAdminService.collectionAdminViews$(this.organization.id, userId),
),
),
);
if (collections.length === this.organization.maxCollections) {
this.showFreeOrgUpgradeDialog();
return;

View File

@@ -1,14 +1,14 @@
<app-free-trial-warning
<app-organization-free-trial-warning
*ngIf="useOrganizationWarningsService$ | async"
[organization]="organization"
(clicked)="navigateToPaymentMethod()"
>
</app-free-trial-warning>
<app-reseller-renewal-warning
</app-organization-free-trial-warning>
<app-organization-reseller-renewal-warning
*ngIf="useOrganizationWarningsService$ | async"
[organization]="organization"
>
</app-reseller-renewal-warning>
</app-organization-reseller-renewal-warning>
<ng-container *ngIf="freeTrialWhenWarningsServiceDisabled$ | async as freeTrial">
<bit-banner
id="free-trial-banner"
@@ -84,6 +84,7 @@
{{ trashCleanupWarning }}
</bit-callout>
<app-vault-items
#vaultItems
[ciphers]="ciphers"
[collections]="collections"
[allCollections]="allCollections"

View File

@@ -1,6 +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 } from "@angular/core";
import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { ActivatedRoute, Params, Router } from "@angular/router";
import {
BehaviorSubject,
@@ -29,13 +29,13 @@ import {
import {
CollectionAdminService,
CollectionAdminView,
CollectionService,
CollectionView,
Unassigned,
} from "@bitwarden/admin-console/common";
import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
@@ -55,6 +55,7 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SyncService } from "@bitwarden/common/platform/sync";
import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
@@ -78,8 +79,9 @@ import {
DecryptionFailureDialogComponent,
PasswordRepromptService,
} from "@bitwarden/vault";
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/services/organization-warnings.service";
import { ResellerRenewalWarningComponent } from "@bitwarden/web-vault/app/billing/warnings/reseller-renewal-warning.component";
import { OrganizationResellerRenewalWarningComponent } from "@bitwarden/web-vault/app/billing/warnings/components/organization-reseller-renewal-warning.component";
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/warnings/services/organization-warnings.service";
import { VaultItemsComponent } from "@bitwarden/web-vault/app/vault/components/vault-items/vault-items.component";
import { BillingNotificationService } from "../../../billing/services/billing-notification.service";
import {
@@ -88,7 +90,7 @@ import {
} from "../../../billing/services/reseller-warning.service";
import { TrialFlowService } from "../../../billing/services/trial-flow.service";
import { FreeTrial } from "../../../billing/types/free-trial";
import { FreeTrialWarningComponent } from "../../../billing/warnings/free-trial-warning.component";
import { OrganizationFreeTrialWarningComponent } from "../../../billing/warnings/components/organization-free-trial-warning.component";
import { SharedModule } from "../../../shared";
import { AssignCollectionsWebComponent } from "../../../vault/components/assign-collections";
import {
@@ -125,11 +127,7 @@ import {
BulkCollectionsDialogResult,
} from "./bulk-collections-dialog";
import { CollectionAccessRestrictedComponent } from "./collection-access-restricted.component";
import {
getNestedCollectionTree,
getFlatCollectionTree,
getNestedCollectionTree_vNext,
} from "./utils";
import { getFlatCollectionTree, getNestedCollectionTree } from "./utils";
import { VaultFilterModule } from "./vault-filter/vault-filter.module";
import { VaultHeaderComponent } from "./vault-header/vault-header.component";
@@ -144,7 +142,6 @@ enum AddAccessStatusType {
}
@Component({
standalone: true,
selector: "app-org-vault",
templateUrl: "vault.component.html",
imports: [
@@ -155,8 +152,8 @@ enum AddAccessStatusType {
SharedModule,
BannerModule,
NoItemsModule,
FreeTrialWarningComponent,
ResellerRenewalWarningComponent,
OrganizationFreeTrialWarningComponent,
OrganizationResellerRenewalWarningComponent,
],
providers: [
RoutedVaultFilterService,
@@ -208,6 +205,8 @@ export class VaultComponent implements OnInit, OnDestroy {
protected addAccessStatus$ = new BehaviorSubject<AddAccessStatusType>(0);
private vaultItemDialogRef?: DialogRef<VaultItemDialogResult> | undefined;
@ViewChild("vaultItems", { static: false }) vaultItemsComponent: VaultItemsComponent<CipherView>;
private readonly unpaidSubscriptionDialog$ = this.accountService.activeAccount$.pipe(
map((account) => account?.id),
switchMap((id) =>
@@ -269,6 +268,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private accountService: AccountService,
private billingNotificationService: BillingNotificationService,
private organizationWarningsService: OrganizationWarningsService,
private collectionService: CollectionService,
) {}
async ngOnInit() {
@@ -281,9 +281,16 @@ export class VaultComponent implements OnInit, OnDestroy {
);
const filter$ = this.routedVaultFilterService.filter$;
// FIXME: The RoutedVaultFilterModel uses `organizationId: Unassigned` to represent the individual vault,
// but that is never used in Admin Console. This function narrows the type so it doesn't pollute our code here,
// but really we should change to using our own vault filter model that only represents valid states in AC.
const isOrganizationId = (value: OrganizationId | Unassigned): value is OrganizationId =>
value !== Unassigned;
const organizationId$ = filter$.pipe(
map((filter) => filter.organizationId),
filter((filter) => filter !== undefined),
filter(isOrganizationId),
distinctUntilChanged(),
);
@@ -356,7 +363,12 @@ export class VaultComponent implements OnInit, OnDestroy {
this.allCollectionsWithoutUnassigned$ = this.refresh$.pipe(
switchMap(() => organizationId$),
switchMap((orgId) => this.collectionAdminService.getAll(orgId)),
switchMap((orgId) =>
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.collectionAdminService.collectionAdminViews$(orgId, userId)),
),
),
shareReplay({ refCount: false, bufferSize: 1 }),
);
@@ -376,9 +388,12 @@ export class VaultComponent implements OnInit, OnDestroy {
this.allCollectionsWithoutUnassigned$,
]).pipe(
map(([organizationId, allCollections]) => {
// FIXME: We should not assert that the Unassigned type is a CollectionId.
// Instead we should consider representing the Unassigned collection as a different object, given that
// it is not actually a collection.
const noneCollection = new CollectionAdminView();
noneCollection.name = this.i18nService.t("unassigned");
noneCollection.id = Unassigned;
noneCollection.id = Unassigned as CollectionId;
noneCollection.organizationId = organizationId;
return allCollections.concat(noneCollection);
}),
@@ -424,16 +439,9 @@ export class VaultComponent implements OnInit, OnDestroy {
}),
);
const nestedCollections$ = combineLatest([
allCollections$,
this.configService.getFeatureFlag$(FeatureFlag.OptimizeNestedTraverseTypescript),
]).pipe(
map(
([collections, shouldOptimize]) =>
(shouldOptimize
? getNestedCollectionTree_vNext(collections)
: getNestedCollectionTree(collections)) as TreeNode<CollectionAdminView>[],
),
const nestedCollections$ = allCollections$.pipe(
map((collections) => getNestedCollectionTree(collections)),
shareReplay({ refCount: true, bufferSize: 1 }),
);
const collections$ = combineLatest([
@@ -548,7 +556,7 @@ export class VaultComponent implements OnInit, OnDestroy {
const filterFunction = createFilterFunction(filter);
if (await this.searchService.isSearchable(this.userId, searchText)) {
return await this.searchService.searchCiphers(
return await this.searchService.searchCiphers<CipherView>(
this.userId,
searchText,
[filterFunction],
@@ -761,10 +769,13 @@ export class VaultComponent implements OnInit, OnDestroy {
}
async navigateToPaymentMethod() {
await this.router.navigate(
["organizations", `${this.organization?.id}`, "billing", "payment-method"],
{ state: { launchPaymentModalAutomatically: true } },
const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag(
FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
);
const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method";
await this.router.navigate(["organizations", `${this.organization?.id}`, "billing", route], {
state: { launchPaymentModalAutomatically: true },
});
}
addAccessToggle(e: AddAccessStatusType) {
@@ -781,7 +792,7 @@ export class VaultComponent implements OnInit, OnDestroy {
this.destroy$.complete();
}
async onVaultItemsEvent(event: VaultItemEvent) {
async onVaultItemsEvent(event: VaultItemEvent<CipherView>) {
this.processingEvent = true;
try {
@@ -1077,6 +1088,7 @@ export class VaultComponent implements OnInit, OnDestroy {
if (unassignedCiphers.length > 0 || editAccessCiphers.length > 0) {
await this.cipherService.restoreManyWithServer(
[...unassignedCiphers, ...editAccessCiphers],
this.userId,
this.organization.id,
);
}
@@ -1141,16 +1153,18 @@ export class VaultComponent implements OnInit, OnDestroy {
}
try {
await this.apiService.deleteCollection(this.organization?.id, collection.id);
await this.collectionService.delete([collection.id as CollectionId], this.userId);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("deletedCollectionId", collection.name),
});
// Clear the cipher cache to clear the deleted collection from the cipher state
await this.cipherService.clear();
// Navigate away if we deleted the collection we were viewing
if (this.selectedCollection?.node.id === collection.id) {
// Clear the cipher cache to clear the deleted collection from the cipher state
await this.cipherService.clear();
void this.router.navigate([], {
queryParams: { collectionId: this.selectedCollection.parent?.node.id ?? null },
queryParamsHandling: "merge",
@@ -1424,6 +1438,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private refresh() {
this.refresh$.next();
this.vaultItemsComponent?.clearSelection();
}
private go(queryParams: any = null) {

View File

@@ -1,9 +1,11 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnInit } from "@angular/core";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { Observable, switchMap } from "rxjs";
import { Observable, Subject, switchMap, takeUntil, scheduled, asyncScheduler } from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/integrations";
import {
getOrganizationById,
OrganizationService,
@@ -11,6 +13,8 @@ import {
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { IntegrationType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { HeaderModule } from "../../../layouts/header/header.module";
import { SharedModule } from "../../../shared/shared.module";
@@ -22,7 +26,6 @@ import { Integration } from "../shared/components/integrations/models";
@Component({
selector: "ac-integrations",
templateUrl: "./integrations.component.html",
standalone: true,
imports: [
SharedModule,
SharedOrganizationModule,
@@ -31,12 +34,193 @@ import { Integration } from "../shared/components/integrations/models";
FilterIntegrationsPipe,
],
})
export class AdminConsoleIntegrationsComponent implements OnInit {
integrationsList: Integration[] = [];
export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
// integrationsList: Integration[] = [];
tabIndex: number;
organization$: Observable<Organization>;
isEventBasedIntegrationsEnabled: boolean = false;
private destroy$ = new Subject<void>();
// initialize the integrations list with default integrations
integrationsList: Integration[] = [
{
name: "AD FS",
linkURL: "https://bitwarden.com/help/saml-adfs/",
image: "../../../../../../../images/integrations/azure-active-directory.svg",
type: IntegrationType.SSO,
},
{
name: "Auth0",
linkURL: "https://bitwarden.com/help/saml-auth0/",
image: "../../../../../../../images/integrations/logo-auth0-badge-color.svg",
type: IntegrationType.SSO,
},
{
name: "AWS",
linkURL: "https://bitwarden.com/help/saml-aws/",
image: "../../../../../../../images/integrations/aws-color.svg",
imageDarkMode: "../../../../../../../images/integrations/aws-darkmode.svg",
type: IntegrationType.SSO,
},
{
name: "Microsoft Entra ID",
linkURL: "https://bitwarden.com/help/saml-azure/",
image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg",
type: IntegrationType.SSO,
},
{
name: "Duo",
linkURL: "https://bitwarden.com/help/saml-duo/",
image: "../../../../../../../images/integrations/logo-duo-color.svg",
type: IntegrationType.SSO,
},
{
name: "Google",
linkURL: "https://bitwarden.com/help/saml-google/",
image: "../../../../../../../images/integrations/logo-google-badge-color.svg",
type: IntegrationType.SSO,
},
{
name: "JumpCloud",
linkURL: "https://bitwarden.com/help/saml-jumpcloud/",
image: "../../../../../../../images/integrations/logo-jumpcloud-badge-color.svg",
imageDarkMode: "../../../../../../../images/integrations/jumpcloud-darkmode.svg",
type: IntegrationType.SSO,
},
{
name: "KeyCloak",
linkURL: "https://bitwarden.com/help/saml-keycloak/",
image: "../../../../../../../images/integrations/logo-keycloak-icon.svg",
type: IntegrationType.SSO,
},
{
name: "Okta",
linkURL: "https://bitwarden.com/help/saml-okta/",
image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg",
imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg",
type: IntegrationType.SSO,
},
{
name: "OneLogin",
linkURL: "https://bitwarden.com/help/saml-onelogin/",
image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg",
imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg",
type: IntegrationType.SSO,
},
{
name: "PingFederate",
linkURL: "https://bitwarden.com/help/saml-pingfederate/",
image: "../../../../../../../images/integrations/logo-ping-identity-badge-color.svg",
type: IntegrationType.SSO,
},
{
name: "Microsoft Entra ID",
linkURL: "https://bitwarden.com/help/microsoft-entra-id-scim-integration/",
image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg",
type: IntegrationType.SCIM,
},
{
name: "Okta",
linkURL: "https://bitwarden.com/help/okta-scim-integration/",
image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg",
imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg",
type: IntegrationType.SCIM,
},
{
name: "OneLogin",
linkURL: "https://bitwarden.com/help/onelogin-scim-integration/",
image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg",
imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg",
type: IntegrationType.SCIM,
},
{
name: "JumpCloud",
linkURL: "https://bitwarden.com/help/jumpcloud-scim-integration/",
image: "../../../../../../../images/integrations/logo-jumpcloud-badge-color.svg",
imageDarkMode: "../../../../../../../images/integrations/jumpcloud-darkmode.svg",
type: IntegrationType.SCIM,
},
{
name: "Ping Identity",
linkURL: "https://bitwarden.com/help/ping-identity-scim-integration/",
image: "../../../../../../../images/integrations/logo-ping-identity-badge-color.svg",
type: IntegrationType.SCIM,
},
{
name: "Active Directory",
linkURL: "https://bitwarden.com/help/ldap-directory/",
image: "../../../../../../../images/integrations/azure-active-directory.svg",
type: IntegrationType.BWDC,
},
{
name: "Microsoft Entra ID",
linkURL: "https://bitwarden.com/help/microsoft-entra-id/",
image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg",
type: IntegrationType.BWDC,
},
{
name: "Google Workspace",
linkURL: "https://bitwarden.com/help/workspace-directory/",
image: "../../../../../../../images/integrations/logo-google-badge-color.svg",
type: IntegrationType.BWDC,
},
{
name: "Okta",
linkURL: "https://bitwarden.com/help/okta-directory/",
image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg",
imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg",
type: IntegrationType.BWDC,
},
{
name: "OneLogin",
linkURL: "https://bitwarden.com/help/onelogin-directory/",
image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg",
imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg",
type: IntegrationType.BWDC,
},
{
name: "Splunk",
linkURL: "https://bitwarden.com/help/splunk-siem/",
image: "../../../../../../../images/integrations/logo-splunk-black.svg",
imageDarkMode: "../../../../../../../images/integrations/splunk-darkmode.svg",
type: IntegrationType.EVENT,
},
{
name: "Microsoft Sentinel",
linkURL: "https://bitwarden.com/help/microsoft-sentinel-siem/",
image: "../../../../../../../images/integrations/logo-microsoft-sentinel-color.svg",
type: IntegrationType.EVENT,
},
{
name: "Rapid7",
linkURL: "https://bitwarden.com/help/rapid7-siem/",
image: "../../../../../../../images/integrations/logo-rapid7-black.svg",
imageDarkMode: "../../../../../../../images/integrations/rapid7-darkmode.svg",
type: IntegrationType.EVENT,
},
{
name: "Elastic",
linkURL: "https://bitwarden.com/help/elastic-siem/",
image: "../../../../../../../images/integrations/logo-elastic-badge-color.svg",
type: IntegrationType.EVENT,
},
{
name: "Panther",
linkURL: "https://bitwarden.com/help/panther-siem/",
image: "../../../../../../../images/integrations/logo-panther-round-color.svg",
type: IntegrationType.EVENT,
},
{
name: "Microsoft Intune",
linkURL: "https://bitwarden.com/help/deploy-browser-extensions-with-intune/",
image: "../../../../../../../images/integrations/logo-microsoft-intune-color.svg",
type: IntegrationType.DEVICE,
},
];
ngOnInit(): void {
const orgId = this.route.snapshot.params.organizationId;
this.organization$ = this.route.params.pipe(
switchMap((params) =>
this.accountService.activeAccount$.pipe(
@@ -48,188 +232,56 @@ export class AdminConsoleIntegrationsComponent implements OnInit {
),
),
);
scheduled(this.orgIntegrationApiService.getOrganizationIntegrations(orgId), asyncScheduler)
.pipe(takeUntil(this.destroy$))
.subscribe((integrations) => {
// Update the integrations list with the fetched integrations
if (integrations && integrations.length > 0) {
integrations.forEach((integration) => {
const configJson = JSON.parse(integration.configuration || "{}");
const serviceName = configJson.service ?? "";
const existingIntegration = this.integrationsList.find((i) => i.name === serviceName);
if (existingIntegration) {
// if a configuration exists, then it is connected
existingIntegration.isConnected = !!integration.configuration;
existingIntegration.configuration = integration.configuration || "";
}
});
}
});
}
constructor(
private route: ActivatedRoute,
private organizationService: OrganizationService,
private accountService: AccountService,
private configService: ConfigService,
private orgIntegrationApiService: OrganizationIntegrationApiService,
) {
this.integrationsList = [
{
name: "AD FS",
linkURL: "https://bitwarden.com/help/saml-adfs/",
image: "../../../../../../../images/integrations/azure-active-directory.svg",
type: IntegrationType.SSO,
},
{
name: "Auth0",
linkURL: "https://bitwarden.com/help/saml-auth0/",
image: "../../../../../../../images/integrations/logo-auth0-badge-color.svg",
type: IntegrationType.SSO,
},
{
name: "AWS",
linkURL: "https://bitwarden.com/help/saml-aws/",
image: "../../../../../../../images/integrations/aws-color.svg",
imageDarkMode: "../../../../../../../images/integrations/aws-darkmode.svg",
type: IntegrationType.SSO,
},
{
name: "Microsoft Entra ID",
linkURL: "https://bitwarden.com/help/saml-azure/",
image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg",
type: IntegrationType.SSO,
},
{
name: "Duo",
linkURL: "https://bitwarden.com/help/saml-duo/",
image: "../../../../../../../images/integrations/logo-duo-color.svg",
type: IntegrationType.SSO,
},
{
name: "Google",
linkURL: "https://bitwarden.com/help/saml-google/",
image: "../../../../../../../images/integrations/logo-google-badge-color.svg",
type: IntegrationType.SSO,
},
{
name: "JumpCloud",
linkURL: "https://bitwarden.com/help/saml-jumpcloud/",
image: "../../../../../../../images/integrations/logo-jumpcloud-badge-color.svg",
imageDarkMode: "../../../../../../../images/integrations/jumpcloud-darkmode.svg",
type: IntegrationType.SSO,
},
{
name: "KeyCloak",
linkURL: "https://bitwarden.com/help/saml-keycloak/",
image: "../../../../../../../images/integrations/logo-keycloak-icon.svg",
type: IntegrationType.SSO,
},
{
name: "Okta",
linkURL: "https://bitwarden.com/help/saml-okta/",
image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg",
imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg",
type: IntegrationType.SSO,
},
{
name: "OneLogin",
linkURL: "https://bitwarden.com/help/saml-onelogin/",
image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg",
imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg",
type: IntegrationType.SSO,
},
{
name: "PingFederate",
linkURL: "https://bitwarden.com/help/saml-pingfederate/",
image: "../../../../../../../images/integrations/logo-ping-identity-badge-color.svg",
type: IntegrationType.SSO,
},
{
name: "Microsoft Entra ID",
linkURL: "https://bitwarden.com/help/microsoft-entra-id-scim-integration/",
image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg",
type: IntegrationType.SCIM,
},
{
name: "Okta",
linkURL: "https://bitwarden.com/help/okta-scim-integration/",
image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg",
imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg",
type: IntegrationType.SCIM,
},
{
name: "OneLogin",
linkURL: "https://bitwarden.com/help/onelogin-scim-integration/",
image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg",
imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg",
type: IntegrationType.SCIM,
},
{
name: "JumpCloud",
linkURL: "https://bitwarden.com/help/jumpcloud-scim-integration/",
image: "../../../../../../../images/integrations/logo-jumpcloud-badge-color.svg",
imageDarkMode: "../../../../../../../images/integrations/jumpcloud-darkmode.svg",
type: IntegrationType.SCIM,
},
{
name: "Ping Identity",
linkURL: "https://bitwarden.com/help/ping-identity-scim-integration/",
image: "../../../../../../../images/integrations/logo-ping-identity-badge-color.svg",
type: IntegrationType.SCIM,
},
{
name: "Active Directory",
linkURL: "https://bitwarden.com/help/ldap-directory/",
image: "../../../../../../../images/integrations/azure-active-directory.svg",
type: IntegrationType.BWDC,
},
{
name: "Microsoft Entra ID",
linkURL: "https://bitwarden.com/help/microsoft-entra-id/",
image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg",
type: IntegrationType.BWDC,
},
{
name: "Google Workspace",
linkURL: "https://bitwarden.com/help/workspace-directory/",
image: "../../../../../../../images/integrations/logo-google-badge-color.svg",
type: IntegrationType.BWDC,
},
{
name: "Okta",
linkURL: "https://bitwarden.com/help/okta-directory/",
image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg",
imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg",
type: IntegrationType.BWDC,
},
{
name: "OneLogin",
linkURL: "https://bitwarden.com/help/onelogin-directory/",
image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg",
imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg",
type: IntegrationType.BWDC,
},
{
name: "Splunk",
linkURL: "https://bitwarden.com/help/splunk-siem/",
image: "../../../../../../../images/integrations/logo-splunk-black.svg",
imageDarkMode: "../../../../../../../images/integrations/splunk-darkmode.svg",
this.configService
.getFeatureFlag$(FeatureFlag.EventBasedOrganizationIntegrations)
.pipe(takeUntil(this.destroy$))
.subscribe((isEnabled) => {
this.isEventBasedIntegrationsEnabled = isEnabled;
});
if (this.isEventBasedIntegrationsEnabled) {
this.integrationsList.push({
name: "Crowdstrike",
linkURL: "",
image: "../../../../../../../images/integrations/logo-crowdstrike-black.svg",
type: IntegrationType.EVENT,
},
{
name: "Microsoft Sentinel",
linkURL: "https://bitwarden.com/help/microsoft-sentinel-siem/",
image: "../../../../../../../images/integrations/logo-microsoft-sentinel-color.svg",
type: IntegrationType.EVENT,
},
{
name: "Rapid7",
linkURL: "https://bitwarden.com/help/rapid7-siem/",
image: "../../../../../../../images/integrations/logo-rapid7-black.svg",
imageDarkMode: "../../../../../../../images/integrations/rapid7-darkmode.svg",
type: IntegrationType.EVENT,
},
{
name: "Elastic",
linkURL: "https://bitwarden.com/help/elastic-siem/",
image: "../../../../../../../images/integrations/logo-elastic-badge-color.svg",
type: IntegrationType.EVENT,
},
{
name: "Panther",
linkURL: "https://bitwarden.com/help/panther-siem/",
image: "../../../../../../../images/integrations/logo-panther-round-color.svg",
type: IntegrationType.EVENT,
},
{
name: "Microsoft Intune",
linkURL: "https://bitwarden.com/help/deploy-browser-extensions-with-intune/",
image: "../../../../../../../images/integrations/logo-microsoft-intune-color.svg",
type: IntegrationType.DEVICE,
},
];
description: "crowdstrikeEventIntegrationDesc",
isConnected: false,
canSetupConnection: true,
});
}
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
get IntegrationType(): typeof IntegrationType {

View File

@@ -4,7 +4,7 @@
<org-switcher [filter]="orgFilter" [hideNewButton]="hideNewOrgButton$ | async"></org-switcher>
<bit-nav-group
icon="bwi-filter"
*ngIf="organization.useRiskInsights"
*ngIf="organization.useRiskInsights && organization.canAccessReports"
[text]="'accessIntelligence' | i18n"
route="access-intelligence"
>
@@ -74,7 +74,11 @@
>
<bit-nav-item [text]="'subscription' | i18n" route="billing/subscription"></bit-nav-item>
<ng-container *ngIf="(showPaymentAndHistory$ | async) && (organizationIsUnmanaged$ | async)">
<bit-nav-item [text]="'paymentMethod' | i18n" route="billing/payment-method"></bit-nav-item>
@let paymentDetailsPageData = paymentDetailsPageData$ | async;
<bit-nav-item
[text]="paymentDetailsPageData.textKey | i18n"
[route]="paymentDetailsPageData.route"
></bit-nav-item>
<bit-nav-item [text]="'billingHistory' | i18n" route="billing/history"></bit-nav-item>
</ng-container>
</bit-nav-group>

View File

@@ -27,17 +27,15 @@ 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 { getById } from "@bitwarden/common/platform/misc";
import { BannerModule, IconModule } from "@bitwarden/components";
import { BannerModule, IconModule, AdminConsoleLogo } from "@bitwarden/components";
import { FreeFamiliesPolicyService } from "../../../billing/services/free-families-policy.service";
import { OrgSwitcherComponent } from "../../../layouts/org-switcher/org-switcher.component";
import { WebLayoutModule } from "../../../layouts/web-layout.module";
import { AdminConsoleLogo } from "../../icons/admin-console-logo";
@Component({
selector: "app-organization-layout",
templateUrl: "organization-layout.component.html",
standalone: true,
imports: [
CommonModule,
RouterModule,
@@ -66,6 +64,11 @@ export class OrganizationLayoutComponent implements OnInit {
protected showSponsoredFamiliesDropdown$: Observable<boolean>;
protected canShowPoliciesTab$: Observable<boolean>;
protected paymentDetailsPageData$: Observable<{
route: string;
textKey: string;
}>;
constructor(
private route: ActivatedRoute,
private organizationService: OrganizationService,
@@ -137,6 +140,16 @@ export class OrganizationLayoutComponent implements OnInit {
),
),
);
this.paymentDetailsPageData$ = this.configService
.getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout)
.pipe(
map((managePaymentDetailsOutsideCheckout) =>
managePaymentDetailsOutsideCheckout
? { route: "billing/payment-details", textKey: "paymentDetails" }
: { route: "billing/payment-method", textKey: "paymentMethod" },
),
);
}
canShowVaultTab(organization: Organization): boolean {

View File

@@ -38,7 +38,6 @@ export interface EntityEventsDialogParams {
@Component({
imports: [SharedModule],
templateUrl: "entity-events.component.html",
standalone: true,
})
export class EntityEventsComponent implements OnInit, OnDestroy {
loading = true;

View File

@@ -28,6 +28,7 @@ import {
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -156,7 +157,11 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
private orgCollections$ = from(this.collectionAdminService.getAll(this.organizationId)).pipe(
private orgCollections$ = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.collectionAdminService.collectionAdminViews$(this.organizationId, userId),
),
shareReplay({ refCount: true, bufferSize: 1 }),
);

View File

@@ -22,7 +22,7 @@
<p *ngIf="!dataSource.filteredData.length">{{ "noGroupsInList" | i18n }}</p>
<!-- 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 scrollWindow [itemSize]="rowHeight" class="tw-pb-8">
<cdk-virtual-scroll-viewport bitScrollLayout [itemSize]="rowHeight" class="tw-pb-8">
<bit-table *ngIf="dataSource.filteredData.length" [dataSource]="dataSource">
<ng-container header>
<tr>

View File

@@ -11,6 +11,7 @@ import {
from,
lastValueFrom,
map,
Observable,
switchMap,
tap,
} from "rxjs";
@@ -25,10 +26,13 @@ import {
CollectionView,
} from "@bitwarden/admin-console/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
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 { DialogService, TableDataSource, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { GroupDetailsView, InternalGroupApiService as GroupService } from "../core";
@@ -85,8 +89,8 @@ export class GroupsComponent {
protected searchControl = new FormControl("");
// Fixed sizes used for cdkVirtualScroll
protected rowHeight = 52;
protected rowHeightClass = `tw-h-[52px]`;
protected rowHeight = 50;
protected rowHeightClass = `tw-h-[50px]`;
protected ModalTabType = GroupAddEditTabType;
private refreshGroups$ = new BehaviorSubject<void>(null);
@@ -100,6 +104,8 @@ export class GroupsComponent {
private logService: LogService,
private collectionService: CollectionService,
private toastService: ToastService,
private keyService: KeyService,
private accountService: AccountService,
) {
this.route.params
.pipe(
@@ -244,16 +250,22 @@ export class GroupsComponent {
this.dataSource.data = this.dataSource.data.filter((g) => g !== groupRow);
}
private async toCollectionMap(response: ListResponse<CollectionResponse>) {
private toCollectionMap(
response: ListResponse<CollectionResponse>,
): Observable<Record<string, CollectionView>> {
const collections = response.data.map(
(r) => new Collection(new CollectionData(r as CollectionDetailsResponse)),
);
const decryptedCollections = await this.collectionService.decryptMany(collections);
// Convert to an object using collection Ids as keys for faster name lookups
const collectionMap: Record<string, CollectionView> = {};
decryptedCollections.forEach((c) => (collectionMap[c.id] = c));
return collectionMap;
return this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.keyService.orgKeys$(userId)),
switchMap((orgKeys) => this.collectionService.decryptMany$(collections, orgKeys)),
map((collections) => {
const collectionMap: Record<string, CollectionView> = {};
collections.forEach((c) => (collectionMap[c.id] = c));
return collectionMap;
}),
);
}
}

View File

@@ -14,7 +14,6 @@ import { SharedModule } from "../../../shared/shared.module";
@Component({
templateUrl: "verify-recover-delete-org.component.html",
standalone: true,
imports: [SharedModule],
})
export class VerifyRecoverDeleteOrgComponent implements OnInit {

View File

@@ -0,0 +1,21 @@
<bit-dialog [title]="'recoverAccount' | i18n" [subtitle]="dialogData.name">
<ng-container bitDialogContent>
<bit-callout type="warning"
>{{ "resetPasswordLoggedOutWarning" | i18n: loggedOutWarningName }}
</bit-callout>
<auth-input-password
[flow]="inputPasswordFlow"
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions$ | async"
></auth-input-password>
</ng-container>
<ng-container bitDialogFooter>
<button type="button" bitButton buttonType="primary" [bitAction]="handlePrimaryButtonClick">
{{ "save" | i18n }}
</button>
<button type="button" bitButton buttonType="secondary" bitDialogClose>
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>

View File

@@ -0,0 +1,146 @@
import { CommonModule } from "@angular/common";
import { Component, Inject, ViewChild } from "@angular/core";
import { switchMap } from "rxjs";
import { InputPasswordComponent, InputPasswordFlow } from "@bitwarden/auth/angular";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import {
AsyncActionsModule,
ButtonModule,
CalloutModule,
DIALOG_DATA,
DialogConfig,
DialogModule,
DialogRef,
DialogService,
ToastService,
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { OrganizationUserResetPasswordService } from "../../services/organization-user-reset-password/organization-user-reset-password.service";
/**
* Encapsulates a few key data inputs needed to initiate an account recovery
* process for the organization user in question.
*/
export type AccountRecoveryDialogData = {
/**
* The organization user's full name
*/
name: string;
/**
* The organization user's email address
*/
email: string;
/**
* The `organizationUserId` for the user
*/
organizationUserId: string;
/**
* The organization's `organizationId`
*/
organizationId: OrganizationId;
};
export const AccountRecoveryDialogResultType = {
Ok: "ok",
} as const;
export type AccountRecoveryDialogResultType =
(typeof AccountRecoveryDialogResultType)[keyof typeof AccountRecoveryDialogResultType];
/**
* Used in a dialog for initiating the account recovery process against a
* given organization user. An admin will access this form when they want to
* reset a user's password and log them out of sessions.
*/
@Component({
standalone: true,
selector: "app-account-recovery-dialog",
templateUrl: "account-recovery-dialog.component.html",
imports: [
AsyncActionsModule,
ButtonModule,
CalloutModule,
CommonModule,
DialogModule,
I18nPipe,
InputPasswordComponent,
],
})
export class AccountRecoveryDialogComponent {
@ViewChild(InputPasswordComponent)
inputPasswordComponent: InputPasswordComponent | undefined = undefined;
masterPasswordPolicyOptions$ = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.policyService.masterPasswordPolicyOptions$(userId)),
);
inputPasswordFlow = InputPasswordFlow.ChangePasswordDelegation;
get loggedOutWarningName() {
return this.dialogData.name != null ? this.dialogData.name : this.i18nService.t("thisUser");
}
constructor(
@Inject(DIALOG_DATA) protected dialogData: AccountRecoveryDialogData,
private accountService: AccountService,
private dialogRef: DialogRef<AccountRecoveryDialogResultType>,
private i18nService: I18nService,
private policyService: PolicyService,
private resetPasswordService: OrganizationUserResetPasswordService,
private toastService: ToastService,
) {}
handlePrimaryButtonClick = async () => {
if (!this.inputPasswordComponent) {
throw new Error("InputPasswordComponent is not initialized");
}
const passwordInputResult = await this.inputPasswordComponent.submit();
if (!passwordInputResult) {
return;
}
await this.resetPasswordService.resetMasterPassword(
passwordInputResult.newPassword,
this.dialogData.email,
this.dialogData.organizationUserId,
this.dialogData.organizationId,
);
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("resetPasswordSuccess"),
});
this.dialogRef.close(AccountRecoveryDialogResultType.Ok);
};
/**
* Strongly typed helper to open an `AccountRecoveryDialogComponent`
* @param dialogService Instance of the dialog service that will be used to open the dialog
* @param dialogConfig Configuration for the dialog
*/
static open = (
dialogService: DialogService,
dialogConfig: DialogConfig<
AccountRecoveryDialogData,
DialogRef<AccountRecoveryDialogResultType, unknown>
>,
) => {
return dialogService.open<AccountRecoveryDialogResultType, AccountRecoveryDialogData>(
AccountRecoveryDialogComponent,
dialogConfig,
);
};
}

View File

@@ -0,0 +1 @@
export * from "./account-recovery-dialog.component";

View File

@@ -11,10 +11,13 @@ import {
OrganizationUserBulkResponse,
} from "@bitwarden/admin-console/common";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProviderUserBulkPublicKeyResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user-bulk-public-key.response";
import { ProviderUserBulkResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user-bulk.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { StateProvider } from "@bitwarden/common/platform/state";
@@ -23,11 +26,13 @@ import { OrgKey } from "@bitwarden/common/types/key";
import { DIALOG_DATA, DialogConfig, DialogService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { OrganizationUserService } from "../../services/organization-user/organization-user.service";
import { BaseBulkConfirmComponent } from "./base-bulk-confirm.component";
import { BulkUserDetails } from "./bulk-status.component";
type BulkConfirmDialogParams = {
organizationId: string;
organization: Organization;
users: BulkUserDetails[];
};
@@ -36,7 +41,7 @@ type BulkConfirmDialogParams = {
standalone: false,
})
export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent {
organizationId: string;
organization: Organization;
organizationKey$: Observable<OrgKey>;
users: BulkUserDetails[];
@@ -47,13 +52,15 @@ export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent {
private organizationUserApiService: OrganizationUserApiService,
protected i18nService: I18nService,
private stateProvider: StateProvider,
private organizationUserService: OrganizationUserService,
private configService: ConfigService,
) {
super(keyService, encryptService, i18nService);
this.organizationId = dialogParams.organizationId;
this.organization = dialogParams.organization;
this.organizationKey$ = this.stateProvider.activeUserId$.pipe(
switchMap((userId) => this.keyService.orgKeys$(userId)),
map((organizationKeysById) => organizationKeysById[this.organizationId as OrganizationId]),
map((organizationKeysById) => organizationKeysById[this.organization.id as OrganizationId]),
takeUntilDestroyed(),
);
this.users = dialogParams.users;
@@ -66,7 +73,7 @@ export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent {
ListResponse<OrganizationUserBulkPublicKeyResponse | ProviderUserBulkPublicKeyResponse>
> =>
await this.organizationUserApiService.postOrganizationUsersPublicKey(
this.organizationId,
this.organization.id,
this.filteredUsers.map((user) => user.id),
);
@@ -76,11 +83,19 @@ export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent {
protected postConfirmRequest = async (
userIdsWithKeys: { id: string; key: string }[],
): Promise<ListResponse<OrganizationUserBulkResponse | ProviderUserBulkResponse>> => {
const request = new OrganizationUserBulkConfirmRequest(userIdsWithKeys);
return await this.organizationUserApiService.postOrganizationUserBulkConfirm(
this.organizationId,
request,
);
if (
await firstValueFrom(this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation))
) {
return await firstValueFrom(
this.organizationUserService.bulkConfirmUsers(this.organization, userIdsWithKeys),
);
} else {
const request = new OrganizationUserBulkConfirmRequest(userIdsWithKeys);
return await this.organizationUserApiService.postOrganizationUserBulkConfirm(
this.organization.id,
request,
);
}
};
static open(dialogService: DialogService, config: DialogConfig<BulkConfirmDialogParams>) {

View File

@@ -134,17 +134,11 @@
type="checkbox"
bitCheckbox
formControlName="manageUsers"
(change)="handleDependentPermissions()"
/>
<bit-label>{{ "manageUsers" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input
type="checkbox"
bitCheckbox
formControlName="manageResetPassword"
(change)="handleDependentPermissions()"
/>
<input type="checkbox" bitCheckbox formControlName="manageResetPassword" />
<bit-label>{{ "manageAccountRecovery" | i18n }}</bit-label>
</bit-form-control>
</div>

View File

@@ -32,8 +32,8 @@ import {
import { PermissionsApi } from "@bitwarden/common/admin-console/models/api/permissions.api";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
@@ -277,9 +277,16 @@ export class MemberDialogComponent implements OnDestroy {
),
);
const collections = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.collectionAdminService.collectionAdminViews$(this.params.organizationId, userId),
),
);
combineLatest({
organization: this.organization$,
collections: this.collectionAdminService.getAll(this.params.organizationId),
collections,
userDetails: userDetails$,
groups: groups$,
})
@@ -453,28 +460,6 @@ export class MemberDialogComponent implements OnDestroy {
return Object.assign(p, partialPermissions);
}
async handleDependentPermissions() {
const separateCustomRolePermissions = await this.configService.getFeatureFlag(
FeatureFlag.SeparateCustomRolePermissions,
);
if (separateCustomRolePermissions) {
return;
}
// Manage Password Reset (Account Recovery) must have Manage Users enabled
if (
this.permissionsGroup.value.manageResetPassword &&
!this.permissionsGroup.value.manageUsers
) {
this.permissionsGroup.value.manageUsers = true;
(document.getElementById("manageUsers") as HTMLInputElement).checked = true;
this.toastService.showToast({
variant: "info",
title: null,
message: this.i18nService.t("accountRecoveryManageUsers"),
});
}
}
submit = async () => {
this.formGroup.markAllAsTouched();

View File

@@ -1,67 +0,0 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog [title]="'recoverAccount' | i18n" [subtitle]="data.name">
<ng-container bitDialogContent>
<bit-callout type="warning"
>{{ "resetPasswordLoggedOutWarning" | i18n: loggedOutWarningName }}
</bit-callout>
<auth-password-callout
[policy]="enforcedPolicyOptions"
message="resetPasswordMasterPasswordPolicyInEffect"
*ngIf="enforcedPolicyOptions"
>
</auth-password-callout>
<bit-form-field>
<bit-label>
{{ "newPassword" | i18n }}
</bit-label>
<input
id="newPassword"
bitInput
[type]="showPassword ? 'text' : 'password'"
name="NewPassword"
formControlName="newPassword"
required
appInputVerbatim
autocomplete="new-password"
/>
<button
type="button"
bitIconButton="bwi-generate"
bitSuffix
[appA11yTitle]="'generatePassword' | i18n"
(click)="generatePassword()"
></button>
<button
type="button"
bitSuffix
[bitIconButton]="showPassword ? 'bwi-eye-slash' : 'bwi-eye'"
buttonType="secondary"
appA11yTitle="{{ 'toggleVisibility' | i18n }}"
(click)="togglePassword()"
></button>
<button
type="button"
bitSuffix
bitIconButton="bwi-clone"
appA11yTitle="{{ 'copyPassword' | i18n }}"
(click)="copy()"
></button>
</bit-form-field>
<tools-password-strength
[password]="formGroup.value.newPassword"
[email]="data.email"
[showText]="true"
(passwordStrengthScore)="getStrengthScore($event)"
>
</tools-password-strength>
</ng-container>
<ng-container bitDialogFooter>
<button bitButton buttonType="primary" bitFormButton type="submit">
{{ "save" | i18n }}
</button>
<button bitButton buttonType="secondary" bitDialogClose type="button">
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@@ -1,220 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, Inject, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { Subject, switchMap, takeUntil } from "rxjs";
import { PasswordStrengthV2Component } from "@bitwarden/angular/tools/password-strength/password-strength-v2.component";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
DIALOG_DATA,
DialogConfig,
DialogRef,
DialogService,
ToastService,
} from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { OrganizationUserResetPasswordService } from "../services/organization-user-reset-password/organization-user-reset-password.service";
/**
* Encapsulates a few key data inputs needed to initiate an account recovery
* process for the organization user in question.
*/
export type ResetPasswordDialogData = {
/**
* The organization user's full name
*/
name: string;
/**
* The organization user's email address
*/
email: string;
/**
* The `organizationUserId` for the user
*/
id: string;
/**
* The organization's `organizationId`
*/
organizationId: string;
};
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum ResetPasswordDialogResult {
Ok = "ok",
}
@Component({
selector: "app-reset-password",
templateUrl: "reset-password.component.html",
standalone: false,
})
/**
* Used in a dialog for initiating the account recovery process against a
* given organization user. An admin will access this form when they want to
* reset a user's password and log them out of sessions.
*/
export class ResetPasswordComponent implements OnInit, OnDestroy {
formGroup = this.formBuilder.group({
newPassword: ["", Validators.required],
});
@ViewChild(PasswordStrengthV2Component) passwordStrengthComponent: PasswordStrengthV2Component;
enforcedPolicyOptions: MasterPasswordPolicyOptions;
showPassword = false;
passwordStrengthScore: number;
private destroy$ = new Subject<void>();
constructor(
@Inject(DIALOG_DATA) protected data: ResetPasswordDialogData,
private resetPasswordService: OrganizationUserResetPasswordService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private passwordGenerationService: PasswordGenerationServiceAbstraction,
private policyService: PolicyService,
private logService: LogService,
private dialogService: DialogService,
private toastService: ToastService,
private formBuilder: FormBuilder,
private dialogRef: DialogRef<ResetPasswordDialogResult>,
private accountService: AccountService,
) {}
async ngOnInit() {
this.accountService.activeAccount$
.pipe(
getUserId,
switchMap((userId) => this.policyService.masterPasswordPolicyOptions$(userId)),
takeUntil(this.destroy$),
)
.subscribe(
(enforcedPasswordPolicyOptions) =>
(this.enforcedPolicyOptions = enforcedPasswordPolicyOptions),
);
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
get loggedOutWarningName() {
return this.data.name != null ? this.data.name : this.i18nService.t("thisUser");
}
async generatePassword() {
const options = (await this.passwordGenerationService.getOptions())?.[0] ?? {};
this.formGroup.patchValue({
newPassword: await this.passwordGenerationService.generatePassword(options),
});
this.passwordStrengthComponent.updatePasswordStrength(this.formGroup.value.newPassword);
}
togglePassword() {
this.showPassword = !this.showPassword;
document.getElementById("newPassword").focus();
}
copy() {
const value = this.formGroup.value.newPassword;
if (value == null) {
return;
}
this.platformUtilsService.copyToClipboard(value, { window: window });
this.toastService.showToast({
variant: "info",
title: null,
message: this.i18nService.t("valueCopied", this.i18nService.t("password")),
});
}
submit = async () => {
// Validation
if (this.formGroup.value.newPassword == null || this.formGroup.value.newPassword === "") {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("masterPasswordRequired"),
});
return false;
}
if (this.formGroup.value.newPassword.length < Utils.minimumPasswordLength) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("masterPasswordMinlength", Utils.minimumPasswordLength),
});
return false;
}
if (
this.enforcedPolicyOptions != null &&
!this.policyService.evaluateMasterPassword(
this.passwordStrengthScore,
this.formGroup.value.newPassword,
this.enforcedPolicyOptions,
)
) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("masterPasswordPolicyRequirementsNotMet"),
});
return;
}
if (this.passwordStrengthScore < 3) {
const result = await this.dialogService.openSimpleDialog({
title: { key: "weakMasterPassword" },
content: { key: "weakMasterPasswordDesc" },
type: "warning",
});
if (!result) {
return false;
}
}
try {
await this.resetPasswordService.resetMasterPassword(
this.formGroup.value.newPassword,
this.data.email,
this.data.id,
this.data.organizationId,
);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("resetPasswordSuccess"),
});
} catch (e) {
this.logService.error(e);
}
this.dialogRef.close(ResetPasswordDialogResult.Ok);
};
getStrengthScore(result: number) {
this.passwordStrengthScore = result;
}
static open = (dialogService: DialogService, input: DialogConfig<ResetPasswordDialogData>) => {
return dialogService.open<ResetPasswordDialogResult>(ResetPasswordComponent, input);
};
}

View File

@@ -1,3 +1,8 @@
<app-organization-free-trial-warning
[organization]="organization"
(clicked)="navigateToPaymentMethod()"
>
</app-organization-free-trial-warning>
<app-header>
<bit-search
class="tw-grow"
@@ -75,7 +80,7 @@
</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 scrollWindow [itemSize]="rowHeight" class="tw-pb-8">
<cdk-virtual-scroll-viewport bitScrollLayout [itemSize]="rowHeight" class="tw-pb-8">
<bit-table [dataSource]="dataSource">
<ng-container header>
<tr>

View File

@@ -13,6 +13,7 @@ import {
Observable,
shareReplay,
switchMap,
tap,
} from "rxjs";
import {
@@ -52,6 +53,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
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 { OrganizationId } from "@bitwarden/common/types/guid";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DialogService, SimpleDialogOptions, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
@@ -60,12 +62,17 @@ import {
ChangePlanDialogResultType,
openChangePlanDialog,
} from "../../../billing/organizations/change-plan-dialog.component";
import { OrganizationWarningsService } from "../../../billing/warnings/services";
import { BaseMembersComponent } from "../../common/base-members.component";
import { PeopleTableDataSource } from "../../common/people-table-data-source";
import { GroupApiService } from "../core";
import { OrganizationUserView } from "../core/views/organization-user.view";
import { openEntityEventsDialog } from "../manage/entity-events.component";
import {
AccountRecoveryDialogComponent,
AccountRecoveryDialogResultType,
} from "./components/account-recovery/account-recovery-dialog.component";
import { BulkConfirmDialogComponent } from "./components/bulk/bulk-confirm-dialog.component";
import { BulkDeleteDialogComponent } from "./components/bulk/bulk-delete-dialog.component";
import { BulkEnableSecretsManagerDialogComponent } from "./components/bulk/bulk-enable-sm-dialog.component";
@@ -78,11 +85,8 @@ import {
openUserAddEditDialog,
} from "./components/member-dialog";
import { isFixedSeatPlan } from "./components/member-dialog/validators/org-seat-limit-reached.validator";
import {
ResetPasswordComponent,
ResetPasswordDialogResult,
} from "./components/reset-password.component";
import { DeleteManagedMemberWarningService } from "./services/delete-managed-member/delete-managed-member-warning.service";
import { OrganizationUserService } from "./services/organization-user/organization-user.service";
class MembersTableDataSource extends PeopleTableDataSource<OrganizationUserView> {
protected statusType = OrganizationUserStatusType;
@@ -107,8 +111,8 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
protected showUserManagementControls$: Observable<boolean>;
// Fixed sizes used for cdkVirtualScroll
protected rowHeight = 69;
protected rowHeightClass = `tw-h-[69px]`;
protected rowHeight = 66;
protected rowHeightClass = `tw-h-[66px]`;
private organizationUsersCount = 0;
@@ -141,6 +145,8 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
private billingApiService: BillingApiServiceAbstraction,
protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService,
private configService: ConfigService,
private organizationUserService: OrganizationUserService,
private organizationWarningsService: OrganizationWarningsService,
) {
super(
apiService,
@@ -194,7 +200,14 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
this.organization.canManageUsersPassword &&
!this.organization.hasPublicAndPrivateKeys
) {
const orgShareKey = await this.keyService.getOrgKey(this.organization.id);
const orgShareKey = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.keyService.orgKeys$(userId)),
map((orgKeys) => orgKeys[this.organization.id] ?? null),
),
);
const orgKeys = await this.keyService.makeKeyPair(orgShareKey);
const request = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString);
const response = await this.organizationApiService.updateKeys(
@@ -237,16 +250,16 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
)
.subscribe();
// Setup feature flag-dependent observables
const separateCustomRolePermissionsEnabled$ = this.configService.getFeatureFlag$(
FeatureFlag.SeparateCustomRolePermissions,
);
this.showUserManagementControls$ = separateCustomRolePermissionsEnabled$.pipe(
map(
(separateCustomRolePermissionsEnabled) =>
!separateCustomRolePermissionsEnabled || this.organization.canManageUsers,
),
this.showUserManagementControls$ = organization$.pipe(
map((organization) => organization.canManageUsers),
);
organization$
.pipe(
takeUntilDestroyed(),
tap((org) => (this.organization = org)),
switchMap((org) => this.organizationWarningsService.showInactiveSubscriptionDialog$(org)),
)
.subscribe();
}
async getUsers(): Promise<OrganizationUserView[]> {
@@ -297,17 +310,30 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
* Retrieve a map of all collection IDs <-> names for the organization.
*/
async getCollectionNameMap() {
const collectionMap = new Map<string, string>();
const response = await this.apiService.getCollections(this.organization.id);
const collections = response.data.map(
(r) => new Collection(new CollectionData(r as CollectionDetailsResponse)),
const response = from(this.apiService.getCollections(this.organization.id)).pipe(
map((res) =>
res.data.map((r) => new Collection(new CollectionData(r as CollectionDetailsResponse))),
),
);
const decryptedCollections = await this.collectionService.decryptMany(collections);
decryptedCollections.forEach((c) => collectionMap.set(c.id, c.name));
const decryptedCollections$ = combineLatest([
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.keyService.orgKeys$(userId)),
),
response,
]).pipe(
switchMap(([orgKeys, collections]) =>
this.collectionService.decryptMany$(collections, orgKeys),
),
map((collections) => {
const collectionMap = new Map<string, string>();
collections.forEach((c) => collectionMap.set(c.id, c.name));
return collectionMap;
}),
);
return collectionMap;
return await firstValueFrom(decryptedCollections$);
}
removeUser(id: string): Promise<void> {
@@ -327,15 +353,29 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
}
async confirmUser(user: OrganizationUserView, publicKey: Uint8Array): Promise<void> {
const orgKey = await this.keyService.getOrgKey(this.organization.id);
const key = await this.encryptService.encapsulateKeyUnsigned(orgKey, publicKey);
const request = new OrganizationUserConfirmRequest();
request.key = key.encryptedString;
await this.organizationUserApiService.postOrganizationUserConfirm(
this.organization.id,
user.id,
request,
);
if (
await firstValueFrom(this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation))
) {
await firstValueFrom(
this.organizationUserService.confirmUser(this.organization, user, publicKey),
);
} else {
const orgKey = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.keyService.orgKeys$(userId)),
map((orgKeys) => orgKeys[this.organization.id] ?? null),
),
);
const key = await this.encryptService.encapsulateKeyUnsigned(orgKey, publicKey);
const request = new OrganizationUserConfirmRequest();
request.key = key.encryptedString;
await this.organizationUserApiService.postOrganizationUserConfirm(
this.organization.id,
user.id,
request,
);
}
}
async revoke(user: OrganizationUserView) {
@@ -694,7 +734,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
const dialogRef = BulkConfirmDialogComponent.open(this.dialogService, {
data: {
organizationId: this.organization.id,
organization: this.organization,
users: this.dataSource.getCheckedUsers(),
},
});
@@ -738,19 +778,32 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
}
async resetPassword(user: OrganizationUserView) {
const dialogRef = ResetPasswordComponent.open(this.dialogService, {
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 dialogRef = AccountRecoveryDialogComponent.open(this.dialogService, {
data: {
name: this.userNamePipe.transform(user),
email: user != null ? user.email : null,
organizationId: this.organization.id,
id: user != null ? user.id : null,
email: user.email,
organizationId: this.organization.id as OrganizationId,
organizationUserId: user.id,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result === ResetPasswordDialogResult.Ok) {
if (result === AccountRecoveryDialogResultType.Ok) {
await this.load();
}
return;
}
protected async removeUserConfirmationDialog(user: OrganizationUserView) {
@@ -891,4 +944,14 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
.getCheckedUsers()
.every((member) => member.managedByOrganization && validStatuses.includes(member.status));
}
async navigateToPaymentMethod() {
const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag(
FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
);
const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method";
await this.router.navigate(["organizations", `${this.organization?.id}`, "billing", route], {
state: { launchPaymentModalAutomatically: true },
});
}
}

View File

@@ -3,7 +3,9 @@ import { NgModule } from "@angular/core";
import { PasswordStrengthV2Component } from "@bitwarden/angular/tools/password-strength/password-strength-v2.component";
import { PasswordCalloutComponent } from "@bitwarden/auth/angular";
import { ScrollLayoutDirective } from "@bitwarden/components";
import { OrganizationFreeTrialWarningComponent } from "../../../billing/warnings/components";
import { LooseComponentsModule } from "../../../shared";
import { SharedOrganizationModule } from "../shared";
@@ -14,7 +16,6 @@ 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 { ResetPasswordComponent } from "./components/reset-password.component";
import { MembersRoutingModule } from "./members-routing.module";
import { MembersComponent } from "./members.component";
@@ -27,6 +28,8 @@ import { MembersComponent } from "./members.component";
PasswordCalloutComponent,
ScrollingModule,
PasswordStrengthV2Component,
ScrollLayoutDirective,
OrganizationFreeTrialWarningComponent,
],
declarations: [
BulkConfirmDialogComponent,
@@ -35,7 +38,6 @@ import { MembersComponent } from "./members.component";
BulkRestoreRevokeComponent,
BulkStatusComponent,
MembersComponent,
ResetPasswordComponent,
BulkDeleteDialogComponent,
],
})

View File

@@ -12,13 +12,14 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { OrganizationKeysResponse } from "@bitwarden/common/admin-console/models/response/organization-keys.response";
import { OrganizationApiService } from "@bitwarden/common/admin-console/services/organization/organization-api.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { EncryptionType } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { UserId } from "@bitwarden/common/types/guid";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { UserKey, OrgKey, MasterKey } from "@bitwarden/common/types/key";
import { KdfType, KeyService } from "@bitwarden/key-management";
@@ -36,6 +37,8 @@ describe("OrganizationUserResetPasswordService", () => {
let organizationUserApiService: MockProxy<OrganizationUserApiService>;
let organizationApiService: MockProxy<OrganizationApiService>;
let i18nService: MockProxy<I18nService>;
const mockUserId = Utils.newGuid() as UserId;
let accountService: FakeAccountService;
beforeAll(() => {
keyService = mock<KeyService>();
@@ -44,6 +47,7 @@ describe("OrganizationUserResetPasswordService", () => {
organizationUserApiService = mock<OrganizationUserApiService>();
organizationApiService = mock<OrganizationApiService>();
i18nService = mock<I18nService>();
accountService = mockAccountServiceWith(mockUserId);
sut = new OrganizationUserResetPasswordService(
keyService,
@@ -52,6 +56,7 @@ describe("OrganizationUserResetPasswordService", () => {
organizationUserApiService,
organizationApiService,
i18nService,
accountService,
);
});
@@ -142,7 +147,10 @@ describe("OrganizationUserResetPasswordService", () => {
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
const mockOrgKey = new SymmetricCryptoKey(mockRandomBytes) as OrgKey;
keyService.getOrgKey.mockResolvedValue(mockOrgKey);
keyService.orgKeys$.mockReturnValue(
of({ [mockOrgId]: mockOrgKey } as Record<OrganizationId, OrgKey>),
);
encryptService.decryptToBytes.mockResolvedValue(mockRandomBytes);
encryptService.rsaDecrypt.mockResolvedValue(mockRandomBytes);
@@ -170,7 +178,7 @@ describe("OrganizationUserResetPasswordService", () => {
});
it("should throw an error if the org key is null", async () => {
keyService.getOrgKey.mockResolvedValue(null);
keyService.orgKeys$.mockReturnValue(of(null));
await expect(
sut.resetMasterPassword(mockNewMP, mockEmail, mockOrgUserId, mockOrgId),
).rejects.toThrow();

View File

@@ -1,7 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Injectable } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { firstValueFrom, map, switchMap } from "rxjs";
import {
OrganizationUserApiService,
@@ -10,11 +10,16 @@ import {
} from "@bitwarden/admin-console/common";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import {
EncryptedString,
EncString,
} from "@bitwarden/common/key-management/crypto/models/enc-string";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncryptedString, EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { UserId } from "@bitwarden/common/types/guid";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
import {
Argon2KdfConfig,
@@ -44,6 +49,7 @@ export class OrganizationUserResetPasswordService
private organizationUserApiService: OrganizationUserApiService,
private organizationApiService: OrganizationApiServiceAbstraction,
private i18nService: I18nService,
private accountService: AccountService,
) {}
/**
@@ -96,7 +102,7 @@ export class OrganizationUserResetPasswordService
newMasterPassword: string,
email: string,
orgUserId: string,
orgId: string,
orgId: OrganizationId,
): Promise<void> {
const response = await this.organizationUserApiService.getOrganizationUserResetPasswordDetails(
orgId,
@@ -108,7 +114,14 @@ export class OrganizationUserResetPasswordService
}
// Decrypt Organization's encrypted Private Key with org key
const orgSymKey = await this.keyService.getOrgKey(orgId);
const orgSymKey = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.keyService.orgKeys$(userId)),
map((orgKeys) => orgKeys[orgId as OrganizationId] ?? null),
),
);
if (orgSymKey == null) {
throw new Error("No org key found");
}

View File

@@ -0,0 +1,175 @@
import { TestBed } from "@angular/core/testing";
import { of } from "rxjs";
import {
OrganizationUserConfirmRequest,
OrganizationUserBulkConfirmRequest,
OrganizationUserApiService,
OrganizationUserBulkResponse,
} from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { KeyService } from "@bitwarden/key-management";
import { OrganizationUserView } from "../../../core/views/organization-user.view";
import { OrganizationUserService } from "./organization-user.service";
describe("OrganizationUserService", () => {
let service: OrganizationUserService;
let keyService: jest.Mocked<KeyService>;
let encryptService: jest.Mocked<EncryptService>;
let organizationUserApiService: jest.Mocked<OrganizationUserApiService>;
let accountService: jest.Mocked<AccountService>;
let i18nService: jest.Mocked<I18nService>;
const mockOrganization = new Organization();
mockOrganization.id = "org-123" as OrganizationId;
const mockOrganizationUser = new OrganizationUserView();
mockOrganizationUser.id = "user-123";
const mockPublicKey = new Uint8Array(64) as CsprngArray;
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
const mockOrgKey = new SymmetricCryptoKey(mockRandomBytes) as OrgKey;
const mockEncryptedKey = { encryptedString: "encrypted-key" } as EncString;
const mockEncryptedCollectionName = { encryptedString: "encrypted-collection-name" } as EncString;
const mockDefaultCollectionName = "My Items";
const setupCommonMocks = () => {
keyService.orgKeys$.mockReturnValue(
of({ [mockOrganization.id]: mockOrgKey } as Record<OrganizationId, OrgKey>),
);
encryptService.encryptString.mockResolvedValue(mockEncryptedCollectionName);
i18nService.t.mockReturnValue(mockDefaultCollectionName);
};
beforeEach(() => {
keyService = {
orgKeys$: jest.fn(),
} as any;
encryptService = {
encryptString: jest.fn(),
encapsulateKeyUnsigned: jest.fn(),
} as any;
organizationUserApiService = {
postOrganizationUserConfirm: jest.fn(),
postOrganizationUserBulkConfirm: jest.fn(),
} as any;
accountService = {
activeAccount$: of({ id: "user-123" }),
} as any;
i18nService = {
t: jest.fn(),
} as any;
TestBed.configureTestingModule({
providers: [
OrganizationUserService,
{ provide: KeyService, useValue: keyService },
{ provide: EncryptService, useValue: encryptService },
{ provide: OrganizationUserApiService, useValue: organizationUserApiService },
{ provide: AccountService, useValue: accountService },
{ provide: I18nService, useValue: i18nService },
],
});
service = TestBed.inject(OrganizationUserService);
});
describe("confirmUser", () => {
beforeEach(() => {
setupCommonMocks();
encryptService.encapsulateKeyUnsigned.mockResolvedValue(mockEncryptedKey);
organizationUserApiService.postOrganizationUserConfirm.mockReturnValue(Promise.resolve());
});
it("should confirm a user successfully", (done) => {
service.confirmUser(mockOrganization, mockOrganizationUser, mockPublicKey).subscribe({
next: () => {
expect(i18nService.t).toHaveBeenCalledWith("myItems");
expect(encryptService.encryptString).toHaveBeenCalledWith(
mockDefaultCollectionName,
mockOrgKey,
);
expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith(
mockOrgKey,
mockPublicKey,
);
expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith(
mockOrganization.id,
mockOrganizationUser.id,
{
key: mockEncryptedKey.encryptedString,
defaultUserCollectionName: mockEncryptedCollectionName.encryptedString,
} as OrganizationUserConfirmRequest,
);
done();
},
error: done,
});
});
});
describe("bulkConfirmUsers", () => {
const mockUserIdsWithKeys = [
{ id: "user-1", key: "key-1" },
{ id: "user-2", key: "key-2" },
];
const mockBulkResponse = {
data: [
{ id: "user-1", error: null } as OrganizationUserBulkResponse,
{ id: "user-2", error: null } as OrganizationUserBulkResponse,
],
} as ListResponse<OrganizationUserBulkResponse>;
beforeEach(() => {
setupCommonMocks();
organizationUserApiService.postOrganizationUserBulkConfirm.mockReturnValue(
Promise.resolve(mockBulkResponse),
);
});
it("should bulk confirm users successfully", (done) => {
service.bulkConfirmUsers(mockOrganization, mockUserIdsWithKeys).subscribe({
next: (response) => {
expect(i18nService.t).toHaveBeenCalledWith("myItems");
expect(encryptService.encryptString).toHaveBeenCalledWith(
mockDefaultCollectionName,
mockOrgKey,
);
expect(organizationUserApiService.postOrganizationUserBulkConfirm).toHaveBeenCalledWith(
mockOrganization.id,
new OrganizationUserBulkConfirmRequest(
mockUserIdsWithKeys,
mockEncryptedCollectionName.encryptedString,
),
);
expect(response).toEqual(mockBulkResponse);
done();
},
error: done,
});
});
});
});

View File

@@ -0,0 +1,95 @@
import { Injectable } from "@angular/core";
import { combineLatest, filter, map, Observable, switchMap } from "rxjs";
import {
OrganizationUserConfirmRequest,
OrganizationUserBulkConfirmRequest,
OrganizationUserApiService,
OrganizationUserBulkResponse,
} from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { KeyService } from "@bitwarden/key-management";
import { OrganizationUserView } from "../../../core/views/organization-user.view";
@Injectable({
providedIn: "root",
})
export class OrganizationUserService {
constructor(
protected keyService: KeyService,
private encryptService: EncryptService,
private organizationUserApiService: OrganizationUserApiService,
private accountService: AccountService,
private i18nService: I18nService,
) {}
private orgKey$(organization: Organization) {
return this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.keyService.orgKeys$(userId)),
filter((orgKeys) => !!orgKeys),
map((organizationKeysById) => organizationKeysById[organization.id as OrganizationId]),
);
}
confirmUser(
organization: Organization,
user: OrganizationUserView,
publicKey: Uint8Array,
): Observable<void> {
const encryptedCollectionName$ = this.getEncryptedDefaultCollectionName$(organization);
const encryptedKey$ = this.orgKey$(organization).pipe(
switchMap((orgKey) => this.encryptService.encapsulateKeyUnsigned(orgKey, publicKey)),
);
return combineLatest([encryptedKey$, encryptedCollectionName$]).pipe(
switchMap(([key, collectionName]) => {
const request: OrganizationUserConfirmRequest = {
key: key.encryptedString,
defaultUserCollectionName: collectionName.encryptedString,
};
return this.organizationUserApiService.postOrganizationUserConfirm(
organization.id,
user.id,
request,
);
}),
);
}
bulkConfirmUsers(
organization: Organization,
userIdsWithKeys: { id: string; key: string }[],
): Observable<ListResponse<OrganizationUserBulkResponse>> {
return this.getEncryptedDefaultCollectionName$(organization).pipe(
switchMap((collectionName) => {
const request = new OrganizationUserBulkConfirmRequest(
userIdsWithKeys,
collectionName.encryptedString,
);
return this.organizationUserApiService.postOrganizationUserBulkConfirm(
organization.id,
request,
);
}),
);
}
private getEncryptedDefaultCollectionName$(organization: Organization) {
return this.orgKey$(organization).pipe(
switchMap((orgKey) =>
this.encryptService.encryptString(this.i18nService.t("myItems"), orgKey),
),
);
}
}

View File

@@ -17,7 +17,6 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { deepLinkGuard } from "../../auth/guards/deep-link/deep-link.guard";
import { VaultModule } from "./collections/vault.module";
import { isEnterpriseOrgGuard } from "./guards/is-enterprise-org.guard";
import { organizationPermissionsGuard } from "./guards/org-permissions.guard";
import { organizationRedirectGuard } from "./guards/org-redirect.guard";
import { AdminConsoleIntegrationsComponent } from "./integrations/integrations.component";
@@ -42,10 +41,7 @@ const routes: Routes = [
},
{
path: "integrations",
canActivate: [
isEnterpriseOrgGuard(false),
organizationPermissionsGuard(canAccessIntegrations),
],
canActivate: [organizationPermissionsGuard(canAccessIntegrations)],
component: AdminConsoleIntegrationsComponent,
data: {
titleId: "integrations",

View File

@@ -1,6 +1,8 @@
import { ScrollingModule } from "@angular/cdk/scrolling";
import { NgModule } from "@angular/core";
import { ScrollLayoutDirective } from "@bitwarden/components";
import { LooseComponentsModule } from "../../shared";
import { CoreOrganizationModule } from "./core";
@@ -18,6 +20,7 @@ import { AccessSelectorModule } from "./shared/components/access-selector";
OrganizationsRoutingModule,
LooseComponentsModule,
ScrollingModule,
ScrollLayoutDirective,
],
declarations: [GroupsComponent, GroupAddEditComponent],
})

View File

@@ -1,12 +1,12 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, Input, OnInit } from "@angular/core";
import { UntypedFormControl, UntypedFormGroup } from "@angular/forms";
import { Observable, of } 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 { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
export abstract class BasePolicy {
abstract name: string;
@@ -14,38 +14,56 @@ export abstract class BasePolicy {
abstract type: PolicyType;
abstract component: any;
display(organization: Organization) {
return true;
/**
* If true, the 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 = true;
display(organization: Organization, configService: ConfigService): Observable<boolean> {
return of(true);
}
}
@Directive()
export abstract class BasePolicyComponent implements OnInit {
@Input() policyResponse: PolicyResponse;
@Input() policy: BasePolicy;
@Input() policyResponse: PolicyResponse | undefined;
@Input() policy: BasePolicy | undefined;
enabled = new UntypedFormControl(false);
data: UntypedFormGroup = null;
data: UntypedFormGroup | undefined;
ngOnInit(): void {
this.enabled.setValue(this.policyResponse.enabled);
this.enabled.setValue(this.policyResponse?.enabled);
if (this.policyResponse.data != null) {
if (this.policyResponse?.data != null) {
this.loadData();
}
}
buildRequest() {
const request = new PolicyRequest();
request.enabled = this.enabled.value;
request.type = this.policy.type;
request.data = this.buildRequestData();
if (!this.policy) {
throw new Error("Policy was not found");
}
const request: PolicyRequest = {
type: this.policy.type,
enabled: this.enabled.value,
data: this.buildRequestData(),
};
return Promise.resolve(request);
}
/**
* Enable optional validation before sumitting a respose for policy submission
* */
confirm(): Promise<boolean> | boolean {
return true;
}
protected loadData() {
this.data.patchValue(this.policyResponse.data ?? {});
this.data?.patchValue(this.policyResponse?.data ?? {});
}
protected buildRequestData() {

View File

@@ -3,7 +3,8 @@ export { BasePolicy, BasePolicyComponent } from "./base-policy.component";
export { DisableSendPolicy } from "./disable-send.component";
export { MasterPasswordPolicy } from "./master-password.component";
export { PasswordGeneratorPolicy } from "./password-generator.component";
export { PersonalOwnershipPolicy } from "./personal-ownership.component";
export { vNextOrganizationDataOwnershipPolicy } from "./vnext-organization-data-ownership.component";
export { OrganizationDataOwnershipPolicy } from "./organization-data-ownership.component";
export { RequireSsoPolicy } from "./require-sso.component";
export { ResetPasswordPolicy } from "./reset-password.component";
export { SendOptionsPolicy } from "./send-options.component";
@@ -11,3 +12,4 @@ export { SingleOrgPolicy } from "./single-org.component";
export { TwoFactorAuthenticationPolicy } from "./two-factor-authentication.component";
export { PoliciesComponent } from "./policies.component";
export { RemoveUnlockWithPinPolicy } from "./remove-unlock-with-pin.component";
export { RestrictedItemTypesPolicy } from "./restricted-item-types.component";

View File

@@ -0,0 +1,29 @@
import { Component } from "@angular/core";
import { map, Observable } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
export class OrganizationDataOwnershipPolicy extends BasePolicy {
name = "organizationDataOwnership";
description = "personalOwnershipPolicyDesc";
type = PolicyType.OrganizationDataOwnership;
component = OrganizationDataOwnershipPolicyComponent;
display(organization: Organization, configService: ConfigService): Observable<boolean> {
return configService
.getFeatureFlag$(FeatureFlag.CreateDefaultLocation)
.pipe(map((enabled) => !enabled));
}
}
@Component({
selector: "policy-organization-data-ownership",
templateUrl: "organization-data-ownership.component.html",
standalone: false,
})
export class OrganizationDataOwnershipPolicyComponent extends BasePolicyComponent {}

View File

@@ -1,19 +0,0 @@
import { Component } from "@angular/core";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
export class PersonalOwnershipPolicy extends BasePolicy {
name = "personalOwnership";
description = "personalOwnershipPolicyDesc";
type = PolicyType.PersonalOwnership;
component = PersonalOwnershipPolicyComponent;
}
@Component({
selector: "policy-personal-ownership",
templateUrl: "personal-ownership.component.html",
standalone: false,
})
export class PersonalOwnershipPolicyComponent extends BasePolicyComponent {}

View File

@@ -1,38 +1,45 @@
<app-header>
@let organization = organization$ | async;
<button
bitBadge
class="!tw-align-middle"
(click)="changePlan(organization)"
*ngIf="isBreadcrumbingEnabled$ | async"
slot="title-suffix"
type="button"
variant="primary"
>
{{ "upgrade" | i18n }}
</button>
@if (isBreadcrumbingEnabled$ | async) {
<button
bitBadge
class="!tw-align-middle"
(click)="changePlan(organization)"
slot="title-suffix"
type="button"
variant="primary"
>
{{ "upgrade" | i18n }}
</button>
}
</app-header>
<bit-container>
<ng-container *ngIf="loading">
@if (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>
</ng-container>
<bit-table *ngIf="!loading">
<ng-template body>
<tr bitRow *ngFor="let p of policies">
<td bitCell *ngIf="p.display(organization)" ngPreserveWhitespaces>
<button type="button" bitLink (click)="edit(p)">{{ p.name | i18n }}</button>
<span bitBadge variant="success" *ngIf="policiesEnabledMap.get(p.type)">{{
"on" | i18n
}}</span>
<small class="tw-text-muted tw-block">{{ p.description | i18n }}</small>
</td>
</tr>
</ng-template>
</bit-table>
}
@if (!loading) {
<bit-table>
<ng-template body>
@for (p of policies; track p.name) {
@if (p.display(organization, configService) | async) {
<tr bitRow>
<td bitCell ngPreserveWhitespaces>
<button type="button" bitLink (click)="edit(p)">{{ 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>
}
}
</ng-template>
</bit-table>
}
</bit-container>

View File

@@ -15,6 +15,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { DialogService } from "@bitwarden/components";
import {
ChangePlanDialogResultType,
@@ -51,6 +52,7 @@ export class PoliciesComponent implements OnInit {
private policyListService: PolicyListService,
private organizationBillingService: OrganizationBillingServiceAbstraction,
private dialogService: DialogService,
protected configService: ConfigService,
) {}
async ngOnInit() {
@@ -68,25 +70,27 @@ export class PoliciesComponent implements OnInit {
await this.load();
// Handle policies component launch from Event message
/* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */
this.route.queryParams.pipe(first()).subscribe(async (qParams) => {
if (qParams.policyId != null) {
const policyIdFromEvents: string = qParams.policyId;
for (const orgPolicy of this.orgPolicies) {
if (orgPolicy.id === policyIdFromEvents) {
for (let i = 0; i < this.policies.length; i++) {
if (this.policies[i].type === orgPolicy.type) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.edit(this.policies[i]);
break;
this.route.queryParams
.pipe(first())
/* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */
.subscribe(async (qParams) => {
if (qParams.policyId != null) {
const policyIdFromEvents: string = qParams.policyId;
for (const orgPolicy of this.orgPolicies) {
if (orgPolicy.id === policyIdFromEvents) {
for (let i = 0; i < this.policies.length; i++) {
if (this.policies[i].type === orgPolicy.type) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.edit(this.policies[i]);
break;
}
}
break;
}
break;
}
}
}
});
});
});
}

View File

@@ -4,13 +4,14 @@ import { LooseComponentsModule, SharedModule } from "../../../shared";
import { DisableSendPolicyComponent } from "./disable-send.component";
import { MasterPasswordPolicyComponent } from "./master-password.component";
import { OrganizationDataOwnershipPolicyComponent } from "./organization-data-ownership.component";
import { PasswordGeneratorPolicyComponent } from "./password-generator.component";
import { PersonalOwnershipPolicyComponent } from "./personal-ownership.component";
import { PoliciesComponent } from "./policies.component";
import { PolicyEditComponent } from "./policy-edit.component";
import { RemoveUnlockWithPinPolicyComponent } from "./remove-unlock-with-pin.component";
import { RequireSsoPolicyComponent } from "./require-sso.component";
import { ResetPasswordPolicyComponent } from "./reset-password.component";
import { RestrictedItemTypesPolicyComponent } from "./restricted-item-types.component";
import { SendOptionsPolicyComponent } from "./send-options.component";
import { SingleOrgPolicyComponent } from "./single-org.component";
import { TwoFactorAuthenticationPolicyComponent } from "./two-factor-authentication.component";
@@ -21,7 +22,7 @@ import { TwoFactorAuthenticationPolicyComponent } from "./two-factor-authenticat
DisableSendPolicyComponent,
MasterPasswordPolicyComponent,
PasswordGeneratorPolicyComponent,
PersonalOwnershipPolicyComponent,
OrganizationDataOwnershipPolicyComponent,
RequireSsoPolicyComponent,
ResetPasswordPolicyComponent,
SendOptionsPolicyComponent,
@@ -30,12 +31,13 @@ import { TwoFactorAuthenticationPolicyComponent } from "./two-factor-authenticat
PoliciesComponent,
PolicyEditComponent,
RemoveUnlockWithPinPolicyComponent,
RestrictedItemTypesPolicyComponent,
],
exports: [
DisableSendPolicyComponent,
MasterPasswordPolicyComponent,
PasswordGeneratorPolicyComponent,
PersonalOwnershipPolicyComponent,
OrganizationDataOwnershipPolicyComponent,
RequireSsoPolicyComponent,
ResetPasswordPolicyComponent,
SendOptionsPolicyComponent,

View File

@@ -22,7 +22,9 @@
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
<div [hidden]="loading">
<p bitTypography="body1">{{ policy.description | i18n }}</p>
@if (policy.showDescription) {
<p bitTypography="body1">{{ policy.description | i18n }}</p>
}
<ng-template #policyForm></ng-template>
</div>
</ng-container>

View File

@@ -128,13 +128,20 @@ export class PolicyEditComponent implements AfterViewInit {
}
submit = async () => {
if ((await this.policyComponent.confirm()) == false) {
this.dialogRef.close();
return;
}
let request: PolicyRequest;
try {
request = await this.policyComponent.buildRequest();
} catch (e) {
this.toastService.showToast({ variant: "error", title: null, message: e.message });
return;
}
await this.policyApiService.putPolicy(this.data.organizationId, this.data.policy.type, request);
this.toastService.showToast({
variant: "success",

View File

@@ -1,7 +1,9 @@
import { Component } from "@angular/core";
import { of } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
@@ -11,8 +13,8 @@ export class RequireSsoPolicy extends BasePolicy {
type = PolicyType.RequireSso;
component = RequireSsoPolicyComponent;
display(organization: Organization) {
return organization.useSso;
display(organization: Organization, configService: ConfigService) {
return of(organization.useSso);
}
}

View File

@@ -1,6 +1,6 @@
import { Component, OnInit } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { firstValueFrom } from "rxjs";
import { firstValueFrom, of } from "rxjs";
import {
getOrganizationById,
@@ -10,6 +10,7 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
@@ -19,8 +20,8 @@ export class ResetPasswordPolicy extends BasePolicy {
type = PolicyType.ResetPassword;
component = ResetPasswordPolicyComponent;
display(organization: Organization) {
return organization.useResetPassword;
display(organization: Organization, configService: ConfigService) {
return of(organization.useResetPassword);
}
}
@@ -52,6 +53,10 @@ export class ResetPasswordPolicyComponent extends BasePolicyComponent implements
throw new Error("No user found.");
}
if (!this.policyResponse) {
throw new Error("Policies not found");
}
const organization = await firstValueFrom(
this.organizationService
.organizations$(userId)

View File

@@ -0,0 +1,6 @@
<bit-form-control>
<input type="checkbox" bitCheckbox [formControl]="enabled" id="enabled" />
<bit-label>{{ "turnOn" | i18n }}</bit-label>
</bit-form-control>
<!-- To allow for multiple item types we can add a data formGroup, iterate over the
cipher types as checkboxes/multi-select and use that as a means to track which types are restricted -->

View File

@@ -0,0 +1,31 @@
import { Component } from "@angular/core";
import { Observable } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
export class RestrictedItemTypesPolicy extends BasePolicy {
name = "restrictedItemTypePolicy";
description = "restrictedItemTypePolicyDesc";
type = PolicyType.RestrictedItemTypes;
component = RestrictedItemTypesPolicyComponent;
display(organization: Organization, configService: ConfigService): Observable<boolean> {
return configService.getFeatureFlag$(FeatureFlag.RemoveCardItemTypePolicy);
}
}
@Component({
selector: "policy-restricted-item-types",
templateUrl: "restricted-item-types.component.html",
standalone: false,
})
export class RestrictedItemTypesPolicyComponent extends BasePolicyComponent {
constructor() {
super();
}
}

View File

@@ -20,6 +20,9 @@ export class SingleOrgPolicyComponent extends BasePolicyComponent implements OnI
async ngOnInit() {
super.ngOnInit();
if (!this.policyResponse) {
throw new Error("Policies not found");
}
if (!this.policyResponse.canToggleState) {
this.enabled.disable();
}

View File

@@ -0,0 +1,57 @@
<p>
{{ "organizationDataOwnershipContent" | i18n }}
<a
bitLink
href="https://bitwarden.com/resources/credential-lifecycle-management/"
target="_blank"
>
{{ "organizationDataOwnershipContentAnchor" | i18n }}.
</a>
</p>
<bit-form-control>
<input type="checkbox" bitCheckbox [formControl]="enabled" id="enabled" />
<bit-label>{{ "turnOn" | i18n }}</bit-label>
</bit-form-control>
<ng-template #dialog>
<bit-simple-dialog background="alt">
<span bitDialogTitle>{{ "organizationDataOwnershipWarningTitle" | i18n }}</span>
<ng-container bitDialogContent>
<div class="tw-text-left tw-overflow-hidden">
{{ "organizationDataOwnershipWarningContentTop" | i18n }}
<div class="tw-flex tw-flex-col tw-p-2">
<ul class="tw-list-disc tw-pl-5 tw-space-y-2 tw-break-words tw-mb-0">
<li>
{{ "organizationDataOwnershipWarning1" | i18n }}
</li>
<li>
{{ "organizationDataOwnershipWarning2" | i18n }}
</li>
<li>
{{ "organizationDataOwnershipWarning3" | i18n }}
</li>
</ul>
</div>
{{ "organizationDataOwnershipWarningContentBottom" | i18n }}
<a
bitLink
href="https://bitwarden.com/resources/credential-lifecycle-management/"
target="_blank"
>
{{ "organizationDataOwnershipContentAnchor" | i18n }}.
</a>
</div>
</ng-container>
<ng-container bitDialogFooter>
<span class="tw-flex tw-gap-2">
<button bitButton buttonType="primary" [bitDialogClose]="true" type="submit">
{{ "continue" | i18n }}
</button>
<button bitButton buttonType="secondary" [bitDialogClose]="false" type="button">
{{ "cancel" | i18n }}
</button>
</span>
</ng-container>
</bit-simple-dialog>
</ng-template>

View File

@@ -0,0 +1,50 @@
import { Component, OnInit, TemplateRef, ViewChild } from "@angular/core";
import { lastValueFrom, Observable } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { DialogService } from "@bitwarden/components";
import { SharedModule } from "../../../shared";
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
export class vNextOrganizationDataOwnershipPolicy extends BasePolicy {
name = "organizationDataOwnership";
description = "organizationDataOwnershipDesc";
type = PolicyType.OrganizationDataOwnership;
component = vNextOrganizationDataOwnershipPolicyComponent;
showDescription = false;
override display(organization: Organization, configService: ConfigService): Observable<boolean> {
return configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation);
}
}
@Component({
selector: "vnext-policy-organization-data-ownership",
templateUrl: "vnext-organization-data-ownership.component.html",
standalone: true,
imports: [SharedModule],
})
export class vNextOrganizationDataOwnershipPolicyComponent
extends BasePolicyComponent
implements OnInit
{
constructor(private dialogService: DialogService) {
super();
}
@ViewChild("dialog", { static: true }) warningContent!: TemplateRef<unknown>;
override async confirm(): Promise<boolean> {
if (this.policyResponse?.enabled && !this.enabled.value) {
const dialogRef = this.dialogService.open(this.warningContent);
const result = await lastValueFrom(dialogRef.closed);
return Boolean(result);
}
return true;
}
}

View File

@@ -70,7 +70,7 @@
<bit-label>{{ "limitCollectionDeletionDesc" | i18n }}</bit-label>
<input type="checkbox" bitCheckbox formControlName="limitCollectionDeletion" />
</bit-form-control>
<bit-form-control *ngIf="limitItemDeletionFeatureFlagIsEnabled">
<bit-form-control>
<bit-label>{{ "limitItemDeletionDescription" | i18n }}</bit-label>
<input type="checkbox" bitCheckbox formControlName="limitItemDeletion" />
</bit-form-control>

View File

@@ -8,6 +8,7 @@ import {
firstValueFrom,
from,
lastValueFrom,
map,
of,
Subject,
switchMap,
@@ -25,11 +26,10 @@ import { OrganizationUpdateRequest } from "@bitwarden/common/admin-console/model
import { OrganizationResponse } from "@bitwarden/common/admin-console/models/response/organization.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
@@ -51,8 +51,6 @@ export class AccountComponent implements OnInit, OnDestroy {
org: OrganizationResponse;
taxFormPromise: Promise<unknown>;
limitItemDeletionFeatureFlagIsEnabled: boolean;
// FormGroup validators taken from server Organization domain object
protected formGroup = this.formBuilder.group({
orgName: this.formBuilder.control(
@@ -95,17 +93,11 @@ export class AccountComponent implements OnInit, OnDestroy {
private dialogService: DialogService,
private formBuilder: FormBuilder,
private toastService: ToastService,
private configService: ConfigService,
) {}
async ngOnInit() {
this.selfHosted = this.platformUtilsService.isSelfHost();
this.configService
.getFeatureFlag$(FeatureFlag.LimitItemDeletion)
.pipe(takeUntil(this.destroy$))
.subscribe((isAble) => (this.limitItemDeletionFeatureFlagIsEnabled = isAble));
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this.route.params
.pipe(
@@ -189,7 +181,13 @@ export class AccountComponent implements OnInit, OnDestroy {
// Backfill pub/priv key if necessary
if (!this.org.hasPublicAndPrivateKeys) {
const orgShareKey = await this.keyService.getOrgKey(this.organizationId);
const orgShareKey = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.keyService.orgKeys$(userId)),
map((orgKeys) => orgKeys[this.organizationId as OrganizationId] ?? null),
),
);
const orgKeys = await this.keyService.makeKeyPair(orgShareKey);
request.keys = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString);
}

View File

@@ -18,7 +18,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherType, toCipherTypeName } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
DIALOG_DATA,
@@ -80,7 +80,6 @@ export enum DeleteOrganizationDialogResult {
@Component({
selector: "app-delete-organization",
standalone: true,
imports: [SharedModule, UserVerificationModule],
templateUrl: "delete-organization-dialog.component.html",
})
@@ -163,7 +162,7 @@ export class DeleteOrganizationDialogComponent implements OnInit, OnDestroy {
organizationContentSummary.itemCountByType.push(
new OrganizationContentSummaryItem(
count,
this.getOrganizationItemLocalizationKeysByType(CipherType[cipherType]),
this.getOrganizationItemLocalizationKeysByType(toCipherTypeName(cipherType)),
),
);
}

View File

@@ -1,22 +1,19 @@
<!-- Please remove this disable statement when editing this file! -->
<!-- eslint-disable tailwindcss/no-custom-classname -->
<div class="tw-flex" *ngIf="!hideMultiSelect">
<bit-form-field *ngIf="permissionMode == 'edit'" class="tw-mr-3 tw-shrink-0">
<bit-form-field *ngIf="permissionMode == 'edit'" class="tw-mr-3 tw-shrink-0 tw-basis-2/5">
<bit-label>{{ "permission" | i18n }}</bit-label>
<select
<bit-select
bitInput
[disabled]="disabled"
[(ngModel)]="initialPermission"
[ngModelOptions]="{ standalone: true }"
(blur)="handleBlur()"
(closed)="handleBlur()"
>
<option *ngFor="let p of permissionList" [value]="p.perm">
{{ p.labelId | i18n }}
</option>
</select>
<bit-option *ngFor="let p of permissionList" [value]="p.perm" [label]="p.labelId | i18n">
</bit-option>
</bit-select>
</bit-form-field>
<bit-form-field class="tw-grow" *ngIf="!disabled">
<bit-form-field class="tw-grow tw-p-3" *ngIf="!disabled">
<bit-label>{{ selectorLabelText }}</bit-label>
<bit-multi-select
class="tw-w-full"
@@ -51,7 +48,7 @@
[formGroupName]="i"
[ngClass]="{ 'tw-text-muted': item.readonly }"
>
<td bitCell [ngSwitch]="item.type">
<td bitCell [ngSwitch]="item.type" class="tw-w-5/12">
<div class="tw-flex tw-items-center" *ngSwitchCase="itemType.Member">
<bit-avatar size="small" class="tw-mr-3" text="{{ item.labelName }}"></bit-avatar>
<div class="tw-flex tw-flex-col">
@@ -79,28 +76,22 @@
<td bitCell *ngIf="permissionMode != 'hidden'">
<ng-container *ngIf="canEditItemPermission(item); else readOnlyPerm">
<label class="tw-sr-only" [for]="'permission' + i"
>{{ item.labelName }} {{ "permission" | i18n }}</label
>
<div class="tw-relative tw-inline-block">
<select
<bit-form-field>
<bit-label>{{ item.labelName }} {{ "permission" | i18n }}</bit-label>
<bit-select
bitInput
class="tw-apperance-none -tw-ml-3 tw-max-w-40 tw-appearance-none tw-overflow-ellipsis !tw-rounded tw-border-transparent !tw-bg-transparent tw-pr-6 tw-font-bold hover:tw-border-primary-700"
formControlName="permission"
[id]="'permission' + i"
(blur)="handleBlur()"
(closed)="handleBlur()"
>
<option *ngFor="let p of permissionList" [value]="p.perm">
{{ p.labelId | i18n }}
</option>
</select>
<label
[for]="'permission' + i"
class="tw-absolute tw-inset-y-0 tw-right-4 tw-mb-0 tw-flex tw-items-center"
>
<i class="bwi bwi-sm bwi-angle-down tw-leading-[0]"></i>
</label>
</div>
<bit-option
*ngFor="let p of permissionList"
[value]="p.perm"
[label]="p.labelId | i18n"
>
</bit-option>
</bit-select>
</bit-form-field>
</ng-container>
<ng-template #readOnlyPerm>

View File

@@ -14,6 +14,7 @@ import {
ButtonModule,
FormFieldModule,
IconButtonModule,
SelectModule,
TableModule,
TabsModule,
} from "@bitwarden/components";
@@ -71,6 +72,7 @@ describe("AccessSelectorComponent", () => {
PreloadedEnglishI18nModule,
JslibModule,
IconButtonModule,
SelectModule,
],
declarations: [TestableAccessSelectorComponent, UserTypePipe],
providers: [],

View File

@@ -10,6 +10,7 @@ import {
DialogModule,
FormFieldModule,
IconButtonModule,
SelectModule,
TableModule,
TabsModule,
} from "@bitwarden/components";
@@ -47,6 +48,7 @@ export default {
TableModule,
JslibModule,
IconButtonModule,
SelectModule,
],
providers: [],
}),

View File

@@ -26,7 +26,6 @@ import {
CollectionResponse,
CollectionView,
CollectionService,
Collection,
} from "@bitwarden/admin-console/common";
import {
getOrganizationById,
@@ -38,7 +37,9 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { getById } from "@bitwarden/common/platform/misc";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import {
DIALOG_DATA,
DialogConfig,
@@ -87,8 +88,8 @@ enum ButtonType {
}
export interface CollectionDialogParams {
collectionId?: string;
organizationId: string;
collectionId?: CollectionId;
organizationId: OrganizationId;
initialTab?: CollectionDialogTabType;
parentCollectionId?: string;
showOrgSelector?: boolean;
@@ -117,7 +118,6 @@ export enum CollectionDialogAction {
@Component({
templateUrl: "collection-dialog.component.html",
standalone: true,
imports: [SharedModule, AccessSelectorModule, SelectModule],
})
export class CollectionDialogComponent implements OnInit, OnDestroy {
@@ -137,12 +137,11 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
externalId: { value: "", disabled: true },
parent: undefined as string | undefined,
access: [[] as AccessItemValue[]],
selectedOrg: "",
selectedOrg: "" as OrganizationId,
});
protected PermissionMode = PermissionMode;
protected showDeleteButton = false;
protected showAddAccessWarning = false;
protected collections: Collection[];
protected buttonDisplayName: ButtonType = ButtonType.Save;
private orgExceedingCollectionLimit!: Organization;
@@ -167,14 +166,12 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
async ngOnInit() {
// Opened from the individual vault
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id)));
if (this.params.showOrgSelector) {
this.showOrgSelector = true;
this.formGroup.controls.selectedOrg.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe((id) => this.loadOrg(id));
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
this.organizations$ = this.organizationService.organizations$(userId).pipe(
first(),
map((orgs) =>
@@ -196,9 +193,14 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
);
if (isBreadcrumbEventLogsEnabled) {
this.collections = await this.collectionService.getAll();
this.organizationSelected.setAsyncValidators(
freeOrgCollectionLimitValidator(this.organizations$, this.collections, this.i18nService),
freeOrgCollectionLimitValidator(
this.organizations$,
this.collectionService
.encryptedCollections$(userId)
.pipe(map((collections) => collections ?? [])),
this.i18nService,
),
);
this.formGroup.updateValueAndValidity();
}
@@ -213,7 +215,7 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
}
}),
filter(() => this.organizationSelected.errors?.cannotCreateCollections),
switchMap((value) => this.findOrganizationById(value)),
switchMap((organizationId) => this.organizations$.pipe(getById(organizationId))),
takeUntil(this.destroy$),
)
.subscribe((org) => {
@@ -223,11 +225,6 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
});
}
async findOrganizationById(orgId: string): Promise<Organization | undefined> {
const organizations = await firstValueFrom(this.organizations$);
return organizations.find((org) => org.id === orgId);
}
async loadOrg(orgId: string) {
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
const organization$ = this.organizationService
@@ -243,9 +240,15 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
return this.groupService.getAll(orgId);
}),
);
const collections = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.collectionAdminService.collectionAdminViews$(orgId, userId)),
);
combineLatest({
organization: organization$,
collections: this.collectionAdminService.getAll(orgId),
collections,
groups: groups$,
users: this.organizationUserApiService.getAllMiniUserDetails(orgId),
})
@@ -414,7 +417,8 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
collectionView.name = this.formGroup.controls.name.value;
}
const savedCollection = await this.collectionAdminService.save(collectionView);
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const savedCollection = await this.collectionAdminService.save(collectionView, userId);
this.toastService.showToast({
variant: "success",
@@ -592,5 +596,5 @@ export function openCollectionDialog(
dialogService: DialogService,
config: DialogConfig<CollectionDialogParams, DialogRef<CollectionDialogResult>>,
) {
return dialogService.open(CollectionDialogComponent, config);
return dialogService.open<CollectionDialogResult>(CollectionDialogComponent, config);
}

View File

@@ -17,16 +17,40 @@
</div>
</div>
<div class="tw-p-5">
<h3 class="tw-text-main tw-text-lg tw-font-semibold">{{ name }}</h3>
<a
class="tw-block tw-mb-0 tw-font-bold hover:tw-no-underline focus:tw-outline-none after:tw-content-[''] after:tw-block after:tw-absolute after:tw-size-full after:tw-left-0 after:tw-top-0"
[href]="linkURL"
rel="noopener noreferrer"
target="_blank"
>
</a>
<span *ngIf="showNewBadge()" bitBadge class="tw-mt-3" variant="secondary">
{{ "new" | i18n }}
</span>
<h3 class="tw-text-main tw-text-lg tw-font-semibold">
{{ name }}
@if (showConnectedBadge()) {
<span class="tw-ml-3">
@if (isConnected) {
<span bitBadge variant="success">{{ "on" | i18n }}</span>
}
@if (!isConnected) {
<span bitBadge>{{ "off" | i18n }}</span>
}
</span>
}
</h3>
<p class="tw-mb-0">{{ description }}</p>
@if (canSetupConnection) {
<button type="button" class="tw-mt-3" bitButton (click)="setupConnection()">
<span>{{ "connectIntegrationButtonDesc" | i18n: name }}</span>
</button>
}
@if (linkURL) {
<a
class="tw-block tw-mb-0 tw-font-bold hover:tw-no-underline focus:tw-outline-none after:tw-content-[''] after:tw-block after:tw-absolute after:tw-size-full after:tw-left-0 after:tw-top-0"
[href]="linkURL"
rel="noopener noreferrer"
target="_blank"
>
</a>
}
@if (showNewBadge()) {
<span bitBadge class="tw-mt-3" variant="secondary">
{{ "new" | i18n }}
</span>
}
</div>
</div>

View File

@@ -1,12 +1,15 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ActivatedRoute } from "@angular/router";
import { mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
// eslint-disable-next-line no-restricted-imports
import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/integrations/services";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
// FIXME: remove `src` and fix import
import { ToastService } from "@bitwarden/components";
// eslint-disable-next-line no-restricted-imports
import { SharedModule } from "@bitwarden/components/src/shared";
import { I18nPipe } from "@bitwarden/ui-common";
@@ -16,6 +19,9 @@ import { IntegrationCardComponent } from "./integration-card.component";
describe("IntegrationCardComponent", () => {
let component: IntegrationCardComponent;
let fixture: ComponentFixture<IntegrationCardComponent>;
const mockI18nService = mock<I18nService>();
const activatedRoute = mock<ActivatedRoute>();
const mockOrgIntegrationApiService = mock<OrganizationIntegrationApiService>();
const systemTheme$ = new BehaviorSubject<ThemeType>(ThemeType.Light);
const usersPreferenceTheme$ = new BehaviorSubject<ThemeType>(ThemeType.Light);
@@ -23,26 +29,22 @@ describe("IntegrationCardComponent", () => {
beforeEach(async () => {
// reset system theme
systemTheme$.next(ThemeType.Light);
activatedRoute.snapshot = {
paramMap: {
get: jest.fn().mockReturnValue("test-organization-id"),
},
} as any;
await TestBed.configureTestingModule({
imports: [IntegrationCardComponent, SharedModule],
providers: [
{
provide: ThemeStateService,
useValue: { selectedTheme$: usersPreferenceTheme$ },
},
{
provide: SYSTEM_THEME_OBSERVABLE,
useValue: systemTheme$,
},
{
provide: I18nPipe,
useValue: mock<I18nPipe>(),
},
{
provide: I18nService,
useValue: mock<I18nService>(),
},
{ provide: ThemeStateService, useValue: { selectedTheme$: usersPreferenceTheme$ } },
{ provide: SYSTEM_THEME_OBSERVABLE, useValue: systemTheme$ },
{ provide: I18nPipe, useValue: mock<I18nPipe>() },
{ provide: I18nService, useValue: mockI18nService },
{ provide: ActivatedRoute, useValue: activatedRoute },
{ provide: OrganizationIntegrationApiService, useValue: mockOrgIntegrationApiService },
{ provide: ToastService, useValue: mock<ToastService>() },
],
}).compileComponents();
});
@@ -55,6 +57,7 @@ describe("IntegrationCardComponent", () => {
component.image = "test-image.png";
component.linkURL = "https://example.com/";
mockI18nService.t.mockImplementation((key) => key);
fixture.detectChanges();
});
@@ -67,7 +70,7 @@ describe("IntegrationCardComponent", () => {
it("renders card body", () => {
const name = fixture.nativeElement.querySelector("h3");
expect(name.textContent).toBe("Integration Name");
expect(name.textContent).toContain("Integration Name");
});
it("assigns external rel attribute", () => {
@@ -182,4 +185,28 @@ describe("IntegrationCardComponent", () => {
});
});
});
describe("connected badge", () => {
it("shows connected badge when isConnected is true", () => {
component.isConnected = true;
expect(component.showConnectedBadge()).toBe(true);
});
it("does not show connected badge when isConnected is false", () => {
component.isConnected = false;
fixture.detectChanges();
const name = fixture.nativeElement.querySelector("h3 > span > span > span");
expect(name.textContent).toContain("off");
// when isConnected is true/false, the badge should be shown as on/off
// when isConnected is undefined, the badge should not be shown
expect(component.showConnectedBadge()).toBe(true);
});
it("does not show connected badge when isConnected is undefined", () => {
component.isConnected = undefined;
expect(component.showConnectedBadge()).toBe(false);
});
});
});

View File

@@ -9,18 +9,30 @@ import {
OnDestroy,
ViewChild,
} from "@angular/core";
import { Observable, Subject, combineLatest, takeUntil } from "rxjs";
import { ActivatedRoute } from "@angular/router";
import { Observable, Subject, combineLatest, lastValueFrom, takeUntil } from "rxjs";
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
// eslint-disable-next-line no-restricted-imports
import {
OrganizationIntegrationType,
OrganizationIntegrationRequest,
OrganizationIntegrationResponse,
OrganizationIntegrationApiService,
} from "@bitwarden/bit-common/dirt/integrations/index";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { DialogService, ToastService } from "@bitwarden/components";
import { SharedModule } from "../../../../../../shared/shared.module";
import { openHecConnectDialog } from "../integration-dialog/index";
import { Integration } from "../models";
@Component({
selector: "app-integration-card",
templateUrl: "./integration-card.component.html",
standalone: true,
imports: [SharedModule],
})
export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
@@ -31,6 +43,7 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
@Input() image: string;
@Input() imageDarkMode?: string;
@Input() linkURL: string;
@Input() integrationSettings: Integration;
/** Adds relevant `rel` attribute to external links */
@Input() externalURL?: boolean;
@@ -42,11 +55,19 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
* @example "2024-12-31"
*/
@Input() newBadgeExpiration?: string;
@Input() description?: string;
@Input() isConnected?: boolean;
@Input() canSetupConnection?: boolean;
constructor(
private themeStateService: ThemeStateService,
@Inject(SYSTEM_THEME_OBSERVABLE)
private systemTheme$: Observable<ThemeType>,
private dialogService: DialogService,
private activatedRoute: ActivatedRoute,
private apiService: OrganizationIntegrationApiService,
private toastService: ToastService,
private i18nService: I18nService,
) {}
ngAfterViewInit() {
@@ -94,4 +115,63 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
return expirationDate > new Date();
}
showConnectedBadge(): boolean {
return this.isConnected !== undefined;
}
async setupConnection() {
// invoke the dialog to connect the integration
const dialog = openHecConnectDialog(this.dialogService, {
data: {
settings: this.integrationSettings,
},
});
const result = await lastValueFrom(dialog.closed);
// the dialog was cancelled
if (!result || !result.success) {
return;
}
// save the integration
try {
const dbResponse = await this.saveHecIntegration(result.configuration);
this.isConnected = !!dbResponse.id;
} catch {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("failedToSaveIntegration"),
});
return;
}
}
async saveHecIntegration(configuration: string): Promise<OrganizationIntegrationResponse> {
const organizationId = this.activatedRoute.snapshot.paramMap.get(
"organizationId",
) as OrganizationId;
const request = new OrganizationIntegrationRequest(
OrganizationIntegrationType.Hec,
configuration,
);
const integrations = await this.apiService.getOrganizationIntegrations(organizationId);
const existingIntegration = integrations.find(
(i) => i.type === OrganizationIntegrationType.Hec,
);
if (existingIntegration) {
return await this.apiService.updateOrganizationIntegration(
organizationId,
existingIntegration.id,
request,
);
} else {
return await this.apiService.createOrganizationIntegration(organizationId, request);
}
}
}

View File

@@ -1,58 +0,0 @@
import { importProvidersFrom } from "@angular/core";
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
import { of } from "rxjs";
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
import { ThemeTypes } from "@bitwarden/common/platform/enums";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { PreloadedEnglishI18nModule } from "../../../../../../core/tests";
import { IntegrationCardComponent } from "./integration-card.component";
class MockThemeService implements Partial<ThemeStateService> {}
export default {
title: "Web/Integration Layout/Integration Card",
component: IntegrationCardComponent,
decorators: [
applicationConfig({
providers: [importProvidersFrom(PreloadedEnglishI18nModule)],
}),
moduleMetadata({
providers: [
{
provide: ThemeStateService,
useClass: MockThemeService,
},
{
provide: SYSTEM_THEME_OBSERVABLE,
useValue: of(ThemeTypes.Light),
},
],
}),
],
args: {
integrations: [],
},
} as Meta;
type Story = StoryObj<IntegrationCardComponent>;
export const Default: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<app-integration-card
[name]="name"
[image]="image"
[linkURL]="linkURL"
></app-integration-card>
`,
}),
args: {
name: "Bitwarden",
image: "/integrations/bitwarden-vertical-blue.svg",
linkURL: "https://bitwarden.com",
},
};

View File

@@ -0,0 +1,38 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="large" [loading]="loading">
<span bitDialogTitle>
{{ "connectIntegrationButtonDesc" | i18n: connectInfo.settings.name }}
</span>
<div bitDialogContent class="tw-flex tw-flex-col tw-gap-4">
@if (loading) {
<ng-container #spinner>
<i class="bwi bwi-spinner bwi-lg bwi-spin" aria-hidden="true"></i>
</ng-container>
}
@if (!loading) {
<ng-container>
<bit-form-field>
<bit-label>{{ "url" | i18n }}</bit-label>
<input bitInput formControlName="url" />
</bit-form-field>
<bit-form-field>
<bit-label>{{ "bearerToken" | i18n }}</bit-label>
<input bitInput formControlName="bearerToken" />
</bit-form-field>
<bit-form-field>
<bit-label>{{ "index" | i18n }}</bit-label>
<input bitInput formControlName="index" />
</bit-form-field>
</ng-container>
}
</div>
<ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="primary" [disabled]="loading">
{{ "save" | i18n }}
</button>
<button type="button" bitButton bitDialogClose buttonType="secondary" [disabled]="loading">
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@@ -0,0 +1,176 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { mock } from "jest-mock-extended";
import { IntegrationType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { Integration } from "../../models";
import {
ConnectHecDialogComponent,
HecConnectDialogParams,
HecConnectDialogResult,
openHecConnectDialog,
} from "./connect-dialog-hec.component";
beforeAll(() => {
// Mock element.animate for jsdom
// the animate function is not available in jsdom, so we provide a mock implementation
// This is necessary for tests that rely on animations
// This mock does not perform any actual animations, it just provides a structure that allows tests
// to run without throwing errors related to missing animate function
if (!HTMLElement.prototype.animate) {
HTMLElement.prototype.animate = function () {
return {
play: () => {},
pause: () => {},
finish: () => {},
cancel: () => {},
reverse: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
onfinish: null,
oncancel: null,
startTime: 0,
currentTime: 0,
playbackRate: 1,
playState: "idle",
replaceState: "active",
effect: null,
finished: Promise.resolve(),
id: "",
remove: () => {},
timeline: null,
ready: Promise.resolve(),
} as unknown as Animation;
};
}
});
describe("ConnectDialogHecComponent", () => {
let component: ConnectHecDialogComponent;
let fixture: ComponentFixture<ConnectHecDialogComponent>;
let dialogRefMock = mock<DialogRef<HecConnectDialogResult>>();
const mockI18nService = mock<I18nService>();
const integrationMock: Integration = {
name: "Test Integration",
image: "test-image.png",
linkURL: "https://example.com",
imageDarkMode: "test-image-dark.png",
newBadgeExpiration: "2024-12-31",
description: "Test Description",
isConnected: false,
canSetupConnection: true,
type: IntegrationType.EVENT,
} as Integration;
const connectInfo: HecConnectDialogParams = { settings: integrationMock };
beforeEach(async () => {
dialogRefMock = mock<DialogRef<HecConnectDialogResult>>();
await TestBed.configureTestingModule({
imports: [ReactiveFormsModule, SharedModule, BrowserAnimationsModule],
providers: [
FormBuilder,
{ provide: DIALOG_DATA, useValue: connectInfo },
{ provide: DialogRef, useValue: dialogRefMock },
{ provide: I18nPipe, useValue: mock<I18nPipe>() },
{ provide: I18nService, useValue: mockI18nService },
],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ConnectHecDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
mockI18nService.t.mockImplementation((key) => key);
});
it("should create the component", () => {
expect(component).toBeTruthy();
});
it("should initialize form with empty values", () => {
expect(component.formGroup.value).toEqual({
url: "",
bearerToken: "",
index: "",
service: "Test Integration",
});
});
it("should have required validators for all fields", () => {
component.formGroup.setValue({ url: "", bearerToken: "", index: "", service: "" });
expect(component.formGroup.valid).toBeFalsy();
component.formGroup.setValue({
url: "https://test.com",
bearerToken: "token",
index: "1",
service: "Test Service",
});
expect(component.formGroup.valid).toBeTruthy();
});
it("should invalidate url if not matching pattern", () => {
component.formGroup.setValue({
url: "ftp://test.com",
bearerToken: "token",
index: "1",
service: "Test Service",
});
expect(component.formGroup.valid).toBeFalsy();
component.formGroup.setValue({
url: "https://test.com",
bearerToken: "token",
index: "1",
service: "Test Service",
});
expect(component.formGroup.valid).toBeTruthy();
});
it("should call dialogRef.close with correct result on submit", async () => {
component.formGroup.setValue({
url: "https://test.com",
bearerToken: "token",
index: "1",
service: "Test Service",
});
await component.submit();
expect(dialogRefMock.close).toHaveBeenCalledWith({
integrationSettings: integrationMock,
configuration: JSON.stringify({
url: "https://test.com",
bearerToken: "token",
index: "1",
service: "Test Service",
}),
success: true,
error: null,
});
});
});
describe("openCrowdstrikeConnectDialog", () => {
it("should call dialogService.open with correct params", () => {
const dialogServiceMock = mock<DialogService>();
const config: DialogConfig<HecConnectDialogParams, DialogRef<HecConnectDialogResult>> = {
data: { settings: { name: "Test" } as Integration },
} as any;
openHecConnectDialog(dialogServiceMock, config);
expect(dialogServiceMock.open).toHaveBeenCalledWith(ConnectHecDialogComponent, config);
});
});

View File

@@ -0,0 +1,81 @@
import { Component, Inject, OnInit } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { Integration } from "../../models";
export type HecConnectDialogParams = {
settings: Integration;
};
export interface HecConnectDialogResult {
integrationSettings: Integration;
configuration: string;
success: boolean;
error: string | null;
}
@Component({
templateUrl: "./connect-dialog-hec.component.html",
imports: [SharedModule],
})
export class ConnectHecDialogComponent implements OnInit {
loading = false;
formGroup = this.formBuilder.group({
url: ["", [Validators.required, Validators.pattern("https?://.+")]],
bearerToken: ["", Validators.required],
index: ["", Validators.required],
service: ["", Validators.required],
});
constructor(
@Inject(DIALOG_DATA) protected connectInfo: HecConnectDialogParams,
protected formBuilder: FormBuilder,
private dialogRef: DialogRef<HecConnectDialogResult>,
) {}
ngOnInit(): void {
const settings = this.getSettingsAsJson(this.connectInfo.settings.configuration ?? "");
if (settings) {
this.formGroup.patchValue({
url: settings?.url || "",
bearerToken: settings?.bearerToken || "",
index: settings?.index || "",
service: this.connectInfo.settings.name,
});
}
}
getSettingsAsJson(configuration: string) {
try {
return JSON.parse(configuration);
} catch {
return {};
}
}
submit = async (): Promise<void> => {
const formJson = this.formGroup.getRawValue();
const result: HecConnectDialogResult = {
integrationSettings: this.connectInfo.settings,
configuration: JSON.stringify(formJson),
success: true,
error: null,
};
this.dialogRef.close(result);
return;
};
}
export function openHecConnectDialog(
dialogService: DialogService,
config: DialogConfig<HecConnectDialogParams, DialogRef<HecConnectDialogResult>>,
) {
return dialogService.open<HecConnectDialogResult>(ConnectHecDialogComponent, config);
}

View File

@@ -0,0 +1 @@
export * from "./connect-dialog/connect-dialog-hec.component";

View File

@@ -13,6 +13,10 @@
[imageDarkMode]="integration.imageDarkMode"
[externalURL]="integration.type === IntegrationType.SDK"
[newBadgeExpiration]="integration.newBadgeExpiration"
[description]="integration.description | i18n"
[isConnected]="integration.isConnected"
[canSetupConnection]="integration.canSetupConnection"
[integrationSettings]="integration"
></app-integration-card>
</li>
</ul>

View File

@@ -1,14 +1,20 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { ActivatedRoute } from "@angular/router";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
// eslint-disable-next-line no-restricted-imports
import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/integrations/services";
import { IntegrationType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ThemeTypes } from "@bitwarden/common/platform/enums";
// eslint-disable-next-line import/order
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
// FIXME: remove `src` and fix import
import { ToastService } from "@bitwarden/components";
// eslint-disable-next-line no-restricted-imports
import { SharedModule } from "@bitwarden/components/src/shared";
import { I18nPipe } from "@bitwarden/ui-common";
@@ -21,6 +27,8 @@ import { IntegrationGridComponent } from "./integration-grid.component";
describe("IntegrationGridComponent", () => {
let component: IntegrationGridComponent;
let fixture: ComponentFixture<IntegrationGridComponent>;
const mockActivatedRoute = mock<ActivatedRoute>();
const mockOrgIntegrationApiService = mock<OrganizationIntegrationApiService>();
const integrations: Integration[] = [
{
name: "Integration 1",
@@ -37,6 +45,12 @@ describe("IntegrationGridComponent", () => {
];
beforeEach(() => {
mockActivatedRoute.snapshot = {
paramMap: {
get: jest.fn().mockReturnValue("test-organization-id"),
},
} as any;
TestBed.configureTestingModule({
imports: [IntegrationGridComponent, IntegrationCardComponent, SharedModule],
providers: [
@@ -56,6 +70,18 @@ describe("IntegrationGridComponent", () => {
provide: I18nService,
useValue: mock<I18nService>({ t: (key, p1) => key + " " + p1 }),
},
{
provide: ActivatedRoute,
useValue: mockActivatedRoute,
},
{
provide: OrganizationIntegrationApiService,
useValue: mockOrgIntegrationApiService,
},
{
provide: ToastService,
useValue: mock<ToastService>(),
},
],
});

View File

@@ -11,7 +11,6 @@ import { Integration } from "../models";
@Component({
selector: "app-integration-grid",
templateUrl: "./integration-grid.component.html",
standalone: true,
imports: [IntegrationCardComponent, SharedModule],
})
export class IntegrationGridComponent {

View File

@@ -1,70 +0,0 @@
import { importProvidersFrom } from "@angular/core";
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
import { of } from "rxjs";
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
import { IntegrationType } from "@bitwarden/common/enums";
import { ThemeTypes } from "@bitwarden/common/platform/enums";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { PreloadedEnglishI18nModule } from "../../../../../../core/tests";
import { IntegrationCardComponent } from "../integration-card/integration-card.component";
import { IntegrationGridComponent } from "../integration-grid/integration-grid.component";
class MockThemeService implements Partial<ThemeStateService> {}
export default {
title: "Web/Integration Layout/Integration Grid",
component: IntegrationGridComponent,
decorators: [
applicationConfig({
providers: [importProvidersFrom(PreloadedEnglishI18nModule)],
}),
moduleMetadata({
imports: [IntegrationCardComponent],
providers: [
{
provide: ThemeStateService,
useClass: MockThemeService,
},
{
provide: SYSTEM_THEME_OBSERVABLE,
useValue: of(ThemeTypes.Dark),
},
],
}),
],
} as Meta;
type Story = StoryObj<IntegrationGridComponent>;
export const Default: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<app-integration-grid [integrations]="integrations"></app-integration-grid>
`,
}),
args: {
integrations: [
{
name: "Card 1",
linkURL: "https://bitwarden.com",
image: "/integrations/bitwarden-vertical-blue.svg",
type: IntegrationType.SSO,
},
{
name: "Card 2",
linkURL: "https://bitwarden.com",
image: "/integrations/bitwarden-vertical-blue.svg",
type: IntegrationType.SDK,
},
{
name: "Card 3",
linkURL: "https://bitwarden.com",
image: "/integrations/bitwarden-vertical-blue.svg",
type: IntegrationType.SCIM,
},
],
},
};

View File

@@ -6,7 +6,6 @@ import { Integration } from "../../../shared/components/integrations/models";
@Pipe({
name: "filterIntegrations",
standalone: true,
})
export class FilterIntegrationsPipe implements PipeTransform {
transform(integrations: Integration[], type: IntegrationType): Integration[] {

View File

@@ -17,4 +17,8 @@ export type Integration = {
* @example "2024-12-31"
*/
newBadgeExpiration?: string;
description?: string;
isConnected?: boolean;
canSetupConnection?: boolean;
configuration?: string;
};

View File

@@ -18,7 +18,7 @@ describe("freeOrgCollectionLimitValidator", () => {
it("returns null if organization is not found", async () => {
const orgs: Organization[] = [];
const validator = freeOrgCollectionLimitValidator(of(orgs), [], i18nService);
const validator = freeOrgCollectionLimitValidator(of(orgs), of([]), i18nService);
const control = new FormControl("org-id");
const result: Observable<ValidationErrors> = validator(control) as Observable<ValidationErrors>;
@@ -28,7 +28,7 @@ describe("freeOrgCollectionLimitValidator", () => {
});
it("returns null if control is not an instance of FormControl", async () => {
const validator = freeOrgCollectionLimitValidator(of([]), [], i18nService);
const validator = freeOrgCollectionLimitValidator(of([]), of([]), i18nService);
const control = {} as AbstractControl;
const result: Observable<ValidationErrors | null> = validator(
@@ -40,7 +40,7 @@ describe("freeOrgCollectionLimitValidator", () => {
});
it("returns null if control is not provided", async () => {
const validator = freeOrgCollectionLimitValidator(of([]), [], i18nService);
const validator = freeOrgCollectionLimitValidator(of([]), of([]), i18nService);
const result: Observable<ValidationErrors | null> = validator(
undefined as any,
@@ -53,7 +53,7 @@ describe("freeOrgCollectionLimitValidator", () => {
it("returns null if organization has not reached collection limit (Observable)", async () => {
const org = { id: "org-id", maxCollections: 2 } as Organization;
const collections = [{ organizationId: "org-id" } as Collection];
const validator = freeOrgCollectionLimitValidator(of([org]), collections, i18nService);
const validator = freeOrgCollectionLimitValidator(of([org]), of(collections), i18nService);
const control = new FormControl("org-id");
const result$ = validator(control) as Observable<ValidationErrors | null>;
@@ -65,7 +65,7 @@ describe("freeOrgCollectionLimitValidator", () => {
it("returns error if organization has reached collection limit (Observable)", async () => {
const org = { id: "org-id", maxCollections: 1 } as Organization;
const collections = [{ organizationId: "org-id" } as Collection];
const validator = freeOrgCollectionLimitValidator(of([org]), collections, i18nService);
const validator = freeOrgCollectionLimitValidator(of([org]), of(collections), i18nService);
const control = new FormControl("org-id");
const result$ = validator(control) as Observable<ValidationErrors | null>;

View File

@@ -1,13 +1,14 @@
import { AbstractControl, AsyncValidatorFn, FormControl, ValidationErrors } from "@angular/forms";
import { map, Observable, of } from "rxjs";
import { combineLatest, map, Observable, of } from "rxjs";
import { Collection } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { getById } from "@bitwarden/common/platform/misc";
export function freeOrgCollectionLimitValidator(
orgs: Observable<Organization[]>,
collections: Collection[],
organizations$: Observable<Organization[]>,
collections$: Observable<Collection[]>,
i18nService: I18nService,
): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
@@ -21,15 +22,16 @@ export function freeOrgCollectionLimitValidator(
return of(null);
}
return orgs.pipe(
map((organizations) => organizations.find((org) => org.id === orgId)),
map((org) => {
if (!org) {
return combineLatest([organizations$.pipe(getById(orgId)), collections$]).pipe(
map(([organization, collections]) => {
if (!organization) {
return null;
}
const orgCollections = collections.filter((c) => c.organizationId === org.id);
const hasReachedLimit = org.maxCollections === orgCollections.length;
const orgCollections = collections.filter(
(collection: Collection) => collection.organizationId === organization.id,
);
const hasReachedLimit = organization.maxCollections === orgCollections.length;
if (hasReachedLimit) {
return {

View File

@@ -3,11 +3,10 @@
import { Component, inject } from "@angular/core";
import { Params } from "@angular/router";
import { BitwardenLogo } from "@bitwarden/auth/angular";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { OrganizationSponsorshipResponse } from "@bitwarden/common/admin-console/models/response/organization-sponsorship.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { ToastService } from "@bitwarden/components";
import { Icons, ToastService } from "@bitwarden/components";
import { BaseAcceptComponent } from "../../../common/base.accept.component";
@@ -22,7 +21,7 @@ import { BaseAcceptComponent } from "../../../common/base.accept.component";
standalone: false,
})
export class AcceptFamilySponsorshipComponent extends BaseAcceptComponent {
protected logo = BitwardenLogo;
protected logo = Icons.BitwardenLogo;
failedShortMessage = "inviteAcceptFailedShort";
failedMessage = "inviteAcceptFailed";

View File

@@ -30,7 +30,6 @@ import {
@Component({
templateUrl: "families-for-enterprise-setup.component.html",
standalone: true,
imports: [SharedModule, OrganizationPlansComponent],
})
export class FamiliesForEnterpriseSetupComponent implements OnInit, OnDestroy {

View File

@@ -13,7 +13,6 @@ import { SharedModule } from "../../shared";
@Component({
templateUrl: "create-organization.component.html",
standalone: true,
imports: [SharedModule, OrganizationPlansComponent, HeaderModule],
})
export class CreateOrganizationComponent {

View File

@@ -1,15 +1,14 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DOCUMENT } from "@angular/common";
import { Component, Inject, NgZone, OnDestroy, OnInit } from "@angular/core";
import { Component, DestroyRef, NgZone, OnDestroy, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Router } from "@angular/router";
import { Subject, filter, firstValueFrom, map, takeUntil, timeout } from "rxjs";
import { Subject, filter, firstValueFrom, map, timeout } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction";
import { DocumentLangSetter } from "@bitwarden/angular/platform/i18n";
import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
@@ -26,6 +25,7 @@ import { NotificationsService } from "@bitwarden/common/platform/notifications";
import { StateEventRunnerService } from "@bitwarden/common/platform/state";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService, BiometricStateService } from "@bitwarden/key-management";
@@ -34,13 +34,15 @@ import {
DisableSendPolicy,
MasterPasswordPolicy,
PasswordGeneratorPolicy,
PersonalOwnershipPolicy,
OrganizationDataOwnershipPolicy,
vNextOrganizationDataOwnershipPolicy,
RequireSsoPolicy,
ResetPasswordPolicy,
SendOptionsPolicy,
SingleOrgPolicy,
TwoFactorAuthenticationPolicy,
RemoveUnlockWithPinPolicy,
RestrictedItemTypesPolicy,
} from "./admin-console/organizations/policies";
const BroadcasterSubscriptionId = "AppComponent";
@@ -60,7 +62,6 @@ export class AppComponent implements OnDestroy, OnInit {
loading = false;
constructor(
@Inject(DOCUMENT) private document: Document,
private broadcasterService: BroadcasterService,
private folderService: InternalFolderService,
private cipherService: CipherService,
@@ -86,15 +87,16 @@ export class AppComponent implements OnDestroy, OnInit {
private accountService: AccountService,
private processReloadService: ProcessReloadServiceAbstraction,
private deviceTrustToastService: DeviceTrustToastService,
private readonly destoryRef: DestroyRef,
private readonly documentLangSetter: DocumentLangSetter,
) {
this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe();
const langSubscription = this.documentLangSetter.start();
this.destoryRef.onDestroy(() => langSubscription.unsubscribe());
}
ngOnInit() {
this.i18nService.locale$.pipe(takeUntil(this.destroy$)).subscribe((locale) => {
this.document.documentElement.lang = locale;
});
this.ngZone.runOutsideAngular(() => {
window.onmousemove = () => this.recordActivity();
window.onmousedown = () => this.recordActivity();
@@ -243,9 +245,11 @@ export class AppComponent implements OnDestroy, OnInit {
new PasswordGeneratorPolicy(),
new SingleOrgPolicy(),
new RequireSsoPolicy(),
new PersonalOwnershipPolicy(),
new OrganizationDataOwnershipPolicy(),
new vNextOrganizationDataOwnershipPolicy(),
new DisableSendPolicy(),
new SendOptionsPolicy(),
new RestrictedItemTypesPolicy(),
]);
}
@@ -285,7 +289,6 @@ export class AppComponent implements OnDestroy, OnInit {
this.keyService.clearKeys(userId),
this.cipherService.clear(userId),
this.folderService.clear(userId),
this.collectionService.clear(userId),
this.biometricStateService.logout(userId),
]);

View File

@@ -1,8 +1,12 @@
import { ChangePasswordService, DefaultChangePasswordService } from "@bitwarden/auth/angular";
import {
ChangePasswordService,
DefaultChangePasswordService,
} from "@bitwarden/angular/auth/password-management/change-password";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { KeyService } from "@bitwarden/key-management";
import { RouterService } from "@bitwarden/web-vault/app/core";
import { UserKeyRotationService } from "@bitwarden/web-vault/app/key-management/key-rotation/user-key-rotation.service";
export class WebChangePasswordService
@@ -14,6 +18,7 @@ export class WebChangePasswordService
protected masterPasswordApiService: MasterPasswordApiService,
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
private userKeyRotationService: UserKeyRotationService,
private routerService: RouterService,
) {
super(keyService, masterPasswordApiService, masterPasswordService);
}
@@ -31,4 +36,8 @@ export class WebChangePasswordService
newPasswordHint,
);
}
async clearDeeplinkState() {
await this.routerService.getAndClearLoginRedirectUrl();
}
}

View File

@@ -2,7 +2,7 @@ export * from "./change-password";
export * from "./login";
export * from "./login-decryption-options";
export * from "./webauthn-login";
export * from "./set-password-jit";
export * from "./password-management";
export * from "./registration";
export * from "./two-factor-auth";
export * from "./link-sso.service";

View File

@@ -4,10 +4,10 @@ import {
LoginDecryptionOptionsService,
DefaultLoginDecryptionOptionsService,
} from "@bitwarden/auth/angular";
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { RouterService } from "../../../../core/router.service";
import { AcceptOrganizationInviteService } from "../../../organization-invite/accept-organization.service";
export class WebLoginDecryptionOptionsService
extends DefaultLoginDecryptionOptionsService
@@ -16,7 +16,7 @@ export class WebLoginDecryptionOptionsService
constructor(
protected messagingService: MessagingService,
private routerService: RouterService,
private acceptOrganizationInviteService: AcceptOrganizationInviteService,
private organizationInviteService: OrganizationInviteService,
) {
super(messagingService);
}
@@ -27,7 +27,7 @@ export class WebLoginDecryptionOptionsService
// accepted while being enrolled in admin recovery. So we need to clear
// the redirect and stored org invite.
await this.routerService.getAndClearLoginRedirectUrl();
await this.acceptOrganizationInviteService.clearOrganizationInvitation();
await this.organizationInviteService.clearOrganizationInvitation();
} catch (error) {
throw new Error(error);
}

View File

@@ -1,6 +1,5 @@
import { TestBed } from "@angular/core/testing";
import { MockProxy, mock } from "jest-mock-extended";
import { of } from "rxjs";
import { DefaultLoginComponentService } from "@bitwarden/auth/angular";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
@@ -10,7 +9,10 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { ResetPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/reset-password-policy-options";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { OrganizationInvite } from "@bitwarden/common/auth/services/organization-invite/organization-invite";
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -22,7 +24,6 @@ import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legac
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { RouterService } from "../../../../../../../../apps/web/src/app/core";
import { AcceptOrganizationInviteService } from "../../../organization-invite/accept-organization.service";
import { WebLoginComponentService } from "./web-login-component.service";
@@ -32,7 +33,7 @@ jest.mock("../../../../../utils/flags", () => ({
describe("WebLoginComponentService", () => {
let service: WebLoginComponentService;
let acceptOrganizationInviteService: MockProxy<AcceptOrganizationInviteService>;
let organizationInviteService: MockProxy<OrganizationInviteService>;
let logService: MockProxy<LogService>;
let policyApiService: MockProxy<PolicyApiServiceAbstraction>;
let internalPolicyService: MockProxy<InternalPolicyService>;
@@ -44,9 +45,10 @@ describe("WebLoginComponentService", () => {
let ssoLoginService: MockProxy<SsoLoginServiceAbstraction>;
const mockUserId = Utils.newGuid() as UserId;
let accountService: FakeAccountService;
let configService: MockProxy<ConfigService>;
beforeEach(() => {
acceptOrganizationInviteService = mock<AcceptOrganizationInviteService>();
organizationInviteService = mock<OrganizationInviteService>();
logService = mock<LogService>();
policyApiService = mock<PolicyApiServiceAbstraction>();
internalPolicyService = mock<InternalPolicyService>();
@@ -57,12 +59,13 @@ describe("WebLoginComponentService", () => {
platformUtilsService = mock<PlatformUtilsService>();
ssoLoginService = mock<SsoLoginServiceAbstraction>();
accountService = mockAccountServiceWith(mockUserId);
configService = mock<ConfigService>();
TestBed.configureTestingModule({
providers: [
WebLoginComponentService,
{ provide: DefaultLoginComponentService, useClass: WebLoginComponentService },
{ provide: AcceptOrganizationInviteService, useValue: acceptOrganizationInviteService },
{ provide: OrganizationInviteService, useValue: organizationInviteService },
{ provide: LogService, useValue: logService },
{ provide: PolicyApiServiceAbstraction, useValue: policyApiService },
{ provide: InternalPolicyService, useValue: internalPolicyService },
@@ -73,6 +76,7 @@ describe("WebLoginComponentService", () => {
{ provide: PlatformUtilsService, useValue: platformUtilsService },
{ provide: SsoLoginServiceAbstraction, useValue: ssoLoginService },
{ provide: AccountService, useValue: accountService },
{ provide: ConfigService, useValue: configService },
],
});
service = TestBed.inject(WebLoginComponentService);
@@ -83,26 +87,29 @@ describe("WebLoginComponentService", () => {
});
describe("getOrgPoliciesFromOrgInvite", () => {
const mockEmail = "test@example.com";
const orgInvite: OrganizationInvite = {
organizationId: "org-id",
token: "token",
email: mockEmail,
organizationUserId: "org-user-id",
initOrganization: false,
orgSsoIdentifier: "sso-id",
orgUserHasExistingUser: false,
organizationName: "org-name",
};
it("returns undefined if organization invite is null", async () => {
acceptOrganizationInviteService.getOrganizationInvite.mockResolvedValue(null);
const result = await service.getOrgPoliciesFromOrgInvite();
organizationInviteService.getOrganizationInvite.mockResolvedValue(null);
const result = await service.getOrgPoliciesFromOrgInvite(mockEmail);
expect(result).toBeUndefined();
});
it("logs an error if getPoliciesByToken throws an error", async () => {
const error = new Error("Test error");
acceptOrganizationInviteService.getOrganizationInvite.mockResolvedValue({
organizationId: "org-id",
token: "token",
email: "email",
organizationUserId: "org-user-id",
initOrganization: false,
orgSsoIdentifier: "sso-id",
orgUserHasExistingUser: false,
organizationName: "org-name",
});
organizationInviteService.getOrganizationInvite.mockResolvedValue(orgInvite);
policyApiService.getPoliciesByToken.mockRejectedValue(error);
await service.getOrgPoliciesFromOrgInvite();
await service.getOrgPoliciesFromOrgInvite(mockEmail);
expect(logService.error).toHaveBeenCalledWith(error);
});
@@ -117,16 +124,7 @@ describe("WebLoginComponentService", () => {
const resetPasswordPolicyOptions = new ResetPasswordPolicyOptions();
resetPasswordPolicyOptions.autoEnrollEnabled = autoEnrollEnabled;
acceptOrganizationInviteService.getOrganizationInvite.mockResolvedValue({
organizationId: "org-id",
token: "token",
email: "email",
organizationUserId: "org-user-id",
initOrganization: false,
orgSsoIdentifier: "sso-id",
orgUserHasExistingUser: false,
organizationName: "org-name",
});
organizationInviteService.getOrganizationInvite.mockResolvedValue(orgInvite);
policyApiService.getPoliciesByToken.mockResolvedValue(policies);
internalPolicyService.getResetPasswordPolicyOptions.mockReturnValue([
@@ -134,11 +132,11 @@ describe("WebLoginComponentService", () => {
resetPasswordPolicyEnabled,
]);
internalPolicyService.masterPasswordPolicyOptions$.mockReturnValue(
of(masterPasswordPolicyOptions),
internalPolicyService.combinePoliciesIntoMasterPasswordPolicyOptions.mockReturnValue(
masterPasswordPolicyOptions,
);
const result = await service.getOrgPoliciesFromOrgInvite();
const result = await service.getOrgPoliciesFromOrgInvite(mockEmail);
expect(result).toEqual({
policies: policies,
@@ -148,5 +146,40 @@ describe("WebLoginComponentService", () => {
});
},
);
describe("given the orgInvite email does not match the provided email", () => {
const mockMismatchedEmail = "mismatched@example.com";
it("should clear the login redirect URL and organization invite", async () => {
// Arrange
organizationInviteService.getOrganizationInvite.mockResolvedValue({
...orgInvite,
email: mockMismatchedEmail,
});
// Act
await service.getOrgPoliciesFromOrgInvite(mockEmail);
// Assert
expect(routerService.getAndClearLoginRedirectUrl).toHaveBeenCalledTimes(1);
expect(organizationInviteService.clearOrganizationInvitation).toHaveBeenCalledTimes(1);
});
it("should log an error and return undefined", async () => {
// Arrange
organizationInviteService.getOrganizationInvite.mockResolvedValue({
...orgInvite,
email: mockMismatchedEmail,
});
// Act
const result = await service.getOrgPoliciesFromOrgInvite(mockEmail);
// Assert
expect(logService.error).toHaveBeenCalledWith(
`WebLoginComponentService.getOrgPoliciesFromOrgInvite: Email mismatch. Expected: ${mockMismatchedEmail}, Received: ${mockEmail}`,
);
expect(result).toBeUndefined();
});
});
});
});

View File

@@ -2,7 +2,6 @@
// @ts-strict-ignore
import { Injectable } from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom, switchMap } from "rxjs";
import {
DefaultLoginComponentService,
@@ -14,15 +13,15 @@ import { InternalPolicyService } from "@bitwarden/common/admin-console/abstracti
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { RouterService } from "../../../../core/router.service";
import { AcceptOrganizationInviteService } from "../../../organization-invite/accept-organization.service";
@Injectable()
export class WebLoginComponentService
@@ -30,7 +29,7 @@ export class WebLoginComponentService
implements LoginComponentService
{
constructor(
protected acceptOrganizationInviteService: AcceptOrganizationInviteService,
protected organizationInviteService: OrganizationInviteService,
protected logService: LogService,
protected policyApiService: PolicyApiServiceAbstraction,
protected policyService: InternalPolicyService,
@@ -42,6 +41,7 @@ export class WebLoginComponentService
ssoLoginService: SsoLoginServiceAbstraction,
private router: Router,
private accountService: AccountService,
private configService: ConfigService,
) {
super(
cryptoFunctionService,
@@ -66,10 +66,27 @@ export class WebLoginComponentService
return;
}
async getOrgPoliciesFromOrgInvite(): Promise<PasswordPolicies | null> {
const orgInvite = await this.acceptOrganizationInviteService.getOrganizationInvite();
async getOrgPoliciesFromOrgInvite(email: string): Promise<PasswordPolicies | undefined> {
const orgInvite = await this.organizationInviteService.getOrganizationInvite();
if (orgInvite != null) {
/**
* Check if the email on the org invite matches the email submitted in the login form. This is
* important because say userA at "userA@mail.com" clicks an emailed org invite link, but then
* on the login page form they change the email to "userB@mail.com". We don't want to apply the org
* invite in state to userB. Therefore we clear the login redirect url as well as the org invite,
* allowing userB to login as normal.
*/
if (orgInvite.email !== email.toLowerCase()) {
await this.routerService.getAndClearLoginRedirectUrl();
await this.organizationInviteService.clearOrganizationInvitation();
this.logService.error(
`WebLoginComponentService.getOrgPoliciesFromOrgInvite: Email mismatch. Expected: ${orgInvite.email}, Received: ${email}`,
);
return undefined;
}
let policies: Policy[];
try {
@@ -84,7 +101,7 @@ export class WebLoginComponentService
}
if (policies == null) {
return;
return undefined;
}
const resetPasswordPolicy = this.policyService.getResetPasswordPolicyOptions(
@@ -95,12 +112,8 @@ export class WebLoginComponentService
const isPolicyAndAutoEnrollEnabled =
resetPasswordPolicy[1] && resetPasswordPolicy[0].autoEnrollEnabled;
const enforcedPasswordPolicyOptions = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.policyService.masterPasswordPolicyOptions$(userId, policies)),
),
);
const enforcedPasswordPolicyOptions =
this.policyService.combinePoliciesIntoMasterPasswordPolicyOptions(policies);
return {
policies,

View File

@@ -0,0 +1,38 @@
import { firstValueFrom } from "rxjs";
import { OrganizationInvite } from "@bitwarden/common/auth/services/organization-invite/organization-invite";
import { ORGANIZATION_INVITE } from "@bitwarden/common/auth/services/organization-invite/organization-invite-state";
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
import { GlobalState, GlobalStateProvider } from "@bitwarden/common/platform/state";
export class WebOrganizationInviteService implements OrganizationInviteService {
private organizationInvitationState: GlobalState<OrganizationInvite | null>;
constructor(private readonly globalStateProvider: GlobalStateProvider) {
this.organizationInvitationState = this.globalStateProvider.get(ORGANIZATION_INVITE);
}
/**
* Returns the currently stored organization invite
*/
async getOrganizationInvite(): Promise<OrganizationInvite | null> {
return await firstValueFrom(this.organizationInvitationState.state$);
}
/**
* Stores a new organization invite
* @param invite an organization invite
* @throws if the invite is nullish
*/
async setOrganizationInvitation(invite: OrganizationInvite): Promise<void> {
if (invite == null) {
throw new Error("Invite cannot be null. Use clearOrganizationInvitation instead.");
}
await this.organizationInvitationState.update(() => invite);
}
/** Clears the currently stored organization invite */
async clearOrganizationInvitation(): Promise<void> {
await this.organizationInvitationState.update(() => null);
}
}

View File

@@ -0,0 +1 @@
export * from "./set-initial-password/web-set-initial-password.service";

View File

@@ -0,0 +1,206 @@
import { MockProxy, mock } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import {
SetInitialPasswordCredentials,
SetInitialPasswordService,
SetInitialPasswordUserType,
} from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.service.abstraction";
import {
FakeUserDecryptionOptions as UserDecryptionOptions,
InternalUserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request";
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management";
import { RouterService } from "@bitwarden/web-vault/app/core";
import { WebSetInitialPasswordService } from "./web-set-initial-password.service";
describe("WebSetInitialPasswordService", () => {
let sut: SetInitialPasswordService;
let apiService: MockProxy<ApiService>;
let encryptService: MockProxy<EncryptService>;
let i18nService: MockProxy<I18nService>;
let kdfConfigService: MockProxy<KdfConfigService>;
let keyService: MockProxy<KeyService>;
let masterPasswordApiService: MockProxy<MasterPasswordApiService>;
let masterPasswordService: MockProxy<InternalMasterPasswordServiceAbstraction>;
let organizationApiService: MockProxy<OrganizationApiServiceAbstraction>;
let organizationUserApiService: MockProxy<OrganizationUserApiService>;
let userDecryptionOptionsService: MockProxy<InternalUserDecryptionOptionsServiceAbstraction>;
let organizationInviteService: MockProxy<OrganizationInviteService>;
let routerService: MockProxy<RouterService>;
beforeEach(() => {
apiService = mock<ApiService>();
encryptService = mock<EncryptService>();
i18nService = mock<I18nService>();
kdfConfigService = mock<KdfConfigService>();
keyService = mock<KeyService>();
masterPasswordApiService = mock<MasterPasswordApiService>();
masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
organizationApiService = mock<OrganizationApiServiceAbstraction>();
organizationUserApiService = mock<OrganizationUserApiService>();
userDecryptionOptionsService = mock<InternalUserDecryptionOptionsServiceAbstraction>();
organizationInviteService = mock<OrganizationInviteService>();
routerService = mock<RouterService>();
sut = new WebSetInitialPasswordService(
apiService,
encryptService,
i18nService,
kdfConfigService,
keyService,
masterPasswordApiService,
masterPasswordService,
organizationApiService,
organizationUserApiService,
userDecryptionOptionsService,
organizationInviteService,
routerService,
);
});
it("should instantiate", () => {
expect(sut).not.toBeFalsy();
});
describe("setInitialPassword(...)", () => {
// Mock function parameters
let credentials: SetInitialPasswordCredentials;
let userType: SetInitialPasswordUserType;
let userId: UserId;
// Mock other function data
let userKey: UserKey;
let userKeyEncString: EncString;
let masterKeyEncryptedUserKey: [UserKey, EncString];
let keyPair: [string, EncString];
let keysRequest: KeysRequest;
let userDecryptionOptions: UserDecryptionOptions;
let userDecryptionOptionsSubject: BehaviorSubject<UserDecryptionOptions>;
let setPasswordRequest: SetPasswordRequest;
beforeEach(() => {
// Mock function parameters
credentials = {
newMasterKey: new SymmetricCryptoKey(new Uint8Array(32).buffer as CsprngArray) as MasterKey,
newServerMasterKeyHash: "newServerMasterKeyHash",
newLocalMasterKeyHash: "newLocalMasterKeyHash",
newPasswordHint: "newPasswordHint",
kdfConfig: DEFAULT_KDF_CONFIG,
orgSsoIdentifier: "orgSsoIdentifier",
orgId: "orgId",
resetPasswordAutoEnroll: false,
};
userId = "userId" as UserId;
userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER;
// Mock other function data
userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
userKeyEncString = new EncString("masterKeyEncryptedUserKey");
masterKeyEncryptedUserKey = [userKey, userKeyEncString];
keyPair = ["publicKey", new EncString("privateKey")];
keysRequest = new KeysRequest(keyPair[0], keyPair[1].encryptedString);
userDecryptionOptions = new UserDecryptionOptions({ hasMasterPassword: true });
userDecryptionOptionsSubject = new BehaviorSubject(userDecryptionOptions);
userDecryptionOptionsService.userDecryptionOptions$ = userDecryptionOptionsSubject;
setPasswordRequest = new SetPasswordRequest(
credentials.newServerMasterKeyHash,
masterKeyEncryptedUserKey[1].encryptedString,
credentials.newPasswordHint,
credentials.orgSsoIdentifier,
keysRequest,
credentials.kdfConfig.kdfType,
credentials.kdfConfig.iterations,
);
});
function setupMocks() {
// Mock makeMasterKeyEncryptedUserKey() values
keyService.userKey$.mockReturnValue(of(userKey));
keyService.encryptUserKeyWithMasterKey.mockResolvedValue(masterKeyEncryptedUserKey);
// Mock keyPair values
keyService.userPrivateKey$.mockReturnValue(of(null));
keyService.userPublicKey$.mockReturnValue(of(null));
keyService.makeKeyPair.mockResolvedValue(keyPair);
}
describe("given the initial password was successfully set", () => {
it("should call routerService.getAndClearLoginRedirectUrl()", async () => {
// Arrange
setupMocks();
// Act
await sut.setInitialPassword(credentials, userType, userId);
// Assert
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
expect(routerService.getAndClearLoginRedirectUrl).toHaveBeenCalledTimes(1);
});
it("should call acceptOrganizationInviteService.clearOrganizationInvitation()", async () => {
// Arrange
setupMocks();
// Act
await sut.setInitialPassword(credentials, userType, userId);
// Assert
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
expect(organizationInviteService.clearOrganizationInvitation).toHaveBeenCalledTimes(1);
});
});
describe("given the initial password was NOT successfully set (due to some error in setInitialPassword())", () => {
it("should NOT call routerService.getAndClearLoginRedirectUrl()", async () => {
// Arrange
credentials.newMasterKey = null; // will trigger an error in setInitialPassword()
setupMocks();
// Act
const promise = sut.setInitialPassword(credentials, userType, userId);
// Assert
await expect(promise).rejects.toThrow();
expect(masterPasswordApiService.setPassword).not.toHaveBeenCalled();
expect(routerService.getAndClearLoginRedirectUrl).not.toHaveBeenCalled();
});
it("should NOT call acceptOrganizationInviteService.clearOrganizationInvitation()", async () => {
// Arrange
credentials.newMasterKey = null; // will trigger an error in setInitialPassword()
setupMocks();
// Act
const promise = sut.setInitialPassword(credentials, userType, userId);
// Assert
await expect(promise).rejects.toThrow();
expect(masterPasswordApiService.setPassword).not.toHaveBeenCalled();
expect(organizationInviteService.clearOrganizationInvitation).not.toHaveBeenCalled();
});
});
});
});

View File

@@ -0,0 +1,83 @@
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import { DefaultSetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/default-set-initial-password.service.implementation";
import {
SetInitialPasswordCredentials,
SetInitialPasswordService,
SetInitialPasswordUserType,
} from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.service.abstraction";
import { InternalUserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UserId } from "@bitwarden/common/types/guid";
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
import { RouterService } from "@bitwarden/web-vault/app/core";
export class WebSetInitialPasswordService
extends DefaultSetInitialPasswordService
implements SetInitialPasswordService
{
constructor(
protected apiService: ApiService,
protected encryptService: EncryptService,
protected i18nService: I18nService,
protected kdfConfigService: KdfConfigService,
protected keyService: KeyService,
protected masterPasswordApiService: MasterPasswordApiService,
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
protected organizationApiService: OrganizationApiServiceAbstraction,
protected organizationUserApiService: OrganizationUserApiService,
protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
private organizationInviteService: OrganizationInviteService,
private routerService: RouterService,
) {
super(
apiService,
encryptService,
i18nService,
kdfConfigService,
keyService,
masterPasswordApiService,
masterPasswordService,
organizationApiService,
organizationUserApiService,
userDecryptionOptionsService,
);
}
override async setInitialPassword(
credentials: SetInitialPasswordCredentials,
userType: SetInitialPasswordUserType,
userId: UserId,
) {
await super.setInitialPassword(credentials, userType, userId);
/**
* TODO: Investigate refactoring the following logic in https://bitwarden.atlassian.net/browse/PM-22615
* ---
* When a user has been invited to an org, they can be accepted into the org in two different ways:
*
* 1) By clicking the email invite link, which triggers the normal AcceptOrganizationComponent flow
* a. This flow sets an org invite in state
* b. However, if the user does not already have an account AND the org has SSO enabled AND the require
* SSO policy enabled, the AcceptOrganizationComponent will send the user to /sso to accelerate
* the user through the SSO JIT provisioning process (see #2 below)
*
* 2) By logging in via SSO, which triggers the JIT provisioning process
* a. This flow does NOT (itself) set an org invite in state
* b. The set initial password process on the server accepts the user into the org after successfully
* setting the password (see server - SetInitialMasterPasswordCommand.cs)
*
* If a user clicks the email link but gets accelerated through the SSO JIT process (see 1b),
* the SSO JIT process will accept the user into the org upon setting their initial password (see 2b),
* at which point we must remember to clear the deep linked URL used for accepting the org invite, as well
* as clear the org invite itself that was originally set in state by the AcceptOrganizationComponent.
*/
await this.routerService.getAndClearLoginRedirectUrl();
await this.organizationInviteService.clearOrganizationInvitation();
}
}

View File

@@ -9,19 +9,15 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service";
import { OrganizationInvite } from "@bitwarden/common/auth/services/organization-invite/organization-invite";
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { DEFAULT_KDF_CONFIG, KeyService } from "@bitwarden/key-management";
import { AcceptOrganizationInviteService } from "../../../organization-invite/accept-organization.service";
import { OrganizationInvite } from "../../../organization-invite/organization-invite";
import { WebRegistrationFinishService } from "./web-registration-finish.service";
describe("WebRegistrationFinishService", () => {
@@ -29,30 +25,26 @@ describe("WebRegistrationFinishService", () => {
let keyService: MockProxy<KeyService>;
let accountApiService: MockProxy<AccountApiService>;
let acceptOrgInviteService: MockProxy<AcceptOrganizationInviteService>;
let organizationInviteService: MockProxy<OrganizationInviteService>;
let policyApiService: MockProxy<PolicyApiServiceAbstraction>;
let logService: MockProxy<LogService>;
let policyService: MockProxy<PolicyService>;
const mockUserId = Utils.newGuid() as UserId;
let accountService: FakeAccountService;
beforeEach(() => {
keyService = mock<KeyService>();
accountApiService = mock<AccountApiService>();
acceptOrgInviteService = mock<AcceptOrganizationInviteService>();
organizationInviteService = mock<OrganizationInviteService>();
policyApiService = mock<PolicyApiServiceAbstraction>();
logService = mock<LogService>();
policyService = mock<PolicyService>();
accountService = mockAccountServiceWith(mockUserId);
service = new WebRegistrationFinishService(
keyService,
accountApiService,
acceptOrgInviteService,
organizationInviteService,
policyApiService,
logService,
policyService,
accountService,
);
});
@@ -72,21 +64,21 @@ describe("WebRegistrationFinishService", () => {
});
it("returns null when the org invite is null", async () => {
acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(null);
organizationInviteService.getOrganizationInvite.mockResolvedValue(null);
const result = await service.getOrgNameFromOrgInvite();
expect(result).toBeNull();
expect(acceptOrgInviteService.getOrganizationInvite).toHaveBeenCalled();
expect(organizationInviteService.getOrganizationInvite).toHaveBeenCalled();
});
it("returns the organization name from the organization invite when it exists", async () => {
acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(orgInvite);
organizationInviteService.getOrganizationInvite.mockResolvedValue(orgInvite);
const result = await service.getOrgNameFromOrgInvite();
expect(result).toEqual(orgInvite.organizationName);
expect(acceptOrgInviteService.getOrganizationInvite).toHaveBeenCalled();
expect(organizationInviteService.getOrganizationInvite).toHaveBeenCalled();
});
});
@@ -102,22 +94,22 @@ describe("WebRegistrationFinishService", () => {
});
it("returns null when the org invite is null", async () => {
acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(null);
organizationInviteService.getOrganizationInvite.mockResolvedValue(null);
const result = await service.getMasterPasswordPolicyOptsFromOrgInvite();
expect(result).toBeNull();
expect(acceptOrgInviteService.getOrganizationInvite).toHaveBeenCalled();
expect(organizationInviteService.getOrganizationInvite).toHaveBeenCalled();
});
it("returns null when the policies are null", async () => {
acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(orgInvite);
organizationInviteService.getOrganizationInvite.mockResolvedValue(orgInvite);
policyApiService.getPoliciesByToken.mockResolvedValue(null);
const result = await service.getMasterPasswordPolicyOptsFromOrgInvite();
expect(result).toBeNull();
expect(acceptOrgInviteService.getOrganizationInvite).toHaveBeenCalled();
expect(organizationInviteService.getOrganizationInvite).toHaveBeenCalled();
expect(policyApiService.getPoliciesByToken).toHaveBeenCalledWith(
orgInvite.organizationId,
orgInvite.token,
@@ -127,13 +119,13 @@ describe("WebRegistrationFinishService", () => {
});
it("logs an error and returns null when policies cannot be fetched", async () => {
acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(orgInvite);
organizationInviteService.getOrganizationInvite.mockResolvedValue(orgInvite);
policyApiService.getPoliciesByToken.mockRejectedValue(new Error("error"));
const result = await service.getMasterPasswordPolicyOptsFromOrgInvite();
expect(result).toBeNull();
expect(acceptOrgInviteService.getOrganizationInvite).toHaveBeenCalled();
expect(organizationInviteService.getOrganizationInvite).toHaveBeenCalled();
expect(policyApiService.getPoliciesByToken).toHaveBeenCalledWith(
orgInvite.organizationId,
orgInvite.token,
@@ -147,14 +139,14 @@ describe("WebRegistrationFinishService", () => {
const masterPasswordPolicies = [new Policy()];
const masterPasswordPolicyOptions = new MasterPasswordPolicyOptions();
acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(orgInvite);
organizationInviteService.getOrganizationInvite.mockResolvedValue(orgInvite);
policyApiService.getPoliciesByToken.mockResolvedValue(masterPasswordPolicies);
policyService.masterPasswordPolicyOptions$.mockReturnValue(of(masterPasswordPolicyOptions));
const result = await service.getMasterPasswordPolicyOptsFromOrgInvite();
expect(result).toEqual(masterPasswordPolicyOptions);
expect(acceptOrgInviteService.getOrganizationInvite).toHaveBeenCalled();
expect(organizationInviteService.getOrganizationInvite).toHaveBeenCalled();
expect(policyApiService.getPoliciesByToken).toHaveBeenCalledWith(
orgInvite.organizationId,
orgInvite.token,
@@ -221,7 +213,7 @@ describe("WebRegistrationFinishService", () => {
keyService.makeUserKey.mockResolvedValue([userKey, userKeyEncString]);
keyService.makeKeyPair.mockResolvedValue(userKeyPair);
accountApiService.registerFinish.mockResolvedValue();
acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(null);
organizationInviteService.getOrganizationInvite.mockResolvedValue(null);
await service.finishRegistration(email, passwordInputResult, emailVerificationToken);
@@ -257,7 +249,7 @@ describe("WebRegistrationFinishService", () => {
keyService.makeUserKey.mockResolvedValue([userKey, userKeyEncString]);
keyService.makeKeyPair.mockResolvedValue(userKeyPair);
accountApiService.registerFinish.mockResolvedValue();
acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(orgInvite);
organizationInviteService.getOrganizationInvite.mockResolvedValue(orgInvite);
await service.finishRegistration(email, passwordInputResult);
@@ -293,7 +285,7 @@ describe("WebRegistrationFinishService", () => {
keyService.makeUserKey.mockResolvedValue([userKey, userKeyEncString]);
keyService.makeKeyPair.mockResolvedValue(userKeyPair);
accountApiService.registerFinish.mockResolvedValue();
acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(null);
organizationInviteService.getOrganizationInvite.mockResolvedValue(null);
await service.finishRegistration(
email,
@@ -334,7 +326,7 @@ describe("WebRegistrationFinishService", () => {
keyService.makeUserKey.mockResolvedValue([userKey, userKeyEncString]);
keyService.makeKeyPair.mockResolvedValue(userKeyPair);
accountApiService.registerFinish.mockResolvedValue();
acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(null);
organizationInviteService.getOrganizationInvite.mockResolvedValue(null);
await service.finishRegistration(
email,
@@ -377,7 +369,7 @@ describe("WebRegistrationFinishService", () => {
keyService.makeUserKey.mockResolvedValue([userKey, userKeyEncString]);
keyService.makeKeyPair.mockResolvedValue(userKeyPair);
accountApiService.registerFinish.mockResolvedValue();
acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(null);
organizationInviteService.getOrganizationInvite.mockResolvedValue(null);
await service.finishRegistration(
email,

View File

@@ -12,14 +12,15 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { RegisterFinishRequest } from "@bitwarden/common/auth/models/request/registration/register-finish.request";
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
import {
EncryptedString,
EncString,
} from "@bitwarden/common/key-management/crypto/models/enc-string";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { EncryptedString, EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { KeyService } from "@bitwarden/key-management";
import { AcceptOrganizationInviteService } from "../../../organization-invite/accept-organization.service";
export class WebRegistrationFinishService
extends DefaultRegistrationFinishService
implements RegistrationFinishService
@@ -27,17 +28,16 @@ export class WebRegistrationFinishService
constructor(
protected keyService: KeyService,
protected accountApiService: AccountApiService,
private acceptOrgInviteService: AcceptOrganizationInviteService,
private organizationInviteService: OrganizationInviteService,
private policyApiService: PolicyApiServiceAbstraction,
private logService: LogService,
private policyService: PolicyService,
private accountService: AccountService,
) {
super(keyService, accountApiService);
}
override async getOrgNameFromOrgInvite(): Promise<string | null> {
const orgInvite = await this.acceptOrgInviteService.getOrganizationInvite();
const orgInvite = await this.organizationInviteService.getOrganizationInvite();
if (orgInvite == null) {
return null;
}
@@ -47,7 +47,7 @@ export class WebRegistrationFinishService
override async getMasterPasswordPolicyOptsFromOrgInvite(): Promise<MasterPasswordPolicyOptions | null> {
// If there's a deep linked org invite, use it to get the password policies
const orgInvite = await this.acceptOrgInviteService.getOrganizationInvite();
const orgInvite = await this.organizationInviteService.getOrganizationInvite();
if (orgInvite == null) {
return null;
@@ -100,7 +100,7 @@ export class WebRegistrationFinishService
// web specific logic
// Org invites are deep linked. Non-existent accounts are redirected to the register page.
// Org user id and token are included here only for validation and two factor purposes.
const orgInvite = await this.acceptOrgInviteService.getOrganizationInvite();
const orgInvite = await this.organizationInviteService.getOrganizationInvite();
if (orgInvite != null) {
registerRequest.organizationUserId = orgInvite.organizationUserId;
registerRequest.orgInviteToken = orgInvite.token;

Some files were not shown because too many files have changed in this diff Show More