1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-24 12:06:15 +01:00

[AC-1192] Create new device approvals component for TDE (#5548)

* Add feature flag route guard and tests

* Add additional test for not showing error toast

* Strengthen error toast test with message check

* Cleanup leaking test state in platformService mock

* Negate if statement to reduce nesting

* Update return type to CanActivateFn

* Use null check instead of undefined

* Introduce interface to support different feature flag types

- Switch to observable pattern to access serverConfig$ subject
- Add catchError handler to allow navigation in case of unexpected exception
- Add additional tests

* Add additional test for missing feature flag

* Remove subscription to the serverConfig observable

Introduce type checking logic to determine the appropriately typed flag getter to use in configService

* [AC-1192] Create initial device approvals component and route

* [AC-1192] Introduce appIfFeature directive for conditionally rendering content based on feature flags

* [AC-1192] Add DeviceApprovals link in Settings navigation

* Remove align middle from bitCell directive

The bitRow directive supports alignment for the entire row and should be used instead

* [AC-1192] Add initial device approvals page template

* [AC-1192] Introduce fingerprint pipe

* [AC-1192] Create core organization module in bitwarden_license directory

* [AC-1192] Add support for new Devices icon to no items component

- Add new Devices svg
- Make icon property of bit-no-items an Input property

* [AC-1192] Introduce organization-auth-request.service.ts with related views/responses

* [AC-1192] Display pending requests on device approvals page

- Add support for loading spinner and no items component

* [AC-1192] Add method to bulk deny auth requests

* [AC-1192] Add functionality to deny requests from device approvals page

* [AC-1192] Add organizationUserId to pending-auth-request.view.ts

* [AC-1192] Add approvePendingRequest method to organization-auth-request.service.ts

* [AC-1192] Add logic to approve a device approval request

* [AC-1192] Change bitMenuItem directive into a component and implement ButtonLikeAbstraction

Update the bitMenuItem to be a component and implement the ButtonLikeAbstraction to support the bitAction directive.

* [AC-1192] Update menu items to use bitActions

* [AC-1192] Update device approvals description copy

* [AC-1192] Revert changes to bitMenuItem directive

* [AC-1192] Rework menus to use click handlers

- Wrap async actions to catch/log any exceptions, set an in-progress state, and refresh after completion
- Show a loading spinner in the header when an action is in progress
- Disable all menu items when an action is in progress

* [AC-1192] Move Devices icon into admin-console web directory

* [AC-1192] bit-no-items formatting

* [AC-1192] Update appIfFeature directive to hide content on error

* [AC-1192] Remove deprecated providedIn for OrganizationAuthRequestService

* [AC-1192] Rename key to encryptedUserKey to be more descriptive

* [AC-1192] Cleanup loading/spinner logic on data refresh

* [AC-1192] Set middle as the default bitRow.alignContent

* [AC-1192] Change default alignRowContent for table story

* [AC-1192] Rename userId to fingerprintMaterial to be more general

The fingerprint material is not always the userId so this name is more general

* [AC-1192] Remove redundant alignContent attribute

* [AC-1192] Move fingerprint pipe to platform
This commit is contained in:
Shane Melton 2023-06-15 14:53:21 -07:00 committed by GitHub
parent bec51c95f9
commit 0afbd90a2d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 746 additions and 9 deletions

View File

@ -0,0 +1,17 @@
import { svgIcon } from "@bitwarden/components";
export const Devices = svgIcon`
<svg width="201" height="201" viewBox="0 0 201 201" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity=".49">
<path class="tw-fill-secondary-500" fill-rule="evenodd" clip-rule="evenodd" d="M34.3628 82.0889H10.3628C7.04908 82.0889 4.36279 84.7752 4.36279 88.0889V148.089C4.36279 151.403 7.04909 154.089 10.3628 154.089H34.3628C37.6765 154.089 40.3628 151.403 40.3628 148.089V88.0889C40.3628 84.7752 37.6765 82.0889 34.3628 82.0889ZM10.3628 78.0889C4.83995 78.0889 0.362793 82.566 0.362793 88.0889V148.089C0.362793 153.612 4.83995 158.089 10.3628 158.089H34.3628C39.8856 158.089 44.3628 153.612 44.3628 148.089V88.0889C44.3628 82.566 39.8856 78.0889 34.3628 78.0889H10.3628Z" />
<path class="tw-fill-secondary-500" fill-rule="evenodd" clip-rule="evenodd" d="M20.7329 86.8979C20.7329 86.3457 21.1806 85.8979 21.7329 85.8979H22.975C23.5273 85.8979 23.975 86.3457 23.975 86.8979C23.975 87.4502 23.5273 87.8979 22.975 87.8979H21.7329C21.1806 87.8979 20.7329 87.4502 20.7329 86.8979Z" />
<path class="tw-fill-secondary-500" fill-rule="evenodd" clip-rule="evenodd" d="M68.3628 159.089C68.3628 158.537 68.8105 158.089 69.3628 158.089H127.363C127.915 158.089 128.363 158.537 128.363 159.089C128.363 159.641 127.915 160.089 127.363 160.089H69.3628C68.8105 160.089 68.3628 159.641 68.3628 159.089Z" />
<path class="tw-fill-secondary-500" fill-rule="evenodd" clip-rule="evenodd" d="M88.103 159.089V141.325H90.103V159.089H88.103Z" />
<path class="tw-fill-secondary-500" fill-rule="evenodd" clip-rule="evenodd" d="M108.073 159.089V141.325H110.073V159.089H108.073Z" />
<path class="tw-fill-secondary-500" fill-rule="evenodd" clip-rule="evenodd" d="M27.3628 64.0889C27.3628 56.3569 33.6308 50.0889 41.3628 50.0889H157.363C165.095 50.0889 171.363 56.3569 171.363 64.0889V70.0889H167.363V64.0889C167.363 58.566 162.886 54.0889 157.363 54.0889H41.3628C35.8399 54.0889 31.3628 58.566 31.3628 64.0889V80.0889H27.3628V64.0889ZM42.3628 138.089H127.363V142.089H42.3628V138.089Z" />
<path class="tw-fill-secondary-500" fill-rule="evenodd" clip-rule="evenodd" d="M35.3628 65.0889C35.3628 61.2229 38.4968 58.0889 42.3628 58.0889H156.363C160.229 58.0889 163.363 61.2229 163.363 65.0889V70.0889H161.363V65.0889C161.363 62.3274 159.124 60.0889 156.363 60.0889H42.3628C39.6014 60.0889 37.3628 62.3274 37.3628 65.0889V80.0889H35.3628V65.0889ZM42.3628 132.089H127.363V134.089H42.3628V132.089Z" />
<path class="tw-fill-secondary-500" fill-rule="evenodd" clip-rule="evenodd" d="M125.363 78.0889C125.363 72.566 129.84 68.0889 135.363 68.0889H188.363C193.886 68.0889 198.363 72.566 198.363 78.0889V158.089C198.363 163.612 193.886 168.089 188.363 168.089H135.363C129.84 168.089 125.363 163.612 125.363 158.089V78.0889ZM135.363 72.0889C132.049 72.0889 129.363 74.7752 129.363 78.0889V158.089C129.363 161.403 132.049 164.089 135.363 164.089H188.363C191.676 164.089 194.363 161.403 194.363 158.089V78.0889C194.363 74.7752 191.677 72.0889 188.363 72.0889H135.363Z" />
<path class="tw-fill-secondary-500" d="M164.363 159.089C164.363 160.193 163.467 161.089 162.363 161.089C161.258 161.089 160.363 160.193 160.363 159.089C160.363 157.984 161.258 157.089 162.363 157.089C163.467 157.089 164.363 157.984 164.363 159.089Z" />
</g>
</svg>
`;

View File

@ -0,0 +1 @@
export * from "./devices";

View File

@ -60,6 +60,16 @@
>
{{ "singleSignOn" | i18n }}
</a>
<ng-container *appIfFeature="FeatureFlag.TrustedDeviceEncryption">
<a
routerLink="device-approvals"
class="list-group-item"
routerLinkActive="active"
*ngIf="org.canManageUsersPassword"
>
{{ "deviceApprovals" | i18n }}
</a>
</ng-container>
<a
routerLink="scim"
class="list-group-item"

View File

@ -4,6 +4,7 @@ import { Observable, switchMap } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
@Component({
selector: "app-org-settings",
@ -11,6 +12,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
})
export class SettingsComponent implements OnInit {
organization$: Observable<Organization>;
FeatureFlag = FeatureFlag;
constructor(private route: ActivatedRoute, private organizationService: OrganizationService) {}

View File

@ -6844,5 +6844,41 @@
},
"updatedTempPassword": {
"message": "User updated a password issued through account recovery."
},
"deviceApprovals": {
"message": "Device approvals"
},
"deviceApprovalsDesc": {
"message": "Approve login requests below to allow the requesting member to finish logging in. Unapproved requests expire after 1 week. Verify the members information before approving."
},
"deviceInfo": {
"message": "Device info"
},
"time": {
"message": "Time"
},
"denyAllRequests": {
"message": "Deny all requests"
},
"denyRequest": {
"message": "Deny request"
},
"approveRequest": {
"message": "Approve request"
},
"noDeviceRequests": {
"message": "No device requests"
},
"noDeviceRequestsDesc": {
"message": "Member device approval requests will appear here"
},
"loginRequestDenied": {
"message": "Login request denied"
},
"allLoginRequestsDenied": {
"message": "All login requests denied"
},
"loginRequestApproved": {
"message": "Login request approved"
}
}

