Browse Source
* [EC-8] Restructure Tabs (#3109) * Cherry pick pending PR for tabs component [CL-17] Tabs - Routing * Update organization tabs from 4 to 6 * Create initial 'Members' tab * Create initial 'Groups' tab * Add initial "Reporting" tab * Use correct report label/layout by product type * Create initial 'Billing' tab * Breakup billing payment and billing history pages * Cleanup org routing and nav permission service * More org tab permission cleanup * Refactor organization billing to use a module * Refactor organization reporting to use module * Cherry pick finished/merged tabs component [CL-17] Tabs - Router (#2952) * This partially reverts commitpull/3950/head24bb775to fix tracking of people.component.html rename. * Fix people component file rename * Recover lost member page changes * Undo members component rename as it was causing difficult merge conflicts * Fix member and group page container * Remove unnecessary organization lookup * [EC-8] Some PR suggestions * [EC-8] Reuse user billing history for orgs * [EC-8] Renamed user billing history component * [EC-8] Repurpose payment method component Update end user payment method component to be usable for organizations. * [EC-8] Fix missing verify bank condition * [EC-8] Remove org payment method component * [EC-8] Use CL in payment method component * [EC-8] Extend maxWidth Tailwind theme config * [EC-8] Add lazy loading to org reports * [EC-8] Add lazy loading to org billing * [EC-8] Prettier * [EC-8] Cleanup org reporting component redundancy * [EC-8] Use different class for negative margin * [EC-8] Make billing history component "dumb" * Revert "[EC-8] Cleanup org reporting component redundancy" This reverts commiteca337e89b. * [EC-8] Create and export shared reports module * [EC-8] Use shared reports module in orgs * [EC-8] Use takeUntil pattern * [EC-8] Move org reporting module out of old modules folder * [EC-8] Move org billing module out of old modules folder * [EC-8] Fix some remaining merge conflicts * [EC-8] Move maxWidth into 'extend' key for Tailwind config * [EC-8] Remove unused module * [EC-8] Rename org report list component * Prettier Co-authored-by: Vincent Salucci <vincesalucci21@gmail.com> * [EC-451] Org Admin Refresh Permissions Refactor (#3320) * [EC-451] Update new org permissions for new tabs * [EC-451] Remove redudant route guards * [EC-451] Remove canAccessManageTab() * [EC-451] Use canAccess* callbacks in org routing module * Fix org api service refactor and linting after pulling in master * Fix broken org people and group pages after merge * [EC-18] Reporting side nav direction (#3420) * [EC-18] Re-order side nav for org reports according to Figma * [EC-18] Fix rxjs linter errors and redundant org flag * [EC-526] Default to Event Logs page for Reporting Tab (#3470) * [EC-526] Default to the Events Logs page when navigating to the Reporting tab * [EC-526] Undo default routing redirect when the child path is missing. Avoids defaulting to "/events" in case a user/org doesn't have access to event logs. * [EC-19] Update Organization Settings Page (#3251) * [EC-19] Refactor existing organization settings components to its own module * [EC-19] Move SSO page to settings tab * [EC-19] Move Policies page to Settings tab Refactor Policy components into its own module * [EC-19] Move ImageSubscriptionHiddenComponent * [EC-19] Lazy load org settings module * [EC-19] Add SSO Id to SSO config view * [EC-19] Remove SSO identfier from org info page * [EC-19] Update org settings/policies to follow ADR-0011 * [EC-19] Update two-step login setup description * [EC-19] Revert nested policy components folder * [EC-19] Revert nested org setting components folder * [EC-19] Remove left over image component * [EC-19] Prettier * [EC-19] Fix missing i18n * [EC-19] Update SSO form to use CL * [EC-19] Remove unused SSO input components * [EC-19] Fix bad SSO locale identifier * [EC-19] Fix import order linting * [EC-19] Add explicit whitespace check for launch click directive * [EC-19] Add restricted import paths to eslint config * [EC-19] Tag deprecated field with Jira issue to cleanup in future release * [EC-19] Remove out of date comment * [EC-19] Move policy components to policies module * [EC-19] Remove dityRequired validator * [EC-19] Use explicit type for SSO config form * [EC-19] Fix rxjs linter errors * [EC-19] Fix RxJS eslint comments in org settings component * [EC-19] Use explicit ControlsOf<T> helper for nested SSO form groups. * [EC-19] Attribute source of ControlsOf<T> helper * [EC-19] Fix missing settings side nav links * [EC-19] Fix member/user language for policy modals * [EC-551] Update Event Logs Client Column (#3572) * [EC-551] Fix RxJS warnings * [EC-551] Update page to use CL components and Tailwind classes * [EC-551] Update Client column to use text instead of icon. Update language and i18n. * [EC-14] Refactor vault filter (#3440) * [EC-14] initial refactoring of vault filter * [EC-14] return observable trees for all filters with head node * [EC-14] Remove bindings on callbacks * [EC-14] fix formatting on disabled orgs * [EC-14] hide MyVault if personal org policy * [EC-14] add check for single org policy * [EC-14] add policies to org and change node constructor * [EC-14] don't show options if personal vault policy * [EC-14] default to all vaults * [EC-14] add default selection to filters * [EC-14] finish filter model callbacks * [EC-14] finish filter functionality and begin cleaning up * [EC-14] clean up old components and start on org vault * [EC-14] loop through filters for presentation * [EC-14] refactor VaultFilterService and put filter presentation data back into Vault Filter component. Remove VaultService * [EC-14] begin refactoring org vault * [EC-14] Refactor Vault Filter Service to use observables * [EC-14] finish org vault filter * [EC-14] fix vault model tests * [EC-14] fix org service calls * [EC-14] pull refactor out of shared code * [EC-14] include head node for collections even if collections aren't loaded yet * [EC-14] fix url params for vaults * [EC-14] remove comments * [EC-14] Remove unnecesary getter for org on vault filter * [EC-14] fix linter * [EC-14] fix prettier * [EC-14] add deprecated methods to collection service for desktop and browser * [EC-14] simplify cipher type node check * [EC-14] add getters to vault filter model * [EC-14] refactor how we build the filter list into methods * [EC-14] add getters to build filter method * [EC-14] remove param ids if false * [EC-14] fix collapsing nodes * [EC-14] add specific type to search placeholder * [EC-14] remove extra constructor and comment from org vault filter * [EC-14] extract subscription callback to methods * [EC-14] Remove unecessary await * [EC-14] Remove ternary operators while building org filter * [EC-14] remove unnecessary deps array in vault filter service declaration * [EC-14] consolidate new models into one file * [EC-14] initialize nested observable inside of service Signed-off-by: Jacob Fink <jfink@bitwarden.com> * [EC-14] change how we load orgs into the vault filter and select the default filter * [EC-14] remove get from getters name * [EC-14] remove eslint-disable comment * [EC-14] move vault filter service abstraction to angular folder and separate * [EC-14] rename filter types and delete VaultFilterLabel * [EC-14] remove changes to workspace file * [EC-14] remove deprecated service from jslib module * [EC-14] remove any remaining files from common code * [EC-14] consolidate vault filter components into components folder * [EC-14] simplify method call * [EC-14] refactor the vault filter service - orgs now have observable property - BehaviorSubjects have been migrated to ReplaySubjects if they don't need starting value - added unit tests - fix small error when selecting org badge of personal vault - renamed some properties * [EC-14] replace mergeMap with switchMap in vault filter service * [EC-14] early return to prevent nesting * [EC-14] clean up filterCollections method * [EC-14] use isDeleted helper in html * [EC-14] add jsdoc comments to ServiceUtils * [EC-14] fix linter * [EC-14] use array.slice instead of setting length * Update apps/web/src/app/vault/vault-filter/services/vault-filter.service.ts Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com> * [EC-14] add missing high level jsdoc description * [EC-14] fix storybook absolute imports * [EC-14] delete vault-shared.module * [EC-14] change search placeholder text to getter and add missing strings * [EC-14] remove two way binding from search text in vault filter * [EC-14] removed all binding from search text and just use input event * [EC-14] remove async from apply vault filter * [EC-14] remove circular observable calls in vault filter service Co-authored-by: Thomas Rittson <eliykat@users.noreply.github.com> * [EC-14] move collapsed nodes to vault filter section * [EC-14] deconstruct filter section inside component * [EC-14] fix merge conflicts and introduce refactored organization service to vault filter service * [EC-14] remove mutation from filter builders * [EC-14] fix styling on buildFolderTree * [EC-14] remove leftover folder-filters reference and use ternary for collapse icon * [EC-14] remove unecessary checks * [EC-14] stop rebuilding filters when the organization changes * [EC-14] Move subscription out of setter in vault filter section * [EC-14] remove extra policy service methods from vault filter service * [EC-14] remove new methods from old vault-filter.service * [EC-14] Use vault filter service in vault components * [EC-14] reload collections from vault now that we have vault filter service * [EC-14] remove currentFilterCollections in vault filter component * [EC-14] change VaultFilterType to more specific OrganizationFilter in organization-options * [EC-14] include org check in isNodeSelected * [EC-14] add getters to filter function, fix storybook, and add test for All Collections * [EC-14] show org options even if there's a personal vault policy * [EC-14] use !"AllCollections" instead of just !null * [EC-14] Remove extra org Subject in vault filter service * [EC-14] remove null check from vault search text * [EC-14] replace store/build names with set/get. Remove extra call to setOrganizationFilter * [EC-14] add take(1) to subscribe in test * [EC-14] move init logic in org vault filter component to ngOnInit * [EC-14] Fix linter * [EC-14] revert change to vault filter model * [EC-14] be specific about ignoring All Collections * [EC-14] move observable init logic to beforeEach in test * [EC-14] make buildAllFilters return something to reduce side effects Signed-off-by: Jacob Fink <jfink@bitwarden.com> Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Co-authored-by: Thomas Rittson <eliykat@users.noreply.github.com> * [EC-97] Organization Billing Language / RxJS Warnings (#3688) * [EC-97] Update copy to use the word members in a few places * [EC-97] Cleanup RxJS warnings and unused properties in org billing components * [EC-599] Access Selector Component (#3717) * Add Access Selector Component and Stories * Cherry pick FormSelectionList * Fix some problems caused from cherry-pick * Fix some Web module problems caused from cherry-pick * Move AccessSelector out of the root components directory. Move UserType pipe to AccessSelectorModule * Fix broken member access selector story * Add organization feature module * Undo changes to messages.json * Fix messages.json * Remove redundant CommonModule * [EC-599] Fix avatar/icon sizing * [EC-599] Remove padding in permission column * [EC-599] Make FormSelectionList operations immutable * [EC-599] Integrate the multi-select component * [EC-599] Handle readonly/access all edge cases * [EC-599] Add initial unit tests Also cleans up public interface for the AccessSelectorComponent. Fixes a bug found during unit test creation. * [EC-599] Include item name in control labels * [EC-599] Cleanup member email display * [EC-599] Review suggestions - Change PermissionMode to Enum - Rename permControl to permissionControl to be more clear - Rename FormSelectionList file to kebab case. - Move permission row boolean logic to named function for readability * [EC-599] Cleanup AccessSelectorComponent tests - Clarify test states - Add tests for column rendering - Add tests for permission mode - Add id to column headers for testing - Fix small permissionControl bug found during testing * [EC-599] Add FormSelectionList unit tests * [EC-599] Fix unit test and linter * [EC-599] Update Enums to Pascal case * [EC-599] Undo change to Enum values * [EC-7] fix: broken build * [EC-646] Org Admin Vault Refresh November Release Prep (#3913) * [EC-646] Remove links from Manage component These links are no longer necessary as they are now located in the new OAVR tabs. * [EC-646] Re-introduce the canAccessManageTab helper * [EC-646] Re-introduce /manage route in Organization routing module - Add the parent /manage route - Add child routes for collections, people, and groups * [EC-646] Adjust Org admin tabs Re-introduce the Manage tab and remove Groups and Members tabs. * [EC-646] Change Members title back to People * [EC-646] Move missing billing components Some billing components were in the org settings module and needed to be moved the org billing module * [EC-646] Fix import file upload button -Update to use click event handler and tailwind class to hide input. Avoids inline styles/js blocked by CSP - Fix broken async pipe * [EC-646] Fix groups and people page overflow Remove the container and page-content wrapper as the pages are no longer on their own tab * [EC-646] Change People to Members Change the text regarding managing members from People to Members to more closely follow changes coming later in the OAVR. Also update the URL to use /manage/members * [EC-646] Cherry-pickae39afeto fix tab text color * [EC-646] Fix org routing permissions helpers - Add canAccessVaultTab helper - Update canAccessOrgAdmin include check for vault tab access - Simplify canManageCollections * [EC-646] Fix Manage tab conditional logic - Add *ngIf condition for rendering Manage tab - Re-introduce dynamic route for Manage tab * Revert "[EC-14] Refactor vault filter (#3440)" (#3926) This reverts commit4d83b81d82. * Remove old reference to bit-submit-button that no longer exists (#3927) * [EC-593] Top align event logs row content (#3813) * [EC-593] Top align event log row contents * [EC-593] Prevent event log timestamp from wrapping * [EC-593] Add alignContent input to bitRow directive * [EC-593] Remove ineffective inline styles (CSP) * [EC-593] Remove templated tailwind classes Tailwind minimizes the bundled stylesheet by removing classes that aren't used in code. Using a string template for the classes causes those classes to be ignored. * [EC-593] Introduce alignContent input to table story * [EC-657] Hide Billing History and Payment Method for selfhosted orgs (#3935) Signed-off-by: Jacob Fink <jfink@bitwarden.com> Co-authored-by: Vincent Salucci <vincesalucci21@gmail.com> Co-authored-by: Andreas Coroiu <andreas.coroiu@gmail.com> Co-authored-by: Jake Fink <jfink@bitwarden.com> Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Co-authored-by: Thomas Rittson <eliykat@users.noreply.github.com>
123 changed files with 3510 additions and 1594 deletions
@ -0,0 +1,27 @@ |
|||||||
|
<div class="d-flex page-header"> |
||||||
|
<h1> |
||||||
|
{{ "billingHistory" | i18n }} |
||||||
|
</h1> |
||||||
|
<button |
||||||
|
bitButton |
||||||
|
buttonType="secondary" |
||||||
|
(click)="load()" |
||||||
|
class="tw-ml-auto" |
||||||
|
*ngIf="firstLoaded" |
||||||
|
[disabled]="loading" |
||||||
|
> |
||||||
|
<i class="bwi bwi-refresh bwi-fw" [ngClass]="{ 'bwi-spin': loading }" aria-hidden="true"></i> |
||||||
|
{{ "refresh" | i18n }} |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
<ng-container *ngIf="!firstLoaded && loading"> |
||||||
|
<i |
||||||
|
class="bwi bwi-spinner bwi-spin text-muted" |
||||||
|
title="{{ 'loading' | i18n }}" |
||||||
|
aria-hidden="true" |
||||||
|
></i> |
||||||
|
<span class="sr-only">{{ "loading" | i18n }}</span> |
||||||
|
</ng-container> |
||||||
|
<ng-container *ngIf="billing"> |
||||||
|
<app-billing-history [billing]="billing"></app-billing-history> |
||||||
|
</ng-container> |
||||||
@ -0,0 +1,51 @@ |
|||||||
|
import { Component, OnDestroy, OnInit } from "@angular/core"; |
||||||
|
import { ActivatedRoute } from "@angular/router"; |
||||||
|
import { concatMap, Subject, takeUntil } from "rxjs"; |
||||||
|
|
||||||
|
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/abstractions/organization/organization-api.service.abstraction"; |
||||||
|
import { BillingHistoryResponse } from "@bitwarden/common/models/response/billing-history.response"; |
||||||
|
|
||||||
|
@Component({ |
||||||
|
selector: "app-org-billing-history-view", |
||||||
|
templateUrl: "organization-billing-history-view.component.html", |
||||||
|
}) |
||||||
|
export class OrgBillingHistoryViewComponent implements OnInit, OnDestroy { |
||||||
|
loading = false; |
||||||
|
firstLoaded = false; |
||||||
|
billing: BillingHistoryResponse; |
||||||
|
organizationId: string; |
||||||
|
|
||||||
|
private destroy$ = new Subject<void>(); |
||||||
|
|
||||||
|
constructor( |
||||||
|
private organizationApiService: OrganizationApiServiceAbstraction, |
||||||
|
private route: ActivatedRoute |
||||||
|
) {} |
||||||
|
|
||||||
|
async ngOnInit() { |
||||||
|
this.route.params |
||||||
|
.pipe( |
||||||
|
concatMap(async (params) => { |
||||||
|
this.organizationId = params.organizationId; |
||||||
|
await this.load(); |
||||||
|
this.firstLoaded = true; |
||||||
|
}), |
||||||
|
takeUntil(this.destroy$) |
||||||
|
) |
||||||
|
.subscribe(); |
||||||
|
} |
||||||
|
|
||||||
|
ngOnDestroy() { |
||||||
|
this.destroy$.next(); |
||||||
|
this.destroy$.complete(); |
||||||
|
} |
||||||
|
|
||||||
|
async load() { |
||||||
|
if (this.loading) { |
||||||
|
return; |
||||||
|
} |
||||||
|
this.loading = true; |
||||||
|
this.billing = await this.organizationApiService.getBilling(this.organizationId); |
||||||
|
this.loading = false; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,48 @@ |
|||||||
|
import { NgModule } from "@angular/core"; |
||||||
|
import { RouterModule, Routes } from "@angular/router"; |
||||||
|
|
||||||
|
import { canAccessBillingTab } from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; |
||||||
|
|
||||||
|
import { PaymentMethodComponent } from "../../settings/payment-method.component"; |
||||||
|
import { OrganizationPermissionsGuard } from "../guards/org-permissions.guard"; |
||||||
|
|
||||||
|
import { OrgBillingHistoryViewComponent } from "./organization-billing-history-view.component"; |
||||||
|
import { OrganizationBillingTabComponent } from "./organization-billing-tab.component"; |
||||||
|
import { OrganizationSubscriptionComponent } from "./organization-subscription.component"; |
||||||
|
|
||||||
|
const routes: Routes = [ |
||||||
|
{ |
||||||
|
path: "", |
||||||
|
component: OrganizationBillingTabComponent, |
||||||
|
canActivate: [OrganizationPermissionsGuard], |
||||||
|
data: { organizationPermissions: canAccessBillingTab }, |
||||||
|
children: [ |
||||||
|
{ path: "", pathMatch: "full", redirectTo: "subscription" }, |
||||||
|
{ |
||||||
|
path: "subscription", |
||||||
|
component: OrganizationSubscriptionComponent, |
||||||
|
data: { titleId: "subscription" }, |
||||||
|
}, |
||||||
|
{ |
||||||
|
path: "payment-method", |
||||||
|
component: PaymentMethodComponent, |
||||||
|
data: { |
||||||
|
titleId: "paymentMethod", |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
path: "history", |
||||||
|
component: OrgBillingHistoryViewComponent, |
||||||
|
data: { |
||||||
|
titleId: "billingHistory", |
||||||
|
}, |
||||||
|
}, |
||||||
|
], |
||||||
|
}, |
||||||
|
]; |
||||||
|
|
||||||
|
@NgModule({ |
||||||
|
imports: [RouterModule.forChild(routes)], |
||||||
|
exports: [RouterModule], |
||||||
|
}) |
||||||
|
export class OrganizationBillingRoutingModule {} |
||||||
@ -0,0 +1,33 @@ |
|||||||
|
<div class="container page-content"> |
||||||
|
<div class="row"> |
||||||
|
<div class="col-3"> |
||||||
|
<div class="card"> |
||||||
|
<div class="card-header">{{ "billing" | i18n }}</div> |
||||||
|
<div class="list-group list-group-flush"> |
||||||
|
<a routerLink="subscription" class="list-group-item" routerLinkActive="active"> |
||||||
|
{{ "subscription" | i18n }} |
||||||
|
</a> |
||||||
|
<a |
||||||
|
*ngIf="showPaymentAndHistory" |
||||||
|
routerLink="payment-method" |
||||||
|
class="list-group-item" |
||||||
|
routerLinkActive="active" |
||||||
|
> |
||||||
|
{{ "paymentMethod" | i18n }} |
||||||
|
</a> |
||||||
|
<a |
||||||
|
*ngIf="showPaymentAndHistory" |
||||||
|
routerLink="history" |
||||||
|
class="list-group-item" |
||||||
|
routerLinkActive="active" |
||||||
|
> |
||||||
|
{{ "billingHistory" | i18n }} |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div class="col-9"> |
||||||
|
<router-outlet></router-outlet> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
@ -0,0 +1,14 @@ |
|||||||
|
import { Component } from "@angular/core"; |
||||||
|
|
||||||
|
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; |
||||||
|
|
||||||
|
@Component({ |
||||||
|
selector: "app-org-billing-tab", |
||||||
|
templateUrl: "organization-billing-tab.component.html", |
||||||
|
}) |
||||||
|
export class OrganizationBillingTabComponent { |
||||||
|
showPaymentAndHistory: boolean; |
||||||
|
constructor(private platformUtilsService: PlatformUtilsService) { |
||||||
|
this.showPaymentAndHistory = !this.platformUtilsService.isSelfHost(); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,26 @@ |
|||||||
|
import { NgModule } from "@angular/core"; |
||||||
|
|
||||||
|
import { LooseComponentsModule, SharedModule } from "../../shared"; |
||||||
|
|
||||||
|
import { AdjustSubscription } from "./adjust-subscription.component"; |
||||||
|
import { BillingSyncApiKeyComponent } from "./billing-sync-api-key.component"; |
||||||
|
import { ChangePlanComponent } from "./change-plan.component"; |
||||||
|
import { DownloadLicenseComponent } from "./download-license.component"; |
||||||
|
import { OrgBillingHistoryViewComponent } from "./organization-billing-history-view.component"; |
||||||
|
import { OrganizationBillingRoutingModule } from "./organization-billing-routing.module"; |
||||||
|
import { OrganizationBillingTabComponent } from "./organization-billing-tab.component"; |
||||||
|
import { OrganizationSubscriptionComponent } from "./organization-subscription.component"; |
||||||
|
|
||||||
|
@NgModule({ |
||||||
|
imports: [SharedModule, LooseComponentsModule, OrganizationBillingRoutingModule], |
||||||
|
declarations: [ |
||||||
|
AdjustSubscription, |
||||||
|
BillingSyncApiKeyComponent, |
||||||
|
ChangePlanComponent, |
||||||
|
DownloadLicenseComponent, |
||||||
|
OrganizationBillingTabComponent, |
||||||
|
OrganizationSubscriptionComponent, |
||||||
|
OrgBillingHistoryViewComponent, |
||||||
|
], |
||||||
|
}) |
||||||
|
export class OrganizationBillingModule {} |
||||||
@ -0,0 +1,136 @@ |
|||||||
|
<div class="tw-flex"> |
||||||
|
<bit-form-field *ngIf="permissionMode == 'edit'"> |
||||||
|
<bit-label>{{ "permission" | i18n }}</bit-label> |
||||||
|
<select |
||||||
|
bitInput |
||||||
|
[disabled]="disabled" |
||||||
|
[(ngModel)]="initialPermission" |
||||||
|
[ngModelOptions]="{ standalone: true }" |
||||||
|
(blur)="handleBlur()" |
||||||
|
> |
||||||
|
<option *ngFor="let p of permissionList" [value]="p.perm"> |
||||||
|
{{ p.labelId | i18n }} |
||||||
|
</option> |
||||||
|
</select> |
||||||
|
</bit-form-field> |
||||||
|
|
||||||
|
<bit-form-field class="tw-ml-3 tw-flex-grow"> |
||||||
|
<bit-label>{{ selectorLabelText }}</bit-label> |
||||||
|
<bit-multi-select |
||||||
|
class="tw-w-full" |
||||||
|
[baseItems]="selectionList.deselectedItems" |
||||||
|
[removeSelectedItems]="true" |
||||||
|
[disabled]="disabled" |
||||||
|
(onItemsConfirmed)="selectItems($event)" |
||||||
|
(blur)="handleBlur()" |
||||||
|
></bit-multi-select> |
||||||
|
<bit-hint *ngIf="selectorHelpText">{{ selectorHelpText }}</bit-hint> |
||||||
|
</bit-form-field> |
||||||
|
</div> |
||||||
|
|
||||||
|
<bit-table [formGroup]="formGroup"> |
||||||
|
<ng-container header> |
||||||
|
<tr> |
||||||
|
<th bitCell>{{ columnHeader }}</th> |
||||||
|
<th bitCell id="permissionColHeading" *ngIf="permissionMode != 'hidden'"> |
||||||
|
{{ "permission" | i18n }} |
||||||
|
</th> |
||||||
|
<th bitCell id="roleColHeading" *ngIf="showMemberRoles">{{ "role" | i18n }}</th> |
||||||
|
<th bitCell id="groupColHeading" *ngIf="showGroupColumn">{{ "group" | i18n }}</th> |
||||||
|
<th bitCell style="width: 50px"></th> |
||||||
|
</tr> |
||||||
|
</ng-container> |
||||||
|
<ng-container body formArrayName="items"> |
||||||
|
<tr |
||||||
|
bitRow |
||||||
|
*ngFor="let item of selectionList.selectedItems; let i = index" |
||||||
|
[formGroupName]="i" |
||||||
|
[ngClass]="{ 'tw-text-muted': item.readonly }" |
||||||
|
> |
||||||
|
<td bitCell [ngSwitch]="item.type"> |
||||||
|
<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"> |
||||||
|
<div class="tw-text-sm"> |
||||||
|
{{ item.labelName }} |
||||||
|
<span *ngIf="item.status == 0" bitBadge badgeType="secondary"> |
||||||
|
{{ "invited" | i18n }} |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
<div class="tw-text-xs tw-text-muted" *ngIf="item.status != 0">{{ item.email }}</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="tw-flex tw-items-center tw-text-sm" *ngSwitchDefault> |
||||||
|
<i |
||||||
|
class="bwi tw-mr-3 tw-px-0.5 tw-text-2xl" |
||||||
|
[ngClass]="item.icon || itemIcon(item)" |
||||||
|
aria-hidden="true" |
||||||
|
></i> |
||||||
|
<span>{{ item.labelName }}</span> |
||||||
|
</div> |
||||||
|
</td> |
||||||
|
|
||||||
|
<td bitCell *ngIf="permissionMode != 'hidden'"> |
||||||
|
<ng-container *ngIf="canEditItemPermission(item); else readOnlyPerm"> |
||||||
|
<label class="sr-only" [for]="'permission' + i" |
||||||
|
>{{ item.labelName }} {{ "permission" | i18n }}</label |
||||||
|
> |
||||||
|
<select |
||||||
|
bitInput |
||||||
|
class="-tw-ml-1 tw-max-w-36 tw-overflow-ellipsis !tw-rounded tw-border-0 !tw-bg-transparent tw-pl-0 tw-font-bold" |
||||||
|
formControlName="permission" |
||||||
|
[id]="'permission' + i" |
||||||
|
(blur)="handleBlur()" |
||||||
|
> |
||||||
|
<option *ngFor="let p of permissionList" [value]="p.perm"> |
||||||
|
{{ p.labelId | i18n }} |
||||||
|
</option> |
||||||
|
</select> |
||||||
|
</ng-container> |
||||||
|
|
||||||
|
<ng-template #readOnlyPerm> |
||||||
|
<div |
||||||
|
*ngIf="item.accessAllItems" |
||||||
|
class="tw-max-w-36 tw-overflow-hidden tw-overflow-ellipsis tw-whitespace-nowrap tw-font-bold tw-text-muted" |
||||||
|
[appA11yTitle]="accessAllLabelId(item) | i18n" |
||||||
|
> |
||||||
|
{{ "canEdit" | i18n }} |
||||||
|
<i class="bwi bwi-filter tw-ml-1" aria-hidden="true"></i> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div |
||||||
|
*ngIf="item.readonly" |
||||||
|
class="tw-max-w-36 tw-overflow-hidden tw-overflow-ellipsis tw-whitespace-nowrap tw-font-bold tw-text-muted" |
||||||
|
[title]="permissionLabelId(item.readonlyPermission) | i18n" |
||||||
|
> |
||||||
|
{{ permissionLabelId(item.readonlyPermission) | i18n }} |
||||||
|
</div> |
||||||
|
</ng-template> |
||||||
|
</td> |
||||||
|
|
||||||
|
<td bitCell *ngIf="showMemberRoles"> |
||||||
|
{{ item.role | userType: "-" }} |
||||||
|
</td> |
||||||
|
|
||||||
|
<td bitCell *ngIf="showGroupColumn"> |
||||||
|
{{ item.viaGroupName ?? "-" }} |
||||||
|
</td> |
||||||
|
|
||||||
|
<td bitCell> |
||||||
|
<button |
||||||
|
*ngIf="!item.readonly" |
||||||
|
type="button" |
||||||
|
bitIconButton="bwi-close" |
||||||
|
buttonType="muted" |
||||||
|
appA11yTitle="{{ 'remove' | i18n }} {{ item.labelName }}" |
||||||
|
[disabled]="disabled" |
||||||
|
(click)="selectionList.deselectItem(item.id); handleBlur()" |
||||||
|
></button> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
<tr *ngIf="selectionList.selectedItems.length == 0"> |
||||||
|
<td bitCell>{{ emptySelectionText }}</td> |
||||||
|
</tr> |
||||||
|
</ng-container> |
||||||
|
</bit-table> |
||||||
@ -0,0 +1,250 @@ |
|||||||
|
import { ComponentFixture, TestBed } from "@angular/core/testing"; |
||||||
|
import { FormsModule, ReactiveFormsModule } from "@angular/forms"; |
||||||
|
|
||||||
|
import { JslibModule } from "@bitwarden/angular/jslib.module"; |
||||||
|
import { OrganizationUserStatusType } from "@bitwarden/common/enums/organizationUserStatusType"; |
||||||
|
import { OrganizationUserType } from "@bitwarden/common/enums/organizationUserType"; |
||||||
|
import { |
||||||
|
AvatarModule, |
||||||
|
BadgeModule, |
||||||
|
ButtonModule, |
||||||
|
FormFieldModule, |
||||||
|
IconButtonModule, |
||||||
|
TableModule, |
||||||
|
TabsModule, |
||||||
|
} from "@bitwarden/components"; |
||||||
|
import { SelectItemView } from "@bitwarden/components/src/multi-select/models/select-item-view"; |
||||||
|
|
||||||
|
import { PreloadedEnglishI18nModule } from "../../../tests/preloaded-english-i18n.module"; |
||||||
|
|
||||||
|
import { AccessSelectorComponent, PermissionMode } from "./access-selector.component"; |
||||||
|
import { AccessItemType, CollectionPermission } from "./access-selector.models"; |
||||||
|
import { UserTypePipe } from "./user-type.pipe"; |
||||||
|
|
||||||
|
/** |
||||||
|
* Helper class that makes it easier to test the AccessSelectorComponent by |
||||||
|
* exposing some protected methods/properties |
||||||
|
*/ |
||||||
|
class TestableAccessSelectorComponent extends AccessSelectorComponent { |
||||||
|
selectItems(items: SelectItemView[]) { |
||||||
|
super.selectItems(items); |
||||||
|
} |
||||||
|
deselectItem(id: string) { |
||||||
|
this.selectionList.deselectItem(id); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Helper used to simulate a user selecting a new permission for a table row |
||||||
|
* @param index - "Row" index |
||||||
|
* @param perm - The new permission value |
||||||
|
*/ |
||||||
|
changeSelectedItemPerm(index: number, perm: CollectionPermission) { |
||||||
|
this.selectionList.formArray.at(index).patchValue({ |
||||||
|
permission: perm, |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
describe("AccessSelectorComponent", () => { |
||||||
|
let component: TestableAccessSelectorComponent; |
||||||
|
let fixture: ComponentFixture<TestableAccessSelectorComponent>; |
||||||
|
|
||||||
|
beforeEach(() => { |
||||||
|
TestBed.configureTestingModule({ |
||||||
|
imports: [ |
||||||
|
ButtonModule, |
||||||
|
FormFieldModule, |
||||||
|
AvatarModule, |
||||||
|
BadgeModule, |
||||||
|
ReactiveFormsModule, |
||||||
|
FormsModule, |
||||||
|
TabsModule, |
||||||
|
TableModule, |
||||||
|
PreloadedEnglishI18nModule, |
||||||
|
JslibModule, |
||||||
|
IconButtonModule, |
||||||
|
], |
||||||
|
declarations: [TestableAccessSelectorComponent, UserTypePipe], |
||||||
|
providers: [], |
||||||
|
}).compileComponents(); |
||||||
|
}); |
||||||
|
|
||||||
|
beforeEach(() => { |
||||||
|
fixture = TestBed.createComponent(TestableAccessSelectorComponent); |
||||||
|
component = fixture.componentInstance; |
||||||
|
|
||||||
|
component.emptySelectionText = "Nothing selected"; |
||||||
|
|
||||||
|
fixture.detectChanges(); |
||||||
|
}); |
||||||
|
|
||||||
|
it("should create", () => { |
||||||
|
expect(component).toBeTruthy(); |
||||||
|
}); |
||||||
|
|
||||||
|
describe("item selection", () => { |
||||||
|
beforeEach(() => { |
||||||
|
component.items = [ |
||||||
|
{ |
||||||
|
id: "123", |
||||||
|
type: AccessItemType.Group, |
||||||
|
labelName: "Group 1", |
||||||
|
listName: "Group 1", |
||||||
|
}, |
||||||
|
]; |
||||||
|
fixture.detectChanges(); |
||||||
|
}); |
||||||
|
|
||||||
|
it("should show the empty row when nothing is selected", () => { |
||||||
|
const emptyTableCell = fixture.nativeElement.querySelector("tbody tr td"); |
||||||
|
expect(emptyTableCell?.textContent).toEqual("Nothing selected"); |
||||||
|
}); |
||||||
|
|
||||||
|
it("should show one row when one value is selected", () => { |
||||||
|
component.selectItems([{ id: "123" } as any]); |
||||||
|
fixture.detectChanges(); |
||||||
|
const firstColSpan = fixture.nativeElement.querySelector("tbody tr td span"); |
||||||
|
expect(firstColSpan.textContent).toEqual("Group 1"); |
||||||
|
}); |
||||||
|
|
||||||
|
it("should emit value change when a value is selected", () => { |
||||||
|
// Arrange
|
||||||
|
const mockChange = jest.fn(); |
||||||
|
component.registerOnChange(mockChange); |
||||||
|
component.permissionMode = PermissionMode.Edit; |
||||||
|
|
||||||
|
// Act
|
||||||
|
component.selectItems([{ id: "123" } as any]); |
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(mockChange.mock.calls.length).toEqual(1); |
||||||
|
expect(mockChange.mock.lastCall[0]).toHaveProperty("[0].id", "123"); |
||||||
|
}); |
||||||
|
|
||||||
|
it("should emit value change when a row is modified", () => { |
||||||
|
// Arrange
|
||||||
|
const mockChange = jest.fn(); |
||||||
|
component.permissionMode = PermissionMode.Edit; |
||||||
|
component.selectItems([{ id: "123" } as any]); |
||||||
|
component.registerOnChange(mockChange); // Register change listener after setup
|
||||||
|
|
||||||
|
// Act
|
||||||
|
component.changeSelectedItemPerm(0, CollectionPermission.Edit); |
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(mockChange.mock.calls.length).toEqual(1); |
||||||
|
expect(mockChange.mock.lastCall[0]).toHaveProperty("[0].id", "123"); |
||||||
|
expect(mockChange.mock.lastCall[0]).toHaveProperty( |
||||||
|
"[0].permission", |
||||||
|
CollectionPermission.Edit |
||||||
|
); |
||||||
|
}); |
||||||
|
|
||||||
|
it("should emit value change when a row is removed", () => { |
||||||
|
// Arrange
|
||||||
|
const mockChange = jest.fn(); |
||||||
|
component.permissionMode = PermissionMode.Edit; |
||||||
|
component.selectItems([{ id: "123" } as any]); |
||||||
|
component.registerOnChange(mockChange); // Register change listener after setup
|
||||||
|
|
||||||
|
// Act
|
||||||
|
component.deselectItem("123"); |
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(mockChange.mock.calls.length).toEqual(1); |
||||||
|
expect(mockChange.mock.lastCall[0].length).toEqual(0); |
||||||
|
}); |
||||||
|
|
||||||
|
it("should emit permission values when in edit mode", () => { |
||||||
|
// Arrange
|
||||||
|
const mockChange = jest.fn(); |
||||||
|
component.registerOnChange(mockChange); |
||||||
|
component.permissionMode = PermissionMode.Edit; |
||||||
|
|
||||||
|
// Act
|
||||||
|
component.selectItems([{ id: "123" } as any]); |
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(mockChange.mock.calls.length).toEqual(1); |
||||||
|
expect(mockChange.mock.lastCall[0]).toHaveProperty("[0].id", "123"); |
||||||
|
expect(mockChange.mock.lastCall[0]).toHaveProperty("[0].permission"); |
||||||
|
}); |
||||||
|
|
||||||
|
it("should not emit permission values when not in edit mode", () => { |
||||||
|
// Arrange
|
||||||
|
const mockChange = jest.fn(); |
||||||
|
component.registerOnChange(mockChange); |
||||||
|
component.permissionMode = PermissionMode.Hidden; |
||||||
|
|
||||||
|
// Act
|
||||||
|
component.selectItems([{ id: "123" } as any]); |
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(mockChange.mock.calls.length).toEqual(1); |
||||||
|
expect(mockChange.mock.lastCall[0]).toHaveProperty("[0].id", "123"); |
||||||
|
expect(mockChange.mock.lastCall[0]).not.toHaveProperty("[0].permission"); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe("column rendering", () => { |
||||||
|
beforeEach(() => { |
||||||
|
component.items = [ |
||||||
|
{ |
||||||
|
id: "g1", |
||||||
|
type: AccessItemType.Group, |
||||||
|
labelName: "Group 1", |
||||||
|
listName: "Group 1", |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: "m1", |
||||||
|
type: AccessItemType.Member, |
||||||
|
labelName: "Member 1", |
||||||
|
listName: "Member 1 (member1@email.com)", |
||||||
|
email: "member1@email.com", |
||||||
|
role: OrganizationUserType.Manager, |
||||||
|
status: OrganizationUserStatusType.Confirmed, |
||||||
|
}, |
||||||
|
]; |
||||||
|
fixture.detectChanges(); |
||||||
|
}); |
||||||
|
|
||||||
|
test.each([true, false])("should show the role column when enabled", (columnEnabled) => { |
||||||
|
// Act
|
||||||
|
component.showMemberRoles = columnEnabled; |
||||||
|
fixture.detectChanges(); |
||||||
|
|
||||||
|
// Assert
|
||||||
|
const colHeading = fixture.nativeElement.querySelector("#roleColHeading"); |
||||||
|
expect(!!colHeading).toEqual(columnEnabled); |
||||||
|
}); |
||||||
|
|
||||||
|
test.each([true, false])("should show the group column when enabled", (columnEnabled) => { |
||||||
|
// Act
|
||||||
|
component.showGroupColumn = columnEnabled; |
||||||
|
fixture.detectChanges(); |
||||||
|
|
||||||
|
// Assert
|
||||||
|
const colHeading = fixture.nativeElement.querySelector("#groupColHeading"); |
||||||
|
expect(!!colHeading).toEqual(columnEnabled); |
||||||
|
}); |
||||||
|
|
||||||
|
const permissionColumnCases = [ |
||||||
|
[PermissionMode.Hidden, false], |
||||||
|
[PermissionMode.Edit, true], |
||||||
|
[PermissionMode.Readonly, true], |
||||||
|
]; |
||||||
|
|
||||||
|
test.each(permissionColumnCases)( |
||||||
|
"should show the permission column when enabled", |
||||||
|
(mode: PermissionMode, shouldShowColumn) => { |
||||||
|
// Act
|
||||||
|
component.permissionMode = mode; |
||||||
|
fixture.detectChanges(); |
||||||
|
|
||||||
|
// Assert
|
||||||
|
const colHeading = fixture.nativeElement.querySelector("#permissionColHeading"); |
||||||
|
expect(!!colHeading).toEqual(shouldShowColumn); |
||||||
|
} |
||||||
|
); |
||||||
|
}); |
||||||
|
}); |
||||||
@ -0,0 +1,290 @@ |
|||||||
|
import { Component, forwardRef, Input, OnDestroy, OnInit } from "@angular/core"; |
||||||
|
import { ControlValueAccessor, FormBuilder, NG_VALUE_ACCESSOR } from "@angular/forms"; |
||||||
|
import { Subject, takeUntil } from "rxjs"; |
||||||
|
|
||||||
|
import { FormSelectionList } from "@bitwarden/angular/utils/form-selection-list"; |
||||||
|
import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; |
||||||
|
import { SelectItemView } from "@bitwarden/components/src/multi-select/models/select-item-view"; |
||||||
|
|
||||||
|
import { |
||||||
|
AccessItemType, |
||||||
|
AccessItemValue, |
||||||
|
AccessItemView, |
||||||
|
CollectionPermission, |
||||||
|
} from "./access-selector.models"; |
||||||
|
|
||||||
|
export enum PermissionMode { |
||||||
|
/** |
||||||
|
* No permission controls or column present. No permission values are emitted. |
||||||
|
*/ |
||||||
|
Hidden = "hidden", |
||||||
|
|
||||||
|
/** |
||||||
|
* No permission controls. Column rendered an if available on an item. No permission values are emitted |
||||||
|
*/ |
||||||
|
Readonly = "readonly", |
||||||
|
|
||||||
|
/** |
||||||
|
* Permission Controls and column present. Permission values are emitted. |
||||||
|
*/ |
||||||
|
Edit = "edit", |
||||||
|
} |
||||||
|
|
||||||
|
@Component({ |
||||||
|
selector: "bit-access-selector", |
||||||
|
templateUrl: "access-selector.component.html", |
||||||
|
providers: [ |
||||||
|
{ |
||||||
|
provide: NG_VALUE_ACCESSOR, |
||||||
|
useExisting: forwardRef(() => AccessSelectorComponent), |
||||||
|
multi: true, |
||||||
|
}, |
||||||
|
], |
||||||
|
}) |
||||||
|
export class AccessSelectorComponent implements ControlValueAccessor, OnInit, OnDestroy { |
||||||
|
private destroy$ = new Subject<void>(); |
||||||
|
private notifyOnChange: (v: unknown) => void; |
||||||
|
private notifyOnTouch: () => void; |
||||||
|
private pauseChangeNotification: boolean; |
||||||
|
|
||||||
|
/** |
||||||
|
* The internal selection list that tracks the value of this form control / component. |
||||||
|
* It's responsible for keeping items sorted and synced with the rendered form controls |
||||||
|
* @protected |
||||||
|
*/ |
||||||
|
protected selectionList = new FormSelectionList<AccessItemView, AccessItemValue>((item) => { |
||||||
|
const permissionControl = this.formBuilder.control(this.initialPermission); |
||||||
|
|
||||||
|
const fg = this.formBuilder.group({ |
||||||
|
id: item.id, |
||||||
|
type: item.type, |
||||||
|
permission: permissionControl, |
||||||
|
}); |
||||||
|
|
||||||
|
// Disable entire row form group if readonly
|
||||||
|
if (item.readonly) { |
||||||
|
fg.disable(); |
||||||
|
} |
||||||
|
|
||||||
|
// Disable permission control if accessAllItems is enabled
|
||||||
|
if (item.accessAllItems || this.permissionMode != PermissionMode.Edit) { |
||||||
|
permissionControl.disable(); |
||||||
|
} |
||||||
|
|
||||||
|
return fg; |
||||||
|
}, this._itemComparator.bind(this)); |
||||||
|
|
||||||
|
/** |
||||||
|
* Internal form group for this component. |
||||||
|
* @protected |
||||||
|
*/ |
||||||
|
protected formGroup = this.formBuilder.group({ |
||||||
|
items: this.selectionList.formArray, |
||||||
|
}); |
||||||
|
|
||||||
|
protected itemType = AccessItemType; |
||||||
|
protected permissionList = [ |
||||||
|
{ perm: CollectionPermission.View, labelId: "canView" }, |
||||||
|
{ perm: CollectionPermission.ViewExceptPass, labelId: "canViewExceptPass" }, |
||||||
|
{ perm: CollectionPermission.Edit, labelId: "canEdit" }, |
||||||
|
{ perm: CollectionPermission.EditExceptPass, labelId: "canEditExceptPass" }, |
||||||
|
]; |
||||||
|
protected initialPermission = CollectionPermission.View; |
||||||
|
|
||||||
|
disabled: boolean; |
||||||
|
|
||||||
|
/** |
||||||
|
* List of all selectable items that. Sorted internally. |
||||||
|
*/ |
||||||
|
@Input() |
||||||
|
get items(): AccessItemView[] { |
||||||
|
return this.selectionList.allItems; |
||||||
|
} |
||||||
|
|
||||||
|
set items(val: AccessItemView[]) { |
||||||
|
const selected = (this.selectionList.formArray.getRawValue() ?? []).concat( |
||||||
|
val.filter((m) => m.readonly) |
||||||
|
); |
||||||
|
this.selectionList.populateItems( |
||||||
|
val.map((m) => { |
||||||
|
m.icon = m.icon ?? this.itemIcon(m); // Ensure an icon is set
|
||||||
|
return m; |
||||||
|
}), |
||||||
|
selected |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Permission mode that controls if the permission form controls and column should be present. |
||||||
|
*/ |
||||||
|
@Input() |
||||||
|
get permissionMode(): PermissionMode { |
||||||
|
return this._permissionMode; |
||||||
|
} |
||||||
|
|
||||||
|
set permissionMode(value: PermissionMode) { |
||||||
|
this._permissionMode = value; |
||||||
|
// Toggle any internal permission controls
|
||||||
|
for (const control of this.selectionList.formArray.controls) { |
||||||
|
if (value == PermissionMode.Edit) { |
||||||
|
control.get("permission").enable(); |
||||||
|
} else { |
||||||
|
control.get("permission").disable(); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
private _permissionMode: PermissionMode = PermissionMode.Hidden; |
||||||
|
|
||||||
|
/** |
||||||
|
* Column header for the selected items table |
||||||
|
*/ |
||||||
|
@Input() columnHeader: string; |
||||||
|
|
||||||
|
/** |
||||||
|
* Label used for the ng selector |
||||||
|
*/ |
||||||
|
@Input() selectorLabelText: string; |
||||||
|
|
||||||
|
/** |
||||||
|
* Helper text displayed under the ng selector |
||||||
|
*/ |
||||||
|
@Input() selectorHelpText: string; |
||||||
|
|
||||||
|
/** |
||||||
|
* Text that is shown in the table when no items are selected |
||||||
|
*/ |
||||||
|
@Input() emptySelectionText: string; |
||||||
|
|
||||||
|
/** |
||||||
|
* Flag for if the member roles column should be present |
||||||
|
*/ |
||||||
|
@Input() showMemberRoles: boolean; |
||||||
|
|
||||||
|
/** |
||||||
|
* Flag for if the group column should be present |
||||||
|
*/ |
||||||
|
@Input() showGroupColumn: boolean; |
||||||
|
|
||||||
|
constructor( |
||||||
|
private readonly formBuilder: FormBuilder, |
||||||
|
private readonly i18nService: I18nService |
||||||
|
) {} |
||||||
|
|
||||||
|
/** Required for NG_VALUE_ACCESSOR */ |
||||||
|
registerOnChange(fn: any): void { |
||||||
|
this.notifyOnChange = fn; |
||||||
|
} |
||||||
|
|
||||||
|
/** Required for NG_VALUE_ACCESSOR */ |
||||||
|
registerOnTouched(fn: any): void { |
||||||
|
this.notifyOnTouch = fn; |
||||||
|
} |
||||||
|
|
||||||
|
/** Required for NG_VALUE_ACCESSOR */ |
||||||
|
setDisabledState(isDisabled: boolean): void { |
||||||
|
this.disabled = isDisabled; |
||||||
|
|
||||||
|
// Keep the internal FormGroup in sync
|
||||||
|
if (this.disabled) { |
||||||
|
this.formGroup.disable(); |
||||||
|
} else { |
||||||
|
this.formGroup.enable(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** Required for NG_VALUE_ACCESSOR */ |
||||||
|
writeValue(selectedItems: AccessItemValue[]): void { |
||||||
|
// Modifying the selection list, mistakenly fires valueChanges in the
|
||||||
|
// internal form array, so we need to know to pause external notification
|
||||||
|
this.pauseChangeNotification = true; |
||||||
|
|
||||||
|
// Always clear the internal selection list on a new value
|
||||||
|
this.selectionList.deselectAll(); |
||||||
|
|
||||||
|
// We need to also select any read only items to appear in the table
|
||||||
|
this.selectionList.selectItems(this.items.filter((m) => m.readonly).map((m) => m.id)); |
||||||
|
|
||||||
|
// If the new value is null, then we're done
|
||||||
|
if (selectedItems == null) { |
||||||
|
this.pauseChangeNotification = false; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Unable to handle other value types, throw
|
||||||
|
if (!Array.isArray(selectedItems)) { |
||||||
|
throw new Error("The access selector component only supports Array form values!"); |
||||||
|
} |
||||||
|
|
||||||
|
// Iterate and internally select each item
|
||||||
|
for (const value of selectedItems) { |
||||||
|
this.selectionList.selectItem(value.id, value); |
||||||
|
} |
||||||
|
|
||||||
|
this.pauseChangeNotification = false; |
||||||
|
} |
||||||
|
|
||||||
|
ngOnInit() { |
||||||
|
// Watch the internal formArray for changes and propagate them
|
||||||
|
this.selectionList.formArray.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((v) => { |
||||||
|
if (!this.notifyOnChange || this.pauseChangeNotification) { |
||||||
|
return; |
||||||
|
} |
||||||
|
this.notifyOnChange(v); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
ngOnDestroy() { |
||||||
|
this.destroy$.next(); |
||||||
|
this.destroy$.complete(); |
||||||
|
} |
||||||
|
|
||||||
|
protected handleBlur() { |
||||||
|
if (!this.notifyOnTouch) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
this.notifyOnTouch(); |
||||||
|
} |
||||||
|
|
||||||
|
protected selectItems(items: SelectItemView[]) { |
||||||
|
this.pauseChangeNotification = true; |
||||||
|
this.selectionList.selectItems(items.map((i) => i.id)); |
||||||
|
this.pauseChangeNotification = false; |
||||||
|
if (this.notifyOnChange != undefined) { |
||||||
|
this.notifyOnChange(this.selectionList.formArray.value); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
protected itemIcon(item: AccessItemView) { |
||||||
|
switch (item.type) { |
||||||
|
case AccessItemType.Collection: |
||||||
|
return "bwi-collection"; |
||||||
|
case AccessItemType.Group: |
||||||
|
return "bwi-users"; |
||||||
|
case AccessItemType.Member: |
||||||
|
return "bwi-user"; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
protected permissionLabelId(perm: CollectionPermission) { |
||||||
|
return this.permissionList.find((p) => p.perm == perm)?.labelId; |
||||||
|
} |
||||||
|
|
||||||
|
protected accessAllLabelId(item: AccessItemView) { |
||||||
|
return item.type == AccessItemType.Group ? "groupAccessAll" : "memberAccessAll"; |
||||||
|
} |
||||||
|
|
||||||
|
protected canEditItemPermission(item: AccessItemView) { |
||||||
|
return this.permissionMode == PermissionMode.Edit && !item.readonly && !item.accessAllItems; |
||||||
|
} |
||||||
|
|
||||||
|
private _itemComparator(a: AccessItemView, b: AccessItemView) { |
||||||
|
if (a.type != b.type) { |
||||||
|
return a.type - b.type; |
||||||
|
} |
||||||
|
return this.i18nService.collator.compare( |
||||||
|
a.listName + a.labelName + a.readonly, |
||||||
|
b.listName + b.labelName + b.readonly |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,107 @@ |
|||||||
|
import { OrganizationUserStatusType } from "@bitwarden/common/enums/organizationUserStatusType"; |
||||||
|
import { OrganizationUserType } from "@bitwarden/common/enums/organizationUserType"; |
||||||
|
import { SelectionReadOnlyRequest } from "@bitwarden/common/models/request/selection-read-only.request"; |
||||||
|
import { SelectionReadOnlyResponse } from "@bitwarden/common/models/response/selection-read-only.response"; |
||||||
|
import { SelectItemView } from "@bitwarden/components/src/multi-select/models/select-item-view"; |
||||||
|
|
||||||
|
/** |
||||||
|
* Permission options that replace/correspond with readOnly and hidePassword server fields. |
||||||
|
*/ |
||||||
|
export enum CollectionPermission { |
||||||
|
View = "view", |
||||||
|
ViewExceptPass = "viewExceptPass", |
||||||
|
Edit = "edit", |
||||||
|
EditExceptPass = "editExceptPass", |
||||||
|
} |
||||||
|
|
||||||
|
export enum AccessItemType { |
||||||
|
Collection, |
||||||
|
Group, |
||||||
|
Member, |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* A "generic" type that describes an item that can be selected from a |
||||||
|
* ng-select list and have its collection permission modified. |
||||||
|
* |
||||||
|
* Currently, it supports Collections, Groups, and Members. Members require some additional |
||||||
|
* details to render in the AccessSelectorComponent so their type is defined separately |
||||||
|
* and then joined back with the base type. |
||||||
|
* |
||||||
|
*/ |
||||||
|
export type AccessItemView = |
||||||
|
| SelectItemView & { |
||||||
|
/** |
||||||
|
* Flag that this group/member can access all items. |
||||||
|
* This will disable the permission editor for this item. |
||||||
|
*/ |
||||||
|
accessAllItems?: boolean; |
||||||
|
|
||||||
|
/** |
||||||
|
* Flag that this item cannot be modified. |
||||||
|
* This will disable the permission editor and will keep |
||||||
|
* the item always selected. |
||||||
|
*/ |
||||||
|
readonly?: boolean; |
||||||
|
|
||||||
|
/** |
||||||
|
* Optional permission that will be rendered for this |
||||||
|
* item if it set to readonly. |
||||||
|
*/ |
||||||
|
readonlyPermission?: CollectionPermission; |
||||||
|
} & ( |
||||||
|
| { |
||||||
|
type: AccessItemType.Collection; |
||||||
|
viaGroupName?: string; |
||||||
|
} |
||||||
|
| { |
||||||
|
type: AccessItemType.Group; |
||||||
|
} |
||||||
|
| { |
||||||
|
type: AccessItemType.Member; // Members have a few extra details required to display, so they're added here
|
||||||
|
email: string; |
||||||
|
role: OrganizationUserType; |
||||||
|
status: OrganizationUserStatusType; |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
/** |
||||||
|
* A type that is emitted as a value for the ngControl |
||||||
|
*/ |
||||||
|
export type AccessItemValue = { |
||||||
|
id: string; |
||||||
|
permission?: CollectionPermission; |
||||||
|
type: AccessItemType; |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* Converts the older SelectionReadOnly interface to one of the new CollectionPermission values |
||||||
|
* for the dropdown in the AccessSelectorComponent |
||||||
|
* @param value |
||||||
|
*/ |
||||||
|
export const convertToPermission = (value: SelectionReadOnlyResponse) => { |
||||||
|
if (value.readOnly) { |
||||||
|
return value.hidePasswords ? CollectionPermission.ViewExceptPass : CollectionPermission.View; |
||||||
|
} else { |
||||||
|
return value.hidePasswords ? CollectionPermission.EditExceptPass : CollectionPermission.Edit; |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* Converts an AccessItemValue back into a SelectionReadOnly class using the CollectionPermission |
||||||
|
* to determine the values for `readOnly` and `hidePassword` |
||||||
|
* @param value |
||||||
|
*/ |
||||||
|
export const convertToSelectionReadOnly = (value: AccessItemValue) => { |
||||||
|
return new SelectionReadOnlyRequest( |
||||||
|
value.id, |
||||||
|
readOnly(value.permission), |
||||||
|
hidePassword(value.permission) |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
const readOnly = (perm: CollectionPermission) => |
||||||
|
[CollectionPermission.View, CollectionPermission.ViewExceptPass].includes(perm); |
||||||
|
|
||||||
|
const hidePassword = (perm: CollectionPermission) => |
||||||
|
[CollectionPermission.ViewExceptPass, CollectionPermission.EditExceptPass].includes(perm); |
||||||
@ -0,0 +1,13 @@ |
|||||||
|
import { NgModule } from "@angular/core"; |
||||||
|
|
||||||
|
import { SharedModule } from "../../../shared"; |
||||||
|
|
||||||
|
import { AccessSelectorComponent } from "./access-selector.component"; |
||||||
|
import { UserTypePipe } from "./user-type.pipe"; |
||||||
|
|
||||||
|
@NgModule({ |
||||||
|
imports: [SharedModule], |
||||||
|
declarations: [AccessSelectorComponent, UserTypePipe], |
||||||
|
exports: [AccessSelectorComponent], |
||||||
|
}) |
||||||
|
export class AccessSelectorModule {} |
||||||
@ -0,0 +1,302 @@ |
|||||||
|
import { FormBuilder, FormsModule, ReactiveFormsModule } from "@angular/forms"; |
||||||
|
import { action } from "@storybook/addon-actions"; |
||||||
|
import { Meta, moduleMetadata, Story } from "@storybook/angular"; |
||||||
|
|
||||||
|
import { JslibModule } from "@bitwarden/angular/jslib.module"; |
||||||
|
import { OrganizationUserStatusType } from "@bitwarden/common/enums/organizationUserStatusType"; |
||||||
|
import { OrganizationUserType } from "@bitwarden/common/enums/organizationUserType"; |
||||||
|
import { |
||||||
|
AvatarModule, |
||||||
|
BadgeModule, |
||||||
|
ButtonModule, |
||||||
|
FormFieldModule, |
||||||
|
IconButtonModule, |
||||||
|
TableModule, |
||||||
|
TabsModule, |
||||||
|
} from "@bitwarden/components"; |
||||||
|
|
||||||
|
import { PreloadedEnglishI18nModule } from "../../../tests/preloaded-english-i18n.module"; |
||||||
|
|
||||||
|
import { AccessSelectorComponent } from "./access-selector.component"; |
||||||
|
import { AccessItemType, AccessItemView, CollectionPermission } from "./access-selector.models"; |
||||||
|
import { UserTypePipe } from "./user-type.pipe"; |
||||||
|
|
||||||
|
export default { |
||||||
|
title: "Web/Organizations/Access Selector", |
||||||
|
decorators: [ |
||||||
|
moduleMetadata({ |
||||||
|
declarations: [AccessSelectorComponent, UserTypePipe], |
||||||
|
imports: [ |
||||||
|
ButtonModule, |
||||||
|
FormFieldModule, |
||||||
|
AvatarModule, |
||||||
|
BadgeModule, |
||||||
|
ReactiveFormsModule, |
||||||
|
FormsModule, |
||||||
|
TabsModule, |
||||||
|
TableModule, |
||||||
|
PreloadedEnglishI18nModule, |
||||||
|
JslibModule, |
||||||
|
IconButtonModule, |
||||||
|
], |
||||||
|
providers: [], |
||||||
|
}), |
||||||
|
], |
||||||
|
parameters: {}, |
||||||
|
argTypes: { |
||||||
|
formObj: { table: { disable: true } }, |
||||||
|
}, |
||||||
|
} as Meta; |
||||||
|
|
||||||
|
const actionsData = { |
||||||
|
onValueChanged: action("onValueChanged"), |
||||||
|
onSubmit: action("onSubmit"), |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* Factory to help build semi-realistic looking items |
||||||
|
* @param n - The number of items to build |
||||||
|
* @param type - Which type to build |
||||||
|
*/ |
||||||
|
const itemsFactory = (n: number, type: AccessItemType) => { |
||||||
|
return [...Array(n)].map((_: unknown, id: number) => { |
||||||
|
const item: AccessItemView = { |
||||||
|
id: id.toString(), |
||||||
|
type: type, |
||||||
|
} as AccessItemView; |
||||||
|
|
||||||
|
switch (item.type) { |
||||||
|
case AccessItemType.Collection: |
||||||
|
item.labelName = item.listName = `Collection ${id}`; |
||||||
|
item.id = item.id + "c"; |
||||||
|
item.parentGrouping = "Collection Parent Group " + ((id % 2) + 1); |
||||||
|
break; |
||||||
|
case AccessItemType.Group: |
||||||
|
item.labelName = item.listName = `Group ${id}`; |
||||||
|
item.id = item.id + "g"; |
||||||
|
break; |
||||||
|
case AccessItemType.Member: |
||||||
|
item.id = item.id + "m"; |
||||||
|
item.email = `member${id}@email.com`; |
||||||
|
item.status = id % 3 == 0 ? 0 : 2; |
||||||
|
item.labelName = item.status == 2 ? `Member ${id}` : item.email; |
||||||
|
item.listName = item.status == 2 ? `${item.labelName} (${item.email})` : item.email; |
||||||
|
item.role = id % 5; |
||||||
|
break; |
||||||
|
} |
||||||
|
|
||||||
|
return item; |
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
const sampleMembers = itemsFactory(10, AccessItemType.Member); |
||||||
|
const sampleGroups = itemsFactory(6, AccessItemType.Group); |
||||||
|
|
||||||
|
const StandaloneAccessSelectorTemplate: Story<AccessSelectorComponent> = ( |
||||||
|
args: AccessSelectorComponent |
||||||
|
) => ({ |
||||||
|
props: { |
||||||
|
items: [], |
||||||
|
valueChanged: actionsData.onValueChanged, |
||||||
|
initialValue: [], |
||||||
|
...args, |
||||||
|
}, |
||||||
|
template: ` |
||||||
|
<bit-access-selector |
||||||
|
(ngModelChange)="valueChanged($event)" |
||||||
|
[ngModel]="initialValue" |
||||||
|
[items]="items" |
||||||
|
[disabled]="disabled" |
||||||
|
[columnHeader]="columnHeader" |
||||||
|
[showGroupColumn]="showGroupColumn" |
||||||
|
[selectorLabelText]="selectorLabelText" |
||||||
|
[selectorHelpText]="selectorHelpText" |
||||||
|
[emptySelectionText]="emptySelectionText" |
||||||
|
[permissionMode]="permissionMode" |
||||||
|
[showMemberRoles]="showMemberRoles" |
||||||
|
></bit-access-selector> |
||||||
|
`,
|
||||||
|
}); |
||||||
|
|
||||||
|
const memberCollectionAccessItems = itemsFactory(3, AccessItemType.Collection).concat([ |
||||||
|
{ |
||||||
|
id: "c1-group1", |
||||||
|
type: AccessItemType.Collection, |
||||||
|
labelName: "Collection 1", |
||||||
|
listName: "Collection 1", |
||||||
|
viaGroupName: "Group 1", |
||||||
|
readonlyPermission: CollectionPermission.View, |
||||||
|
readonly: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: "c1-group2", |
||||||
|
type: AccessItemType.Collection, |
||||||
|
labelName: "Collection 1", |
||||||
|
listName: "Collection 1", |
||||||
|
viaGroupName: "Group 2", |
||||||
|
readonlyPermission: CollectionPermission.ViewExceptPass, |
||||||
|
readonly: true, |
||||||
|
}, |
||||||
|
]); |
||||||
|
|
||||||
|
export const MemberCollectionAccess = StandaloneAccessSelectorTemplate.bind({}); |
||||||
|
MemberCollectionAccess.args = { |
||||||
|
permissionMode: "edit", |
||||||
|
showMemberRoles: false, |
||||||
|
showGroupColumn: true, |
||||||
|
columnHeader: "Collection", |
||||||
|
selectorLabelText: "Select Collections", |
||||||
|
selectorHelpText: "Some helper text describing what this does", |
||||||
|
emptySelectionText: "No collections added", |
||||||
|
disabled: false, |
||||||
|
initialValue: [], |
||||||
|
items: memberCollectionAccessItems, |
||||||
|
}; |
||||||
|
MemberCollectionAccess.story = { |
||||||
|
parameters: { |
||||||
|
docs: { |
||||||
|
storyDescription: ` |
||||||
|
Example of an access selector for modifying the collections a member has access to. |
||||||
|
Includes examples of a readonly group and member that cannot be edited. |
||||||
|
`,
|
||||||
|
}, |
||||||
|
}, |
||||||
|
}; |
||||||
|
|
||||||
|
export const MemberGroupAccess = StandaloneAccessSelectorTemplate.bind({}); |
||||||
|
MemberGroupAccess.args = { |
||||||
|
permissionMode: "readonly", |
||||||
|
showMemberRoles: false, |
||||||
|
columnHeader: "Groups", |
||||||
|
selectorLabelText: "Select Groups", |
||||||
|
selectorHelpText: "Some helper text describing what this does", |
||||||
|
emptySelectionText: "No groups added", |
||||||
|
disabled: false, |
||||||
|
initialValue: [{ id: "3g" }, { id: "0g" }], |
||||||
|
items: itemsFactory(4, AccessItemType.Group).concat([ |
||||||
|
{ |
||||||
|
id: "admin", |
||||||
|
type: AccessItemType.Group, |
||||||
|
listName: "Admin Group", |
||||||
|
labelName: "Admin Group", |
||||||
|
accessAllItems: true, |
||||||
|
}, |
||||||
|
]), |
||||||
|
}; |
||||||
|
MemberGroupAccess.story = { |
||||||
|
parameters: { |
||||||
|
docs: { |
||||||
|
storyDescription: ` |
||||||
|
Example of an access selector for selecting which groups an individual member belongs too. |
||||||
|
`,
|
||||||
|
}, |
||||||
|
}, |
||||||
|
}; |
||||||
|
|
||||||
|
export const GroupMembersAccess = StandaloneAccessSelectorTemplate.bind({}); |
||||||
|
GroupMembersAccess.args = { |
||||||
|
permissionMode: "hidden", |
||||||
|
showMemberRoles: true, |
||||||
|
columnHeader: "Members", |
||||||
|
selectorLabelText: "Select Members", |
||||||
|
selectorHelpText: "Some helper text describing what this does", |
||||||
|
emptySelectionText: "No members added", |
||||||
|
disabled: false, |
||||||
|
initialValue: [{ id: "2m" }, { id: "0m" }], |
||||||
|
items: sampleMembers, |
||||||
|
}; |
||||||
|
GroupMembersAccess.story = { |
||||||
|
parameters: { |
||||||
|
docs: { |
||||||
|
storyDescription: ` |
||||||
|
Example of an access selector for selecting which members belong to an specific group. |
||||||
|
`,
|
||||||
|
}, |
||||||
|
}, |
||||||
|
}; |
||||||
|
|
||||||
|
export const CollectionAccess = StandaloneAccessSelectorTemplate.bind({}); |
||||||
|
CollectionAccess.args = { |
||||||
|
permissionMode: "edit", |
||||||
|
showMemberRoles: false, |
||||||
|
columnHeader: "Groups/Members", |
||||||
|
selectorLabelText: "Select groups and members", |
||||||
|
selectorHelpText: |
||||||
|
"Permissions set for a member will replace permissions set by that member's group", |
||||||
|
emptySelectionText: "No members or groups added", |
||||||
|
disabled: false, |
||||||
|
initialValue: [ |
||||||
|
{ id: "3g", permission: CollectionPermission.EditExceptPass }, |
||||||
|
{ id: "0m", permission: CollectionPermission.View }, |
||||||
|
], |
||||||
|
items: sampleGroups.concat(sampleMembers).concat([ |
||||||
|
{ |
||||||
|
id: "admin-group", |
||||||
|
type: AccessItemType.Group, |
||||||
|
listName: "Admin Group", |
||||||
|
labelName: "Admin Group", |
||||||
|
accessAllItems: true, |
||||||
|
readonly: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: "admin-member", |
||||||
|
type: AccessItemType.Member, |
||||||
|
listName: "Admin Member (admin@email.com)", |
||||||
|
labelName: "Admin Member", |
||||||
|
status: OrganizationUserStatusType.Confirmed, |
||||||
|
role: OrganizationUserType.Admin, |
||||||
|
email: "admin@email.com", |
||||||
|
accessAllItems: true, |
||||||
|
readonly: true, |
||||||
|
}, |
||||||
|
]), |
||||||
|
}; |
||||||
|
GroupMembersAccess.story = { |
||||||
|
parameters: { |
||||||
|
docs: { |
||||||
|
storyDescription: ` |
||||||
|
Example of an access selector for selecting which members/groups have access to a specific collection. |
||||||
|
`,
|
||||||
|
}, |
||||||
|
}, |
||||||
|
}; |
||||||
|
|
||||||
|
const fb = new FormBuilder(); |
||||||
|
|
||||||
|
const ReactiveFormAccessSelectorTemplate: Story<AccessSelectorComponent> = ( |
||||||
|
args: AccessSelectorComponent |
||||||
|
) => ({ |
||||||
|
props: { |
||||||
|
items: [], |
||||||
|
onSubmit: actionsData.onSubmit, |
||||||
|
...args, |
||||||
|
}, |
||||||
|
template: ` |
||||||
|
<form [formGroup]="formObj" (ngSubmit)="onSubmit(formObj.controls.formItems.value)"> |
||||||
|
<bit-access-selector |
||||||
|
formControlName="formItems" |
||||||
|
[items]="items" |
||||||
|
[columnHeader]="columnHeader" |
||||||
|
[selectorLabelText]="selectorLabelText" |
||||||
|
[selectorHelpText]="selectorHelpText" |
||||||
|
[emptySelectionText]="emptySelectionText" |
||||||
|
[permissionMode]="permissionMode" |
||||||
|
[showMemberRoles]="showMemberRoles" |
||||||
|
></bit-access-selector> |
||||||
|
<button type="submit" bitButton buttonType="primary" class="tw-mt-5">Submit</button> |
||||||
|
</form> |
||||||
|
`,
|
||||||
|
}); |
||||||
|
|
||||||
|
export const ReactiveForm = ReactiveFormAccessSelectorTemplate.bind({}); |
||||||
|
ReactiveForm.args = { |
||||||
|
formObj: fb.group({ formItems: [[{ id: "1g" }]] }), |
||||||
|
permissionMode: "edit", |
||||||
|
showMemberRoles: false, |
||||||
|
columnHeader: "Groups/Members", |
||||||
|
selectorLabelText: "Select groups and members", |
||||||
|
selectorHelpText: |
||||||
|
"Permissions set for a member will replace permissions set by that member's group", |
||||||
|
emptySelectionText: "No members or groups added", |
||||||
|
items: sampleGroups.concat(sampleMembers), |
||||||
|
}; |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
export * from "./access-selector.component"; |
||||||
|
export * from "./access-selector.module"; |
||||||
|
export * from "./access-selector.models"; |
||||||
@ -0,0 +1,29 @@ |
|||||||
|
import { Pipe, PipeTransform } from "@angular/core"; |
||||||
|
|
||||||
|
import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; |
||||||
|
import { OrganizationUserType } from "@bitwarden/common/enums/organizationUserType"; |
||||||
|
|
||||||
|
@Pipe({ |
||||||
|
name: "userType", |
||||||
|
}) |
||||||
|
export class UserTypePipe implements PipeTransform { |
||||||
|
constructor(private i18nService: I18nService) {} |
||||||
|
|
||||||
|
transform(value?: OrganizationUserType, unknownText?: string): string { |
||||||
|
if (value == null) { |
||||||
|
return unknownText ?? this.i18nService.t("unknown"); |
||||||
|
} |
||||||
|
switch (value) { |
||||||
|
case OrganizationUserType.Owner: |
||||||
|
return this.i18nService.t("owner"); |
||||||
|
case OrganizationUserType.Admin: |
||||||
|
return this.i18nService.t("admin"); |
||||||
|
case OrganizationUserType.User: |
||||||
|
return this.i18nService.t("user"); |
||||||
|
case OrganizationUserType.Manager: |
||||||
|
return this.i18nService.t("manager"); |
||||||
|
case OrganizationUserType.Custom: |
||||||
|
return this.i18nService.t("custom"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -1,49 +1,32 @@ |
|||||||
<app-navbar></app-navbar> |
<app-navbar></app-navbar> |
||||||
<ng-container *ngIf="organization$ | async as organization"> |
<div class="org-nav !tw-h-32" *ngIf="organization$ | async as organization"> |
||||||
<div class="org-nav" *ngIf="organization"> |
<div class="container d-flex"> |
||||||
<div class="container d-flex"> |
<div class="d-flex flex-column"> |
||||||
<div class="d-flex flex-column"> |
<app-organization-switcher |
||||||
<app-organization-switcher |
class="my-auto pl-1" |
||||||
class="my-auto pl-1" |
[activeOrganization]="organization" |
||||||
[activeOrganization]="organization" |
></app-organization-switcher> |
||||||
></app-organization-switcher> |
<bit-tab-nav-bar class="-tw-mb-px"> |
||||||
<ul class="nav nav-tabs"> |
<bit-tab-link route="vault">{{ "vault" | i18n }}</bit-tab-link> |
||||||
<li class="nav-item"> |
<bit-tab-link *ngIf="canShowManageTab(organization)" [route]="getManageRoute(organization)"> |
||||||
<a class="nav-link" routerLink="vault" routerLinkActive="active"> |
{{ "manage" | i18n }} |
||||||
<i class="bwi bwi-lock" aria-hidden="true"></i> |
</bit-tab-link> |
||||||
{{ "vault" | i18n }} |
<bit-tab-link |
||||||
</a> |
*ngIf="canShowReportsTab(organization)" |
||||||
</li> |
[route]="getReportRoute(organization)" |
||||||
<li class="nav-item" *ngIf="canShowManageTab(organization)"> |
> |
||||||
<a |
{{ getReportTabLabel(organization) | i18n }} |
||||||
class="nav-link" |
</bit-tab-link> |
||||||
[routerLink]="getManageRoute(organization)" |
<bit-tab-link *ngIf="canShowBillingTab(organization)" route="billing">{{ |
||||||
routerLinkActive="active" |
"billing" | i18n |
||||||
> |
}}</bit-tab-link> |
||||||
<i class="bwi bwi-sliders" aria-hidden="true"></i> |
<bit-tab-link *ngIf="canShowSettingsTab(organization)" route="settings">{{ |
||||||
{{ "manage" | i18n }} |
"settings" | i18n |
||||||
</a> |
}}</bit-tab-link> |
||||||
</li> |
</bit-tab-nav-bar> |
||||||
<li class="nav-item" *ngIf="canShowToolsTab(organization)"> |
|
||||||
<a |
|
||||||
class="nav-link" |
|
||||||
[routerLink]="getToolsRoute(organization)" |
|
||||||
routerLinkActive="active" |
|
||||||
> |
|
||||||
<i class="bwi bwi-wrench" aria-hidden="true"></i> |
|
||||||
{{ "tools" | i18n }} |
|
||||||
</a> |
|
||||||
</li> |
|
||||||
<li class="nav-item" *ngIf="canShowSettingsTab(organization)"> |
|
||||||
<a class="nav-link" routerLink="settings" routerLinkActive="active"> |
|
||||||
<i class="bwi bwi-cogs" aria-hidden="true"></i> |
|
||||||
{{ "settings" | i18n }} |
|
||||||
</a> |
|
||||||
</li> |
|
||||||
</ul> |
|
||||||
</div> |
|
||||||
</div> |
</div> |
||||||
</div> |
</div> |
||||||
</ng-container> |
</div> |
||||||
|
|
||||||
<router-outlet></router-outlet> |
<router-outlet></router-outlet> |
||||||
<app-footer></app-footer> |
<app-footer></app-footer> |
||||||
|
|||||||
@ -0,0 +1,11 @@ |
|||||||
|
import { NgModule } from "@angular/core"; |
||||||
|
|
||||||
|
import { SharedModule } from "../shared"; |
||||||
|
|
||||||
|
import { AccessSelectorModule } from "./components/access-selector"; |
||||||
|
import { OrganizationsRoutingModule } from "./organization-routing.module"; |
||||||
|
|
||||||
|
@NgModule({ |
||||||
|
imports: [SharedModule, AccessSelectorModule, OrganizationsRoutingModule], |
||||||
|
}) |
||||||
|
export class OrganizationModule {} |
||||||
@ -0,0 +1,12 @@ |
|||||||
|
export * from "./policies.module"; |
||||||
|
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 { RequireSsoPolicy } from "./require-sso.component"; |
||||||
|
export { ResetPasswordPolicy } from "./reset-password.component"; |
||||||
|
export { SendOptionsPolicy } from "./send-options.component"; |
||||||
|
export { SingleOrgPolicy } from "./single-org.component"; |
||||||
|
export { TwoFactorAuthenticationPolicy } from "./two-factor-authentication.component"; |
||||||
|
export { PoliciesComponent } from "./policies.component"; |
||||||
@ -0,0 +1,46 @@ |
|||||||
|
import { NgModule } from "@angular/core"; |
||||||
|
|
||||||
|
import { LooseComponentsModule, SharedModule } from "../../shared"; |
||||||
|
|
||||||
|
import { DisableSendPolicyComponent } from "./disable-send.component"; |
||||||
|
import { MasterPasswordPolicyComponent } from "./master-password.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 { RequireSsoPolicyComponent } from "./require-sso.component"; |
||||||
|
import { ResetPasswordPolicyComponent } from "./reset-password.component"; |
||||||
|
import { SendOptionsPolicyComponent } from "./send-options.component"; |
||||||
|
import { SingleOrgPolicyComponent } from "./single-org.component"; |
||||||
|
import { TwoFactorAuthenticationPolicyComponent } from "./two-factor-authentication.component"; |
||||||
|
|
||||||
|
@NgModule({ |
||||||
|
imports: [SharedModule, LooseComponentsModule], |
||||||
|
declarations: [ |
||||||
|
DisableSendPolicyComponent, |
||||||
|
MasterPasswordPolicyComponent, |
||||||
|
PasswordGeneratorPolicyComponent, |
||||||
|
PersonalOwnershipPolicyComponent, |
||||||
|
RequireSsoPolicyComponent, |
||||||
|
ResetPasswordPolicyComponent, |
||||||
|
SendOptionsPolicyComponent, |
||||||
|
SingleOrgPolicyComponent, |
||||||
|
TwoFactorAuthenticationPolicyComponent, |
||||||
|
PoliciesComponent, |
||||||
|
PolicyEditComponent, |
||||||
|
], |
||||||
|
exports: [ |
||||||
|
DisableSendPolicyComponent, |
||||||
|
MasterPasswordPolicyComponent, |
||||||
|
PasswordGeneratorPolicyComponent, |
||||||
|
PersonalOwnershipPolicyComponent, |
||||||
|
RequireSsoPolicyComponent, |
||||||
|
ResetPasswordPolicyComponent, |
||||||
|
SendOptionsPolicyComponent, |
||||||
|
SingleOrgPolicyComponent, |
||||||
|
TwoFactorAuthenticationPolicyComponent, |
||||||
|
PoliciesComponent, |
||||||
|
PolicyEditComponent, |
||||||
|
], |
||||||
|
}) |
||||||
|
export class PoliciesModule {} |
||||||
@ -0,0 +1,87 @@ |
|||||||
|
import { NgModule } from "@angular/core"; |
||||||
|
import { RouterModule, Routes } from "@angular/router"; |
||||||
|
|
||||||
|
import { canAccessReportingTab } from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; |
||||||
|
import { Organization } from "@bitwarden/common/models/domain/organization"; |
||||||
|
|
||||||
|
import { OrganizationPermissionsGuard } from "../guards/org-permissions.guard"; |
||||||
|
import { EventsComponent } from "../manage/events.component"; |
||||||
|
import { ExposedPasswordsReportComponent } from "../tools/exposed-passwords-report.component"; |
||||||
|
import { InactiveTwoFactorReportComponent } from "../tools/inactive-two-factor-report.component"; |
||||||
|
import { ReusedPasswordsReportComponent } from "../tools/reused-passwords-report.component"; |
||||||
|
import { UnsecuredWebsitesReportComponent } from "../tools/unsecured-websites-report.component"; |
||||||
|
import { WeakPasswordsReportComponent } from "../tools/weak-passwords-report.component"; |
||||||
|
|
||||||
|
import { ReportingComponent } from "./reporting.component"; |
||||||
|
import { ReportsHomeComponent } from "./reports-home.component"; |
||||||
|
|
||||||
|
const routes: Routes = [ |
||||||
|
{ |
||||||
|
path: "", |
||||||
|
component: ReportingComponent, |
||||||
|
canActivate: [OrganizationPermissionsGuard], |
||||||
|
data: { organizationPermissions: canAccessReportingTab }, |
||||||
|
children: [ |
||||||
|
{ path: "", pathMatch: "full", redirectTo: "reports" }, |
||||||
|
{ |
||||||
|
path: "reports", |
||||||
|
component: ReportsHomeComponent, |
||||||
|
canActivate: [OrganizationPermissionsGuard], |
||||||
|
data: { |
||||||
|
titleId: "reports", |
||||||
|
}, |
||||||
|
children: [ |
||||||
|
{ |
||||||
|
path: "exposed-passwords-report", |
||||||
|
component: ExposedPasswordsReportComponent, |
||||||
|
data: { |
||||||
|
titleId: "exposedPasswordsReport", |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
path: "inactive-two-factor-report", |
||||||
|
component: InactiveTwoFactorReportComponent, |
||||||
|
data: { |
||||||
|
titleId: "inactive2faReport", |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
path: "reused-passwords-report", |
||||||
|
component: ReusedPasswordsReportComponent, |
||||||
|
data: { |
||||||
|
titleId: "reusedPasswordsReport", |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
path: "unsecured-websites-report", |
||||||
|
component: UnsecuredWebsitesReportComponent, |
||||||
|
data: { |
||||||
|
titleId: "unsecuredWebsitesReport", |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
path: "weak-passwords-report", |
||||||
|
component: WeakPasswordsReportComponent, |
||||||
|
data: { |
||||||
|
titleId: "weakPasswordsReport", |
||||||
|
}, |
||||||
|
}, |
||||||
|
], |
||||||
|
}, |
||||||
|
{ |
||||||
|
path: "events", |
||||||
|
component: EventsComponent, |
||||||
|
canActivate: [OrganizationPermissionsGuard], |
||||||
|
data: { |
||||||
|
titleId: "eventLogs", |
||||||
|
organizationPermissions: (org: Organization) => org.canAccessEventLogs, |
||||||
|
}, |
||||||
|
}, |
||||||
|
], |
||||||
|
}, |
||||||
|
]; |
||||||
|
@NgModule({ |
||||||
|
imports: [RouterModule.forChild(routes)], |
||||||
|
exports: [RouterModule], |
||||||
|
}) |
||||||
|
export class OrganizationReportingRoutingModule {} |
||||||
@ -0,0 +1,14 @@ |
|||||||
|
import { NgModule } from "@angular/core"; |
||||||
|
|
||||||
|
import { ReportsSharedModule } from "../../reports"; |
||||||
|
import { SharedModule } from "../../shared/shared.module"; |
||||||
|
|
||||||
|
import { OrganizationReportingRoutingModule } from "./organization-reporting-routing.module"; |
||||||
|
import { ReportingComponent } from "./reporting.component"; |
||||||
|
import { ReportsHomeComponent } from "./reports-home.component"; |
||||||
|
|
||||||
|
@NgModule({ |
||||||
|
imports: [SharedModule, ReportsSharedModule, OrganizationReportingRoutingModule], |
||||||
|
declarations: [ReportsHomeComponent, ReportingComponent], |
||||||
|
}) |
||||||
|
export class OrganizationReportingModule {} |
||||||
@ -0,0 +1,30 @@ |
|||||||
|
<div class="container page-content"> |
||||||
|
<div class="row"> |
||||||
|
<div class="col-3" *ngIf="showLeftNav"> |
||||||
|
<div class="card" *ngIf="organization"> |
||||||
|
<div class="card-header">{{ "reporting" | i18n }}</div> |
||||||
|
<div class="list-group list-group-flush"> |
||||||
|
<a |
||||||
|
routerLink="events" |
||||||
|
class="list-group-item" |
||||||
|
routerLinkActive="active" |
||||||
|
*ngIf="organization.canAccessEventLogs" |
||||||
|
> |
||||||
|
{{ "eventLogs" | i18n }} |
||||||
|
</a> |
||||||
|
<a |
||||||
|
routerLink="reports" |
||||||
|
class="list-group-item" |
||||||
|
routerLinkActive="active" |
||||||
|
*ngIf="organization.canAccessReports" |
||||||
|
> |
||||||
|
{{ "reports" | i18n }} |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div class="col-9" [ngClass]="showLeftNav ? 'col-9' : 'col-12'"> |
||||||
|
<router-outlet></router-outlet> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
@ -0,0 +1,36 @@ |
|||||||
|
import { Component, OnDestroy, OnInit } from "@angular/core"; |
||||||
|
import { ActivatedRoute } from "@angular/router"; |
||||||
|
import { concatMap, Subject, takeUntil } from "rxjs"; |
||||||
|
|
||||||
|
import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; |
||||||
|
import { Organization } from "@bitwarden/common/models/domain/organization"; |
||||||
|
|
||||||
|
@Component({ |
||||||
|
selector: "app-org-reporting", |
||||||
|
templateUrl: "reporting.component.html", |
||||||
|
}) |
||||||
|
export class ReportingComponent implements OnInit, OnDestroy { |
||||||
|
organization: Organization; |
||||||
|
showLeftNav = true; |
||||||
|
|
||||||
|
private destroy$ = new Subject<void>(); |
||||||
|
|
||||||
|
constructor(private route: ActivatedRoute, private organizationService: OrganizationService) {} |
||||||
|
|
||||||
|
ngOnInit() { |
||||||
|
this.route.params |
||||||
|
.pipe( |
||||||
|
concatMap(async (params) => { |
||||||
|
this.organization = await this.organizationService.get(params.organizationId); |
||||||
|
this.showLeftNav = this.organization.canAccessEventLogs; |
||||||
|
}), |
||||||
|
takeUntil(this.destroy$) |
||||||
|
) |
||||||
|
.subscribe(); |
||||||
|
} |
||||||
|
|
||||||
|
ngOnDestroy(): void { |
||||||
|
this.destroy$.next(); |
||||||
|
this.destroy$.complete(); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,20 @@ |
|||||||
|
<ng-container *ngIf="homepage"> |
||||||
|
<div class="page-header"> |
||||||
|
<h1>{{ "reports" | i18n }}</h1> |
||||||
|
</div> |
||||||
|
|
||||||
|
<p>{{ "orgsReportsDesc" | i18n }}</p> |
||||||
|
|
||||||
|
<app-report-list [reports]="reports"></app-report-list> |
||||||
|
</ng-container> |
||||||
|
|
||||||
|
<router-outlet></router-outlet> |
||||||
|
|
||||||
|
<div class="row mt-4"> |
||||||
|
<div class="col"> |
||||||
|
<a bitButton routerLink="./" *ngIf="!homepage"> |
||||||
|
<i class="bwi bwi-angle-left" aria-hidden="true"></i> |
||||||
|
{{ "backToReports" | i18n }} |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
@ -0,0 +1,65 @@ |
|||||||
|
import { Component, OnDestroy, OnInit } from "@angular/core"; |
||||||
|
import { NavigationEnd, Router } from "@angular/router"; |
||||||
|
import { filter, Subject, takeUntil } from "rxjs"; |
||||||
|
|
||||||
|
import { StateService } from "@bitwarden/common/abstractions/state.service"; |
||||||
|
|
||||||
|
import { ReportVariant, reports, ReportType, ReportEntry } from "../../reports"; |
||||||
|
|
||||||
|
@Component({ |
||||||
|
selector: "app-org-reports-home", |
||||||
|
templateUrl: "reports-home.component.html", |
||||||
|
}) |
||||||
|
export class ReportsHomeComponent implements OnInit, OnDestroy { |
||||||
|
reports: ReportEntry[]; |
||||||
|
|
||||||
|
homepage = true; |
||||||
|
private destrory$: Subject<void> = new Subject<void>(); |
||||||
|
|
||||||
|
constructor(private stateService: StateService, router: Router) { |
||||||
|
router.events |
||||||
|
.pipe( |
||||||
|
filter((event) => event instanceof NavigationEnd), |
||||||
|
takeUntil(this.destrory$) |
||||||
|
) |
||||||
|
.subscribe((event) => { |
||||||
|
this.homepage = (event as NavigationEnd).urlAfterRedirects.endsWith("/reports"); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
async ngOnInit(): Promise<void> { |
||||||
|
const userHasPremium = await this.stateService.getCanAccessPremium(); |
||||||
|
|
||||||
|
const reportRequiresPremium = userHasPremium |
||||||
|
? ReportVariant.Enabled |
||||||
|
: ReportVariant.RequiresPremium; |
||||||
|
|
||||||
|
this.reports = [ |
||||||
|
{ |
||||||
|
...reports[ReportType.ExposedPasswords], |
||||||
|
variant: reportRequiresPremium, |
||||||
|
}, |
||||||
|
{ |
||||||
|
...reports[ReportType.ReusedPasswords], |
||||||
|
variant: reportRequiresPremium, |
||||||
|
}, |
||||||
|
{ |
||||||
|
...reports[ReportType.WeakPasswords], |
||||||
|
variant: reportRequiresPremium, |
||||||
|
}, |
||||||
|
{ |
||||||
|
...reports[ReportType.UnsecuredWebsites], |
||||||
|
variant: reportRequiresPremium, |
||||||
|
}, |
||||||
|
{ |
||||||
|
...reports[ReportType.Inactive2fa], |
||||||
|
variant: reportRequiresPremium, |
||||||
|
}, |
||||||
|
]; |
||||||
|
} |
||||||
|
|
||||||
|
ngOnDestroy(): void { |
||||||
|
this.destrory$.next(); |
||||||
|
this.destrory$.complete(); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,2 @@ |
|||||||
|
export * from "./organization-settings.module"; |
||||||
|
export { DeleteOrganizationComponent } from "./delete-organization.component"; |
||||||
@ -1,212 +0,0 @@ |
|||||||
<div class="page-header d-flex"> |
|
||||||
<h1> |
|
||||||
{{ "billing" | i18n }} |
|
||||||
</h1> |
|
||||||
<button |
|
||||||
(click)="load()" |
|
||||||
class="btn btn-sm btn-outline-primary ml-auto" |
|
||||||
*ngIf="firstLoaded" |
|
||||||
[disabled]="loading" |
|
||||||
> |
|
||||||
<i class="bwi bwi-refresh bwi-fw" [ngClass]="{ 'bwi-spin': loading }" aria-hidden="true"></i> |
|
||||||
{{ "refresh" | i18n }} |
|
||||||
</button> |
|
||||||
</div> |
|
||||||
<ng-container *ngIf="!firstLoaded && loading"> |
|
||||||
<i |
|
||||||
class="bwi bwi-spinner bwi-spin text-muted" |
|
||||||
title="{{ 'loading' | i18n }}" |
|
||||||
aria-hidden="true" |
|
||||||
></i> |
|
||||||
<span class="sr-only">{{ "loading" | i18n }}</span> |
|
||||||
</ng-container> |
|
||||||
<ng-container *ngIf="billing"> |
|
||||||
<h2>{{ (isCreditBalance ? "accountCredit" : "accountBalance") | i18n }}</h2> |
|
||||||
<p class="text-lg"> |
|
||||||
<strong>{{ creditOrBalance | currency: "$" }}</strong> |
|
||||||
</p> |
|
||||||
<p>{{ "creditAppliedDesc" | i18n }}</p> |
|
||||||
<button |
|
||||||
type="button" |
|
||||||
class="btn btn-outline-secondary" |
|
||||||
(click)="addCredit()" |
|
||||||
*ngIf="!showAddCredit" |
|
||||||
> |
|
||||||
{{ "addCredit" | i18n }} |
|
||||||
</button> |
|
||||||
<app-add-credit |
|
||||||
[organizationId]="organizationId" |
|
||||||
(onAdded)="closeAddCredit(true)" |
|
||||||
(onCanceled)="closeAddCredit(false)" |
|
||||||
*ngIf="showAddCredit" |
|
||||||
> |
|
||||||
</app-add-credit> |
|
||||||
<h2 class="spaced-header">{{ "paymentMethod" | i18n }}</h2> |
|
||||||
<p *ngIf="!paymentSource">{{ "noPaymentMethod" | i18n }}</p> |
|
||||||
<ng-container *ngIf="paymentSource"> |
|
||||||
<app-callout |
|
||||||
type="warning" |
|
||||||
title="{{ 'verifyBankAccount' | i18n }}" |
|
||||||
*ngIf=" |
|
||||||
paymentSource.type === paymentMethodType.BankAccount && paymentSource.needsVerification |
|
||||||
" |
|
||||||
> |
|
||||||
<p>{{ "verifyBankAccountDesc" | i18n }} {{ "verifyBankAccountFailureWarning" | i18n }}</p> |
|
||||||
<form |
|
||||||
#verifyForm |
|
||||||
class="form-inline" |
|
||||||
(ngSubmit)="verifyBank()" |
|
||||||
[appApiAction]="verifyBankPromise" |
|
||||||
ngNativeValidate |
|
||||||
> |
|
||||||
<label class="sr-only" for="verifyAmount1">{{ "amount" | i18n: "1" }}</label> |
|
||||||
<div class="input-group mr-2"> |
|
||||||
<div class="input-group-prepend"> |
|
||||||
<div class="input-group-text">$0.</div> |
|
||||||
</div> |
|
||||||
<input |
|
||||||
type="number" |
|
||||||
class="form-control" |
|
||||||
id="verifyAmount1" |
|
||||||
placeholder="xx" |
|
||||||
name="Amount1" |
|
||||||
[(ngModel)]="verifyAmount1" |
|
||||||
min="1" |
|
||||||
max="99" |
|
||||||
step="1" |
|
||||||
required |
|
||||||
/> |
|
||||||
</div> |
|
||||||
<label class="sr-only" for="verifyAmount2">{{ "amount" | i18n: "2" }}</label> |
|
||||||
<div class="input-group mr-2"> |
|
||||||
<div class="input-group-prepend"> |
|
||||||
<div class="input-group-text">$0.</div> |
|
||||||
</div> |
|
||||||
<input |
|
||||||
type="number" |
|
||||||
class="form-control" |
|
||||||
id="verifyAmount2" |
|
||||||
placeholder="xx" |
|
||||||
name="Amount2" |
|
||||||
[(ngModel)]="verifyAmount2" |
|
||||||
min="1" |
|
||||||
max="99" |
|
||||||
step="1" |
|
||||||
required |
|
||||||
/> |
|
||||||
</div> |
|
||||||
<button |
|
||||||
type="submit" |
|
||||||
class="btn btn-outline-primary btn-submit" |
|
||||||
[disabled]="verifyForm.loading" |
|
||||||
> |
|
||||||
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i> |
|
||||||
<span>{{ "verifyBankAccount" | i18n }}</span> |
|
||||||
</button> |
|
||||||
</form> |
|
||||||
</app-callout> |
|
||||||
<p> |
|
||||||
<i |
|
||||||
class="bwi bwi-fw" |
|
||||||
[ngClass]="{ |
|
||||||
'bwi-credit-card': paymentSource.type === paymentMethodType.Card, |
|
||||||
'bwi-bank': paymentSource.type === paymentMethodType.BankAccount, |
|
||||||
'bwi-money': paymentSource.type === paymentMethodType.Check, |
|
||||||
'bwi-paypal text-primary': paymentSource.type === paymentMethodType.PayPal, |
|
||||||
'bwi-apple text-muted': paymentSource.type === paymentMethodType.AppleInApp, |
|
||||||
'bwi-google text-muted': paymentSource.type === paymentMethodType.GoogleInApp |
|
||||||
}" |
|
||||||
></i> |
|
||||||
<span *ngIf="paymentSourceInApp">{{ "inAppPurchase" | i18n }}</span> |
|
||||||
{{ paymentSource.description }} |
|
||||||
</p> |
|
||||||
</ng-container> |
|
||||||
<button |
|
||||||
type="button" |
|
||||||
class="btn btn-outline-secondary" |
|
||||||
(click)="changePayment()" |
|
||||||
*ngIf="!showAdjustPayment" |
|
||||||
> |
|
||||||
{{ (paymentSource ? "changePaymentMethod" : "addPaymentMethod") | i18n }} |
|
||||||
</button> |
|
||||||
<app-adjust-payment |
|
||||||
[currentType]="paymentSource != null ? paymentSource.type : null" |
|
||||||
[organizationId]="organizationId" |
|
||||||
(onAdjusted)="closePayment(true)" |
|
||||||
(onCanceled)="closePayment(false)" |
|
||||||
*ngIf="showAdjustPayment" |
|
||||||
> |
|
||||||
</app-adjust-payment> |
|
||||||
<h2 class="spaced-header">{{ "invoices" | i18n }}</h2> |
|
||||||
<p *ngIf="!invoices || !invoices.length">{{ "noInvoices" | i18n }}</p> |
|
||||||
<table class="table mb-2" *ngIf="invoices && invoices.length"> |
|
||||||
<tbody> |
|
||||||
<tr *ngFor="let i of invoices"> |
|
||||||
<td>{{ i.date | date: "mediumDate" }}</td> |
|
||||||
<td> |
|
||||||
<a |
|
||||||
href="{{ i.pdfUrl }}" |
|
||||||
target="_blank" |
|
||||||
rel="noopener" |
|
||||||
class="mr-2" |
|
||||||
appA11yTitle="{{ 'downloadInvoice' | i18n }}" |
|
||||||
> |
|
||||||
<i class="bwi bwi-file-pdf" aria-hidden="true"></i |
|
||||||
></a> |
|
||||||
<a href="{{ i.url }}" target="_blank" rel="noopener" title="{{ 'viewInvoice' | i18n }}"> |
|
||||||
{{ "invoiceNumber" | i18n: i.number }}</a |
|
||||||
> |
|
||||||
</td> |
|
||||||
<td>{{ i.amount | currency: "$" }}</td> |
|
||||||
<td> |
|
||||||
<span *ngIf="i.paid"> |
|
||||||
<i class="bwi bwi-check text-success" aria-hidden="true"></i> |
|
||||||
{{ "paid" | i18n }} |
|
||||||
</span> |
|
||||||
<span *ngIf="!i.paid"> |
|
||||||
<i class="bwi bwi-exclamation-circle text-muted" aria-hidden="true"></i> |
|
||||||
{{ "unpaid" | i18n }} |
|
||||||
</span> |
|
||||||
</td> |
|
||||||
</tr> |
|
||||||
</tbody> |
|
||||||
</table> |
|
||||||
<h2 class="spaced-header">{{ "transactions" | i18n }}</h2> |
|
||||||
<p *ngIf="!transactions || !transactions.length">{{ "noTransactions" | i18n }}</p> |
|
||||||
<table class="table mb-2" *ngIf="transactions && transactions.length"> |
|
||||||
<tbody> |
|
||||||
<tr *ngFor="let t of transactions"> |
|
||||||
<td>{{ t.createdDate | date: "mediumDate" }}</td> |
|
||||||
<td> |
|
||||||
<span *ngIf="t.type === transactionType.Charge || t.type === transactionType.Credit"> |
|
||||||
{{ "chargeNoun" | i18n }} |
|
||||||
</span> |
|
||||||
<span *ngIf="t.type === transactionType.Refund">{{ "refundNoun" | i18n }}</span> |
|
||||||
</td> |
|
||||||
<td> |
|
||||||
<i |
|
||||||
class="bwi bwi-fw" |
|
||||||
*ngIf="t.paymentMethodType" |
|
||||||
aria-hidden="true" |
|
||||||
[ngClass]="{ |
|
||||||
'bwi-credit-card': t.paymentMethodType === paymentMethodType.Card, |
|
||||||
'bwi-bank': |
|
||||||
t.paymentMethodType === paymentMethodType.BankAccount || |
|
||||||
t.paymentMethodType === paymentMethodType.WireTransfer, |
|
||||||
'bwi-bitcoin text-warning': t.paymentMethodType === paymentMethodType.BitPay, |
|
||||||
'bwi-paypal text-primary': t.paymentMethodType === paymentMethodType.PayPal |
|
||||||
}" |
|
||||||
></i> |
|
||||||
{{ t.details }} |
|
||||||
</td> |
|
||||||
<td |
|
||||||
[ngClass]="{ 'text-strike': t.refunded }" |
|
||||||
title="{{ (t.refunded ? 'refunded' : '') | i18n }}" |
|
||||||
> |
|
||||||
{{ t.amount | currency: "$" }} |
|
||||||
</td> |
|
||||||
</tr> |
|
||||||
</tbody> |
|
||||||
</table> |
|
||||||
<small class="text-muted">* {{ "chargesStatement" | i18n: "BITWARDEN" }}</small> |
|
||||||
</ng-container> |
|
||||||
@ -1,153 +0,0 @@ |
|||||||
import { Component, OnInit } from "@angular/core"; |
|
||||||
import { ActivatedRoute } from "@angular/router"; |
|
||||||
|
|
||||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; |
|
||||||
import { LogService } from "@bitwarden/common/abstractions/log.service"; |
|
||||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/abstractions/organization/organization-api.service.abstraction"; |
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; |
|
||||||
import { PaymentMethodType } from "@bitwarden/common/enums/paymentMethodType"; |
|
||||||
import { TransactionType } from "@bitwarden/common/enums/transactionType"; |
|
||||||
import { VerifyBankRequest } from "@bitwarden/common/models/request/verify-bank.request"; |
|
||||||
import { BillingResponse } from "@bitwarden/common/models/response/billing.response"; |
|
||||||
|
|
||||||
@Component({ |
|
||||||
selector: "app-org-billing", |
|
||||||
templateUrl: "./organization-billing.component.html", |
|
||||||
}) |
|
||||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
|
||||||
export class OrganizationBillingComponent implements OnInit { |
|
||||||
loading = false; |
|
||||||
firstLoaded = false; |
|
||||||
showAdjustPayment = false; |
|
||||||
showAddCredit = false; |
|
||||||
billing: BillingResponse; |
|
||||||
paymentMethodType = PaymentMethodType; |
|
||||||
transactionType = TransactionType; |
|
||||||
organizationId: string; |
|
||||||
verifyAmount1: number; |
|
||||||
verifyAmount2: number; |
|
||||||
|
|
||||||
verifyBankPromise: Promise<void>; |
|
||||||
|
|
||||||
// TODO - Make sure to properly split out the billing/invoice and payment method/account during org admin refresh
|
|
||||||
|
|
||||||
constructor( |
|
||||||
private i18nService: I18nService, |
|
||||||
private route: ActivatedRoute, |
|
||||||
private platformUtilsService: PlatformUtilsService, |
|
||||||
private logService: LogService, |
|
||||||
private organizationApiService: OrganizationApiServiceAbstraction |
|
||||||
) {} |
|
||||||
|
|
||||||
async ngOnInit() { |
|
||||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
|
||||||
this.route.parent.parent.params.subscribe(async (params) => { |
|
||||||
this.organizationId = params.organizationId; |
|
||||||
await this.load(); |
|
||||||
this.firstLoaded = true; |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
async load() { |
|
||||||
if (this.loading) { |
|
||||||
return; |
|
||||||
} |
|
||||||
this.loading = true; |
|
||||||
if (this.organizationId != null) { |
|
||||||
this.billing = await this.organizationApiService.getBilling(this.organizationId); |
|
||||||
} |
|
||||||
this.loading = false; |
|
||||||
} |
|
||||||
|
|
||||||
async verifyBank() { |
|
||||||
if (this.loading) { |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
try { |
|
||||||
const request = new VerifyBankRequest(); |
|
||||||
request.amount1 = this.verifyAmount1; |
|
||||||
request.amount2 = this.verifyAmount2; |
|
||||||
this.verifyBankPromise = this.organizationApiService.verifyBank(this.organizationId, request); |
|
||||||
await this.verifyBankPromise; |
|
||||||
this.platformUtilsService.showToast( |
|
||||||
"success", |
|
||||||
null, |
|
||||||
this.i18nService.t("verifiedBankAccount") |
|
||||||
); |
|
||||||
this.load(); |
|
||||||
} catch (e) { |
|
||||||
this.logService.error(e); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
addCredit() { |
|
||||||
if (this.paymentSourceInApp) { |
|
||||||
this.platformUtilsService.showDialog( |
|
||||||
this.i18nService.t("cannotPerformInAppPurchase"), |
|
||||||
this.i18nService.t("addCredit"), |
|
||||||
null, |
|
||||||
null, |
|
||||||
"warning" |
|
||||||
); |
|
||||||
return; |
|
||||||
} |
|
||||||
this.showAddCredit = true; |
|
||||||
} |
|
||||||
|
|
||||||
closeAddCredit(load: boolean) { |
|
||||||
this.showAddCredit = false; |
|
||||||
if (load) { |
|
||||||
this.load(); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
changePayment() { |
|
||||||
if (this.paymentSourceInApp) { |
|
||||||
this.platformUtilsService.showDialog( |
|
||||||
this.i18nService.t("cannotPerformInAppPurchase"), |
|
||||||
this.i18nService.t("changePaymentMethod"), |
|
||||||
null, |
|
||||||
null, |
|
||||||
"warning" |
|
||||||
); |
|
||||||
return; |
|
||||||
} |
|
||||||
this.showAdjustPayment = true; |
|
||||||
} |
|
||||||
|
|
||||||
closePayment(load: boolean) { |
|
||||||
this.showAdjustPayment = false; |
|
||||||
if (load) { |
|
||||||
this.load(); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
get isCreditBalance() { |
|
||||||
return this.billing == null || this.billing.balance <= 0; |
|
||||||
} |
|
||||||
|
|
||||||
get creditOrBalance() { |
|
||||||
return Math.abs(this.billing != null ? this.billing.balance : 0); |
|
||||||
} |
|
||||||
|
|
||||||
get paymentSource() { |
|
||||||
return this.billing != null ? this.billing.paymentSource : null; |
|
||||||
} |
|
||||||
|
|
||||||
get paymentSourceInApp() { |
|
||||||
return ( |
|
||||||
this.paymentSource != null && |
|
||||||
(this.paymentSource.type === PaymentMethodType.AppleInApp || |
|
||||||
this.paymentSource.type === PaymentMethodType.GoogleInApp) |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
get invoices() { |
|
||||||
return this.billing != null ? this.billing.invoices : null; |
|
||||||
} |
|
||||||
|
|
||||||
get transactions() { |
|
||||||
return this.billing != null ? this.billing.transactions : null; |
|
||||||
} |
|
||||||
} |
|
||||||
@ -0,0 +1,52 @@ |
|||||||
|
import { NgModule } from "@angular/core"; |
||||||
|
import { RouterModule, Routes } from "@angular/router"; |
||||||
|
|
||||||
|
import { canAccessSettingsTab } from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; |
||||||
|
import { Organization } from "@bitwarden/common/models/domain/organization"; |
||||||
|
|
||||||
|
import { OrganizationPermissionsGuard } from "../guards/org-permissions.guard"; |
||||||
|
import { PoliciesComponent } from "../policies"; |
||||||
|
|
||||||
|
import { AccountComponent } from "./account.component"; |
||||||
|
import { SettingsComponent } from "./settings.component"; |
||||||
|
import { TwoFactorSetupComponent } from "./two-factor-setup.component"; |
||||||
|
|
||||||
|
const routes: Routes = [ |
||||||
|
{ |
||||||
|
path: "", |
||||||
|
component: SettingsComponent, |
||||||
|
canActivate: [OrganizationPermissionsGuard], |
||||||
|
data: { organizationPermissions: canAccessSettingsTab }, |
||||||
|
children: [ |
||||||
|
{ path: "", pathMatch: "full", redirectTo: "account" }, |
||||||
|
{ path: "account", component: AccountComponent, data: { titleId: "organizationInfo" } }, |
||||||
|
{ |
||||||
|
path: "two-factor", |
||||||
|
component: TwoFactorSetupComponent, |
||||||
|
data: { titleId: "twoStepLogin" }, |
||||||
|
}, |
||||||
|
{ |
||||||
|
path: "policies", |
||||||
|
component: PoliciesComponent, |
||||||
|
canActivate: [OrganizationPermissionsGuard], |
||||||
|
data: { |
||||||
|
organizationPermissions: (org: Organization) => org.canManagePolicies, |
||||||
|
titleId: "policies", |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
path: "tools", |
||||||
|
loadChildren: () => |
||||||
|
import("../tools/import-export/org-import-export.module").then( |
||||||
|
(m) => m.OrganizationImportExportModule |
||||||
|
), |
||||||
|
}, |
||||||
|
], |
||||||
|
}, |
||||||
|
]; |
||||||
|
|
||||||
|
@NgModule({ |
||||||
|
imports: [RouterModule.forChild(routes)], |
||||||
|
exports: [RouterModule], |
||||||
|
}) |
||||||
|
export class OrganizationSettingsRoutingModule {} |
||||||
@ -0,0 +1,21 @@ |
|||||||
|
import { NgModule } from "@angular/core"; |
||||||
|
|
||||||
|
import { LooseComponentsModule, SharedModule } from "../../shared"; |
||||||
|
import { PoliciesModule } from "../policies"; |
||||||
|
|
||||||
|
import { AccountComponent } from "./account.component"; |
||||||
|
import { DeleteOrganizationComponent } from "./delete-organization.component"; |
||||||
|
import { OrganizationSettingsRoutingModule } from "./organization-settings-routing.module"; |
||||||
|
import { SettingsComponent } from "./settings.component"; |
||||||
|
import { TwoFactorSetupComponent } from "./two-factor-setup.component"; |
||||||
|
|
||||||
|
@NgModule({ |
||||||
|
imports: [SharedModule, LooseComponentsModule, PoliciesModule, OrganizationSettingsRoutingModule], |
||||||
|
declarations: [ |
||||||
|
SettingsComponent, |
||||||
|
AccountComponent, |
||||||
|
DeleteOrganizationComponent, |
||||||
|
TwoFactorSetupComponent, |
||||||
|
], |
||||||
|
}) |
||||||
|
export class OrganizationSettingsModule {} |
||||||
@ -1,30 +1,34 @@ |
|||||||
import { Component } from "@angular/core"; |
import { Component, OnDestroy, OnInit } from "@angular/core"; |
||||||
import { ActivatedRoute } from "@angular/router"; |
import { ActivatedRoute } from "@angular/router"; |
||||||
|
import { Subject, switchMap, takeUntil } from "rxjs"; |
||||||
|
|
||||||
import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; |
import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; |
||||||
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; |
import { Organization } from "@bitwarden/common/models/domain/organization"; |
||||||
|
|
||||||
@Component({ |
@Component({ |
||||||
selector: "app-org-settings", |
selector: "app-org-settings", |
||||||
templateUrl: "settings.component.html", |
templateUrl: "settings.component.html", |
||||||
}) |
}) |
||||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
export class SettingsComponent implements OnInit, OnDestroy { |
||||||
export class SettingsComponent { |
organization: Organization; |
||||||
access2fa = false; |
|
||||||
showBilling: boolean; |
|
||||||
|
|
||||||
constructor( |
private destroy$ = new Subject<void>(); |
||||||
private route: ActivatedRoute, |
|
||||||
private organizationService: OrganizationService, |
constructor(private route: ActivatedRoute, private organizationService: OrganizationService) {} |
||||||
private platformUtilsService: PlatformUtilsService |
|
||||||
) {} |
|
||||||
|
|
||||||
ngOnInit() { |
ngOnInit() { |
||||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
this.route.params |
||||||
this.route.parent.params.subscribe(async (params) => { |
.pipe( |
||||||
const organization = await this.organizationService.get(params.organizationId); |
switchMap(async (params) => await this.organizationService.get(params.organizationId)), |
||||||
this.showBilling = !this.platformUtilsService.isSelfHost() && organization.canManageBilling; |
takeUntil(this.destroy$) |
||||||
this.access2fa = organization.use2fa; |
) |
||||||
}); |
.subscribe((organization) => { |
||||||
|
this.organization = organization; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
ngOnDestroy(): void { |
||||||
|
this.destroy$.next(); |
||||||
|
this.destroy$.complete(); |
||||||
} |
} |
||||||
} |
} |
||||||
|
|||||||
@ -1,3 +1,3 @@ |
|||||||
export * from "./reports.module"; |
export * from "./reports.module"; |
||||||
export * from "./models/report-entry"; |
export * from "./shared"; |
||||||
export * from "./models/report-variant"; |
export * from "./reports"; |
||||||
|
|||||||
@ -0,0 +1,3 @@ |
|||||||
|
export * from "./models/report-entry"; |
||||||
|
export * from "./models/report-variant"; |
||||||
|
export * from "./reports-shared.module"; |
||||||
@ -1,5 +1,5 @@ |
|||||||
<a |
<a |
||||||
class="tw-block tw-h-full tw-w-72 tw-overflow-hidden tw-rounded tw-border tw-border-solid tw-border-secondary-300 !tw-text-main tw-transition-all hover:tw-scale-105 hover:tw-no-underline focus:tw-outline-none focus:tw-ring focus:tw-ring-primary-700 focus:tw-ring-offset-2" |
class="tw-block tw-h-full tw-max-w-72 tw-overflow-hidden tw-rounded tw-border tw-border-solid tw-border-secondary-300 !tw-text-main tw-transition-all hover:tw-scale-105 hover:tw-no-underline focus:tw-outline-none focus:tw-ring focus:tw-ring-primary-700 focus:tw-ring-offset-2" |
||||||
[routerLink]="route" |
[routerLink]="route" |
||||||
> |
> |
||||||
<div class="tw-relative"> |
<div class="tw-relative"> |
||||||
@ -0,0 +1,14 @@ |
|||||||
|
import { CommonModule } from "@angular/common"; |
||||||
|
import { NgModule } from "@angular/core"; |
||||||
|
|
||||||
|
import { SharedModule } from "../../shared/shared.module"; |
||||||
|
|
||||||
|
import { ReportCardComponent } from "./report-card/report-card.component"; |
||||||
|
import { ReportListComponent } from "./report-list/report-list.component"; |
||||||
|
|
||||||
|
@NgModule({ |
||||||
|
imports: [CommonModule, SharedModule], |
||||||
|
declarations: [ReportCardComponent, ReportListComponent], |
||||||
|
exports: [ReportCardComponent, ReportListComponent], |
||||||
|
}) |
||||||
|
export class ReportsSharedModule {} |
||||||
@ -0,0 +1,27 @@ |
|||||||
|
<div class="d-flex tabbed-header"> |
||||||
|
<h1> |
||||||
|
{{ "billingHistory" | i18n }} |
||||||
|
</h1> |
||||||
|
<button |
||||||
|
bitButton |
||||||
|
buttonType="secondary" |
||||||
|
(click)="load()" |
||||||
|
class="tw-ml-auto" |
||||||
|
*ngIf="firstLoaded" |
||||||
|
[disabled]="loading" |
||||||
|
> |
||||||
|
<i class="bwi bwi-refresh bwi-fw" [ngClass]="{ 'bwi-spin': loading }" aria-hidden="true"></i> |
||||||
|
{{ "refresh" | i18n }} |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
<ng-container *ngIf="!firstLoaded && loading"> |
||||||
|
<i |
||||||
|
class="bwi bwi-spinner bwi-spin text-muted" |
||||||
|
title="{{ 'loading' | i18n }}" |
||||||
|
aria-hidden="true" |
||||||
|
></i> |
||||||
|
<span class="sr-only">{{ "loading" | i18n }}</span> |
||||||
|
</ng-container> |
||||||
|
<ng-container *ngIf="billing"> |
||||||
|
<app-billing-history [billing]="billing"></app-billing-history> |
||||||
|
</ng-container> |
||||||
@ -0,0 +1,65 @@ |
|||||||
|
<h2 class="mt-3">{{ "invoices" | i18n }}</h2> |
||||||
|
<p *ngIf="!invoices || !invoices.length">{{ "noInvoices" | i18n }}</p> |
||||||
|
<table class="table mb-2" *ngIf="invoices && invoices.length"> |
||||||
|
<tbody> |
||||||
|
<tr *ngFor="let i of invoices"> |
||||||
|
<td>{{ i.date | date: "mediumDate" }}</td> |
||||||
|
<td> |
||||||
|
<a |
||||||
|
href="{{ i.pdfUrl }}" |
||||||
|
target="_blank" |
||||||
|
rel="noopener" |
||||||
|
class="mr-2" |
||||||
|
appA11yTitle="{{ 'downloadInvoice' | i18n }}" |
||||||
|
> |
||||||
|
<i class="bwi bwi-file-pdf" aria-hidden="true"></i |
||||||
|
></a> |
||||||
|
<a href="{{ i.url }}" target="_blank" rel="noopener" title="{{ 'viewInvoice' | i18n }}"> |
||||||
|
{{ "invoiceNumber" | i18n: i.number }}</a |
||||||
|
> |
||||||
|
</td> |
||||||
|
<td>{{ i.amount | currency: "$" }}</td> |
||||||
|
<td> |
||||||
|
<span *ngIf="i.paid"> |
||||||
|
<i class="bwi bwi-check text-success" aria-hidden="true"></i> |
||||||
|
{{ "paid" | i18n }} |
||||||
|
</span> |
||||||
|
<span *ngIf="!i.paid"> |
||||||
|
<i class="bwi bwi-exclamation-circle text-muted" aria-hidden="true"></i> |
||||||
|
{{ "unpaid" | i18n }} |
||||||
|
</span> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
<h2 class="spaced-header">{{ "transactions" | i18n }}</h2> |
||||||
|
<p *ngIf="!transactions || !transactions.length">{{ "noTransactions" | i18n }}</p> |
||||||
|
<table class="table mb-2" *ngIf="transactions && transactions.length"> |
||||||
|
<tbody> |
||||||
|
<tr *ngFor="let t of transactions"> |
||||||
|
<td>{{ t.createdDate | date: "mediumDate" }}</td> |
||||||
|
<td> |
||||||
|
<span *ngIf="t.type === transactionType.Charge || t.type === transactionType.Credit"> |
||||||
|
{{ "chargeNoun" | i18n }} |
||||||
|
</span> |
||||||
|
<span *ngIf="t.type === transactionType.Refund">{{ "refundNoun" | i18n }}</span> |
||||||
|
</td> |
||||||
|
<td> |
||||||
|
<i |
||||||
|
class="bwi bwi-fw" |
||||||
|
*ngIf="t.paymentMethodType" |
||||||
|
aria-hidden="true" |
||||||
|
[ngClass]="paymentMethodClasses(t.paymentMethodType)" |
||||||
|
></i> |
||||||
|
{{ t.details }} |
||||||
|
</td> |
||||||
|
<td |
||||||
|
[ngClass]="{ 'text-strike': t.refunded }" |
||||||
|
title="{{ (t.refunded ? 'refunded' : '') | i18n }}" |
||||||
|
> |
||||||
|
{{ t.amount | currency: "$" }} |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
<small class="text-muted">* {{ "chargesStatement" | i18n: "BITWARDEN" }}</small> |
||||||
@ -0,0 +1,41 @@ |
|||||||
|
import { Component, Input } from "@angular/core"; |
||||||
|
|
||||||
|
import { PaymentMethodType } from "@bitwarden/common/enums/paymentMethodType"; |
||||||
|
import { TransactionType } from "@bitwarden/common/enums/transactionType"; |
||||||
|
import { BillingHistoryResponse } from "@bitwarden/common/models/response/billing-history.response"; |
||||||
|
|
||||||
|
@Component({ |
||||||
|
selector: "app-billing-history", |
||||||
|
templateUrl: "billing-history.component.html", |
||||||
|
}) |
||||||
|
export class BillingHistoryComponent { |
||||||
|
@Input() |
||||||
|
billing: BillingHistoryResponse; |
||||||
|
|
||||||
|
paymentMethodType = PaymentMethodType; |
||||||
|
transactionType = TransactionType; |
||||||
|
|
||||||
|
get invoices() { |
||||||
|
return this.billing != null ? this.billing.invoices : null; |
||||||
|
} |
||||||
|
|
||||||
|
get transactions() { |
||||||
|
return this.billing != null ? this.billing.transactions : null; |
||||||
|
} |
||||||
|
|
||||||
|
paymentMethodClasses(type: PaymentMethodType) { |
||||||
|
switch (type) { |
||||||
|
case PaymentMethodType.Card: |
||||||
|
return ["bwi-credit-card"]; |
||||||
|
case PaymentMethodType.BankAccount: |
||||||
|
case PaymentMethodType.WireTransfer: |
||||||
|
return ["bwi-bank"]; |
||||||
|
case PaymentMethodType.BitPay: |
||||||
|
return ["bwi-bitcoin text-warning"]; |
||||||
|
case PaymentMethodType.PayPal: |
||||||
|
return ["bwi-paypal text-primary"]; |
||||||
|
default: |
||||||
|
return []; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -1,98 +0,0 @@ |
|||||||
<div class="tabbed-header d-flex"> |
|
||||||
<h1> |
|
||||||
{{ "billingHistory" | i18n }} |
|
||||||
</h1> |
|
||||||
<button |
|
||||||
bitButton |
|
||||||
buttonType="secondary" |
|
||||||
(click)="load()" |
|
||||||
class="tw-ml-auto" |
|
||||||
*ngIf="firstLoaded" |
|
||||||
[disabled]="loading" |
|
||||||
> |
|
||||||
<i class="bwi bwi-refresh bwi-fw" [ngClass]="{ 'bwi-spin': loading }" aria-hidden="true"></i> |
|
||||||
{{ "refresh" | i18n }} |
|
||||||
</button> |
|
||||||
</div> |
|
||||||
<ng-container *ngIf="!firstLoaded && loading"> |
|
||||||
<i |
|
||||||
class="bwi bwi-spinner bwi-spin text-muted" |
|
||||||
title="{{ 'loading' | i18n }}" |
|
||||||
aria-hidden="true" |
|
||||||
></i> |
|
||||||
<span class="sr-only">{{ "loading" | i18n }}</span> |
|
||||||
</ng-container> |
|
||||||
<ng-container *ngIf="billing"> |
|
||||||
<h2>{{ "invoices" | i18n }}</h2> |
|
||||||
<p *ngIf="!invoices || !invoices.length">{{ "noInvoices" | i18n }}</p> |
|
||||||
<table class="table mb-2" *ngIf="invoices && invoices.length"> |
|
||||||
<tbody> |
|
||||||
<tr *ngFor="let i of invoices"> |
|
||||||
<td>{{ i.date | date: "mediumDate" }}</td> |
|
||||||
<td> |
|
||||||
<a |
|
||||||
href="{{ i.pdfUrl }}" |
|
||||||
target="_blank" |
|
||||||
rel="noopener" |
|
||||||
class="mr-2" |
|
||||||
appA11yTitle="{{ 'downloadInvoice' | i18n }}" |
|
||||||
> |
|
||||||
<i class="bwi bwi-file-pdf" aria-hidden="true"></i |
|
||||||
></a> |
|
||||||
<a href="{{ i.url }}" target="_blank" rel="noopener" title="{{ 'viewInvoice' | i18n }}"> |
|
||||||
{{ "invoiceNumber" | i18n: i.number }}</a |
|
||||||
> |
|
||||||
</td> |
|
||||||
<td>{{ i.amount | currency: "$" }}</td> |
|
||||||
<td> |
|
||||||
<span *ngIf="i.paid"> |
|
||||||
<i class="bwi bwi-check text-success" aria-hidden="true"></i> |
|
||||||
{{ "paid" | i18n }} |
|
||||||
</span> |
|
||||||
<span *ngIf="!i.paid"> |
|
||||||
<i class="bwi bwi-exclamation-circle text-muted" aria-hidden="true"></i> |
|
||||||
{{ "unpaid" | i18n }} |
|
||||||
</span> |
|
||||||
</td> |
|
||||||
</tr> |
|
||||||
</tbody> |
|
||||||
</table> |
|
||||||
<h2 class="spaced-header">{{ "transactions" | i18n }}</h2> |
|
||||||
<p *ngIf="!transactions || !transactions.length">{{ "noTransactions" | i18n }}</p> |
|
||||||
<table class="table mb-2" *ngIf="transactions && transactions.length"> |
|
||||||
<tbody> |
|
||||||
<tr *ngFor="let t of transactions"> |
|
||||||
<td>{{ t.createdDate | date: "mediumDate" }}</td> |
|
||||||
<td> |
|
||||||
<span *ngIf="t.type === transactionType.Charge || t.type === transactionType.Credit"> |
|
||||||
{{ "chargeNoun" | i18n }} |
|
||||||
</span> |
|
||||||
<span *ngIf="t.type === transactionType.Refund">{{ "refundNoun" | i18n }}</span> |
|
||||||
</td> |
|
||||||
<td> |
|
||||||
<i |
|
||||||
class="bwi bwi-fw" |
|
||||||
*ngIf="t.paymentMethodType" |
|
||||||
aria-hidden="true" |
|
||||||
[ngClass]="{ |
|
||||||
'bwi-credit-card': t.paymentMethodType === paymentMethodType.Card, |
|
||||||
'bwi-bank': |
|
||||||
t.paymentMethodType === paymentMethodType.BankAccount || |
|
||||||
t.paymentMethodType === paymentMethodType.WireTransfer, |
|
||||||
'bwi-bitcoin text-warning': t.paymentMethodType === paymentMethodType.BitPay, |
|
||||||
'bwi-paypal text-primary': t.paymentMethodType === paymentMethodType.PayPal |
|
||||||
}" |
|
||||||
></i> |
|
||||||
{{ t.details }} |
|
||||||
</td> |
|
||||||
<td |
|
||||||
[ngClass]="{ 'text-strike': t.refunded }" |
|
||||||
title="{{ (t.refunded ? 'refunded' : '') | i18n }}" |
|
||||||
> |
|
||||||
{{ t.amount | currency: "$" }} |
|
||||||
</td> |
|
||||||
</tr> |
|
||||||
</tbody> |
|
||||||
</table> |
|
||||||
<small class="text-muted">* {{ "chargesStatement" | i18n: "BITWARDEN" }}</small> |
|
||||||
</ng-container> |
|
||||||
@ -1,26 +0,0 @@ |
|||||||
<div class="form-group"> |
|
||||||
<label>{{ label }}</label> |
|
||||||
<div class="input-group"> |
|
||||||
<input class="form-control" readonly [value]="controlValue" /> |
|
||||||
<div class="input-group-append" *ngIf="showLaunch"> |
|
||||||
<button |
|
||||||
type="button" |
|
||||||
class="btn btn-outline-secondary" |
|
||||||
appA11yTitle="{{ 'launch' | i18n }}" |
|
||||||
(click)="launchUri(controlValue)" |
|
||||||
> |
|
||||||
<i class="bwi bwi-lg bwi-external-link" aria-hidden="true"></i> |
|
||||||
</button> |
|
||||||
</div> |
|
||||||
<div class="input-group-append" *ngIf="showCopy"> |
|
||||||
<button |
|
||||||
type="button" |
|
||||||
class="btn btn-outline-secondary" |
|
||||||
appA11yTitle="{{ 'copyValue' | i18n }}" |
|
||||||
(click)="copy(controlValue)" |
|
||||||
> |
|
||||||
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i> |
|
||||||
</button> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
@ -1,25 +0,0 @@ |
|||||||
import { Component, Input } from "@angular/core"; |
|
||||||
|
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; |
|
||||||
|
|
||||||
/** For use in the SSO Config Form only - will be deprecated by the Component Library */ |
|
||||||
@Component({ |
|
||||||
selector: "app-input-text-readonly", |
|
||||||
templateUrl: "input-text-readonly.component.html", |
|
||||||
}) |
|
||||||
export class InputTextReadOnlyComponent { |
|
||||||
@Input() controlValue: string; |
|
||||||
@Input() label: string; |
|
||||||
@Input() showCopy = true; |
|
||||||
@Input() showLaunch = false; |
|
||||||
|
|
||||||
constructor(private platformUtilsService: PlatformUtilsService) {} |
|
||||||
|
|
||||||
copy(value: string) { |
|
||||||
this.platformUtilsService.copyToClipboard(value); |
|
||||||
} |
|
||||||
|
|
||||||
launchUri(url: string) { |
|
||||||
this.platformUtilsService.launchUri(url); |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,33 +0,0 @@ |
|||||||
<div class="form-group"> |
|
||||||
<label [attr.for]="controlId"> |
|
||||||
{{ label }} |
|
||||||
<small *ngIf="isRequired" class="text-muted form-text d-inline" |
|
||||||
>({{ "required" | i18n }})</small |
|
||||||
> |
|
||||||
</label> |
|
||||||
<input |
|
||||||
[formControl]="internalControl" |
|
||||||
class="form-control" |
|
||||||
[attr.id]="controlId" |
|
||||||
[attr.aria-describedby]="describedById" |
|
||||||
[attr.aria-invalid]="controlDir.control.invalid" |
|
||||||
(blur)="onBlurInternal()" |
|
||||||
/> |
|
||||||
<div *ngIf="showDescribedBy" [attr.id]="describedById"> |
|
||||||
<small |
|
||||||
*ngIf="helperText != null && !controlDir.control.hasError(helperTextSameAsError)" |
|
||||||
class="form-text text-muted" |
|
||||||
> |
|
||||||
{{ helperText }} |
|
||||||
</small> |
|
||||||
<small class="error-inline" *ngIf="controlDir.control.hasError('required')" role="alert"> |
|
||||||
<i class="bwi bwi-exclamation-circle" aria-hidden="true"></i> |
|
||||||
<span class="sr-only">{{ "error" | i18n }}:</span> |
|
||||||
{{ |
|
||||||
controlDir.control.hasError(helperTextSameAsError) |
|
||||||
? helperText |
|
||||||
: ("fieldRequiredError" | i18n: label) |
|
||||||
}} |
|
||||||
</small> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
@ -1,48 +0,0 @@ |
|||||||
import { Component, Input, OnInit } from "@angular/core"; |
|
||||||
|
|
||||||
import { BaseCvaComponent } from "./base-cva.component"; |
|
||||||
|
|
||||||
/** For use in the SSO Config Form only - will be deprecated by the Component Library */ |
|
||||||
@Component({ |
|
||||||
selector: "app-input-text[label][controlId]", |
|
||||||
templateUrl: "input-text.component.html", |
|
||||||
}) |
|
||||||
export class InputTextComponent extends BaseCvaComponent implements OnInit { |
|
||||||
@Input() helperTextSameAsError: string; |
|
||||||
@Input() requiredErrorMessage: string; |
|
||||||
@Input() stripSpaces = false; |
|
||||||
|
|
||||||
transformValue: (value: string) => string = null; |
|
||||||
|
|
||||||
ngOnInit() { |
|
||||||
super.ngOnInit(); |
|
||||||
if (this.stripSpaces) { |
|
||||||
this.transformValue = this.doStripSpaces; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
writeValue(value: string) { |
|
||||||
this.internalControl.setValue(value == null ? "" : value); |
|
||||||
} |
|
||||||
|
|
||||||
protected onValueChangesInternal: any = (value: string) => { |
|
||||||
let newValue = value; |
|
||||||
if (this.transformValue != null) { |
|
||||||
newValue = this.transformValue(value); |
|
||||||
this.internalControl.setValue(newValue, { emitEvent: false }); |
|
||||||
} |
|
||||||
this.onChange(newValue); |
|
||||||
}; |
|
||||||
|
|
||||||
protected onValueChangeInternal(value: string) { |
|
||||||
let newValue = value; |
|
||||||
if (this.transformValue != null) { |
|
||||||
newValue = this.transformValue(value); |
|
||||||
this.internalControl.setValue(newValue, { emitEvent: false }); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
private doStripSpaces(value: string) { |
|
||||||
return value.replace(/ /g, ""); |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,19 +0,0 @@ |
|||||||
<div class="form-group"> |
|
||||||
<label [attr.for]="controlId"> |
|
||||||
{{ label }} |
|
||||||
<small *ngIf="isRequired" class="text-muted form-text d-inline" |
|
||||||
>({{ "required" | i18n }})</small |
|
||||||
> |
|
||||||
</label> |
|
||||||
<select |
|
||||||
class="form-control" |
|
||||||
[attr.id]="controlId" |
|
||||||
[attr.aria-invalid]="controlDir.control.invalid" |
|
||||||
[formControl]="internalControl" |
|
||||||
(blur)="onBlurInternal()" |
|
||||||
> |
|
||||||
<option *ngFor="let o of selectOptions" [ngValue]="o.value" disabled="{{ o.disabled }}"> |
|
||||||
{{ o.name }} |
|
||||||
</option> |
|
||||||
</select> |
|
||||||
</div> |
|
||||||
@ -1,14 +0,0 @@ |
|||||||
import { Component, Input } from "@angular/core"; |
|
||||||
|
|
||||||
import { SelectOptions } from "@bitwarden/angular/interfaces/selectOptions"; |
|
||||||
|
|
||||||
import { BaseCvaComponent } from "./base-cva.component"; |
|
||||||
|
|
||||||
/** For use in the SSO Config Form only - will be deprecated by the Component Library */ |
|
||||||
@Component({ |
|
||||||
selector: "app-select", |
|
||||||
templateUrl: "select.component.html", |
|
||||||
}) |
|
||||||
export class SelectComponent extends BaseCvaComponent { |
|
||||||
@Input() selectOptions: SelectOptions[]; |
|
||||||
} |
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue