1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-12 22:33:35 +00:00

[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>
This commit is contained in:
Shane Melton
2022-08-08 15:27:56 -07:00
committed by GitHub
parent 95bb429281
commit ea97ffe60e
56 changed files with 1252 additions and 1169 deletions

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,35 @@
import { Component, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { BillingHistoryResponse } from "@bitwarden/common/models/response/billingHistoryResponse";
@Component({
selector: "app-org-billing-history-view",
templateUrl: "organization-billing-history-view.component.html",
})
export class OrgBillingHistoryViewComponent implements OnInit {
loading = false;
firstLoaded = false;
billing: BillingHistoryResponse;
organizationId: string;
constructor(private apiService: ApiService, private route: ActivatedRoute) {}
async ngOnInit() {
this.route.params.subscribe(async (params) => {
this.organizationId = params.organizationId;
await this.load();
this.firstLoaded = true;
});
}
async load() {
if (this.loading) {
return;
}
this.loading = true;
this.billing = await this.apiService.getOrganizationBilling(this.organizationId);
this.loading = false;
}
}

View File

@@ -0,0 +1,47 @@
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { Permissions } from "@bitwarden/common/enums/permissions";
import { PaymentMethodComponent } from "../../settings/payment-method.component";
import { PermissionsGuard } from "../guards/permissions.guard";
import { NavigationPermissionsService } from "../services/navigation-permissions.service";
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: [PermissionsGuard],
data: { permissions: NavigationPermissionsService.getPermissions("billing") },
children: [
{ path: "", pathMatch: "full", redirectTo: "subscription" },
{
path: "subscription",
component: OrganizationSubscriptionComponent,
data: { titleId: "subscription" },
},
{
path: "payment-method",
component: PaymentMethodComponent,
canActivate: [PermissionsGuard],
data: { titleId: "paymentMethod", permissions: [Permissions.ManageBilling] },
},
{
path: "history",
component: OrgBillingHistoryViewComponent,
canActivate: [PermissionsGuard],
data: { titleId: "billingHistory", permissions: [Permissions.ManageBilling] },
},
],
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class OrganizationBillingRoutingModule {}

View File

@@ -0,0 +1,23 @@
<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 routerLink="payment-method" class="list-group-item" routerLinkActive="active">
{{ "paymentMethod" | i18n }}
</a>
<a 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,7 @@
import { Component } from "@angular/core";
@Component({
selector: "app-org-billing-tab",
templateUrl: "organization-billing-tab.component.html",
})
export class OrganizationBillingTabComponent {}

View File

@@ -0,0 +1,21 @@
import { NgModule } from "@angular/core";
import { LooseComponentsModule } from "../../shared/loose-components.module";
import { SharedModule } from "../../shared/shared.module";
import { BillingSyncApiKeyComponent } from "./billing-sync-api-key.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: [
BillingSyncApiKeyComponent,
OrganizationBillingTabComponent,
OrganizationSubscriptionComponent,
OrgBillingHistoryViewComponent,
],
})
export class OrganizationBillingModule {}

View File

@@ -6,34 +6,27 @@
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="showManageTab">
<a class="nav-link" [routerLink]="manageRoute" routerLinkActive="active">
<i class="bwi bwi-sliders" aria-hidden="true"></i>
{{ "manage" | i18n }}
</a>
</li>
<li class="nav-item" *ngIf="showToolsTab">
<a class="nav-link" [routerLink]="toolsRoute" routerLinkActive="active">
<i class="bwi bwi-wrench" aria-hidden="true"></i>
{{ "tools" | i18n }}
</a>
</li>
<li class="nav-item" *ngIf="showSettingsTab">
<a class="nav-link" routerLink="settings" routerLinkActive="active">
<i class="bwi bwi-cogs" aria-hidden="true"></i>
{{ "settings" | i18n }}
</a>
</li>
</ul>
<bit-tab-group class="-tw-mb-px">
<bit-tab-item [route]="['vault']">{{ "vault" | i18n }}</bit-tab-item>
<bit-tab-item *ngIf="showMembersTab" [route]="['members']">{{
"members" | i18n
}}</bit-tab-item>
<bit-tab-item *ngIf="showGroupsTab" [route]="['groups']">{{
"groups" | i18n
}}</bit-tab-item>
<bit-tab-item *ngIf="showReportsTab" [route]="['reporting']">{{
reportTabLabel | i18n
}}</bit-tab-item>
<bit-tab-item *ngIf="showBillingTab" [route]="['billing']">{{
"billing" | i18n
}}</bit-tab-item>
<bit-tab-item *ngIf="showSettingsTab" [route]="['settings']">{{
"settings" | i18n
}}</bit-tab-item>
</bit-tab-group>
</div>
</div>
</div>
<router-outlet></router-outlet>
<app-footer></app-footer>

View File