View File

@ -0,0 +1,8 @@
import { NgModule } from "@angular/core";
import { OrganizationAuthRequestService } from "./services/auth-requests";
@NgModule({
providers: [OrganizationAuthRequestService],
})
export class CoreOrganizationModule {}

View File

@ -0,0 +1 @@
export * from "./core-organization.module";

View File

@ -0,0 +1,8 @@
export class AdminAuthRequestUpdateRequest {
/**
*
* @param requestApproved - Whether the request was approved/denied. If true, the key must be provided.
* @param encryptedUserKey The user's symmetric key that has been encrypted with a device public key if the request was approved.
*/
constructor(public requestApproved: boolean, public encryptedUserKey?: string) {}
}

View File

@ -0,0 +1,6 @@
export class BulkDenyAuthRequestsRequest {
private ids: string[];
constructor(authRequestIds: string[]) {
this.ids = authRequestIds;
}
}

View File

@ -0,0 +1,2 @@
export * from "./pending-organization-auth-request.response";
export * from "./organization-auth-request.service";

View File

@ -0,0 +1,54 @@
import { Injectable } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { PendingAuthRequestView } from "../../views/pending-auth-request.view";
import { AdminAuthRequestUpdateRequest } from "./admin-auth-request-update.request";
import { BulkDenyAuthRequestsRequest } from "./bulk-deny-auth-requests.request";
import { PendingOrganizationAuthRequestResponse } from "./pending-organization-auth-request.response";
@Injectable()
export class OrganizationAuthRequestService {
constructor(private apiService: ApiService) {}
async listPendingRequests(organizationId: string): Promise<PendingAuthRequestView[]> {
const r = await this.apiService.send(
"GET",
`/organizations/${organizationId}/auth-requests`,
null,
true,
true
);
const listResponse = new ListResponse(r, PendingOrganizationAuthRequestResponse);
return listResponse.data.map((ar) => PendingAuthRequestView.fromResponse(ar));
}
async denyPendingRequests(organizationId: string, ...requestIds: string[]): Promise<void> {
await this.apiService.send(
"POST",
`/organizations/${organizationId}/auth-requests/deny`,
new BulkDenyAuthRequestsRequest(requestIds),
true,
false
);
}
async approvePendingRequest(
organizationId: string,
requestId: string,
encryptedKey: EncString
): Promise<void> {
await this.apiService.send(
"POST",
`/organizations/${organizationId}/auth-requests/${requestId}`,
new AdminAuthRequestUpdateRequest(true, encryptedKey.encryptedString),
true,
false
);
}
}

View File

@ -0,0 +1,26 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
export class PendingOrganizationAuthRequestResponse extends BaseResponse {
id: string;
userId: string;
organizationUserId: string;
email: string;
publicKey: string;
requestDeviceIdentifier: string;
requestDeviceType: string;
requestIpAddress: string;
creationDate: string;
constructor(response: any) {
super(response);
this.id = this.getResponseProperty("Id");
this.userId = this.getResponseProperty("UserId");
this.organizationUserId = this.getResponseProperty("OrganizationUserId");
this.email = this.getResponseProperty("Email");
this.publicKey = this.getResponseProperty("PublicKey");
this.requestDeviceIdentifier = this.getResponseProperty("RequestDeviceIdentifier");
this.requestDeviceType = this.getResponseProperty("RequestDeviceType");
this.requestIpAddress = this.getResponseProperty("RequestIpAddress");
this.creationDate = this.getResponseProperty("CreationDate");
}
}

View File

@ -0,0 +1,23 @@
import { View } from "@bitwarden/common/models/view/view";
import { PendingOrganizationAuthRequestResponse } from "../services/auth-requests";
export class PendingAuthRequestView implements View {
id: string;
userId: string;
organizationUserId: string;
email: string;
publicKey: string;
requestDeviceIdentifier: string;
requestDeviceType: string;
requestIpAddress: string;
creationDate: Date;
static fromResponse(response: PendingOrganizationAuthRequestResponse): PendingAuthRequestView {
const view = Object.assign(new PendingAuthRequestView(), response) as PendingAuthRequestView;
view.creationDate = new Date(response.creationDate);
return view;
}
}

