mirror of
https://github.com/bitwarden/browser
synced 2026-02-27 01:53:23 +00:00
Merge branch 'main' into PM-9022-scaffold-the-extension-and-build-pipeline
This commit is contained in:
@@ -8,7 +8,6 @@ import {
|
||||
AuthRequestServiceAbstraction,
|
||||
AuthRequestService,
|
||||
LoginEmailServiceAbstraction,
|
||||
LoginEmailService,
|
||||
LogoutReason,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service";
|
||||
@@ -708,8 +707,6 @@ export default class MainBackground {
|
||||
this.stateProvider,
|
||||
);
|
||||
|
||||
this.loginEmailService = new LoginEmailService(this.stateProvider);
|
||||
|
||||
this.ssoLoginService = new SsoLoginService(this.stateProvider);
|
||||
|
||||
this.userVerificationApiService = new UserVerificationApiService(this.apiService);
|
||||
@@ -1255,9 +1252,6 @@ export default class MainBackground {
|
||||
clearCaches();
|
||||
|
||||
if (userId == null) {
|
||||
this.loginEmailService.setRememberEmail(false);
|
||||
await this.loginEmailService.saveEmailSettings();
|
||||
|
||||
await this.refreshBadge();
|
||||
await this.refreshMenu();
|
||||
await this.overlayBackground?.updateOverlayCiphers(); // null in popup only contexts
|
||||
|
||||
@@ -14,6 +14,8 @@ switch (process.platform) {
|
||||
|
||||
default:
|
||||
targets = ['x86_64-unknown-linux-musl'];
|
||||
process.env["PKG_CONFIG_ALLOW_CROSS"] = "1";
|
||||
process.env["PKG_CONFIG_ALL_STATIC"] = "1";
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"**/node_modules/argon2/package.json",
|
||||
"**/node_modules/argon2/build/Release/argon2.node"
|
||||
],
|
||||
"electronVersion": "30.1.2",
|
||||
"electronVersion": "31.1.0",
|
||||
"generateUpdatesFilesForAllChannels": true,
|
||||
"publish": {
|
||||
"provider": "generic",
|
||||
|
||||
@@ -165,13 +165,10 @@ export class AccountSwitcherComponent {
|
||||
async addAccount() {
|
||||
this.close();
|
||||
|
||||
this.loginEmailService.setRememberEmail(false);
|
||||
await this.loginEmailService.saveEmailSettings();
|
||||
|
||||
await this.router.navigate(["/login"]);
|
||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
await this.stateService.clearDecryptedData(activeAccount?.id as UserId);
|
||||
await this.accountService.switchAccount(null);
|
||||
await this.router.navigate(["/login"]);
|
||||
}
|
||||
|
||||
private async createInactiveAccounts(baseAccounts: {
|
||||
|
||||
@@ -291,8 +291,7 @@ export class Main {
|
||||
});
|
||||
},
|
||||
(e: any) => {
|
||||
// eslint-disable-next-line
|
||||
console.error(e);
|
||||
this.logService.error("Error while running migrations:", e);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,9 +82,14 @@ export class ElectronStorageService implements AbstractStorageService {
|
||||
}
|
||||
|
||||
save(key: string, obj: unknown): Promise<void> {
|
||||
if (obj === undefined) {
|
||||
return this.remove(key);
|
||||
}
|
||||
|
||||
if (obj instanceof Set) {
|
||||
obj = Array.from(obj);
|
||||
}
|
||||
|
||||
this.store.set(key, obj);
|
||||
this.updatesSubject.next({ key, updateType: "save" });
|
||||
return Promise.resolve();
|
||||
|
||||
@@ -71,6 +71,11 @@ type GroupDetailsRow = {
|
||||
collectionNames?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated To be replaced with NewGroupsComponent which significantly refactors this component.
|
||||
* The GroupsComponentRefactor flag switches between the old and new components; this component will be removed when
|
||||
* the feature flag is removed.
|
||||
*/
|
||||
@Component({
|
||||
selector: "app-org-groups",
|
||||
templateUrl: "groups.component.html",
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<app-header>
|
||||
<bit-search
|
||||
[placeholder]="'searchGroups' | i18n"
|
||||
[formControl]="searchControl"
|
||||
class="tw-w-80"
|
||||
></bit-search>
|
||||
<button bitButton type="button" buttonType="primary" (click)="add()">
|
||||
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
|
||||
{{ "newGroup" | i18n }}
|
||||
</button>
|
||||
</app-header>
|
||||
|
||||
<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">
|
||||
<p *ngIf="!dataSource.filteredData.length">{{ "noGroupsInList" | i18n }}</p>
|
||||
<!-- The padding on the bottom of the cdk-virtual-scroll-viewport element is required to prevent table row content
|
||||
from overflowing the <main> element. -->
|
||||
<cdk-virtual-scroll-viewport scrollWindow [itemSize]="rowHeight" class="tw-pb-8">
|
||||
<bit-table *ngIf="dataSource.filteredData.length" [dataSource]="dataSource">
|
||||
<ng-container header>
|
||||
<tr>
|
||||
<th bitCell class="tw-w-20">
|
||||
<input
|
||||
type="checkbox"
|
||||
bitCheckbox
|
||||
class="tw-mr-2"
|
||||
(change)="toggleAllVisible($event)"
|
||||
id="selectAll"
|
||||
/>
|
||||
<label class="tw-mb-0 !tw-font-bold !tw-text-muted" for="selectAll">{{
|
||||
"all" | i18n
|
||||
}}</label>
|
||||
</th>
|
||||
<th bitCell bitSortable="name" default>{{ "name" | i18n }}</th>
|
||||
<th bitCell>{{ "collections" | i18n }}</th>
|
||||
<th bitCell class="tw-w-10">
|
||||
<button
|
||||
[bitMenuTriggerFor]="headerMenu"
|
||||
type="button"
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
size="small"
|
||||
appA11yTitle="{{ 'options' | i18n }}"
|
||||
></button>
|
||||
|
||||
<bit-menu #headerMenu>
|
||||
<button type="button" bitMenuItem (click)="deleteAllSelected()">
|
||||
<span class="tw-text-danger"
|
||||
><i aria-hidden="true" class="bwi bwi-trash"></i> {{ "delete" | i18n }}</span
|
||||
>
|
||||
</button>
|
||||
</bit-menu>
|
||||
</th>
|
||||
</tr>
|
||||
</ng-container>
|
||||
<ng-template body let-rows$>
|
||||
<tr bitRow *cdkVirtualFor="let g of rows$" [ngClass]="rowHeightClass">
|
||||
<td bitCell (click)="check(g)" class="tw-cursor-pointer">
|
||||
<input type="checkbox" bitCheckbox [(ngModel)]="g.checked" />
|
||||
</td>
|
||||
<td bitCell class="tw-cursor-pointer tw-font-bold" (click)="edit(g)">
|
||||
<button type="button" bitLink>
|
||||
{{ g.details.name }}
|
||||
</button>
|
||||
</td>
|
||||
<td bitCell (click)="edit(g, ModalTabType.Collections)" class="tw-cursor-pointer">
|
||||
<bit-badge-list
|
||||
[items]="g.collectionNames"
|
||||
[maxItems]="3"
|
||||
variant="secondary"
|
||||
></bit-badge-list>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<button
|
||||
[bitMenuTriggerFor]="rowMenu"
|
||||
type="button"
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
size="small"
|
||||
appA11yTitle="{{ 'options' | i18n }}"
|
||||
></button>
|
||||
|
||||
<bit-menu #rowMenu>
|
||||
<button type="button" bitMenuItem (click)="edit(g)">
|
||||
<i aria-hidden="true" class="bwi bwi-pencil-square"></i> {{ "editInfo" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem (click)="edit(g, ModalTabType.Members)">
|
||||
<i aria-hidden="true" class="bwi bwi-user"></i> {{ "members" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem (click)="edit(g, ModalTabType.Collections)">
|
||||
<i aria-hidden="true" class="bwi bwi-collection"></i> {{ "collections" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem (click)="delete(g)">
|
||||
<span class="tw-text-danger"
|
||||
><i aria-hidden="true" class="bwi bwi-trash"></i> {{ "delete" | i18n }}</span
|
||||
>
|
||||
</button>
|
||||
</bit-menu>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</bit-table>
|
||||
</cdk-virtual-scroll-viewport>
|
||||
</ng-container>
|
||||
@@ -0,0 +1,255 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { FormControl } from "@angular/forms";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
concatMap,
|
||||
from,
|
||||
lastValueFrom,
|
||||
map,
|
||||
switchMap,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
import { debounceTime, first } from "rxjs/operators";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
|
||||
import { CollectionData } from "@bitwarden/common/vault/models/data/collection.data";
|
||||
import { Collection } from "@bitwarden/common/vault/models/domain/collection";
|
||||
import {
|
||||
CollectionDetailsResponse,
|
||||
CollectionResponse,
|
||||
} from "@bitwarden/common/vault/models/response/collection.response";
|
||||
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
|
||||
import { DialogService, TableDataSource, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { InternalGroupService as GroupService, GroupView } from "../core";
|
||||
|
||||
import {
|
||||
GroupAddEditDialogResultType,
|
||||
GroupAddEditTabType,
|
||||
openGroupAddEditDialog,
|
||||
} from "./group-add-edit.component";
|
||||
|
||||
type GroupDetailsRow = {
|
||||
/**
|
||||
* Details used for displaying group information
|
||||
*/
|
||||
details: GroupView;
|
||||
|
||||
/**
|
||||
* True if the group is selected in the table
|
||||
*/
|
||||
checked?: boolean;
|
||||
|
||||
/**
|
||||
* A list of collection names the group has access to
|
||||
*/
|
||||
collectionNames?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom filter predicate that filters the groups table by id and name only.
|
||||
* This is required because the default implementation searches by all properties, which can unintentionally match
|
||||
* with members' names (who are assigned to the group) or collection names (which the group has access to).
|
||||
*/
|
||||
const groupsFilter = (filter: string) => {
|
||||
const transformedFilter = filter.trim().toLowerCase();
|
||||
return (data: GroupDetailsRow) => {
|
||||
const group = data.details;
|
||||
|
||||
return (
|
||||
group.id.toLowerCase().indexOf(transformedFilter) != -1 ||
|
||||
group.name.toLowerCase().indexOf(transformedFilter) != -1
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@Component({
|
||||
templateUrl: "new-groups.component.html",
|
||||
})
|
||||
export class NewGroupsComponent {
|
||||
loading = true;
|
||||
organizationId: string;
|
||||
|
||||
protected dataSource = new TableDataSource<GroupDetailsRow>();
|
||||
protected searchControl = new FormControl("");
|
||||
|
||||
// Fixed sizes used for cdkVirtualScroll
|
||||
protected rowHeight = 46;
|
||||
protected rowHeightClass = `tw-h-[46px]`;
|
||||
|
||||
protected ModalTabType = GroupAddEditTabType;
|
||||
private refreshGroups$ = new BehaviorSubject<void>(null);
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private groupService: GroupService,
|
||||
private route: ActivatedRoute,
|
||||
private i18nService: I18nService,
|
||||
private dialogService: DialogService,
|
||||
private logService: LogService,
|
||||
private collectionService: CollectionService,
|
||||
private toastService: ToastService,
|
||||
) {
|
||||
this.route.params
|
||||
.pipe(
|
||||
tap((params) => (this.organizationId = params.organizationId)),
|
||||
switchMap(() =>
|
||||
combineLatest([
|
||||
// collectionMap
|
||||
from(this.apiService.getCollections(this.organizationId)).pipe(
|
||||
concatMap((response) => this.toCollectionMap(response)),
|
||||
),
|
||||
// groups
|
||||
this.refreshGroups$.pipe(
|
||||
switchMap(() => this.groupService.getAll(this.organizationId)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
map(([collectionMap, groups]) => {
|
||||
return groups.map<GroupDetailsRow>((g) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
details: g,
|
||||
checked: false,
|
||||
collectionNames: g.collections
|
||||
.map((c) => collectionMap[c.id]?.name)
|
||||
.sort(this.i18nService.collator?.compare),
|
||||
}));
|
||||
}),
|
||||
takeUntilDestroyed(),
|
||||
)
|
||||
.subscribe((groups) => {
|
||||
this.dataSource.data = groups;
|
||||
this.loading = false;
|
||||
});
|
||||
|
||||
// Connect the search input to the table dataSource filter input
|
||||
this.searchControl.valueChanges
|
||||
.pipe(debounceTime(200), takeUntilDestroyed())
|
||||
.subscribe((v) => (this.dataSource.filter = groupsFilter(v)));
|
||||
|
||||
this.route.queryParams.pipe(first(), takeUntilDestroyed()).subscribe((qParams) => {
|
||||
this.searchControl.setValue(qParams.search);
|
||||
});
|
||||
}
|
||||
|
||||
async edit(
|
||||
group: GroupDetailsRow,
|
||||
startingTabIndex: GroupAddEditTabType = GroupAddEditTabType.Info,
|
||||
) {
|
||||
const dialogRef = openGroupAddEditDialog(this.dialogService, {
|
||||
data: {
|
||||
initialTab: startingTabIndex,
|
||||
organizationId: this.organizationId,
|
||||
groupId: group != null ? group.details.id : null,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await lastValueFrom(dialogRef.closed);
|
||||
|
||||
if (result == GroupAddEditDialogResultType.Saved) {
|
||||
this.refreshGroups$.next();
|
||||
} else if (result == GroupAddEditDialogResultType.Deleted) {
|
||||
this.removeGroup(group);
|
||||
}
|
||||
}
|
||||
|
||||
async add() {
|
||||
await this.edit(null);
|
||||
}
|
||||
|
||||
async delete(groupRow: GroupDetailsRow) {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: groupRow.details.name,
|
||||
content: { key: "deleteGroupConfirmation" },
|
||||
type: "warning",
|
||||
});
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.groupService.delete(this.organizationId, groupRow.details.id);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("deletedGroupId", groupRow.details.name),
|
||||
});
|
||||
this.removeGroup(groupRow);
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAllSelected() {
|
||||
const groupsToDelete = this.dataSource.data.filter((g) => g.checked);
|
||||
|
||||
if (groupsToDelete.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deleteMessage = groupsToDelete.map((g) => g.details.name).join(", ");
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: {
|
||||
key: "deleteMultipleGroupsConfirmation",
|
||||
placeholders: [groupsToDelete.length.toString()],
|
||||
},
|
||||
content: deleteMessage,
|
||||
type: "warning",
|
||||
});
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.groupService.deleteMany(
|
||||
this.organizationId,
|
||||
groupsToDelete.map((g) => g.details.id),
|
||||
);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("deletedManyGroups", groupsToDelete.length.toString()),
|
||||
});
|
||||
|
||||
groupsToDelete.forEach((g) => this.removeGroup(g));
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
check(groupRow: GroupDetailsRow) {
|
||||
groupRow.checked = !groupRow.checked;
|
||||
}
|
||||
|
||||
toggleAllVisible(event: Event) {
|
||||
this.dataSource.filteredData.forEach(
|
||||
(g) => (g.checked = (event.target as HTMLInputElement).checked),
|
||||
);
|
||||
}
|
||||
|
||||
private removeGroup(groupRow: GroupDetailsRow) {
|
||||
// Assign a new array to dataSource.data to trigger the setters and update the table
|
||||
this.dataSource.data = this.dataSource.data.filter((g) => g !== groupRow);
|
||||
}
|
||||
|
||||
private async toCollectionMap(response: ListResponse<CollectionResponse>) {
|
||||
const collections = response.data.map(
|
||||
(r) => new Collection(new CollectionData(r as CollectionDetailsResponse)),
|
||||
);
|
||||
const decryptedCollections = await this.collectionService.decryptMany(collections);
|
||||
|
||||
// Convert to an object using collection Ids as keys for faster name lookups
|
||||
const collectionMap: Record<string, CollectionView> = {};
|
||||
decryptedCollections.forEach((c) => (collectionMap[c.id] = c));
|
||||
|
||||
return collectionMap;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { NgModule } from "@angular/core";
|
||||
import { RouterModule, Routes } from "@angular/router";
|
||||
|
||||
import { AuthGuard } from "@bitwarden/angular/auth/guards";
|
||||
import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route";
|
||||
import {
|
||||
canAccessOrgAdmin,
|
||||
canAccessGroupsTab,
|
||||
@@ -11,11 +12,13 @@ import {
|
||||
canAccessSettingsTab,
|
||||
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
|
||||
import { organizationPermissionsGuard } from "../../admin-console/organizations/guards/org-permissions.guard";
|
||||
import { organizationRedirectGuard } from "../../admin-console/organizations/guards/org-redirect.guard";
|
||||
import { OrganizationLayoutComponent } from "../../admin-console/organizations/layouts/organization-layout.component";
|
||||
import { GroupsComponent } from "../../admin-console/organizations/manage/groups.component";
|
||||
import { NewGroupsComponent } from "../../admin-console/organizations/manage/new-groups.component";
|
||||
import { deepLinkGuard } from "../../auth/guards/deep-link.guard";
|
||||
import { VaultModule } from "../../vault/org-vault/vault.module";
|
||||
|
||||
@@ -46,14 +49,18 @@ const routes: Routes = [
|
||||
path: "members",
|
||||
loadChildren: () => import("./members").then((m) => m.MembersModule),
|
||||
},
|
||||
{
|
||||
path: "groups",
|
||||
component: GroupsComponent,
|
||||
canActivate: [organizationPermissionsGuard(canAccessGroupsTab)],
|
||||
data: {
|
||||
titleId: "groups",
|
||||
...featureFlaggedRoute({
|
||||
defaultComponent: GroupsComponent,
|
||||
flaggedComponent: NewGroupsComponent,
|
||||
featureFlag: FeatureFlag.GroupsComponentRefactor,
|
||||
routeOptions: {
|
||||
path: "groups",
|
||||
canActivate: [organizationPermissionsGuard(canAccessGroupsTab)],
|
||||
data: {
|
||||
titleId: "groups",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
path: "reporting",
|
||||
loadChildren: () =>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ScrollingModule } from "@angular/cdk/scrolling";
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { LooseComponentsModule } from "../../shared";
|
||||
@@ -5,6 +6,7 @@ import { LooseComponentsModule } from "../../shared";
|
||||
import { CoreOrganizationModule } from "./core";
|
||||
import { GroupAddEditComponent } from "./manage/group-add-edit.component";
|
||||
import { GroupsComponent } from "./manage/groups.component";
|
||||
import { NewGroupsComponent } from "./manage/new-groups.component";
|
||||
import { OrganizationsRoutingModule } from "./organization-routing.module";
|
||||
import { SharedOrganizationModule } from "./shared";
|
||||
import { AccessSelectorModule } from "./shared/components/access-selector";
|
||||
@@ -16,7 +18,8 @@ import { AccessSelectorModule } from "./shared/components/access-selector";
|
||||
CoreOrganizationModule,
|
||||
OrganizationsRoutingModule,
|
||||
LooseComponentsModule,
|
||||
ScrollingModule,
|
||||
],
|
||||
declarations: [GroupsComponent, GroupAddEditComponent],
|
||||
declarations: [GroupsComponent, NewGroupsComponent, GroupAddEditComponent],
|
||||
})
|
||||
export class OrganizationModule {}
|
||||
|
||||
@@ -22,11 +22,21 @@ const routes: Routes = [
|
||||
canActivate: [organizationRedirectGuard(getSettingsRoute)],
|
||||
children: [], // This is required to make the auto redirect work,
|
||||
},
|
||||
{ path: "account", component: AccountComponent, data: { titleId: "organizationInfo" } },
|
||||
{
|
||||
path: "account",
|
||||
component: AccountComponent,
|
||||
canActivate: [organizationPermissionsGuard((o) => o.isOwner)],
|
||||
data: {
|
||||
titleId: "organizationInfo",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "two-factor",
|
||||
component: TwoFactorSetupComponent,
|
||||
data: { titleId: "twoStepLogin" },
|
||||
canActivate: [organizationPermissionsGuard((o) => o.use2fa && o.isOwner)],
|
||||
data: {
|
||||
titleId: "twoStepLogin",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "policies",
|
||||
|
||||
@@ -8511,14 +8511,18 @@
|
||||
"downloadCSV": {
|
||||
"message": "Download CSV"
|
||||
},
|
||||
"billingHistoryDescription": {
|
||||
"message": "Download a CSV to obtain client details for each billing date. Prorated charges are not included in the CSV and may vary from the linked invoice. For the most accurate billing details, refer to your monthly invoices.",
|
||||
"description": "A paragraph on the Billing History page of the Provider Portal letting users know they can download a CSV report for their invoices that does not include prorations."
|
||||
},
|
||||
"monthlySubscriptionUserSeatsMessage": {
|
||||
"message": "Adjustments to your subscription will result in prorated charges to your billing totals on your next billing period. "
|
||||
},
|
||||
"annualSubscriptionUserSeatsMessage": {
|
||||
"message": "Adjustments to your subscription will result in prorated charges on a monthly billing cycle. "
|
||||
},
|
||||
"billingHistoryDescription": {
|
||||
"message": "Download a CSV to obtain client details for each billing date. Prorated charges are not included in the CSV and may vary from the linked invoice. For the most accurate billing details, refer to your monthly invoices.",
|
||||
"description": "A paragraph on the Billing History page of the Provider Portal letting users know they can download a CSV report for their invoices that does not include prorations."
|
||||
},
|
||||
"noInvoicesToList": {
|
||||
"message": "There are no invoices to list",
|
||||
"description": "A paragraph on the Billing History page of the Provider Portal letting users know they can download a CSV report for their invoices that does not include prorations."
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user