1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-12-23 16:38:45 +01:00

[EC-7] Org Admin Vault Refresh Client V1 (#3925)

* [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 commit 24bb775 to 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 commit eca337e89b.

* [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-pick ae39afe to 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 commit 4d83b81d82.

* 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>
This commit is contained in:
Shane Melton 2022-11-02 09:57:25 -07:00 committed by GitHub
parent d12ef5c734
commit 09c3bc8f1b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
123 changed files with 3493 additions and 1577 deletions

View File

@ -10,6 +10,8 @@
"**/app/core/*",
"**/reports/*",
"**/app/shared/*",
"**/organizations/settings/*",
"**/organizations/policies/*",
"@bitwarden/web-vault/*",
"src/**/*"
],

View File

@ -27,15 +27,17 @@ import { SyncService } from "@bitwarden/common/abstractions/sync/sync.service.ab
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeout.service";
import { PolicyListService, RouterService } from "./core";
import { DisableSendPolicy } from "./organizations/policies/disable-send.component";
import { MasterPasswordPolicy } from "./organizations/policies/master-password.component";
import { PasswordGeneratorPolicy } from "./organizations/policies/password-generator.component";
import { PersonalOwnershipPolicy } from "./organizations/policies/personal-ownership.component";
import { RequireSsoPolicy } from "./organizations/policies/require-sso.component";
import { ResetPasswordPolicy } from "./organizations/policies/reset-password.component";
import { SendOptionsPolicy } from "./organizations/policies/send-options.component";
import { SingleOrgPolicy } from "./organizations/policies/single-org.component";
import { TwoFactorAuthenticationPolicy } from "./organizations/policies/two-factor-authentication.component";
import {
DisableSendPolicy,
MasterPasswordPolicy,
PasswordGeneratorPolicy,
PersonalOwnershipPolicy,
RequireSsoPolicy,
ResetPasswordPolicy,
SendOptionsPolicy,
SingleOrgPolicy,
TwoFactorAuthenticationPolicy,
} from "./organizations/policies";
const BroadcasterSubscriptionId = "AppComponent";
const IdleTimeout = 60000 * 10; // 10 minutes

View File

@ -479,16 +479,14 @@ export class EventService implements OnInit, OnDestroy {
private formatGroupId(ev: EventResponse) {
const shortId = this.getShortId(ev.groupId);
const a = this.makeAnchor(shortId);
a.setAttribute(
"href",
"#/organizations/" + ev.organizationId + "/manage/groups?search=" + shortId
);
a.setAttribute("href", "#/organizations/" + ev.organizationId + "/groups?search=" + shortId);
return a.outerHTML;
}
private formatCollectionId(ev: EventResponse) {
const shortId = this.getShortId(ev.collectionId);
const a = this.makeAnchor(shortId);
// TODO: Update view/edit collection link after EC-14 is completed
a.setAttribute(
"href",
"#/organizations/" + ev.organizationId + "/manage/collections?search=" + shortId
@ -503,7 +501,7 @@ export class EventService implements OnInit, OnDestroy {
"href",
"#/organizations/" +
ev.organizationId +
"/manage/people?search=" +
"/members?search=" +
shortId +
"&viewEvents=" +
ev.organizationUserId

View File

@ -1,4 +1,4 @@
import { BasePolicy } from "../organizations/policies/base-policy.component";
import { BasePolicy } from "../organizations/policies";
export class PolicyListService {
private policies: BasePolicy[] = [];

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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 {}

View File

@ -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>

View File

@ -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();
}
}

View File

@ -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 {}

View File

@ -1,5 +1,6 @@
import { Component, OnInit, ViewChild, ViewContainerRef } from "@angular/core";
import { Component, OnDestroy, OnInit, ViewChild, ViewContainerRef } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { concatMap, Subject, takeUntil } from "rxjs";
import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref";
import { ModalService } from "@bitwarden/angular/services/modal.service";
@ -27,17 +28,13 @@ import { SubscriptionHiddenIcon } from "./subscription-hidden.icon";
selector: "app-org-subscription",
templateUrl: "organization-subscription.component.html",
})
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
export class OrganizationSubscriptionComponent implements OnInit {
export class OrganizationSubscriptionComponent implements OnInit, OnDestroy {
@ViewChild("setupBillingSyncTemplate", { read: ViewContainerRef, static: true })
setupBillingSyncModalRef: ViewContainerRef;
loading = false;
firstLoaded = false;
organizationId: string;
adjustSeatsAdd = true;
showAdjustSeats = false;
showAdjustSeatAutoscale = false;
adjustStorageAdd = true;
showAdjustStorage = false;
showUpdateLicense = false;
@ -61,6 +58,8 @@ export class OrganizationSubscriptionComponent implements OnInit {
subscriptionHiddenIcon = SubscriptionHiddenIcon;
private destroy$ = new Subject<void>();
constructor(
private apiService: ApiService,
private platformUtilsService: PlatformUtilsService,
@ -76,19 +75,27 @@ export class OrganizationSubscriptionComponent implements OnInit {
}
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;
});
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.userOrg = this.organizationService.get(this.organizationId);
if (this.userOrg.canManageBilling) {
@ -175,7 +182,7 @@ export class OrganizationSubscriptionComponent implements OnInit {
this.showChangePlan = !this.showChangePlan;
}
closeChangePlan(changed: boolean) {
closeChangePlan() {
this.showChangePlan = false;
}
@ -192,10 +199,14 @@ export class OrganizationSubscriptionComponent implements OnInit {
comp.hasBillingToken = this.hasBillingSyncToken;
}
);
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
ref.onClosed.subscribe(async () => {
await this.load();
});
ref.onClosed
.pipe(
concatMap(async () => {
await this.load();
}),
takeUntil(this.destroy$)
)
.subscribe();
}
closeDownloadLicense() {

View File

@ -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>

View File

@ -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);
}
);
});
});

View File

@ -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
);
}
}

View File

@ -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);

View File

@ -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 {}

View File

@ -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),
};

View File

@ -0,0 +1,3 @@
export * from "./access-selector.component";
export * from "./access-selector.module";
export * from "./access-selector.models";

View File

@ -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");
}
}
}

View File

@ -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>

View File

