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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -0,0 +1,3 @@
|
||||
export * from "./access-selector.component"; |
||||
export * from "./access-selector.module"; |
||||
export * from "./access-selector.models"; |
||||
@ -0,0 +1,29 @@
@@ -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 @@
@@ -1,49 +1,32 @@
|
||||
<app-navbar></app-navbar> |
||||
<ng-container *ngIf="organization$ | async as organization"> |
||||
<div class="org-nav" *ngIf="organization"> |
||||
<div class="container d-flex"> |
||||
<div class="d-flex flex-column"> |
||||
<app-organization-switcher |
||||
class="my-auto pl-1" |
||||
[activeOrganization]="organization" |
||||
></app-organization-switcher> |
||||
<ul class="nav nav-tabs"> |
||||
<li class="nav-item"> |
||||
<a class="nav-link" routerLink="vault" routerLinkActive="active"> |
||||
<i class="bwi bwi-lock" aria-hidden="true"></i> |
||||
{{ "vault" | i18n }} |
||||
</a> |
||||
</li> |
||||
<li class="nav-item" *ngIf="canShowManageTab(organization)"> |
||||
<a |
||||
class="nav-link" |
||||
[routerLink]="getManageRoute(organization)" |
||||
routerLinkActive="active" |
||||
> |
||||
<i class="bwi bwi-sliders" aria-hidden="true"></i> |
||||
{{ "manage" | i18n }} |
||||
</a> |
||||
</li> |
||||
<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 class="org-nav !tw-h-32" *ngIf="organization$ | async as organization"> |
||||
<div class="container d-flex"> |
||||
<div class="d-flex flex-column"> |
||||
<app-organization-switcher |
||||
class="my-auto pl-1" |
||||
[activeOrganization]="organization" |
||||
></app-organization-switcher> |
||||
<bit-tab-nav-bar class="-tw-mb-px"> |
||||
<bit-tab-link route="vault">{{ "vault" | i18n }}</bit-tab-link> |
||||
<bit-tab-link *ngIf="canShowManageTab(organization)" [route]="getManageRoute(organization)"> |
||||
{{ "manage" | i18n }} |
||||
</bit-tab-link> |
||||
<bit-tab-link |
||||
*ngIf="canShowReportsTab(organization)" |
||||
[route]="getReportRoute(organization)" |
||||
> |
||||
{{ getReportTabLabel(organization) | i18n }} |
||||
</bit-tab-link> |
||||
<bit-tab-link *ngIf="canShowBillingTab(organization)" route="billing">{{ |
||||
"billing" | i18n |
||||
}}</bit-tab-link> |
||||
<bit-tab-link *ngIf="canShowSettingsTab(organization)" route="settings">{{ |
||||
"settings" | i18n |
||||
}}</bit-tab-link> |
||||
</bit-tab-nav-bar> |
||||
</div> |
||||
</div> |
||||
</ng-container> |
||||
</div> |
||||
|
||||
<router-outlet></router-outlet> |
||||
<app-footer></app-footer> |
||||
|
||||
@ -0,0 +1,11 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -0,0 +1,2 @@
|
||||
export * from "./organization-settings.module"; |
||||
export { DeleteOrganizationComponent } from "./delete-organization.component"; |
||||
@ -1,212 +0,0 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -1,30 +1,34 @@
|
||||
import { Component } from "@angular/core"; |
||||
import { Component, OnDestroy, OnInit } from "@angular/core"; |
||||
import { ActivatedRoute } from "@angular/router"; |
||||
import { Subject, switchMap, takeUntil } from "rxjs"; |
||||
|
||||
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({ |
||||
selector: "app-org-settings", |
||||
templateUrl: "settings.component.html", |
||||
}) |
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||
export class SettingsComponent { |
||||
access2fa = false; |
||||
showBilling: boolean; |
||||
export class SettingsComponent implements OnInit, OnDestroy { |
||||
organization: Organization; |
||||
|
||||
constructor( |
||||
private route: ActivatedRoute, |
||||
private organizationService: OrganizationService, |
||||
private platformUtilsService: PlatformUtilsService |
||||
) {} |
||||
private destroy$ = new Subject<void>(); |
||||
|
||||
constructor(private route: ActivatedRoute, private organizationService: OrganizationService) {} |
||||
|
||||
ngOnInit() { |
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
||||
this.route.parent.params.subscribe(async (params) => { |
||||
const organization = await this.organizationService.get(params.organizationId); |
||||
this.showBilling = !this.platformUtilsService.isSelfHost() && organization.canManageBilling; |
||||
this.access2fa = organization.use2fa; |
||||
}); |
||||
this.route.params |
||||
.pipe( |
||||
switchMap(async (params) => await this.organizationService.get(params.organizationId)), |
||||
takeUntil(this.destroy$) |
||||
) |
||||
.subscribe((organization) => { |
||||
this.organization = organization; |
||||
}); |
||||
} |
||||
|
||||
ngOnDestroy(): void { |
||||
this.destroy$.next(); |
||||
this.destroy$.complete(); |
||||
} |
||||
} |
||||
|
||||
@ -1,3 +1,3 @@
@@ -1,3 +1,3 @@
|
||||
export * from "./reports.module"; |
||||
export * from "./models/report-entry"; |
||||
export * from "./models/report-variant"; |
||||
export * from "./shared"; |
||||
export * from "./reports"; |
||||
|
||||
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
export * from "./models/report-entry"; |
||||
export * from "./models/report-variant"; |
||||
export * from "./reports-shared.module"; |
||||
@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
|
||||
<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" |
||||
> |
||||
<div class="tw-relative"> |
||||
@ -0,0 +1,14 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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