View File

@ -0,0 +1,100 @@
<h1>
{{ "deviceApprovals" | i18n }}
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
*ngIf="actionInProgress || loading"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</h1>
<p>
{{ "deviceApprovalsDesc" | i18n }}
</p>
<bit-table [dataSource]="tableDataSource">
<ng-container header>
<tr>
<th bitCell>{{ "member" | i18n }}</th>
<th bitCell>{{ "deviceInfo" | i18n }}</th>
<th bitCell>{{ "time" | i18n }}</th>
<th bitCell class="tw-w-10">
<button
[bitMenuTriggerFor]="headerMenu"
bitIconButton="bwi-ellipsis-v"
size="small"
type="button"
appA11yTitle="{{ 'options' | i18n }}"
></button>
<bit-menu #headerMenu>
<button
type="button"
bitMenuItem
(click)="denyAllRequests()"
[disabled]="actionInProgress"
>
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-close" aria-hidden="true"></i>
{{ "denyAllRequests" | i18n }}
</span>
</button>
</bit-menu>
</th>
</tr>
</ng-container>
<ng-template body let-rows$>
<tr bitRow alignContent="top" *ngFor="let r of rows$ | async">
<td bitCell class="tw-flex-col">
<div>{{ r.email }}</div>
<code class="tw-text-sm">{{ r.publicKey | fingerprint : r.email | async }}</code>
</td>
<td bitCell class="tw-flex-col">
<div>{{ r.requestDeviceType }}</div>
<div class="tw-text-sm">{{ r.requestIpAddress }}</div>
</td>
<td bitCell class="tw-flex-col tw-text-muted">
{{ r.creationDate | date : "medium" }}
</td>
<td bitCell class="tw-align-middle">
<button
[bitMenuTriggerFor]="rowMenu"
bitIconButton="bwi-ellipsis-v"
size="small"
type="button"
appA11yTitle="{{ 'options' | i18n }}"
></button>
<bit-menu #rowMenu>
<button
type="button"
bitMenuItem
(click)="approveRequest(r)"
[disabled]="actionInProgress"
>
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
{{ "approveRequest" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="denyRequest(r.id)"
[disabled]="actionInProgress"
>
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-close" aria-hidden="true"></i>
{{ "denyRequest" | i18n }}
</span>
</button>
</bit-menu>
</td>
</tr>
</ng-template>
</bit-table>
<bit-no-items
class="tw-text-main"
*ngIf="!loading && tableDataSource.data.length == 0"
[icon]="Devices"
>
<ng-container slot="title">{{ "noDeviceRequests" | i18n }}</ng-container>
<ng-container slot="description">{{ "noDeviceRequestsDesc" | i18n }}</ng-container>
</bit-no-items>

View File

@ -0,0 +1,174 @@
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { BehaviorSubject, Subject, switchMap, takeUntil, tap } from "rxjs";
import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service";
import { OrganizationUserResetPasswordDetailsResponse } from "@bitwarden/common/abstractions/organization-user/responses";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.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 { ValidationService } from "@bitwarden/common/platform/abstractions/validation.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 { TableDataSource } from "@bitwarden/components";
import { Devices } from "@bitwarden/web-vault/app/admin-console/icons";
import { OrganizationAuthRequestService } from "../../core/services/auth-requests";
import { PendingAuthRequestView } from "../../core/views/pending-auth-request.view";
@Component({
selector: "app-org-device-approvals",
templateUrl: "./device-approvals.component.html",
})
export class DeviceApprovalsComponent implements OnInit, OnDestroy {
tableDataSource = new TableDataSource<PendingAuthRequestView>();
organizationId: string;
loading = true;
actionInProgress = false;
protected readonly Devices = Devices;
private destroy$ = new Subject<void>();
private refresh$ = new BehaviorSubject<void>(null);
constructor(
private organizationAuthRequestService: OrganizationAuthRequestService,
private organizationUserService: OrganizationUserService,
private cryptoService: CryptoService,
private route: ActivatedRoute,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private logService: LogService,
private validationService: ValidationService
) {}
async ngOnInit() {
this.route.params
.pipe(
tap((params) => (this.organizationId = params.organizationId)),
switchMap(() =>
this.refresh$.pipe(
tap(() => (this.loading = true)),
switchMap(() =>
this.organizationAuthRequestService.listPendingRequests(this.organizationId)
)
)
),
takeUntil(this.destroy$)
)
.subscribe((r) => {
this.tableDataSource.data = r;
this.loading = false;
});
}
/**
* Creates a copy of the user's symmetric key that has been encrypted with the provided device's public key.
* @param devicePublicKey
* @param resetPasswordDetails
* @private
*/
private async getEncryptedUserSymKey(
devicePublicKey: string,
resetPasswordDetails: OrganizationUserResetPasswordDetailsResponse
): Promise<EncString> {
const encryptedUserSymKey = resetPasswordDetails.resetPasswordKey;
const encryptedOrgPrivateKey = resetPasswordDetails.encryptedPrivateKey;
const devicePubKey = Utils.fromB64ToArray(devicePublicKey);
// Decrypt Organization's encrypted Private Key with org key
const orgSymKey = await this.cryptoService.getOrgKey(this.organizationId);
const decOrgPrivateKey = await this.cryptoService.decryptToBytes(
new EncString(encryptedOrgPrivateKey),
orgSymKey
);
// Decrypt User's symmetric key with decrypted org private key
const decValue = await this.cryptoService.rsaDecrypt(encryptedUserSymKey, decOrgPrivateKey);
const userSymKey = new SymmetricCryptoKey(decValue);
// Re-encrypt User's Symmetric Key with the Device Public Key
return await this.cryptoService.rsaEncrypt(userSymKey.key, devicePubKey.buffer);
}
async approveRequest(authRequest: PendingAuthRequestView) {
await this.performAsyncAction(async () => {
const details = await this.organizationUserService.getOrganizationUserResetPasswordDetails(
this.organizationId,
authRequest.organizationUserId
);
// The user must be enrolled in account recovery (password reset) in order for the request to be approved.
if (details == null || details.resetPasswordKey == null) {
this.platformUtilsService.showToast(
"error",
null,
this.i18nService.t("resetPasswordDetailsError")
);
return;
}
const encryptedKey = await this.getEncryptedUserSymKey(authRequest.publicKey, details);
await this.organizationAuthRequestService.approvePendingRequest(
this.organizationId,
authRequest.id,
encryptedKey
);
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("loginRequestApproved")
);
});
}
async denyRequest(requestId: string) {
await this.performAsyncAction(async () => {
await this.organizationAuthRequestService.denyPendingRequests(this.organizationId, requestId);
this.platformUtilsService.showToast("error", null, this.i18nService.t("loginRequestDenied"));
});
}
async denyAllRequests() {
if (this.tableDataSource.data.length === 0) {
return;
}
await this.performAsyncAction(async () => {
await this.organizationAuthRequestService.denyPendingRequests(
this.organizationId,
...this.tableDataSource.data.map((r) => r.id)
);
this.platformUtilsService.showToast(
"error",
null,
this.i18nService.t("allLoginRequestsDenied")
);
});
}
private async performAsyncAction(action: () => Promise<void>) {
if (this.actionInProgress) {
return;
}
this.actionInProgress = true;
try {
await action();
this.refresh$.next();
} catch (err: unknown) {
this.logService.error(err.toString());
this.validationService.showError(err);
} finally {
this.actionInProgress = false;
}
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@ -2,14 +2,17 @@ import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { AuthGuard } from "@bitwarden/angular/auth/guards/auth.guard";
import { canAccessFeature } from "@bitwarden/angular/guard/feature-flag.guard";
import { canAccessSettingsTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { OrganizationPermissionsGuard } from "@bitwarden/web-vault/app/admin-console/organizations/guards/org-permissions.guard";
import { OrganizationLayoutComponent } from "@bitwarden/web-vault/app/admin-console/organizations/layouts/organization-layout.component";
import { SettingsComponent } from "@bitwarden/web-vault/app/admin-console/organizations/settings/settings.component";
import { SsoComponent } from "../../auth/sso/sso.component";
import { DeviceApprovalsComponent } from "./manage/device-approvals/device-approvals.component";
import { DomainVerificationComponent } from "./manage/domain-verification/domain-verification.component";
import { ScimComponent } from "./manage/scim.component";
@ -51,6 +54,18 @@ const routes: Routes = [
organizationPermissions: (org: Organization) => org.canManageScim,
},
},
{
path: "device-approvals",
component: DeviceApprovalsComponent,
canActivate: [
OrganizationPermissionsGuard,
canAccessFeature(FeatureFlag.TrustedDeviceEncryption),
],
data: {
organizationPermissions: (org: Organization) => org.canManageUsersPassword,
titleId: "deviceApprovals",
},
},
],
},
],

