mirror of
https://github.com/bitwarden/browser
synced 2026-02-05 11:13:44 +00:00
Merge branch 'main' into auth/pm-25130/inactive-user-server-notification
This commit is contained in:
1104
.github/workflows/release-desktop-beta.yml
vendored
1104
.github/workflows/release-desktop-beta.yml
vendored
File diff suppressed because it is too large
Load Diff
@@ -5588,6 +5588,9 @@
|
||||
"showLess": {
|
||||
"message": "Show less"
|
||||
},
|
||||
"next": {
|
||||
"message": "Next"
|
||||
},
|
||||
"moreBreadcrumbs": {
|
||||
"message": "More breadcrumbs",
|
||||
"description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed."
|
||||
|
||||
@@ -302,6 +302,7 @@ import { OffscreenStorageService } from "../platform/storage/offscreen-storage.s
|
||||
import { SyncServiceListener } from "../platform/sync/sync-service.listener";
|
||||
import { BrowserSystemNotificationService } from "../platform/system-notifications/browser-system-notification.service";
|
||||
import { fromChromeRuntimeMessaging } from "../platform/utils/from-chrome-runtime-messaging";
|
||||
import { AtRiskCipherBadgeUpdaterService } from "../vault/services/at-risk-cipher-badge-updater.service";
|
||||
|
||||
import CommandsBackground from "./commands.background";
|
||||
import IdleBackground from "./idle.background";
|
||||
@@ -433,6 +434,7 @@ export default class MainBackground {
|
||||
badgeService: BadgeService;
|
||||
authStatusBadgeUpdaterService: AuthStatusBadgeUpdaterService;
|
||||
autofillBadgeUpdaterService: AutofillBadgeUpdaterService;
|
||||
atRiskCipherUpdaterService: AtRiskCipherBadgeUpdaterService;
|
||||
|
||||
onUpdatedRan: boolean;
|
||||
onReplacedRan: boolean;
|
||||
@@ -735,6 +737,7 @@ export default class MainBackground {
|
||||
this.logService,
|
||||
(logoutReason: LogoutReason, userId?: UserId) => this.logout(logoutReason, userId),
|
||||
this.vaultTimeoutSettingsService,
|
||||
this.accountService,
|
||||
{ createRequest: (url, request) => new Request(url, request) },
|
||||
);
|
||||
|
||||
@@ -841,7 +844,7 @@ export default class MainBackground {
|
||||
this.tokenService,
|
||||
);
|
||||
|
||||
this.configApiService = new ConfigApiService(this.apiService, this.tokenService);
|
||||
this.configApiService = new ConfigApiService(this.apiService);
|
||||
|
||||
this.configService = new DefaultConfigService(
|
||||
this.configApiService,
|
||||
@@ -1838,6 +1841,14 @@ export default class MainBackground {
|
||||
this.logService,
|
||||
);
|
||||
|
||||
this.atRiskCipherUpdaterService = new AtRiskCipherBadgeUpdaterService(
|
||||
this.badgeService,
|
||||
this.accountService,
|
||||
this.cipherService,
|
||||
this.logService,
|
||||
this.taskService,
|
||||
);
|
||||
|
||||
this.tabsBackground = new TabsBackground(
|
||||
this,
|
||||
this.notificationBackground,
|
||||
@@ -1847,6 +1858,7 @@ export default class MainBackground {
|
||||
await this.overlayBackground.init();
|
||||
await this.tabsBackground.init();
|
||||
await this.autofillBadgeUpdaterService.init();
|
||||
await this.atRiskCipherUpdaterService.init();
|
||||
}
|
||||
|
||||
generatePassword = async (): Promise<string> => {
|
||||
|
||||
BIN
apps/browser/src/images/berry19.png
Normal file
BIN
apps/browser/src/images/berry19.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
BIN
apps/browser/src/images/berry38.png
Normal file
BIN
apps/browser/src/images/berry38.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
@@ -1,4 +1,8 @@
|
||||
export const BadgeIcon = {
|
||||
Berry: {
|
||||
19: "/images/berry19.png",
|
||||
38: "/images/berry38.png",
|
||||
},
|
||||
LoggedOut: {
|
||||
19: "/images/icon19_gray.png",
|
||||
38: "/images/icon38_gray.png",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { SecurityTask, TaskService } from "@bitwarden/common/vault/tasks";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { BadgeService } from "../../platform/badge/badge.service";
|
||||
import { BadgeIcon } from "../../platform/badge/icon";
|
||||
import { BadgeStatePriority } from "../../platform/badge/priority";
|
||||
import { Unset } from "../../platform/badge/state";
|
||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
|
||||
import { AtRiskCipherBadgeUpdaterService } from "./at-risk-cipher-badge-updater.service";
|
||||
|
||||
describe("AtRiskCipherBadgeUpdaterService", () => {
|
||||
let service: AtRiskCipherBadgeUpdaterService;
|
||||
|
||||
let setState: jest.Mock;
|
||||
let clearState: jest.Mock;
|
||||
let warning: jest.Mock;
|
||||
let getAllDecryptedForUrl: jest.Mock;
|
||||
let getTab: jest.Mock;
|
||||
let addListener: jest.Mock;
|
||||
|
||||
const activeAccount$ = new BehaviorSubject({ id: "test-account-id" });
|
||||
const cipherViews$ = new BehaviorSubject([]);
|
||||
const pendingTasks$ = new BehaviorSubject<SecurityTask[]>([]);
|
||||
const userId = "test-user-id" as UserId;
|
||||
|
||||
beforeEach(async () => {
|
||||
setState = jest.fn().mockResolvedValue(undefined);
|
||||
clearState = jest.fn().mockResolvedValue(undefined);
|
||||
warning = jest.fn();
|
||||
getAllDecryptedForUrl = jest.fn().mockResolvedValue([]);
|
||||
getTab = jest.fn();
|
||||
addListener = jest.fn();
|
||||
|
||||
jest.spyOn(BrowserApi, "addListener").mockImplementation(addListener);
|
||||
jest.spyOn(BrowserApi, "getTab").mockImplementation(getTab);
|
||||
|
||||
service = new AtRiskCipherBadgeUpdaterService(
|
||||
{ setState, clearState } as unknown as BadgeService,
|
||||
{ activeAccount$ } as unknown as AccountService,
|
||||
{ cipherViews$, getAllDecryptedForUrl } as unknown as CipherService,
|
||||
{ warning } as unknown as LogService,
|
||||
{ pendingTasks$ } as unknown as TaskService,
|
||||
);
|
||||
|
||||
await service.init();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("clears the tab state when there are no ciphers and no pending tasks", async () => {
|
||||
const tab = { id: 1 } as chrome.tabs.Tab;
|
||||
|
||||
await service["setTabState"](tab, userId, []);
|
||||
|
||||
expect(clearState).toHaveBeenCalledWith("at-risk-cipher-badge-1");
|
||||
});
|
||||
|
||||
it("sets state when there are pending tasks for the tab", async () => {
|
||||
const tab = { id: 3, url: "https://bitwarden.com" } as chrome.tabs.Tab;
|
||||
const pendingTasks: SecurityTask[] = [{ id: "task1", cipherId: "cipher1" } as SecurityTask];
|
||||
getAllDecryptedForUrl.mockResolvedValueOnce([{ id: "cipher1" }]);
|
||||
|
||||
await service["setTabState"](tab, userId, pendingTasks);
|
||||
|
||||
expect(setState).toHaveBeenCalledWith(
|
||||
"at-risk-cipher-badge-3",
|
||||
BadgeStatePriority.High,
|
||||
{
|
||||
icon: BadgeIcon.Berry,
|
||||
text: Unset,
|
||||
backgroundColor: Unset,
|
||||
},
|
||||
3,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { combineLatest, map, mergeMap, of, Subject, switchMap } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { SecurityTask, SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks";
|
||||
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
|
||||
|
||||
import { BadgeService } from "../../platform/badge/badge.service";
|
||||
import { BadgeIcon } from "../../platform/badge/icon";
|
||||
import { BadgeStatePriority } from "../../platform/badge/priority";
|
||||
import { Unset } from "../../platform/badge/state";
|
||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
|
||||
const StateName = (tabId: number) => `at-risk-cipher-badge-${tabId}`;
|
||||
|
||||
export class AtRiskCipherBadgeUpdaterService {
|
||||
private tabReplaced$ = new Subject<{ addedTab: chrome.tabs.Tab; removedTabId: number }>();
|
||||
private tabUpdated$ = new Subject<chrome.tabs.Tab>();
|
||||
private tabRemoved$ = new Subject<number>();
|
||||
private tabActivated$ = new Subject<chrome.tabs.Tab>();
|
||||
|
||||
private activeUserData$ = this.accountService.activeAccount$.pipe(
|
||||
filterOutNullish(),
|
||||
switchMap((user) =>
|
||||
combineLatest([
|
||||
of(user.id),
|
||||
this.taskService
|
||||
.pendingTasks$(user.id)
|
||||
.pipe(
|
||||
map((tasks) => tasks.filter((t) => t.type === SecurityTaskType.UpdateAtRiskCredential)),
|
||||
),
|
||||
this.cipherService.cipherViews$(user.id).pipe(filterOutNullish()),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private badgeService: BadgeService,
|
||||
private accountService: AccountService,
|
||||
private cipherService: CipherService,
|
||||
private logService: LogService,
|
||||
private taskService: TaskService,
|
||||
) {
|
||||
combineLatest({
|
||||
replaced: this.tabReplaced$,
|
||||
activeUserData: this.activeUserData$,
|
||||
})
|
||||
.pipe(
|
||||
mergeMap(async ({ replaced, activeUserData: [userId, pendingTasks] }) => {
|
||||
await this.clearTabState(replaced.removedTabId);
|
||||
await this.setTabState(replaced.addedTab, userId, pendingTasks);
|
||||
}),
|
||||
)
|
||||
.subscribe(() => {});
|
||||
|
||||
combineLatest({
|
||||
tab: this.tabActivated$,
|
||||
activeUserData: this.activeUserData$,
|
||||
})
|
||||
.pipe(
|
||||
mergeMap(async ({ tab, activeUserData: [userId, pendingTasks] }) => {
|
||||
await this.setTabState(tab, userId, pendingTasks);
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
combineLatest({
|
||||
tab: this.tabUpdated$,
|
||||
activeUserData: this.activeUserData$,
|
||||
})
|
||||
.pipe(
|
||||
mergeMap(async ({ tab, activeUserData: [userId, pendingTasks] }) => {
|
||||
await this.setTabState(tab, userId, pendingTasks);
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.tabRemoved$
|
||||
.pipe(
|
||||
mergeMap(async (tabId) => {
|
||||
await this.clearTabState(tabId);
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
init() {
|
||||
BrowserApi.addListener(chrome.tabs.onReplaced, async (addedTabId, removedTabId) => {
|
||||
const newTab = await BrowserApi.getTab(addedTabId);
|
||||
if (!newTab) {
|
||||
this.logService.warning(
|
||||
`Tab replaced event received but new tab not found (id: ${addedTabId})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.tabReplaced$.next({
|
||||
removedTabId,
|
||||
addedTab: newTab,
|
||||
});
|
||||
});
|
||||
|
||||
BrowserApi.addListener(chrome.tabs.onUpdated, (_, changeInfo, tab) => {
|
||||
if (changeInfo.url) {
|
||||
this.tabUpdated$.next(tab);
|
||||
}
|
||||
});
|
||||
|
||||
BrowserApi.addListener(chrome.tabs.onActivated, async (activeInfo) => {
|
||||
const tab = await BrowserApi.getTab(activeInfo.tabId);
|
||||
if (!tab) {
|
||||
this.logService.warning(
|
||||
`Tab activated event received but tab not found (id: ${activeInfo.tabId})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.tabActivated$.next(tab);
|
||||
});
|
||||
|
||||
BrowserApi.addListener(chrome.tabs.onRemoved, (tabId, _) => this.tabRemoved$.next(tabId));
|
||||
}
|
||||
|
||||
/** Sets the pending task state for the tab */
|
||||
private async setTabState(tab: chrome.tabs.Tab, userId: UserId, pendingTasks: SecurityTask[]) {
|
||||
if (!tab.id) {
|
||||
this.logService.warning("Tab event received but tab id is undefined");
|
||||
return;
|
||||
}
|
||||
|
||||
const ciphers = tab.url
|
||||
? await this.cipherService.getAllDecryptedForUrl(tab.url, userId, [], undefined, true)
|
||||
: [];
|
||||
|
||||
const hasPendingTasksForTab = pendingTasks.some((task) =>
|
||||
ciphers.some((cipher) => cipher.id === task.cipherId && !cipher.isDeleted),
|
||||
);
|
||||
|
||||
if (!hasPendingTasksForTab) {
|
||||
await this.clearTabState(tab.id);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.badgeService.setState(
|
||||
StateName(tab.id),
|
||||
BadgeStatePriority.High,
|
||||
{
|
||||
icon: BadgeIcon.Berry,
|
||||
// Unset text and background color to use default badge appearance
|
||||
text: Unset,
|
||||
backgroundColor: Unset,
|
||||
},
|
||||
tab.id,
|
||||
);
|
||||
}
|
||||
|
||||
/** Clears the pending task state from a tab */
|
||||
private async clearTabState(tabId: number) {
|
||||
await this.badgeService.clearState(StateName(tabId));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import * as FormData from "form-data";
|
||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
import * as fe from "node-fetch";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
@@ -28,6 +29,7 @@ export class NodeApiService extends ApiService {
|
||||
logService: LogService,
|
||||
logoutCallback: () => Promise<void>,
|
||||
vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
||||
accountService: AccountService,
|
||||
customUserAgent: string = null,
|
||||
) {
|
||||
super(
|
||||
@@ -39,6 +41,7 @@ export class NodeApiService extends ApiService {
|
||||
logService,
|
||||
logoutCallback,
|
||||
vaultTimeoutSettingsService,
|
||||
accountService,
|
||||
{ createRequest: (url, request) => new Request(url, request) },
|
||||
customUserAgent,
|
||||
);
|
||||
|
||||
@@ -504,12 +504,13 @@ export class ServiceContainer {
|
||||
this.logService,
|
||||
logoutCallback,
|
||||
this.vaultTimeoutSettingsService,
|
||||
this.accountService,
|
||||
customUserAgent,
|
||||
);
|
||||
|
||||
this.containerService = new ContainerService(this.keyService, this.encryptService);
|
||||
|
||||
this.configApiService = new ConfigApiService(this.apiService, this.tokenService);
|
||||
this.configApiService = new ConfigApiService(this.apiService);
|
||||
|
||||
this.authService = new AuthService(
|
||||
this.accountService,
|
||||
|
||||
@@ -4080,5 +4080,8 @@
|
||||
"moreBreadcrumbs": {
|
||||
"message": "More breadcrumbs",
|
||||
"description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed."
|
||||
},
|
||||
"next": {
|
||||
"message": "Next"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export type TrialOrganizationType = Exclude<ProductTierType, ProductTierType.Fre
|
||||
export interface OrganizationInfo {
|
||||
name: string;
|
||||
email: string;
|
||||
type: TrialOrganizationType;
|
||||
type: TrialOrganizationType | null;
|
||||
}
|
||||
|
||||
export interface OrganizationCreatedEvent {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
[showClose]="false"
|
||||
*ngIf="freeTrialData?.shownBanner"
|
||||
>
|
||||
{{ freeTrialData.message }}
|
||||
{{ freeTrialData?.message }}
|
||||
<a
|
||||
bitLink
|
||||
linkType="secondary"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Location } from "@angular/common";
|
||||
import { Component, OnDestroy } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
@@ -47,21 +45,21 @@ import { FreeTrial } from "../../types/free-trial";
|
||||
standalone: false,
|
||||
})
|
||||
export class OrganizationPaymentMethodComponent implements OnDestroy {
|
||||
organizationId: string;
|
||||
organizationId!: string;
|
||||
isUnpaid = false;
|
||||
accountCredit: number;
|
||||
accountCredit?: number;
|
||||
paymentSource?: PaymentSourceResponse;
|
||||
subscriptionStatus?: string;
|
||||
protected freeTrialData: FreeTrial;
|
||||
organization: Organization;
|
||||
organizationSubscriptionResponse: OrganizationSubscriptionResponse;
|
||||
protected freeTrialData?: FreeTrial;
|
||||
organization?: Organization;
|
||||
organizationSubscriptionResponse?: OrganizationSubscriptionResponse;
|
||||
|
||||
loading = true;
|
||||
|
||||
protected readonly Math = Math;
|
||||
launchPaymentModalAutomatically = false;
|
||||
|
||||
protected taxInformation: TaxInformation;
|
||||
protected taxInformation?: TaxInformation;
|
||||
|
||||
constructor(
|
||||
private activatedRoute: ActivatedRoute,
|
||||
@@ -104,7 +102,7 @@ export class OrganizationPaymentMethodComponent implements OnDestroy {
|
||||
.subscribe();
|
||||
|
||||
const state = this.router.getCurrentNavigation()?.extras?.state;
|
||||
// incase the above state is undefined or null we use redundantState
|
||||
// In case the above state is undefined or null, we use redundantState
|
||||
const redundantState: any = location.getState();
|
||||
const queryParam = this.activatedRoute.snapshot.queryParamMap.get(
|
||||
"launchPaymentModalAutomatically",
|
||||
@@ -116,10 +114,8 @@ export class OrganizationPaymentMethodComponent implements OnDestroy {
|
||||
Object.prototype.hasOwnProperty.call(redundantState, "launchPaymentModalAutomatically")
|
||||
) {
|
||||
this.launchPaymentModalAutomatically = redundantState.launchPaymentModalAutomatically;
|
||||
} else if (queryParam === "true") {
|
||||
this.launchPaymentModalAutomatically = true;
|
||||
} else {
|
||||
this.launchPaymentModalAutomatically = false;
|
||||
this.launchPaymentModalAutomatically = queryParam === "true";
|
||||
}
|
||||
}
|
||||
ngOnDestroy(): void {
|
||||
@@ -155,14 +151,21 @@ export class OrganizationPaymentMethodComponent implements OnDestroy {
|
||||
this.paymentSource = paymentSource;
|
||||
this.subscriptionStatus = subscriptionStatus;
|
||||
this.taxInformation = taxInformation;
|
||||
this.isUnpaid = this.subscriptionStatus === "unpaid";
|
||||
|
||||
if (this.organizationId) {
|
||||
const organizationSubscriptionPromise = this.organizationApiService.getSubscription(
|
||||
this.organizationId,
|
||||
);
|
||||
|
||||
const userId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||
);
|
||||
|
||||
if (!userId) {
|
||||
throw new Error("User ID is not found");
|
||||
}
|
||||
|
||||
const organizationPromise = await firstValueFrom(
|
||||
this.organizationService
|
||||
.organizations$(userId)
|
||||
@@ -173,15 +176,20 @@ export class OrganizationPaymentMethodComponent implements OnDestroy {
|
||||
organizationSubscriptionPromise,
|
||||
organizationPromise,
|
||||
]);
|
||||
|
||||
if (!this.organization) {
|
||||
throw new Error("Organization is not found");
|
||||
}
|
||||
if (!this.paymentSource) {
|
||||
throw new Error("Payment source is not found");
|
||||
}
|
||||
|
||||
this.freeTrialData = this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(
|
||||
this.organization,
|
||||
this.organizationSubscriptionResponse,
|
||||
paymentSource,
|
||||
this.paymentSource,
|
||||
);
|
||||
}
|
||||
// TODO: Eslint upgrade. Please resolve this since the ?? does nothing
|
||||
// eslint-disable-next-line no-constant-binary-expression
|
||||
this.isUnpaid = this.subscriptionStatus === "unpaid" ?? false;
|
||||
// If the flag `launchPaymentModalAutomatically` is set to true,
|
||||
// we schedule a timeout (delay of 800ms) to automatically launch the payment modal.
|
||||
// This delay ensures that any prior UI/rendering operations complete before triggering the modal.
|
||||
@@ -219,14 +227,14 @@ export class OrganizationPaymentMethodComponent implements OnDestroy {
|
||||
const dialogRef = TrialPaymentDialogComponent.open(this.dialogService, {
|
||||
data: {
|
||||
organizationId: this.organizationId,
|
||||
subscription: this.organizationSubscriptionResponse,
|
||||
productTierType: this.organization?.productTierType,
|
||||
subscription: this.organizationSubscriptionResponse!,
|
||||
productTierType: this.organization!.productTierType,
|
||||
},
|
||||
});
|
||||
const result = await lastValueFrom(dialogRef.closed);
|
||||
if (result === TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.SUBMITTED) {
|
||||
this.location.replaceState(this.location.path(), "", {});
|
||||
if (this.launchPaymentModalAutomatically && !this.organization.enabled) {
|
||||
if (this.launchPaymentModalAutomatically && !this.organization?.enabled) {
|
||||
await this.syncService.fullSync(true);
|
||||
}
|
||||
this.launchPaymentModalAutomatically = false;
|
||||
@@ -238,13 +246,14 @@ export class OrganizationPaymentMethodComponent implements OnDestroy {
|
||||
await this.billingApiService.verifyOrganizationBankAccount(this.organizationId, request);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
title: "",
|
||||
message: this.i18nService.t("verifiedBankAccount"),
|
||||
});
|
||||
};
|
||||
|
||||
protected get accountCreditHeaderText(): string {
|
||||
const key = this.accountCredit <= 0 ? "accountBalance" : "accountCredit";
|
||||
const hasAccountCredit = this.accountCredit && this.accountCredit > 0;
|
||||
const key = hasAccountCredit ? "accountCredit" : "accountBalance";
|
||||
return this.i18nService.t(key);
|
||||
}
|
||||
|
||||
@@ -279,7 +288,7 @@ export class OrganizationPaymentMethodComponent implements OnDestroy {
|
||||
if (!hasBillingAddress) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: null,
|
||||
title: "",
|
||||
message: this.i18nService.t("billingAddressRequiredToAddCredit"),
|
||||
});
|
||||
return false;
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import { PaymentComponent } from "../payment/payment.component";
|
||||
|
||||
export interface AdjustPaymentDialogParams {
|
||||
initialPaymentMethod?: PaymentMethodType;
|
||||
initialPaymentMethod?: PaymentMethodType | null;
|
||||
organizationId?: string;
|
||||
productTier?: ProductTierType;
|
||||
providerId?: string;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Location } from "@angular/common";
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { FormBuilder, FormControl, Validators } from "@angular/forms";
|
||||
@@ -42,21 +40,21 @@ import {
|
||||
export class PaymentMethodComponent implements OnInit, OnDestroy {
|
||||
loading = false;
|
||||
firstLoaded = false;
|
||||
billing: BillingPaymentResponse;
|
||||
org: OrganizationSubscriptionResponse;
|
||||
sub: SubscriptionResponse;
|
||||
billing?: BillingPaymentResponse;
|
||||
org?: OrganizationSubscriptionResponse;
|
||||
sub?: SubscriptionResponse;
|
||||
paymentMethodType = PaymentMethodType;
|
||||
organizationId: string;
|
||||
organizationId?: string;
|
||||
isUnpaid = false;
|
||||
organization: Organization;
|
||||
organization?: Organization;
|
||||
|
||||
verifyBankForm = this.formBuilder.group({
|
||||
amount1: new FormControl<number>(null, [
|
||||
amount1: new FormControl<number>(0, [
|
||||
Validators.required,
|
||||
Validators.max(99),
|
||||
Validators.min(0),
|
||||
]),
|
||||
amount2: new FormControl<number>(null, [
|
||||
amount2: new FormControl<number>(0, [
|
||||
Validators.required,
|
||||
Validators.max(99),
|
||||
Validators.min(0),
|
||||
@@ -64,7 +62,7 @@ export class PaymentMethodComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
|
||||
launchPaymentModalAutomatically = false;
|
||||
protected freeTrialData: FreeTrial;
|
||||
protected freeTrialData?: FreeTrial;
|
||||
|
||||
constructor(
|
||||
protected apiService: ApiService,
|
||||
@@ -84,7 +82,7 @@ export class PaymentMethodComponent implements OnInit, OnDestroy {
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
const state = this.router.getCurrentNavigation()?.extras?.state;
|
||||
// incase the above state is undefined or null we use redundantState
|
||||
// In case the above state is undefined or null, we use redundantState
|
||||
const redundantState: any = location.getState();
|
||||
if (state && Object.prototype.hasOwnProperty.call(state, "launchPaymentModalAutomatically")) {
|
||||
this.launchPaymentModalAutomatically = state.launchPaymentModalAutomatically;
|
||||
@@ -129,17 +127,23 @@ export class PaymentMethodComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
this.loading = true;
|
||||
if (this.forOrganization) {
|
||||
const billingPromise = this.organizationApiService.getBilling(this.organizationId);
|
||||
const billingPromise = this.organizationApiService.getBilling(this.organizationId!);
|
||||
const organizationSubscriptionPromise = this.organizationApiService.getSubscription(
|
||||
this.organizationId,
|
||||
this.organizationId!,
|
||||
);
|
||||
|
||||
const userId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||
);
|
||||
|
||||
if (!userId) {
|
||||
throw new Error("User ID is not found");
|
||||
}
|
||||
|
||||
const organizationPromise = await firstValueFrom(
|
||||
this.organizationService
|
||||
.organizations$(userId)
|
||||
.pipe(getOrganizationById(this.organizationId)),
|
||||
.pipe(getOrganizationById(this.organizationId!)),
|
||||
);
|
||||
|
||||
[this.billing, this.org, this.organization] = await Promise.all([
|
||||
@@ -171,14 +175,16 @@ export class PaymentMethodComponent implements OnInit, OnDestroy {
|
||||
};
|
||||
|
||||
addCredit = async () => {
|
||||
const dialogRef = openAddCreditDialog(this.dialogService, {
|
||||
data: {
|
||||
organizationId: this.organizationId,
|
||||
},
|
||||
});
|
||||
const result = await lastValueFrom(dialogRef.closed);
|
||||
if (result === AddCreditDialogResult.Added) {
|
||||
await this.load();
|
||||
if (this.forOrganization) {
|
||||
const dialogRef = openAddCreditDialog(this.dialogService, {
|
||||
data: {
|
||||
organizationId: this.organizationId!,
|
||||
},
|
||||
});
|
||||
const result = await lastValueFrom(dialogRef.closed);
|
||||
if (result === AddCreditDialogResult.Added) {
|
||||
await this.load();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -194,7 +200,7 @@ export class PaymentMethodComponent implements OnInit, OnDestroy {
|
||||
|
||||
if (result === AdjustPaymentDialogResultType.Submitted) {
|
||||
this.location.replaceState(this.location.path(), "", {});
|
||||
if (this.launchPaymentModalAutomatically && !this.organization.enabled) {
|
||||
if (this.launchPaymentModalAutomatically && !this.organization?.enabled) {
|
||||
await this.syncService.fullSync(true);
|
||||
}
|
||||
this.launchPaymentModalAutomatically = false;
|
||||
@@ -208,18 +214,22 @@ export class PaymentMethodComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
const request = new VerifyBankRequest();
|
||||
request.amount1 = this.verifyBankForm.value.amount1;
|
||||
request.amount2 = this.verifyBankForm.value.amount2;
|
||||
await this.organizationApiService.verifyBank(this.organizationId, request);
|
||||
request.amount1 = this.verifyBankForm.value.amount1!;
|
||||
request.amount2 = this.verifyBankForm.value.amount2!;
|
||||
await this.organizationApiService.verifyBank(this.organizationId!, request);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
title: "",
|
||||
message: this.i18nService.t("verifiedBankAccount"),
|
||||
});
|
||||
await this.load();
|
||||
};
|
||||
|
||||
determineOrgsWithUpcomingPaymentIssues() {
|
||||
if (!this.organization || !this.org || !this.billing) {
|
||||
throw new Error("Organization, organization subscription, or billing is not defined");
|
||||
}
|
||||
|
||||
this.freeTrialData = this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(
|
||||
this.organization,
|
||||
this.org,
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
[disabled]="orgInfoFormGroup.controls.name.invalid"
|
||||
[loading]="loading && (trialPaymentOptional$ | async)"
|
||||
[loading]="loading && (trialPaymentOptional$ | async)!"
|
||||
(click)="orgNameEntrySubmit()"
|
||||
>
|
||||
{{
|
||||
@@ -55,8 +55,8 @@
|
||||
<app-trial-billing-step
|
||||
*ngIf="stepper.selectedIndex === 2"
|
||||
[organizationInfo]="{
|
||||
name: orgInfoFormGroup.value.name,
|
||||
email: orgInfoFormGroup.value.billingEmail,
|
||||
name: orgInfoFormGroup.value.name!,
|
||||
email: orgInfoFormGroup.value.billingEmail!,
|
||||
type: trialOrganizationType,
|
||||
}"
|
||||
[subscriptionProduct]="
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { StepperSelectionEvent } from "@angular/cdk/stepper";
|
||||
import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core";
|
||||
import { FormBuilder, Validators } from "@angular/forms";
|
||||
@@ -50,15 +48,15 @@ export type InitiationPath =
|
||||
standalone: false,
|
||||
})
|
||||
export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
|
||||
@ViewChild("stepper", { static: false }) verticalStepper: VerticalStepperComponent;
|
||||
@ViewChild("stepper", { static: false }) verticalStepper!: VerticalStepperComponent;
|
||||
|
||||
inputPasswordFlow = InputPasswordFlow.SetInitialPasswordAccountRegistration;
|
||||
initializing = true;
|
||||
|
||||
/** Password Manager or Secrets Manager */
|
||||
product: ProductType;
|
||||
product?: ProductType;
|
||||
/** The tier of product being subscribed to */
|
||||
productTier: ProductTierType;
|
||||
productTier!: ProductTierType;
|
||||
/** Product types that display steppers for Password Manager */
|
||||
stepperProductTypes: ProductTierType[] = [
|
||||
ProductTierType.Teams,
|
||||
@@ -79,16 +77,16 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
|
||||
orgId = "";
|
||||
orgLabel = "";
|
||||
billingSubLabel = "";
|
||||
enforcedPolicyOptions: MasterPasswordPolicyOptions;
|
||||
enforcedPolicyOptions?: MasterPasswordPolicyOptions;
|
||||
|
||||
/** User's email address associated with the trial */
|
||||
email = "";
|
||||
/** Token from the backend associated with the email verification */
|
||||
emailVerificationToken: string;
|
||||
emailVerificationToken?: string;
|
||||
loading = false;
|
||||
productTierValue: number;
|
||||
productTierValue?: ProductTierType;
|
||||
|
||||
trialLength: number;
|
||||
trialLength!: number;
|
||||
|
||||
orgInfoFormGroup = this.formBuilder.group({
|
||||
name: ["", { validators: [Validators.required, Validators.maxLength(50)], updateOn: "change" }],
|
||||
@@ -132,7 +130,7 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
|
||||
// Show email validation toast when coming from email
|
||||
if (qParams.fromEmail && qParams.fromEmail === "true") {
|
||||
this.toastService.showToast({
|
||||
title: null,
|
||||
title: "",
|
||||
message: this.i18nService.t("emailVerifiedV2"),
|
||||
variant: "success",
|
||||
});
|
||||
@@ -172,9 +170,15 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
|
||||
const invite = await this.organizationInviteService.getOrganizationInvite();
|
||||
let policies: Policy[] | null = null;
|
||||
let policies: Policy[] | undefined | null = null;
|
||||
|
||||
if (invite != null) {
|
||||
if (
|
||||
invite != null &&
|
||||
invite.organizationId &&
|
||||
invite.token &&
|
||||
invite.email &&
|
||||
invite.organizationUserId
|
||||
) {
|
||||
try {
|
||||
policies = await this.policyApiService.getPoliciesByToken(
|
||||
invite.organizationId,
|
||||
@@ -218,7 +222,7 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
|
||||
if (event.selectedIndex === 1 && this.orgInfoFormGroup.controls.name.value === "") {
|
||||
this.orgInfoSubLabel = this.planInfoLabel;
|
||||
} else if (event.previouslySelectedIndex === 1) {
|
||||
this.orgInfoSubLabel = this.orgInfoFormGroup.controls.name.value;
|
||||
this.orgInfoSubLabel = this.orgInfoFormGroup.controls.name.value!;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,8 +264,11 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
const organization: OrganizationInformation = {
|
||||
name: this.orgInfoFormGroup.value.name,
|
||||
billingEmail: this.orgInfoFormGroup.value.billingEmail,
|
||||
name: this.orgInfoFormGroup.value.name == null ? "" : this.orgInfoFormGroup.value.name,
|
||||
billingEmail:
|
||||
this.orgInfoFormGroup.value.billingEmail == null
|
||||
? ""
|
||||
: this.orgInfoFormGroup.value.billingEmail,
|
||||
initiationPath: trialInitiationPath,
|
||||
};
|
||||
|
||||
@@ -326,7 +333,7 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
get trialOrganizationType(): TrialOrganizationType {
|
||||
get trialOrganizationType(): TrialOrganizationType | null {
|
||||
if (this.productTier === ProductTierType.Free) {
|
||||
return null;
|
||||
}
|
||||
@@ -352,8 +359,12 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
|
||||
|
||||
const response = await this.organizationBillingService.startFree({
|
||||
organization: {
|
||||
name: this.orgInfoFormGroup.value.name,
|
||||
billingEmail: this.orgInfoFormGroup.value.billingEmail,
|
||||
name: this.orgInfoFormGroup.value.name == null ? "" : this.orgInfoFormGroup.value.name,
|
||||
billingEmail:
|
||||
this.orgInfoFormGroup.value.billingEmail == null
|
||||
? ""
|
||||
: this.orgInfoFormGroup.value.billingEmail,
|
||||
initiationPath: "Password Manager trial from marketing website",
|
||||
},
|
||||
plan: {
|
||||
type: 0,
|
||||
@@ -405,11 +416,11 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
|
||||
await this.loginStrategyService.logIn(credentials);
|
||||
}
|
||||
|
||||
finishRegistration(passwordInputResult: PasswordInputResult) {
|
||||
async finishRegistration(passwordInputResult: PasswordInputResult) {
|
||||
this.submitting = true;
|
||||
return this.registrationFinishService
|
||||
.finishRegistration(this.email, passwordInputResult, this.emailVerificationToken)
|
||||
.catch((e) => {
|
||||
.catch((e: unknown): null => {
|
||||
this.validationService.showError(e);
|
||||
this.submitting = false;
|
||||
return null;
|
||||
|
||||
@@ -752,6 +752,7 @@ const safeProviders: SafeProvider[] = [
|
||||
LogService,
|
||||
LOGOUT_CALLBACK,
|
||||
VaultTimeoutSettingsService,
|
||||
AccountService,
|
||||
HTTP_OPERATIONS,
|
||||
],
|
||||
}),
|
||||
@@ -1158,7 +1159,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: ConfigApiServiceAbstraction,
|
||||
useClass: ConfigApiService,
|
||||
deps: [ApiServiceAbstraction, TokenServiceAbstraction],
|
||||
deps: [ApiServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: AnonymousHubServiceAbstraction,
|
||||
|
||||
@@ -127,11 +127,34 @@ import { OptionalCipherResponse } from "../vault/models/response/optional-cipher
|
||||
* of this decision please read https://contributing.bitwarden.com/architecture/adr/refactor-api-service.
|
||||
*/
|
||||
export abstract class ApiService {
|
||||
/** @deprecated Use the overload accepting the user you want the request authenticated for. */
|
||||
abstract send(
|
||||
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
|
||||
path: string,
|
||||
body: any,
|
||||
authed: boolean,
|
||||
authed: true,
|
||||
hasResponse: boolean,
|
||||
apiUrl?: string | null,
|
||||
alterHeaders?: (header: Headers) => void,
|
||||
): Promise<any>;
|
||||
|
||||
/** Sends an unauthenticated API request. */
|
||||
abstract send(
|
||||
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
|
||||
path: string,
|
||||
body: any,
|
||||
authed: false,
|
||||
hasResponse: boolean,
|
||||
apiUrl?: string | null,
|
||||
alterHeaders?: (header: Headers) => void,
|
||||
): Promise<any>;
|
||||
|
||||
/** Sends an API request authenticated with the given users ID. */
|
||||
abstract send(
|
||||
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
|
||||
path: string,
|
||||
body: any,
|
||||
userId: UserId,
|
||||
hasResponse: boolean,
|
||||
apiUrl?: string | null,
|
||||
alterHeaders?: (headers: Headers) => void,
|
||||
@@ -499,7 +522,7 @@ export abstract class ApiService {
|
||||
abstract postBitPayInvoice(request: BitPayInvoiceRequest): Promise<string>;
|
||||
abstract postSetupPayment(): Promise<string>;
|
||||
|
||||
abstract getActiveBearerToken(): Promise<string>;
|
||||
abstract getActiveBearerToken(userId: UserId): Promise<string>;
|
||||
abstract fetch(request: Request): Promise<Response>;
|
||||
abstract nativeFetch(request: Request): Promise<Response>;
|
||||
|
||||
|
||||
@@ -72,14 +72,14 @@ export abstract class TokenService {
|
||||
* @param userId - The optional user id to get the access token for; if not provided, the active user is used.
|
||||
* @returns A promise that resolves with the access token or null.
|
||||
*/
|
||||
abstract getAccessToken(userId?: UserId): Promise<string | null>;
|
||||
abstract getAccessToken(userId: UserId): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Gets the refresh token.
|
||||
* @param userId - The optional user id to get the refresh token for; if not provided, the active user is used.
|
||||
* @returns A promise that resolves with the refresh token or null.
|
||||
*/
|
||||
abstract getRefreshToken(userId?: UserId): Promise<string | null>;
|
||||
abstract getRefreshToken(userId: UserId): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Sets the API Key Client ID for the active user id in memory or disk based on the given vaultTimeoutAction and vaultTimeout.
|
||||
@@ -96,10 +96,10 @@ export abstract class TokenService {
|
||||
): Promise<string>;
|
||||
|
||||
/**
|
||||
* Gets the API Key Client ID for the active user.
|
||||
* Gets the API Key Client ID for the given user.
|
||||
* @returns A promise that resolves with the API Key Client ID or undefined
|
||||
*/
|
||||
abstract getClientId(userId?: UserId): Promise<string | undefined>;
|
||||
abstract getClientId(userId: UserId): Promise<string | undefined>;
|
||||
|
||||
/**
|
||||
* Sets the API Key Client Secret for the active user id in memory or disk based on the given vaultTimeoutAction and vaultTimeout.
|
||||
@@ -116,10 +116,10 @@ export abstract class TokenService {
|
||||
): Promise<string>;
|
||||
|
||||
/**
|
||||
* Gets the API Key Client Secret for the active user.
|
||||
* Gets the API Key Client Secret for the given user.
|
||||
* @returns A promise that resolves with the API Key Client Secret or undefined
|
||||
*/
|
||||
abstract getClientSecret(userId?: UserId): Promise<string | undefined>;
|
||||
abstract getClientSecret(userId: UserId): Promise<string | undefined>;
|
||||
|
||||
/**
|
||||
* Sets the two factor token for the given email in global state.
|
||||
@@ -157,7 +157,7 @@ export abstract class TokenService {
|
||||
* Gets the expiration date for the access token. Returns if token can't be decoded or has no expiration
|
||||
* @returns A promise that resolves with the expiration date for the access token.
|
||||
*/
|
||||
abstract getTokenExpirationDate(): Promise<Date | null>;
|
||||
abstract getTokenExpirationDate(userId: UserId): Promise<Date | null>;
|
||||
|
||||
/**
|
||||
* Calculates the adjusted time in seconds until the access token expires, considering an optional offset.
|
||||
@@ -168,14 +168,14 @@ export abstract class TokenService {
|
||||
* based on the actual expiration.
|
||||
* @returns {Promise<number>} Promise resolving to the adjusted seconds remaining.
|
||||
*/
|
||||
abstract tokenSecondsRemaining(offsetSeconds?: number): Promise<number>;
|
||||
abstract tokenSecondsRemaining(userId: UserId, offsetSeconds?: number): Promise<number>;
|
||||
|
||||
/**
|
||||
* Checks if the access token needs to be refreshed.
|
||||
* @param {number} [minutes=5] - Optional number of minutes before the access token expires to consider refreshing it.
|
||||
* @returns A promise that resolves with a boolean indicating if the access token needs to be refreshed.
|
||||
*/
|
||||
abstract tokenNeedsRefresh(minutes?: number): Promise<boolean>;
|
||||
abstract tokenNeedsRefresh(userId: UserId, minutes?: number): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Gets the user id for the active user from the access token.
|
||||
|
||||
@@ -409,28 +409,8 @@ describe("TokenService", () => {
|
||||
});
|
||||
|
||||
describe("getAccessToken", () => {
|
||||
it("returns null when no user id is provided and there is no active user in global state", async () => {
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken();
|
||||
// Assert
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no access token is found in memory, disk, or secure storage", async () => {
|
||||
// Arrange
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken();
|
||||
// Assert
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
describe("Memory storage tests", () => {
|
||||
test.each([
|
||||
["gets the access token from memory when a user id is provided ", userIdFromAccessToken],
|
||||
["gets the access token from memory when no user id is provided", undefined],
|
||||
])("%s", async (_, userId) => {
|
||||
it("gets the access token from memory when a user id is provided ", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
@@ -442,12 +422,10 @@ describe("TokenService", () => {
|
||||
.nextState(undefined);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
if (!userId) {
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
}
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken(userId);
|
||||
const result = await tokenService.getAccessToken(userIdFromAccessToken);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(accessTokenJwt);
|
||||
@@ -455,10 +433,7 @@ describe("TokenService", () => {
|
||||
});
|
||||
|
||||
describe("Disk storage tests (secure storage not supported on platform)", () => {
|
||||
test.each([
|
||||
["gets the access token from disk when the user id is specified", userIdFromAccessToken],
|
||||
["gets the access token from disk when no user id is specified", undefined],
|
||||
])("%s", async (_, userId) => {
|
||||
it("gets the access token from disk when the user id is specified", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
@@ -469,12 +444,10 @@ describe("TokenService", () => {
|
||||
.nextState(accessTokenJwt);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
if (!userId) {
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
}
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken(userId);
|
||||
const result = await tokenService.getAccessToken(userIdFromAccessToken);
|
||||
// Assert
|
||||
expect(result).toEqual(accessTokenJwt);
|
||||
});
|
||||
@@ -486,16 +459,7 @@ describe("TokenService", () => {
|
||||
tokenService = createTokenService(supportsSecureStorage);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[
|
||||
"gets the encrypted access token from disk, decrypts it, and returns it when a user id is provided",
|
||||
userIdFromAccessToken,
|
||||
],
|
||||
[
|
||||
"gets the encrypted access token from disk, decrypts it, and returns it when no user id is provided",
|
||||
undefined,
|
||||
],
|
||||
])("%s", async (_, userId) => {
|
||||
it("gets the encrypted access token from disk, decrypts it, and returns it when a user id is provided", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
@@ -509,27 +473,17 @@ describe("TokenService", () => {
|
||||
encryptService.decryptString.mockResolvedValue("decryptedAccessToken");
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
if (!userId) {
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
}
|
||||
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken(userId);
|
||||
const result = await tokenService.getAccessToken(userIdFromAccessToken);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual("decryptedAccessToken");
|
||||
});
|
||||
|
||||
test.each([
|
||||
[
|
||||
"falls back and gets the unencrypted access token from disk when there isn't an access token key in secure storage and a user id is provided",
|
||||
userIdFromAccessToken,
|
||||
],
|
||||
[
|
||||
"falls back and gets the unencrypted access token from disk when there isn't an access token key in secure storage and no user id is provided",
|
||||
undefined,
|
||||
],
|
||||
])("%s", async (_, userId) => {
|
||||
it("falls back and gets the unencrypted access token from disk when there isn't an access token key in secure storage and a user id is provided", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
@@ -540,14 +494,12 @@ describe("TokenService", () => {
|
||||
.nextState(accessTokenJwt);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
if (!userId) {
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
}
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
|
||||
// No access token key set
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken(userId);
|
||||
const result = await tokenService.getAccessToken(userIdFromAccessToken);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(accessTokenJwt);
|
||||
@@ -738,7 +690,7 @@ describe("TokenService", () => {
|
||||
|
||||
// Act
|
||||
// note: don't await here because we want to test the error
|
||||
const result = tokenService.getTokenExpirationDate();
|
||||
const result = tokenService.getTokenExpirationDate(userIdFromAccessToken);
|
||||
// Assert
|
||||
await expect(result).rejects.toThrow("Failed to decode access token: Mock error");
|
||||
});
|
||||
@@ -748,7 +700,7 @@ describe("TokenService", () => {
|
||||
tokenService.decodeAccessToken = jest.fn().mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getTokenExpirationDate();
|
||||
const result = await tokenService.getTokenExpirationDate(userIdFromAccessToken);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeNull();
|
||||
@@ -763,7 +715,7 @@ describe("TokenService", () => {
|
||||
.mockResolvedValue(accessTokenDecodedWithoutExp);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getTokenExpirationDate();
|
||||
const result = await tokenService.getTokenExpirationDate(userIdFromAccessToken);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeNull();
|
||||
@@ -777,7 +729,7 @@ describe("TokenService", () => {
|
||||
.mockResolvedValue(accessTokenDecodedWithNonNumericExp);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getTokenExpirationDate();
|
||||
const result = await tokenService.getTokenExpirationDate(userIdFromAccessToken);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeNull();
|
||||
@@ -788,7 +740,7 @@ describe("TokenService", () => {
|
||||
tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getTokenExpirationDate();
|
||||
const result = await tokenService.getTokenExpirationDate(userIdFromAccessToken);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(new Date(accessTokenDecoded.exp * 1000));
|
||||
@@ -801,7 +753,7 @@ describe("TokenService", () => {
|
||||
tokenService.getTokenExpirationDate = jest.fn().mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.tokenSecondsRemaining();
|
||||
const result = await tokenService.tokenSecondsRemaining(userIdFromAccessToken);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(0);
|
||||
@@ -823,7 +775,7 @@ describe("TokenService", () => {
|
||||
tokenService.getTokenExpirationDate = jest.fn().mockResolvedValue(expirationDate);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.tokenSecondsRemaining();
|
||||
const result = await tokenService.tokenSecondsRemaining(userIdFromAccessToken);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(expectedSecondsRemaining);
|
||||
@@ -849,7 +801,10 @@ describe("TokenService", () => {
|
||||
tokenService.getTokenExpirationDate = jest.fn().mockResolvedValue(expirationDate);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.tokenSecondsRemaining(offsetSeconds);
|
||||
const result = await tokenService.tokenSecondsRemaining(
|
||||
userIdFromAccessToken,
|
||||
offsetSeconds,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(expectedSecondsRemaining);
|
||||
@@ -866,7 +821,7 @@ describe("TokenService", () => {
|
||||
tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.tokenNeedsRefresh();
|
||||
const result = await tokenService.tokenNeedsRefresh(userIdFromAccessToken);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(true);
|
||||
@@ -878,7 +833,7 @@ describe("TokenService", () => {
|
||||
tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.tokenNeedsRefresh();
|
||||
const result = await tokenService.tokenNeedsRefresh(userIdFromAccessToken);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(false);
|
||||
@@ -890,7 +845,7 @@ describe("TokenService", () => {
|
||||
tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.tokenNeedsRefresh(2);
|
||||
const result = await tokenService.tokenNeedsRefresh(userIdFromAccessToken, 2);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(true);
|
||||
@@ -902,7 +857,7 @@ describe("TokenService", () => {
|
||||
tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.tokenNeedsRefresh(5);
|
||||
const result = await tokenService.tokenNeedsRefresh(userIdFromAccessToken, 5);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(false);
|
||||
@@ -1565,26 +1520,6 @@ describe("TokenService", () => {
|
||||
});
|
||||
|
||||
describe("Memory storage tests", () => {
|
||||
it("gets the refresh token from memory when no user id is specified (uses global active user)", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
|
||||
.nextState(refreshToken);
|
||||
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
|
||||
.nextState(undefined);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getRefreshToken();
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(refreshToken);
|
||||
});
|
||||
|
||||
it("gets the refresh token from memory when a user id is specified", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
@@ -1603,25 +1538,6 @@ describe("TokenService", () => {
|
||||
});
|
||||
|
||||
describe("Disk storage tests (secure storage not supported on platform)", () => {
|
||||
it("gets the refresh token from disk when no user id is specified", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
|
||||
.nextState(undefined);
|
||||
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
|
||||
.nextState(refreshToken);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getRefreshToken();
|
||||
// Assert
|
||||
expect(result).toEqual(refreshToken);
|
||||
});
|
||||
|
||||
it("gets the refresh token from disk when a user id is specified", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
@@ -1645,27 +1561,6 @@ describe("TokenService", () => {
|
||||
tokenService = createTokenService(supportsSecureStorage);
|
||||
});
|
||||
|
||||
it("gets the refresh token from secure storage when no user id is specified", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
|
||||
.nextState(undefined);
|
||||
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
|
||||
.nextState(undefined);
|
||||
|
||||
secureStorageService.get.mockResolvedValue(refreshToken);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getRefreshToken();
|
||||
// Assert
|
||||
expect(result).toEqual(refreshToken);
|
||||
});
|
||||
|
||||
it("gets the refresh token from secure storage when a user id is specified", async () => {
|
||||
// Arrange
|
||||
|
||||
@@ -1705,29 +1600,6 @@ describe("TokenService", () => {
|
||||
expect(secureStorageService.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back and gets the refresh token from disk when no user id is specified even if the platform supports secure storage", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
|
||||
.nextState(undefined);
|
||||
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
|
||||
.nextState(refreshToken);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getRefreshToken();
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(refreshToken);
|
||||
|
||||
// assert that secure storage was not called
|
||||
expect(secureStorageService.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null when the refresh token is not found in memory, on disk, or in secure storage", async () => {
|
||||
// Arrange
|
||||
secureStorageService.get.mockResolvedValue(null);
|
||||
@@ -1944,45 +1816,7 @@ describe("TokenService", () => {
|
||||
});
|
||||
|
||||
describe("getClientId", () => {
|
||||
it("returns undefined when no user id is provided and there is no active user in global state", async () => {
|
||||
// Act
|
||||
const result = await tokenService.getClientId();
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns null when no client id is found in memory or disk", async () => {
|
||||
// Arrange
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getClientId();
|
||||
// Assert
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
describe("Memory storage tests", () => {
|
||||
it("gets the client id from memory when no user id is specified (uses global active user)", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY)
|
||||
.nextState(clientId);
|
||||
|
||||
// set disk to undefined
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK)
|
||||
.nextState(undefined);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getClientId();
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(clientId);
|
||||
});
|
||||
|
||||
it("gets the client id from memory when given a user id", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
@@ -2002,25 +1836,6 @@ describe("TokenService", () => {
|
||||
});
|
||||
|
||||
describe("Disk storage tests", () => {
|
||||
it("gets the client id from disk when no user id is specified", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY)
|
||||
.nextState(undefined);
|
||||
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK)
|
||||
.nextState(clientId);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getClientId();
|
||||
// Assert
|
||||
expect(result).toEqual(clientId);
|
||||
});
|
||||
|
||||
it("gets the client id from disk when a user id is specified", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
@@ -2215,45 +2030,17 @@ describe("TokenService", () => {
|
||||
});
|
||||
|
||||
describe("getClientSecret", () => {
|
||||
it("returns undefined when no user id is provided and there is no active user in global state", async () => {
|
||||
// Act
|
||||
const result = await tokenService.getClientSecret();
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns null when no client secret is found in memory or disk", async () => {
|
||||
// Arrange
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getClientSecret();
|
||||
const result = await tokenService.getClientSecret(userIdFromAccessToken);
|
||||
// Assert
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
describe("Memory storage tests", () => {
|
||||
it("gets the client secret from memory when no user id is specified (uses global active user)", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY)
|
||||
.nextState(clientSecret);
|
||||
|
||||
// set disk to undefined
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK)
|
||||
.nextState(undefined);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getClientSecret();
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(clientSecret);
|
||||
});
|
||||
|
||||
it("gets the client secret from memory when a user id is specified", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
@@ -2273,25 +2060,6 @@ describe("TokenService", () => {
|
||||
});
|
||||
|
||||
describe("Disk storage tests", () => {
|
||||
it("gets the client secret from disk when no user id specified", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY)
|
||||
.nextState(undefined);
|
||||
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK)
|
||||
.nextState(clientSecret);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getClientSecret();
|
||||
// Assert
|
||||
expect(result).toEqual(clientSecret);
|
||||
});
|
||||
|
||||
it("gets the client secret from disk when a user id is specified", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
|
||||
@@ -452,9 +452,7 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MEMORY).update((_) => null);
|
||||
}
|
||||
|
||||
async getAccessToken(userId?: UserId): Promise<string | null> {
|
||||
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
|
||||
|
||||
async getAccessToken(userId: UserId): Promise<string | null> {
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
@@ -631,9 +629,7 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
}
|
||||
}
|
||||
|
||||
async getRefreshToken(userId?: UserId): Promise<string | null> {
|
||||
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
|
||||
|
||||
async getRefreshToken(userId: UserId): Promise<string | null> {
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
@@ -746,9 +742,7 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
}
|
||||
}
|
||||
|
||||
async getClientId(userId?: UserId): Promise<string | undefined> {
|
||||
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
|
||||
|
||||
async getClientId(userId: UserId): Promise<string | undefined> {
|
||||
if (!userId) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -822,9 +816,7 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
}
|
||||
}
|
||||
|
||||
async getClientSecret(userId?: UserId): Promise<string | undefined> {
|
||||
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
|
||||
|
||||
async getClientSecret(userId: UserId): Promise<string | undefined> {
|
||||
if (!userId) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -915,7 +907,9 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
if (Utils.isGuid(tokenOrUserId)) {
|
||||
token = await this.getAccessToken(tokenOrUserId as UserId);
|
||||
} else {
|
||||
token ??= await this.getAccessToken();
|
||||
token ??= await this.getAccessToken(
|
||||
await firstValueFrom(this.activeUserIdGlobalState.state$),
|
||||
);
|
||||
}
|
||||
|
||||
if (token == null) {
|
||||
@@ -928,10 +922,10 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
// TODO: PM-6678- tech debt - consider consolidating the return types of all these access
|
||||
// token data retrieval methods to return null if something goes wrong instead of throwing an error.
|
||||
|
||||
async getTokenExpirationDate(): Promise<Date | null> {
|
||||
async getTokenExpirationDate(userId: UserId): Promise<Date | null> {
|
||||
let decoded: DecodedAccessToken;
|
||||
try {
|
||||
decoded = await this.decodeAccessToken();
|
||||
decoded = await this.decodeAccessToken(userId);
|
||||
} catch (error) {
|
||||
throw new Error("Failed to decode access token: " + error.message);
|
||||
}
|
||||
@@ -947,8 +941,8 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
return expirationDate;
|
||||
}
|
||||
|
||||
async tokenSecondsRemaining(offsetSeconds = 0): Promise<number> {
|
||||
const date = await this.getTokenExpirationDate();
|
||||
async tokenSecondsRemaining(userId: UserId, offsetSeconds = 0): Promise<number> {
|
||||
const date = await this.getTokenExpirationDate(userId);
|
||||
if (date == null) {
|
||||
return 0;
|
||||
}
|
||||
@@ -957,8 +951,8 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
return Math.round(msRemaining / 1000);
|
||||
}
|
||||
|
||||
async tokenNeedsRefresh(minutes = 5): Promise<boolean> {
|
||||
const sRemaining = await this.tokenSecondsRemaining();
|
||||
async tokenNeedsRefresh(userId: UserId, minutes = 5): Promise<boolean> {
|
||||
const sRemaining = await this.tokenSecondsRemaining(userId);
|
||||
return sRemaining < 60 * minutes;
|
||||
}
|
||||
|
||||
|
||||
@@ -70,17 +70,17 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
|
||||
|
||||
// We swap these tokens from being on disk for lock actions, and in memory for logout actions
|
||||
// Get them here to set them to their new location after changing the timeout action and clearing if needed
|
||||
const accessToken = await this.tokenService.getAccessToken();
|
||||
const refreshToken = await this.tokenService.getRefreshToken();
|
||||
const clientId = await this.tokenService.getClientId();
|
||||
const clientSecret = await this.tokenService.getClientSecret();
|
||||
const accessToken = await this.tokenService.getAccessToken(userId);
|
||||
const refreshToken = await this.tokenService.getRefreshToken(userId);
|
||||
const clientId = await this.tokenService.getClientId(userId);
|
||||
const clientSecret = await this.tokenService.getClientSecret(userId);
|
||||
|
||||
await this.setVaultTimeout(userId, timeout);
|
||||
|
||||
if (timeout != VaultTimeoutStringType.Never && action === VaultTimeoutAction.LogOut) {
|
||||
// if we have a vault timeout and the action is log out, reset tokens
|
||||
// as the tokens were stored on disk and now should be stored in memory
|
||||
await this.tokenService.clearTokens();
|
||||
await this.tokenService.clearTokens(userId);
|
||||
}
|
||||
|
||||
await this.setVaultTimeoutAction(userId, action);
|
||||
|
||||
@@ -78,7 +78,7 @@ export class SignalRConnectionService {
|
||||
return new Observable<SignalRNotification>((subsciber) => {
|
||||
const connection = this.hubConnectionBuilderFactory()
|
||||
.withUrl(notificationsUrl + "/hub", {
|
||||
accessTokenFactory: () => this.apiService.getActiveBearerToken(),
|
||||
accessTokenFactory: () => this.apiService.getActiveBearerToken(userId),
|
||||
skipNegotiation: true,
|
||||
transport: HttpTransportType.WebSockets,
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { AppIdService } from "../../abstractions/app-id.service";
|
||||
|
||||
@@ -12,13 +14,13 @@ export class WebPushNotificationsApiService {
|
||||
/**
|
||||
* Posts a device-user association to the server and ensures it's installed for push server notifications
|
||||
*/
|
||||
async putSubscription(pushSubscription: PushSubscriptionJSON): Promise<void> {
|
||||
async putSubscription(pushSubscription: PushSubscriptionJSON, userId: UserId): Promise<void> {
|
||||
const request = WebPushRequest.from(pushSubscription);
|
||||
await this.apiService.send(
|
||||
"POST",
|
||||
`/devices/identifier/${await this.appIdService.getAppId()}/web-push-auth`,
|
||||
request,
|
||||
true,
|
||||
userId,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ class MyWebPushConnector implements WebPushConnector {
|
||||
await subscriptionUsersState.update(() => subscriptionUsers);
|
||||
|
||||
// Inform the server about the new subscription-user association
|
||||
await this.webPushApiService.putSubscription(subscription.toJSON());
|
||||
await this.webPushApiService.putSubscription(subscription.toJSON(), this.userId);
|
||||
}),
|
||||
switchMap(() => this.pushEvent$),
|
||||
map((e) => {
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { TokenService } from "../../../auth/abstractions/token.service";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction";
|
||||
import { ServerConfigResponse } from "../../models/response/server-config.response";
|
||||
|
||||
export class ConfigApiService implements ConfigApiServiceAbstraction {
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private tokenService: TokenService,
|
||||
) {}
|
||||
constructor(private apiService: ApiService) {}
|
||||
|
||||
async get(userId: UserId | null): Promise<ServerConfigResponse> {
|
||||
// Authentication adds extra context to config responses, if the user has an access token, we want to use it
|
||||
// We don't particularly care about ensuring the token is valid and not expired, just that it exists
|
||||
const authed: boolean =
|
||||
userId == null ? false : (await this.tokenService.getAccessToken(userId)) != null;
|
||||
let r: any;
|
||||
if (userId == null) {
|
||||
r = await this.apiService.send("GET", "/config", null, false, true);
|
||||
} else {
|
||||
r = await this.apiService.send("GET", "/config", null, userId, true);
|
||||
}
|
||||
|
||||
const r = await this.apiService.send("GET", "/config", null, authed, true);
|
||||
return new ServerConfigResponse(r);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
import { ObservedValueOf, of } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { LogoutReason } from "@bitwarden/auth/common";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { AccountService } from "../auth/abstractions/account.service";
|
||||
import { TokenService } from "../auth/abstractions/token.service";
|
||||
import { DeviceType } from "../enums";
|
||||
import { VaultTimeoutSettingsService } from "../key-management/vault-timeout";
|
||||
import {
|
||||
VaultTimeoutAction,
|
||||
VaultTimeoutSettingsService,
|
||||
VaultTimeoutStringType,
|
||||
} from "../key-management/vault-timeout";
|
||||
import { ErrorResponse } from "../models/response/error.response";
|
||||
import { AppIdService } from "../platform/abstractions/app-id.service";
|
||||
import { Environment, EnvironmentService } from "../platform/abstractions/environment.service";
|
||||
@@ -25,10 +31,14 @@ describe("ApiService", () => {
|
||||
let logService: MockProxy<LogService>;
|
||||
let logoutCallback: jest.Mock<Promise<void>, [reason: LogoutReason]>;
|
||||
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
|
||||
let accountService: MockProxy<AccountService>;
|
||||
let httpOperations: MockProxy<HttpOperations>;
|
||||
|
||||
let sut: ApiService;
|
||||
|
||||
const testActiveUser = "activeUser" as UserId;
|
||||
const testInactiveUser = "inactiveUser" as UserId;
|
||||
|
||||
beforeEach(() => {
|
||||
tokenService = mock();
|
||||
platformUtilsService = mock();
|
||||
@@ -40,6 +50,15 @@ describe("ApiService", () => {
|
||||
logService = mock();
|
||||
logoutCallback = jest.fn();
|
||||
vaultTimeoutSettingsService = mock();
|
||||
accountService = mock();
|
||||
|
||||
accountService.activeAccount$ = of({
|
||||
id: testActiveUser,
|
||||
email: "user1@example.com",
|
||||
emailVerified: true,
|
||||
name: "Test Name",
|
||||
} satisfies ObservedValueOf<AccountService["activeAccount$"]>);
|
||||
|
||||
httpOperations = mock();
|
||||
|
||||
sut = new ApiService(
|
||||
@@ -51,6 +70,7 @@ describe("ApiService", () => {
|
||||
logService,
|
||||
logoutCallback,
|
||||
vaultTimeoutSettingsService,
|
||||
accountService,
|
||||
httpOperations,
|
||||
"custom-user-agent",
|
||||
);
|
||||
@@ -62,6 +82,12 @@ describe("ApiService", () => {
|
||||
getApiUrl: () => "https://example.com",
|
||||
} satisfies Partial<Environment> as Environment);
|
||||
|
||||
environmentService.getEnvironment$.mockReturnValue(
|
||||
of({
|
||||
getApiUrl: () => "https://authed.example.com",
|
||||
} satisfies Partial<Environment> as Environment),
|
||||
);
|
||||
|
||||
httpOperations.createRequest.mockImplementation((url, request) => {
|
||||
return {
|
||||
url: url,
|
||||
@@ -96,6 +122,7 @@ describe("ApiService", () => {
|
||||
|
||||
expect(nativeFetch).toHaveBeenCalledTimes(1);
|
||||
const request = nativeFetch.mock.calls[0][0];
|
||||
expect(request.url).toBe("https://authed.example.com/something");
|
||||
// This should get set for users of send
|
||||
expect(request.cache).toBe("no-store");
|
||||
// TODO: Could expect on the credentials parameter
|
||||
@@ -109,6 +136,185 @@ describe("ApiService", () => {
|
||||
// The response body
|
||||
expect(response).toEqual({ hello: "world" });
|
||||
});
|
||||
|
||||
it("authenticates with non-active user when user is passed in", async () => {
|
||||
environmentService.environment$ = of({
|
||||
getApiUrl: () => "https://example.com",
|
||||
} satisfies Partial<Environment> as Environment);
|
||||
|
||||
environmentService.getEnvironment$.calledWith(testInactiveUser).mockReturnValueOnce(
|
||||
of({
|
||||
getApiUrl: () => "https://inactive.example.com",
|
||||
} satisfies Partial<Environment> as Environment),
|
||||
);
|
||||
|
||||
httpOperations.createRequest.mockImplementation((url, request) => {
|
||||
return {
|
||||
url: url,
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
method: request.method,
|
||||
mode: request.mode,
|
||||
signal: request.signal,
|
||||
headers: new Headers(request.headers),
|
||||
} satisfies Partial<Request> as unknown as Request;
|
||||
});
|
||||
|
||||
tokenService.getAccessToken
|
||||
.calledWith(testInactiveUser)
|
||||
.mockResolvedValue("inactive_access_token");
|
||||
|
||||
tokenService.tokenNeedsRefresh.calledWith(testInactiveUser).mockResolvedValue(false);
|
||||
|
||||
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
|
||||
|
||||
nativeFetch.mockImplementation((request) => {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ hello: "world" }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
});
|
||||
|
||||
sut.nativeFetch = nativeFetch;
|
||||
|
||||
const response = await sut.send(
|
||||
"GET",
|
||||
"/something",
|
||||
null,
|
||||
testInactiveUser,
|
||||
true,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
|
||||
expect(nativeFetch).toHaveBeenCalledTimes(1);
|
||||
const request = nativeFetch.mock.calls[0][0];
|
||||
expect(request.url).toBe("https://inactive.example.com/something");
|
||||
// This should get set for users of send
|
||||
expect(request.cache).toBe("no-store");
|
||||
// TODO: Could expect on the credentials parameter
|
||||
expect(request.headers.get("Device-Type")).toBe("2"); // Chrome Extension
|
||||
// Custom user agent should get set
|
||||
expect(request.headers.get("User-Agent")).toBe("custom-user-agent");
|
||||
// This should be set when the caller has indicated there is a response
|
||||
expect(request.headers.get("Accept")).toBe("application/json");
|
||||
// If they have indicated that it's authed, then the authorization header should get set.
|
||||
expect(request.headers.get("Authorization")).toBe("Bearer inactive_access_token");
|
||||
// The response body
|
||||
expect(response).toEqual({ hello: "world" });
|
||||
});
|
||||
|
||||
const cases: {
|
||||
name: string;
|
||||
authedOrUserId: boolean | UserId;
|
||||
expectedEffectiveUser: UserId;
|
||||
}[] = [
|
||||
{
|
||||
name: "refreshes active user when true passed in for auth",
|
||||
authedOrUserId: true,
|
||||
expectedEffectiveUser: testActiveUser,
|
||||
},
|
||||
{
|
||||
name: "refreshes acess token when the user passed in happens to be the active one",
|
||||
authedOrUserId: testActiveUser,
|
||||
expectedEffectiveUser: testActiveUser,
|
||||
},
|
||||
{
|
||||
name: "refreshes access token when the user passed in happens to be inactive",
|
||||
authedOrUserId: testInactiveUser,
|
||||
expectedEffectiveUser: testInactiveUser,
|
||||
},
|
||||
];
|
||||
|
||||
it.each(cases)("$name does", async ({ authedOrUserId, expectedEffectiveUser }) => {
|
||||
environmentService.getEnvironment$.calledWith(expectedEffectiveUser).mockReturnValue(
|
||||
of({
|
||||
getApiUrl: () => `https://${expectedEffectiveUser}.example.com`,
|
||||
getIdentityUrl: () => `https://${expectedEffectiveUser}.identity.example.com`,
|
||||
} satisfies Partial<Environment> as Environment),
|
||||
);
|
||||
|
||||
tokenService.getAccessToken
|
||||
.calledWith(expectedEffectiveUser)
|
||||
.mockResolvedValue(`${expectedEffectiveUser}_access_token`);
|
||||
|
||||
tokenService.tokenNeedsRefresh.calledWith(expectedEffectiveUser).mockResolvedValue(true);
|
||||
|
||||
tokenService.getRefreshToken
|
||||
.calledWith(expectedEffectiveUser)
|
||||
.mockResolvedValue(`${expectedEffectiveUser}_refresh_token`);
|
||||
|
||||
tokenService.decodeAccessToken
|
||||
.calledWith(expectedEffectiveUser)
|
||||
.mockResolvedValue({ client_id: "web" });
|
||||
|
||||
tokenService.decodeAccessToken
|
||||
.calledWith(`${expectedEffectiveUser}_new_access_token`)
|
||||
.mockResolvedValue({ sub: expectedEffectiveUser });
|
||||
|
||||
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$
|
||||
.calledWith(expectedEffectiveUser)
|
||||
.mockReturnValue(of(VaultTimeoutAction.Lock));
|
||||
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$
|
||||
.calledWith(expectedEffectiveUser)
|
||||
.mockReturnValue(of(VaultTimeoutStringType.Never));
|
||||
|
||||
tokenService.setTokens
|
||||
.calledWith(
|
||||
`${expectedEffectiveUser}_new_access_token`,
|
||||
VaultTimeoutAction.Lock,
|
||||
VaultTimeoutStringType.Never,
|
||||
`${expectedEffectiveUser}_new_refresh_token`,
|
||||
)
|
||||
.mockResolvedValue({ accessToken: `${expectedEffectiveUser}_refreshed_access_token` });
|
||||
|
||||
httpOperations.createRequest.mockImplementation((url, request) => {
|
||||
return {
|
||||
url: url,
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
method: request.method,
|
||||
mode: request.mode,
|
||||
signal: request.signal,
|
||||
headers: new Headers(request.headers),
|
||||
} satisfies Partial<Request> as unknown as Request;
|
||||
});
|
||||
|
||||
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
|
||||
|
||||
nativeFetch.mockImplementation((request) => {
|
||||
if (request.url.includes("identity")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
access_token: `${expectedEffectiveUser}_new_access_token`,
|
||||
refresh_token: `${expectedEffectiveUser}_new_refresh_token`,
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ hello: "world" }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
});
|
||||
|
||||
sut.nativeFetch = nativeFetch;
|
||||
|
||||
await sut.send("GET", "/something", null, authedOrUserId, true, null, null);
|
||||
|
||||
expect(nativeFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
const errorData: {
|
||||
@@ -169,9 +375,11 @@ describe("ApiService", () => {
|
||||
it.each(errorData)(
|
||||
"throws error-like response when not ok response with $name",
|
||||
async ({ input, error }) => {
|
||||
environmentService.environment$ = of({
|
||||
getApiUrl: () => "https://example.com",
|
||||
} satisfies Partial<Environment> as Environment);
|
||||
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
|
||||
of({
|
||||
getApiUrl: () => "https://example.com",
|
||||
} satisfies Partial<Environment> as Environment),
|
||||
);
|
||||
|
||||
httpOperations.createRequest.mockImplementation((url, request) => {
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { firstValueFrom, map } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
ProviderUserUserDetailsResponse,
|
||||
} from "../admin-console/models/response/provider/provider-user.response";
|
||||
import { SelectionReadOnlyResponse } from "../admin-console/models/response/selection-read-only.response";
|
||||
import { AccountService } from "../auth/abstractions/account.service";
|
||||
import { TokenService } from "../auth/abstractions/token.service";
|
||||
import { DeviceVerificationRequest } from "../auth/models/request/device-verification.request";
|
||||
import { DisableTwoFactorAuthenticatorRequest } from "../auth/models/request/disable-two-factor-authenticator.request";
|
||||
@@ -121,7 +122,7 @@ import { ListResponse } from "../models/response/list.response";
|
||||
import { ProfileResponse } from "../models/response/profile.response";
|
||||
import { UserKeyResponse } from "../models/response/user-key.response";
|
||||
import { AppIdService } from "../platform/abstractions/app-id.service";
|
||||
import { EnvironmentService } from "../platform/abstractions/environment.service";
|
||||
import { Environment, EnvironmentService } from "../platform/abstractions/environment.service";
|
||||
import { LogService } from "../platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service";
|
||||
import { flagEnabled } from "../platform/misc/flags";
|
||||
@@ -155,7 +156,7 @@ export type HttpOperations = {
|
||||
export class ApiService implements ApiServiceAbstraction {
|
||||
private device: DeviceType;
|
||||
private deviceType: string;
|
||||
private refreshTokenPromise: Promise<string> | undefined;
|
||||
private refreshTokenPromise: Record<UserId, Promise<string>> = {};
|
||||
|
||||
/**
|
||||
* The message (responseJson.ErrorModel.Message) that comes back from the server when a new device verification is required.
|
||||
@@ -172,6 +173,7 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
private logService: LogService,
|
||||
private logoutCallback: (logoutReason: LogoutReason) => Promise<void>,
|
||||
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
||||
private readonly accountService: AccountService,
|
||||
private readonly httpOperations: HttpOperations,
|
||||
private customUserAgent: string = null,
|
||||
) {
|
||||
@@ -209,7 +211,7 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
const response = await this.fetch(
|
||||
this.httpOperations.createRequest(env.getIdentityUrl() + "/connect/token", {
|
||||
body: this.qsStringify(identityToken),
|
||||
credentials: await this.getCredentials(),
|
||||
credentials: await this.getCredentials(env),
|
||||
cache: "no-store",
|
||||
headers: headers,
|
||||
method: "POST",
|
||||
@@ -241,9 +243,13 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
return Promise.reject(new ErrorResponse(responseJson, response.status, true));
|
||||
}
|
||||
|
||||
async refreshIdentityToken(): Promise<any> {
|
||||
async refreshIdentityToken(userId: UserId | null = null): Promise<any> {
|
||||
const normalizedUser = (userId ??= await this.getActiveUser());
|
||||
if (normalizedUser == null) {
|
||||
throw new Error("No user provided and no active user, cannot refresh the identity token.");
|
||||
}
|
||||
try {
|
||||
await this.refreshToken();
|
||||
await this.refreshToken(normalizedUser);
|
||||
} catch (e) {
|
||||
this.logService.error("Error refreshing access token: ", e);
|
||||
throw e;
|
||||
@@ -1398,11 +1404,16 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
if (this.customUserAgent != null) {
|
||||
headers.set("User-Agent", this.customUserAgent);
|
||||
}
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
|
||||
const env = await firstValueFrom(
|
||||
userId == null
|
||||
? this.environmentService.environment$
|
||||
: this.environmentService.getEnvironment$(userId),
|
||||
);
|
||||
const response = await this.fetch(
|
||||
this.httpOperations.createRequest(env.getEventsUrl() + "/collect", {
|
||||
cache: "no-store",
|
||||
credentials: await this.getCredentials(),
|
||||
credentials: await this.getCredentials(env),
|
||||
method: "POST",
|
||||
body: JSON.stringify(request),
|
||||
headers: headers,
|
||||
@@ -1444,7 +1455,11 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
async getMasterKeyFromKeyConnector(
|
||||
keyConnectorUrl: string,
|
||||
): Promise<KeyConnectorUserKeyResponse> {
|
||||
const authHeader = await this.getActiveBearerToken();
|
||||
const activeUser = await this.getActiveUser();
|
||||
if (activeUser == null) {
|
||||
throw new Error("No active user, cannot get master key from key connector.");
|
||||
}
|
||||
const authHeader = await this.getActiveBearerToken(activeUser);
|
||||
|
||||
const response = await this.fetch(
|
||||
this.httpOperations.createRequest(keyConnectorUrl + "/user-keys", {
|
||||
@@ -1469,7 +1484,11 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
keyConnectorUrl: string,
|
||||
request: KeyConnectorUserKeyRequest,
|
||||
): Promise<void> {
|
||||
const authHeader = await this.getActiveBearerToken();
|
||||
const activeUser = await this.getActiveUser();
|
||||
if (activeUser == null) {
|
||||
throw new Error("No active user, cannot post key to key connector.");
|
||||
}
|
||||
const authHeader = await this.getActiveBearerToken(activeUser);
|
||||
|
||||
const response = await this.fetch(
|
||||
this.httpOperations.createRequest(keyConnectorUrl + "/user-keys", {
|
||||
@@ -1521,10 +1540,10 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
|
||||
// Helpers
|
||||
|
||||
async getActiveBearerToken(): Promise<string> {
|
||||
let accessToken = await this.tokenService.getAccessToken();
|
||||
if (await this.tokenService.tokenNeedsRefresh()) {
|
||||
accessToken = await this.refreshToken();
|
||||
async getActiveBearerToken(userId: UserId): Promise<string> {
|
||||
let accessToken = await this.tokenService.getAccessToken(userId);
|
||||
if (await this.tokenService.tokenNeedsRefresh(userId)) {
|
||||
accessToken = await this.refreshToken(userId);
|
||||
}
|
||||
return accessToken;
|
||||
}
|
||||
@@ -1563,7 +1582,7 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
const response = await this.fetch(
|
||||
this.httpOperations.createRequest(env.getIdentityUrl() + path, {
|
||||
cache: "no-store",
|
||||
credentials: await this.getCredentials(),
|
||||
credentials: await this.getCredentials(env),
|
||||
headers: headers,
|
||||
method: "GET",
|
||||
}),
|
||||
@@ -1646,26 +1665,27 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
}
|
||||
|
||||
// Keep the running refreshTokenPromise to prevent parallel calls.
|
||||
protected refreshToken(): Promise<string> {
|
||||
if (this.refreshTokenPromise === undefined) {
|
||||
this.refreshTokenPromise = this.internalRefreshToken();
|
||||
void this.refreshTokenPromise.finally(() => {
|
||||
this.refreshTokenPromise = undefined;
|
||||
protected refreshToken(userId: UserId): Promise<string> {
|
||||
if (this.refreshTokenPromise[userId] === undefined) {
|
||||
// TODO: Have different promise for each user
|
||||
this.refreshTokenPromise[userId] = this.internalRefreshToken(userId);
|
||||
void this.refreshTokenPromise[userId].finally(() => {
|
||||
delete this.refreshTokenPromise[userId];
|
||||
});
|
||||
}
|
||||
return this.refreshTokenPromise;
|
||||
return this.refreshTokenPromise[userId];
|
||||
}
|
||||
|
||||
private async internalRefreshToken(): Promise<string> {
|
||||
const refreshToken = await this.tokenService.getRefreshToken();
|
||||
private async internalRefreshToken(userId: UserId): Promise<string> {
|
||||
const refreshToken = await this.tokenService.getRefreshToken(userId);
|
||||
if (refreshToken != null && refreshToken !== "") {
|
||||
return this.refreshAccessToken();
|
||||
return await this.refreshAccessToken(userId);
|
||||
}
|
||||
|
||||
const clientId = await this.tokenService.getClientId();
|
||||
const clientSecret = await this.tokenService.getClientSecret();
|
||||
const clientId = await this.tokenService.getClientId(userId);
|
||||
const clientSecret = await this.tokenService.getClientSecret(userId);
|
||||
if (!Utils.isNullOrWhitespace(clientId) && !Utils.isNullOrWhitespace(clientSecret)) {
|
||||
return this.refreshApiToken();
|
||||
return await this.refreshApiToken(userId);
|
||||
}
|
||||
|
||||
this.refreshAccessTokenErrorCallback();
|
||||
@@ -1673,8 +1693,8 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
throw new Error("Cannot refresh access token, no refresh token or api keys are stored.");
|
||||
}
|
||||
|
||||
protected async refreshAccessToken(): Promise<string> {
|
||||
const refreshToken = await this.tokenService.getRefreshToken();
|
||||
private async refreshAccessToken(userId: UserId): Promise<string> {
|
||||
const refreshToken = await this.tokenService.getRefreshToken(userId);
|
||||
if (refreshToken == null || refreshToken === "") {
|
||||
throw new Error();
|
||||
}
|
||||
@@ -1687,8 +1707,8 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
headers.set("User-Agent", this.customUserAgent);
|
||||
}
|
||||
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
const decodedToken = await this.tokenService.decodeAccessToken();
|
||||
const env = await firstValueFrom(this.environmentService.getEnvironment$(userId));
|
||||
const decodedToken = await this.tokenService.decodeAccessToken(userId);
|
||||
const response = await this.fetch(
|
||||
this.httpOperations.createRequest(env.getIdentityUrl() + "/connect/token", {
|
||||
body: this.qsStringify({
|
||||
@@ -1697,7 +1717,7 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
cache: "no-store",
|
||||
credentials: await this.getCredentials(),
|
||||
credentials: await this.getCredentials(env),
|
||||
headers: headers,
|
||||
method: "POST",
|
||||
}),
|
||||
@@ -1732,9 +1752,9 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
}
|
||||
}
|
||||
|
||||
protected async refreshApiToken(): Promise<string> {
|
||||
const clientId = await this.tokenService.getClientId();
|
||||
const clientSecret = await this.tokenService.getClientSecret();
|
||||
protected async refreshApiToken(userId: UserId): Promise<string> {
|
||||
const clientId = await this.tokenService.getClientId(userId);
|
||||
const clientSecret = await this.tokenService.getClientSecret(userId);
|
||||
|
||||
const appId = await this.appIdService.getAppId();
|
||||
const deviceRequest = new DeviceRequest(appId, this.platformUtilsService);
|
||||
@@ -1751,7 +1771,12 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
}
|
||||
|
||||
const newDecodedAccessToken = await this.tokenService.decodeAccessToken(response.accessToken);
|
||||
const userId = newDecodedAccessToken.sub;
|
||||
|
||||
if (newDecodedAccessToken.sub !== userId) {
|
||||
throw new Error(
|
||||
`Token was supposed to be refreshed for ${userId} but the token we got back was for ${newDecodedAccessToken.sub}`,
|
||||
);
|
||||
}
|
||||
|
||||
const vaultTimeoutAction = await firstValueFrom(
|
||||
this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(userId),
|
||||
@@ -1772,12 +1797,28 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
|
||||
path: string,
|
||||
body: any,
|
||||
authed: boolean,
|
||||
authedOrUserId: UserId | boolean,
|
||||
hasResponse: boolean,
|
||||
apiUrl?: string | null,
|
||||
alterHeaders?: (headers: Headers) => void,
|
||||
): Promise<any> {
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
if (authedOrUserId == null) {
|
||||
throw new Error("A user id was given but it was null, cannot complete API request.");
|
||||
}
|
||||
|
||||
let userId: UserId | null = null;
|
||||
if (typeof authedOrUserId === "boolean" && authedOrUserId) {
|
||||
// Backwards compatible for authenticating the active user when `true` is passed in
|
||||
userId = await this.getActiveUser();
|
||||
} else if (typeof authedOrUserId === "string") {
|
||||
userId = authedOrUserId;
|
||||
}
|
||||
|
||||
const env = await firstValueFrom(
|
||||
userId == null
|
||||
? this.environmentService.environment$
|
||||
: this.environmentService.getEnvironment$(userId),
|
||||
);
|
||||
apiUrl = Utils.isNullOrWhitespace(apiUrl) ? env.getApiUrl() : apiUrl;
|
||||
|
||||
// Prevent directory traversal from malicious paths
|
||||
@@ -1786,7 +1827,7 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
apiUrl + Utils.normalizePath(pathParts[0]) + (pathParts.length > 1 ? `?${pathParts[1]}` : "");
|
||||
|
||||
const [requestHeaders, requestBody] = await this.buildHeadersAndBody(
|
||||
authed,
|
||||
userId,
|
||||
hasResponse,
|
||||
body,
|
||||
alterHeaders,
|
||||
@@ -1794,7 +1835,7 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
|
||||
const requestInit: RequestInit = {
|
||||
cache: "no-store",
|
||||
credentials: await this.getCredentials(),
|
||||
credentials: await this.getCredentials(env),
|
||||
method: method,
|
||||
};
|
||||
requestInit.headers = requestHeaders;
|
||||
@@ -1810,13 +1851,13 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
} else if (hasResponse && response.status === 200 && responseIsCsv) {
|
||||
return await response.text();
|
||||
} else if (response.status !== 200 && response.status !== 204) {
|
||||
const error = await this.handleError(response, false, authed);
|
||||
const error = await this.handleError(response, false, userId != null);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async buildHeadersAndBody(
|
||||
authed: boolean,
|
||||
userToAuthenticate: UserId | null,
|
||||
hasResponse: boolean,
|
||||
body: any,
|
||||
alterHeaders: (headers: Headers) => void,
|
||||
@@ -1838,8 +1879,8 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
if (alterHeaders != null) {
|
||||
alterHeaders(headers);
|
||||
}
|
||||
if (authed) {
|
||||
const authHeader = await this.getActiveBearerToken();
|
||||
if (userToAuthenticate != null) {
|
||||
const authHeader = await this.getActiveBearerToken(userToAuthenticate);
|
||||
headers.set("Authorization", "Bearer " + authHeader);
|
||||
} else {
|
||||
// For unauthenticated requests, we need to tell the server what the device is for flag targeting,
|
||||
@@ -1901,8 +1942,11 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
.join("&");
|
||||
}
|
||||
|
||||
private async getCredentials(): Promise<RequestCredentials> {
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
private async getActiveUser(): Promise<UserId | null> {
|
||||
return await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id)));
|
||||
}
|
||||
|
||||
private async getCredentials(env: Environment): Promise<RequestCredentials> {
|
||||
if (this.platformUtilsService.getClientType() !== ClientType.Web || env.hasBaseUrl()) {
|
||||
return "include";
|
||||
}
|
||||
|
||||
@@ -65,12 +65,16 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
|
||||
userId: UserId,
|
||||
includeOtherTypes?: CipherType[],
|
||||
defaultMatch?: UriMatchStrategySetting,
|
||||
/** When true, will override the match strategy for the cipher if it is Never. */
|
||||
overrideNeverMatchStrategy?: true,
|
||||
): Promise<CipherView[]>;
|
||||
abstract filterCiphersForUrl<C extends CipherViewLike = CipherView>(
|
||||
ciphers: C[],
|
||||
url: string,
|
||||
includeOtherTypes?: CipherType[],
|
||||
defaultMatch?: UriMatchStrategySetting,
|
||||
/** When true, will override the match strategy for the cipher if it is Never. */
|
||||
overrideNeverMatchStrategy?: true,
|
||||
): Promise<C[]>;
|
||||
abstract getAllFromApiForOrganization(organizationId: string): Promise<CipherView[]>;
|
||||
/**
|
||||
|
||||
@@ -111,6 +111,33 @@ describe("LoginUriView", () => {
|
||||
|
||||
expect(actual).toBe(false);
|
||||
});
|
||||
|
||||
it("overrides Never match strategy with Domain when parameter is set", () => {
|
||||
const loginUri = new LoginUriView();
|
||||
loginUri.uri = "https://example.org";
|
||||
loginUri.match = UriMatchStrategy.Never;
|
||||
|
||||
expect(loginUri.matchesUri("https://example.org", new Set(), undefined, true)).toBe(true);
|
||||
expect(loginUri.matchesUri("https://example.org", new Set(), undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("overrides Never match strategy when passed in as default strategy", () => {
|
||||
const loginUriNoMatch = new LoginUriView();
|
||||
loginUriNoMatch.uri = "https://example.org";
|
||||
|
||||
expect(
|
||||
loginUriNoMatch.matchesUri(
|
||||
"https://example.org",
|
||||
new Set(),
|
||||
UriMatchStrategy.Never,
|
||||
true,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
loginUriNoMatch.matchesUri("https://example.org", new Set(), UriMatchStrategy.Never),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("using host matching", () => {
|
||||
|
||||
@@ -142,6 +142,8 @@ export class LoginUriView implements View {
|
||||
targetUri: string,
|
||||
equivalentDomains: Set<string>,
|
||||
defaultUriMatch: UriMatchStrategySetting = null,
|
||||
/** When present, will override the match strategy for the cipher if it is `Never` with `Domain` */
|
||||
overrideNeverMatchStrategy?: true,
|
||||
): boolean {
|
||||
if (!this.uri || !targetUri) {
|
||||
return false;
|
||||
@@ -150,6 +152,12 @@ export class LoginUriView implements View {
|
||||
let matchType = this.match ?? defaultUriMatch;
|
||||
matchType ??= UriMatchStrategy.Domain;
|
||||
|
||||
// Override the match strategy with `Domain` when it is `Never` and `overrideNeverMatchStrategy` is true.
|
||||
// This is useful in scenarios when the cipher should be matched to rely other information other than autofill.
|
||||
if (overrideNeverMatchStrategy && matchType === UriMatchStrategy.Never) {
|
||||
matchType = UriMatchStrategy.Domain;
|
||||
}
|
||||
|
||||
const targetDomain = Utils.getDomain(targetUri);
|
||||
const matchDomains = equivalentDomains.add(targetDomain);
|
||||
|
||||
|
||||
@@ -82,12 +82,16 @@ export class LoginView extends ItemView {
|
||||
targetUri: string,
|
||||
equivalentDomains: Set<string>,
|
||||
defaultUriMatch: UriMatchStrategySetting = null,
|
||||
/** When present, will override the match strategy for the cipher if it is `Never` with `Domain` */
|
||||
overrideNeverMatchStrategy?: true,
|
||||
): boolean {
|
||||
if (this.uris == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.uris.some((uri) => uri.matchesUri(targetUri, equivalentDomains, defaultUriMatch));
|
||||
return this.uris.some((uri) =>
|
||||
uri.matchesUri(targetUri, equivalentDomains, defaultUriMatch, overrideNeverMatchStrategy),
|
||||
);
|
||||
}
|
||||
|
||||
static fromJSON(obj: Partial<DeepJsonify<LoginView>>): LoginView {
|
||||
|
||||
@@ -601,6 +601,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
userId: UserId,
|
||||
includeOtherTypes?: CipherType[],
|
||||
defaultMatch: UriMatchStrategySetting = null,
|
||||
overrideNeverMatchStrategy?: true,
|
||||
): Promise<CipherView[]> {
|
||||
return await firstValueFrom(
|
||||
this.cipherViews$(userId).pipe(
|
||||
@@ -612,6 +613,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
url,
|
||||
includeOtherTypes,
|
||||
defaultMatch,
|
||||
overrideNeverMatchStrategy,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -623,6 +625,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
url: string,
|
||||
includeOtherTypes?: CipherType[],
|
||||
defaultMatch: UriMatchStrategySetting = null,
|
||||
overrideNeverMatchStrategy?: true,
|
||||
): Promise<C[]> {
|
||||
if (url == null && includeOtherTypes == null) {
|
||||
return [];
|
||||
@@ -647,7 +650,13 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
}
|
||||
|
||||
if (cipherIsLogin) {
|
||||
return CipherViewLikeUtils.matchesUri(cipher, url, equivalentDomains, defaultMatch);
|
||||
return CipherViewLikeUtils.matchesUri(
|
||||
cipher,
|
||||
url,
|
||||
equivalentDomains,
|
||||
defaultMatch,
|
||||
overrideNeverMatchStrategy,
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -174,13 +174,19 @@ export class CipherViewLikeUtils {
|
||||
targetUri: string,
|
||||
equivalentDomains: Set<string>,
|
||||
defaultUriMatch: UriMatchStrategySetting = UriMatchStrategy.Domain,
|
||||
overrideNeverMatchStrategy?: true,
|
||||
): boolean => {
|
||||
if (CipherViewLikeUtils.getType(cipher) !== CipherType.Login) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.isCipherListView(cipher)) {
|
||||
return cipher.login.matchesUri(targetUri, equivalentDomains, defaultUriMatch);
|
||||
return cipher.login.matchesUri(
|
||||
targetUri,
|
||||
equivalentDomains,
|
||||
defaultUriMatch,
|
||||
overrideNeverMatchStrategy,
|
||||
);
|
||||
}
|
||||
|
||||
const login = this.getLogin(cipher);
|
||||
@@ -198,7 +204,7 @@ export class CipherViewLikeUtils {
|
||||
});
|
||||
|
||||
return loginUriViews.some((uriView) =>
|
||||
uriView.matchesUri(targetUri, equivalentDomains, defaultUriMatch),
|
||||
uriView.matchesUri(targetUri, equivalentDomains, defaultUriMatch, overrideNeverMatchStrategy),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -6,18 +6,40 @@
|
||||
#container
|
||||
>
|
||||
<vault-carousel-content [content]="slides.get(selectedIndex)?.content"></vault-carousel-content>
|
||||
<div
|
||||
class="tw-w-full tw-flex tw-gap-2 tw-justify-center tw-mt-auto tw-pt-4"
|
||||
role="tablist"
|
||||
(keydown)="keyManager.onKeydown($event)"
|
||||
#carouselButtonWrapper
|
||||
>
|
||||
<vault-carousel-button
|
||||
*ngFor="let slide of slides; let i = index"
|
||||
[slide]="slide"
|
||||
[isActive]="i === selectedIndex"
|
||||
(onClick)="selectSlide(i)"
|
||||
></vault-carousel-button>
|
||||
<div class="tw-w-full tw-flex tw-justify-between tw-mt-auto tw-px-4 tw-pb-2 tw-pt-4">
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-angle-left"
|
||||
class="tw-size-6 tw-p-0 tw-flex tw-items-center tw-justify-center"
|
||||
size="small"
|
||||
[attr.label]="'back' | i18n"
|
||||
(click)="prevSlide()"
|
||||
[disabled]="selectedIndex <= 0"
|
||||
appA11yTitle="{{ 'back' | i18n }}"
|
||||
></button>
|
||||
<div
|
||||
class="tw-w-full tw-flex tw-gap-2 tw-justify-center tw-mt-auto"
|
||||
role="tablist"
|
||||
(keydown)="keyManager.onKeydown($event)"
|
||||
#carouselButtonWrapper
|
||||
>
|
||||
<vault-carousel-button
|
||||
*ngFor="let slide of slides; let i = index"
|
||||
[slide]="slide"
|
||||
[isActive]="i === selectedIndex"
|
||||
(onClick)="selectSlide(i)"
|
||||
></vault-carousel-button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-angle-right"
|
||||
class="tw-size-6 tw-p-0 tw-flex tw-items-center tw-justify-center"
|
||||
[attr.label]="'next' | i18n"
|
||||
size="small"
|
||||
(click)="nextSlide()"
|
||||
[disabled]="selectedIndex >= slides.length - 1"
|
||||
appA11yTitle="{{ 'next' | i18n }}"
|
||||
></button>
|
||||
</div>
|
||||
<div class="tw-absolute tw-invisible" #tempSlideContainer *ngIf="minHeight === null">
|
||||
<ng-template cdkPortalOutlet></ng-template>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Component } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.component";
|
||||
import { VaultCarouselComponent } from "./carousel.component";
|
||||
|
||||
@@ -33,6 +35,7 @@ describe("VaultCarouselComponent", () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VaultCarouselComponent, VaultCarouselSlideComponent],
|
||||
providers: [{ provide: I18nService, useValue: { t: (key: string) => key } }],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
@@ -48,7 +51,7 @@ describe("VaultCarouselComponent", () => {
|
||||
|
||||
it("shows the active slides content", () => {
|
||||
// Set the second slide as active
|
||||
fixture.debugElement.queryAll(By.css("button"))[1].nativeElement.click();
|
||||
fixture.debugElement.queryAll(By.css("button"))[2].nativeElement.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const heading = fixture.debugElement.query(By.css("h1")).nativeElement;
|
||||
@@ -63,10 +66,37 @@ describe("VaultCarouselComponent", () => {
|
||||
it('emits "slideChange" event when slide changes', () => {
|
||||
jest.spyOn(component.slideChange, "emit");
|
||||
|
||||
const thirdSlideButton = fixture.debugElement.queryAll(By.css("button"))[2];
|
||||
const thirdSlideButton = fixture.debugElement.queryAll(By.css("button"))[3];
|
||||
|
||||
thirdSlideButton.nativeElement.click();
|
||||
|
||||
expect(component.slideChange.emit).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('advances to the next slide when the "next" button is pressed', () => {
|
||||
const middleSlideButton = fixture.debugElement.queryAll(By.css("button"))[2];
|
||||
const nextButton = fixture.debugElement.queryAll(By.css("button"))[4];
|
||||
|
||||
middleSlideButton.nativeElement.click();
|
||||
|
||||
jest.spyOn(component.slideChange, "emit");
|
||||
|
||||
nextButton.nativeElement.click();
|
||||
|
||||
expect(component.slideChange.emit).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('advances to the previous slide when the "back" button is pressed', async () => {
|
||||
const middleSlideButton = fixture.debugElement.queryAll(By.css("button"))[2];
|
||||
const backButton = fixture.debugElement.queryAll(By.css("button"))[0];
|
||||
|
||||
middleSlideButton.nativeElement.click();
|
||||
await new Promise((r) => setTimeout(r, 100)); // Give time for the DOM to update.
|
||||
|
||||
jest.spyOn(component.slideChange, "emit");
|
||||
|
||||
backButton.nativeElement.click();
|
||||
|
||||
expect(component.slideChange.emit).toHaveBeenCalledWith(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,9 @@ import {
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { take } from "rxjs";
|
||||
|
||||
import { ButtonModule } from "@bitwarden/components";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { ButtonModule, IconButtonModule } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { VaultCarouselButtonComponent } from "./carousel-button/carousel-button.component";
|
||||
import { VaultCarouselContentComponent } from "./carousel-content/carousel-content.component";
|
||||
@@ -32,9 +34,12 @@ import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.com
|
||||
imports: [
|
||||
CdkPortalOutlet,
|
||||
CommonModule,
|
||||
JslibModule,
|
||||
IconButtonModule,
|
||||
ButtonModule,
|
||||
VaultCarouselContentComponent,
|
||||
VaultCarouselButtonComponent,
|
||||
I18nPipe,
|
||||
],
|
||||
})
|
||||
export class VaultCarouselComponent implements AfterViewInit {
|
||||
@@ -97,6 +102,18 @@ export class VaultCarouselComponent implements AfterViewInit {
|
||||
this.slideChange.emit(index);
|
||||
}
|
||||
|
||||
protected nextSlide() {
|
||||
if (this.selectedIndex < this.slides.length - 1) {
|
||||
this.selectSlide(this.selectedIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
protected prevSlide() {
|
||||
if (this.selectedIndex > 0) {
|
||||
this.selectSlide(this.selectedIndex - 1);
|
||||
}
|
||||
}
|
||||
|
||||
async ngAfterViewInit() {
|
||||
this.keyManager = new FocusKeyManager(this.carouselButtons)
|
||||
.withHorizontalOrientation("ltr")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { ButtonComponent, TypographyModule } from "@bitwarden/components";
|
||||
|
||||
import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.component";
|
||||
@@ -11,6 +12,7 @@ export default {
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [VaultCarouselSlideComponent, TypographyModule, ButtonComponent],
|
||||
providers: [{ provide: I18nService, useValue: { t: (key: string) => key } }],
|
||||
}),
|
||||
],
|
||||
} as Meta;
|
||||
|
||||
Reference in New Issue
Block a user