@@ -50,49 +50,29 @@ export class OrganizationLayoutComponent implements OnInit, OnDestroy {
this.organization = await this.organizationService.get(this.organizationId);
}
get showManageTab(): boolean {
return NavigationPermissionsService.canAccessManage(this.organization);
}
get showToolsTab(): boolean {
return NavigationPermissionsService.canAccessTools(this.organization);
}
get showSettingsTab(): boolean {
return NavigationPermissionsService.canAccessSettings(this.organization);
}
get toolsRoute(): string {
return this.organization.canAccessImportExport
? "tools/import"
: "tools/exposed-passwords-report";
get showMembersTab(): boolean {
return NavigationPermissionsService.canAccessMembers(this.organization);
}
get manageRoute(): string {
let route: string;
switch (true) {
case this.organization.canManageUsers:
route = "manage/people";
break;
case this.organization.canViewAssignedCollections || this.organization.canViewAllCollections:
route = "manage/collections";
break;
case this.organization.canManageGroups:
route = "manage/groups";
break;
case this.organization.canManagePolicies:
route = "manage/policies";
break;
case this.organization.canManageSso:
route = "manage/sso";
break;
case this.organization.canManageScim:
route = "manage/scim";
break;
case this.organization.canAccessEventLogs:
route = "manage/events";
break;
}
return route;
get showGroupsTab(): boolean {
return (
this.organization.useGroups && NavigationPermissionsService.canAccessGroups(this.organization)
);
}
get showReportsTab(): boolean {
return NavigationPermissionsService.canAccessReporting(this.organization);
}
get showBillingTab(): boolean {
return NavigationPermissionsService.canAccessBilling(this.organization);
}
get reportTabLabel(): string {
return this.organization.useEvents ? "reporting" : "reports";
}
}

View File

@@ -1,77 +1,79 @@
<div class="page-header d-flex">
<h1>{{ "groups" | i18n }}</h1>
<div class="ml-auto d-flex">
<div>
<label class="sr-only" for="search">{{ "search" | i18n }}</label>
<input
type="search"
class="form-control form-control-sm"
id="search"
placeholder="{{ 'search' | i18n }}"
[(ngModel)]="searchText"
/>
<div class="container page-content">
<div class="page-header d-flex">
<h1>{{ "groups" | i18n }}</h1>
<div class="ml-auto d-flex">
<div>
<label class="sr-only" for="search">{{ "search" | i18n }}</label>
<input
type="search"
class="form-control form-control-sm"
id="search"
placeholder="{{ 'search' | i18n }}"
[(ngModel)]="searchText"
/>
</div>
<button type="button" class="btn btn-sm btn-outline-primary ml-3" (click)="add()">
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "newGroup" | i18n }}
</button>
</div>
<button type="button" class="btn btn-sm btn-outline-primary ml-3" (click)="add()">
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "newGroup" | i18n }}
</button>
</div>
</div>
<ng-container *ngIf="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="
!loading &&
(isPaging() ? pagedGroups : (groups | search: searchText:'name':'id')) as searchedGroups
"
>
<p *ngIf="!searchedGroups.length">{{ "noGroupsInList" | i18n }}</p>
<table
class="table table-hover table-list"
*ngIf="searchedGroups.length"
infiniteScroll
[infiniteScrollDistance]="1"
[infiniteScrollDisabled]="!isPaging()"
(scrolled)="loadMore()"
<ng-container *ngIf="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="
!loading &&
(isPaging() ? pagedGroups : (groups | search: searchText:'name':'id')) as searchedGroups
"
>
<tbody>
<tr *ngFor="let g of searchedGroups">
<td>
<a href="#" appStopClick (click)="edit(g)">{{ g.name }}</a>
</td>
<td class="table-list-options">
<div class="dropdown" appListDropdown>
<button
class="btn btn-outline-secondary dropdown-toggle"
type="button"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
appA11yTitle="{{ 'options' | i18n }}"
>
<i class="bwi bwi-cog bwi-lg" aria-hidden="true"></i>
</button>
<div class="dropdown-menu dropdown-menu-right">
<a class="dropdown-item" href="#" appStopClick (click)="users(g)">
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "users" | i18n }}
</a>
<a class="dropdown-item text-danger" href="#" appStopClick (click)="delete(g)">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ "delete" | i18n }}
</a>
<p *ngIf="!searchedGroups.length">{{ "noGroupsInList" | i18n }}</p>
<table
class="table table-hover table-list"
*ngIf="searchedGroups.length"
infiniteScroll
[infiniteScrollDistance]="1"
[infiniteScrollDisabled]="!isPaging()"
(scrolled)="loadMore()"
>
<tbody>
<tr *ngFor="let g of searchedGroups">
<td>
<a href="#" appStopClick (click)="edit(g)">{{ g.name }}</a>
</td>
<td class="table-list-options">
<div class="dropdown" appListDropdown>
<button
class="btn btn-outline-secondary dropdown-toggle"
type="button"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
appA11yTitle="{{ 'options' | i18n }}"
>
<i class="bwi bwi-cog bwi-lg" aria-hidden="true"></i>
</button>
<div class="dropdown-menu dropdown-menu-right">
<a class="dropdown-item" href="#" appStopClick (click)="users(g)">
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "users" | i18n }}
</a>
<a class="dropdown-item text-danger" href="#" appStopClick (click)="delete(g)">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ "delete" | i18n }}
</a>
</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</ng-container>
<ng-template #addEdit></ng-template>
<ng-template #usersTemplate></ng-template>
</td>
</tr>
</tbody>
</table>
</ng-container>
<ng-template #addEdit></ng-template>
<ng-template #usersTemplate></ng-template>
</div>

View File

