1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-08-27 23:31:41 +02:00

[PM-6426] Merging main into branch and fixing merge conflicts

This commit is contained in:
Cesar Gonzalez 2024-04-17 09:05:35 -05:00
commit dabc644c02
No known key found for this signature in database
GPG Key ID: 3381A5457F8CCECF
56 changed files with 1118 additions and 622 deletions

View File

@ -189,12 +189,12 @@ jobs:
path: browser-source/apps/browser/dist/dist-chrome.zip
if-no-files-found: error
# - name: Upload Chrome MV3 artifact
# uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2
# with:
# name: dist-chrome-MV3-${{ env._BUILD_NUMBER }}.zip
# path: browser-source/apps/browser/dist/dist-chrome-mv3.zip
# if-no-files-found: error
- name: Upload Chrome MV3 artifact (DO NOT USE FOR PROD)
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
with:
name: DO-NOT-USE-FOR-PROD-dist-chrome-MV3-${{ env._BUILD_NUMBER }}.zip
path: browser-source/apps/browser/dist/dist-chrome-mv3.zip
if-no-files-found: error
- name: Upload Firefox artifact
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1

View File

@ -172,6 +172,12 @@
"changeMasterPassword": {
"message": "Change master password"
},
"continueToWebApp": {
"message": "Continue to web app?"
},
"changeMasterPasswordOnWebConfirmation": {
"message": "You can change your master password on the Bitwarden web app."
},
"fingerprintPhrase": {
"message": "Fingerprint phrase",
"description": "A 'fingerprint phrase' is a unique word phrase (similar to a passphrase) that a user can use to authenticate their public key with another user, for the purposes of sharing."
@ -557,12 +563,6 @@
"addedFolder": {
"message": "Folder added"
},
"changeMasterPass": {
"message": "Change master password"
},
"changeMasterPasswordConfirmation": {
"message": "You can change your master password on the bitwarden.com web vault. Do you want to visit the website now?"
},
"twoStepLoginConfirmation": {
"message": "Two-step login makes your account more secure by requiring you to verify your login with another device such as a security key, authenticator app, SMS, phone call, or email. Two-step login can be set up on the bitwarden.com web vault. Do you want to visit the website now?"
},

View File

@ -153,7 +153,7 @@
*ngIf="showChangeMasterPass"
>
<div class="row-main">{{ "changeMasterPassword" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
<i class="bwi bwi-external-link bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
<button
type="button"

View File

@ -441,9 +441,10 @@ export class SettingsComponent implements OnInit {
async changePassword() {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "changeMasterPassword" },
content: { key: "changeMasterPasswordConfirmation" },
title: { key: "continueToWebApp" },
content: { key: "changeMasterPasswordOnWebConfirmation" },
type: "info",
acceptButtonText: { key: "continue" },
});
if (confirmed) {
const env = await firstValueFrom(this.environmentService.environment$);

View File

@ -18,11 +18,13 @@ import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction";
import { OrganizationApiService } from "@bitwarden/common/admin-console/services/organization/organization-api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/services/organization/organization.service";
import { OrganizationUserServiceImplementation } from "@bitwarden/common/admin-console/services/organization-user/organization-user.service.implementation";
import { PolicyApiService } from "@bitwarden/common/admin-console/services/policy/policy-api.service";
import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service";
import { ProviderApiService } from "@bitwarden/common/admin-console/services/provider/provider-api.service";
import { ProviderService } from "@bitwarden/common/admin-console/services/provider.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AvatarService as AvatarServiceAbstraction } from "@bitwarden/common/auth/abstractions/avatar.service";
@ -232,6 +234,7 @@ export class Main {
stateEventRunnerService: StateEventRunnerService;
biometricStateService: BiometricStateService;
billingAccountProfileStateService: BillingAccountProfileStateService;
providerApiService: ProviderApiServiceAbstraction;
constructor() {
let p = null;
@ -692,6 +695,8 @@ export class Main {
this.eventUploadService,
this.authService,
);
this.providerApiService = new ProviderApiService(this.apiService);
}
async run() {

View File

@ -800,8 +800,11 @@
"changeMasterPass": {
"message": "Change master password"
},
"changeMasterPasswordConfirmation": {
"message": "You can change your master password on the bitwarden.com web vault. Do you want to visit the website now?"
"continueToWebApp": {
"message": "Continue to web app?"
},
"changeMasterPasswordOnWebConfirmation": {
"message": "You can change your master password on the Bitwarden web app."
},
"fingerprintPhrase": {
"message": "Fingerprint phrase",

View File

@ -65,10 +65,10 @@ export class AccountMenu implements IMenubarMenu {
id: "changeMasterPass",
click: async () => {
const result = await dialog.showMessageBox(this._window, {
title: this.localize("changeMasterPass"),
message: this.localize("changeMasterPass"),
detail: this.localize("changeMasterPasswordConfirmation"),
buttons: [this.localize("yes"), this.localize("no")],
title: this.localize("continueToWebApp"),
message: this.localize("continueToWebApp"),
detail: this.localize("changeMasterPasswordOnWebConfirmation"),
buttons: [this.localize("continue"), this.localize("cancel")],
cancelId: 1,
defaultId: 0,
noLink: true,

View File

@ -1,6 +1,6 @@
{
"name": "@bitwarden/web-vault",
"version": "2024.4.0",
"version": "2024.4.1",
"scripts": {
"build:oss": "webpack",
"build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js",

View File

@ -31,7 +31,12 @@
</bit-tab>
<bit-tab label="{{ 'members' | i18n }}">
<p>{{ "editGroupMembersDesc" | i18n }}</p>
<p>
{{ "editGroupMembersDesc" | i18n }}
<span *ngIf="restrictGroupAccess$ | async">
{{ "restrictedGroupAccessDesc" | i18n }}
</span>
</p>
<bit-access-selector
formControlName="members"
[items]="members"

View File

@ -1,15 +1,31 @@
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { ChangeDetectorRef, Component, Inject, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { catchError, combineLatest, from, map, of, Subject, switchMap, takeUntil } from "rxjs";
import {
catchError,
combineLatest,
concatMap,
from,
map,
Observable,
of,
shareReplay,
Subject,
switchMap,
takeUntil,
} from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { UserId } from "@bitwarden/common/types/guid";
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";
@ -88,10 +104,9 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
tabIndex: GroupAddEditTabType;
loading = true;
editMode = false;
title: string;
collections: AccessItemView[] = [];
members: AccessItemView[] = [];
members: Array<AccessItemView & { userId: UserId }> = [];
group: GroupView;
groupForm = this.formBuilder.group({
@ -110,6 +125,10 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
return this.params.organizationId;
}
protected get editMode(): boolean {
return this.groupId != null;
}
private destroy$ = new Subject<void>();
private get orgCollections$() {
@ -134,7 +153,7 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
);
}
private get orgMembers$() {
private get orgMembers$(): Observable<Array<AccessItemView & { userId: UserId }>> {
return from(this.organizationUserService.getAllUsers(this.organizationId)).pipe(
map((response) =>
response.data.map((m) => ({
@ -145,34 +164,55 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
listName: m.name?.length > 0 ? `${m.name} (${m.email})` : m.email,
labelName: m.name || m.email,
status: m.status,
userId: m.userId as UserId,
})),
),
);
}
private get groupDetails$() {
if (!this.editMode) {
return of(undefined);
}
return combineLatest([
this.groupService.get(this.organizationId, this.groupId),
this.apiService.getGroupUsers(this.organizationId, this.groupId),
]).pipe(
map(([groupView, users]) => {
groupView.members = users;
return groupView;
}),
catchError((e: unknown) => {
if (e instanceof ErrorResponse) {
this.logService.error(e.message);
} else {
this.logService.error(e.toString());
}
private groupDetails$: Observable<GroupView | undefined> = of(this.editMode).pipe(
concatMap((editMode) => {
if (!editMode) {
return of(undefined);
}),
);
}
}
return combineLatest([
this.groupService.get(this.organizationId, this.groupId),
this.apiService.getGroupUsers(this.organizationId, this.groupId),
]).pipe(
map(([groupView, users]) => {
groupView.members = users;
return groupView;
}),
catchError((e: unknown) => {
if (e instanceof ErrorResponse) {
this.logService.error(e.message);
} else {
this.logService.error(e.toString());
}
return of(undefined);
}),
);
}),
shareReplay({ refCount: false }),
);
restrictGroupAccess$ = combineLatest([
this.organizationService.get$(this.organizationId),
this.configService.getFeatureFlag$(FeatureFlag.FlexibleCollectionsV1),
this.groupDetails$,
]).pipe(
map(
([organization, flexibleCollectionsV1Enabled, group]) =>
// Feature flag conditionals
flexibleCollectionsV1Enabled &&
organization.flexibleCollections &&
// Business logic conditionals
!organization.allowAdminAccessToAllCollectionItems &&
group !== undefined,
),
shareReplay({ refCount: true, bufferSize: 1 }),
);
constructor(
@Inject(DIALOG_DATA) private params: GroupAddEditDialogParams,
@ -188,17 +228,25 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
private changeDetectorRef: ChangeDetectorRef,
private dialogService: DialogService,
private organizationService: OrganizationService,
private configService: ConfigService,
private accountService: AccountService,
) {
this.tabIndex = params.initialTab ?? GroupAddEditTabType.Info;
}
ngOnInit() {
this.editMode = this.loading = this.groupId != null;
this.loading = true;
this.title = this.i18nService.t(this.editMode ? "editGroup" : "newGroup");
combineLatest([this.orgCollections$, this.orgMembers$, this.groupDetails$])
combineLatest([
this.orgCollections$,
this.orgMembers$,
this.groupDetails$,
this.restrictGroupAccess$,
this.accountService.activeAccount$,
])
.pipe(takeUntil(this.destroy$))
.subscribe(([collections, members, group]) => {
.subscribe(([collections, members, group, restrictGroupAccess, activeAccount]) => {
this.collections = collections;
this.members = members;
this.group = group;
@ -224,6 +272,18 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
});
}
// If the current user is not already in the group and cannot add themselves, remove them from the list
if (restrictGroupAccess) {
const organizationUserId = this.members.find((m) => m.userId === activeAccount.id).id;
const isAlreadyInGroup = this.groupForm.value.members.some(
(m) => m.id === organizationUserId,
);
if (!isAlreadyInGroup) {
this.members = this.members.filter((m) => m.id !== organizationUserId);
}
}
this.loading = false;
});
}

View File

@ -31,6 +31,7 @@ import { CollectionView } from "@bitwarden/common/vault/models/view/collection.v
import { DialogService } from "@bitwarden/components";
import { CollectionAdminService } from "../../../../../vault/core/collection-admin.service";
import { CollectionAdminView } from "../../../../../vault/core/views/collection-admin.view";
import {
CollectionAccessSelectionView,
GroupService,
@ -206,25 +207,52 @@ export class MemberDialogComponent implements OnDestroy {
collections: this.collectionAdminService.getAll(this.params.organizationId),
userDetails: userDetails$,
groups: groups$,
flexibleCollectionsV1Enabled: this.configService.getFeatureFlag$(
FeatureFlag.FlexibleCollectionsV1,
false,
),
})
.pipe(takeUntil(this.destroy$))
.subscribe(({ organization, collections, userDetails, groups }) => {
this.setFormValidators(organization);
.subscribe(
({ organization, collections, userDetails, groups, flexibleCollectionsV1Enabled }) => {
this.setFormValidators(organization);
this.collectionAccessItems = [].concat(
collections.map((c) => mapCollectionToAccessItemView(c)),
);
// Groups tab: populate available groups
this.groupAccessItems = [].concat(
groups.map<AccessItemView>((g) => mapGroupToAccessItemView(g)),
);
this.groupAccessItems = [].concat(
groups.map<AccessItemView>((g) => mapGroupToAccessItemView(g)),
);
// Collections tab: Populate all available collections (including current user access where applicable)
this.collectionAccessItems = collections
.map((c) =>
mapCollectionToAccessItemView(
c,
organization,
flexibleCollectionsV1Enabled,
userDetails == null
? undefined
: c.users.find((access) => access.id === userDetails.id),
),
)
// But remove collections that we can't assign access to, unless the user is already assigned
.filter(
(item) =>
!item.readonly || userDetails?.collections.some((access) => access.id == item.id),
);
if (this.params.organizationUserId) {
this.loadOrganizationUser(userDetails, groups, collections);
}
if (userDetails != null) {
this.loadOrganizationUser(
userDetails,
groups,
collections,
organization,
flexibleCollectionsV1Enabled,
);
}
this.loading = false;
});
this.loading = false;
},
);
}
private setFormValidators(organization: Organization) {
@ -246,7 +274,9 @@ export class MemberDialogComponent implements OnDestroy {
private loadOrganizationUser(
userDetails: OrganizationUserAdminView,
groups: GroupView[],
collections: CollectionView[],
collections: CollectionAdminView[],
organization: Organization,
flexibleCollectionsV1Enabled: boolean,
) {
if (!userDetails) {
throw new Error("Could not find user to edit.");
@ -295,13 +325,22 @@ export class MemberDialogComponent implements OnDestroy {
}),
);
// Populate additional collection access via groups (rendered as separate rows from user access)
this.collectionAccessItems = this.collectionAccessItems.concat(
collectionsFromGroups.map(({ collection, accessSelection, group }) =>
mapCollectionToAccessItemView(collection, accessSelection, group),
mapCollectionToAccessItemView(
collection,
organization,
flexibleCollectionsV1Enabled,
accessSelection,
group,
),
),
);
const accessSelections = mapToAccessSelections(userDetails);
// Set current collections and groups the user has access to (excluding collections the current user doesn't have
// permissions to change - they are included as readonly via the CollectionAccessItems)
const accessSelections = mapToAccessSelections(userDetails, this.collectionAccessItems);
const groupAccessSelections = mapToGroupAccessSelections(userDetails.groups);
this.formGroup.removeControl("emails");
@ -573,6 +612,8 @@ export class MemberDialogComponent implements OnDestroy {
function mapCollectionToAccessItemView(
collection: CollectionView,
organization: Organization,
flexibleCollectionsV1Enabled: boolean,
accessSelection?: CollectionAccessSelectionView,
group?: GroupView,
): AccessItemView {
@ -581,7 +622,8 @@ function mapCollectionToAccessItemView(
id: group ? `${collection.id}-${group.id}` : collection.id,
labelName: collection.name,
listName: collection.name,
readonly: group !== undefined,
readonly:
group !== undefined || !collection.canEdit(organization, flexibleCollectionsV1Enabled),
readonlyPermission: accessSelection ? convertToPermission(accessSelection) : undefined,
viaGroupName: group?.name,
};
@ -596,16 +638,23 @@ function mapGroupToAccessItemView(group: GroupView): AccessItemView {
};
}
function mapToAccessSelections(user: OrganizationUserAdminView): AccessItemValue[] {
function mapToAccessSelections(
user: OrganizationUserAdminView,
items: AccessItemView[],
): AccessItemValue[] {
if (user == undefined) {
return [];
}
return [].concat(
user.collections.map<AccessItemValue>((selection) => ({
id: selection.id,
type: AccessItemType.Collection,
permission: convertToPermission(selection),
})),
return (
user.collections
// The FormControl value only represents editable collection access - exclude readonly access selections
.filter((selection) => !items.find((item) => item.id == selection.id).readonly)
.map<AccessItemValue>((selection) => ({
id: selection.id,
type: AccessItemType.Collection,
permission: convertToPermission(selection),
}))
);
}

View File

@ -0,0 +1,34 @@
<form #form (ngSubmit)="submit()" [appApiAction]="formPromise" class="container" ngNativeValidate>
<div class="row justify-content-md-center mt-5">
<div class="col-5">
<p class="lead text-center mb-4">{{ "deleteProvider" | i18n }}</p>
<div class="card">
<div class="card-body">
<app-callout type="warning">{{ "deleteProviderWarning" | i18n }}</app-callout>
<p class="text-center">
<strong>{{ name }}</strong>
</p>
<p>{{ "deleteProviderRecoverConfirmDesc" | i18n }}</p>
<hr />
<div class="d-flex">
<button
type="submit"
class="btn btn-danger btn-block btn-submit"
[disabled]="form.loading"
>
<span>{{ "deleteProvider" | i18n }}</span>
<i
class="bwi bwi-spinner bwi-spin"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
</button>
<a routerLink="/login" class="btn btn-outline-secondary btn-block ml-2 mt-0">
{{ "cancel" | i18n }}
</a>
</div>
</div>
</div>
</div>
</div>
</form>

View File

@ -0,0 +1,61 @@
import { Component, OnInit } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction";
import { ProviderVerifyRecoverDeleteRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-verify-recover-delete.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@Component({
selector: "app-verify-recover-delete-provider",
templateUrl: "verify-recover-delete-provider.component.html",
})
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
export class VerifyRecoverDeleteProviderComponent implements OnInit {
name: string;
formPromise: Promise<any>;
private providerId: string;
private token: string;
constructor(
private router: Router,
private providerApiService: ProviderApiServiceAbstraction,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private route: ActivatedRoute,
private logService: LogService,
) {}
async ngOnInit() {
const qParams = await firstValueFrom(this.route.queryParams);
if (qParams.providerId != null && qParams.token != null && qParams.name != null) {
this.providerId = qParams.providerId;
this.token = qParams.token;
this.name = qParams.name;
} else {
await this.router.navigate(["/"]);
}
}
async submit() {
try {
const request = new ProviderVerifyRecoverDeleteRequest(this.token);
this.formPromise = this.providerApiService.providerRecoverDeleteToken(
this.providerId,
request,
);
await this.formPromise;
this.platformUtilsService.showToast(
"success",
this.i18nService.t("providerDeleted"),
this.i18nService.t("providerDeletedDesc"),
);
await this.router.navigate(["/"]);
} catch (e) {
this.logService.error(e);
}
}
}

View File

@ -1,13 +1,12 @@
<div class="d-flex tabbed-header">
<h1>
<div class="tw-flex tw-justify-between tw-mb-2 tw-pb-2 tw-mt-6">
<h2 bitTypography="h2">
{{ "billingHistory" | i18n }}
</h1>
</h2>
<button
type="button"
bitButton
buttonType="secondary"
(click)="load()"
class="tw-ml-auto"
*ngIf="firstLoaded"
[disabled]="loading"
>
@ -17,11 +16,11 @@
</div>
<ng-container *ngIf="!firstLoaded && loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<ng-container *ngIf="billing">
<app-billing-history [billing]="billing"></app-billing-history>

View File

@ -170,8 +170,8 @@
</div>
<ng-container *ngIf="subscription && !subscription.cancelled && !subscriptionMarkedForCancel">
<div class="mt-3">
<div class="d-flex" *ngIf="!showAdjustStorage">
<button bitButton type="button" buttonType="secondary" (click)="adjustStorage(true)">
<div class="d-flex">
<button bitButton type="button" buttonType="secondary" [bitAction]="adjustStorage(true)">
{{ "addStorage" | i18n }}
</button>
<button
@ -179,18 +179,11 @@
type="button"
buttonType="secondary"
class="tw-ml-1"
(click)="adjustStorage(false)"
[bitAction]="adjustStorage(false)"
>
{{ "removeStorage" | i18n }}
</button>
</div>
<app-adjust-storage
[storageGbPrice]="4"
[add]="adjustStorageAdd"
(onAdjusted)="closeStorage(true)"
(onCanceled)="closeStorage(false)"
*ngIf="showAdjustStorage"
></app-adjust-storage>
</div>
</ng-container>
</ng-container>

View File

@ -12,6 +12,10 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService } from "@bitwarden/components";
import {
AdjustStorageDialogResult,
openAdjustStorageDialog,
} from "../shared/adjust-storage.component";
import {
OffboardingSurveyDialogResultType,
openOffboardingSurvey,
@ -24,7 +28,6 @@ export class UserSubscriptionComponent implements OnInit {
loading = false;
firstLoaded = false;
adjustStorageAdd = true;
showAdjustStorage = false;
showUpdateLicense = false;
sub: SubscriptionResponse;
selfHosted = false;
@ -144,19 +147,20 @@ export class UserSubscriptionComponent implements OnInit {
}
}
adjustStorage(add: boolean) {
this.adjustStorageAdd = add;
this.showAdjustStorage = true;
}
closeStorage(load: boolean) {
this.showAdjustStorage = false;
if (load) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.load();
}
}
adjustStorage = (add: boolean) => {
return async () => {
const dialogRef = openAdjustStorageDialog(this.dialogService, {
data: {
storageGbPrice: 4,
add: add,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result === AdjustStorageDialogResult.Adjusted) {
await this.load();
}
};
};
get subscriptionMarkedForCancel() {
return (

View File

@ -1,10 +1,18 @@
<div class="card card-org-plans">
<div class="card-body">
<button type="button" class="close" appA11yTitle="{{ 'cancel' | i18n }}" (click)="cancel()">
<span aria-hidden="true">&times;</span>
</button>
<h2 class="card-body-header">{{ "changeBillingPlan" | i18n }}</h2>
<p class="mb-0">{{ "changeBillingPlanUpgrade" | i18n }}</p>
<div
class="tw-relative tw-flex tw-flex-col tw-min-w-0 tw-rounded tw-border tw-border-solid tw-border-secondary-300"
>
<div class="tw-flex-auto tw-p-5">
<button
bitIconButton="bwi-close"
buttonType="main"
type="button"
size="small"
class="tw-float-right"
appA11yTitle="{{ 'cancel' | i18n }}"
(click)="cancel()"
></button>
<h2 bitTypography="h2">{{ "changeBillingPlan" | i18n }}</h2>
<p bitTypography="body1" class="tw-mb-0">{{ "changeBillingPlanUpgrade" | i18n }}</p>
<app-organization-plans
[showFree]="false"
[showCancel]="true"

View File

@ -16,11 +16,11 @@
<bit-container>
<ng-container *ngIf="!firstLoaded && loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<ng-container *ngIf="billing">
<app-billing-history [billing]="billing"></app-billing-history>

View File

@ -15,6 +15,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { OrganizationCreateRequest } from "@bitwarden/common/admin-console/models/request/organization-create.request";
@ -147,6 +148,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
private messagingService: MessagingService,
private formBuilder: FormBuilder,
private organizationApiService: OrganizationApiServiceAbstraction,
private providerApiService: ProviderApiServiceAbstraction,
) {
this.selfHosted = platformUtilsService.isSelfHost();
}
@ -182,7 +184,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
if (this.hasProvider) {
this.formGroup.controls.businessOwned.setValue(true);
this.changedOwnedBusiness();
this.provider = await this.apiService.getProvider(this.providerId);
this.provider = await this.providerApiService.getProvider(this.providerId);
const providerDefaultPlan = this.passwordManagerPlans.find(
(plan) => plan.type === PlanType.TeamsAnnually,
);

View File

@ -175,23 +175,24 @@
<bit-progress [barWidth]="storagePercentage" bgColor="success"></bit-progress>
<ng-container *ngIf="subscription && !subscription.cancelled && !subscriptionMarkedForCancel">
<div class="tw-mt-3">
<div class="tw-flex tw-space-x-2" *ngIf="!showAdjustStorage">
<button bitButton buttonType="secondary" type="button" (click)="adjustStorage(true)">
<div class="tw-flex tw-space-x-2">
<button
bitButton
buttonType="secondary"
type="button"
[bitAction]="adjustStorage(true)"
>
{{ "addStorage" | i18n }}
</button>
<button bitButton buttonType="secondary" type="button" (click)="adjustStorage(false)">
<button
bitButton
buttonType="secondary"
type="button"
[bitAction]="adjustStorage(false)"
>
{{ "removeStorage" | i18n }}
</button>
</div>
<app-adjust-storage
[storageGbPrice]="storageGbPrice"
[add]="adjustStorageAdd"
[organizationId]="organizationId"
[interval]="billingInterval"
(onAdjusted)="closeStorage(true)"
(onCanceled)="closeStorage(false)"
*ngIf="showAdjustStorage"
></app-adjust-storage>
</div>
</ng-container>
<ng-container *ngIf="showAdjustSecretsManager">

View File

@ -18,6 +18,10 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService } from "@bitwarden/components";
import {
AdjustStorageDialogResult,
openAdjustStorageDialog,
} from "../shared/adjust-storage.component";
import {
OffboardingSurveyDialogResultType,
openOffboardingSurvey,
@ -36,8 +40,6 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
userOrg: Organization;
showChangePlan = false;
showDownloadLicense = false;
adjustStorageAdd = true;
showAdjustStorage = false;
hasBillingSyncToken: boolean;
showAdjustSecretsManager = false;
showSecretsManagerSubscribe = false;
@ -361,19 +363,22 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
this.load();
}
adjustStorage(add: boolean) {
this.adjustStorageAdd = add;
this.showAdjustStorage = true;
}
closeStorage(load: boolean) {
this.showAdjustStorage = false;
if (load) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.load();
}
}
adjustStorage = (add: boolean) => {
return async () => {
const dialogRef = openAdjustStorageDialog(this.dialogService, {
data: {
storageGbPrice: this.storageGbPrice,
add: add,
organizationId: this.organizationId,
interval: this.billingInterval,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result === AdjustStorageDialogResult.Adjusted) {
await this.load();
}
};
};
removeSponsorship = async () => {
const confirmed = await this.dialogService.openSimpleDialog({

View File

@ -0,0 +1,25 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog
dialogSize="large"
[title]="(currentType != null ? 'changePaymentMethod' : 'addPaymentMethod') | i18n"
>
<ng-container bitDialogContent>
<app-payment [hideBank]="!organizationId" [hideCredit]="true"></app-payment>
<app-tax-info (onCountryChanged)="changeCountry()"></app-tax-info>
</ng-container>
<ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="primary">
{{ "submit" | i18n }}
</button>
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
[bitDialogClose]="DialogResult.Cancelled"
>
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@ -0,0 +1,110 @@
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject, ViewChild } from "@angular/core";
import { FormGroup } from "@angular/forms";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { PaymentMethodWarningsServiceAbstraction as PaymentMethodWarningService } from "@bitwarden/common/billing/abstractions/payment-method-warnings-service.abstraction";
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
import { PaymentRequest } from "@bitwarden/common/billing/models/request/payment.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService } from "@bitwarden/components";
import { PaymentComponent } from "./payment.component";
import { TaxInfoComponent } from "./tax-info.component";
export interface AdjustPaymentDialogData {
organizationId: string;
currentType: PaymentMethodType;
}
export enum AdjustPaymentDialogResult {
Adjusted = "adjusted",
Cancelled = "cancelled",
}
@Component({
templateUrl: "adjust-payment-dialog.component.html",
})
export class AdjustPaymentDialogComponent {
@ViewChild(PaymentComponent, { static: true }) paymentComponent: PaymentComponent;
@ViewChild(TaxInfoComponent, { static: true }) taxInfoComponent: TaxInfoComponent;
organizationId: string;
currentType: PaymentMethodType;
paymentMethodType = PaymentMethodType;
protected DialogResult = AdjustPaymentDialogResult;
protected formGroup = new FormGroup({});
constructor(
private dialogRef: DialogRef,
@Inject(DIALOG_DATA) protected data: AdjustPaymentDialogData,
private apiService: ApiService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private logService: LogService,
private organizationApiService: OrganizationApiServiceAbstraction,
private paymentMethodWarningService: PaymentMethodWarningService,
) {
this.organizationId = data.organizationId;
this.currentType = data.currentType;
}
submit = async () => {
const request = new PaymentRequest();
const response = this.paymentComponent.createPaymentToken().then((result) => {
request.paymentToken = result[0];
request.paymentMethodType = result[1];
request.postalCode = this.taxInfoComponent.taxInfo.postalCode;
request.country = this.taxInfoComponent.taxInfo.country;
if (this.organizationId == null) {
return this.apiService.postAccountPayment(request);
} else {
request.taxId = this.taxInfoComponent.taxInfo.taxId;
request.state = this.taxInfoComponent.taxInfo.state;
request.line1 = this.taxInfoComponent.taxInfo.line1;
request.line2 = this.taxInfoComponent.taxInfo.line2;
request.city = this.taxInfoComponent.taxInfo.city;
request.state = this.taxInfoComponent.taxInfo.state;
return this.organizationApiService.updatePayment(this.organizationId, request);
}
});
await response;
if (this.organizationId) {
await this.paymentMethodWarningService.removeSubscriptionRisk(this.organizationId);
}
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("updatedPaymentMethod"),
);
this.dialogRef.close(AdjustPaymentDialogResult.Adjusted);
};
changeCountry() {
if (this.taxInfoComponent.taxInfo.country === "US") {
this.paymentComponent.hideBank = !this.organizationId;
} else {
this.paymentComponent.hideBank = true;
if (this.paymentComponent.method === PaymentMethodType.BankAccount) {
this.paymentComponent.method = PaymentMethodType.Card;
this.paymentComponent.changeMethod();
}
}
}
}
/**
* Strongly typed helper to open a AdjustPaymentDialog
* @param dialogService Instance of the dialog service that will be used to open the dialog
* @param config Configuration for the dialog
*/
export function openAdjustPaymentDialog(
dialogService: DialogService,
config: DialogConfig<AdjustPaymentDialogData>,
) {
return dialogService.open<AdjustPaymentDialogResult>(AdjustPaymentDialogComponent, config);
}

View File

@ -1,19 +0,0 @@
<form #form class="card" (ngSubmit)="submit()" [appApiAction]="formPromise" ngNativeValidate>
<div class="card-body">
<button type="button" class="close" appA11yTitle="{{ 'cancel' | i18n }}" (click)="cancel()">
<span aria-hidden="true">&times;</span>
</button>
<h3 class="card-body-header">
{{ (currentType != null ? "changePaymentMethod" : "addPaymentMethod") | i18n }}
</h3>
<app-payment [hideBank]="!organizationId" [hideCredit]="true"></app-payment>
<app-tax-info (onCountryChanged)="changeCountry()"></app-tax-info>
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "submit" | i18n }}</span>
</button>
<button type="button" class="btn btn-outline-secondary" (click)="cancel()">
{{ "cancel" | i18n }}
</button>
</div>
</form>

View File

@ -1,90 +0,0 @@
import { Component, EventEmitter, Input, Output, ViewChild } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { PaymentMethodWarningsServiceAbstraction as PaymentMethodWarningService } from "@bitwarden/common/billing/abstractions/payment-method-warnings-service.abstraction";
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
import { PaymentRequest } from "@bitwarden/common/billing/models/request/payment.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { PaymentComponent } from "./payment.component";
import { TaxInfoComponent } from "./tax-info.component";
@Component({
selector: "app-adjust-payment",
templateUrl: "adjust-payment.component.html",
})
export class AdjustPaymentComponent {
@ViewChild(PaymentComponent, { static: true }) paymentComponent: PaymentComponent;
@ViewChild(TaxInfoComponent, { static: true }) taxInfoComponent: TaxInfoComponent;
@Input() currentType?: PaymentMethodType;
@Input() organizationId: string;
@Output() onAdjusted = new EventEmitter();
@Output() onCanceled = new EventEmitter();
paymentMethodType = PaymentMethodType;
formPromise: Promise<void>;
constructor(
private apiService: ApiService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private logService: LogService,
private organizationApiService: OrganizationApiServiceAbstraction,
private paymentMethodWarningService: PaymentMethodWarningService,
) {}
async submit() {
try {
const request = new PaymentRequest();
this.formPromise = this.paymentComponent.createPaymentToken().then((result) => {
request.paymentToken = result[0];
request.paymentMethodType = result[1];
request.postalCode = this.taxInfoComponent.taxInfo.postalCode;
request.country = this.taxInfoComponent.taxInfo.country;
if (this.organizationId == null) {
return this.apiService.postAccountPayment(request);
} else {
request.taxId = this.taxInfoComponent.taxInfo.taxId;
request.state = this.taxInfoComponent.taxInfo.state;
request.line1 = this.taxInfoComponent.taxInfo.line1;
request.line2 = this.taxInfoComponent.taxInfo.line2;
request.city = this.taxInfoComponent.taxInfo.city;
request.state = this.taxInfoComponent.taxInfo.state;
return this.organizationApiService.updatePayment(this.organizationId, request);
}
});
await this.formPromise;
if (this.organizationId) {
await this.paymentMethodWarningService.removeSubscriptionRisk(this.organizationId);
}
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("updatedPaymentMethod"),
);
this.onAdjusted.emit();
} catch (e) {
this.logService.error(e);
}
}
cancel() {
this.onCanceled.emit();
}
changeCountry() {
if (this.taxInfoComponent.taxInfo.country === "US") {
this.paymentComponent.hideBank = !this.organizationId;
} else {
this.paymentComponent.hideBank = true;
if (this.paymentComponent.method === PaymentMethodType.BankAccount) {
this.paymentComponent.method = PaymentMethodType.Card;
this.paymentComponent.changeMethod();
}
}
}
}

View File

@ -1,43 +1,35 @@
<form #form class="card" (ngSubmit)="submit()" [appApiAction]="formPromise" ngNativeValidate>
<div class="card-body">
<button type="button" class="close" appA11yTitle="{{ 'cancel' | i18n }}" (click)="cancel()">
<span aria-hidden="true">&times;</span>
</button>
<h3 class="card-body-header">{{ (add ? "addStorage" : "removeStorage") | i18n }}</h3>
<div class="row">
<div class="form-group col-6">
<label for="storageAdjustment">{{
(add ? "gbStorageAdd" : "gbStorageRemove") | i18n
}}</label>
<input
id="storageAdjustment"
class="form-control"
type="number"
name="StorageGbAdjustment"
[(ngModel)]="storageAdjustment"
min="0"
max="99"
step="1"
required
/>
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="default" [title]="(add ? 'addStorage' : 'removeStorage') | i18n">
<ng-container bitDialogContent>
<p bitTypography="body1">{{ (add ? "storageAddNote" : "storageRemoveNote") | i18n }}</p>
<div class="tw-grid tw-grid-cols-12">
<bit-form-field class="tw-col-span-7">
<bit-label>{{ (add ? "gbStorageAdd" : "gbStorageRemove") | i18n }}</bit-label>
<input bitInput type="number" formControlName="storageAdjustment" />
<bit-hint *ngIf="add">
<strong>{{ "total" | i18n }}:</strong>
{{ formGroup.get("storageAdjustment").value || 0 }} GB &times;
{{ storageGbPrice | currency: "$" }} = {{ adjustedStorageTotal | currency: "$" }} /{{
interval | i18n
}}
</bit-hint>
</bit-form-field>
</div>
</div>
<div *ngIf="add" class="mb-3">
<strong>{{ "total" | i18n }}:</strong> {{ storageAdjustment || 0 }} GB &times;
{{ storageGbPrice | currency: "$" }} = {{ adjustedStorageTotal | currency: "$" }} /{{
interval | i18n
}}
</div>
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "submit" | i18n }}</span>
</button>
<button type="button" class="btn btn-outline-secondary" (click)="cancel()">
{{ "cancel" | i18n }}
</button>
<small class="d-block text-muted mt-3">
{{ (add ? "storageAddNote" : "storageRemoveNote") | i18n }}
</small>
</div>
</ng-container>
<ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="primary">
{{ "submit" | i18n }}
</button>
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
[bitDialogClose]="DialogResult.Cancelled"
>
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>
<app-payment [showMethods]="false"></app-payment>

View File

@ -1,4 +1,6 @@
import { Component, EventEmitter, Input, Output, ViewChild } from "@angular/core";
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject, ViewChild } from "@angular/core";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
@ -8,27 +10,45 @@ import { StorageRequest } from "@bitwarden/common/models/request/storage.request
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService } from "@bitwarden/components";
import { PaymentComponent } from "./payment.component";
export interface AdjustStorageDialogData {
storageGbPrice: number;
add: boolean;
organizationId?: string;
interval?: string;
}
export enum AdjustStorageDialogResult {
Adjusted = "adjusted",
Cancelled = "cancelled",
}
@Component({
selector: "app-adjust-storage",
templateUrl: "adjust-storage.component.html",
})
export class AdjustStorageComponent {
@Input() storageGbPrice = 0;
@Input() add = true;
@Input() organizationId: string;
@Input() interval = "year";
@Output() onAdjusted = new EventEmitter<number>();
@Output() onCanceled = new EventEmitter();
storageGbPrice: number;
add: boolean;
organizationId: string;
interval: string;
@ViewChild(PaymentComponent, { static: true }) paymentComponent: PaymentComponent;
storageAdjustment = 0;
formPromise: Promise<PaymentResponse | void>;
protected DialogResult = AdjustStorageDialogResult;
protected formGroup = new FormGroup({
storageAdjustment: new FormControl(0, [
Validators.required,
Validators.min(0),
Validators.max(99),
]),
});
constructor(
private dialogRef: DialogRef,
@Inject(DIALOG_DATA) protected data: AdjustStorageDialogData,
private apiService: ApiService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
@ -36,69 +56,74 @@ export class AdjustStorageComponent {
private activatedRoute: ActivatedRoute,
private logService: LogService,
private organizationApiService: OrganizationApiServiceAbstraction,
) {}
) {
this.storageGbPrice = data.storageGbPrice;
this.add = data.add;
this.organizationId = data.organizationId;
this.interval = data.interval || "year";
}
async submit() {
try {
const request = new StorageRequest();
request.storageGbAdjustment = this.storageAdjustment;
if (!this.add) {
request.storageGbAdjustment *= -1;
}
let paymentFailed = false;
const action = async () => {
let response: Promise<PaymentResponse>;
if (this.organizationId == null) {
response = this.formPromise = this.apiService.postAccountStorage(request);
} else {
response = this.formPromise = this.organizationApiService.updateStorage(
this.organizationId,
request,
);
}
const result = await response;
if (result != null && result.paymentIntentClientSecret != null) {
try {
await this.paymentComponent.handleStripeCardPayment(
result.paymentIntentClientSecret,
null,
);
} catch {
paymentFailed = true;
}
}
};
this.formPromise = action();
await this.formPromise;
this.onAdjusted.emit(this.storageAdjustment);
if (paymentFailed) {
this.platformUtilsService.showToast(
"warning",
null,
this.i18nService.t("couldNotChargeCardPayInvoice"),
{ timeout: 10000 },
);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["../billing"], { relativeTo: this.activatedRoute });
} else {
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("adjustedStorage", request.storageGbAdjustment.toString()),
);
}
} catch (e) {
this.logService.error(e);
submit = async () => {
const request = new StorageRequest();
request.storageGbAdjustment = this.formGroup.value.storageAdjustment;
if (!this.add) {
request.storageGbAdjustment *= -1;
}
}
cancel() {
this.onCanceled.emit();
}
let paymentFailed = false;
const action = async () => {
let response: Promise<PaymentResponse>;
if (this.organizationId == null) {
response = this.apiService.postAccountStorage(request);
} else {
response = this.organizationApiService.updateStorage(this.organizationId, request);
}
const result = await response;
if (result != null && result.paymentIntentClientSecret != null) {
try {
await this.paymentComponent.handleStripeCardPayment(
result.paymentIntentClientSecret,
null,
);
} catch {
paymentFailed = true;
}
}
};
await action();
this.dialogRef.close(AdjustStorageDialogResult.Adjusted);
if (paymentFailed) {
this.platformUtilsService.showToast(
"warning",
null,
this.i18nService.t("couldNotChargeCardPayInvoice"),
{ timeout: 10000 },
);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["../billing"], { relativeTo: this.activatedRoute });
} else {
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("adjustedStorage", request.storageGbAdjustment.toString()),
);
}
};
get adjustedStorageTotal(): number {
return this.storageGbPrice * this.storageAdjustment;
return this.storageGbPrice * this.formGroup.value.storageAdjustment;
}
}
/**
* Strongly typed helper to open an AdjustStorageDialog
* @param dialogService Instance of the dialog service that will be used to open the dialog
* @param config Configuration for the dialog
*/
export function openAdjustStorageDialog(
dialogService: DialogService,
config: DialogConfig<AdjustStorageDialogData>,
) {
return dialogService.open<AdjustStorageDialogResult>(AdjustStorageComponent, config);
}

View File

@ -1,65 +1,72 @@
<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="noreferrer"
class="mr-2"
appA11yTitle="{{ 'downloadInvoice' | i18n }}"
<bit-section>
<h3 bitTypography="h3">{{ "invoices" | i18n }}</h3>
<p bitTypography="body1" *ngIf="!invoices || !invoices.length">{{ "noInvoices" | i18n }}</p>
<bit-table>
<ng-template body>
<tr bitRow *ngFor="let i of invoices">
<td bitCell>{{ i.date | date: "mediumDate" }}</td>
<td bitCell>
<a
href="{{ i.pdfUrl }}"
target="_blank"
rel="noreferrer"
class="tw-mr-2"
appA11yTitle="{{ 'downloadInvoice' | i18n }}"
>
<i class="bwi bwi-file-pdf" aria-hidden="true"></i
></a>
<a href="{{ i.url }}" target="_blank" rel="noreferrer" title="{{ 'viewInvoice' | i18n }}">
{{ "invoiceNumber" | i18n: i.number }}</a
>
</td>
<td bitCell>{{ i.amount | currency: "$" }}</td>
<td bitCell>
<span *ngIf="i.paid">
<i class="bwi bwi-check tw-text-success" aria-hidden="true"></i>
{{ "paid" | i18n }}
</span>
<span *ngIf="!i.paid">
<i class="bwi bwi-exclamation-circle tw-text-muted" aria-hidden="true"></i>
{{ "unpaid" | i18n }}
</span>
</td>
</tr>
</ng-template>
</bit-table>
</bit-section>
<bit-section>
<h3 bitTypography="h3">{{ "transactions" | i18n }}</h3>
<p bitTypography="body1" *ngIf="!transactions || !transactions.length">
{{ "noTransactions" | i18n }}
</p>
<bit-table *ngIf="transactions && transactions.length">
<ng-template body>
<tr bitRow *ngFor="let t of transactions">
<td bitCell>{{ t.createdDate | date: "mediumDate" }}</td>
<td bitCell>
<span *ngIf="t.type === transactionType.Charge || t.type === transactionType.Credit">
{{ "chargeNoun" | i18n }}
</span>
<span *ngIf="t.type === transactionType.Refund">{{ "refundNoun" | i18n }}</span>
</td>
<td bitCell>
<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 }}"
bitCell
>
<i class="bwi bwi-file-pdf" aria-hidden="true"></i
></a>
<a href="{{ i.url }}" target="_blank" rel="noreferrer" 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>
{{ t.amount | currency: "$" }}
</td>
</tr>
</ng-template>
</bit-table>
<small class="tw-text-muted">* {{ "chargesStatement" | i18n: "BITWARDEN" }}</small>
</bit-section>

View File

@ -4,7 +4,7 @@ import { HeaderModule } from "../../layouts/header/header.module";
import { SharedModule } from "../../shared";
import { AddCreditComponent } from "./add-credit.component";
import { AdjustPaymentComponent } from "./adjust-payment.component";
import { AdjustPaymentDialogComponent } from "./adjust-payment-dialog.component";
import { AdjustStorageComponent } from "./adjust-storage.component";
import { BillingHistoryComponent } from "./billing-history.component";
import { OffboardingSurveyComponent } from "./offboarding-survey.component";
@ -18,7 +18,7 @@ import { UpdateLicenseComponent } from "./update-license.component";
imports: [SharedModule, PaymentComponent, TaxInfoComponent, HeaderModule],
declarations: [
AddCreditComponent,
AdjustPaymentComponent,
AdjustPaymentDialogComponent,
AdjustStorageComponent,
BillingHistoryComponent,
PaymentMethodComponent,

View File

@ -15,7 +15,7 @@
<bit-container>
<div class="tabbed-header" *ngIf="!organizationId">
<!-- TODO: Organization and individual should use different "page" components -->
<!--TODO: Organization and individual should use different "page" components -->
<h1>{{ "paymentMethod" | i18n }}</h1>
</div>
@ -102,23 +102,9 @@
{{ paymentSource.description }}
</p>
</ng-container>
<button
type="button"
bitButton
buttonType="secondary"
(click)="changePayment()"
*ngIf="!showAdjustPayment"
>
<button type="button" bitButton buttonType="secondary" [bitAction]="changePayment">
{{ (paymentSource ? "changePaymentMethod" : "addPaymentMethod") | i18n }}
</button>
<app-adjust-payment
[organizationId]="organizationId"
[currentType]="paymentSource != null ? paymentSource.type : null"
(onAdjusted)="closePayment(true)"
(onCanceled)="closePayment(false)"
*ngIf="showAdjustPayment"
>
</app-adjust-payment>
<p *ngIf="isUnpaid">{{ "paymentChargedWithUnpaidSubscription" | i18n }}</p>
<ng-container *ngIf="forOrganization">
<h2 class="spaced-header">{{ "taxInformation" | i18n }}</h2>

View File

@ -1,6 +1,7 @@
import { Component, OnInit, ViewChild } from "@angular/core";
import { FormBuilder, FormControl, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { lastValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
@ -14,6 +15,10 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService } from "@bitwarden/components";
import {
AdjustPaymentDialogResult,
openAdjustPaymentDialog,
} from "./adjust-payment-dialog.component";
import { TaxInfoComponent } from "./tax-info.component";
@Component({
@ -25,7 +30,6 @@ export class PaymentMethodComponent implements OnInit {
loading = false;
firstLoaded = false;
showAdjustPayment = false;
showAddCredit = false;
billing: BillingPaymentResponse;
org: OrganizationSubscriptionResponse;
@ -120,18 +124,18 @@ export class PaymentMethodComponent implements OnInit {
}
}
changePayment() {
this.showAdjustPayment = true;
}
closePayment(load: boolean) {
this.showAdjustPayment = false;
if (load) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.load();
changePayment = async () => {
const dialogRef = openAdjustPaymentDialog(this.dialogService, {
data: {
organizationId: this.organizationId,
currentType: this.paymentSource !== null ? this.paymentSource.type : null,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result === AdjustPaymentDialogResult.Adjusted) {
await this.load();
}
}
};
async verifyBank() {
if (this.loading || !this.forOrganization) {

View File

@ -13,6 +13,7 @@ import { flagEnabled, Flags } from "../utils/flags";
import { AcceptFamilySponsorshipComponent } from "./admin-console/organizations/sponsorships/accept-family-sponsorship.component";
import { FamiliesForEnterpriseSetupComponent } from "./admin-console/organizations/sponsorships/families-for-enterprise-setup.component";
import { VerifyRecoverDeleteProviderComponent } from "./admin-console/providers/verify-recover-delete-provider.component";
import { CreateOrganizationComponent } from "./admin-console/settings/create-organization.component";
import { SponsoredFamiliesComponent } from "./admin-console/settings/sponsored-families.component";
import { AcceptOrganizationComponent } from "./auth/accept-organization.component";
@ -156,6 +157,12 @@ const routes: Routes = [
canActivate: [UnauthGuard],
data: { titleId: "deleteAccount" },
},
{
path: "verify-recover-delete-provider",
component: VerifyRecoverDeleteProviderComponent,
canActivate: [UnauthGuard],
data: { titleId: "deleteAccount" },
},
{
path: "send/:sendId/:key",
component: AccessComponent,

View File

@ -13,6 +13,7 @@ import { ReusedPasswordsReportComponent as OrgReusedPasswordsReportComponent } f
import { UnsecuredWebsitesReportComponent as OrgUnsecuredWebsitesReportComponent } from "../admin-console/organizations/tools/unsecured-websites-report.component";
import { WeakPasswordsReportComponent as OrgWeakPasswordsReportComponent } from "../admin-console/organizations/tools/weak-passwords-report.component";
import { ProvidersComponent } from "../admin-console/providers/providers.component";
import { VerifyRecoverDeleteProviderComponent } from "../admin-console/providers/verify-recover-delete-provider.component";
import { SponsoredFamiliesComponent } from "../admin-console/settings/sponsored-families.component";
import { SponsoringOrgRowComponent } from "../admin-console/settings/sponsoring-org-row.component";
import { AcceptOrganizationComponent } from "../auth/accept-organization.component";
@ -184,6 +185,7 @@ import { SharedModule } from "./shared.module";
VerifyEmailComponent,
VerifyEmailTokenComponent,
VerifyRecoverDeleteComponent,
VerifyRecoverDeleteProviderComponent,
LowKdfComponent,
],
exports: [
@ -261,6 +263,7 @@ import { SharedModule } from "./shared.module";
VerifyEmailComponent,
VerifyEmailTokenComponent,
VerifyRecoverDeleteComponent,
VerifyRecoverDeleteProviderComponent,
LowKdfComponent,
HeaderModule,
DangerZoneComponent,

View File

@ -124,6 +124,9 @@ export class CollectionAdminService {
view.groups = c.groups;
view.users = c.users;
view.assigned = c.assigned;
view.readOnly = c.readOnly;
view.hidePasswords = c.hidePasswords;
view.manage = c.manage;
}
return view;

View File

@ -7905,5 +7905,44 @@
},
"unassignedItemsBannerSelfHost": {
"message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in your All Vaults view across devices and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible."
},
"restrictedGroupAccessDesc": {
"message": "You cannot add yourself to a group."
},
"deleteProvider": {
"message": "Delete provider"
},
"deleteProviderConfirmation": {
"message": "Deleting a provider is permanent and irreversible. Enter your master password to confirm the deletion of the provider and all associated data."
},
"deleteProviderName": {
"message": "Cannot delete $ID$",
"placeholders": {
"id": {
"content": "$1",
"example": "John Smith"
}
}
},
"deleteProviderWarningDesc": {
"message": "You must unlink all clients before you can delete $ID$",
"placeholders": {
"id": {
"content": "$1",
"example": "John Smith"
}
}
},
"providerDeleted": {
"message": "Provider deleted"
},
"providerDeletedDesc": {
"message": "The Provider and all associated data has been deleted."
},
"deleteProviderRecoverConfirmDesc": {
"message": "You have requested to delete this Provider. Use the button below to confirm."
},
"deleteProviderWarning": {
"message": "Deleting your provider is permanent. It cannot be undone."
}
}

View File

@ -8,6 +8,7 @@ import { OrganizationPlansComponent, TaxInfoComponent } from "@bitwarden/web-vau
import { PaymentMethodWarningsModule } from "@bitwarden/web-vault/app/billing/shared";
import { OssModule } from "@bitwarden/web-vault/app/oss.module";
import { DangerZoneComponent } from "../../../../../../apps/web/src/app/auth/settings/account/danger-zone.component";
import { ManageClientOrganizationSubscriptionComponent } from "../../billing/providers/clients/manage-client-organization-subscription.component";
import { ManageClientOrganizationsComponent } from "../../billing/providers/clients/manage-client-organizations.component";
@ -40,6 +41,7 @@ import { SetupComponent } from "./setup/setup.component";
ProvidersLayoutComponent,
PaymentMethodWarningsModule,
TaxInfoComponent,
DangerZoneComponent,
],
declarations: [
AcceptProviderComponent,

View File

@ -1,51 +1,58 @@
<app-header></app-header>
<div *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>
</div>
<form
*ngIf="provider && !loading"
#form
(ngSubmit)="submit()"
[appApiAction]="formPromise"
ngNativeValidate
>
<div class="row">
<div class="col-6">
<div class="form-group">
<label for="name">{{ "providerName" | i18n }}</label>
<input
id="name"
class="form-control"
type="text"
name="Name"
[(ngModel)]="provider.name"
[disabled]="selfHosted"
/>
</div>
<div class="form-group">
<label for="billingEmail">{{ "billingEmail" | i18n }}</label>
<input
id="billingEmail"
class="form-control"
type="text"
name="BillingEmail"
[(ngModel)]="provider.billingEmail"
[disabled]="selfHosted"
/>
</div>
</div>
<div class="col-6">
<bit-avatar [text]="provider.name" [id]="provider.id" size="large"></bit-avatar>
</div>
<bit-container>
<div *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>
</div>
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "save" | i18n }}</span>
</button>
</form>
<form
*ngIf="provider && !loading"
#form
(ngSubmit)="submit()"
[appApiAction]="formPromise"
ngNativeValidate
>
<div class="row">
<div class="col-6">
<div class="form-group">
<label for="name">{{ "providerName" | i18n }}</label>
<input
id="name"
class="form-control"
type="text"
name="Name"
[(ngModel)]="provider.name"
[disabled]="selfHosted"
/>
</div>
<div class="form-group">
<label for="billingEmail">{{ "billingEmail" | i18n }}</label>
<input
id="billingEmail"
class="form-control"
type="text"
name="BillingEmail"
[(ngModel)]="provider.billingEmail"
[disabled]="selfHosted"
/>
</div>
</div>
<div class="col-6">
<bit-avatar [text]="provider.name" [id]="provider.id" size="large"></bit-avatar>
</div>
</div>
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "save" | i18n }}</span>
</button>
</form>
<app-danger-zone *ngIf="enableDeleteProvider$ | async">
<button type="button" bitButton buttonType="danger" (click)="deleteProvider()">
{{ "deleteProvider" | i18n }}
</button>
</app-danger-zone>
</bit-container>

View File

@ -1,13 +1,18 @@
import { Component } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { UserVerificationDialogComponent } from "@bitwarden/auth/angular";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction";
import { ProviderUpdateRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-update.request";
import { ProviderResponse } from "@bitwarden/common/admin-console/models/response/provider/provider.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DialogService } from "@bitwarden/components";
@Component({
selector: "provider-account",
@ -23,6 +28,11 @@ export class AccountComponent {
private providerId: string;
protected enableDeleteProvider$ = this.configService.getFeatureFlag$(
FeatureFlag.EnableDeleteProvider,
false,
);
constructor(
private apiService: ApiService,
private i18nService: I18nService,
@ -30,6 +40,9 @@ export class AccountComponent {
private syncService: SyncService,
private platformUtilsService: PlatformUtilsService,
private logService: LogService,
private dialogService: DialogService,
private configService: ConfigService,
private providerApiService: ProviderApiServiceAbstraction,
) {}
async ngOnInit() {
@ -38,7 +51,7 @@ export class AccountComponent {
this.route.parent.parent.params.subscribe(async (params) => {
this.providerId = params.providerId;
try {
this.provider = await this.apiService.getProvider(this.providerId);
this.provider = await this.providerApiService.getProvider(this.providerId);
} catch (e) {
this.logService.error(`Handled exception: ${e}`);
}
@ -53,7 +66,7 @@ export class AccountComponent {
request.businessName = this.provider.businessName;
request.billingEmail = this.provider.billingEmail;
this.formPromise = this.apiService.putProvider(this.providerId, request).then(() => {
this.formPromise = this.providerApiService.putProvider(this.providerId, request).then(() => {
return this.syncService.fullSync(true);
});
await this.formPromise;
@ -62,4 +75,60 @@ export class AccountComponent {
this.logService.error(`Handled exception: ${e}`);
}
}
async deleteProvider() {
const providerClients = await this.apiService.getProviderClients(this.providerId);
if (providerClients.data != null && providerClients.data.length > 0) {
await this.dialogService.openSimpleDialog({
title: { key: "deleteProviderName", placeholders: [this.provider.name] },
content: { key: "deleteProviderWarningDesc", placeholders: [this.provider.name] },
acceptButtonText: { key: "ok" },
type: "danger",
});
return false;
}
const userVerified = await this.verifyUser();
if (!userVerified) {
return;
}
this.formPromise = this.providerApiService.deleteProvider(this.providerId);
try {
await this.formPromise;
this.platformUtilsService.showToast(
"success",
this.i18nService.t("providerDeleted"),
this.i18nService.t("providerDeletedDesc"),
);
} catch (e) {
this.logService.error(e);
}
this.formPromise = null;
}
private async verifyUser(): Promise<boolean> {
const confirmDescription = "deleteProviderConfirmation";
const result = await UserVerificationDialogComponent.open(this.dialogService, {
title: "deleteProvider",
bodyText: confirmDescription,
confirmButtonOptions: {
text: "deleteProvider",
type: "danger",
},
});
// Handle the result of the dialog based on user action and verification success
if (result.userAction === "cancel") {
// User cancelled the dialog
return false;
}
// User confirmed the dialog so check verification success
if (!result.verificationSuccess) {
return false;
}
return true;
}
}

View File

@ -3,7 +3,7 @@ import { ActivatedRoute, Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { first } from "rxjs/operators";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction";
import { ProviderSetupRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-setup.request";
import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
@ -50,10 +50,10 @@ export class SetupComponent implements OnInit {
private i18nService: I18nService,
private route: ActivatedRoute,
private cryptoService: CryptoService,
private apiService: ApiService,
private syncService: SyncService,
private validationService: ValidationService,
private configService: ConfigService,
private providerApiService: ProviderApiServiceAbstraction,
) {}
ngOnInit() {
@ -80,7 +80,7 @@ export class SetupComponent implements OnInit {
// Check if provider exists, redirect if it does
try {
const provider = await this.apiService.getProvider(this.providerId);
const provider = await this.providerApiService.getProvider(this.providerId);
if (provider.name != null) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
@ -128,7 +128,7 @@ export class SetupComponent implements OnInit {
}
}
const provider = await this.apiService.postProviderSetup(this.providerId, request);
const provider = await this.providerApiService.postProviderSetup(this.providerId, request);
this.platformUtilsService.showToast("success", null, this.i18nService.t("providerSetup"));
await this.syncService.fullSync(true);

View File

@ -38,6 +38,7 @@ import {
InternalPolicyService,
PolicyService as PolicyServiceAbstraction,
} from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction";
import { ProviderService as ProviderServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { OrganizationApiService } from "@bitwarden/common/admin-console/services/organization/organization-api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/services/organization/organization.service";
@ -47,6 +48,7 @@ import { DefaultOrganizationManagementPreferencesService } from "@bitwarden/comm
import { OrganizationUserServiceImplementation } from "@bitwarden/common/admin-console/services/organization-user/organization-user.service.implementation";
import { PolicyApiService } from "@bitwarden/common/admin-console/services/policy/policy-api.service";
import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service";
import { ProviderApiService } from "@bitwarden/common/admin-console/services/provider/provider-api.service";
import { ProviderService } from "@bitwarden/common/admin-console/services/provider.service";
import { AccountApiService as AccountApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/account-api.service";
import {
@ -1128,6 +1130,11 @@ const safeProviders: SafeProvider[] = [
provide: TaskSchedulerService,
deps: [],
}),
safeProvider({
provide: ProviderApiServiceAbstraction,
useClass: ProviderApiService,
deps: [ApiServiceAbstraction],
}),
];
function encryptServiceFactory(

View File

@ -166,8 +166,8 @@ export abstract class LoginStrategy {
const userId = accountInformation.sub;
const vaultTimeoutAction = await this.stateService.getVaultTimeoutAction();
const vaultTimeout = await this.stateService.getVaultTimeout();
const vaultTimeoutAction = await this.stateService.getVaultTimeoutAction({ userId });
const vaultTimeout = await this.stateService.getVaultTimeout({ userId });
// set access token and refresh token before account initialization so authN status can be accurate
// User id will be derived from the access token.

View File

@ -4,8 +4,6 @@ import { OrganizationSponsorshipRedeemRequest } from "../admin-console/models/re
import { OrganizationConnectionRequest } from "../admin-console/models/request/organization-connection.request";
import { ProviderAddOrganizationRequest } from "../admin-console/models/request/provider/provider-add-organization.request";
import { ProviderOrganizationCreateRequest } from "../admin-console/models/request/provider/provider-organization-create.request";
import { ProviderSetupRequest } from "../admin-console/models/request/provider/provider-setup.request";
import { ProviderUpdateRequest } from "../admin-console/models/request/provider/provider-update.request";
import { ProviderUserAcceptRequest } from "../admin-console/models/request/provider/provider-user-accept.request";
import { ProviderUserBulkConfirmRequest } from "../admin-console/models/request/provider/provider-user-bulk-confirm.request";
import { ProviderUserBulkRequest } from "../admin-console/models/request/provider/provider-user-bulk.request";
@ -29,7 +27,6 @@ import {
ProviderUserResponse,
ProviderUserUserDetailsResponse,
} from "../admin-console/models/response/provider/provider-user.response";
import { ProviderResponse } from "../admin-console/models/response/provider/provider.response";
import { SelectionReadOnlyResponse } from "../admin-console/models/response/selection-read-only.response";
import { CreateAuthRequest } from "../auth/models/request/create-auth.request";
import { DeviceVerificationRequest } from "../auth/models/request/device-verification.request";
@ -297,7 +294,6 @@ export abstract class ApiService {
) => Promise<any>;
getGroupUsers: (organizationId: string, id: string) => Promise<string[]>;
putGroupUsers: (organizationId: string, id: string, request: string[]) => Promise<any>;
deleteGroupUser: (organizationId: string, id: string, organizationUserId: string) => Promise<any>;
getSync: () => Promise<SyncResponse>;
@ -373,10 +369,6 @@ export abstract class ApiService {
getPlans: () => Promise<ListResponse<PlanResponse>>;
getTaxRates: () => Promise<ListResponse<TaxRateResponse>>;
postProviderSetup: (id: string, request: ProviderSetupRequest) => Promise<ProviderResponse>;
getProvider: (id: string) => Promise<ProviderResponse>;
putProvider: (id: string, request: ProviderUpdateRequest) => Promise<ProviderResponse>;
getProviderUsers: (providerId: string) => Promise<ListResponse<ProviderUserUserDetailsResponse>>;
getProviderUser: (providerId: string, id: string) => Promise<ProviderUserResponse>;
postProviderUserInvite: (providerId: string, request: ProviderUserInviteRequest) => Promise<any>;

View File

@ -0,0 +1,15 @@
import { ProviderSetupRequest } from "../../models/request/provider/provider-setup.request";
import { ProviderUpdateRequest } from "../../models/request/provider/provider-update.request";
import { ProviderVerifyRecoverDeleteRequest } from "../../models/request/provider/provider-verify-recover-delete.request";
import { ProviderResponse } from "../../models/response/provider/provider.response";
export class ProviderApiServiceAbstraction {
postProviderSetup: (id: string, request: ProviderSetupRequest) => Promise<ProviderResponse>;
getProvider: (id: string) => Promise<ProviderResponse>;
putProvider: (id: string, request: ProviderUpdateRequest) => Promise<ProviderResponse>;
providerRecoverDeleteToken: (
organizationId: string,
request: ProviderVerifyRecoverDeleteRequest,
) => Promise<any>;
deleteProvider: (id: string) => Promise<void>;
}

View File

@ -0,0 +1,7 @@
export class ProviderVerifyRecoverDeleteRequest {
token: string;
constructor(token: string) {
this.token = token;
}
}

View File

@ -0,0 +1,47 @@
import { ApiService } from "../../../abstractions/api.service";
import { ProviderApiServiceAbstraction } from "../../abstractions/provider/provider-api.service.abstraction";
import { ProviderSetupRequest } from "../../models/request/provider/provider-setup.request";
import { ProviderUpdateRequest } from "../../models/request/provider/provider-update.request";
import { ProviderVerifyRecoverDeleteRequest } from "../../models/request/provider/provider-verify-recover-delete.request";
import { ProviderResponse } from "../../models/response/provider/provider.response";
export class ProviderApiService implements ProviderApiServiceAbstraction {
constructor(private apiService: ApiService) {}
async postProviderSetup(id: string, request: ProviderSetupRequest) {
const r = await this.apiService.send(
"POST",
"/providers/" + id + "/setup",
request,
true,
true,
);
return new ProviderResponse(r);
}
async getProvider(id: string) {
const r = await this.apiService.send("GET", "/providers/" + id, null, true, true);
return new ProviderResponse(r);
}
async putProvider(id: string, request: ProviderUpdateRequest) {
const r = await this.apiService.send("PUT", "/providers/" + id, request, true, true);
return new ProviderResponse(r);
}
providerRecoverDeleteToken(
providerId: string,
request: ProviderVerifyRecoverDeleteRequest,
): Promise<any> {
return this.apiService.send(
"POST",
"/providers/" + providerId + "/delete-recover-token",
request,
false,
false,
);
}
async deleteProvider(id: string): Promise<void> {
await this.apiService.send("DELETE", "/providers/" + id, null, true, false);
}
}

View File

@ -23,7 +23,6 @@ import {
EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL,
REFRESH_TOKEN_DISK,
REFRESH_TOKEN_MEMORY,
REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE,
} from "./token.state";
describe("TokenService", () => {
@ -1120,20 +1119,13 @@ describe("TokenService", () => {
secureStorageOptions,
);
// assert data was migrated out of disk and memory + flag was set
// assert data was migrated out of disk and memory
expect(
singleUserStateProvider.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK).nextMock,
).toHaveBeenCalledWith(null);
expect(
singleUserStateProvider.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY).nextMock,
).toHaveBeenCalledWith(null);
expect(
singleUserStateProvider.getFake(
userIdFromAccessToken,
REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE,
).nextMock,
).toHaveBeenCalledWith(true);
});
});
});
@ -1260,11 +1252,6 @@ describe("TokenService", () => {
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
.stateSubject.next(userIdFromAccessToken);
// set access token migration flag to true
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE)
.stateSubject.next([userIdFromAccessToken, true]);
// Act
const result = await tokenService.getRefreshToken();
// Assert
@ -1284,11 +1271,6 @@ describe("TokenService", () => {
secureStorageService.get.mockResolvedValue(refreshToken);
// set access token migration flag to true
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE)
.stateSubject.next([userIdFromAccessToken, true]);
// Act
const result = await tokenService.getRefreshToken(userIdFromAccessToken);
// Assert
@ -1305,11 +1287,6 @@ describe("TokenService", () => {
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
.stateSubject.next([userIdFromAccessToken, refreshToken]);
// set refresh token migration flag to false
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE)
.stateSubject.next([userIdFromAccessToken, false]);
// Act
const result = await tokenService.getRefreshToken(userIdFromAccessToken);
@ -1335,11 +1312,6 @@ describe("TokenService", () => {
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
.stateSubject.next(userIdFromAccessToken);
// set access token migration flag to false
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE)
.stateSubject.next([userIdFromAccessToken, false]);
// Act
const result = await tokenService.getRefreshToken();

View File

@ -32,7 +32,6 @@ import {
EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL,
REFRESH_TOKEN_DISK,
REFRESH_TOKEN_MEMORY,
REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE,
} from "./token.state";
export enum TokenStorageLocation {
@ -441,9 +440,6 @@ export class TokenService implements TokenServiceAbstraction {
await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_DISK).update((_) => null);
await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_MEMORY).update((_) => null);
// Set flag to indicate that the refresh token has been migrated to secure storage (don't remove this)
await this.setRefreshTokenMigratedToSecureStorage(userId);
return;
case TokenStorageLocation.Disk:
@ -467,12 +463,6 @@ export class TokenService implements TokenServiceAbstraction {
return undefined;
}
const refreshTokenMigratedToSecureStorage =
await this.getRefreshTokenMigratedToSecureStorage(userId);
if (this.platformSupportsSecureStorage && refreshTokenMigratedToSecureStorage) {
return await this.getStringFromSecureStorage(userId, this.refreshTokenSecureStorageKey);
}
// pre-secure storage migration:
// Always read memory first b/c faster
const refreshTokenMemory = await this.getStateValueByUserIdAndKeyDef(
@ -484,13 +474,24 @@ export class TokenService implements TokenServiceAbstraction {
return refreshTokenMemory;
}
// if memory is null, read from disk
// if memory is null, read from disk and then secure storage
const refreshTokenDisk = await this.getStateValueByUserIdAndKeyDef(userId, REFRESH_TOKEN_DISK);
if (refreshTokenDisk != null) {
return refreshTokenDisk;
}
if (this.platformSupportsSecureStorage) {
const refreshTokenSecureStorage = await this.getStringFromSecureStorage(
userId,
this.refreshTokenSecureStorageKey,
);
if (refreshTokenSecureStorage != null) {
return refreshTokenSecureStorage;
}
}
return null;
}
@ -516,18 +517,6 @@ export class TokenService implements TokenServiceAbstraction {
await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_DISK).update((_) => null);
}
private async getRefreshTokenMigratedToSecureStorage(userId: UserId): Promise<boolean> {
return await firstValueFrom(
this.singleUserStateProvider.get(userId, REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE).state$,
);
}
private async setRefreshTokenMigratedToSecureStorage(userId: UserId): Promise<void> {
await this.singleUserStateProvider
.get(userId, REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE)
.update((_) => true);
}
async setClientId(
clientId: string,
vaultTimeoutAction: VaultTimeoutAction,

View File

@ -10,7 +10,6 @@ import {
EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL,
REFRESH_TOKEN_DISK,
REFRESH_TOKEN_MEMORY,
REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE,
} from "./token.state";
describe.each([
@ -18,7 +17,6 @@ describe.each([
[ACCESS_TOKEN_MEMORY, "accessTokenMemory"],
[REFRESH_TOKEN_DISK, "refreshTokenDisk"],
[REFRESH_TOKEN_MEMORY, "refreshTokenMemory"],
[REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE, true],
[EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL, { user: "token" }],
[API_KEY_CLIENT_ID_DISK, "apiKeyClientIdDisk"],
[API_KEY_CLIENT_ID_MEMORY, "apiKeyClientIdMemory"],

View File

@ -30,15 +30,6 @@ export const REFRESH_TOKEN_MEMORY = new UserKeyDefinition<string>(TOKEN_MEMORY,
clearOn: [], // Manually handled
});
export const REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE = new UserKeyDefinition<boolean>(
TOKEN_DISK,
"refreshTokenMigratedToSecureStorage",
{
deserializer: (refreshTokenMigratedToSecureStorage) => refreshTokenMigratedToSecureStorage,
clearOn: [], // Don't clear on lock/logout so that we always check the correct place (secure storage) for the refresh token if it's been migrated
},
);
export const EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL = KeyDefinition.record<string, string>(
TOKEN_DISK_LOCAL,
"emailTwoFactorTokenRecord",

View File

@ -10,6 +10,7 @@ export enum FeatureFlag {
EnableConsolidatedBilling = "enable-consolidated-billing",
AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section",
UnassignedItemsBanner = "unassigned-items-banner",
EnableDeleteProvider = "AC-1218-delete-provider",
}
// Replace this with a type safe lookup of the feature flag values in PM-2282

View File

@ -7,8 +7,6 @@ import { OrganizationSponsorshipRedeemRequest } from "../admin-console/models/re
import { OrganizationConnectionRequest } from "../admin-console/models/request/organization-connection.request";
import { ProviderAddOrganizationRequest } from "../admin-console/models/request/provider/provider-add-organization.request";
import { ProviderOrganizationCreateRequest } from "../admin-console/models/request/provider/provider-organization-create.request";
import { ProviderSetupRequest } from "../admin-console/models/request/provider/provider-setup.request";
import { ProviderUpdateRequest } from "../admin-console/models/request/provider/provider-update.request";
import { ProviderUserAcceptRequest } from "../admin-console/models/request/provider/provider-user-accept.request";
import { ProviderUserBulkConfirmRequest } from "../admin-console/models/request/provider/provider-user-bulk-confirm.request";
import { ProviderUserBulkRequest } from "../admin-console/models/request/provider/provider-user-bulk.request";
@ -32,7 +30,6 @@ import {
ProviderUserResponse,
ProviderUserUserDetailsResponse,
} from "../admin-console/models/response/provider/provider-user.response";
import { ProviderResponse } from "../admin-console/models/response/provider/provider.response";
import { SelectionReadOnlyResponse } from "../admin-console/models/response/selection-read-only.response";
import { TokenService } from "../auth/abstractions/token.service";
import { CreateAuthRequest } from "../auth/models/request/create-auth.request";
@ -866,16 +863,6 @@ export class ApiService implements ApiServiceAbstraction {
return r;
}
async putGroupUsers(organizationId: string, id: string, request: string[]): Promise<any> {
await this.send(
"PUT",
"/organizations/" + organizationId + "/groups/" + id + "/users",
request,
true,
false,
);
}
deleteGroupUser(organizationId: string, id: string, organizationUserId: string): Promise<any> {
return this.send(
"DELETE",
@ -1161,23 +1148,6 @@ export class ApiService implements ApiServiceAbstraction {
return this.send("DELETE", "/organizations/connections/" + id, null, true, false);
}
// Provider APIs
async postProviderSetup(id: string, request: ProviderSetupRequest) {
const r = await this.send("POST", "/providers/" + id + "/setup", request, true, true);
return new ProviderResponse(r);
}
async getProvider(id: string) {
const r = await this.send("GET", "/providers/" + id, null, true, true);
return new ProviderResponse(r);
}
async putProvider(id: string, request: ProviderUpdateRequest) {
const r = await this.send("PUT", "/providers/" + id, request, true, true);
return new ProviderResponse(r);
}
// Provider User APIs
async getProviderUsers(

View File

@ -54,6 +54,7 @@ import { SendMigrator } from "./migrations/54-move-encrypted-sends";
import { MoveMasterKeyStateToProviderMigrator } from "./migrations/55-move-master-key-state-to-provider";
import { AuthRequestMigrator } from "./migrations/56-move-auth-requests";
import { CipherServiceMigrator } from "./migrations/57-move-cipher-service-to-state-provider";
import { RemoveRefreshTokenMigratedFlagMigrator } from "./migrations/58-remove-refresh-token-migrated-state-provider-flag";
import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key";
import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account";
import { MoveStateVersionMigrator } from "./migrations/8-move-state-version";
@ -61,7 +62,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting
import { MinVersionMigrator } from "./migrations/min-version";
export const MIN_VERSION = 3;
export const CURRENT_VERSION = 57;
export const CURRENT_VERSION = 58;
export type MinVersion = typeof MIN_VERSION;
export function createMigrationBuilder() {
@ -120,7 +121,8 @@ export function createMigrationBuilder() {
.with(SendMigrator, 53, 54)
.with(MoveMasterKeyStateToProviderMigrator, 54, 55)
.with(AuthRequestMigrator, 55, 56)
.with(CipherServiceMigrator, 56, CURRENT_VERSION);
.with(CipherServiceMigrator, 56, 57)
.with(RemoveRefreshTokenMigratedFlagMigrator, 57, CURRENT_VERSION);
}
export async function currentVersion(

View File

@ -0,0 +1,72 @@
import { MockProxy, any } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { IRREVERSIBLE } from "../migrator";
import {
REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE,
RemoveRefreshTokenMigratedFlagMigrator,
} from "./58-remove-refresh-token-migrated-state-provider-flag";
// Represents data in state service pre-migration
function preMigrationJson() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user1", "user2", "user3"],
user_user1_token_refreshTokenMigratedToSecureStorage: true,
user_user2_token_refreshTokenMigratedToSecureStorage: false,
};
}
function rollbackJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user1", "user2", "user3"],
};
}
describe("RemoveRefreshTokenMigratedFlagMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: RemoveRefreshTokenMigratedFlagMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(preMigrationJson(), 57);
sut = new RemoveRefreshTokenMigratedFlagMigrator(57, 58);
});
it("should remove refreshTokenMigratedToSecureStorage from state provider for all accounts that have it", async () => {
await sut.migrate(helper);
expect(helper.removeFromUser).toHaveBeenCalledWith(
"user1",
REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE,
);
expect(helper.removeFromUser).toHaveBeenCalledWith(
"user2",
REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE,
);
expect(helper.removeFromUser).toHaveBeenCalledTimes(2);
expect(helper.removeFromUser).not.toHaveBeenCalledWith("user3", any());
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 58);
sut = new RemoveRefreshTokenMigratedFlagMigrator(57, 58);
});
it("should not add data back and throw IRREVERSIBLE error on call", async () => {
await expect(sut.rollback(helper)).rejects.toThrow(IRREVERSIBLE);
});
});
});

View File

@ -0,0 +1,34 @@
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { IRREVERSIBLE, Migrator } from "../migrator";
type ExpectedAccountType = NonNullable<unknown>;
export const REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE: KeyDefinitionLike = {
key: "refreshTokenMigratedToSecureStorage", // matches KeyDefinition.key in DeviceTrustCryptoService
stateDefinition: {
name: "token", // matches StateDefinition.name in StateDefinitions
},
};
export class RemoveRefreshTokenMigratedFlagMigrator extends Migrator<57, 58> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const refreshTokenMigratedFlag = await helper.getFromUser(
userId,
REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE,
);
if (refreshTokenMigratedFlag != null) {
// Only delete the flag if it exists
await helper.removeFromUser(userId, REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE);
}
}
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
}
async rollback(helper: MigrationHelper): Promise<void> {
throw IRREVERSIBLE;
}
}

View File

@ -21,6 +21,10 @@ export class CollectionDetailsResponse extends CollectionResponse {
readOnly: boolean;
manage: boolean;
hidePasswords: boolean;
/**
* Flag indicating the user has been explicitly assigned to this Collection
*/
assigned: boolean;
constructor(response: any) {
@ -35,15 +39,10 @@ export class CollectionDetailsResponse extends CollectionResponse {
}
}
export class CollectionAccessDetailsResponse extends CollectionResponse {
export class CollectionAccessDetailsResponse extends CollectionDetailsResponse {
groups: SelectionReadOnlyResponse[] = [];
users: SelectionReadOnlyResponse[] = [];
/**
* Flag indicating the user has been explicitly assigned to this Collection
*/
assigned: boolean;
constructor(response: any) {
super(response);
this.assigned = this.getResponseProperty("Assigned") || false;

2
package-lock.json generated
View File

@ -247,7 +247,7 @@
},
"apps/web": {
"name": "@bitwarden/web-vault",
"version": "2024.4.0"
"version": "2024.4.1"
},
"libs/admin-console": {
"name": "@bitwarden/admin-console",