View File

@ -1,21 +1,25 @@
import { NgModule } from "@angular/core";
import { NoItemsModule } from "@bitwarden/components";
import { SharedModule } from "@bitwarden/web-vault/app/shared/shared.module";
import { SsoComponent } from "../../auth/sso/sso.component";
import { CoreOrganizationModule } from "./core";
import { DeviceApprovalsComponent } from "./manage/device-approvals/device-approvals.component";
import { DomainAddEditDialogComponent } from "./manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component";
import { DomainVerificationComponent } from "./manage/domain-verification/domain-verification.component";
import { ScimComponent } from "./manage/scim.component";
import { OrganizationsRoutingModule } from "./organizations-routing.module";
@NgModule({
imports: [SharedModule, OrganizationsRoutingModule],
imports: [SharedModule, CoreOrganizationModule, OrganizationsRoutingModule, NoItemsModule],
declarations: [
SsoComponent,
ScimComponent,
DomainVerificationComponent,
DomainAddEditDialogComponent,
DeviceApprovalsComponent,
],
})
export class OrganizationsModule {}

View File

@ -0,0 +1,137 @@
import { Component } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { mock, MockProxy } from "jest-mock-extended";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { IfFeatureDirective } from "./if-feature.directive";
const testBooleanFeature: FeatureFlag = "boolean-feature" as FeatureFlag;
const testStringFeature: FeatureFlag = "string-feature" as FeatureFlag;
const testStringFeatureValue = "test-value";
@Component({
template: `
<div *appIfFeature="testBooleanFeature">
<div data-testid="boolean-content">Hidden behind feature flag</div>
</div>
<div *appIfFeature="stringFeature; value: stringFeatureValue">
<div data-testid="string-content">Hidden behind feature flag</div>
</div>
<div *appIfFeature="missingFlag">
<div data-testid="missing-flag-content">
Hidden behind missing flag. Should not be visible.
</div>
</div>
`,
})
class TestComponent {
testBooleanFeature = testBooleanFeature;
stringFeature = testStringFeature;
stringFeatureValue = testStringFeatureValue;
missingFlag = "missing-flag" as FeatureFlag;
}
describe("IfFeatureDirective", () => {
let fixture: ComponentFixture<TestComponent>;
let content: HTMLElement;
let mockConfigService: MockProxy<ConfigServiceAbstraction>;
const mockConfigFlagValue = (flag: FeatureFlag, flagValue: any) => {
if (typeof flagValue === "boolean") {
mockConfigService.getFeatureFlagBool.mockImplementation((f, defaultValue = false) =>
flag == f ? Promise.resolve(flagValue) : Promise.resolve(defaultValue)
);
} else if (typeof flagValue === "string") {
mockConfigService.getFeatureFlagString.mockImplementation((f, defaultValue = "") =>
flag == f ? Promise.resolve(flagValue) : Promise.resolve(defaultValue)
);
} else if (typeof flagValue === "number") {
mockConfigService.getFeatureFlagNumber.mockImplementation((f, defaultValue = 0) =>
flag == f ? Promise.resolve(flagValue) : Promise.resolve(defaultValue)
);
}
};
const queryContent = (testId: string) =>
fixture.debugElement.query(By.css(`[data-testid="${testId}"]`))?.nativeElement;
beforeEach(async () => {
mockConfigService = mock<ConfigServiceAbstraction>();
await TestBed.configureTestingModule({
declarations: [IfFeatureDirective, TestComponent],
providers: [
{ provide: LogService, useValue: mock<LogService>() },
{
provide: ConfigServiceAbstraction,
useValue: mockConfigService,
},
],
}).compileComponents();
fixture = TestBed.createComponent(TestComponent);
});
it("renders content when the feature flag is enabled", async () => {
mockConfigFlagValue(testBooleanFeature, true);
fixture.detectChanges();
await fixture.whenStable();
content = queryContent("boolean-content");
expect(content).toBeDefined();
});
it("renders content when the feature flag value matches the provided value", async () => {
mockConfigFlagValue(testStringFeature, testStringFeatureValue);
fixture.detectChanges();
await fixture.whenStable();
content = queryContent("string-content");
expect(content).toBeDefined();
});
it("hides content when the feature flag is disabled", async () => {
mockConfigFlagValue(testBooleanFeature, false);
fixture.detectChanges();
await fixture.whenStable();
content = queryContent("boolean-content");
expect(content).toBeUndefined();
});
it("hides content when the feature flag value does not match the provided value", async () => {
mockConfigFlagValue(testStringFeature, "wrong-value");
fixture.detectChanges();
await fixture.whenStable();
content = queryContent("string-content");
expect(content).toBeUndefined();
});
it("hides content when the feature flag is missing", async () => {
fixture.detectChanges();
await fixture.whenStable();
content = queryContent("missing-flag-content");
expect(content).toBeUndefined();
});
it("hides content when the directive throws an unexpected exception", async () => {
mockConfigService.getFeatureFlagBool.mockImplementation(() => Promise.reject("Some error"));
fixture.detectChanges();
await fixture.whenStable();
content = queryContent("boolean-content");
expect(content).toBeUndefined();
});
});