@ -3,11 +3,14 @@ import { ActivatedRoute } from "@angular/router";
import { map, mergeMap, Observable, Subject, takeUntil } from "rxjs";
import {
OrganizationService,
getOrganizationById,
canAccessBillingTab,
canAccessGroupsTab,
canAccessManageTab,
canAccessMembersTab,
canAccessReportingTab,
canAccessSettingsTab,
canAccessToolsTab,
getOrganizationById,
OrganizationService,
} from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/models/domain/organization";
@ -17,7 +20,6 @@ import { Organization } from "@bitwarden/common/models/domain/organization";
})
export class OrganizationLayoutComponent implements OnInit, OnDestroy {
organization$: Observable<Organization>;
businessTokenPromise: Promise<void>;
private _destroy = new Subject<void>();
@ -43,27 +45,43 @@ export class OrganizationLayoutComponent implements OnInit, OnDestroy {
this._destroy.complete();
}
canShowManageTab(organization: Organization): boolean {
return canAccessManageTab(organization);
}
canShowToolsTab(organization: Organization): boolean {
return canAccessToolsTab(organization);
}
canShowSettingsTab(organization: Organization): boolean {
return canAccessSettingsTab(organization);
}
getToolsRoute(organization: Organization): string {
return organization.canAccessImportExport ? "tools/import" : "tools/exposed-passwords-report";
canShowManageTab(organization: Organization): boolean {
return canAccessManageTab(organization);
}
canShowMembersTab(organization: Organization): boolean {
return canAccessMembersTab(organization);
}
canShowGroupsTab(organization: Organization): boolean {
return canAccessGroupsTab(organization);
}
canShowReportsTab(organization: Organization): boolean {
return canAccessReportingTab(organization);
}
canShowBillingTab(organization: Organization): boolean {
return canAccessBillingTab(organization);
}
getReportTabLabel(organization: Organization): string {
return organization.useEvents ? "reporting" : "reports";
}
getReportRoute(organization: Organization): string {
return organization.useEvents ? "reporting/events" : "reporting/reports";
}
getManageRoute(organization: Organization): string {
let route: string;
switch (true) {
case organization.canManageUsers:
route = "manage/people";
route = "manage/members";
break;
case organization.canViewAssignedCollections || organization.canViewAllCollections:
route = "manage/collections";
@ -71,18 +89,6 @@ export class OrganizationLayoutComponent implements OnInit, OnDestroy {
case organization.canManageGroups:
route = "manage/groups";
break;
case organization.canManagePolicies:
route = "manage/policies";
break;
case organization.canManageSso:
route = "manage/sso";
break;
case organization.canManageScim:
route = "manage/scim";
break;
case organization.canAccessEventLogs:
route = "manage/events";
break;
}
return route;
}

View File

@ -1,54 +1,57 @@
<div class="page-header d-flex">
<div class="tw-mb-4">
<h1>{{ "eventLogs" | i18n }}</h1>
<div class="ml-auto d-flex">
<div class="form-inline">
<label class="sr-only" for="start">{{ "startDate" | i18n }}</label>
<div class="tw-mt-4 tw-flex tw-items-center">
<bit-form-field>
<bit-label>{{ "from" | i18n }}</bit-label>
<input
bitInput
type="datetime-local"
class="form-control form-control-sm"
id="start"
placeholder="{{ 'startDate' | i18n }}"
[(ngModel)]="start"
placeholder="YYYY-MM-DDTHH:MM"
(change)="dirtyDates = true"
/>
<span class="mx-2">-</span>
<label class="sr-only" for="end">{{ "endDate" | i18n }}</label>
</bit-form-field>
<span class="tw-mx-2">-</span>
<bit-form-field>
<bit-label>{{ "to" | i18n }}</bit-label>
<input
bitInput
type="datetime-local"
class="form-control form-control-sm"
id="end"
placeholder="{{ 'endDate' | i18n }}"
[(ngModel)]="end"
placeholder="YYYY-MM-DDTHH:MM"
(change)="dirtyDates = true"
/>
</div>
<form #refreshForm [appApiAction]="refreshPromise" class="d-inline">
</bit-form-field>
<form #refreshForm [appApiAction]="refreshPromise">
<button
class="tw-mx-3 tw-mt-1"
type="button"
class="btn btn-sm btn-outline-primary ml-3"
bitButton
buttonType="primary"
(click)="loadEvents(true)"
[disabled]="loaded && refreshForm.loading"
>
<i
class="bwi bwi-refresh bwi-fw"
aria-hidden="true"
[ngClass]="{ 'bwi-spin': loaded && refreshForm.loading }"
></i>
{{ "refresh" | i18n }}
{{ "update" | i18n }}
</button>
</form>
<form #exportForm [appApiAction]="exportPromise" class="d-inline">
<form #exportForm [appApiAction]="exportPromise">
<button
type="button"
class="btn btn-sm btn-outline-primary btn-submit manual ml-3"
class="tw-mt-1"
bitButton
[ngClass]="{ loading: exportForm.loading }"
(click)="exportEvents()"
[disabled]="(loaded && exportForm.loading) || dirtyDates"
>
<i class="bwi bwi-spinner bwi-spin" aria-hidden="true"></i>
<span>{{ "export" | i18n }}</span>
<i
class="bwi bwi-fw"
aria-hidden="true"
[ngClass]="{
'bwi-sign-in': !exportForm.loading,
'bwi-spinner bwi-spin': exportForm.loading
}"
></i>
</button>
</form>
</div>
@ -63,45 +66,44 @@
</ng-container>
<ng-container *ngIf="loaded">
<p *ngIf="!events || !events.length">{{ "noEventsInList" | i18n }}</p>
<table class="table table-hover" *ngIf="events && events.length">
<thead>
<bit-table *ngIf="events && events.length">
<ng-container header>
<tr>
<th class="border-top-0" width="210">{{ "timestamp" | i18n }}</th>
<th class="border-top-0" width="40">
<span class="sr-only">{{ "device" | i18n }}</span>
</th>
<th class="border-top-0" width="150">{{ "user" | i18n }}</th>
<th class="border-top-0">{{ "event" | i18n }}</th>
<th bitCell>{{ "timestamp" | i18n }}</th>
<th bitCell>{{ "client" | i18n }}</th>
<th bitCell>{{ "member" | i18n }}</th>
<th bitCell>{{ "event" | i18n }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let e of events">
<td>{{ e.date | date: "medium" }}</td>
<td>
<i
class="text-muted bwi bwi-lg {{ e.appIcon }}"
title="{{ e.appName }}, {{ e.ip }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ e.appName }}, {{ e.ip }}</span>
</ng-container>
<ng-container body>
<tr bitRow *ngFor="let e of events" alignContent="top">
<td bitCell class="tw-whitespace-nowrap">{{ e.date | date: "medium" }}</td>
<td bitCell>
<span title="{{ e.appName }}, {{ e.ip }}">{{ e.appName }}</span>
</td>
<td>
<td bitCell>
<span title="{{ e.userEmail }}">{{ e.userName }}</span>
</td>
<td [innerHTML]="e.message"></td>
<td bitCell [innerHTML]="e.message"></td>
</tr>
</tbody>
</table>
</ng-container>
</bit-table>
<button
#moreBtn
[appApiAction]="morePromise"
type="button"
class="btn btn-block btn-link btn-submit"
bitButton
buttonType="primary"
(click)="loadEvents(false)"
[disabled]="loaded && moreBtn.loading"
*ngIf="continuationToken"
>
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<i
class="bwi bwi-spinner bwi-spin"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
*ngIf="moreBtn.loading"
></i>
<span>{{ "loadMore" | i18n }}</span>
</button>
</ng-container>

View File

@ -1,5 +1,6 @@
import { Component, OnInit } from "@angular/core";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { concatMap, Subject, takeUntil } from "rxjs";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
@ -20,13 +21,13 @@ import { EventService } from "../../core";
selector: "app-org-events",
templateUrl: "events.component.html",
})
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
export class EventsComponent extends BaseEventsComponent implements OnInit {
export class EventsComponent extends BaseEventsComponent implements OnInit, OnDestroy {
exportFileName = "org-events";
organizationId: string;
organization: Organization;
private orgUsersUserIdMap = new Map<string, any>();
private destroy$ = new Subject<void>();
constructor(
private apiService: ApiService,
@ -53,17 +54,20 @@ export class EventsComponent extends BaseEventsComponent implements OnInit {
}
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;
this.organization = await this.organizationService.get(this.organizationId);
if (this.organization == null || !this.organization.useEvents) {
this.router.navigate(["/organizations", this.organizationId]);
return;
}
await this.load();
});
this.route.params
.pipe(
concatMap(async (params) => {
this.organizationId = params.organizationId;
this.organization = await this.organizationService.get(this.organizationId);
if (this.organization == null || !this.organization.useEvents) {
await this.router.navigate(["/organizations", this.organizationId]);
return;
}
await this.load();
}),
takeUntil(this.destroy$)
)
.subscribe();
}
async load() {
@ -126,4 +130,9 @@ export class EventsComponent extends BaseEventsComponent implements OnInit {
return null;
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@ -47,7 +47,7 @@ export class GroupsComponent implements OnInit {
async ngOnInit() {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.parent.parent.params.subscribe(async (params) => {
this.route.parent.params.subscribe(async (params) => {
this.organizationId = params.organizationId;
await this.load();
/* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */

View File

@ -5,12 +5,12 @@
<div class="card-header">{{ "manage" | i18n }}</div>
<div class="list-group list-group-flush">
<a
routerLink="people"
routerLink="members"
class="list-group-item"
routerLinkActive="active"
*ngIf="organization.canManageUsers"
>
{{ "people" | i18n }}
{{ "members" | i18n }}
</a>
<a
routerLink="collections"
@ -28,38 +28,6 @@
>
{{ "groups" | i18n }}
</a>
<a
routerLink="policies"
class="list-group-item"
routerLinkActive="active"
*ngIf="organization.canManagePolicies"
>
{{ "policies" | i18n }}
</a>
<a
routerLink="sso"
class="list-group-item"
routerLinkActive="active"
*ngIf="organization.canManageSso"
>
{{ "singleSignOn" | i18n }}
</a>
<a
routerLink="scim"
class="list-group-item"
routerLinkActive="active"
*ngIf="organization.canManageScim"
>
{{ "scim" | i18n }}
</a>
<a
routerLink="events"
class="list-group-item"
routerLinkActive="active"
*ngIf="organization.canAccessEventLogs"
>
{{ "eventLogs" | i18n }}
</a>
</div>
</div>
</div>

View File

@ -1,6 +1,6 @@
<div class="page-header">
<h1>{{ "people" | i18n }}</h1>
<div class="mt-2 d-flex">
<div class="page-header d-flex">
<h1>{{ "members" | i18n }}</h1>
<div class="ml-auto d-flex">
<div class="btn-group btn-group-sm" role="group">
<button
type="button"

View File

@ -3,32 +3,19 @@ import { RouterModule, Routes } from "@angular/router";
import { AuthGuard } from "@bitwarden/angular/guards/auth.guard";
import {
canAccessOrgAdmin,
canAccessGroupsTab,
canAccessManageTab,
canAccessSettingsTab,
canAccessToolsTab,
canAccessMembersTab,
canAccessOrgAdmin,
canManageCollections,
} from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/models/domain/organization";
import { OrganizationPermissionsGuard } from "./guards/org-permissions.guard";
import { OrganizationLayoutComponent } from "./layouts/organization-layout.component";
import { CollectionsComponent } from "./manage/collections.component";
import { EventsComponent } from "./manage/events.component";
import { GroupsComponent } from "./manage/groups.component";
import { ManageComponent } from "./manage/manage.component";
import { PeopleComponent } from "./manage/people.component";
import { PoliciesComponent } from "./manage/policies.component";
import { AccountComponent } from "./settings/account.component";
import { OrganizationBillingComponent } from "./settings/organization-billing.component";
import { OrganizationSubscriptionComponent } from "./settings/organization-subscription.component";
import { SettingsComponent } from "./settings/settings.component";
import { TwoFactorSetupComponent } from "./settings/two-factor-setup.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 { ToolsComponent } from "./tools/tools.component";
import { UnsecuredWebsitesReportComponent } from "./tools/unsecured-websites-report.component";
import { WeakPasswordsReportComponent } from "./tools/weak-passwords-report.component";
import { VaultModule } from "./vault/vault.module";
const routes: Routes = [
@ -46,71 +33,8 @@ const routes: Routes = [
loadChildren: () => VaultModule,
},
{
path: "tools",
component: ToolsComponent,
canActivate: [OrganizationPermissionsGuard],
data: {
organizationPermissions: canAccessToolsTab,
},
children: [
{
path: "",
pathMatch: "full",
redirectTo: "import",
},
{
path: "",
loadChildren: () =>
import("./tools/import-export/org-import-export.module").then(
(m) => m.OrganizationImportExportModule
),
},
{
path: "exposed-passwords-report",
component: ExposedPasswordsReportComponent,
canActivate: [OrganizationPermissionsGuard],
data: {
titleId: "exposedPasswordsReport",
organizationPermissions: (org: Organization) => org.canAccessReports,
},
},
{
path: "inactive-two-factor-report",
component: InactiveTwoFactorReportComponent,
canActivate: [OrganizationPermissionsGuard],
data: {
titleId: "inactive2faReport",
organizationPermissions: (org: Organization) => org.canAccessReports,
},
},
{
path: "reused-passwords-report",
component: ReusedPasswordsReportComponent,
canActivate: [OrganizationPermissionsGuard],
data: {
titleId: "reusedPasswordsReport",
organizationPermissions: (org: Organization) => org.canAccessReports,
},
},
{
path: "unsecured-websites-report",
component: UnsecuredWebsitesReportComponent,
canActivate: [OrganizationPermissionsGuard],
data: {
titleId: "unsecuredWebsitesReport",
organizationPermissions: (org: Organization) => org.canAccessReports,
},
},
{
path: "weak-passwords-report",
component: WeakPasswordsReportComponent,
canActivate: [OrganizationPermissionsGuard],
data: {
titleId: "weakPasswordsReport",
organizationPermissions: (org: Organization) => org.canAccessReports,
},
},
],
path: "settings",
loadChildren: () => import("./settings").then((m) => m.OrganizationSettingsModule),
},
{
path: "manage",
@ -123,7 +47,7 @@ const routes: Routes = [
{
path: "",
pathMatch: "full",
redirectTo: "people",
redirectTo: "members",
},
{
path: "collections",
@ -131,21 +55,7 @@ const routes: Routes = [
canActivate: [OrganizationPermissionsGuard],
data: {
titleId: "collections",
organizationPermissions: (org: Organization) =>
org.canCreateNewCollections ||
org.canEditAnyCollection ||
org.canDeleteAnyCollection ||
org.canEditAssignedCollections ||
org.canDeleteAssignedCollections,
},
},
{
path: "events",
component: EventsComponent,
canActivate: [OrganizationPermissionsGuard],
data: {
titleId: "eventLogs",
organizationPermissions: (org: Organization) => org.canAccessEventLogs,
organizationPermissions: canManageCollections,
},
},
{
@ -154,58 +64,31 @@ const routes: Routes = [
canActivate: [OrganizationPermissionsGuard],
data: {
titleId: "groups",
organizationPermissions: (org: Organization) => org.canManageGroups,
organizationPermissions: canAccessGroupsTab,
},
},
{
path: "people",
path: "members",
component: PeopleComponent,
canActivate: [OrganizationPermissionsGuard],
data: {
titleId: "people",
organizationPermissions: (org: Organization) =>
org.canManageUsers || org.canManageUsersPassword,
},
},
{
path: "policies",
component: PoliciesComponent,
canActivate: [OrganizationPermissionsGuard],
data: {
titleId: "policies",
organizationPermissions: (org: Organization) => org.canManagePolicies,
titleId: "members",
organizationPermissions: canAccessMembersTab,
},
},
],
},
{
path: "settings",
component: SettingsComponent,
canActivate: [OrganizationPermissionsGuard],
data: { organizationPermissions: canAccessSettingsTab },
children: [
{ path: "", pathMatch: "full", redirectTo: "account" },
{ path: "account", component: AccountComponent, data: { titleId: "myOrganization" } },
{
path: "two-factor",
component: TwoFactorSetupComponent,
data: { titleId: "twoStepLogin" },
},
{
path: "billing",
component: OrganizationBillingComponent,
canActivate: [OrganizationPermissionsGuard],
data: {
titleId: "billing",
organizationPermissions: (org: Organization) => org.canManageBilling,
},
},
{
path: "subscription",
component: OrganizationSubscriptionComponent,
data: { titleId: "subscription" },
},
],
path: "reporting",
loadChildren: () =>
import("./reporting/organization-reporting.module").then(
(m) => m.OrganizationReportingModule
),
},
{
path: "billing",
loadChildren: () =>
import("./billing/organization-billing.module").then((m) => m.OrganizationBillingModule),
},
],
},

View File

@ -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 {}

View File

@ -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";

View File

@ -10,7 +10,7 @@ import { Organization } from "@bitwarden/common/models/domain/organization";
import { PolicyResponse } from "@bitwarden/common/models/response/policy.response";
import { PolicyListService } from "../../core";
import { BasePolicy } from "../policies/base-policy.component";
import { BasePolicy } from "../policies";
import { PolicyEditComponent } from "./policy-edit.component";

View File

@ -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 {}

View File

@ -17,7 +17,7 @@ import { PolicyType } from "@bitwarden/common/enums/policyType";
import { PolicyRequest } from "@bitwarden/common/models/request/policy.request";
import { PolicyResponse } from "@bitwarden/common/models/response/policy.response";
import { BasePolicy, BasePolicyComponent } from "../policies/base-policy.component";
import { BasePolicy, BasePolicyComponent } from "../policies";
@Component({
selector: "app-policy-edit",

View File

@ -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 {}

View File

@ -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 {}

View File

@ -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>

View File

@ -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();
}
}

View File

@ -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>

View File

@ -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();
}
}

View File

@ -1,5 +1,5 @@
<div class="page-header">
<h1>{{ "myOrganization" | i18n }}</h1>
<h1>{{ "organizationInfo" | i18n }}</h1>
</div>
<div *ngIf="loading">
<i
@ -51,16 +51,6 @@
[disabled]="selfHosted || !canManageBilling"
/>
</div>
<div class="form-group">
<label for="identifier">{{ "identifier" | i18n }}</label>
<input
id="identifier"
class="form-control"
type="text"
name="Identifier"
[(ngModel)]="org.identifier"
/>
</div>
</div>
<div class="col-6">
<bit-avatar [text]="org.name" [id]="org.id" size="large"></bit-avatar>
@ -87,31 +77,6 @@
{{ "rotateApiKey" | i18n }}
</button>
</ng-container>
<div class="secondary-header border-0 mb-0">
<h1>{{ "taxInformation" | i18n }}</h1>
</div>
<p>{{ "taxInformationDesc" | i18n }}</p>
<div *ngIf="!org || loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</div>
<form
*ngIf="org && !loading"
#formTax
(ngSubmit)="submitTaxInfo()"
[appApiAction]="taxFormPromise"
ngNativeValidate
>
<app-tax-info></app-tax-info>
<button type="submit" class="btn btn-primary btn-submit" [disabled]="formTax.loading">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "save" | i18n }}</span>
</button>
</form>
<div class="secondary-header text-danger border-0 mb-0">
<h1>{{ "dangerZone" | i18n }}</h1>
</div>

View File

@ -14,7 +14,6 @@ import { OrganizationResponse } from "@bitwarden/common/models/response/organiza
import { ApiKeyComponent } from "../../settings/api-key.component";
import { PurgeVaultComponent } from "../../settings/purge-vault.component";
import { TaxInfoComponent } from "../../settings/tax-info.component";
import { DeleteOrganizationComponent } from "./delete-organization.component";
@ -32,7 +31,6 @@ export class AccountComponent {
apiKeyModalRef: ViewContainerRef;
@ViewChild("rotateApiKeyTemplate", { read: ViewContainerRef, static: true })
rotateApiKeyModalRef: ViewContainerRef;
@ViewChild(TaxInfoComponent) taxInfo: TaxInfoComponent;
selfHosted = false;
canManageBilling = true;
@ -100,12 +98,6 @@ export class AccountComponent {
}
}
async submitTaxInfo() {
this.taxFormPromise = this.taxInfo.submitTaxInfo();
await this.taxFormPromise;
this.platformUtilsService.showToast("success", null, this.i18nService.t("taxInfoUpdated"));
}
async deleteOrganization() {
await this.modalService.openViewRef(
DeleteOrganizationComponent,

View File

@ -0,0 +1,2 @@
export * from "./organization-settings.module";
export { DeleteOrganizationComponent } from "./delete-organization.component";

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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 {}

View File

@ -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 {}

View File

@ -5,27 +5,56 @@
<div class="card-header">{{ "settings" | i18n }}</div>
<div class="list-group list-group-flush">
<a routerLink="account" class="list-group-item" routerLinkActive="active">
{{ "myOrganization" | i18n }}
</a>
<a routerLink="subscription" class="list-group-item" routerLinkActive="active">
{{ "subscription" | i18n }}
{{ "organizationInfo" | i18n }}
</a>
<a
routerLink="billing"
routerLink="policies"
class="list-group-item"
routerLinkActive="active"
*ngIf="showBilling"
*ngIf="organization?.canManagePolicies"
>
{{ "billing" | i18n }}
{{ "policies" | i18n }}
</a>
<a
routerLink="two-factor"
class="list-group-item"
routerLinkActive="active"
*ngIf="access2fa"
*ngIf="organization?.use2fa"
>
{{ "twoStepLogin" | i18n }}
</a>
<a
routerLink="tools/import"
class="list-group-item"
routerLinkActive="active"
*ngIf="organization?.canAccessImportExport"
>
{{ "importData" | i18n }}
</a>
<a
routerLink="tools/export"
class="list-group-item"
routerLinkActive="active"
*ngIf="organization?.canAccessImportExport"
>
{{ "exportVault" | i18n }}
</a>
<a
routerLink="sso"
class="list-group-item"
routerLinkActive="active"
*ngIf="organization?.canManageSso"
>
{{ "singleSignOn" | i18n }}
</a>
<a
routerLink="scim"
class="list-group-item"
routerLinkActive="active"
*ngIf="organization?.canManageScim"
>
{{ "scim" | i18n }}
</a>
</div>
</div>
</div>

View File

@ -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();
}
}

View File

@ -16,8 +16,8 @@ import { ProductType } from "@bitwarden/common/enums/productType";
import { Organization } from "@bitwarden/common/models/domain/organization";
import { OrganizationSponsorshipRedeemRequest } from "@bitwarden/common/models/request/organization/organization-sponsorship-redeem.request";
import { DeleteOrganizationComponent } from "../../organizations/settings";
import { OrganizationPlansComponent } from "../../settings/organization-plans.component";
import { DeleteOrganizationComponent } from "../settings/delete-organization.component";
@Component({
selector: "families-for-enterprise-setup",

View File

@ -28,7 +28,7 @@ import { VerifyRecoverDeleteComponent } from "./accounts/verify-recover-delete.c
import { HomeGuard } from "./guards/home.guard";
import { FrontendLayoutComponent } from "./layouts/frontend-layout.component";
import { UserLayoutComponent } from "./layouts/user-layout.component";
import { OrganizationsRoutingModule } from "./organizations/organization-routing.module";
import { OrganizationModule } from "./organizations/organization.module";
import { AcceptFamilySponsorshipComponent } from "./organizations/sponsorships/accept-family-sponsorship.component";
import { FamiliesForEnterpriseSetupComponent } from "./organizations/sponsorships/families-for-enterprise-setup.component";
import { ReportsModule } from "./reports";
@ -251,7 +251,7 @@ const routes: Routes = [
},
{
path: "organizations",
loadChildren: () => OrganizationsRoutingModule,
loadChildren: () => OrganizationModule,
},
];

View File

@ -1,3 +1,3 @@
export * from "./reports.module";
export * from "./models/report-entry";
export * from "./models/report-variant";
export * from "./shared";
export * from "./reports";

View File

@ -2,9 +2,8 @@ import { Component, OnInit } from "@angular/core";
import { StateService } from "@bitwarden/common/abstractions/state.service";
import { ReportEntry } from "../models/report-entry";
import { ReportVariant } from "../models/report-variant";
import { reports, ReportType } from "../reports";
import { ReportEntry, ReportVariant } from "../shared";
@Component({
selector: "app-reports-home",

View File

@ -10,19 +10,16 @@ import { ReportsHomeComponent } from "./pages/reports-home.component";
import { ReusedPasswordsReportComponent } from "./pages/reused-passwords-report.component";
import { UnsecuredWebsitesReportComponent } from "./pages/unsecured-websites-report.component";
import { WeakPasswordsReportComponent } from "./pages/weak-passwords-report.component";
import { ReportCardComponent } from "./report-card/report-card.component";
import { ReportListComponent } from "./report-list/report-list.component";
import { ReportsLayoutComponent } from "./reports-layout.component";
import { ReportsRoutingModule } from "./reports-routing.module";
import { ReportsSharedModule } from "./shared";
@NgModule({
imports: [CommonModule, SharedModule, ReportsRoutingModule],
imports: [CommonModule, SharedModule, ReportsSharedModule, ReportsRoutingModule],
declarations: [
BreachReportComponent,
ExposedPasswordsReportComponent,
InactiveTwoFactorReportComponent,
ReportCardComponent,
ReportListComponent,
ReportsLayoutComponent,
ReportsHomeComponent,
ReusedPasswordsReportComponent,

View File

@ -4,7 +4,7 @@ import { ReportInactiveTwoFactor } from "./icons/report-inactive-two-factor.icon
import { ReportReusedPasswords } from "./icons/report-reused-passwords.icon";
import { ReportUnsecuredWebsites } from "./icons/report-unsecured-websites.icon";
import { ReportWeakPasswords } from "./icons/report-weak-passwords.icon";
import { ReportEntry } from "./models/report-entry";
import { ReportEntry } from "./shared";
export enum ReportType {
ExposedPasswords = "exposedPasswords",

View File

@ -0,0 +1,3 @@
export * from "./models/report-entry";
export * from "./models/report-variant";
export * from "./reports-shared.module";

View File

@ -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">

View File

@ -4,8 +4,8 @@ import { Meta, Story, moduleMetadata } from "@storybook/angular";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { BadgeModule, IconModule } from "@bitwarden/components";
import { PremiumBadgeComponent } from "../../components/premium-badge.component";
import { PreloadedEnglishI18nModule } from "../../tests/preloaded-english-i18n.module";
import { PremiumBadgeComponent } from "../../../components/premium-badge.component";
import { PreloadedEnglishI18nModule } from "../../../tests/preloaded-english-i18n.module";
import { ReportVariant } from "../models/report-variant";
import { ReportCardComponent } from "./report-card.component";

View File

@ -4,11 +4,11 @@ import { Meta, Story, moduleMetadata } from "@storybook/angular";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { BadgeModule, IconModule } from "@bitwarden/components";
import { PremiumBadgeComponent } from "../../components/premium-badge.component";
import { PreloadedEnglishI18nModule } from "../../tests/preloaded-english-i18n.module";
import { PremiumBadgeComponent } from "../../../components/premium-badge.component";
import { PreloadedEnglishI18nModule } from "../../../tests/preloaded-english-i18n.module";
import { reports } from "../../reports";
import { ReportVariant } from "../models/report-variant";
import { ReportCardComponent } from "../report-card/report-card.component";
import { reports } from "../reports";
import { ReportListComponent } from "./report-list.component";

View File

@ -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 {}

View File

@ -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>

View File

@ -2,33 +2,28 @@ import { Component, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
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-user-billing",
templateUrl: "user-billing-history.component.html",
selector: "app-billing-history-view",
templateUrl: "billing-history-view.component.html",
})
export class UserBillingHistoryComponent implements OnInit {
export class BillingHistoryViewComponent implements OnInit {
loading = false;
firstLoaded = false;
billing: BillingHistoryResponse;
paymentMethodType = PaymentMethodType;
transactionType = TransactionType;
constructor(
protected apiService: ApiService,
protected i18nService: I18nService,
protected platformUtilsService: PlatformUtilsService,
private apiService: ApiService,
private platformUtilsService: PlatformUtilsService,
private router: Router
) {}
async ngOnInit() {
if (this.platformUtilsService.isSelfHost()) {
this.router.navigate(["/settings/subscription"]);
return;
}
await this.load();
this.firstLoaded = true;
@ -42,12 +37,4 @@ export class UserBillingHistoryComponent implements OnInit {
this.billing = await this.apiService.getUserBillingHistory();
this.loading = false;
}
get invoices() {
return this.billing != null ? this.billing.invoices : null;
}
get transactions() {
return this.billing != null ? this.billing.transactions : null;
}
}

View File

@ -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>

View File

@ -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 [];
}
}
}

View File

@ -1,4 +1,4 @@
<div class="tabbed-header d-flex">
<div class="d-flex" [ngClass]="headerClass">
<h1>
{{ "paymentMethod" | i18n }}
</h1>
@ -40,18 +40,48 @@
<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="
forOrganization &&
paymentSource.type === paymentMethodType.BankAccount &&
paymentSource.needsVerification
"
>
<p>{{ "verifyBankAccountDesc" | i18n }} {{ "verifyBankAccountFailureWarning" | i18n }}</p>
<form
#verifyForm
class="form-inline"
(ngSubmit)="verifyBank()"
[formGroup]="verifyBankForm"
[appApiAction]="verifyBankPromise"
ngNativeValidate
>
<bit-form-field class="tw-mr-2 tw-w-40">
<bit-label>{{ "amountX" | i18n: "1" }}</bit-label>
<input bitInput type="number" step="1" placeholder="xx" formControlName="amount1" />
<span bitPrefix>$0.</span>
</bit-form-field>
<bit-form-field class="tw-mr-2 tw-w-40">
<bit-label>{{ "amountX" | i18n: "2" }}</bit-label>
<input bitInput type="number" step="1" placeholder="xx" formControlName="amount2" />
<span bitPrefix>$0.</span>
</bit-form-field>
<button
bitButton
buttonType="primary"
type="submit"
class="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>
<i class="bwi bwi-fw" [ngClass]="paymentSourceClasses"></i>
<span *ngIf="paymentSourceInApp">{{ "inAppPurchase" | i18n }}</span>
{{ paymentSource.description }}
</p>
@ -66,4 +96,35 @@
*ngIf="showAdjustPayment"
>
</app-adjust-payment>
<ng-container *ngIf="forOrganization">
<h2 class="spaced-header">{{ "taxInformation" | i18n }}</h2>
<p>{{ "taxInformationDesc" | i18n }}</p>
<div *ngIf="!org || loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</div>
<form
*ngIf="org && !loading"
#formTax
(ngSubmit)="submitTaxInfo()"
[appApiAction]="taxFormPromise"
ngNativeValidate
>
<app-tax-info></app-tax-info>
<button
bitButton
buttonType="primary"
type="submit"
class="btn-submit"
[disabled]="formTax.loading"
>
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "save" | i18n }}</span>
</button>
</form>
</ng-container>
</ng-container>

View File

@ -1,37 +1,76 @@
import { Component, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { Component, OnInit, ViewChild } from "@angular/core";
import { FormBuilder, FormControl, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
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 { VerifyBankRequest } from "@bitwarden/common/models/request/verify-bank.request";
import { BillingPaymentResponse } from "@bitwarden/common/models/response/billing-payment.response";
import { OrganizationResponse } from "@bitwarden/common/models/response/organization.response";
import { TaxInfoComponent } from "./tax-info.component";
@Component({
selector: "app-payment-method",
templateUrl: "payment-method.component.html",
})
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
export class PaymentMethodComponent implements OnInit {
@ViewChild(TaxInfoComponent) taxInfo: TaxInfoComponent;
loading = false;
firstLoaded = false;
showAdjustPayment = false;
showAddCredit = false;
billing: BillingPaymentResponse;
org: OrganizationResponse;
paymentMethodType = PaymentMethodType;
organizationId: string;
verifyBankPromise: Promise<any>;
taxFormPromise: Promise<any>;
verifyBankForm = this.formBuilder.group({
amount1: new FormControl<number>(null, [
Validators.required,
Validators.max(99),
Validators.min(0),
]),
amount2: new FormControl<number>(null, [
Validators.required,
Validators.max(99),
Validators.min(0),
]),
});
constructor(
protected apiService: ApiService,
protected organizationApiService: OrganizationApiServiceAbstraction,
protected i18nService: I18nService,
protected platformUtilsService: PlatformUtilsService,
private router: Router
private router: Router,
private logService: LogService,
private route: ActivatedRoute,
private formBuilder: FormBuilder
) {}
async ngOnInit() {
if (this.platformUtilsService.isSelfHost()) {
this.router.navigate(["/settings/subscription"]);
}
await this.load();
this.firstLoaded = true;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.params.subscribe(async (params) => {
if (params.organizationId) {
this.organizationId = params.organizationId;
} else if (this.platformUtilsService.isSelfHost()) {
this.router.navigate(["/settings/subscription"]);
return;
}
await this.load();
this.firstLoaded = true;
});
}
async load() {
@ -39,7 +78,16 @@ export class PaymentMethodComponent implements OnInit {
return;
}
this.loading = true;
this.billing = await this.apiService.getUserBillingPayment();
if (this.forOrganization) {
const billingPromise = this.organizationApiService.getBilling(this.organizationId);
const orgPromise = this.organizationApiService.get(this.organizationId);
[this.billing, this.org] = await Promise.all([billingPromise, orgPromise]);
} else {
this.billing = await this.apiService.getUserBillingPayment();
}
this.loading = false;
}
@ -85,6 +133,34 @@ export class PaymentMethodComponent implements OnInit {
}
}
async verifyBank() {
if (this.loading || !this.forOrganization) {
return;
}
try {
const request = new VerifyBankRequest();
request.amount1 = this.verifyBankForm.value.amount1;
request.amount2 = this.verifyBankForm.value.amount2;
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);
}
}
async submitTaxInfo() {
this.taxFormPromise = this.taxInfo.submitTaxInfo();
await this.taxFormPromise;
this.platformUtilsService.showToast("success", null, this.i18nService.t("taxInfoUpdated"));
}
get isCreditBalance() {
return this.billing == null || this.billing.balance <= 0;
}
@ -97,6 +173,36 @@ export class PaymentMethodComponent implements OnInit {
return this.billing != null ? this.billing.paymentSource : null;
}
get forOrganization() {
return this.organizationId != null;
}
get headerClass() {
return this.forOrganization ? ["page-header"] : ["tabbed-header"];
}
get paymentSourceClasses() {
if (this.paymentSource == null) {
return [];
}
switch (this.paymentSource.type) {
case PaymentMethodType.Card:
return ["bwi-credit-card"];
case PaymentMethodType.BankAccount:
return ["bwi-bank"];
case PaymentMethodType.Check:
return ["bwi-money"];
case PaymentMethodType.AppleInApp:
return ["bwi-apple text-muted"];
case PaymentMethodType.GoogleInApp:
return ["bwi-google text-muted"];
case PaymentMethodType.PayPal:
return ["bwi-paypal text-primary"];
default:
return [];
}
}
get paymentSourceInApp() {
return (
this.paymentSource != null &&

View File

@ -1,10 +1,10 @@
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { BillingHistoryViewComponent } from "./billing-history-view.component";
import { PaymentMethodComponent } from "./payment-method.component";
import { PremiumComponent } from "./premium.component";
import { SubscriptionComponent } from "./subscription.component";
import { UserBillingHistoryComponent } from "./user-billing-history.component";
import { UserSubscriptionComponent } from "./user-subscription.component";
const routes: Routes = [
@ -31,7 +31,7 @@ const routes: Routes = [
},
{
path: "billing-history",
component: UserBillingHistoryComponent,
component: BillingHistoryViewComponent,
data: { titleId: "billingHistory" },
},
],

View File

@ -1,8 +1,17 @@
<div class="tabbed-header">
<h1>{{ "twoStepLogin" | i18n }}</h1>
<h1 *ngIf="!organizationId">{{ "twoStepLogin" | i18n }}</h1>
<h1 *ngIf="organizationId">{{ "twoStepLoginEnforcement" | i18n }}</h1>
</div>
<p *ngIf="!organizationId">{{ "twoStepLoginDesc" | i18n }}</p>
<p *ngIf="organizationId">{{ "twoStepLoginOrganizationDesc" | i18n }}</p>
<ng-container *ngIf="organizationId">
<p>
{{ "twoStepLoginOrganizationDescStart" | i18n }}
<a routerLink="../policies">{{ "twoStepLoginPolicy" | i18n }}.</a>
<br />
{{ "twoStepLoginOrganizationDuoDesc" | i18n }}
</p>
<p>{{ "twoStepLoginOrganizationSsoDesc" | i18n }}</p>
</ng-container>
<bit-callout type="warning" *ngIf="!organizationId">
<p>{{ "twoStepLoginRecoveryWarning" | i18n }}</p>
<button bitButton buttonType="secondary" (click)="recoveryCode()">

View File

@ -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>

View File

@ -42,31 +42,10 @@ import { GroupAddEditComponent as OrgGroupAddEditComponent } from "../organizati
import { GroupsComponent as OrgGroupsComponent } from "../organizations/manage/groups.component";
import { ManageComponent as OrgManageComponent } from "../organizations/manage/manage.component";
import { PeopleComponent as OrgPeopleComponent } from "../organizations/manage/people.component";
import { PoliciesComponent as OrgPoliciesComponent } from "../organizations/manage/policies.component";
import { PolicyEditComponent as OrgPolicyEditComponent } from "../organizations/manage/policy-edit.component";
import { ResetPasswordComponent as OrgResetPasswordComponent } from "../organizations/manage/reset-password.component";
import { UserAddEditComponent as OrgUserAddEditComponent } from "../organizations/manage/user-add-edit.component";
import { UserConfirmComponent as OrgUserConfirmComponent } from "../organizations/manage/user-confirm.component";
import { UserGroupsComponent as OrgUserGroupsComponent } from "../organizations/manage/user-groups.component";
import { DisableSendPolicyComponent } from "../organizations/policies/disable-send.component";
import { MasterPasswordPolicyComponent } from "../organizations/policies/master-password.component";
import { PasswordGeneratorPolicyComponent } from "../organizations/policies/password-generator.component";
import { PersonalOwnershipPolicyComponent } from "../organizations/policies/personal-ownership.component";
import { RequireSsoPolicyComponent } from "../organizations/policies/require-sso.component";
import { ResetPasswordPolicyComponent } from "../organizations/policies/reset-password.component";
import { SendOptionsPolicyComponent } from "../organizations/policies/send-options.component";
import { SingleOrgPolicyComponent } from "../organizations/policies/single-org.component";
import { TwoFactorAuthenticationPolicyComponent } from "../organizations/policies/two-factor-authentication.component";
import { AccountComponent as OrgAccountComponent } from "../organizations/settings/account.component";
import { AdjustSubscription } from "../organizations/settings/adjust-subscription.component";
import { BillingSyncApiKeyComponent } from "../organizations/settings/billing-sync-api-key.component";
import { ChangePlanComponent } from "../organizations/settings/change-plan.component";
import { DeleteOrganizationComponent } from "../organizations/settings/delete-organization.component";
import { DownloadLicenseComponent } from "../organizations/settings/download-license.component";
import { OrganizationBillingComponent } from "../organizations/settings/organization-billing.component";
import { OrganizationSubscriptionComponent } from "../organizations/settings/organization-subscription.component";
import { SettingsComponent as OrgSettingComponent } from "../organizations/settings/settings.component";
import { TwoFactorSetupComponent as OrgTwoFactorSetupComponent } from "../organizations/settings/two-factor-setup.component";
import { AcceptFamilySponsorshipComponent } from "../organizations/sponsorships/accept-family-sponsorship.component";
import { FamiliesForEnterpriseSetupComponent } from "../organizations/sponsorships/families-for-enterprise-setup.component";
import { ExposedPasswordsReportComponent as OrgExposedPasswordsReportComponent } from "../organizations/tools/exposed-passwords-report.component";
@ -88,6 +67,8 @@ import { AddCreditComponent } from "../settings/add-credit.component";
import { AdjustPaymentComponent } from "../settings/adjust-payment.component";
import { AdjustStorageComponent } from "../settings/adjust-storage.component";
import { ApiKeyComponent } from "../settings/api-key.component";
import { BillingHistoryViewComponent } from "../settings/billing-history-view.component";
import { BillingHistoryComponent } from "../settings/billing-history.component";
import { BillingSyncKeyComponent } from "../settings/billing-sync-key.component";
import { ChangeEmailComponent } from "../settings/change-email.component";
import { ChangeKdfComponent } from "../settings/change-kdf.component";
@ -127,7 +108,6 @@ import { TwoFactorWebAuthnComponent } from "../settings/two-factor-webauthn.comp
import { TwoFactorYubiKeyComponent } from "../settings/two-factor-yubikey.component";
import { UpdateKeyComponent } from "../settings/update-key.component";
import { UpdateLicenseComponent } from "../settings/update-license.component";
import { UserBillingHistoryComponent } from "../settings/user-billing-history.component";
import { UserSubscriptionComponent } from "../settings/user-subscription.component";
import { VaultTimeoutInputComponent } from "../settings/vault-timeout-input.component";
import { VerifyEmailComponent } from "../settings/verify-email.component";
@ -173,10 +153,8 @@ import { SharedModule } from ".";
AddEditCustomFieldsComponent,
AdjustPaymentComponent,
AdjustStorageComponent,
AdjustSubscription,
ApiKeyComponent,
AttachmentsComponent,
BillingSyncApiKeyComponent,
BillingSyncKeyComponent,
BulkActionsComponent,
BulkDeleteComponent,
@ -186,15 +164,11 @@ import { SharedModule } from ".";
ChangeEmailComponent,
ChangeKdfComponent,
ChangePasswordComponent,
ChangePlanComponent,
CollectionsComponent,
CreateOrganizationComponent,
DeauthorizeSessionsComponent,
DeleteAccountComponent,
DeleteOrganizationComponent,
DisableSendPolicyComponent,
DomainRulesComponent,
DownloadLicenseComponent,
EmergencyAccessAddEditComponent,
EmergencyAccessAttachmentsComponent,
EmergencyAccessComponent,
@ -208,16 +182,12 @@ import { SharedModule } from ".";
FrontendLayoutComponent,
HintComponent,
LockComponent,
MasterPasswordPolicyComponent,
NavbarComponent,
NestedCheckboxComponent,
OrganizationSwitcherComponent,
OrgAccountComponent,
OrgAddEditComponent,
OrganizationBillingComponent,
OrganizationLayoutComponent,
OrganizationPlansComponent,
OrganizationSubscriptionComponent,
OrgAttachmentsComponent,
OrgBulkConfirmComponent,
OrgBulkRestoreRevokeComponent,
@ -234,13 +204,9 @@ import { SharedModule } from ".";
OrgManageCollectionsComponent,
OrgManageComponent,
OrgPeopleComponent,
OrgPoliciesComponent,
OrgPolicyEditComponent,
OrgResetPasswordComponent,
OrgReusedPasswordsReportComponent,
OrgSettingComponent,
OrgToolsComponent,
OrgTwoFactorSetupComponent,
OrgUnsecuredWebsitesReportComponent,
OrgUserAddEditComponent,
OrgUserConfirmComponent,
@ -248,12 +214,10 @@ import { SharedModule } from ".";
OrgWeakPasswordsReportComponent,
GeneratorComponent,
PasswordGeneratorHistoryComponent,
PasswordGeneratorPolicyComponent,
PasswordRepromptComponent,
UserVerificationPromptComponent,
PaymentComponent,
PaymentMethodComponent,
PersonalOwnershipPolicyComponent,
PreferencesComponent,
PremiumBadgeComponent,
PremiumComponent,
@ -264,25 +228,20 @@ import { SharedModule } from ".";
RecoverTwoFactorComponent,
RegisterComponent,
RemovePasswordComponent,
RequireSsoPolicyComponent,
ResetPasswordPolicyComponent,
SecurityComponent,
SecurityKeysComponent,
SendAddEditComponent,
SendComponent,
SendEffluxDatesComponent,
SendOptionsPolicyComponent,
SetPasswordComponent,
SettingsComponent,
ShareComponent,
SingleOrgPolicyComponent,
SponsoredFamiliesComponent,
SponsoringOrgRowComponent,
SsoComponent,
SubscriptionComponent,
TaxInfoComponent,
ToolsComponent,
TwoFactorAuthenticationPolicyComponent,
TwoFactorAuthenticatorComponent,
TwoFactorComponent,
TwoFactorDuoComponent,
@ -297,7 +256,8 @@ import { SharedModule } from ".";
UpdateLicenseComponent,
UpdatePasswordComponent,
UpdateTempPasswordComponent,
UserBillingHistoryComponent,
BillingHistoryComponent,
BillingHistoryViewComponent,
UserLayoutComponent,
UserSubscriptionComponent,
UserVerificationComponent,
@ -318,7 +278,6 @@ import { SharedModule } from ".";
AddEditCustomFieldsComponent,
AdjustPaymentComponent,
AdjustStorageComponent,
AdjustSubscription,
ApiKeyComponent,
AttachmentsComponent,
BulkActionsComponent,
@ -329,15 +288,11 @@ import { SharedModule } from ".";
ChangeEmailComponent,
ChangeKdfComponent,
ChangePasswordComponent,
ChangePlanComponent,
CollectionsComponent,
CreateOrganizationComponent,
DeauthorizeSessionsComponent,
DeleteAccountComponent,
DeleteOrganizationComponent,
DisableSendPolicyComponent,
DomainRulesComponent,
DownloadLicenseComponent,
EmergencyAccessAddEditComponent,
EmergencyAccessAttachmentsComponent,
EmergencyAccessComponent,
@ -351,16 +306,12 @@ import { SharedModule } from ".";
FrontendLayoutComponent,
HintComponent,
LockComponent,
MasterPasswordPolicyComponent,
NavbarComponent,
NestedCheckboxComponent,
OrganizationSwitcherComponent,
OrgAccountComponent,
OrgAddEditComponent,
OrganizationBillingComponent,
OrganizationLayoutComponent,
OrganizationPlansComponent,
OrganizationSubscriptionComponent,
OrgAttachmentsComponent,
OrgBulkConfirmComponent,
OrgBulkRestoreRevokeComponent,
@ -377,13 +328,9 @@ import { SharedModule } from ".";
OrgManageCollectionsComponent,
OrgManageComponent,
OrgPeopleComponent,
OrgPoliciesComponent,
OrgPolicyEditComponent,
OrgResetPasswordComponent,
OrgReusedPasswordsReportComponent,
OrgSettingComponent,
OrgToolsComponent,
OrgTwoFactorSetupComponent,
OrgUnsecuredWebsitesReportComponent,
OrgUserAddEditComponent,
OrgUserConfirmComponent,
@ -391,11 +338,9 @@ import { SharedModule } from ".";
OrgWeakPasswordsReportComponent,
GeneratorComponent,
PasswordGeneratorHistoryComponent,
PasswordGeneratorPolicyComponent,
PasswordRepromptComponent,
PaymentComponent,
PaymentMethodComponent,
PersonalOwnershipPolicyComponent,
PreferencesComponent,
PremiumBadgeComponent,
PremiumComponent,
@ -406,25 +351,20 @@ import { SharedModule } from ".";
RecoverTwoFactorComponent,
RegisterComponent,
RemovePasswordComponent,
RequireSsoPolicyComponent,
ResetPasswordPolicyComponent,
SecurityComponent,
SecurityKeysComponent,
SendAddEditComponent,
SendComponent,
SendEffluxDatesComponent,
SendOptionsPolicyComponent,
SetPasswordComponent,
SettingsComponent,
ShareComponent,
SingleOrgPolicyComponent,
SponsoredFamiliesComponent,
SponsoringOrgRowComponent,
SsoComponent,
SubscriptionComponent,
TaxInfoComponent,
ToolsComponent,
TwoFactorAuthenticationPolicyComponent,
TwoFactorAuthenticatorComponent,
TwoFactorComponent,
TwoFactorDuoComponent,
@ -439,7 +379,8 @@ import { SharedModule } from ".";
UpdateLicenseComponent,
UpdatePasswordComponent,
UpdateTempPasswordComponent,
UserBillingHistoryComponent,
BillingHistoryComponent,
BillingHistoryViewComponent,
UserLayoutComponent,
UserSubscriptionComponent,
UserVerificationComponent,

View File

@ -1,5 +1,5 @@
import { DragDropModule } from "@angular/cdk/drag-drop";
import { DatePipe, CommonModule } from "@angular/common";
import { CommonModule, DatePipe } from "@angular/common";
import { NgModule } from "@angular/core";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { RouterModule } from "@angular/router";
@ -14,8 +14,11 @@ import {
ButtonModule,
CalloutModule,
FormFieldModule,
IconButtonModule,
IconModule,
MenuModule,
TableModule,
TabsModule,
} from "@bitwarden/components";
// Register the locales for the application
@ -46,7 +49,10 @@ import "./locales";
MenuModule,
FormFieldModule,
IconModule,
TabsModule,
TableModule,
AvatarModule,
IconButtonModule,
],
exports: [
CommonModule,
@ -66,7 +72,10 @@ import "./locales";
MenuModule,
FormFieldModule,
IconModule,
TabsModule,
TableModule,
AvatarModule,
IconButtonModule,
],
providers: [DatePipe],
bootstrap: [],

View File

@ -314,7 +314,7 @@
<button
type="submit"
class="btn btn-primary btn-submit"
[disabled]="loading || importBlockedByPolicy$ | async"
[disabled]="loading || (importBlockedByPolicy$ | async)"
[ngClass]="{ manual: importBlockedByPolicy$ | async }"
>
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>

View File

@ -97,14 +97,6 @@
<i class="bwi bwi-fw bwi-business" aria-hidden="true"></i>
{{ organizations[0].name }}
</button>
<span class="tw-ml-auto">
<button [bitMenuTriggerFor]="orgMenu" class="org-options">
<i class="bwi bwi-ellipsis-v" aria-hidden="true"></i>
</button>
<bit-menu class="filter-organization-options" #orgMenu>
<app-organization-options [organization]="organizations[0]"></app-organization-options>
</bit-menu>
</span>
</div>
</ng-container>
<ng-container *ngSwitchDefault>

View File

@ -278,7 +278,7 @@
"message": "Search favorites"
},
"searchType": {
"message": "Search type",
"message": "Search Type",
"description": "Search item type"
},
"searchVault": {
@ -1290,11 +1290,24 @@
"twoStepLogin": {
"message": "Two-step login"
},
"twoStepLoginEnforcement": {
"message": "Two-step Login Enforcement"
},
"twoStepLoginDesc": {
"message": "Secure your account by requiring an additional step when logging in."
},
"twoStepLoginOrganizationDesc": {
"message": "Require two-step login for your organization's users by configuring providers at the organization level."
"twoStepLoginOrganizationDescStart": {
"message": "Enforce Bitwarden Two-step Login options for members by using the ",
"description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Enforce Bitwarden Two-step Login options for members by using the Two-step Login Policy.'"
},
"twoStepLoginPolicy": {
"message": "Two-step Login Policy"
},
"twoStepLoginOrganizationDuoDesc": {
"message": "To enforce Two-step Login through Duo, use the options below."
},
"twoStepLoginOrganizationSsoDesc": {
"message": "If you have setup SSO or plan to, Two-step Login may already be enforced through your Identity Provider."
},
"twoStepLoginRecoveryWarning": {
"message": "Setting up two-step login can permanently lock you out of your Bitwarden account. A recovery code allows you to access your account in the event that you can no longer use your normal two-step login provider (example: you lose your device). Bitwarden support will not be able to assist you if you lose access to your account. We recommend you write down or print the recovery code and keep it in a safe place."
@ -1541,7 +1554,11 @@
},
"reportsDesc": {
"message": "Identify and close security gaps in your online accounts by clicking the reports below.",
"description": "Vault health reports can be used to evaluate the security of your Bitwarden individual or organization vault."
"description": "Vault health reports can be used to evaluate the security of your Bitwarden personal or organization vault."
},
"orgsReportsDesc": {
"message": "Identify and close security gaps in your organization's accounts by clicking the reports below.",
"description": "Vault health reports can be used to evaluate the security of your Bitwarden personal or organization Vault."
},
"unsecuredWebsitesReport": {
"message": "Unsecure websites"
@ -2990,6 +3007,9 @@
"myOrganization": {
"message": "My organization"
},
"organizationInfo": {
"message": "Organization Info"
},
"deleteOrganization": {
"message": "Delete organization"
},
@ -3108,7 +3128,7 @@
"message": "Enter your installation id"
},
"limitSubscriptionDesc": {
"message": "Set a seat limit for your subscription. Once this limit is reached, you will not be able to invite new users."
"message": "Set a seat limit for your subscription. Once this limit is reached, you will not be able to invite new members."
},
"maxSeatLimit": {
"message": "Seat Limit (optional)",
@ -3129,7 +3149,7 @@
"message": "Adjustments to your subscription will result in prorated changes to your billing totals. If newly invited users exceed your subscription seats, you will immediately receive a prorated charge for the additional users."
},
"subscriptionUserSeats": {
"message": "Your subscription allows for a total of $COUNT$ users.",
"message": "Your subscription allows for a total of $COUNT$ members.",
"placeholders": {
"count": {
"content": "$1",
@ -3153,10 +3173,10 @@
"message": "For additional help in managing your subscription, please contact Customer Support."
},
"subscriptionUserSeatsUnlimitedAutoscale": {
"message": "Adjustments to your subscription will result in prorated changes to your billing totals. If newly invited users exceed your subscription seats, you will immediately receive a prorated charge for the additional users."
"message": "Adjustments to your subscription will result in prorated changes to your billing totals. If newly invited members exceed your subscription seats, you will immediately receive a prorated charge for the additional members."
},
"subscriptionUserSeatsLimitedAutoscale": {
"message": "Adjustments to your subscription will result in prorated changes to your billing totals. If newly invited users exceed your subscription seats, you will immediately receive a prorated charge for the additional users until your $MAX$ seat limit is reached.",
"message": "Adjustments to your subscription will result in prorated changes to your billing totals. If newly invited members exceed your subscription seats, you will immediately receive a prorated charge for the additional members until your $MAX$ seat limit is reached.",
"placeholders": {
"max": {
"content": "$1",
@ -3165,7 +3185,7 @@
}
},
"subscriptionFreePlan": {
"message": "You cannot invite more than $COUNT$ users without upgrading your plan.",
"message": "You cannot invite more than $COUNT$ members without upgrading your plan.",
"placeholders": {
"count": {
"content": "$1",
@ -3174,7 +3194,7 @@
}
},
"subscriptionFamiliesPlan": {
"message": "You cannot invite more than $COUNT$ users without upgrading your plan. Please contact Customer Support to upgrade.",
"message": "You cannot invite more than $COUNT$ members without upgrading your plan. Please contact Customer Support to upgrade.",
"placeholders": {
"count": {
"content": "$1",
@ -3183,7 +3203,7 @@
}
},
"subscriptionSponsoredFamiliesPlan": {
"message": "Your subscription allows for a total of $COUNT$ users. Your plan is sponsored and billed to an external organization.",
"message": "Your subscription allows for a total of $COUNT$ members. Your plan is sponsored and billed to an external organization.",
"placeholders": {
"count": {
"content": "$1",
@ -3192,7 +3212,7 @@
}
},
"subscriptionMaxReached": {
"message": "Adjustments to your subscription will result in prorated changes to your billing totals. You cannot invite more than $COUNT$ users without increasing your subscription seats.",
"message": "Adjustments to your subscription will result in prorated changes to your billing totals. You cannot invite more than $COUNT$ members without increasing your subscription seats.",
"placeholders": {
"count": {
"content": "$1",
@ -3686,6 +3706,12 @@
"ssoIdentifierRequired": {
"message": "Organization identifier is required."
},
"ssoIdentifier": {
"message": "SSO Identifier"
},
"ssoIdentifierHint": {
"message": "Provide this ID to your members to login with SSO."
},
"unlinkSso": {
"message": "Unlink SSO"
},
@ -4031,7 +4057,7 @@
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"disableSendExemption": {
"message": "Organization users that can manage the organization's policies are exempt from this policy's enforcement."
"message": "Organization members that can manage the organization's policies are exempt from this policy's enforcement."
},
"sendDisabled": {
"message": "Send removed",
@ -4050,7 +4076,7 @@
"description": "'Sends' is a plural noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"sendOptionsExemption": {
"message": "Organization users that can manage the organization's policies are exempt from this policy's enforcement."
"message": "Organization members that can manage the organization's policies are exempt from this policy's enforcement."
},
"disableHideEmail": {
"message": "Always show members email address with recipients when creating or editing a Send.",
@ -4087,6 +4113,9 @@
"permissions": {
"message": "Permissions"
},
"permission": {
"message": "Permission"
},
"managerPermissions": {
"message": "Manager Permissions"
},
@ -4361,19 +4390,19 @@
"message": "Allow admins to reset master passwords for members."
},
"resetPasswordPolicyWarning": {
"message": "Users in the organization will need to self-enroll or be auto-enrolled before administrators can reset their master password."
"message": "Members in the organization will need to self-enroll or be auto-enrolled before administrators can reset their master password."
},
"resetPasswordPolicyAutoEnroll": {
"message": "Automatic enrollment"
},
"resetPasswordPolicyAutoEnrollDescription": {
"message": "All users will be automatically enrolled in password reset once their invite is accepted and will not be allowed to withdraw."
"message": "All members will be automatically enrolled in password reset once their invite is accepted and will not be allowed to withdraw."
},
"resetPasswordPolicyAutoEnrollWarning": {
"message": "Users already in the organization will not be retroactively enrolled in password reset. They will need to self-enroll before administrators can reset their master password."
"message": "Members already in the organization will not be retroactively enrolled in password reset. They will need to self-enroll before administrators can reset their master password."
},
"resetPasswordPolicyAutoEnrollCheckbox": {
"message": "Require new users to be enrolled automatically"
"message": "Require new members to be enrolled automatically"
},
"resetPasswordAutoEnrollInviteWarning": {
"message": "This organization has an Enterprise policy that will automatically enroll you in password reset. Enrollment will allow organization administrators to change your master password."
@ -4492,6 +4521,10 @@
"clients": {
"message": "Clients"
},
"client": {
"message": "Client",
"description": "This is used as a table header to describe which client application created an event log."
},
"providerAdmin": {
"message": "Provider admin"
},
@ -5376,6 +5409,15 @@
}
}
},
"inputMaxLength": {
"message": "Input must not exceed $COUNT$ characters in length.",
"placeholders": {
"count": {
"content": "$1",
"example": "20"
}
}
},
"fieldsNeedAttention": {
"message": "$COUNT$ field(s) above need your attention.",
"placeholders": {
@ -5391,6 +5433,12 @@
"on": {
"message": "On"
},
"members": {
"message": "Members"
},
"reporting": {
"message": "Reporting"
},
"cardBrandMir": {
"message": "Mir"
},
@ -5414,5 +5462,41 @@
},
"multiSelectClearAll": {
"message": "Clear all"
},
"from": {
"message": "From"
},
"to": {
"message": "To"
},
"member": {
"message": "Member"
},
"update": {
"message": "Update"
},
"role": {
"message": "Role"
},
"canView": {
"message": "Can view"
},
"canViewExceptPass": {
"message": "Can view, except passwords"
},
"canEdit": {
"message": "Can edit"
},
"canEditExceptPass": {
"message": "Can edit, except passwords"
},
"group": {
"message": "Group"
},
"groupAccessAll": {
"message": "This group can access and modify all items."
},
"memberAccessAll": {
"message": "This member can access and modify all items."
}
}

View File

@ -1,8 +1,6 @@
import { Directive, Input, OnInit, Self } from "@angular/core";
import { ControlValueAccessor, UntypedFormControl, NgControl, Validators } from "@angular/forms";
import { dirtyRequired } from "@bitwarden/angular/validators/dirty.validator";
/** For use in the SSO Config Form only - will be deprecated by the Component Library */
@Directive()
export abstract class BaseCvaComponent implements ControlValueAccessor, OnInit {
@ -15,10 +13,7 @@ export abstract class BaseCvaComponent implements ControlValueAccessor, OnInit {
}
get isRequired() {
return (
this.controlDir.control.hasValidator(Validators.required) ||
this.controlDir.control.hasValidator(dirtyRequired)
);
return this.controlDir.control.hasValidator(Validators.required);
}
@Input() label: string;

View File

@ -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>

View File

@ -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);
}
}

View File

@ -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>

View File

@ -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, "");
}
}

View File

@ -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>

View File

@ -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[];
}

View File

@ -35,6 +35,14 @@
[helperText]="'allowSsoDesc' | i18n"
></app-input-checkbox>
<bit-form-field>
<bit-label>{{ "ssoIdentifier" | i18n }}</bit-label>
<input bitInput type="text" [formControl]="ssoIdentifier" />
<bit-hint>{{ "ssoIdentifierHint" | i18n }}</bit-hint>
</bit-form-field>
<hr />
<div class="form-group">
<label>{{ "memberDecryptionOption" | i18n }}</label>
<div class="form-check form-check-block">
@ -80,66 +88,55 @@
{{ "keyConnectorWarning" | i18n }}
</app-callout>
<div class="form-group">
<label for="keyConnectorUrl">
{{ "keyConnectorUrl" | i18n }}
<small class="text-muted form-text d-inline">({{ "required" | i18n }})</small>
</label>
<div class="input-group">
<input
class="form-control"
formControlName="keyConnectorUrl"
id="keyConnectorUrl"
aria-describedby="keyConnectorUrlDesc"
(change)="haveTestedKeyConnector = false"
appInputStripSpaces
appA11yInvalid
/>
<div class="input-group-append">
<button
type="button"
class="btn btn-outline-secondary"
(click)="validateKeyConnectorUrl()"
[disabled]="!enableTestKeyConnector"
>
<i
class="bwi bwi-spinner bwi-spin"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
*ngIf="keyConnectorUrl.pending"
></i>
<span *ngIf="!keyConnectorUrl.pending">
{{ "keyConnectorTest" | i18n }}
</span>
</button>
</div>
</div>
<div *ngIf="haveTestedKeyConnector" id="keyConnectorUrlDesc" aria-live="polite">
<small
class="error-inline"
*ngIf="keyConnectorUrl.hasError('invalidUrl'); else keyConnectorSuccess"
>
<i class="bwi bwi-exclamation-circle" aria-hidden="true"></i>
<span class="sr-only">{{ "error" | i18n }}:</span>
{{ "keyConnectorTestFail" | i18n }}
<bit-form-field>
<bit-label>{{ "keyConnectorUrl" | i18n }}</bit-label>
<input
bitInput
type="text"
required
formControlName="keyConnectorUrl"
appInputStripSpaces
(input)="haveTestedKeyConnector = false"
/>
<button
bitSuffix
bitButton
[disabled]="!enableTestKeyConnector"
type="button"
(click)="validateKeyConnectorUrl()"
>
<i
class="bwi bwi-spinner bwi-spin"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
*ngIf="keyConnectorUrl.pending"
></i>
<span *ngIf="!keyConnectorUrl.pending">
{{ "keyConnectorTest" | i18n }}
</span>
</button>
<bit-hint
aria-live="polite"
*ngIf="haveTestedKeyConnector && !keyConnectorUrl.hasError('invalidUrl')"
>
<small class="text-success">
<i class="bwi bwi-check-circle" aria-hidden="true"></i>
{{ "keyConnectorTestSuccess" | i18n }}
</small>
<ng-template #keyConnectorSuccess>
<small class="text-success">
<i class="bwi bwi-check-circle" aria-hidden="true"></i>
{{ "keyConnectorTestSuccess" | i18n }}
</small>
</ng-template>
</div>
</div>
</bit-hint>
</bit-form-field>
</ng-container>
<app-select
controlId="type"
[label]="'type' | i18n"
[selectOptions]="ssoTypeOptions"
formControlName="configType"
>
</app-select>
<hr />
<bit-form-field>
<bit-label>{{ "type" | i18n }}</bit-label>
<select bitInput formControlName="configType">
<option *ngFor="let o of ssoTypeOptions" [ngValue]="o.value" disabled="{{ o.disabled }}">
{{ o.name }}
</option>
</select>
</bit-form-field>
</ng-container>
<!-- OIDC -->
@ -150,52 +147,67 @@
<div class="config-section">
<h2 class="secondary-header">{{ "openIdConnectConfig" | i18n }}</h2>
<app-input-text-readonly
[label]="'callbackPath' | i18n"
[controlValue]="callbackPath"
></app-input-text-readonly>
<bit-form-field>
<bit-label>{{ "callbackPath" | i18n }}</bit-label>
<input bitInput disabled [value]="callbackPath" />
<button
bitButton
bitSuffix
type="button"
[appCopyClick]="callbackPath"
[appA11yTitle]="'copyValue' | i18n"
>
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
</button>
</bit-form-field>
<app-input-text-readonly
[label]="'signedOutCallbackPath' | i18n"
[controlValue]="signedOutCallbackPath"
></app-input-text-readonly>
<bit-form-field>
<bit-label>{{ "signedOutCallbackPath" | i18n }}</bit-label>
<input bitInput disabled [value]="signedOutCallbackPath" />
<button
bitButton
bitSuffix
type="button"
[appCopyClick]="signedOutCallbackPath"
[appA11yTitle]="'copyValue' | i18n"
>
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
</button>
</bit-form-field>
<app-input-text
[label]="'authority' | i18n"
controlId="authority"
[stripSpaces]="true"
formControlName="authority"
></app-input-text>
<bit-form-field>
<bit-label>{{ "authority" | i18n }}</bit-label>
<input bitInput type="text" formControlName="authority" appInputStripSpaces />
</bit-form-field>
<app-input-text
[label]="'clientId' | i18n"
controlId="clientId"
[stripSpaces]="true"
formControlName="clientId"
></app-input-text>
<bit-form-field>
<bit-label>{{ "clientId" | i18n }}</bit-label>
<input bitInput type="text" formControlName="clientId" appInputStripSpaces />
</bit-form-field>
<app-input-text
[label]="'clientSecret' | i18n"
controlId="clientSecret"
[stripSpaces]="true"
formControlName="clientSecret"
></app-input-text>
<bit-form-field>
<bit-label>{{ "clientSecret" | i18n }}</bit-label>
<input bitInput type="text" formControlName="clientSecret" appInputStripSpaces />
</bit-form-field>
<app-input-text
[label]="'metadataAddress' | i18n"
controlId="metadataAddress"
[stripSpaces]="true"
[helperText]="'openIdAuthorityRequired' | i18n"
formControlName="metadataAddress"
></app-input-text>
<bit-form-field>
<bit-label>{{ "metadataAddress" | i18n }}</bit-label>
<input bitInput type="text" formControlName="metadataAddress" appInputStripSpaces />
<bit-hint>{{ "openIdAuthorityRequired" | i18n }}</bit-hint>
</bit-form-field>
<app-select
controlId="redirectBehavior"
[label]="'oidcRedirectBehavior' | i18n"
[selectOptions]="connectRedirectOptions"
formControlName="redirectBehavior"
>
</app-select>
<bit-form-field>
<bit-label>{{ "oidcRedirectBehavior" | i18n }}</bit-label>
<select bitInput formControlName="redirectBehavior">
<option
*ngFor="let o of connectRedirectOptions"
[ngValue]="o.value"
disabled="{{ o.disabled }}"
>
{{ o.name }}
</option>
</select>
</bit-form-field>
<app-input-checkbox
controlId="getClaimsFromUserInfoEndpoint"
@ -231,47 +243,41 @@
</button>
</div>
<div id="customizations" [hidden]="!showOpenIdCustomizations">
<app-input-text
[label]="'additionalScopes' | i18n"
controlId="additionalScopes"
[helperText]="'separateMultipleWithComma' | i18n"
formControlName="additionalScopes"
></app-input-text>
<bit-form-field>
<bit-label>{{ "additionalScopes" | i18n }}</bit-label>
<input bitInput type="text" formControlName="additionalScopes" />
<bit-hint>{{ "separateMultipleWithComma" | i18n }}</bit-hint>
</bit-form-field>
<app-input-text
[label]="'additionalUserIdClaimTypes' | i18n"
controlId="additionalUserIdClaimTypes"
[helperText]="'separateMultipleWithComma' | i18n"
formControlName="additionalUserIdClaimTypes"
></app-input-text>
<bit-form-field>
<bit-label>{{ "additionalUserIdClaimTypes" | i18n }}</bit-label>
<input bitInput type="text" formControlName="additionalUserIdClaimTypes" />
<bit-hint>{{ "separateMultipleWithComma" | i18n }}</bit-hint>
</bit-form-field>
<app-input-text
[label]="'additionalEmailClaimTypes' | i18n"
controlId="additionalEmailClaimTypes"
[helperText]="'separateMultipleWithComma' | i18n"
formControlName="additionalEmailClaimTypes"
></app-input-text>
<bit-form-field>
<bit-label>{{ "additionalEmailClaimTypes" | i18n }}</bit-label>
<input bitInput type="text" formControlName="additionalEmailClaimTypes" />
<bit-hint>{{ "separateMultipleWithComma" | i18n }}</bit-hint>
</bit-form-field>
<app-input-text
[label]="'additionalNameClaimTypes' | i18n"
controlId="additionalNameClaimTypes"
[helperText]="'separateMultipleWithComma' | i18n"
formControlName="additionalNameClaimTypes"
></app-input-text>
<bit-form-field>
<bit-label>{{ "additionalNameClaimTypes" | i18n }}</bit-label>
<input bitInput type="text" formControlName="additionalNameClaimTypes" />
<bit-hint>{{ "separateMultipleWithComma" | i18n }}</bit-hint>
</bit-form-field>
<app-input-text
[label]="'acrValues' | i18n"
controlId="acrValues"
helperText="acr_values"
formControlName="acrValues"
></app-input-text>
<bit-form-field>
<bit-label>{{ "acrValues" | i18n }}</bit-label>
<input bitInput type="text" formControlName="acrValues" />
<bit-hint>acr_values</bit-hint>
</bit-form-field>
<app-input-text
[label]="'expectedReturnAcrValue' | i18n"
controlId="expectedReturnAcrValue"
helperText="acr_validation"
formControlName="expectedReturnAcrValue"
></app-input-text>
<bit-form-field>
<bit-label>{{ "expectedReturnAcrValue" | i18n }}</bit-label>
<input bitInput type="text" formControlName="expectedReturnAcrValue" />
<bit-hint>acr_validaton</bit-hint>
</bit-form-field>
</div>
</div>
</div>
@ -282,53 +288,108 @@
<div class="config-section">
<h2 class="secondary-header">{{ "samlSpConfig" | i18n }}</h2>
<app-input-text-readonly
[label]="'spEntityId' | i18n"
[controlValue]="spEntityId"
></app-input-text-readonly>
<bit-form-field>
<bit-label>{{ "spEntityId" | i18n }}</bit-label>
<input bitInput disabled [value]="spEntityId" />
<button
bitButton
bitSuffix
type="button"
[appCopyClick]="spEntityId"
[appA11yTitle]="'copyValue' | i18n"
>
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
</button>
</bit-form-field>
<app-input-text-readonly
[label]="'spMetadataUrl' | i18n"
[controlValue]="spMetadataUrl"
[showLaunch]="true"
></app-input-text-readonly>
<bit-form-field>
<bit-label>{{ "spMetadataUrl" | i18n }}</bit-label>
<input bitInput disabled [value]="spMetadataUrl" />
<button
bitButton
bitSuffix
type="button"
[appLaunchClick]="spMetadataUrl"
[appA11yTitle]="'launch' | i18n"
>
<i class="bwi bwi-lg bwi-external-link" aria-hidden="true"></i>
</button>
<button
bitButton
bitSuffix
type="button"
[appCopyClick]="spMetadataUrl"
[appA11yTitle]="'copyValue' | i18n"
>
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
</button>
</bit-form-field>
<app-input-text-readonly
[label]="'spAcsUrl' | i18n"
[controlValue]="spAcsUrl"
></app-input-text-readonly>
<bit-form-field>
<bit-label>{{ "spAcsUrl" | i18n }}</bit-label>
<input bitInput disabled [value]="spAcsUrl" />
<button
bitButton
bitSuffix
type="button"
[appCopyClick]="spAcsUrl"
[appA11yTitle]="'copyValue' | i18n"
>
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
</button>
</bit-form-field>
<app-select
controlId="spNameIdFormat"
[label]="'spNameIdFormat' | i18n"
[selectOptions]="saml2NameIdFormatOptions"
formControlName="spNameIdFormat"
>
</app-select>
<bit-form-field>
<bit-label>{{ "spNameIdFormat" | i18n }}</bit-label>
<select bitInput formControlName="spNameIdFormat">
<option
*ngFor="let o of saml2NameIdFormatOptions"
[ngValue]="o.value"
disabled="{{ o.disabled }}"
>
{{ o.name }}
</option>
</select>
</bit-form-field>
<app-select
controlId="spOutboundSigningAlgorithm"
[label]="'spOutboundSigningAlgorithm' | i18n"
[selectOptions]="samlSigningAlgorithmOptions"
formControlName="spOutboundSigningAlgorithm"
>
</app-select>
<bit-form-field>
<bit-label>{{ "spOutboundSigningAlgorithm" | i18n }}</bit-label>
<select bitInput formControlName="spOutboundSigningAlgorithm">
<option
*ngFor="let o of samlSigningAlgorithmOptions"
[ngValue]="o.value"
disabled="{{ o.disabled }}"
>
{{ o.name }}
</option>
</select>
</bit-form-field>
<app-select
controlId="spSigningBehavior"
[label]="'spSigningBehavior' | i18n"
[selectOptions]="saml2SigningBehaviourOptions"
formControlName="spSigningBehavior"
>
</app-select>
<bit-form-field>
<bit-label>{{ "spSigningBehavior" | i18n }}</bit-label>
<select bitInput formControlName="spSigningBehavior">
<option
*ngFor="let o of saml2SigningBehaviourOptions"
[ngValue]="o.value"
disabled="{{ o.disabled }}"
>
{{ o.name }}
</option>
</select>
</bit-form-field>
<app-select
controlId="spMinIncomingSigningAlgorithm"
[label]="'spMinIncomingSigningAlgorithm' | i18n"
[selectOptions]="samlSigningAlgorithmOptions"
formControlName="spMinIncomingSigningAlgorithm"
>
</app-select>
<bit-form-field>
<bit-label>{{ "spMinIncomingSigningAlgorithm" | i18n }}</bit-label>
<select bitInput formControlName="spMinIncomingSigningAlgorithm">
<option
*ngFor="let o of samlSigningAlgorithmOptions"
[ngValue]="o.value"
disabled="{{ o.disabled }}"
>
{{ o.name }}
</option>
</select>
</bit-form-field>
<app-input-checkbox
controlId="spWantAssertionsSigned"
@ -347,67 +408,62 @@
<div class="config-section">
<h2 class="secondary-header">{{ "samlIdpConfig" | i18n }}</h2>
<app-input-text
[label]="'idpEntityId' | i18n"
controlId="idpEntityId"
formControlName="idpEntityId"
></app-input-text>
<bit-form-field>
<bit-label>{{ "idpEntityId" | i18n }}</bit-label>
<input bitInput type="text" formControlName="idpEntityId" />
</bit-form-field>
<app-select
controlId="idpBindingType"
[label]="'idpBindingType' | i18n"
[selectOptions]="saml2BindingTypeOptions"
formControlName="idpBindingType"
>
</app-select>
<bit-form-field>
<bit-label>{{ "idpBindingType" | i18n }}</bit-label>
<select bitInput formControlName="idpBindingType">
<option
*ngFor="let o of saml2BindingTypeOptions"
[ngValue]="o.value"
disabled="{{ o.disabled }}"
>
{{ o.name }}
</option>
</select>
</bit-form-field>
<app-input-text
[label]="'idpSingleSignOnServiceUrl' | i18n"
controlId="idpSingleSignOnServiceUrl"
[helperText]="'idpSingleSignOnServiceUrlRequired' | i18n"
[stripSpaces]="true"
formControlName="idpSingleSignOnServiceUrl"
></app-input-text>
<bit-form-field>
<bit-label>{{ "idpSingleSignOnServiceUrl" | i18n }}</bit-label>
<input
bitInput
type="text"
formControlName="idpSingleSignOnServiceUrl"
appInputStripSpaces
/>
<bit-hint>{{ "idpSingleSignOnServiceUrlRequired" | i18n }}</bit-hint>
</bit-form-field>
<app-input-text
[label]="'idpSingleLogoutServiceUrl' | i18n"
controlId="idpSingleLogoutServiceUrl"
[stripSpaces]="true"
formControlName="idpSingleLogoutServiceUrl"
></app-input-text>
<bit-form-field>
<bit-label>{{ "idpSingleLogoutServiceUrl" | i18n }}</bit-label>
<input
bitInput
type="text"
formControlName="idpSingleLogoutServiceUrl"
appInputStripSpaces
/>
</bit-form-field>
<div class="form-group">
<label for="idpX509PublicCert">
{{ "idpX509PublicCert" | i18n }}
<small class="text-muted form-text d-inline">({{ "required" | i18n }})</small>
</label>
<textarea
formControlName="idpX509PublicCert"
class="form-control form-control-sm text-monospace"
rows="6"
id="idpX509PublicCert"
appA11yInvalid
aria-describedby="idpX509PublicCertDesc"
></textarea>
<small
id="idpX509PublicCertDesc"
class="error-inline"
role="alert"
*ngIf="samlForm.get('idpX509PublicCert').hasError('required')"
>
<i class="bwi bwi-exclamation-circle" aria-hidden="true"></i>
<span class="sr-only">{{ "error" | i18n }}:</span>
{{ "fieldRequiredError" | i18n: ("idpX509PublicCert" | i18n) }}
</small>
</div>
<bit-form-field>
<bit-label>{{ "idpX509PublicCert" | i18n }}</bit-label>
<textarea bitInput rows="6" formControlName="idpX509PublicCert"></textarea>
</bit-form-field>
<app-select
controlId="idpOutboundSigningAlgorithm"
[label]="'idpOutboundSigningAlgorithm' | i18n"
[selectOptions]="samlSigningAlgorithmOptions"
formControlName="idpOutboundSigningAlgorithm"
>
</app-select>
<bit-form-field>
<bit-label>{{ "idpOutboundSigningAlgorithm" | i18n }}</bit-label>
<select bitInput formControlName="idpOutboundSigningAlgorithm">
<option
*ngFor="let o of samlSigningAlgorithmOptions"
[ngValue]="o.value"
disabled="{{ o.disabled }}"
>
{{ o.name }}
</option>
</select>
</bit-form-field>
<!--TODO: Uncomment once Unsolicited IdP Response is supported-->
<!-- <app-input-checkbox
@ -430,19 +486,8 @@
</div>
</div>
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "save" | i18n }}</span>
<button type="submit" buttonType="primary" bitButton [loading]="form.loading">
{{ "save" | i18n }}
</button>
<div
id="errorSummary"
class="error-summary text-danger"
*ngIf="this.getErrorCount(ssoConfigForm) as errorCount"
>
<i class="bwi bwi-exclamation-circle" aria-hidden="true"></i>
<span class="sr-only">{{ "error" | i18n }}:</span>
{{
(errorCount === 1 ? "formErrorSummarySingle" : "formErrorSummaryPlural") | i18n: errorCount
}}
</div>
<bit-error-summary [formGroup]="ssoConfigForm"></bit-error-summary>
</form>

View File

@ -1,9 +1,16 @@
import { Component, OnInit } from "@angular/core";
import { AbstractControl, UntypedFormBuilder, UntypedFormGroup } from "@angular/forms";
import { Component, OnDestroy, OnInit } from "@angular/core";
import {
AbstractControl,
FormBuilder,
FormControl,
UntypedFormGroup,
Validators,
} from "@angular/forms";
import { ActivatedRoute } from "@angular/router";
import { concatMap, Subject, takeUntil } from "rxjs";
import { SelectOptions } from "@bitwarden/angular/interfaces/selectOptions";
import { dirtyRequired } from "@bitwarden/angular/validators/dirty.validator";
import { ControlsOf } from "@bitwarden/angular/types/controls-of";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/abstractions/organization/organization-api.service.abstraction";
@ -29,8 +36,7 @@ const defaultSigningAlgorithm = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha2
selector: "app-org-manage-sso",
templateUrl: "sso.component.html",
})
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
export class SsoComponent implements OnInit {
export class SsoComponent implements OnInit, OnDestroy {
readonly ssoType = SsoType;
readonly ssoTypeOptions: SelectOptions[] = [
@ -75,6 +81,8 @@ export class SsoComponent implements OnInit {
{ name: "Form POST", value: OpenIdConnectRedirectBehavior.FormPost },
];
private destory$ = new Subject<void>();
showOpenIdCustomizations = false;
loading = true;
@ -89,62 +97,69 @@ export class SsoComponent implements OnInit {
spMetadataUrl: string;
spAcsUrl: string;
enabled = this.formBuilder.control(false);
private enabled = this.formBuilder.control(false);
openIdForm = this.formBuilder.group(
private ssoIdentifier = this.formBuilder.control("", {
validators: [Validators.maxLength(50), Validators.required],
});
private openIdForm = this.formBuilder.group<ControlsOf<SsoConfigView["openId"]>>(
{
authority: ["", dirtyRequired],
clientId: ["", dirtyRequired],
clientSecret: ["", dirtyRequired],
metadataAddress: [],
redirectBehavior: [OpenIdConnectRedirectBehavior.RedirectGet, dirtyRequired],
getClaimsFromUserInfoEndpoint: [],
additionalScopes: [],
additionalUserIdClaimTypes: [],
additionalEmailClaimTypes: [],
additionalNameClaimTypes: [],
acrValues: [],
expectedReturnAcrValue: [],
authority: new FormControl("", Validators.required),
clientId: new FormControl("", Validators.required),
clientSecret: new FormControl("", Validators.required),
metadataAddress: new FormControl(),
redirectBehavior: new FormControl(
OpenIdConnectRedirectBehavior.RedirectGet,
Validators.required
),
getClaimsFromUserInfoEndpoint: new FormControl(),
additionalScopes: new FormControl(),
additionalUserIdClaimTypes: new FormControl(),
additionalEmailClaimTypes: new FormControl(),
additionalNameClaimTypes: new FormControl(),
acrValues: new FormControl(),
expectedReturnAcrValue: new FormControl(),
},
{
updateOn: "blur",
}
);
samlForm = this.formBuilder.group(
private samlForm = this.formBuilder.group<ControlsOf<SsoConfigView["saml"]>>(
{
spNameIdFormat: [Saml2NameIdFormat.NotConfigured],
spOutboundSigningAlgorithm: [defaultSigningAlgorithm],
spSigningBehavior: [Saml2SigningBehavior.IfIdpWantAuthnRequestsSigned],
spMinIncomingSigningAlgorithm: [defaultSigningAlgorithm],
spWantAssertionsSigned: [],
spValidateCertificates: [],
spNameIdFormat: new FormControl(Saml2NameIdFormat.NotConfigured),
spOutboundSigningAlgorithm: new FormControl(defaultSigningAlgorithm),
spSigningBehavior: new FormControl(Saml2SigningBehavior.IfIdpWantAuthnRequestsSigned),
spMinIncomingSigningAlgorithm: new FormControl(defaultSigningAlgorithm),
spWantAssertionsSigned: new FormControl(),
spValidateCertificates: new FormControl(),
idpEntityId: ["", dirtyRequired],
idpBindingType: [Saml2BindingType.HttpRedirect],
idpSingleSignOnServiceUrl: [],
idpSingleLogoutServiceUrl: [],
idpX509PublicCert: ["", dirtyRequired],
idpOutboundSigningAlgorithm: [defaultSigningAlgorithm],
idpAllowUnsolicitedAuthnResponse: [],
idpAllowOutboundLogoutRequests: [true],
idpWantAuthnRequestsSigned: [],
idpEntityId: new FormControl("", Validators.required),
idpBindingType: new FormControl(Saml2BindingType.HttpRedirect),
idpSingleSignOnServiceUrl: new FormControl(),
idpSingleLogoutServiceUrl: new FormControl(),
idpX509PublicCert: new FormControl("", Validators.required),
idpOutboundSigningAlgorithm: new FormControl(defaultSigningAlgorithm),
idpAllowUnsolicitedAuthnResponse: new FormControl(),
idpAllowOutboundLogoutRequests: new FormControl(true),
idpWantAuthnRequestsSigned: new FormControl(),
},
{
updateOn: "blur",
}
);
ssoConfigForm = this.formBuilder.group({
configType: [SsoType.None],
keyConnectorEnabled: [false],
keyConnectorUrl: [""],
private ssoConfigForm = this.formBuilder.group<ControlsOf<SsoConfigView>>({
configType: new FormControl(SsoType.None),
keyConnectorEnabled: new FormControl(false),
keyConnectorUrl: new FormControl(""),
openId: this.openIdForm,
saml: this.samlForm,
});
constructor(
private formBuilder: UntypedFormBuilder,
private formBuilder: FormBuilder,
private route: ActivatedRoute,
private apiService: ApiService,
private platformUtilsService: PlatformUtilsService,
@ -154,32 +169,41 @@ export class SsoComponent implements OnInit {
) {}
async ngOnInit() {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
this.ssoConfigForm.get("configType").valueChanges.subscribe((newType: SsoType) => {
if (newType === SsoType.OpenIdConnect) {
this.openIdForm.enable();
this.samlForm.disable();
} else if (newType === SsoType.Saml2) {
this.openIdForm.disable();
this.samlForm.enable();
} else {
this.openIdForm.disable();
this.samlForm.disable();
}
});
this.ssoConfigForm
.get("configType")
.valueChanges.pipe(takeUntil(this.destory$))
.subscribe((newType: SsoType) => {
if (newType === SsoType.OpenIdConnect) {
this.openIdForm.enable();
this.samlForm.disable();
} else if (newType === SsoType.Saml2) {
this.openIdForm.disable();
this.samlForm.enable();
} else {
this.openIdForm.disable();
this.samlForm.disable();
}
});
this.samlForm
.get("spSigningBehavior")
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
.valueChanges.subscribe(() =>
this.samlForm.get("idpX509PublicCert").updateValueAndValidity()
);
.valueChanges.pipe(takeUntil(this.destory$))
.subscribe(() => this.samlForm.get("idpX509PublicCert").updateValueAndValidity());
// 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.route.params
.pipe(
concatMap(async (params) => {
this.organizationId = params.organizationId;
await this.load();
}),
takeUntil(this.destory$)
)
.subscribe();
}
ngOnDestroy(): void {
this.destory$.next();
this.destory$.complete();
}
async load() {
@ -199,7 +223,8 @@ export class SsoComponent implements OnInit {
async submit() {
this.validateForm(this.ssoConfigForm);
if (this.ssoConfigForm.get("keyConnectorEnabled").value) {
if (this.ssoConfigForm.value.keyConnectorEnabled) {
this.haveTestedKeyConnector = false;
await this.validateKeyConnectorUrl();
}
@ -210,7 +235,8 @@ export class SsoComponent implements OnInit {
const request = new OrganizationSsoRequest();
request.enabled = this.enabled.value;
request.data = SsoConfigApi.fromView(this.ssoConfigForm.value as SsoConfigView);
request.identifier = this.ssoIdentifier.value;
request.data = SsoConfigApi.fromView(this.ssoConfigForm.getRawValue());
this.formPromise = this.organizationApiService.updateSso(this.organizationId, request);
@ -237,7 +263,7 @@ export class SsoComponent implements OnInit {
this.keyConnectorUrl.updateValueAndValidity();
} catch {
this.keyConnectorUrl.setErrors({
invalidUrl: true,
invalidUrl: { message: this.i18nService.t("keyConnectorTestFail") },
});
}
@ -294,6 +320,7 @@ export class SsoComponent implements OnInit {
private populateForm(ssoSettings: OrganizationSsoResponse) {
this.enabled.setValue(ssoSettings.enabled);
this.ssoIdentifier.setValue(ssoSettings.identifier);
if (ssoSettings.data != null) {
const ssoConfigView = new SsoConfigView(ssoSettings.data);
this.ssoConfigForm.patchValue(ssoConfigView);

View File

@ -2,11 +2,11 @@ import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { AuthGuard } from "@bitwarden/angular/guards/auth.guard";
import { canAccessManageTab } from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
import { canAccessSettingsTab } from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/models/domain/organization";
import { OrganizationPermissionsGuard } from "@bitwarden/web-vault/app/organizations/guards/org-permissions.guard";
import { OrganizationLayoutComponent } from "@bitwarden/web-vault/app/organizations/layouts/organization-layout.component";
import { ManageComponent } from "@bitwarden/web-vault/app/organizations/manage/manage.component";
import { SettingsComponent } from "@bitwarden/web-vault/app/organizations/settings/settings.component";
import { ScimComponent } from "./manage/scim.component";
import { SsoComponent } from "./manage/sso.component";
@ -18,11 +18,11 @@ const routes: Routes = [
canActivate: [AuthGuard, OrganizationPermissionsGuard],
children: [
{
path: "manage",
component: ManageComponent,
path: "settings",
component: SettingsComponent,
canActivate: [OrganizationPermissionsGuard],
data: {
organizationPermissions: canAccessManageTab,
organizationPermissions: canAccessSettingsTab,
},
children: [
{

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