@@ -48,7 +48,7 @@ export class GroupsComponent implements OnInit {
) {}
async ngOnInit() {
this.route.parent.parent.params.subscribe(async (params) => {
this.route.parent.params.subscribe(async (params) => {
this.organizationId = params.organizationId;
const organization = await this.organizationService.get(this.organizationId);
if (organization == null || !organization.useGroups) {

View File

@@ -1,293 +1,295 @@
<div class="page-header">
<h1>{{ "people" | i18n }}</h1>
<div class="mt-2 d-flex">
<div class="btn-group btn-group-sm" role="group">
<button
type="button"
class="btn btn-outline-secondary"
[ngClass]="{ active: status == null }"
(click)="filter(null)"
>
{{ "all" | i18n }}
<span bitBadge badgeType="info" *ngIf="allCount">{{ allCount }}</span>
</button>
<button
type="button"
class="btn btn-outline-secondary"
[ngClass]="{ active: status == userStatusType.Invited }"
(click)="filter(userStatusType.Invited)"
>
{{ "invited" | i18n }}
<span bitBadge badgeType="info" *ngIf="invitedCount">{{ invitedCount }}</span>
</button>
<button
type="button"
class="btn btn-outline-secondary"
[ngClass]="{ active: status == userStatusType.Accepted }"
(click)="filter(userStatusType.Accepted)"
>
{{ "accepted" | i18n }}
<span bitBadge badgeType="warning" *ngIf="acceptedCount">{{ acceptedCount }}</span>
</button>
<button
type="button"
class="btn btn-outline-secondary"
[ngClass]="{ active: status == userStatusType.Revoked }"
(click)="filter(userStatusType.Revoked)"
>
{{ "revoked" | i18n }}
<span bitBadge badgeType="info" *ngIf="revokedCount">{{ revokedCount }}</span>
</button>
</div>
<div class="ml-3">
<label class="sr-only" for="search">{{ "search" | i18n }}</label>
<input
type="search"
class="form-control form-control-sm"
id="search"
placeholder="{{ 'search' | i18n }}"
[(ngModel)]="searchText"
/>
</div>
<div class="dropdown ml-3" appListDropdown>
<button
class="btn btn-sm btn-outline-secondary dropdown-toggle"
type="button"
id="bulkActionsButton"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
appA11yTitle="{{ 'options' | i18n }}"
>
<i class="bwi bwi-cog" aria-hidden="true"></i>
</button>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="bulkActionsButton">
<button class="dropdown-item" appStopClick (click)="bulkReinvite()">
<i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
{{ "reinviteSelected" | i18n }}
<div class="container page-content">
<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"
class="btn btn-outline-secondary"
[ngClass]="{ active: status == null }"
(click)="filter(null)"
>
{{ "all" | i18n }}
<span bitBadge badgeType="info" *ngIf="allCount">{{ allCount }}</span>
</button>
<button
class="dropdown-item text-success"
appStopClick
(click)="bulkConfirm()"
*ngIf="showBulkConfirmUsers"
type="button"
class="btn btn-outline-secondary"
[ngClass]="{ active: status == userStatusType.Invited }"
(click)="filter(userStatusType.Invited)"
>
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
{{ "confirmSelected" | i18n }}
{{ "invited" | i18n }}
<span bitBadge badgeType="info" *ngIf="invitedCount">{{ invitedCount }}</span>
</button>
<button class="dropdown-item" appStopClick (click)="bulkRestore()">
<i class="bwi bwi-fw bwi-plus-circle" aria-hidden="true"></i>
{{ "restoreAccess" | i18n }}
<button
type="button"
class="btn btn-outline-secondary"
[ngClass]="{ active: status == userStatusType.Accepted }"
(click)="filter(userStatusType.Accepted)"
>
{{ "accepted" | i18n }}
<span bitBadge badgeType="warning" *ngIf="acceptedCount">{{ acceptedCount }}</span>
</button>
<button class="dropdown-item" appStopClick (click)="bulkRevoke()">
<i class="bwi bwi-fw bwi-minus-circle" aria-hidden="true"></i>
{{ "revokeAccess" | i18n }}
</button>
<button class="dropdown-item text-danger" appStopClick (click)="bulkRemove()">
<i class="bwi bwi-fw bwi-close" aria-hidden="true"></i>
{{ "remove" | i18n }}
</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item" appStopClick (click)="selectAll(true)">
<i class="bwi bwi-fw bwi-check-square" aria-hidden="true"></i>
{{ "selectAll" | i18n }}
</button>
<button class="dropdown-item" appStopClick (click)="selectAll(false)">
<i class="bwi bwi-fw bwi-minus-square" aria-hidden="true"></i>
{{ "unselectAll" | i18n }}
<button
type="button"
class="btn btn-outline-secondary"
[ngClass]="{ active: status == userStatusType.Revoked }"
(click)="filter(userStatusType.Revoked)"
>
{{ "revoked" | i18n }}
<span bitBadge badgeType="info" *ngIf="revokedCount">{{ revokedCount }}</span>
</button>
</div>
<div class="ml-3">
<label class="sr-only" for="search">{{ "search" | i18n }}</label>
<input
type="search"
class="form-control form-control-sm"
id="search"
placeholder="{{ 'search' | i18n }}"
[(ngModel)]="searchText"
/>
</div>
<div class="dropdown ml-3" appListDropdown>
<button
class="btn btn-sm btn-outline-secondary dropdown-toggle"
type="button"
id="bulkActionsButton"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
appA11yTitle="{{ 'options' | i18n }}"
>
<i class="bwi bwi-cog" aria-hidden="true"></i>
</button>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="bulkActionsButton">
<button class="dropdown-item" appStopClick (click)="bulkReinvite()">
<i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
{{ "reinviteSelected" | i18n }}
</button>
<button
class="dropdown-item text-success"
appStopClick
(click)="bulkConfirm()"
*ngIf="showBulkConfirmUsers"
>
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
{{ "confirmSelected" | i18n }}
</button>
<button class="dropdown-item" appStopClick (click)="bulkRestore()">
<i class="bwi bwi-fw bwi-plus-circle" aria-hidden="true"></i>
{{ "restoreAccess" | i18n }}
</button>
<button class="dropdown-item" appStopClick (click)="bulkRevoke()">
<i class="bwi bwi-fw bwi-minus-circle" aria-hidden="true"></i>
{{ "revokeAccess" | i18n }}
</button>
<button class="dropdown-item text-danger" appStopClick (click)="bulkRemove()">
<i class="bwi bwi-fw bwi-close" aria-hidden="true"></i>
{{ "remove" | i18n }}
</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item" appStopClick (click)="selectAll(true)">
<i class="bwi bwi-fw bwi-check-square" aria-hidden="true"></i>
{{ "selectAll" | i18n }}
</button>
<button class="dropdown-item" appStopClick (click)="selectAll(false)">
<i class="bwi bwi-fw bwi-minus-square" aria-hidden="true"></i>
{{ "unselectAll" | i18n }}
</button>
</div>
</div>
<button type="button" class="btn btn-sm btn-outline-primary ml-3" (click)="invite()">
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "inviteUser" | i18n }}
</button>
</div>
<button type="button" class="btn btn-sm btn-outline-primary ml-3" (click)="invite()">
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "inviteUser" | i18n }}
</button>
</div>
</div>
<ng-container *ngIf="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="
!loading &&
(isPaging() ? pagedUsers : (users | search: searchText:'name':'email':'id')) as searchedUsers
"
>
<p *ngIf="!searchedUsers.length">{{ "noUsersInList" | i18n }}</p>
<ng-container *ngIf="searchedUsers.length">
<app-callout
type="info"
title="{{ 'confirmUsers' | i18n }}"
icon="bwi bwi-check-circle"
*ngIf="showConfirmUsers"
>
{{ "usersNeedConfirmed" | i18n }}
</app-callout>
<table
class="table table-hover table-list"
infiniteScroll
[infiniteScrollDistance]="1"
[infiniteScrollDisabled]="!isPaging()"
(scrolled)="loadMore()"
>
<tbody>
<tr *ngFor="let u of searchedUsers">
<td (click)="checkUser(u)" class="table-list-checkbox">
<input type="checkbox" [(ngModel)]="u.checked" appStopProp />
</td>
<td width="30">
<app-avatar
[data]="u | userName"
[email]="u.email"
size="25"
[circle]="true"
[fontSize]="14"
>
</app-avatar>
</td>
<td>
<a href="#" appStopClick (click)="edit(u)">{{ u.email }}</a>
<span bitBadge badgeType="secondary" *ngIf="u.status === userStatusType.Invited">{{
"invited" | i18n
}}</span>
<span bitBadge badgeType="warning" *ngIf="u.status === userStatusType.Accepted">{{
"accepted" | i18n
}}</span>
<span bitBadge badgeType="secondary" *ngIf="u.status === userStatusType.Revoked">{{
"revoked" | i18n
}}</span>
<small class="text-muted d-block" *ngIf="u.name">{{ u.name }}</small>
</td>
<td>
<ng-container *ngIf="u.twoFactorEnabled">
<i
class="bwi bwi-lock"
title="{{ 'userUsingTwoStep' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "userUsingTwoStep" | i18n }}</span>
</ng-container>
<ng-container *ngIf="showEnrolledStatus(u)">
<i
class="bwi bwi-key"
title="{{ 'enrolledPasswordReset' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "enrolledPasswordReset" | i18n }}</span>
</ng-container>
</td>
<td>
<span *ngIf="u.type === userType.Owner">{{ "owner" | i18n }}</span>
<span *ngIf="u.type === userType.Admin">{{ "admin" | i18n }}</span>
<span *ngIf="u.type === userType.Manager">{{ "manager" | i18n }}</span>
<span *ngIf="u.type === userType.User">{{ "user" | i18n }}</span>
<span *ngIf="u.type === userType.Custom">{{ "custom" | i18n }}</span>
</td>
<td class="table-list-options">
<div class="dropdown" appListDropdown>
<button
class="btn btn-outline-secondary dropdown-toggle"
type="button"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
appA11yTitle="{{ 'options' | i18n }}"
>
<i class="bwi bwi-cog bwi-lg" aria-hidden="true"></i>
</button>
<div class="dropdown-menu dropdown-menu-right">
<a
class="dropdown-item"
href="#"
appStopClick
(click)="reinvite(u)"
*ngIf="u.status === userStatusType.Invited"
>
<i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
{{ "resendInvitation" | i18n }}
</a>
<a
class="dropdown-item text-success"
href="#"
appStopClick
(click)="confirm(u)"
*ngIf="u.status === userStatusType.Accepted"
>
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
{{ "confirm" | i18n }}
</a>
<a
class="dropdown-item"
href="#"
appStopClick
(click)="groups(u)"
*ngIf="accessGroups"
>
<i class="bwi bwi-fw bwi-sitemap" aria-hidden="true"></i>
{{ "groups" | i18n }}
</a>
<a
class="dropdown-item"
href="#"
appStopClick
(click)="events(u)"
*ngIf="accessEvents && u.status === userStatusType.Confirmed"
>
<i class="bwi bwi-fw bwi-file-text" aria-hidden="true"></i>
{{ "eventLogs" | i18n }}
</a>
<a
class="dropdown-item"
href="#"
appStopClick
(click)="resetPassword(u)"
*ngIf="allowResetPassword(u)"
>
<i class="bwi bwi-fw bwi-key" aria-hidden="true"></i>
{{ "resetPassword" | i18n }}
</a>
<a
class="dropdown-item"
href="#"
appStopClick
(click)="restore(u)"
*ngIf="u.status === userStatusType.Revoked"
>
<i class="bwi bwi-fw bwi-plus-circle" aria-hidden="true"></i>
{{ "restoreAccess" | i18n }}
</a>
<a
class="dropdown-item"
href="#"
appStopClick
(click)="revoke(u)"
*ngIf="u.status !== userStatusType.Revoked"
>
<i class="bwi bwi-fw bwi-minus-circle" aria-hidden="true"></i>
{{ "revokeAccess" | i18n }}
</a>
<a class="dropdown-item text-danger" href="#" appStopClick (click)="remove(u)">
<i class="bwi bwi-fw bwi-close" aria-hidden="true"></i>
{{ "remove" | i18n }}
</a>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<ng-container *ngIf="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>
<ng-template #addEdit></ng-template>
<ng-template #groupsTemplate></ng-template>
<ng-template #eventsTemplate></ng-template>
<ng-template #confirmTemplate></ng-template>
<ng-template #resetPasswordTemplate></ng-template>
<ng-template #bulkStatusTemplate></ng-template>
<ng-template #bulkConfirmTemplate></ng-template>
<ng-template #bulkRemoveTemplate></ng-template>
<ng-container
*ngIf="
!loading &&
(isPaging() ? pagedUsers : (users | search: searchText:'name':'email':'id')) as searchedUsers
"
>
<p *ngIf="!searchedUsers.length">{{ "noUsersInList" | i18n }}</p>
<ng-container *ngIf="searchedUsers.length">
<app-callout
type="info"
title="{{ 'confirmUsers' | i18n }}"
icon="bwi bwi-check-circle"
*ngIf="showConfirmUsers"
>
{{ "usersNeedConfirmed" | i18n }}
</app-callout>
<table
class="table table-hover table-list"
infiniteScroll
[infiniteScrollDistance]="1"
[infiniteScrollDisabled]="!isPaging()"
(scrolled)="loadMore()"
>
<tbody>
<tr *ngFor="let u of searchedUsers">
<td (click)="checkUser(u)" class="table-list-checkbox">
<input type="checkbox" [(ngModel)]="u.checked" appStopProp />
</td>
<td width="30">
<app-avatar
[data]="u | userName"
[email]="u.email"
size="25"
[circle]="true"
[fontSize]="14"
>
</app-avatar>
</td>
<td>
<a href="#" appStopClick (click)="edit(u)">{{ u.email }}</a>
<span bitBadge badgeType="secondary" *ngIf="u.status === userStatusType.Invited">{{
"invited" | i18n
}}</span>
<span bitBadge badgeType="warning" *ngIf="u.status === userStatusType.Accepted">{{
"accepted" | i18n
}}</span>
<span bitBadge badgeType="secondary" *ngIf="u.status === userStatusType.Revoked">{{
"revoked" | i18n
}}</span>
<small class="text-muted d-block" *ngIf="u.name">{{ u.name }}</small>
</td>
<td>
<ng-container *ngIf="u.twoFactorEnabled">
<i
class="bwi bwi-lock"
title="{{ 'userUsingTwoStep' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "userUsingTwoStep" | i18n }}</span>
</ng-container>
<ng-container *ngIf="showEnrolledStatus(u)">
<i
class="bwi bwi-key"
title="{{ 'enrolledPasswordReset' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "enrolledPasswordReset" | i18n }}</span>
</ng-container>
</td>
<td>
<span *ngIf="u.type === userType.Owner">{{ "owner" | i18n }}</span>
<span *ngIf="u.type === userType.Admin">{{ "admin" | i18n }}</span>
<span *ngIf="u.type === userType.Manager">{{ "manager" | i18n }}</span>
<span *ngIf="u.type === userType.User">{{ "user" | i18n }}</span>
<span *ngIf="u.type === userType.Custom">{{ "custom" | i18n }}</span>
</td>
<td class="table-list-options">
<div class="dropdown" appListDropdown>
<button
class="btn btn-outline-secondary dropdown-toggle"
type="button"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
appA11yTitle="{{ 'options' | i18n }}"
>
<i class="bwi bwi-cog bwi-lg" aria-hidden="true"></i>
</button>
<div class="dropdown-menu dropdown-menu-right">
<a
class="dropdown-item"
href="#"
appStopClick
(click)="reinvite(u)"
*ngIf="u.status === userStatusType.Invited"
>
<i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
{{ "resendInvitation" | i18n }}
</a>
<a
class="dropdown-item text-success"
href="#"
appStopClick
(click)="confirm(u)"
*ngIf="u.status === userStatusType.Accepted"
>
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
{{ "confirm" | i18n }}
</a>
<a
class="dropdown-item"
href="#"
appStopClick
(click)="groups(u)"
*ngIf="accessGroups"
>
<i class="bwi bwi-fw bwi-sitemap" aria-hidden="true"></i>
{{ "groups" | i18n }}
</a>
<a
class="dropdown-item"
href="#"
appStopClick
(click)="events(u)"
*ngIf="accessEvents && u.status === userStatusType.Confirmed"
>
<i class="bwi bwi-fw bwi-file-text" aria-hidden="true"></i>
{{ "eventLogs" | i18n }}
</a>
<a
class="dropdown-item"
href="#"
appStopClick
(click)="resetPassword(u)"
*ngIf="allowResetPassword(u)"
>
<i class="bwi bwi-fw bwi-key" aria-hidden="true"></i>
{{ "resetPassword" | i18n }}
</a>
<a
class="dropdown-item"
href="#"
appStopClick
(click)="restore(u)"
*ngIf="u.status === userStatusType.Revoked"
>
<i class="bwi bwi-fw bwi-plus-circle" aria-hidden="true"></i>
{{ "restoreAccess" | i18n }}
</a>
<a
class="dropdown-item"
href="#"
appStopClick
(click)="revoke(u)"
*ngIf="u.status !== userStatusType.Revoked"
>
<i class="bwi bwi-fw bwi-minus-circle" aria-hidden="true"></i>
{{ "revokeAccess" | i18n }}
</a>
<a class="dropdown-item text-danger" href="#" appStopClick (click)="remove(u)">
<i class="bwi bwi-fw bwi-close" aria-hidden="true"></i>
{{ "remove" | i18n }}
</a>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</ng-container>
</ng-container>
<ng-template #addEdit></ng-template>
<ng-template #groupsTemplate></ng-template>
<ng-template #eventsTemplate></ng-template>
<ng-template #confirmTemplate></ng-template>
<ng-template #resetPasswordTemplate></ng-template>
<ng-template #bulkStatusTemplate></ng-template>
<ng-template #bulkConfirmTemplate></ng-template>
<ng-template #bulkRemoveTemplate></ng-template>
</div>

View File

@@ -110,7 +110,7 @@ export class PeopleComponent
}
async ngOnInit() {
this.route.parent.parent.params.subscribe(async (params) => {
this.route.parent.params.subscribe(async (params) => {
this.organizationId = params.organizationId;
const organization = await this.organizationService.get(this.organizationId);
if (!organization.canManageUsers) {

View File

@@ -2,28 +2,15 @@ import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { AuthGuard } from "@bitwarden/angular/guards/auth.guard";
import { Permissions } from "@bitwarden/common/enums/permissions";
import { PermissionsGuard } from "./guards/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 { NavigationPermissionsService } from "./services/navigation-permissions.service";
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 = [
@@ -40,137 +27,6 @@ const routes: Routes = [
path: "vault",
loadChildren: () => VaultModule,
},
{
path: "tools",
component: ToolsComponent,
canActivate: [PermissionsGuard],
data: { permissions: NavigationPermissionsService.getPermissions("tools") },
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: [PermissionsGuard],
data: {
titleId: "exposedPasswordsReport",
permissions: [Permissions.AccessReports],
},
},
{
path: "inactive-two-factor-report",
component: InactiveTwoFactorReportComponent,
canActivate: [PermissionsGuard],
data: {
titleId: "inactive2faReport",
permissions: [Permissions.AccessReports],
},
},
{
path: "reused-passwords-report",
component: ReusedPasswordsReportComponent,
canActivate: [PermissionsGuard],
data: {
titleId: "reusedPasswordsReport",
permissions: [Permissions.AccessReports],
},
},
{
path: "unsecured-websites-report",
component: UnsecuredWebsitesReportComponent,
canActivate: [PermissionsGuard],
data: {
titleId: "unsecuredWebsitesReport",
permissions: [Permissions.AccessReports],
},
},
{
path: "weak-passwords-report",
component: WeakPasswordsReportComponent,
canActivate: [PermissionsGuard],
data: {
titleId: "weakPasswordsReport",
permissions: [Permissions.AccessReports],
},
},
],
},
{
path: "manage",
component: ManageComponent,
canActivate: [PermissionsGuard],
data: {
permissions: NavigationPermissionsService.getPermissions("manage"),
},
children: [
{
path: "",
pathMatch: "full",
redirectTo: "people",
},
{
path: "collections",
component: CollectionsComponent,
canActivate: [PermissionsGuard],
data: {
titleId: "collections",
permissions: [
Permissions.CreateNewCollections,
Permissions.EditAnyCollection,
Permissions.DeleteAnyCollection,
Permissions.EditAssignedCollections,
Permissions.DeleteAssignedCollections,
],
},
},
{
path: "events",
component: EventsComponent,
canActivate: [PermissionsGuard],
data: {
titleId: "eventLogs",
permissions: [Permissions.AccessEventLogs],
},
},
{
path: "groups",
component: GroupsComponent,
canActivate: [PermissionsGuard],
data: {
titleId: "groups",
permissions: [Permissions.ManageGroups],
},
},
{
path: "people",
component: PeopleComponent,
canActivate: [PermissionsGuard],
data: {
titleId: "people",
permissions: [Permissions.ManageUsers, Permissions.ManageUsersPassword],
},
},
{
path: "policies",
component: PoliciesComponent,
canActivate: [PermissionsGuard],
data: {
titleId: "policies",
permissions: [Permissions.ManagePolicies],
},
},
],
},
{
path: "settings",
component: SettingsComponent,
@@ -178,25 +34,44 @@ const routes: Routes = [
data: { permissions: NavigationPermissionsService.getPermissions("settings") },
children: [
{ path: "", pathMatch: "full", redirectTo: "account" },
{ path: "account", component: AccountComponent, data: { titleId: "myOrganization" } },
{ path: "account", component: AccountComponent, data: { titleId: "organizationInfo" } },
{
path: "two-factor",
component: TwoFactorSetupComponent,
data: { titleId: "twoStepLogin" },
},
{
path: "billing",
component: OrganizationBillingComponent,
canActivate: [PermissionsGuard],
data: { titleId: "billing", permissions: [Permissions.ManageBilling] },
},
{
path: "subscription",
component: OrganizationSubscriptionComponent,
data: { titleId: "subscription" },
},
],
},
{
path: "members",
component: PeopleComponent,
canActivate: [PermissionsGuard],
data: {
titleId: "members",
permissions: NavigationPermissionsService.getPermissions("members"),
},
},
{
path: "groups",
component: GroupsComponent,
canActivate: [PermissionsGuard],
data: {
titleId: "groups",
permissions: NavigationPermissionsService.getPermissions("groups"),
},
},
{
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,98 @@
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { Permissions } from "@bitwarden/common/enums/permissions";
import { PermissionsGuard } from "../guards/permissions.guard";
import { EventsComponent } from "../manage/events.component";
import { NavigationPermissionsService } from "../services/navigation-permissions.service";
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: [PermissionsGuard],
data: { permissions: NavigationPermissionsService.getPermissions("reporting") },
children: [
{ path: "", pathMatch: "full", redirectTo: "reports" },
{
path: "reports",
component: ReportsHomeComponent,
canActivate: [PermissionsGuard],
data: {
titleId: "reports",
permissions: [Permissions.AccessReports],
},
children: [
{
path: "exposed-passwords-report",
component: ExposedPasswordsReportComponent,
canActivate: [PermissionsGuard],
data: {
titleId: "exposedPasswordsReport",
permissions: [Permissions.AccessReports],
},
},
{
path: "inactive-two-factor-report",
component: InactiveTwoFactorReportComponent,
canActivate: [PermissionsGuard],
data: {
titleId: "inactive2faReport",
permissions: [Permissions.AccessReports],
},
},
{
path: "reused-passwords-report",
component: ReusedPasswordsReportComponent,
canActivate: [PermissionsGuard],
data: {
titleId: "reusedPasswordsReport",
permissions: [Permissions.AccessReports],
},
},
{
path: "unsecured-websites-report",
component: UnsecuredWebsitesReportComponent,
canActivate: [PermissionsGuard],
data: {
titleId: "unsecuredWebsitesReport",
permissions: [Permissions.AccessReports],
},
},
{
path: "weak-passwords-report",
component: WeakPasswordsReportComponent,
canActivate: [PermissionsGuard],
data: {
titleId: "weakPasswordsReport",
permissions: [Permissions.AccessReports],
},
},
],
},
{
path: "events",
component: EventsComponent,
canActivate: [PermissionsGuard],
data: {
titleId: "eventLogs",
permissions: [Permissions.AccessEventLogs],
},
},
],
},
];
@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="reports"
class="list-group-item"
routerLinkActive="active"
*ngIf="organization.canAccessReports"
>
{{ "reports" | i18n }}
</a>
<a
routerLink="events"
class="list-group-item"
routerLinkActive="active"
*ngIf="organization.canAccessEventLogs && accessEvents"
>
{{ "eventLogs" | 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,24 @@
import { Component, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { OrganizationService } from "@bitwarden/common/abstractions/organization.service";
import { Organization } from "@bitwarden/common/models/domain/organization";
@Component({
selector: "app-org-reporting",
templateUrl: "reporting.component.html",
})
export class ReportingComponent implements OnInit {
organization: Organization;
accessEvents = false;
showLeftNav = true;
constructor(private route: ActivatedRoute, private organizationService: OrganizationService) {}
ngOnInit() {
this.route.parent.params.subscribe(async (params) => {
this.organization = await this.organizationService.get(params.organizationId);
this.accessEvents = this.showLeftNav = this.organization.useEvents;
});
}
}

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(
takeUntil(this.destrory$),
filter((event) => event instanceof NavigationEnd)
)
.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

@@ -15,8 +15,11 @@ const permissions = {
Permissions.ManageSso,
Permissions.ManageScim,
],
tools: [Permissions.AccessImportExport, Permissions.AccessReports],
settings: [Permissions.ManageOrganization],
members: [Permissions.ManageUsers, Permissions.ManageUsersPassword],
groups: [Permissions.ManageGroups],
reporting: [Permissions.AccessReports, Permissions.AccessEventLogs],
billing: [Permissions.ManageBilling],
settings: [Permissions.ManageOrganization, Permissions.ManagePolicies, Permissions.ManageSso],
};
export class NavigationPermissionsService {
@@ -30,21 +33,30 @@ export class NavigationPermissionsService {
static canAccessAdmin(organization: Organization): boolean {
return (
this.canAccessTools(organization) ||
this.canAccessSettings(organization) ||
this.canAccessManage(organization)
this.canAccessMembers(organization) ||
this.canAccessGroups(organization) ||
this.canAccessReporting(organization) ||
this.canAccessBilling(organization)
);
}
static canAccessTools(organization: Organization): boolean {
return organization.hasAnyPermission(NavigationPermissionsService.getPermissions("tools"));
static canAccessMembers(organization: Organization): boolean {
return organization.hasAnyPermission(NavigationPermissionsService.getPermissions("members"));
}
static canAccessGroups(organization: Organization): boolean {
return organization.hasAnyPermission(NavigationPermissionsService.getPermissions("groups"));
}
static canAccessReporting(organization: Organization): boolean {
return organization.hasAnyPermission(NavigationPermissionsService.getPermissions("reporting"));
}
static canAccessBilling(organization: Organization): boolean {
return organization.hasAnyPermission(NavigationPermissionsService.getPermissions("billing"));
}
static canAccessSettings(organization: Organization): boolean {
return organization.hasAnyPermission(NavigationPermissionsService.getPermissions("settings"));
}
static canAccessManage(organization: Organization): boolean {
return organization.hasAnyPermission(NavigationPermissionsService.getPermissions("manage"));
}
}

View File

@@ -1,5 +1,5 @@
<div class="page-header">
<h1>{{ "myOrganization" | i18n }}</h1>
<h1>{{ "organizationInfo" | i18n }}</h1>
</div>
<div *ngIf="loading">
<i
@@ -87,31 +87,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

@@ -15,7 +15,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;
@@ -40,7 +38,6 @@ export class AccountComponent {
canUseApi = false;
org: OrganizationResponse;
formPromise: Promise<any>;
taxFormPromise: Promise<any>;
private organizationId: string;
@@ -104,12 +101,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

@@ -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,154 +0,0 @@
import { Component, OnInit } from "@angular/core";
import { ActivatedRoute } 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 { 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/verifyBankRequest";
import { BillingResponse } from "@bitwarden/common/models/response/billingResponse";
@Component({
selector: "app-org-billing",
templateUrl: "./organization-billing.component.html",
})
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<any>;
// TODO - Make sure to properly split out the billing/invoice and payment method/account during org admin refresh
constructor(
private apiService: ApiService,
private i18nService: I18nService,
private route: ActivatedRoute,
private platformUtilsService: PlatformUtilsService,
private logService: LogService
) {}
async ngOnInit() {
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.apiService.getOrganizationBilling(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.apiService.postOrganizationVerifyBank(
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

@@ -5,18 +5,7 @@
<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 }}
</a>
<a
routerLink="billing"
class="list-group-item"
routerLinkActive="active"
*ngIf="showBilling"
>
{{ "billing" | i18n }}
{{ "organizationInfo" | i18n }}
</a>
<a
routerLink="two-factor"

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

@@ -1,4 +1,4 @@
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/billingHistoryResponse";
@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/billingHistoryResponse";
@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,72 @@
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 { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { PaymentMethodType } from "@bitwarden/common/enums/paymentMethodType";
import { VerifyBankRequest } from "@bitwarden/common/models/request/verifyBankRequest";
import { BillingPaymentResponse } from "@bitwarden/common/models/response/billingPaymentResponse";
import { OrganizationResponse } from "@bitwarden/common/models/response/organizationResponse";
import { TaxInfoComponent } from "./tax-info.component";
@Component({
selector: "app-payment-method",
templateUrl: "payment-method.component.html",
})
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 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;
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 +74,16 @@ export class PaymentMethodComponent implements OnInit {
return;
}
this.loading = true;
this.billing = await this.apiService.getUserBillingPayment();
if (this.forOrganization) {
const billingPromise = this.apiService.getOrganizationBilling(this.organizationId);
const orgPromise = this.apiService.getOrganization(this.organizationId);
[this.billing, this.org] = await Promise.all([billingPromise, orgPromise]);
} else {
this.billing = await this.apiService.getUserBillingPayment();
}
this.loading = false;
}
@@ -85,6 +129,37 @@ 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.apiService.postOrganizationVerifyBank(
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 +172,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,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

@@ -59,13 +59,10 @@ import { SingleOrgPolicyComponent } from "../organizations/policies/single-org.c
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 { ImageSubscriptionHiddenComponent as OrgSubscriptionHiddenComponent } from "../organizations/settings/image-subscription-hidden.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";
@@ -89,6 +86,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";
@@ -128,7 +127,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";
@@ -177,7 +175,6 @@ import { SharedModule } from ".";
AdjustSubscription,
ApiKeyComponent,
AttachmentsComponent,
BillingSyncApiKeyComponent,
BillingSyncKeyComponent,
BulkActionsComponent,
BulkDeleteComponent,
@@ -216,10 +213,8 @@ import { SharedModule } from ".";
OrganizationSwitcherComponent,
OrgAccountComponent,
OrgAddEditComponent,
OrganizationBillingComponent,
OrganizationLayoutComponent,
OrganizationPlansComponent,
OrganizationSubscriptionComponent,
OrgAttachmentsComponent,
OrgBulkConfirmComponent,
OrgBulkRestoreRevokeComponent,
@@ -299,7 +294,8 @@ import { SharedModule } from ".";
UpdateLicenseComponent,
UpdatePasswordComponent,
UpdateTempPasswordComponent,
UserBillingHistoryComponent,
BillingHistoryComponent,
BillingHistoryViewComponent,
UserLayoutComponent,
UserSubscriptionComponent,
UserVerificationComponent,
@@ -360,10 +356,8 @@ import { SharedModule } from ".";
OrganizationSwitcherComponent,
OrgAccountComponent,
OrgAddEditComponent,
OrganizationBillingComponent,
OrganizationLayoutComponent,
OrganizationPlansComponent,
OrganizationSubscriptionComponent,
OrgAttachmentsComponent,
OrgBulkConfirmComponent,
OrgBulkRestoreRevokeComponent,
@@ -442,7 +436,8 @@ import { SharedModule } from ".";
UpdateLicenseComponent,
UpdatePasswordComponent,
UpdateTempPasswordComponent,
UserBillingHistoryComponent,
BillingHistoryComponent,
BillingHistoryViewComponent,
UserLayoutComponent,
UserSubscriptionComponent,
UserVerificationComponent,

View File

@@ -14,6 +14,7 @@ import {
FormFieldModule,
SubmitButtonModule,
MenuModule,
TabsModule,
IconModule,
} from "@bitwarden/components";
@@ -46,6 +47,7 @@ import "./locales";
FormFieldModule,
SubmitButtonModule,
IconModule,
TabsModule,
],
exports: [
CommonModule,
@@ -65,6 +67,7 @@ import "./locales";
FormFieldModule,
SubmitButtonModule,
IconModule,
TabsModule,
],
providers: [DatePipe],
bootstrap: [],

View File

@@ -1481,6 +1481,10 @@
"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 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"
},
@@ -2925,6 +2929,9 @@
"myOrganization": {
"message": "My Organization"
},
"organizationInfo": {
"message": "Organization Info"
},
"deleteOrganization": {
"message": "Delete Organization"
},
@@ -5304,6 +5311,12 @@
"on": {
"message": "On"
},
"members": {
"message": "Members"
},
"reporting": {
"message": "Reporting"
},
"cardBrandMir": {
"message": "Mir"
},

View File

@@ -23,6 +23,10 @@ export class BillingResponse extends BaseResponse {
this.invoices = invoices.map((i: any) => new BillingInvoiceResponse(i));
}
}
get hasNoHistory() {
return this.invoices.length == 0 && this.transactions.length == 0;
}
}
export class BillingSourceResponse extends BaseResponse {

View File

@@ -78,6 +78,9 @@ module.exports = {
"50vw": "50vw",
"75vw": "75vw",
},
maxWidth: ({ theme }) => ({
...theme("width"),
}),
},
},
plugins: [],