View File

@ -0,0 +1,67 @@
import { Directive, Input, OnInit, TemplateRef, ViewContainerRef } from "@angular/core";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
// Replace this with a type safe lookup of the feature flag values in PM-2282
type FlagValue = boolean | number | string;
/**
* Directive that conditionally renders the element when the feature flag is enabled and/or
* matches the value specified by {@link appIfFeatureValue}.
*
* When a feature flag is not found in the config service, the element is hidden.
*/
@Directive({
selector: "[appIfFeature]",
})
export class IfFeatureDirective implements OnInit {
/**
* The feature flag to check.
*/
@Input() appIfFeature: FeatureFlag;
/**
* Optional value to compare against the value of the feature flag in the config service.
* @default true
*/
@Input() appIfFeatureValue: FlagValue = true;
private hasView = false;
constructor(
private templateRef: TemplateRef<any>,
private viewContainer: ViewContainerRef,
private configService: ConfigServiceAbstraction,
private logService: LogService
) {}
async ngOnInit() {
try {
let flagValue: FlagValue;
if (typeof this.appIfFeatureValue === "boolean") {
flagValue = await this.configService.getFeatureFlagBool(this.appIfFeature);
} else if (typeof this.appIfFeatureValue === "number") {
flagValue = await this.configService.getFeatureFlagNumber(this.appIfFeature);
} else if (typeof this.appIfFeatureValue === "string") {
flagValue = await this.configService.getFeatureFlagString(this.appIfFeature);
}
if (this.appIfFeatureValue === flagValue) {
if (!this.hasView) {
this.viewContainer.createEmbeddedView(this.templateRef);
this.hasView = true;
}
} else {
this.viewContainer.clear();
this.hasView = false;
}
} catch (e) {
this.logService.error(e);
this.viewContainer.clear();
this.hasView = false;
}
}
}

View File

@ -11,6 +11,7 @@ import { AutofocusDirective } from "./directives/autofocus.directive";
import { BoxRowDirective } from "./directives/box-row.directive";
import { CopyClickDirective } from "./directives/copy-click.directive";
import { FallbackSrcDirective } from "./directives/fallback-src.directive";
import { IfFeatureDirective } from "./directives/if-feature.directive";
import { InputStripSpacesDirective } from "./directives/input-strip-spaces.directive";
import { InputVerbatimDirective } from "./directives/input-verbatim.directive";
import { LaunchClickDirective } from "./directives/launch-click.directive";
@ -25,6 +26,7 @@ import { SearchPipe } from "./pipes/search.pipe";
import { UserNamePipe } from "./pipes/user-name.pipe";
import { UserTypePipe } from "./pipes/user-type.pipe";
import { EllipsisPipe } from "./platform/pipes/ellipsis.pipe";
import { FingerprintPipe } from "./platform/pipes/fingerprint.pipe";
import { I18nPipe } from "./platform/pipes/i18n.pipe";
import { PasswordStrengthComponent } from "./shared/components/password-strength/password-strength.component";
import { ExportScopeCalloutComponent } from "./tools/export/components/export-scope-callout.component";
@ -68,6 +70,8 @@ import { IconComponent } from "./vault/components/icon.component";
UserNamePipe,
PasswordStrengthComponent,
UserTypePipe,
IfFeatureDirective,
FingerprintPipe,
],
exports: [
A11yInvalidDirective,
@ -97,7 +101,17 @@ import { IconComponent } from "./vault/components/icon.component";
UserNamePipe,
PasswordStrengthComponent,
UserTypePipe,
IfFeatureDirective,
FingerprintPipe,
],
providers: [
CreditCardNumberPipe,
DatePipe,
I18nPipe,
SearchPipe,
UserNamePipe,
UserTypePipe,
FingerprintPipe,
],
providers: [CreditCardNumberPipe, DatePipe, I18nPipe, SearchPipe, UserNamePipe, UserTypePipe],
})
export class JslibModule {}

View File

@ -0,0 +1,32 @@
import { Pipe } from "@angular/core";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@Pipe({
name: "fingerprint",
})
export class FingerprintPipe {
constructor(private cryptoService: CryptoService) {}
async transform(publicKey: string | Uint8Array, fingerprintMaterial: string): Promise<string> {
try {
if (typeof publicKey === "string") {
publicKey = Utils.fromB64ToArray(publicKey);
}
const fingerprint = await this.cryptoService.getFingerprint(
fingerprintMaterial,
publicKey.buffer
);
if (fingerprint != null) {
return fingerprint.join("-");
}
return "";
} catch {
return "";
}
}
}

View File

@ -1,4 +1,4 @@
import { Component } from "@angular/core";
import { Component, Input } from "@angular/core";
import { Icons } from "..";
@ -10,5 +10,5 @@ import { Icons } from "..";
templateUrl: "./no-items.component.html",
})
export class NoItemsComponent {
protected icon = Icons.Search;
@Input() icon = Icons.Search;
}

View File

@ -1,10 +1,10 @@
import { HostBinding, Directive } from "@angular/core";
import { Directive, HostBinding } from "@angular/core";
@Directive({
selector: "th[bitCell], td[bitCell]",
})
export class CellDirective {
@HostBinding("class") get classList() {
return ["tw-p-3", "tw-align-middle"];
return ["tw-p-3"];
}
}

View File

@ -4,7 +4,7 @@ import { Directive, HostBinding, Input } from "@angular/core";
selector: "tr[bitRow]",
})
export class RowDirective {
@Input() alignContent: "top" | "middle" | "bottom" | "baseline" = "baseline";
@Input() alignContent: "top" | "middle" | "bottom" | "baseline" = "middle";
get alignmentClass(): string {
switch (this.alignContent) {

View File

@ -1,5 +1,5 @@
import { ScrollingModule } from "@angular/cdk/scrolling";
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { countries } from "../form/countries";
@ -62,7 +62,7 @@ export const Default: Story = {
`,
}),
args: {
alignRowContent: "baseline",
alignRowContent: "middle",
},
};