1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-29 12:55:21 +01:00

Merge branch 'main' of github.com:bitwarden/clients

This commit is contained in:
gbubemismith 2024-06-11 23:49:01 -04:00
commit dbf74f05a3
No known key found for this signature in database
173 changed files with 10398 additions and 641 deletions

View File

@ -25,6 +25,7 @@ jobs:
runs-on: ubuntu-22.04
needs: check-run
permissions:
checks: write
contents: read
pull-requests: write

View File

@ -1434,6 +1434,24 @@
"typeIdentity": {
"message": "Identity"
},
"newItemHeader":{
"message": "New $TYPE$",
"placeholders": {
"type": {
"content": "$1",
"example": "Login"
}
}
},
"editItemHeader":{
"message": "Edit $TYPE$",
"placeholders": {
"type": {
"content": "$1",
"example": "Login"
}
}
},
"passwordHistory": {
"message": "Password history"
},

View File

@ -1,71 +0,0 @@
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import AutofillPageDetails from "../models/autofill-page-details";
import { AutofillService } from "../services/abstractions/autofill.service";
export class AutofillTabCommand {
constructor(private autofillService: AutofillService) {}
async doAutofillTabCommand(tab: chrome.tabs.Tab) {
if (!tab.id) {
throw new Error("Tab does not have an id, cannot complete autofill.");
}
const details = await this.collectPageDetails(tab.id);
await this.autofillService.doAutoFillOnTab(
[
{
frameId: 0,
tab: tab,
details: details,
},
],
tab,
true,
);
}
async doAutofillTabWithCipherCommand(tab: chrome.tabs.Tab, cipher: CipherView) {
if (!tab.id) {
throw new Error("Tab does not have an id, cannot complete autofill.");
}
const details = await this.collectPageDetails(tab.id);
await this.autofillService.doAutoFill({
tab: tab,
cipher: cipher,
pageDetails: [
{
frameId: 0,
tab: tab,
details: details,
},
],
skipLastUsed: false,
skipUsernameOnlyFill: false,
onlyEmptyFields: false,
onlyVisibleFields: false,
fillNewPassword: true,
allowTotpAutofill: true,
});
}
private async collectPageDetails(tabId: number): Promise<AutofillPageDetails> {
return new Promise((resolve, reject) => {
chrome.tabs.sendMessage(
tabId,
{
command: "collectPageDetailsImmediately",
},
(response: AutofillPageDetails) => {
if (chrome.runtime.lastError) {
reject(chrome.runtime.lastError);
return;
}
resolve(response);
},
);
});
}
}

View File

@ -0,0 +1,14 @@
export const AutofillMessageCommand = {
collectPageDetails: "collectPageDetails",
collectPageDetailsResponse: "collectPageDetailsResponse",
} as const;
export type AutofillMessageCommandType =
(typeof AutofillMessageCommand)[keyof typeof AutofillMessageCommand];
export const AutofillMessageSender = {
collectPageDetailsFromTabObservable: "collectPageDetailsFromTabObservable",
} as const;
export type AutofillMessageSenderType =
(typeof AutofillMessageSender)[keyof typeof AutofillMessageSender];

View File

@ -1,7 +1,11 @@
import { Observable } from "rxjs";
import { UriMatchStrategySetting } from "@bitwarden/common/models/domain/domain-service";
import { CommandDefinition } from "@bitwarden/common/platform/messaging";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { AutofillMessageCommand } from "../../enums/autofill-message.enums";
import AutofillField from "../../models/autofill-field";
import AutofillForm from "../../models/autofill-form";
import AutofillPageDetails from "../../models/autofill-page-details";
@ -44,7 +48,20 @@ export interface GenerateFillScriptOptions {
defaultUriMatch: UriMatchStrategySetting;
}
export type CollectPageDetailsResponseMessage = {
tab: chrome.tabs.Tab;
details: AutofillPageDetails;
sender?: string;
webExtSender: chrome.runtime.MessageSender;
};
export const COLLECT_PAGE_DETAILS_RESPONSE_COMMAND =
new CommandDefinition<CollectPageDetailsResponseMessage>(
AutofillMessageCommand.collectPageDetailsResponse,
);
export abstract class AutofillService {
collectPageDetailsFromTab$: (tab: chrome.tabs.Tab) => Observable<PageDetail[]>;
loadAutofillScriptsOnInstall: () => Promise<void>;
reloadAutofillScripts: () => Promise<void>;
injectAutofillScripts: (

View File

@ -1,5 +1,5 @@
import { mock, mockReset, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { BehaviorSubject, of, Subject } from "rxjs";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
@ -16,12 +16,14 @@ import { EventType } from "@bitwarden/common/enums";
import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { MessageListener } from "@bitwarden/common/platform/messaging";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service";
import {
FakeStateProvider,
FakeAccountService,
mockAccountServiceWith,
subscribeTo,
} from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { FieldType, LinkedIdType, LoginLinkedId, CipherType } from "@bitwarden/common/vault/enums";
@ -37,6 +39,7 @@ import { TotpService } from "@bitwarden/common/vault/services/totp.service";
import { BrowserApi } from "../../platform/browser/browser-api";
import { BrowserScriptInjectorService } from "../../platform/services/browser-script-injector.service";
import { AutofillMessageCommand, AutofillMessageSender } from "../enums/autofill-message.enums";
import { AutofillPort } from "../enums/autofill-port.enums";
import AutofillField from "../models/autofill-field";
import AutofillPageDetails from "../models/autofill-page-details";
@ -52,6 +55,7 @@ import { flushPromises, triggerTestFailure } from "../spec/testing-utils";
import {
AutoFillOptions,
CollectPageDetailsResponseMessage,
GenerateFillScriptOptions,
PageDetail,
} from "./abstractions/autofill.service";
@ -82,6 +86,7 @@ describe("AutofillService", () => {
const platformUtilsService = mock<PlatformUtilsService>();
let activeAccountStatusMock$: BehaviorSubject<AuthenticationStatus>;
let authService: MockProxy<AuthService>;
let messageListener: MockProxy<MessageListener>;
beforeEach(() => {
scriptInjectorService = new BrowserScriptInjectorService(platformUtilsService, logService);
@ -91,6 +96,7 @@ describe("AutofillService", () => {
activeAccountStatusMock$ = new BehaviorSubject(AuthenticationStatus.Unlocked);
authService = mock<AuthService>();
authService.activeAccountStatus$ = activeAccountStatusMock$;
messageListener = mock<MessageListener>();
autofillService = new AutofillService(
cipherService,
autofillSettingsService,
@ -103,10 +109,11 @@ describe("AutofillService", () => {
scriptInjectorService,
accountService,
authService,
messageListener,
);
domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider);
domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains);
jest.spyOn(BrowserApi, "tabSendMessage");
});
afterEach(() => {
@ -114,6 +121,84 @@ describe("AutofillService", () => {
mockReset(cipherService);
});
describe("collectPageDetailsFromTab$", () => {
const tab = mock<chrome.tabs.Tab>({ id: 1 });
const messages = new Subject<CollectPageDetailsResponseMessage>();
function mockCollectPageDetailsResponseMessage(
tab: chrome.tabs.Tab,
webExtSender: chrome.runtime.MessageSender = mock<chrome.runtime.MessageSender>(),
sender: string = AutofillMessageSender.collectPageDetailsFromTabObservable,
): CollectPageDetailsResponseMessage {
return mock<CollectPageDetailsResponseMessage>({
tab,
webExtSender,
sender,
});
}
beforeEach(() => {
messageListener.messages$.mockReturnValue(messages.asObservable());
});
it("sends a `collectPageDetails` message to the passed tab", () => {
autofillService.collectPageDetailsFromTab$(tab);
expect(BrowserApi.tabSendMessage).toHaveBeenCalledWith(tab, {
command: AutofillMessageCommand.collectPageDetails,
sender: AutofillMessageSender.collectPageDetailsFromTabObservable,
tab,
});
});
it("builds an array of page details from received `collectPageDetailsResponse` messages", async () => {
const topLevelSender = mock<chrome.runtime.MessageSender>({ tab, frameId: 0 });
const subFrameSender = mock<chrome.runtime.MessageSender>({ tab, frameId: 1 });
const tracker = subscribeTo(autofillService.collectPageDetailsFromTab$(tab));
const pausePromise = tracker.pauseUntilReceived(2);
messages.next(mockCollectPageDetailsResponseMessage(tab, topLevelSender));
messages.next(mockCollectPageDetailsResponseMessage(tab, subFrameSender));
await pausePromise;
expect(tracker.emissions[1].length).toBe(2);
});
it("ignores messages from a different tab", async () => {
const otherTab = mock<chrome.tabs.Tab>({ id: 2 });
const tracker = subscribeTo(autofillService.collectPageDetailsFromTab$(tab));
const pausePromise = tracker.pauseUntilReceived(1);
messages.next(mockCollectPageDetailsResponseMessage(tab));
messages.next(mockCollectPageDetailsResponseMessage(otherTab));
await pausePromise;
expect(tracker.emissions[1]).toBeUndefined();
});
it("ignores messages from a different sender", async () => {
const tracker = subscribeTo(autofillService.collectPageDetailsFromTab$(tab));
const pausePromise = tracker.pauseUntilReceived(1);
messages.next(mockCollectPageDetailsResponseMessage(tab));
messages.next(
mockCollectPageDetailsResponseMessage(
tab,
mock<chrome.runtime.MessageSender>(),
"some-other-sender",
),
);
await pausePromise;
expect(tracker.emissions[1]).toBeUndefined();
});
});
describe("loadAutofillScriptsOnInstall", () => {
let tab1: chrome.tabs.Tab;
let tab2: chrome.tabs.Tab;
@ -124,6 +209,9 @@ describe("AutofillService", () => {
tab2 = createChromeTabMock({ id: 2, url: "http://some-url.com" });
tab3 = createChromeTabMock({ id: 3, url: "chrome-extension://some-extension-route" });
jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValueOnce([tab1, tab2]);
jest
.spyOn(BrowserApi, "getAllFrameDetails")
.mockResolvedValue([mock<chrome.webNavigation.GetAllFrameResultDetails>({ frameId: 0 })]);
jest
.spyOn(autofillService, "getOverlayVisibility")
.mockResolvedValue(AutofillOverlayVisibility.OnFieldFocus);
@ -134,6 +222,7 @@ describe("AutofillService", () => {
jest.spyOn(autofillService, "injectAutofillScripts");
await autofillService.loadAutofillScriptsOnInstall();
await flushPromises();
expect(BrowserApi.tabsQuery).toHaveBeenCalledWith({});
expect(autofillService.injectAutofillScripts).toHaveBeenCalledWith(tab1, 0, false);

View File

@ -1,4 +1,4 @@
import { firstValueFrom, startWith } from "rxjs";
import { filter, firstValueFrom, Observable, scan, startWith } from "rxjs";
import { pairwise } from "rxjs/operators";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
@ -17,6 +17,7 @@ import {
UriMatchStrategy,
} from "@bitwarden/common/models/domain/domain-service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessageListener } from "@bitwarden/common/platform/messaging";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
import { FieldType, CipherType } from "@bitwarden/common/vault/enums";
@ -27,6 +28,7 @@ import { FieldView } from "@bitwarden/common/vault/models/view/field.view";
import { BrowserApi } from "../../platform/browser/browser-api";
import { ScriptInjectorService } from "../../platform/services/abstractions/script-injector.service";
import { openVaultItemPasswordRepromptPopout } from "../../vault/popup/utils/vault-popout-window";
import { AutofillMessageCommand, AutofillMessageSender } from "../enums/autofill-message.enums";
import { AutofillPort } from "../enums/autofill-port.enums";
import AutofillField from "../models/autofill-field";
import AutofillPageDetails from "../models/autofill-page-details";
@ -35,6 +37,7 @@ import AutofillScript from "../models/autofill-script";
import {
AutoFillOptions,
AutofillService as AutofillServiceInterface,
COLLECT_PAGE_DETAILS_RESPONSE_COMMAND,
FormData,
GenerateFillScriptOptions,
PageDetail,
@ -64,8 +67,47 @@ export default class AutofillService implements AutofillServiceInterface {
private scriptInjectorService: ScriptInjectorService,
private accountService: AccountService,
private authService: AuthService,
private messageListener: MessageListener,
) {}
/**
* Collects page details from the specific tab. This method returns an observable that can
* be subscribed to in order to build the results from all collectPageDetailsResponse
* messages from the given tab.
*
* @param tab The tab to collect page details from
*/
collectPageDetailsFromTab$(tab: chrome.tabs.Tab): Observable<PageDetail[]> {
const pageDetailsFromTab$ = this.messageListener
.messages$(COLLECT_PAGE_DETAILS_RESPONSE_COMMAND)
.pipe(
filter(
(message) =>
message.tab.id === tab.id &&
message.sender === AutofillMessageSender.collectPageDetailsFromTabObservable,
),
scan(
(acc, message) => [
...acc,
{
frameId: message.webExtSender.frameId,
tab: message.tab,
details: message.details,
},
],
[] as PageDetail[],
),
);
void BrowserApi.tabSendMessage(tab, {
tab: tab,
command: AutofillMessageCommand.collectPageDetails,
sender: AutofillMessageSender.collectPageDetailsFromTabObservable,
});
return pageDetailsFromTab$;
}
/**
* Triggers on installation of the extension Handles injecting
* content scripts into all tabs that are currently open, and
@ -2094,7 +2136,8 @@ export default class AutofillService implements AutofillServiceInterface {
for (let index = 0; index < tabs.length; index++) {
const tab = tabs[index];
if (tab.url?.startsWith("http")) {
void this.injectAutofillScripts(tab, 0, false);
const frames = await BrowserApi.getAllFrameDetails(tab.id);
frames.forEach((frame) => this.injectAutofillScripts(tab, frame.frameId, false));
}
}
}

View File

@ -889,6 +889,7 @@ export default class MainBackground {
this.scriptInjectorService,
this.accountService,
this.authService,
messageListener,
);
this.auditService = new AuditService(this.cryptoFunctionService, this.apiService);

View File

@ -62,7 +62,8 @@
"scripting",
"offscreen",
"webRequest",
"webRequestAuthProvider"
"webRequestAuthProvider",
"webNavigation"
],
"optional_permissions": ["nativeMessaging", "privacy"],
"host_permissions": ["https://*/*", "http://*/*"],

View File

@ -235,6 +235,46 @@ describe("BrowserApi", () => {
});
});
describe("getFrameDetails", () => {
it("returns the frame details of the specified frame", async () => {
const tabId = 1;
const frameId = 2;
const mockFrameDetails = mock<chrome.webNavigation.GetFrameResultDetails>();
chrome.webNavigation.getFrame = jest
.fn()
.mockImplementation((_details, callback) => callback(mockFrameDetails));
const returnFrame = await BrowserApi.getFrameDetails({ tabId, frameId });
expect(chrome.webNavigation.getFrame).toHaveBeenCalledWith(
{ tabId, frameId },
expect.any(Function),
);
expect(returnFrame).toEqual(mockFrameDetails);
});
});
describe("getAllFrameDetails", () => {
it("returns all sub frame details of the specified tab", async () => {
const tabId = 1;
const mockFrameDetails1 = mock<chrome.webNavigation.GetAllFrameResultDetails>();
const mockFrameDetails2 = mock<chrome.webNavigation.GetAllFrameResultDetails>();
chrome.webNavigation.getAllFrames = jest
.fn()
.mockImplementation((_details, callback) =>
callback([mockFrameDetails1, mockFrameDetails2]),
);
const frames = await BrowserApi.getAllFrameDetails(tabId);
expect(chrome.webNavigation.getAllFrames).toHaveBeenCalledWith(
{ tabId },
expect.any(Function),
);
expect(frames).toEqual([mockFrameDetails1, mockFrameDetails2]);
});
});
describe("reloadExtension", () => {
it("reloads the window location if the passed globalContext is for the window", () => {
const windowMock = mock<Window>({

View File

@ -176,21 +176,21 @@ export class BrowserApi {
return BrowserApi.tabSendMessage(tab, obj);
}
static async tabSendMessage<T>(
static async tabSendMessage<T, TResponse = unknown>(
tab: chrome.tabs.Tab,
obj: T,
options: chrome.tabs.MessageSendOptions = null,
): Promise<void> {
): Promise<TResponse> {
if (!tab || !tab.id) {
return;
}
return new Promise<void>((resolve) => {
chrome.tabs.sendMessage(tab.id, obj, options, () => {
return new Promise<TResponse>((resolve) => {
chrome.tabs.sendMessage(tab.id, obj, options, (response) => {
if (chrome.runtime.lastError) {
// Some error happened
}
resolve();
resolve(response);
});
});
}
@ -263,6 +263,28 @@ export class BrowserApi {
);
}
/**
* Gathers the details for a specified sub-frame of a tab.
*
* @param details - The details of the frame to get.
*/
static async getFrameDetails(
details: chrome.webNavigation.GetFrameDetails,
): Promise<chrome.webNavigation.GetFrameResultDetails> {
return new Promise((resolve) => chrome.webNavigation.getFrame(details, resolve));
}
/**
* Gets all frames associated with a tab.
*
* @param tabId - The id of the tab to get the frames for.
*/
static async getAllFrameDetails(
tabId: chrome.tabs.Tab["id"],
): Promise<chrome.webNavigation.GetAllFrameResultDetails[]> {
return new Promise((resolve) => chrome.webNavigation.getAllFrames({ tabId }, resolve));
}
// Keep track of all the events registered in a Safari popup so we can remove
// them when the popup gets unloaded, otherwise we cause a memory leak
private static trackedChromeEventListeners: [

View File

@ -57,6 +57,7 @@ import { VaultFilterComponent } from "../vault/popup/components/vault/vault-filt
import { VaultItemsComponent } from "../vault/popup/components/vault/vault-items.component";
import { VaultV2Component } from "../vault/popup/components/vault/vault-v2.component";
import { ViewComponent } from "../vault/popup/components/vault/view.component";
import { AddEditV2Component } from "../vault/popup/components/vault-v2/add-edit/add-edit-v2.component";
import { AppearanceComponent } from "../vault/popup/settings/appearance.component";
import { FolderAddEditComponent } from "../vault/popup/settings/folder-add-edit.component";
import { FoldersComponent } from "../vault/popup/settings/folders.component";
@ -195,20 +196,18 @@ const routes: Routes = [
canActivate: [AuthGuard],
data: { state: "cipher-password-history" },
},
{
...extensionRefreshSwap(AddEditComponent, AddEditV2Component, {
path: "add-cipher",
component: AddEditComponent,
canActivate: [AuthGuard, debounceNavigationGuard()],
data: { state: "add-cipher" },
runGuardsAndResolvers: "always",
},
{
}),
...extensionRefreshSwap(AddEditComponent, AddEditV2Component, {
path: "edit-cipher",
component: AddEditComponent,
canActivate: [AuthGuard, debounceNavigationGuard()],
data: { state: "edit-cipher" },
runGuardsAndResolvers: "always",
},
}),
{
path: "share-cipher",
component: ShareComponent,

View File

@ -342,6 +342,7 @@ const safeProviders: SafeProvider[] = [
ScriptInjectorService,
AccountServiceAbstraction,
AuthService,
MessageListener,
],
}),
safeProvider({

View File

@ -0,0 +1,9 @@
<popup-page>
<popup-header slot="header" [pageTitle]="headerText" showBackButton> </popup-header>
<popup-footer slot="footer">
<button bitButton type="button" buttonType="primary">
{{ "save" | i18n }}
</button>
</popup-footer>
</popup-page>

View File

@ -0,0 +1,64 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormsModule } from "@angular/forms";
import { ActivatedRoute } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { SearchModule, ButtonModule } from "@bitwarden/components";
import { PopupFooterComponent } from "../../../../../platform/popup/layout/popup-footer.component";
import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component";
@Component({
selector: "app-add-edit-v2",
templateUrl: "add-edit-v2.component.html",
standalone: true,
imports: [
CommonModule,
SearchModule,
JslibModule,
FormsModule,
ButtonModule,
PopupPageComponent,
PopupHeaderComponent,
PopupFooterComponent,
],
})
export class AddEditV2Component {
headerText: string;
constructor(
private route: ActivatedRoute,
private i18nService: I18nService,
) {
this.subscribeToParams();
}
subscribeToParams(): void {
this.route.queryParams.pipe(takeUntilDestroyed()).subscribe((params) => {
const isNew = params.isNew.toLowerCase() === "true";
const cipherType = parseInt(params.type);
this.headerText = this.setHeader(isNew, cipherType);
});
}
setHeader(isNew: boolean, type: CipherType) {
const partOne = isNew ? "newItemHeader" : "editItemHeader";
switch (type) {
case CipherType.Login:
return this.i18nService.t(partOne, this.i18nService.t("typeLogin"));
case CipherType.Card:
return this.i18nService.t(partOne, this.i18nService.t("typeCard"));
case CipherType.Identity:
return this.i18nService.t(partOne, this.i18nService.t("typeIdentity"));
case CipherType.SecureNote:
return this.i18nService.t(partOne, this.i18nService.t("note"));
}
}
}

View File

@ -0,0 +1,22 @@
<button bitButton [bitMenuTriggerFor]="itemOptions" buttonType="primary" type="button">
<i class="bwi bwi-plus-f" aria-hidden="true"></i>
{{ "new" | i18n }}
</button>
<bit-menu #itemOptions>
<a type="button" bitMenuItem (click)="newItemNavigate(cipherType.Login)">
<i class="bwi bwi-globe" slot="start" aria-hidden="true"></i>
{{ "typeLogin" | i18n }}
</a>
<a type="button" bitMenuItem (click)="newItemNavigate(cipherType.Card)">
<i class="bwi bwi-credit-card" slot="start" aria-hidden="true"></i>
{{ "typeCard" | i18n }}
</a>
<a type="button" bitMenuItem (click)="newItemNavigate(cipherType.Identity)">
<i class="bwi bwi-id-card" slot="start" aria-hidden="true"></i>
{{ "typeIdentity" | i18n }}
</a>
<a type="button" bitMenuItem (click)="newItemNavigate(cipherType.SecureNote)">
<i class="bwi bwi-sticky-note" slot="start" aria-hidden="true"></i>
{{ "note" | i18n }}
</a>
</bit-menu>

View File

@ -0,0 +1,28 @@
import { CommonModule } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { Router, RouterLink } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { CipherType } from "@bitwarden/common/vault/enums";
import { ButtonModule, NoItemsModule, MenuModule } from "@bitwarden/components";
@Component({
selector: "app-new-item-dropdown",
templateUrl: "new-item-dropdown-v2.component.html",
standalone: true,
imports: [NoItemsModule, JslibModule, CommonModule, ButtonModule, RouterLink, MenuModule],
})
export class NewItemDropdownV2Component implements OnInit, OnDestroy {
cipherType = CipherType;
constructor(private router: Router) {}
ngOnInit(): void {}
ngOnDestroy(): void {}
// TODO PM-6826: add selectedVault query param
newItemNavigate(type: CipherType) {
void this.router.navigate(["/add-cipher"], { queryParams: { type: type, isNew: true } });
}
}

View File

@ -1,6 +1,6 @@
import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { Subject, firstValueFrom, from } from "rxjs";
import { Subject, firstValueFrom, from, Subscription } from "rxjs";
import { debounceTime, switchMap, takeUntil } from "rxjs/operators";
import { UnassignedItemsBannerService } from "@bitwarden/angular/services/unassigned-items-banner.service";
@ -51,12 +51,12 @@ export class CurrentTabComponent implements OnInit, OnDestroy {
autofillCalloutText: string;
protected search$ = new Subject<void>();
private destroy$ = new Subject<void>();
private collectPageDetailsSubscription: Subscription;
private totpCode: string;
private totpTimeout: number;
private loadedTimeout: number;
private searchTimeout: number;
private initPageDetailsTimeout: number;
protected unassignedItemsBannerEnabled$ = this.configService.getFeatureFlag$(
FeatureFlag.UnassignedItemsBanner,
@ -100,15 +100,6 @@ export class CurrentTabComponent implements OnInit, OnDestroy {
}, 500);
}
break;
case "collectPageDetailsResponse":
if (message.sender === BroadcasterSubscriptionId) {
this.pageDetails.push({
frameId: message.webExtSender.frameId,
tab: message.tab,
details: message.details,
});
}
break;
default:
break;
}
@ -266,6 +257,7 @@ export class CurrentTabComponent implements OnInit, OnDestroy {
protected async load() {
this.isLoading = false;
this.tab = await BrowserApi.getTabFromCurrentWindow();
if (this.tab != null) {
this.url = this.tab.url;
} else {
@ -274,8 +266,14 @@ export class CurrentTabComponent implements OnInit, OnDestroy {
return;
}
this.hostname = Utils.getHostname(this.url);
this.pageDetails = [];
this.collectPageDetailsSubscription?.unsubscribe();
this.collectPageDetailsSubscription = this.autofillService
.collectPageDetailsFromTab$(this.tab)
.pipe(takeUntil(this.destroy$))
.subscribe((pageDetails) => (this.pageDetails = pageDetails));
this.hostname = Utils.getHostname(this.url);
const otherTypes: CipherType[] = [];
const dontShowCards = !(await firstValueFrom(this.vaultSettingsService.showCardsCurrentTab$));
const dontShowIdentities = !(await firstValueFrom(
@ -323,7 +321,6 @@ export class CurrentTabComponent implements OnInit, OnDestroy {
}
this.isLoading = this.loaded = true;
this.collectTabPageDetails();
}
async goToSettings() {
@ -361,19 +358,4 @@ export class CurrentTabComponent implements OnInit, OnDestroy {
this.autofillCalloutText = this.i18nService.t("autofillSelectInfoWithoutCommand");
}
}
private collectTabPageDetails() {
void BrowserApi.tabSendMessage(this.tab, {
command: "collectPageDetails",
tab: this.tab,
sender: BroadcasterSubscriptionId,
});
window.clearTimeout(this.initPageDetailsTimeout);
this.initPageDetailsTimeout = window.setTimeout(() => {
if (this.pageDetails.length === 0) {
this.collectTabPageDetails();
}
}, 250);
}
}

View File

@ -1,11 +1,8 @@
<popup-page>
<popup-header slot="header" [pageTitle]="'vault' | i18n">
<ng-container slot="end">
<!-- TODO PM-6826: add selectedVault query param -->
<a bitButton buttonType="primary" type="button" routerLink="/add-cipher">
<i class="bwi bwi-plus-f" aria-hidden="true"></i>
{{ "new" | i18n }}
</a>
<app-new-item-dropdown></app-new-item-dropdown>
<app-pop-out></app-pop-out>
<app-current-account></app-current-account>
</ng-container>
@ -18,9 +15,7 @@
<bit-no-items [icon]="vaultIcon">
<ng-container slot="title">{{ "yourVaultIsEmpty" | i18n }}</ng-container>
<ng-container slot="description">{{ "autofillSuggestionsTip" | i18n }}</ng-container>
<button slot="button" type="button" bitButton buttonType="primary" (click)="addCipher()">
{{ "new" | i18n }}
</button>
<app-new-item-dropdown slot="button"></app-new-item-dropdown>
</bit-no-items>
</div>

View File

@ -5,6 +5,7 @@ import { Router, RouterLink } from "@angular/router";
import { combineLatest } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { CipherType } from "@bitwarden/common/vault/enums";
import { ButtonModule, Icons, NoItemsModule } from "@bitwarden/components";
import { CurrentAccountComponent } from "../../../../auth/popup/account-switching/current-account.component";
@ -13,6 +14,7 @@ import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-he
import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component";
import { VaultPopupItemsService } from "../../services/vault-popup-items.service";
import { AutofillVaultListItemsComponent, VaultListItemsContainerComponent } from "../vault-v2";
import { NewItemDropdownV2Component } from "../vault-v2/new-item-dropdown/new-item-dropdown-v2.component";
import { VaultListFiltersComponent } from "../vault-v2/vault-list-filters/vault-list-filters.component";
import { VaultV2SearchComponent } from "../vault-v2/vault-search/vault-v2-search.component";
@ -40,9 +42,11 @@ enum VaultState {
ButtonModule,
RouterLink,
VaultV2SearchComponent,
NewItemDropdownV2Component,
],
})
export class VaultV2Component implements OnInit, OnDestroy {
cipherType = CipherType;
protected favoriteCiphers$ = this.vaultPopupItemsService.favoriteCiphers$;
protected remainingCiphers$ = this.vaultPopupItemsService.remainingCiphers$;
@ -86,9 +90,4 @@ export class VaultV2Component implements OnInit, OnDestroy {
ngOnInit(): void {}
ngOnDestroy(): void {}
addCipher() {
// TODO: Add currently filtered organization to query params if available
void this.router.navigate(["/add-cipher"], {});
}
}

View File

@ -1,7 +1,7 @@
import { DatePipe, Location } from "@angular/common";
import { ChangeDetectorRef, Component, NgZone } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { Subject, firstValueFrom, takeUntil } from "rxjs";
import { Subject, firstValueFrom, takeUntil, Subscription } from "rxjs";
import { first } from "rxjs/operators";
import { ViewComponent as BaseViewComponent } from "@bitwarden/angular/vault/components/view.component";
@ -68,6 +68,7 @@ export class ViewComponent extends BaseViewComponent {
inPopout = false;
cipherType = CipherType;
private fido2PopoutSessionData$ = fido2PopoutSessionData$();
private collectPageDetailsSubscription: Subscription;
private destroy$ = new Subject<void>();
@ -152,15 +153,6 @@ export class ViewComponent extends BaseViewComponent {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.ngZone.run(async () => {
switch (message.command) {
case "collectPageDetailsResponse":
if (message.sender === BroadcasterSubscriptionId) {
this.pageDetails.push({
frameId: message.webExtSender.frameId,
tab: message.tab,
details: message.details,
});
}
break;
case "tabChanged":
case "windowChanged":
if (this.loadPageDetailsTimeout != null) {
@ -198,7 +190,9 @@ export class ViewComponent extends BaseViewComponent {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["/edit-cipher"], { queryParams: { cipherId: this.cipher.id } });
this.router.navigate(["/edit-cipher"], {
queryParams: { cipherId: this.cipher.id, type: this.cipher.type, isNew: false },
});
return true;
}
@ -335,6 +329,7 @@ export class ViewComponent extends BaseViewComponent {
}
private async loadPageDetails() {
this.collectPageDetailsSubscription?.unsubscribe();
this.pageDetails = [];
this.tab = this.senderTabId
? await BrowserApi.getTab(this.senderTabId)
@ -344,13 +339,10 @@ export class ViewComponent extends BaseViewComponent {
return;
}
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
BrowserApi.tabSendMessage(this.tab, {
command: "collectPageDetails",
tab: this.tab,
sender: BroadcasterSubscriptionId,
});
this.collectPageDetailsSubscription = this.autofillService
.collectPageDetailsFromTab$(this.tab)
.pipe(takeUntil(this.destroy$))
.subscribe((pageDetails) => (this.pageDetails = pageDetails));
}
private async doAutofill() {

View File

@ -3,6 +3,8 @@ import { FormBuilder } from "@angular/forms";
import { BehaviorSubject, skipWhile } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProductType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@ -23,6 +25,7 @@ describe("VaultPopupListFiltersService", () => {
const folderViews$ = new BehaviorSubject([]);
const cipherViews$ = new BehaviorSubject({});
const decryptedCollections$ = new BehaviorSubject<CollectionView[]>([]);
const policyAppliesToActiveUser$ = new BehaviorSubject<boolean>(false);
const collectionService = {
decryptedCollections$,
@ -45,9 +48,15 @@ describe("VaultPopupListFiltersService", () => {
t: (key: string) => key,
} as I18nService;
const policyService = {
policyAppliesToActiveUser$: jest.fn(() => policyAppliesToActiveUser$),
};
beforeEach(() => {
memberOrganizations$.next([]);
decryptedCollections$.next([]);
policyAppliesToActiveUser$.next(false);
policyService.policyAppliesToActiveUser$.mockClear();
collectionService.getAllNested = () => Promise.resolve([]);
TestBed.configureTestingModule({
@ -72,6 +81,10 @@ describe("VaultPopupListFiltersService", () => {
provide: CollectionService,
useValue: collectionService,
},
{
provide: PolicyService,
useValue: policyService,
},
{ provide: FormBuilder, useClass: FormBuilder },
],
});
@ -127,6 +140,65 @@ describe("VaultPopupListFiltersService", () => {
});
});
describe("PersonalOwnership policy", () => {
it('calls policyAppliesToActiveUser$ with "PersonalOwnership"', () => {
expect(policyService.policyAppliesToActiveUser$).toHaveBeenCalledWith(
PolicyType.PersonalOwnership,
);
});
it("returns an empty array when the policy applies and there is a single organization", (done) => {
policyAppliesToActiveUser$.next(true);
memberOrganizations$.next([
{ name: "bobby's org", id: "1234-3323-23223" },
] as Organization[]);
service.organizations$.subscribe((organizations) => {
expect(organizations).toEqual([]);
done();
});
});
it('adds "myVault" when the policy does not apply and there are multiple organizations', (done) => {
policyAppliesToActiveUser$.next(false);
const orgs = [
{ name: "bobby's org", id: "1234-3323-23223" },
{ name: "alice's org", id: "2223-4343-99888" },
] as Organization[];
memberOrganizations$.next(orgs);
service.organizations$.subscribe((organizations) => {
expect(organizations.map((o) => o.label)).toEqual([
"myVault",
"alice's org",
"bobby's org",
]);
done();
});
});
it('does not add "myVault" the policy applies and there are multiple organizations', (done) => {
policyAppliesToActiveUser$.next(true);
const orgs = [
{ name: "bobby's org", id: "1234-3323-23223" },
{ name: "alice's org", id: "2223-3242-99888" },
{ name: "catherine's org", id: "77733-4343-99888" },
] as Organization[];
memberOrganizations$.next(orgs);
service.organizations$.subscribe((organizations) => {
expect(organizations.map((o) => o.label)).toEqual([
"alice's org",
"bobby's org",
"catherine's org",
]);
done();
});
});
});
describe("icons", () => {
it("sets family icon for family organizations", (done) => {
const orgs = [

View File

@ -13,6 +13,8 @@ import {
import { DynamicTreeNode } from "@bitwarden/angular/vault/vault-filter/models/dynamic-tree-node.model";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProductType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@ -88,6 +90,7 @@ export class VaultPopupListFiltersService {
private i18nService: I18nService,
private collectionService: CollectionService,
private formBuilder: FormBuilder,
private policyService: PolicyService,
) {
this.filterForm.controls.organization.valueChanges
.pipe(takeUntilDestroyed())
@ -167,44 +170,63 @@ export class VaultPopupListFiltersService {
/**
* Organization array structured to be directly passed to `ChipSelectComponent`
*/
organizations$: Observable<ChipSelectOption<Organization>[]> =
this.organizationService.memberOrganizations$.pipe(
map((orgs) => orgs.sort(Utils.getSortFunction(this.i18nService, "name"))),
map((orgs) => {
if (!orgs.length) {
return [];
}
organizations$: Observable<ChipSelectOption<Organization>[]> = combineLatest([
this.organizationService.memberOrganizations$,
this.policyService.policyAppliesToActiveUser$(PolicyType.PersonalOwnership),
]).pipe(
map(([orgs, personalOwnershipApplies]): [Organization[], boolean] => [
orgs.sort(Utils.getSortFunction(this.i18nService, "name")),
personalOwnershipApplies,
]),
map(([orgs, personalOwnershipApplies]) => {
// When there are no organizations return an empty array,
// resulting in the org filter being hidden
if (!orgs.length) {
return [];
}
return [
// When the user is a member of an organization, make the "My Vault" option available
{
value: { id: MY_VAULT_ID } as Organization,
label: this.i18nService.t("myVault"),
icon: "bwi-user",
},
...orgs.map((org) => {
let icon = "bwi-business";
// When there is only one organization and personal ownership policy applies,
// return an empty array, resulting in the org filter being hidden
if (orgs.length === 1 && personalOwnershipApplies) {
return [];
}
if (!org.enabled) {
// Show a warning icon if the organization is deactivated
icon = "bwi-exclamation-triangle tw-text-danger";
} else if (
org.planProductType === ProductType.Families ||
org.planProductType === ProductType.Free
) {
// Show a family icon if the organization is a family or free org
icon = "bwi-family";
}
const myVaultOrg: ChipSelectOption<Organization>[] = [];
return {
value: org,
label: org.name,
icon,
};
}),
];
}),
);
// Only add "My vault" if personal ownership policy does not apply
if (!personalOwnershipApplies) {
myVaultOrg.push({
value: { id: MY_VAULT_ID } as Organization,
label: this.i18nService.t("myVault"),
icon: "bwi-user",
});
}
return [
...myVaultOrg,
...orgs.map((org) => {
let icon = "bwi-business";
if (!org.enabled) {
// Show a warning icon if the organization is deactivated
icon = "bwi-exclamation-triangle tw-text-danger";
} else if (
org.planProductType === ProductType.Families ||
org.planProductType === ProductType.Free
) {
// Show a family icon if the organization is a family or free org
icon = "bwi-family";
}
return {
value: org,
label: org.name,
icon,
};
}),
];
}),
);
/**
* Folder array structured to be directly passed to `ChipSelectComponent`

View File

@ -135,6 +135,8 @@ const permissions = {
};
const webNavigation = {
getFrame: jest.fn(),
getAllFrames: jest.fn(),
onCommitted: {
addListener: jest.fn(),
removeListener: jest.fn(),

View File

@ -753,6 +753,7 @@ export class ServiceContainer {
await this.stateService.clean();
await this.accountService.clean(userId);
await this.accountService.switchAccount(null);
process.env.BW_SESSION = null;
}

View File

@ -26,12 +26,12 @@
appInputVerbatim="false"
/>
</bit-form-field>
<div class="tw-mb-3 tw-flex">
<div class="tw-mb-3 tw-flex tw-items-center">
<button bitButton type="button" buttonType="primary" [bitAction]="sendEmail">
{{ "sendEmail" | i18n }}
</button>
<span class="tw-text-success tw-ml-3" *ngIf="sentEmail">
{{ "verificationCodeEmailSent" | i18n: sentEmail }}
{{ "emailSent" | i18n }}
</span>
</div>
<bit-form-field>

View File

@ -31,7 +31,7 @@ export class TwoFactorEmailComponent extends TwoFactorBaseComponent {
emailPromise: Promise<unknown>;
override componentName = "app-two-factor-email";
formGroup = this.formBuilder.group({
token: [null],
token: ["", [Validators.required]],
email: ["", [Validators.email, Validators.required]],
});
@ -79,6 +79,10 @@ export class TwoFactorEmailComponent extends TwoFactorBaseComponent {
}
submit = async () => {
this.formGroup.markAllAsTouched();
if (this.formGroup.invalid) {
return;
}
if (this.enabled) {
await this.disableEmail();
this.onChangeStatus.emit(false);

View File

@ -1,173 +1,124 @@
<form
#form
(ngSubmit)="submit()"
[appApiAction]="formPromise"
class="container"
ngNativeValidate
autocomplete="off"
>
<div class="row justify-content-md-center mt-5">
<div
class="col-5"
[ngClass]="{
'col-9': !duoFrameless && isDuoProvider
}"
<form [bitSubmit]="submitForm" [formGroup]="formGroup" autocomplete="off">
<div class="tw-min-w-96">
<ng-container
*ngIf="
selectedProviderType === providerType.Email ||
selectedProviderType === providerType.Authenticator
"
>
<p class="lead text-center mb-4">{{ title }}</p>
<div class="card d-block">
<div class="card-body">
<ng-container
*ngIf="
selectedProviderType === providerType.Email ||
selectedProviderType === providerType.Authenticator
"
<p bitTypography="body1" *ngIf="selectedProviderType === providerType.Authenticator">
{{ "enterVerificationCodeApp" | i18n }}
</p>
<p bitTypography="body1" *ngIf="selectedProviderType === providerType.Email">
{{ "enterVerificationCodeEmail" | i18n: twoFactorEmail }}
</p>
<bit-form-field>
<bit-label>{{ "verificationCode" | i18n }}</bit-label>
<input bitInput type="text" formControlName="token" appAutofocus appInputVerbatim />
<bit-hint *ngIf="selectedProviderType === providerType.Email">
<a
bitLink
href="#"
appStopClick
(click)="sendEmail(true)"
*ngIf="selectedProviderType === providerType.Email"
>
<p *ngIf="selectedProviderType === providerType.Authenticator">
{{ "enterVerificationCodeApp" | i18n }}
</p>
<p *ngIf="selectedProviderType === providerType.Email">
{{ "enterVerificationCodeEmail" | i18n: twoFactorEmail }}
</p>
<div class="form-group">
<label for="code" class="sr-only">{{ "verificationCode" | i18n }}</label>
<input
id="code"
type="text"
name="Code"
class="form-control"
[(ngModel)]="token"
required
appAutofocus
inputmode="tel"
appInputVerbatim
/>
<small class="form-text" *ngIf="selectedProviderType === providerType.Email">
<a
href="#"
appStopClick
(click)="sendEmail(true)"
[appApiAction]="emailPromise"
*ngIf="selectedProviderType === providerType.Email"
>
{{ "sendVerificationCodeEmailAgain" | i18n }}
</a>
</small>
</div>
</ng-container>
<ng-container *ngIf="selectedProviderType === providerType.Yubikey">
<p class="text-center">{{ "insertYubiKey" | i18n }}</p>
<picture>
<source srcset="../../images/yubikey.avif" type="image/avif" />
<source srcset="../../images/yubikey.webp" type="image/webp" />
<img src="../../images/yubikey.jpg" class="rounded img-fluid mb-3" alt="" />
</picture>
<div class="form-group">
<label for="code" class="sr-only">{{ "verificationCode" | i18n }}</label>
<input
id="code"
type="password"
name="Code"
class="form-control"
[(ngModel)]="token"
required
appAutofocus
appInputVerbatim
autocomplete="new-password"
/>
</div>
</ng-container>
<ng-container *ngIf="selectedProviderType === providerType.WebAuthn">
<div id="web-authn-frame" class="mb-3">
<iframe id="webauthn_iframe" sandbox="allow-scripts allow-same-origin"></iframe>
</div>
</ng-container>
<!-- Duo -->
<ng-container *ngIf="isDuoProvider">
<ng-container *ngIf="duoFrameless">
<p *ngIf="selectedProviderType === providerType.OrganizationDuo" class="tw-mb-0">
{{ "duoRequiredByOrgForAccount" | i18n }}
</p>
<p>{{ "launchDuoAndFollowStepsToFinishLoggingIn" | i18n }}</p>
</ng-container>
<ng-container *ngIf="!duoFrameless">
<div id="duo-frame" class="mb-3">
<iframe
id="duo_iframe"
sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"
></iframe>
</div>
</ng-container>
</ng-container>
<i
class="bwi bwi-spinner text-muted bwi-spin pull-right"
title="{{ 'loading' | i18n }}"
*ngIf="form.loading && selectedProviderType === providerType.WebAuthn"
aria-hidden="true"
></i>
<div class="form-check" *ngIf="selectedProviderType != null">
<input
id="remember"
type="checkbox"
name="Remember"
class="form-check-input"
[(ngModel)]="remember"
/>
<label for="remember" class="form-check-label">{{ "rememberMe" | i18n }}</label>
</div>
<ng-container *ngIf="selectedProviderType == null">
<p>{{ "noTwoStepProviders" | i18n }}</p>
<p>{{ "noTwoStepProviders2" | i18n }}</p>
</ng-container>
<hr />
<div [hidden]="!showCaptcha()">
<iframe
id="hcaptcha_iframe"
height="80"
sandbox="allow-scripts allow-same-origin"
></iframe>
</div>
<!-- Buttons -->
<div class="tw-flex tw-flex-col tw-mb-3">
<button
type="submit"
class="btn btn-primary btn-block btn-submit"
[disabled]="form.loading"
*ngIf="
selectedProviderType != null &&
!isDuoProvider &&
selectedProviderType !== providerType.WebAuthn
"
>
<span>
<i class="bwi bwi-sign-in" aria-hidden="true"></i> {{ "continue" | i18n }}
</span>
<i
class="bwi bwi-spinner bwi-spin"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
</button>
<button
(click)="launchDuoFrameless()"
type="button"
class="btn btn-primary btn-block"
[disabled]="form.loading"
*ngIf="duoFrameless && isDuoProvider"
>
<span> {{ "launchDuo" | i18n }} </span>
</button>
<a routerLink="/login" class="btn btn-outline-secondary btn-block">
{{ "cancel" | i18n }}
</a>
</div>
<div class="text-center">
<a href="#" appStopClick (click)="anotherMethod()">{{
"useAnotherTwoStepMethod" | i18n
}}</a>
</div>
</div>
{{ "sendVerificationCodeEmailAgain" | i18n }}
</a></bit-hint
>
</bit-form-field>
</ng-container>
<ng-container *ngIf="selectedProviderType === providerType.Yubikey">
<p bitTypography="body1" class="tw-text-center">{{ "insertYubiKey" | i18n }}</p>
<picture>
<source srcset="../../images/yubikey.avif" type="image/avif" />
<source srcset="../../images/yubikey.webp" type="image/webp" />
<img src="../../images/yubikey.jpg" class="tw-rounded img-fluid tw-mb-3" alt="" />
</picture>
<bit-form-field>
<bit-label class="tw-sr-only">{{ "verificationCode" | i18n }}</bit-label>
<input
type="text"
bitInput
formControlName="token"
appAutofocus
appInputVerbatim
autocomplete="new-password"
/>
</bit-form-field>
</ng-container>
<ng-container *ngIf="selectedProviderType === providerType.WebAuthn">
<div id="web-authn-frame" class="tw-mb-3">
<iframe id="webauthn_iframe" sandbox="allow-scripts allow-same-origin"></iframe>
</div>
</ng-container>
<!-- Duo -->
<ng-container *ngIf="isDuoProvider">
<ng-container *ngIf="duoFrameless">
<p
bitTypography="body1"
*ngIf="selectedProviderType === providerType.OrganizationDuo"
class="tw-mb-0"
>
{{ "duoRequiredByOrgForAccount" | i18n }}
</p>
<p bitTypography="body1">{{ "launchDuoAndFollowStepsToFinishLoggingIn" | i18n }}</p>
</ng-container>
<ng-container *ngIf="!duoFrameless">
<div id="duo-frame" class="tw-mb-3">
<iframe
id="duo_iframe"
sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"
></iframe>
</div>
</ng-container>
</ng-container>
<bit-form-control *ngIf="selectedProviderType != null">
<bit-label>{{ "rememberMe" | i18n }}</bit-label>
<input type="checkbox" bitCheckbox formControlName="remember" />
</bit-form-control>
<ng-container *ngIf="selectedProviderType == null">
<p bitTypography="body1">{{ "noTwoStepProviders" | i18n }}</p>
<p bitTypography="body1">{{ "noTwoStepProviders2" | i18n }}</p>
</ng-container>
<hr />
<div [hidden]="!showCaptcha()">
<iframe id="hcaptcha_iframe" height="80" sandbox="allow-scripts allow-same-origin"></iframe>
</div>
<!-- Buttons -->
<div class="tw-flex tw-flex-col tw-space-y-2.5 tw-mb-3">
<button
type="submit"
buttonType="primary"
bitButton
bitFormButton
*ngIf="
selectedProviderType != null &&
!isDuoProvider &&
selectedProviderType !== providerType.WebAuthn
"
>
<span> <i class="bwi bwi-sign-in" aria-hidden="true"></i> {{ "continue" | i18n }} </span>
</button>
<button
(click)="launchDuoFrameless()"
type="button"
buttonType="primary"
bitButton
bitFormButton
*ngIf="duoFrameless && isDuoProvider"
>
<span> {{ "launchDuo" | i18n }} </span>
</button>
<a routerLink="/login" bitButton buttonType="secondary">
{{ "cancel" | i18n }}
</a>
</div>
<div class="text-center">
<a bitLink href="#" appStopClick (click)="anotherMethod()">{{
"useAnotherTwoStepMethod" | i18n
}}</a>
</div>
</div>
</form>

View File

@ -1,6 +1,7 @@
import { Component, Inject, OnDestroy, ViewChild, ViewContainerRef } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { lastValueFrom } from "rxjs";
import { Subject, takeUntil, lastValueFrom } from "rxjs";
import { TwoFactorComponent as BaseTwoFactorComponent } from "@bitwarden/angular/auth/components/two-factor.component";
import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
@ -38,7 +39,17 @@ import {
export class TwoFactorComponent extends BaseTwoFactorComponent implements OnDestroy {
@ViewChild("twoFactorOptions", { read: ViewContainerRef, static: true })
twoFactorOptionsModal: ViewContainerRef;
formGroup = this.formBuilder.group({
token: [
"",
{
validators: [Validators.required],
updateOn: "submit",
},
],
remember: [false],
});
private destroy$ = new Subject<void>();
constructor(
loginStrategyService: LoginStrategyServiceAbstraction,
router: Router,
@ -58,6 +69,7 @@ export class TwoFactorComponent extends BaseTwoFactorComponent implements OnDest
configService: ConfigService,
masterPasswordService: InternalMasterPasswordServiceAbstraction,
accountService: AccountService,
private formBuilder: FormBuilder,
@Inject(WINDOW) protected win: Window,
) {
super(
@ -82,6 +94,16 @@ export class TwoFactorComponent extends BaseTwoFactorComponent implements OnDest
);
this.onSuccessfulLoginNavigate = this.goAfterLogIn;
}
async ngOnInit() {
await super.ngOnInit();
this.formGroup.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((value) => {
this.token = value.token;
this.remember = value.remember;
});
}
submitForm = async () => {
await this.submit();
};
async anotherMethod() {
const dialogRef = TwoFactorOptionsComponent.open(this.dialogService);

View File

@ -82,7 +82,6 @@ const routes: Routes = [
component: LoginViaAuthRequestComponent,
data: { titleId: "adminApprovalRequested" } satisfies DataProperties,
},
{ path: "2fa", component: TwoFactorComponent, canActivate: [UnauthGuard] },
{
path: "login-initiated",
component: LoginDecryptionOptionsComponent,
@ -189,6 +188,33 @@ const routes: Routes = [
path: "",
component: AnonLayoutWrapperComponent,
children: [
{
path: "2fa",
component: TwoFactorComponent,
canActivate: [unauthGuardFn()],
data: {
pageTitle: "verifyIdentity",
},
},
{
path: "recover-2fa",
canActivate: [unauthGuardFn()],
children: [
{
path: "",
component: RecoverTwoFactorComponent,
},
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
data: {
pageTitle: "recoverAccountTwoStep",
titleId: "recoverAccountTwoStep",
} satisfies DataProperties & AnonLayoutWrapperData,
},
{
path: "accept-emergency",
canActivate: [deepLinkGuard()],
@ -212,25 +238,6 @@ const routes: Routes = [
},
],
},
{
path: "recover-2fa",
canActivate: [unauthGuardFn()],
children: [
{
path: "",
component: RecoverTwoFactorComponent,
},
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
data: {
pageTitle: "recoverAccountTwoStep",
titleId: "recoverAccountTwoStep",
} satisfies DataProperties & AnonLayoutWrapperData,
},
{
path: "remove-password",
component: RemovePasswordComponent,

View File

@ -722,6 +722,9 @@
"logIn": {
"message": "Log in"
},
"verifyIdentity": {
"message": "Verify your Identity"
},
"logInInitiated": {
"message": "Log in initiated"
},
@ -8330,5 +8333,12 @@
},
"viewSecret": {
"message": "View secret"
},
"noClients": {
"message": "There are no clients to list"
},
"providerBillingEmailHint": {
"message": "This email address will receive all invoices pertaining to this provider",
"description": "A hint that shows up on the Provider setup page to inform the admin the billing email will receive the provider's invoices."
}
}

View File

@ -11,6 +11,10 @@ import { DenyAllCommand } from "./deny-all.command";
import { DenyCommand } from "./deny.command";
import { ListCommand } from "./list.command";
type Options = {
organizationid: string;
};
export class DeviceApprovalProgram extends BaseProgram {
constructor(protected serviceContainer: ServiceContainer) {
super(serviceContainer);
@ -33,8 +37,8 @@ export class DeviceApprovalProgram extends BaseProgram {
private listCommand(): Command {
return new Command("list")
.description("List all pending requests for an organization")
.argument("<organizationId>")
.action(async (organizationId: string) => {
.requiredOption("--organizationid <organizationid>", "The organization id (required)")
.action(async (options: Options) => {
await this.exitIfFeatureFlagDisabled(FeatureFlag.BulkDeviceApproval);
await this.exitIfLocked();
@ -42,17 +46,18 @@ export class DeviceApprovalProgram extends BaseProgram {
this.serviceContainer.organizationAuthRequestService,
this.serviceContainer.organizationService,
);
const response = await cmd.run(organizationId);
const response = await cmd.run(options.organizationid);
this.processResponse(response);
});
}
private approveCommand(): Command {
return new Command("approve")
.argument("<organizationId>", "The id of the organization")
.argument("<requestId>", "The id of the request to approve")
.requiredOption("--organizationid <organizationid>", "The organization id (required)")
.description("Approve a pending request")
.action(async (organizationId: string, id: string) => {
.action(async (id: string, options: Options) => {
await this.exitIfFeatureFlagDisabled(FeatureFlag.BulkDeviceApproval);
await this.exitIfLocked();
@ -60,7 +65,7 @@ export class DeviceApprovalProgram extends BaseProgram {
this.serviceContainer.organizationService,
this.serviceContainer.organizationAuthRequestService,
);
const response = await cmd.run(organizationId, id);
const response = await cmd.run(options.organizationid, id);
this.processResponse(response);
});
}
@ -68,8 +73,8 @@ export class DeviceApprovalProgram extends BaseProgram {
private approveAllCommand(): Command {
return new Command("approve-all")
.description("Approve all pending requests for an organization")
.argument("<organizationId>")
.action(async (organizationId: string) => {
.requiredOption("--organizationid <organizationid>", "The organization id (required)")
.action(async (options: Options) => {
await this.exitIfFeatureFlagDisabled(FeatureFlag.BulkDeviceApproval);
await this.exitIfLocked();
@ -77,17 +82,17 @@ export class DeviceApprovalProgram extends BaseProgram {
this.serviceContainer.organizationAuthRequestService,
this.serviceContainer.organizationService,
);
const response = await cmd.run(organizationId);
const response = await cmd.run(options.organizationid);
this.processResponse(response);
});
}
private denyCommand(): Command {
return new Command("deny")
.argument("<organizationId>", "The id of the organization")
.argument("<requestId>", "The id of the request to deny")
.requiredOption("--organizationid <organizationid>", "The organization id (required)")
.description("Deny a pending request")
.action(async (organizationId: string, id: string) => {
.action(async (id: string, options: Options) => {
await this.exitIfFeatureFlagDisabled(FeatureFlag.BulkDeviceApproval);
await this.exitIfLocked();
@ -95,7 +100,7 @@ export class DeviceApprovalProgram extends BaseProgram {
this.serviceContainer.organizationService,
this.serviceContainer.organizationAuthRequestService,
);
const response = await cmd.run(organizationId, id);
const response = await cmd.run(options.organizationid, id);
this.processResponse(response);
});
}
@ -103,8 +108,8 @@ export class DeviceApprovalProgram extends BaseProgram {
private denyAllCommand(): Command {
return new Command("deny-all")
.description("Deny all pending requests for an organization")
.argument("<organizationId>")
.action(async (organizationId: string) => {
.requiredOption("--organizationid <organizationid>", "The organization id (required)")
.action(async (options: Options) => {
await this.exitIfFeatureFlagDisabled(FeatureFlag.BulkDeviceApproval);
await this.exitIfLocked();
@ -112,7 +117,7 @@ export class DeviceApprovalProgram extends BaseProgram {
this.serviceContainer.organizationService,
this.serviceContainer.organizationAuthRequestService,
);
const response = await cmd.run(organizationId);
const response = await cmd.run(options.organizationid);
this.processResponse(response);
});
}

View File

@ -11,6 +11,7 @@ import { OssModule } from "@bitwarden/web-vault/app/oss.module";
import {
CreateClientOrganizationComponent,
NoClientsComponent,
ManageClientOrganizationNameComponent,
ManageClientOrganizationsComponent,
ManageClientOrganizationSubscriptionComponent,
@ -65,6 +66,7 @@ import { SetupComponent } from "./setup/setup.component";
SetupProviderComponent,
UserAddEditComponent,
CreateClientOrganizationComponent,
NoClientsComponent,
ManageClientOrganizationsComponent,
ManageClientOrganizationNameComponent,
ManageClientOrganizationSubscriptionComponent,

View File

@ -1,40 +1,41 @@
<app-payment-method-warnings
*ngIf="showPaymentMethodWarningBanners$ | async"
></app-payment-method-warnings>
<div class="container page-content">
<ng-container *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<div class="container page-content" *ngIf="!loading">
<div class="page-header">
<h1>{{ "setupProvider" | i18n }}</h1>
</div>
<p>{{ "setupProviderDesc" | i18n }}</p>
<form #form (ngSubmit)="submit()" [appApiAction]="formPromise" ngNativeValidate *ngIf="loading">
<form [formGroup]="formGroup" [bitSubmit]="submit">
<h2 class="mt-5">{{ "generalInformation" | i18n }}</h2>
<div class="row">
<div class="form-group col-6">
<label for="name">{{ "providerName" | i18n }}</label>
<input id="name" class="form-control" type="text" name="Name" [(ngModel)]="name" required />
<div class="tw-grid tw-grid-flow-col tw-grid-cols-12 tw-gap-4">
<div class="tw-col-span-6">
<bit-form-field>
<bit-label>{{ "providerName" | i18n }}</bit-label>
<input type="text" bitInput formControlName="name" />
</bit-form-field>
</div>
<div class="form-group col-6">
<label for="billingEmail">{{ "billingEmail" | i18n }}</label>
<input
id="billingEmail"
class="form-control"
type="text"
name="BillingEmail"
[(ngModel)]="billingEmail"
required
/>
</div>
<div *ngIf="enableConsolidatedBilling$ | async" class="form-group col-12">
<app-tax-info />
<div class="tw-col-span-6">
<bit-form-field>
<bit-label>{{ "billingEmail" | i18n }}</bit-label>
<input type="email" bitInput formControlName="billingEmail" />
<bit-hint *ngIf="enableConsolidatedBilling$ | async">{{
"providerBillingEmailHint" | i18n
}}</bit-hint>
</bit-form-field>
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "submit" | i18n }}</span>
</button>
</div>
<app-manage-tax-information *ngIf="enableConsolidatedBilling$ | async" />
<button bitButton bitFormButton buttonType="primary" type="submit">
{{ "submit" | i18n }}
</button>
</form>
</div>

View File

@ -1,38 +1,41 @@
import { Component, OnInit, ViewChild } from "@angular/core";
import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { first } from "rxjs/operators";
import { firstValueFrom, Subject, switchMap } from "rxjs";
import { first, takeUntil } from "rxjs/operators";
import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components";
import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction";
import { ProviderSetupRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-setup.request";
import { TaxInformation } from "@bitwarden/common/billing/models/domain";
import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { ProviderKey } from "@bitwarden/common/types/key";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { TaxInfoComponent } from "@bitwarden/web-vault/app/billing";
import { ToastService } from "@bitwarden/components";
@Component({
selector: "provider-setup",
templateUrl: "setup.component.html",
})
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
export class SetupComponent implements OnInit {
@ViewChild(TaxInfoComponent) taxInfoComponent: TaxInfoComponent;
export class SetupComponent implements OnInit, OnDestroy {
@ViewChild(ManageTaxInformationComponent)
manageTaxInformationComponent: ManageTaxInformationComponent;
loading = true;
authed = false;
email: string;
formPromise: Promise<any>;
providerId: string;
token: string;
name: string;
billingEmail: string;
protected formGroup = this.formBuilder.group({
name: ["", Validators.required],
billingEmail: ["", [Validators.required, Validators.email]],
});
protected readonly TaxInformation = TaxInformation;
protected showPaymentMethodWarningBanners$ = this.configService.getFeatureFlag$(
FeatureFlag.ShowPaymentMethodWarningBanners,
@ -42,9 +45,10 @@ export class SetupComponent implements OnInit {
FeatureFlag.EnableConsolidatedBilling,
);
private destroy$ = new Subject<void>();
constructor(
private router: Router,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private route: ActivatedRoute,
private cryptoService: CryptoService,
@ -52,61 +56,81 @@ export class SetupComponent implements OnInit {
private validationService: ValidationService,
private configService: ConfigService,
private providerApiService: ProviderApiServiceAbstraction,
private formBuilder: FormBuilder,
private toastService: ToastService,
) {}
ngOnInit() {
document.body.classList.remove("layout_frontend");
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.queryParams.pipe(first()).subscribe(async (qParams) => {
const error = qParams.providerId == null || qParams.email == null || qParams.token == null;
if (error) {
this.platformUtilsService.showToast(
"error",
null,
this.i18nService.t("emergencyInviteAcceptFailed"),
{ timeout: 10000 },
);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["/"]);
this.route.queryParams
.pipe(
first(),
switchMap(async (queryParams) => {
const error =
queryParams.providerId == null ||
queryParams.email == null ||
queryParams.token == null;
if (error) {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("emergencyInviteAcceptFailed"),
timeout: 10000,
});
return await this.router.navigate(["/"]);
}
this.providerId = queryParams.providerId;
this.token = queryParams.token;
try {
const provider = await this.providerApiService.getProvider(this.providerId);
if (provider.name != null) {
/*
This is currently always going to result in a redirect to the Vault because the `provider-permissions.guard`
checks for the existence of the Provider in state. However, when accessing the Setup page via the email link,
this `navigate` invocation will be hit before the sync can complete, thus resulting in a null Provider. If we want
to resolve it, we'd either need to use the ProviderApiService in the provider-permissions.guard (added expense)
or somehow check that the previous route was /setup.
*/
return await this.router.navigate(["/providers", provider.id], {
replaceUrl: true,
});
}
this.loading = false;
} catch (error) {
this.validationService.showError(error);
return await this.router.navigate(["/"]);
}
}),
takeUntil(this.destroy$),
)
.subscribe();
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
submit = async () => {
try {
this.formGroup.markAllAsTouched();
const taxInformationValid = this.manageTaxInformationComponent.touch();
if (this.formGroup.invalid || !taxInformationValid) {
return;
}
this.providerId = qParams.providerId;
this.token = qParams.token;
// Check if provider exists, redirect if it does
try {
const provider = await this.providerApiService.getProvider(this.providerId);
if (provider.name != null) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["/providers", provider.id], { replaceUrl: true });
}
} catch (e) {
this.validationService.showError(e);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["/"]);
}
});
}
async submit() {
this.formPromise = this.doSubmit();
await this.formPromise;
this.formPromise = null;
}
async doSubmit() {
try {
const providerKey = await this.cryptoService.makeOrgKey<ProviderKey>();
const key = providerKey[0].encryptedString;
const request = new ProviderSetupRequest();
request.name = this.name;
request.billingEmail = this.billingEmail;
request.name = this.formGroup.value.name;
request.billingEmail = this.formGroup.value.billingEmail;
request.token = this.token;
request.key = key;
@ -114,27 +138,32 @@ export class SetupComponent implements OnInit {
if (enableConsolidatedBilling) {
request.taxInfo = new ExpandedTaxInfoUpdateRequest();
const taxInfoView = this.taxInfoComponent.taxInfo;
request.taxInfo.country = taxInfoView.country;
request.taxInfo.postalCode = taxInfoView.postalCode;
if (taxInfoView.includeTaxId) {
request.taxInfo.taxId = taxInfoView.taxId;
request.taxInfo.line1 = taxInfoView.line1;
request.taxInfo.line2 = taxInfoView.line2;
request.taxInfo.city = taxInfoView.city;
request.taxInfo.state = taxInfoView.state;
const taxInformation = this.manageTaxInformationComponent.getTaxInformation();
request.taxInfo.country = taxInformation.country;
request.taxInfo.postalCode = taxInformation.postalCode;
if (taxInformation.includeTaxId) {
request.taxInfo.taxId = taxInformation.taxId;
request.taxInfo.line1 = taxInformation.line1;
request.taxInfo.line2 = taxInformation.line2;
request.taxInfo.city = taxInformation.city;
request.taxInfo.state = taxInformation.state;
}
}
const provider = await this.providerApiService.postProviderSetup(this.providerId, request);
this.platformUtilsService.showToast("success", null, this.i18nService.t("providerSetup"));
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("providerSetup"),
});
await this.syncService.fullSync(true);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["/providers", provider.id]);
await this.router.navigate(["/providers", provider.id]);
} catch (e) {
this.validationService.showError(e);
}
}
};
}

View File

@ -2,3 +2,4 @@ export * from "./create-client-organization.component";
export * from "./manage-client-organizations.component";
export * from "./manage-client-organization-name.component";
export * from "./manage-client-organization-subscription.component";
export * from "./no-clients.component";

View File

@ -21,80 +21,77 @@
<span class="sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<ng-container
*ngIf="!loading && (clients | search: searchText : 'organizationName' : 'id') as searchedClients"
>
<p *ngIf="!searchedClients.length">{{ "noClientsInList" | i18n }}</p>
<ng-container *ngIf="searchedClients.length">
<bit-table
*ngIf="searchedClients?.length >= 1"
[dataSource]="dataSource"
class="table table-hover table-list"
infiniteScroll
[infiniteScrollDistance]="1"
[infiniteScrollDisabled]="!isPaging()"
(scrolled)="loadMore()"
>
<ng-container header>
<tr>
<th colspan="2" bitCell bitSortable="organizationName" default>{{ "client" | i18n }}</th>
<th bitCell bitSortable="seats">{{ "assigned" | i18n }}</th>
<th bitCell bitSortable="userCount">{{ "used" | i18n }}</th>
<th bitCell bitSortable="userCount">{{ "remaining" | i18n }}</th>
<th bitCell bitSortable="plan">{{ "billingPlan" | i18n }}</th>
<th></th>
</tr>
</ng-container>
<ng-template body let-rows$>
<tr bitRow *ngFor="let client of rows$ | async">
<td bitCell width="30">
<bit-avatar [text]="client.organizationName" [id]="client.id" size="small"></bit-avatar>
</td>
<td bitCell>
<div class="tw-flex tw-items-center tw-gap-4 tw-break-all">
<a bitLink [routerLink]="['/organizations', client.organizationId]">{{
client.organizationName
}}</a>
</div>
</td>
<td bitCell class="tw-whitespace-nowrap">
<span>{{ client.seats }}</span>
</td>
<td bitCell class="tw-whitespace-nowrap">
<span>{{ client.userCount }}</span>
</td>
<td bitCell class="tw-whitespace-nowrap">
<span>{{ client.seats - client.userCount }}</span>
</td>
<td>
<span>{{ client.plan }}</span>
</td>
<td bitCell>
<button
[bitMenuTriggerFor]="rowMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
appA11yTitle="{{ 'options' | i18n }}"
></button>
<bit-menu #rowMenu>
<button type="button" bitMenuItem (click)="manageName(client)">
<i aria-hidden="true" class="bwi bwi-pencil-square"></i>
{{ "updateName" | i18n }}
</button>
<button type="button" bitMenuItem (click)="manageSubscription(client)">
<i aria-hidden="true" class="bwi bwi-family"></i>
{{ "manageSubscription" | i18n }}
</button>
<button type="button" bitMenuItem (click)="remove(client)">
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-close"></i> {{ "unlinkOrganization" | i18n }}
</span>
</button>
</bit-menu>
</td>
</tr>
</ng-template>
</bit-table>
</ng-container>
<ng-container *ngIf="!loading">
<bit-table
[dataSource]="dataSource"
class="table table-hover table-list"
infiniteScroll
[infiniteScrollDistance]="1"
[infiniteScrollDisabled]="!isPaging()"
(scrolled)="loadMore()"
>
<ng-container header>
<tr>
<th colspan="2" bitCell bitSortable="organizationName" default>{{ "client" | i18n }}</th>
<th bitCell bitSortable="seats">{{ "assigned" | i18n }}</th>
<th bitCell bitSortable="userCount">{{ "used" | i18n }}</th>
<th bitCell bitSortable="userCount">{{ "remaining" | i18n }}</th>
<th bitCell bitSortable="plan">{{ "billingPlan" | i18n }}</th>
<th></th>
</tr>
</ng-container>
<ng-template body let-rows$>
<tr bitRow *ngFor="let client of rows$ | async">
<td bitCell width="30">
<bit-avatar [text]="client.organizationName" [id]="client.id" size="small"></bit-avatar>
</td>
<td bitCell>
<div class="tw-flex tw-items-center tw-gap-4 tw-break-all">
<a bitLink [routerLink]="['/organizations', client.organizationId]">{{
client.organizationName
}}</a>
</div>
</td>
<td bitCell class="tw-whitespace-nowrap">
<span>{{ client.seats }}</span>
</td>
<td bitCell class="tw-whitespace-nowrap">
<span>{{ client.userCount }}</span>
</td>
<td bitCell class="tw-whitespace-nowrap">
<span>{{ client.seats - client.userCount }}</span>
</td>
<td>
<span>{{ client.plan }}</span>
</td>
<td bitCell>
<button
[bitMenuTriggerFor]="rowMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
appA11yTitle="{{ 'options' | i18n }}"
></button>
<bit-menu #rowMenu>
<button type="button" bitMenuItem (click)="manageName(client)">
<i aria-hidden="true" class="bwi bwi-pencil-square"></i>
{{ "updateName" | i18n }}
</button>
<button type="button" bitMenuItem (click)="manageSubscription(client)">
<i aria-hidden="true" class="bwi bwi-family"></i>
{{ "manageSubscription" | i18n }}
</button>
<button type="button" bitMenuItem (click)="remove(client)">
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-close"></i> {{ "unlinkOrganization" | i18n }}
</span>
</button>
</bit-menu>
</td>
</tr>
</ng-template>
</bit-table>
<div *ngIf="clients.length === 0" class="tw-mt-10">
<app-no-clients (addNewOrganizationClicked)="createClientOrganization()" />
</div>
</ng-container>

View File

@ -0,0 +1,40 @@
import { Component, EventEmitter, Output } from "@angular/core";
import { svgIcon } from "@bitwarden/components";
const gearIcon = svgIcon`
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M59.9995 37.9541C46.4641 37.9541 35.5465 48.6298 35.5465 61.7321C35.5465 74.8343 46.4641 85.51 59.9995 85.51C73.5349 85.51 84.4526 74.8343 84.4526 61.7321C84.4526 48.6298 73.5349 37.9541 59.9995 37.9541ZM33.1465 61.7321C33.1465 47.2444 45.1994 35.5541 59.9995 35.5541C74.7997 35.5541 86.8526 47.2444 86.8526 61.7321C86.8526 76.2197 74.7997 87.91 59.9995 87.91C45.1994 87.91 33.1465 76.2197 33.1465 61.7321Z" fill="#CED4DC"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M98.9992 8.4C94.36 8.4 90.5992 12.1608 90.5992 16.8C90.5992 21.4392 94.36 25.2 98.9992 25.2C103.638 25.2 107.399 21.4392 107.399 16.8C107.399 12.1608 103.638 8.4 98.9992 8.4ZM88.1992 16.8C88.1992 10.8353 93.0345 6 98.9992 6C104.964 6 109.799 10.8353 109.799 16.8C109.799 22.7647 104.964 27.6 98.9992 27.6C93.0345 27.6 88.1992 22.7647 88.1992 16.8Z" fill="#CED4DC"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M109.2 56.4C104.561 56.4 100.8 60.1608 100.8 64.8C100.8 69.4392 104.561 73.2 109.2 73.2C113.84 73.2 117.6 69.4392 117.6 64.8C117.6 60.1608 113.84 56.4 109.2 56.4ZM98.4004 64.8C98.4004 58.8353 103.236 54 109.2 54C115.165 54 120 58.8353 120 64.8C120 70.7647 115.165 75.6 109.2 75.6C103.236 75.6 98.4004 70.7647 98.4004 64.8Z" fill="#CED4DC"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M100.8 99C96.1608 99 92.4 102.761 92.4 107.4C92.4 112.039 96.1608 115.8 100.8 115.8C105.439 115.8 109.2 112.039 109.2 107.4C109.2 102.761 105.439 99 100.8 99ZM90 107.4C90 101.435 94.8353 96.6 100.8 96.6C106.765 96.6 111.6 101.435 111.6 107.4C111.6 113.365 106.765 118.2 100.8 118.2C94.8353 118.2 90 113.365 90 107.4Z" fill="#CED4DC"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M37.8 98.4C33.1608 98.4 29.4 102.161 29.4 106.8C29.4 111.439 33.1608 115.2 37.8 115.2C42.4392 115.2 46.2 111.439 46.2 106.8C46.2 102.161 42.4392 98.4 37.8 98.4ZM27 106.8C27 100.835 31.8353 96 37.8 96C43.7647 96 48.6 100.835 48.6 106.8C48.6 112.765 43.7647 117.6 37.8 117.6C31.8353 117.6 27 112.765 27 106.8Z" fill="#CED4DC"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.8 40.2C6.16081 40.2 2.4 43.9608 2.4 48.6C2.4 53.2392 6.16081 57 10.8 57C15.4392 57 19.2 53.2392 19.2 48.6C19.2 43.9608 15.4392 40.2 10.8 40.2ZM0 48.6C0 42.6353 4.83532 37.8 10.8 37.8C16.7647 37.8 21.6 42.6353 21.6 48.6C21.6 54.5647 16.7647 59.4 10.8 59.4C4.83532 59.4 0 54.5647 0 48.6Z" fill="#CED4DC"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.3996 3.60001C33.7604 3.60001 29.9996 7.36082 29.9996 12C29.9996 16.6392 33.7604 20.4 38.3996 20.4C43.0388 20.4 46.7996 16.6392 46.7996 12C46.7996 7.36082 43.0388 3.60001 38.3996 3.60001ZM27.5996 12C27.5996 6.03534 32.4349 1.20001 38.3996 1.20001C44.3643 1.20001 49.1996 6.03534 49.1996 12C49.1996 17.9647 44.3643 22.8 38.3996 22.8C32.4349 22.8 27.5996 17.9647 27.5996 12Z" fill="#CED4DC"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M42.217 21.3484C42.5229 21.221 42.8742 21.3656 43.0017 21.6715L49.7525 37.8734C49.8799 38.1793 49.7353 38.5306 49.4294 38.6581C49.1235 38.7855 48.7722 38.6409 48.6448 38.335L41.894 22.133C41.7665 21.8272 41.9112 21.4759 42.217 21.3484Z" fill="#CED4DC"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M92.7905 24.1445C93.0435 24.3585 93.075 24.7371 92.861 24.9901L78.0092 42.5422C77.7952 42.7951 77.4166 42.8267 77.1636 42.6126C76.9107 42.3986 76.8791 42.02 77.0932 41.767L91.9449 24.2149C92.159 23.962 92.5375 23.9304 92.7905 24.1445Z" fill="#CED4DC"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.4265 51.4253C20.523 51.1083 20.8582 50.9295 21.1752 51.026L34.9752 55.226C35.2923 55.3225 35.471 55.6577 35.3746 55.9747C35.2781 56.2917 34.9429 56.4705 34.6259 56.374L20.8259 52.174C20.5088 52.0776 20.3301 51.7424 20.4265 51.4253Z" fill="#CED4DC"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M49.4777 84.0684C49.7714 84.2219 49.8849 84.5845 49.7314 84.8781L42.9795 97.7892C42.8259 98.0829 42.4634 98.1964 42.1697 98.0429C41.8761 97.8893 41.7625 97.5268 41.9161 97.2331L48.668 84.322C48.8216 84.0284 49.1841 83.9148 49.4777 84.0684Z" fill="#CED4DC"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M77.1582 79.5058C77.4086 79.2888 77.7876 79.3159 78.0046 79.5663L95.5567 99.8187C95.7737 100.069 95.7466 100.448 95.4962 100.665C95.2458 100.882 94.8669 100.855 94.6499 100.605L77.0978 80.3522C76.8807 80.1018 76.9078 79.7229 77.1582 79.5058Z" fill="#CED4DC"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M85.0558 62.3473C85.0887 62.0176 85.3828 61.7771 85.7125 61.81L99.2141 63.1602C99.5438 63.1932 99.7844 63.4872 99.7514 63.8169C99.7184 64.1466 99.4244 64.3872 99.0947 64.3542L85.5931 63.0041C85.2634 62.9711 85.0228 62.6771 85.0558 62.3473Z" fill="#CED4DC"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M55.0583 45.4382C54.888 45.247 54.615 45.185 54.3788 45.2838L52.1688 46.2079C51.9281 46.3086 51.7801 46.5531 51.8024 46.8129L52.1362 50.6898C52.1819 51.2204 51.9902 51.744 51.6128 52.1197L50.2505 53.4761C49.894 53.8311 49.4052 54.0206 48.9027 53.9989L45.0074 53.8303C44.7569 53.8194 44.5261 53.9655 44.4286 54.1965L43.4934 56.4137C43.3921 56.6536 43.4573 56.9315 43.6545 57.1014L46.5356 59.5838C46.9324 59.9257 47.1606 60.4236 47.1606 60.9474V62.8948C47.1606 63.4058 46.9435 63.8927 46.5633 64.2341L43.7142 66.7927C43.5238 66.9636 43.4628 67.2365 43.5622 67.4723L44.4892 69.6698C44.5905 69.9098 44.835 70.0571 45.0945 70.0343L48.8457 69.7047C49.3746 69.6583 49.897 69.8477 50.2732 70.2223L51.6345 71.5776C51.9931 71.9346 52.1849 72.4261 52.1628 72.9317L51.994 76.7976C51.983 77.0488 52.1299 77.2803 52.3619 77.3773L54.5876 78.308C54.8286 78.4088 55.1071 78.3421 55.2763 78.143L57.6994 75.2918C58.0414 74.8894 58.5429 74.6575 59.071 74.6575H61.0298C61.5381 74.6575 62.0226 74.8723 62.3639 75.249L64.9399 78.0926C65.1106 78.281 65.3815 78.3414 65.616 78.2433L67.8309 77.3171C68.072 77.2163 68.2202 76.9709 68.1971 76.7106L67.8673 72.9892C67.8201 72.4571 68.0117 71.9316 68.3903 71.5547L69.7513 70.1996C70.1078 69.8447 70.5965 69.6551 71.0991 69.6769L74.9944 69.8455C75.2449 69.8563 75.4757 69.7103 75.5731 69.4793L76.5045 67.2715C76.6066 67.0293 76.5393 66.7488 76.3382 66.5794L73.4814 64.1727C73.0755 63.8307 72.8412 63.3269 72.8412 62.7961V60.856C72.8412 60.3457 73.0578 59.8594 73.4371 59.518L76.3646 56.8836C76.5546 56.7125 76.6154 56.4399 76.5161 56.2043L75.5887 54.006C75.4875 53.766 75.2429 53.6187 74.9834 53.6415L71.2322 53.9711C70.7034 54.0175 70.181 53.8281 69.8047 53.4535L68.4434 52.0982C68.0848 51.7411 67.8931 51.2496 67.9152 50.7441L68.084 46.8782C68.0949 46.627 67.9481 46.3955 67.716 46.2985L65.4903 45.3678C65.2493 45.267 64.9708 45.3337 64.8016 45.5328L62.3785 48.384C62.0365 48.7864 61.5351 49.0183 61.007 49.0183H59.0542C58.5406 49.0183 58.0516 48.799 57.71 48.4155L55.0583 45.4382ZM53.9158 44.1767C54.6246 43.8803 55.4434 44.0664 55.9544 44.6401L58.6061 47.6174C58.72 47.7452 58.883 47.8183 59.0542 47.8183H61.007C61.183 47.8183 61.3502 47.741 61.4642 47.6069L63.8872 44.7557C64.3947 44.1585 65.2303 43.9583 65.9532 44.2607L68.179 45.1914C68.8751 45.4825 69.3157 46.1768 69.2828 46.9306L69.114 50.7964C69.1067 50.965 69.1706 51.1288 69.2901 51.2478L70.6514 52.6031C70.7768 52.728 70.9509 52.7911 71.1272 52.7757L74.8784 52.4461C75.6569 52.3777 76.3906 52.8195 76.6944 53.5396L77.6217 55.7379C77.9198 56.4446 77.7374 57.2625 77.1673 57.7755L74.2398 60.41C74.1134 60.5238 74.0412 60.6859 74.0412 60.856V62.7961C74.0412 62.973 74.1193 63.1409 74.2546 63.2549L77.1114 65.6617C77.7145 66.1698 77.9166 67.0113 77.6101 67.7379L76.6788 69.9457C76.3864 70.6387 75.694 71.0769 74.9425 71.0444L71.0472 70.8758C70.8797 70.8685 70.7168 70.9317 70.598 71.05L69.2369 72.4051C69.1108 72.5307 69.0469 72.7059 69.0626 72.8833L69.3924 76.6046C69.4616 77.3857 69.0173 78.1217 68.2939 78.4242L66.079 79.3504C65.3753 79.6447 64.5626 79.4635 64.0505 78.8982L61.4745 76.0547C61.3608 75.9291 61.1993 75.8575 61.0298 75.8575H59.071C58.8949 75.8575 58.7278 75.9348 58.6138 76.0689L56.1907 78.9201C55.6832 79.5173 54.8477 79.7174 54.1247 79.4151L51.899 78.4844C51.2029 78.1933 50.7622 77.499 50.7951 76.7452L50.9639 72.8793C50.9713 72.7108 50.9074 72.547 50.7878 72.428L49.4265 71.0726C49.3011 70.9478 49.127 70.8846 48.9507 70.9001L45.1996 71.2297C44.421 71.298 43.6873 70.8562 43.3836 70.1362L42.4566 67.9387C42.1582 67.2314 42.3412 66.4127 42.9124 65.8998L45.7615 63.3412C45.8883 63.2274 45.9606 63.0651 45.9606 62.8948V60.9474C45.9606 60.7728 45.8846 60.6069 45.7523 60.4929L42.8712 58.0105C42.2794 57.5006 42.0841 56.6671 42.3877 55.9473L43.323 53.7301C43.6153 53.0371 44.3078 52.5989 45.0593 52.6314L48.9546 52.8C49.1221 52.8073 49.285 52.7441 49.4038 52.6258L50.7662 51.2694C50.892 51.1441 50.9558 50.9696 50.9406 50.7927L50.6069 46.9159C50.5398 46.1363 50.9839 45.4027 51.7058 45.1008L53.9158 44.1767ZM65.7734 59.49C64.5008 56.2102 60.7895 54.7187 57.5276 56.0511C54.2534 57.3885 52.7303 61.0753 54.0787 64.2677C55.4244 67.5297 59.1264 69.0401 62.327 67.6984C65.5088 66.3645 67.1209 62.6899 65.7734 59.49ZM64.6574 59.9312C63.6423 57.3035 60.6562 56.0694 57.9814 57.162C55.3169 58.2503 54.0985 61.2338 55.185 63.8029L55.1872 63.808L55.1872 63.808C56.2791 66.4579 59.2771 67.6758 61.863 66.5917C64.4656 65.5006 65.7472 62.5089 64.6645 59.9487C64.662 59.9429 64.6597 59.9371 64.6574 59.9312Z" fill="#CED4DC"/>
</svg>
`;
@Component({
selector: "app-no-clients",
template: `<div class="tw-flex tw-flex-col tw-items-center tw-text-info">
<bit-icon [icon]="icon"></bit-icon>
<p class="tw-mt-4">{{ "noClients" | i18n }}</p>
<a type="button" bitButton buttonType="primary" (click)="addNewOrganization()">
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "addNewOrganization" | i18n }}
</a>
</div>`,
})
export class NoClientsComponent {
icon = gearIcon;
@Output() addNewOrganizationClicked = new EventEmitter();
addNewOrganization = () => this.addNewOrganizationClicked.emit();
}

View File

@ -1,7 +1,4 @@
export * from "./clients/create-client-organization.component";
export * from "./clients/manage-client-organization-name.component";
export * from "./clients/manage-client-organization-subscription.component";
export * from "./clients/manage-client-organizations.component";
export * from "./clients";
export * from "./guards/has-consolidated-billing.guard";
export * from "./payment-method/provider-select-payment-method-dialog.component";
export * from "./payment-method/provider-payment-method.component";

View File

@ -44,7 +44,7 @@
<p>{{ "taxInformationDesc" | i18n }}</p>
<app-manage-tax-information
*ngIf="taxInformation"
[taxInformation]="taxInformation"
[startWith]="taxInformation"
[onSubmit]="updateTaxInformation"
(taxInformationUpdated)="onDataUpdated()"
/>

View File

@ -1,7 +1,7 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<div class="tw-col-span-6">
<bit-form-field>
<bit-form-field [disableMargin]="selectionSupportsAdditionalOptions">
<bit-label>{{ "country" | i18n }}</bit-label>
<bit-select formControlName="country">
<bit-option
@ -14,7 +14,7 @@
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field>
<bit-form-field [disableMargin]="selectionSupportsAdditionalOptions">
<bit-label>{{ "zipPostalCode" | i18n }}</bit-label>
<input bitInput type="text" formControlName="postalCode" autocomplete="postal-code" />
</bit-form-field>

View File

@ -1,5 +1,6 @@
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { TaxInformation } from "@bitwarden/common/billing/models/domain";
@ -13,8 +14,8 @@ type Country = {
selector: "app-manage-tax-information",
templateUrl: "./manage-tax-information.component.html",
})
export class ManageTaxInformationComponent implements OnInit {
@Input({ required: true }) taxInformation: TaxInformation;
export class ManageTaxInformationComponent implements OnInit, OnDestroy {
@Input() startWith: TaxInformation;
@Input() onSubmit?: (taxInformation: TaxInformation) => Promise<void>;
@Output() taxInformationUpdated = new EventEmitter();
@ -29,35 +30,61 @@ export class ManageTaxInformationComponent implements OnInit {
state: "",
});
private destroy$ = new Subject<void>();
private taxInformation: TaxInformation;
constructor(private formBuilder: FormBuilder) {}
submit = async () => {
await this.onSubmit({
country: this.formGroup.value.country,
postalCode: this.formGroup.value.postalCode,
taxId: this.formGroup.value.taxId,
line1: this.formGroup.value.line1,
line2: this.formGroup.value.line2,
city: this.formGroup.value.city,
state: this.formGroup.value.state,
});
getTaxInformation = (): TaxInformation & { includeTaxId: boolean } => ({
...this.taxInformation,
includeTaxId: this.formGroup.value.includeTaxId,
});
submit = async () => {
this.formGroup.markAllAsTouched();
if (this.formGroup.invalid) {
return;
}
await this.onSubmit(this.taxInformation);
this.taxInformationUpdated.emit();
};
touch = (): boolean => {
this.formGroup.markAllAsTouched();
return this.formGroup.valid;
};
async ngOnInit() {
if (this.taxInformation) {
if (this.startWith) {
this.formGroup.patchValue({
...this.taxInformation,
...this.startWith,
includeTaxId:
this.countrySupportsTax(this.taxInformation.country) &&
(!!this.taxInformation.taxId ||
!!this.taxInformation.line1 ||
!!this.taxInformation.line2 ||
!!this.taxInformation.city ||
!!this.taxInformation.state),
this.countrySupportsTax(this.startWith.country) &&
(!!this.startWith.taxId ||
!!this.startWith.line1 ||
!!this.startWith.line2 ||
!!this.startWith.city ||
!!this.startWith.state),
});
}
this.formGroup.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((values) => {
this.taxInformation = {
country: values.country,
postalCode: values.postalCode,
taxId: values.taxId,
line1: values.line1,
line2: values.line2,
city: values.city,
state: values.state,
};
});
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
protected countrySupportsTax(countryCode: string) {

View File

@ -27,9 +27,21 @@ export class AnonLayoutWrapperComponent {
private route: ActivatedRoute,
private i18nService: I18nService,
) {
this.pageTitle = this.i18nService.t(this.route.snapshot.firstChild.data["pageTitle"]);
this.pageSubtitle = this.i18nService.t(this.route.snapshot.firstChild.data["pageSubtitle"]);
this.pageIcon = this.route.snapshot.firstChild.data["pageIcon"];
this.showReadonlyHostname = this.route.snapshot.firstChild.data["showReadonlyHostname"];
const routeData = this.route.snapshot.firstChild?.data;
if (!routeData) {
return;
}
if (routeData["pageTitle"] !== undefined) {
this.pageTitle = this.i18nService.t(routeData["pageTitle"]);
}
if (routeData["pageSubtitle"] !== undefined) {
this.pageSubtitle = this.i18nService.t(routeData["pageSubtitle"]);
}
this.pageIcon = routeData["pageIcon"];
this.showReadonlyHostname = routeData["showReadonlyHostname"];
}
}

View File

@ -4,9 +4,9 @@ import { PolicyService } from "../../../admin-console/abstractions/policy/policy
import { PolicyType } from "../../../admin-console/enums";
import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { distinctIfShallowMatch, reduceCollection } from "../../rx";
import { GeneratorNavigationService } from "../abstractions/generator-navigation.service.abstraction";
import { GENERATOR_SETTINGS } from "../key-definitions";
import { distinctIfShallowMatch, reduceCollection } from "../rx-operators";
import { DefaultGeneratorNavigation, GeneratorNavigation } from "./generator-navigation";
import { GeneratorNavigationEvaluator } from "./generator-navigation-evaluator";

View File

@ -1,45 +1,10 @@
import { distinctUntilChanged, map, OperatorFunction, pipe } from "rxjs";
import { map, pipe } from "rxjs";
import { reduceCollection, distinctIfShallowMatch } from "@bitwarden/common/tools/rx";
import { DefaultPolicyEvaluator } from "./default-policy-evaluator";
import { PolicyConfiguration } from "./policies";
/**
* An observable operator that reduces an emitted collection to a single object,
* returning a default if all items are ignored.
* @param reduce The reduce function to apply to the filtered collection. The
* first argument is the accumulator, and the second is the current item. The
* return value is the new accumulator.
* @param defaultValue The default value to return if the collection is empty. The
* default value is also the initial value of the accumulator.
*/
export function reduceCollection<Item, Accumulator>(
reduce: (acc: Accumulator, value: Item) => Accumulator,
defaultValue: Accumulator,
): OperatorFunction<Item[], Accumulator> {
return map((values: Item[]) => {
const reduced = (values ?? []).reduce(reduce, structuredClone(defaultValue));
return reduced;
});
}
/**
* An observable operator that emits distinct values by checking that all
* values in the previous entry match the next entry. This method emits
* when a key is added and does not when a key is removed.
* @remarks This method checks objects. It does not check items in arrays.
*/
export function distinctIfShallowMatch<Item>(): OperatorFunction<Item, Item> {
return distinctUntilChanged((previous, current) => {
let isDistinct = true;
for (const key in current) {
isDistinct &&= previous[key] === current[key];
}
return isDistinct;
});
}
/** Maps an administrative console policy to a policy evaluator using the provided configuration.
* @param configuration the configuration that constructs the evaluator.
*/

View File

@ -2,12 +2,11 @@
* include structuredClone in test environment.
* @jest-environment ../../../../shared/test.environment.ts
*/
import { of, firstValueFrom } from "rxjs";
import { awaitAsync, trackEmissions } from "../../../spec";
import { awaitAsync, trackEmissions } from "../../spec";
import { distinctIfShallowMatch, reduceCollection } from "./rx-operators";
import { distinctIfShallowMatch, reduceCollection } from "./rx";
describe("reduceCollection", () => {
it.each([[null], [undefined], [[]]])(

View File

@ -0,0 +1,38 @@
import { map, distinctUntilChanged, OperatorFunction } from "rxjs";
/**
* An observable operator that reduces an emitted collection to a single object,
* returning a default if all items are ignored.
* @param reduce The reduce function to apply to the filtered collection. The
* first argument is the accumulator, and the second is the current item. The
* return value is the new accumulator.
* @param defaultValue The default value to return if the collection is empty. The
* default value is also the initial value of the accumulator.
*/
export function reduceCollection<Item, Accumulator>(
reduce: (acc: Accumulator, value: Item) => Accumulator,
defaultValue: Accumulator,
): OperatorFunction<Item[], Accumulator> {
return map((values: Item[]) => {
const reduced = (values ?? []).reduce(reduce, structuredClone(defaultValue));
return reduced;
});
}
/**
* An observable operator that emits distinct values by checking that all
* values in the previous entry match the next entry. This method emits
* when a key is added and does not when a key is removed.
* @remarks This method checks objects. It does not check items in arrays.
*/
export function distinctIfShallowMatch<Item>(): OperatorFunction<Item, Item> {
return distinctUntilChanged((previous, current) => {
let isDistinct = true;
for (const key in current) {
isDistinct &&= previous[key] === current[key];
}
return isDistinct;
});
}

View File

@ -8,6 +8,6 @@ module.exports = {
preset: "ts-jest",
testEnvironment: "../../../shared/test.environment.ts",
moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, {
prefix: "<rootDir>/../../../",
prefix: "<rootDir>/../../",
}),
};

View File

@ -8,6 +8,6 @@ module.exports = {
preset: "ts-jest",
testEnvironment: "../../../shared/test.environment.ts",
moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, {
prefix: "<rootDir>/../../../",
prefix: "<rootDir>/../../",
}),
};

View File

@ -0,0 +1,42 @@
import { Observable } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy as AdminPolicy } from "@bitwarden/common/admin-console/models/domain/policy";
import { SingleUserState } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { PolicyEvaluator } from "./policy-evaluator.abstraction";
/** Tailors the generator service to generate a specific kind of credentials */
export abstract class GeneratorStrategy<Options, Policy> {
/** Retrieve application state that persists across locks.
* @param userId: identifies the user state to retrieve
* @returns the strategy's durable user state
*/
durableState: (userId: UserId) => SingleUserState<Options>;
/** Gets the default options. */
defaults$: (userId: UserId) => Observable<Options>;
/** Identifies the policy enforced by the generator. */
policy: PolicyType;
/** Operator function that converts a policy collection observable to a single
* policy evaluator observable.
* @param policy The policy being evaluated.
* @returns the policy evaluator. If `policy` is is `null` or `undefined`,
* then the evaluator defaults to the application's limits.
* @throws when the policy's type does not match the generator's policy type.
*/
toEvaluator: () => (
source: Observable<AdminPolicy[]>,
) => Observable<PolicyEvaluator<Policy, Options>>;
/** Generates credentials from the given options.
* @param options The options used to generate the credentials.
* @returns a promise that resolves to the generated credentials.
*/
generate: (options: Options) => Promise<string>;
}

View File

@ -0,0 +1,46 @@
import { Observable } from "rxjs";
import { UserId } from "@bitwarden/common/types/guid";
import { PolicyEvaluator } from "./policy-evaluator.abstraction";
/** Generates credentials used for user authentication
* @typeParam Options the credential generation configuration
* @typeParam Policy the policy enforced by the generator
*/
export abstract class GeneratorService<Options, Policy> {
/** An observable monitoring the options saved to disk.
* The observable updates when the options are saved.
* @param userId: Identifies the user making the request
*/
options$: (userId: UserId) => Observable<Options>;
/** An observable monitoring the options used to enforce policy.
* The observable updates when the policy changes.
* @param userId: Identifies the user making the request
*/
evaluator$: (userId: UserId) => Observable<PolicyEvaluator<Policy, Options>>;
/** Gets the default options. */
defaults$: (userId: UserId) => Observable<Options>;
/** Enforces the policy on the given options
* @param userId: Identifies the user making the request
* @param options the options to enforce the policy on
* @returns a new instance of the options with the policy enforced
*/
enforcePolicy: (userId: UserId, options: Options) => Promise<Options>;
/** Generates credentials
* @param options the options to generate credentials with
* @returns a promise that resolves with the generated credentials
*/
generate: (options: Options) => Promise<string>;
/** Saves the given options to disk.
* @param userId: Identifies the user making the request
* @param options the options to save
* @returns a promise that resolves when the options are saved
*/
saveOptions: (userId: UserId, options: Options) => Promise<void>;
}

View File

@ -0,0 +1,6 @@
export { GeneratorHistoryService } from "../../../extensions/src/history/generator-history.abstraction";
export { GeneratorNavigationService } from "../../../extensions/src/navigation/generator-navigation.service.abstraction";
export { GeneratorService } from "./generator.service.abstraction";
export { GeneratorStrategy } from "./generator-strategy.abstraction";
export { PolicyEvaluator } from "./policy-evaluator.abstraction";
export { Randomizer } from "./randomizer";

View File

@ -0,0 +1,28 @@
/** Applies policy to a generation request */
export abstract class PolicyEvaluator<Policy, PolicyTarget> {
/** The policy to enforce */
policy: Policy;
/** Returns true when a policy is being enforced by the evaluator.
* @remarks `applyPolicy` should be called when a policy is not in
* effect to enforce the application's default policy.
*/
policyInEffect: boolean;
/** Apply policy to a set of options.
* @param options The options to build from. These options are not altered.
* @returns A complete generation request with policy applied.
* @remarks This method only applies policy overrides.
* Pass the result to `sanitize` to ensure consistency.
*/
applyPolicy: (options: PolicyTarget) => PolicyTarget;
/** Ensures internal options consistency.
* @param options The options to cascade. These options are not altered.
* @returns A new generation request with cascade applied.
* @remarks This method fills null and undefined values by looking at
* pairs of flags and values (e.g. `number` and `minNumber`). If the flag
* and value are inconsistent, the flag cascades to the value.
*/
sanitize: (options: PolicyTarget) => PolicyTarget;
}

View File

@ -0,0 +1,39 @@
import { WordOptions } from "../types";
/** Entropy source for credential generation. */
export interface Randomizer {
/** picks a random entry from a list.
* @param list random entry source. This must have at least one entry.
* @returns a promise that resolves with a random entry from the list.
*/
pick<Entry>(list: Array<Entry>): Promise<Entry>;
/** picks a random word from a list.
* @param list random entry source. This must have at least one entry.
* @param options customizes the output word
* @returns a promise that resolves with a random word from the list.
*/
pickWord(list: Array<string>, options?: WordOptions): Promise<string>;
/** Shuffles a list of items
* @param list random entry source. This must have at least two entries.
* @param options.copy shuffles a copy of the input when this is true.
* Defaults to true.
* @returns a promise that resolves with the randomized list.
*/
shuffle<Entry>(items: Array<Entry>): Promise<Array<Entry>>;
/** Generates a string containing random lowercase ASCII characters and numbers.
* @param length the number of characters to generate
* @returns a promise that resolves with the randomized string.
*/
chars(length: number): Promise<string>;
/** Selects an integer value from a range by randomly choosing it from
* a uniform distribution.
* @param min the minimum value in the range, inclusive.
* @param max the minimum value in the range, inclusive.
* @returns a promise that resolves with the randomized string.
*/
uniform(min: number, max: number): Promise<number>;
}

View File

@ -0,0 +1,8 @@
import { EmailDomainOptions, SelfHostedApiOptions } from "../types";
export const DefaultAddyIoOptions: SelfHostedApiOptions & EmailDomainOptions = Object.freeze({
website: null,
baseUrl: "https://app.addy.io",
token: "",
domain: "",
});

View File

@ -0,0 +1,8 @@
import { CatchallGenerationOptions } from "../types";
/** The default options for catchall address generation. */
export const DefaultCatchallOptions: CatchallGenerationOptions = Object.freeze({
catchallType: "random",
catchallDomain: "",
website: null,
});

View File

@ -0,0 +1,6 @@
import { ApiOptions } from "../types";
export const DefaultDuckDuckGoOptions: ApiOptions = Object.freeze({
website: null,
token: "",
});

View File

@ -0,0 +1,8 @@
import { EffUsernameGenerationOptions } from "../types";
/** The default options for EFF long word generation. */
export const DefaultEffUsernameOptions: EffUsernameGenerationOptions = Object.freeze({
wordCapitalize: false,
wordIncludeNumber: false,
website: null,
});

View File

@ -0,0 +1,8 @@
import { ApiOptions, EmailPrefixOptions } from "../types";
export const DefaultFastmailOptions: ApiOptions & EmailPrefixOptions = Object.freeze({
website: "",
domain: "",
prefix: "",
token: "",
});

View File

@ -0,0 +1,6 @@
import { ApiOptions } from "../types";
export const DefaultFirefoxRelayOptions: ApiOptions = Object.freeze({
website: null,
token: "",
});

View File

@ -0,0 +1,7 @@
import { ApiOptions, EmailDomainOptions } from "../types";
export const DefaultForwardEmailOptions: ApiOptions & EmailDomainOptions = Object.freeze({
website: null,
token: "",
domain: "",
});

View File

@ -0,0 +1,15 @@
function initializeBoundaries() {
const numWords = Object.freeze({
min: 3,
max: 20,
});
return Object.freeze({
numWords,
});
}
/** Immutable default boundaries for passphrase generation.
* These are used when the policy does not override a value.
*/
export const DefaultPassphraseBoundaries = initializeBoundaries();

View File

@ -0,0 +1,10 @@
import { PassphraseGenerationOptions } from "../types";
/** The default options for passphrase generation. */
export const DefaultPassphraseGenerationOptions: Partial<PassphraseGenerationOptions> =
Object.freeze({
numWords: 3,
wordSeparator: "-",
capitalize: false,
includeNumber: false,
});

View File

@ -0,0 +1,27 @@
function initializeBoundaries() {
const length = Object.freeze({
min: 5,
max: 128,
});
const minDigits = Object.freeze({
min: 0,
max: 9,
});
const minSpecialCharacters = Object.freeze({
min: 0,
max: 9,
});
return Object.freeze({
length,
minDigits,
minSpecialCharacters,
});
}
/** Immutable default boundaries for password generation.
* These are used when the policy does not override a value.
*/
export const DefaultPasswordBoundaries = initializeBoundaries();

View File

@ -0,0 +1,16 @@
import { PasswordGenerationOptions } from "../types";
import { DefaultPasswordBoundaries } from "./default-password-boundaries";
/** The default options for password generation. */
export const DefaultPasswordGenerationOptions: Partial<PasswordGenerationOptions> = Object.freeze({
length: 14,
minLength: DefaultPasswordBoundaries.length.min,
ambiguous: true,
uppercase: true,
lowercase: true,
number: true,
minNumber: 1,
special: false,
minSpecial: 0,
});

View File

@ -0,0 +1,7 @@
import { SelfHostedApiOptions } from "../types";
export const DefaultSimpleLoginOptions: SelfHostedApiOptions = Object.freeze({
website: null,
baseUrl: "https://app.simplelogin.io",
token: "",
});

View File

@ -0,0 +1,8 @@
import { SubaddressGenerationOptions } from "../types";
/** The default options for email subaddress generation. */
export const DefaultSubaddressOptions: SubaddressGenerationOptions = Object.freeze({
subaddressType: "random",
subaddressEmail: "",
website: null,
});

View File

@ -0,0 +1,8 @@
import { PassphraseGeneratorPolicy } from "../types";
/** The default options for password generation policy. */
export const DisabledPassphraseGeneratorPolicy: PassphraseGeneratorPolicy = Object.freeze({
minNumberWords: 0,
capitalize: false,
includeNumber: false,
});

View File

@ -0,0 +1,12 @@
import { PasswordGeneratorPolicy } from "../types";
/** The default options for password generation policy. */
export const DisabledPasswordGeneratorPolicy: PasswordGeneratorPolicy = Object.freeze({
minLength: 0,
useUppercase: false,
useLowercase: false,
useNumbers: false,
numberCount: 0,
useSpecial: false,
specialCount: 0,
});

View File

@ -0,0 +1,49 @@
import { ForwarderMetadata } from "../types";
/** Metadata about an email forwarding service.
* @remarks This is used to populate the forwarder selection list
* and to identify forwarding services in error messages.
*/
export const Forwarders = Object.freeze({
/** For https://addy.io/ */
AddyIo: Object.freeze({
id: "anonaddy",
name: "Addy.io",
validForSelfHosted: true,
} as ForwarderMetadata),
/** For https://duckduckgo.com/email/ */
DuckDuckGo: Object.freeze({
id: "duckduckgo",
name: "DuckDuckGo",
validForSelfHosted: false,
} as ForwarderMetadata),
/** For https://www.fastmail.com. */
Fastmail: Object.freeze({
id: "fastmail",
name: "Fastmail",
validForSelfHosted: true,
} as ForwarderMetadata),
/** For https://relay.firefox.com/ */
FirefoxRelay: Object.freeze({
id: "firefoxrelay",
name: "Firefox Relay",
validForSelfHosted: false,
} as ForwarderMetadata),
/** For https://forwardemail.net/ */
ForwardEmail: Object.freeze({
id: "forwardemail",
name: "Forward Email",
validForSelfHosted: true,
} as ForwarderMetadata),
/** For https://simplelogin.io/ */
SimpleLogin: Object.freeze({
id: "simplelogin",
name: "SimpleLogin",
validForSelfHosted: true,
} as ForwarderMetadata),
});

View File

@ -0,0 +1,17 @@
export * from "./default-addy-io-options";
export * from "./default-catchall-options";
export * from "./default-duck-duck-go-options";
export * from "./default-fastmail-options";
export * from "./default-forward-email-options";
export * from "./default-passphrase-boundaries";
export * from "./default-password-boundaries";
export * from "./default-eff-username-options";
export * from "./default-firefox-relay-options";
export * from "./default-passphrase-generation-options";
export * from "./default-password-generation-options";
export * from "./default-subaddress-generator-options";
export * from "./default-simple-login-options";
export * from "./disabled-passphrase-generator-policy";
export * from "./disabled-password-generator-policy";
export * from "./forwarders";
export * from "./policies";

View File

@ -0,0 +1,29 @@
import { DisabledPassphraseGeneratorPolicy, DisabledPasswordGeneratorPolicy } from "../data";
import {
passphraseLeastPrivilege,
passwordLeastPrivilege,
PassphraseGeneratorOptionsEvaluator,
PasswordGeneratorOptionsEvaluator,
} from "../policies";
import { PassphraseGeneratorPolicy, PasswordGeneratorPolicy, PolicyConfiguration } from "../types";
const PASSPHRASE = Object.freeze({
disabledValue: DisabledPassphraseGeneratorPolicy,
combine: passphraseLeastPrivilege,
createEvaluator: (policy) => new PassphraseGeneratorOptionsEvaluator(policy),
} as PolicyConfiguration<PassphraseGeneratorPolicy, PassphraseGeneratorOptionsEvaluator>);
const PASSWORD = Object.freeze({
disabledValue: DisabledPasswordGeneratorPolicy,
combine: passwordLeastPrivilege,
createEvaluator: (policy) => new PasswordGeneratorOptionsEvaluator(policy),
} as PolicyConfiguration<PasswordGeneratorPolicy, PasswordGeneratorOptionsEvaluator>);
/** Policy configurations */
export const Policies = Object.freeze({
/** Passphrase policy configuration */
Passphrase: PASSPHRASE,
/** Passphrase policy configuration */
Password: PASSWORD,
});

View File

@ -0,0 +1,62 @@
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { Randomizer } from "../abstractions";
import { WordOptions } from "../types";
/** A randomizer backed by a CryptoService. */
export class CryptoServiceRandomizer implements Randomizer {
constructor(private crypto: CryptoService) {}
async pick<Entry>(list: Array<Entry>) {
const index = await this.uniform(0, list.length - 1);
return list[index];
}
async pickWord(list: Array<string>, options?: WordOptions) {
let word = await this.pick(list);
if (options?.titleCase ?? false) {
word = word.charAt(0).toUpperCase() + word.slice(1);
}
if (options?.number ?? false) {
const num = await this.crypto.randomNumber(1, 9999);
word = word + this.zeroPad(num.toString(), 4);
}
return word;
}
// ref: https://stackoverflow.com/a/12646864/1090359
async shuffle<T>(items: Array<T>, options?: { copy?: boolean }) {
const shuffled = options?.copy ?? true ? [...items] : items;
for (let i = items.length - 1; i > 0; i--) {
const j = await this.uniform(0, i);
[items[i], items[j]] = [items[j], items[i]];
}
return shuffled;
}
async chars(length: number) {
let str = "";
const charSet = "abcdefghijklmnopqrstuvwxyz1234567890";
for (let i = 0; i < length; i++) {
const randomCharIndex = await this.uniform(0, charSet.length - 1);
str += charSet.charAt(randomCharIndex);
}
return str;
}
async uniform(min: number, max: number) {
return this.crypto.randomNumber(min, max);
}
// ref: https://stackoverflow.com/a/10073788
private zeroPad(number: string, width: number) {
return number.length >= width
? number
: new Array(width - number.length + 1).join("0") + number;
}
}

View File

@ -0,0 +1 @@
export { CryptoServiceRandomizer } from "./crypto-service-randomizer";

View File

@ -0,0 +1,11 @@
// contains logic that constructs generator services dynamically given
// a generator id.
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { Randomizer } from "./abstractions";
import { CryptoServiceRandomizer } from "./engine/crypto-service-randomizer";
export function createRandomizer(cryptoService: CryptoService): Randomizer {
return new CryptoServiceRandomizer(cryptoService);
}

View File

@ -0,0 +1,9 @@
export * from "./abstractions";
export * from "./data";
export { createRandomizer } from "./factories";
export * as engine from "./engine";
export * as policies from "./policies";
export * as rx from "./rx";
export * as services from "./services";
export * as strategies from "./strategies";
export * from "./types";

View File

@ -0,0 +1,43 @@
import { DefaultPolicyEvaluator } from "./default-policy-evaluator";
describe("Password generator options builder", () => {
describe("policy", () => {
it("should return an empty object", () => {
const builder = new DefaultPolicyEvaluator();
expect(builder.policy).toEqual({});
});
});
describe("policyInEffect", () => {
it("should return false", () => {
const builder = new DefaultPolicyEvaluator();
expect(builder.policyInEffect).toEqual(false);
});
});
describe("applyPolicy(options)", () => {
// All tests should freeze the options to ensure they are not modified
it("should return the input operations without altering them", () => {
const builder = new DefaultPolicyEvaluator();
const options = Object.freeze({});
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions).toEqual(options);
});
});
describe("sanitize(options)", () => {
// All tests should freeze the options to ensure they are not modified
it("should return the input options without altering them", () => {
const builder = new DefaultPolicyEvaluator();
const options = Object.freeze({});
const sanitizedOptions = builder.sanitize(options);
expect(sanitizedOptions).toEqual(options);
});
});
});

View File

@ -0,0 +1,27 @@
import { PolicyEvaluator } from "../abstractions";
import { NoPolicy } from "../types";
/** A policy evaluator that does not apply any policy */
export class DefaultPolicyEvaluator<PolicyTarget>
implements PolicyEvaluator<NoPolicy, PolicyTarget>
{
/** {@link PolicyEvaluator.policy} */
get policy() {
return {};
}
/** {@link PolicyEvaluator.policyInEffect} */
get policyInEffect() {
return false;
}
/** {@link PolicyEvaluator.applyPolicy} */
applyPolicy(options: PolicyTarget) {
return options;
}
/** {@link PolicyEvaluator.sanitize} */
sanitize(options: PolicyTarget) {
return options;
}
}

View File

@ -0,0 +1,5 @@
export { DefaultPolicyEvaluator } from "./default-policy-evaluator";
export { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator";
export { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator";
export { passphraseLeastPrivilege } from "./passphrase-least-privilege";
export { passwordLeastPrivilege } from "./password-least-privilege";

View File

@ -0,0 +1,260 @@
import { DisabledPassphraseGeneratorPolicy, DefaultPassphraseBoundaries } from "../data";
import { PassphraseGenerationOptions } from "../types";
import { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator";
describe("Password generator options builder", () => {
describe("constructor()", () => {
it("should set the policy object to a copy of the input policy", () => {
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
policy.minNumberWords = 10; // arbitrary change for deep equality check
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
expect(builder.policy).toEqual(policy);
expect(builder.policy).not.toBe(policy);
});
it("should set default boundaries when a default policy is used", () => {
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
expect(builder.numWords).toEqual(DefaultPassphraseBoundaries.numWords);
});
it.each([1, 2])(
"should use the default word boundaries when they are greater than `policy.minNumberWords` (= %i)",
(minNumberWords) => {
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
policy.minNumberWords = minNumberWords;
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
expect(builder.numWords).toEqual(DefaultPassphraseBoundaries.numWords);
},
);
it.each([8, 12, 18])(
"should use `policy.minNumberWords` (= %i) when it is greater than the default minimum words",
(minNumberWords) => {
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
policy.minNumberWords = minNumberWords;
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
expect(builder.numWords.min).toEqual(minNumberWords);
expect(builder.numWords.max).toEqual(DefaultPassphraseBoundaries.numWords.max);
},
);
it.each([150, 300, 9000])(
"should use `policy.minNumberWords` (= %i) when it is greater than the default boundaries",
(minNumberWords) => {
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
policy.minNumberWords = minNumberWords;
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
expect(builder.numWords.min).toEqual(minNumberWords);
expect(builder.numWords.max).toEqual(minNumberWords);
},
);
});
describe("policyInEffect", () => {
it("should return false when the policy has no effect", () => {
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
expect(builder.policyInEffect).toEqual(false);
});
it("should return true when the policy has a numWords greater than the default boundary", () => {
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
policy.minNumberWords = DefaultPassphraseBoundaries.numWords.min + 1;
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
expect(builder.policyInEffect).toEqual(true);
});
it("should return true when the policy has capitalize enabled", () => {
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
policy.capitalize = true;
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
expect(builder.policyInEffect).toEqual(true);
});
it("should return true when the policy has includeNumber enabled", () => {
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
policy.includeNumber = true;
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
expect(builder.policyInEffect).toEqual(true);
});
});
describe("applyPolicy(options)", () => {
// All tests should freeze the options to ensure they are not modified
it("should set `capitalize` to `false` when the policy does not override it", () => {
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
const options = Object.freeze({});
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.capitalize).toBe(false);
});
it("should set `capitalize` to `true` when the policy overrides it", () => {
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
policy.capitalize = true;
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ capitalize: false });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.capitalize).toBe(true);
});
it("should set `includeNumber` to false when the policy does not override it", () => {
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
const options = Object.freeze({});
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.includeNumber).toBe(false);
});
it("should set `includeNumber` to true when the policy overrides it", () => {
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
policy.includeNumber = true;
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ includeNumber: false });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.includeNumber).toBe(true);
});
it("should set `numWords` to the minimum value when it isn't supplied", () => {
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
const options = Object.freeze({});
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.numWords).toBe(builder.numWords.min);
});
it.each([1, 2])(
"should set `numWords` (= %i) to the minimum value when it is less than the minimum",
(numWords) => {
expect(numWords).toBeLessThan(DefaultPassphraseBoundaries.numWords.min);
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ numWords });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.numWords).toBe(builder.numWords.min);
},
);
it.each([3, 8, 18, 20])(
"should set `numWords` (= %i) to the input value when it is within the boundaries",
(numWords) => {
expect(numWords).toBeGreaterThanOrEqual(DefaultPassphraseBoundaries.numWords.min);
expect(numWords).toBeLessThanOrEqual(DefaultPassphraseBoundaries.numWords.max);
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ numWords });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.numWords).toBe(numWords);
},
);
it.each([21, 30, 50, 100])(
"should set `numWords` (= %i) to the maximum value when it is greater than the maximum",
(numWords) => {
expect(numWords).toBeGreaterThan(DefaultPassphraseBoundaries.numWords.max);
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ numWords });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.numWords).toBe(builder.numWords.max);
},
);
it("should preserve unknown properties", () => {
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
const options = Object.freeze({
unknown: "property",
another: "unknown property",
}) as PassphraseGenerationOptions;
const sanitizedOptions: any = builder.applyPolicy(options);
expect(sanitizedOptions.unknown).toEqual("property");
expect(sanitizedOptions.another).toEqual("unknown property");
});
});
describe("sanitize(options)", () => {
// All tests should freeze the options to ensure they are not modified
it("should return the input options without altering them", () => {
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ wordSeparator: "%" });
const sanitizedOptions = builder.sanitize(options);
expect(sanitizedOptions).toEqual(options);
});
it("should set `wordSeparator` to '-' when it isn't supplied and there is no policy override", () => {
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
const options = Object.freeze({});
const sanitizedOptions = builder.sanitize(options);
expect(sanitizedOptions.wordSeparator).toEqual("-");
});
it("should leave `wordSeparator` as the empty string '' when it is the empty string", () => {
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ wordSeparator: "" });
const sanitizedOptions = builder.sanitize(options);
expect(sanitizedOptions.wordSeparator).toEqual("");
});
it("should preserve unknown properties", () => {
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
const options = Object.freeze({
unknown: "property",
another: "unknown property",
}) as PassphraseGenerationOptions;
const sanitizedOptions: any = builder.sanitize(options);
expect(sanitizedOptions.unknown).toEqual("property");
expect(sanitizedOptions.another).toEqual("unknown property");
});
});
});

View File

@ -0,0 +1,100 @@
import { PolicyEvaluator } from "../abstractions";
import { DefaultPassphraseGenerationOptions, DefaultPassphraseBoundaries } from "../data";
import { Boundary, PassphraseGenerationOptions, PassphraseGeneratorPolicy } from "../types";
/** Enforces policy for passphrase generation options.
*/
export class PassphraseGeneratorOptionsEvaluator
implements PolicyEvaluator<PassphraseGeneratorPolicy, PassphraseGenerationOptions>
{
// This design is not ideal, but it is a step towards a more robust passphrase
// generator. Ideally, `sanitize` would be implemented on an options class,
// and `applyPolicy` would be implemented on a policy class, "mise en place".
//
// The current design of the passphrase generator, unfortunately, would require
// a substantial rewrite to make this feasible. Hopefully this change can be
// applied when the passphrase generator is ported to rust.
/** Policy applied by the evaluator.
*/
readonly policy: PassphraseGeneratorPolicy;
/** Boundaries for the number of words allowed in the password.
*/
readonly numWords: Boundary;
/** Instantiates the evaluator.
* @param policy The policy applied by the evaluator. When this conflicts with
* the defaults, the policy takes precedence.
*/
constructor(policy: PassphraseGeneratorPolicy) {
function createBoundary(value: number, defaultBoundary: Boundary): Boundary {
const boundary = {
min: Math.max(defaultBoundary.min, value),
max: Math.max(defaultBoundary.max, value),
};
return boundary;
}
this.policy = structuredClone(policy);
this.numWords = createBoundary(policy.minNumberWords, DefaultPassphraseBoundaries.numWords);
}
/** {@link PolicyEvaluator.policyInEffect} */
get policyInEffect(): boolean {
const policies = [
this.policy.capitalize,
this.policy.includeNumber,
this.policy.minNumberWords > DefaultPassphraseBoundaries.numWords.min,
];
return policies.includes(true);
}
/** Apply policy to the input options.
* @param options The options to build from. These options are not altered.
* @returns A new password generation request with policy applied.
*/
applyPolicy(options: PassphraseGenerationOptions): PassphraseGenerationOptions {
function fitToBounds(value: number, boundaries: Boundary) {
const { min, max } = boundaries;
const withUpperBound = Math.min(value ?? boundaries.min, max);
const withLowerBound = Math.max(withUpperBound, min);
return withLowerBound;
}
// apply policy overrides
const capitalize = this.policy.capitalize || options.capitalize || false;
const includeNumber = this.policy.includeNumber || options.includeNumber || false;
// apply boundaries
const numWords = fitToBounds(options.numWords, this.numWords);
return {
...options,
numWords,
capitalize,
includeNumber,
};
}
/** Ensures internal options consistency.
* @param options The options to cascade. These options are not altered.
* @returns A passphrase generation request with cascade applied.
*/
sanitize(options: PassphraseGenerationOptions): PassphraseGenerationOptions {
// ensure words are separated by a single character or the empty string
const wordSeparator =
options.wordSeparator === ""
? ""
: options.wordSeparator?.[0] ?? DefaultPassphraseGenerationOptions.wordSeparator;
return {
...options,
wordSeparator,
};
}
}

View File

@ -0,0 +1,53 @@
import { PolicyType } from "@bitwarden/common/admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { PolicyId } from "@bitwarden/common/types/guid";
import { DisabledPassphraseGeneratorPolicy } from "../data";
import { passphraseLeastPrivilege } from "./passphrase-least-privilege";
function createPolicy(
data: any,
type: PolicyType = PolicyType.PasswordGenerator,
enabled: boolean = true,
) {
return new Policy({
id: "id" as PolicyId,
organizationId: "organizationId",
data,
enabled,
type,
});
}
describe("passphraseLeastPrivilege", () => {
it("should return the accumulator when the policy type does not apply", () => {
const policy = createPolicy({}, PolicyType.RequireSso);
const result = passphraseLeastPrivilege(DisabledPassphraseGeneratorPolicy, policy);
expect(result).toEqual(DisabledPassphraseGeneratorPolicy);
});
it("should return the accumulator when the policy is not enabled", () => {
const policy = createPolicy({}, PolicyType.PasswordGenerator, false);
const result = passphraseLeastPrivilege(DisabledPassphraseGeneratorPolicy, policy);
expect(result).toEqual(DisabledPassphraseGeneratorPolicy);
});
it.each([
["minNumberWords", 10],
["capitalize", true],
["includeNumber", true],
])("should take the %p from the policy", (input, value) => {
const policy = createPolicy({ [input]: value });
const result = passphraseLeastPrivilege(DisabledPassphraseGeneratorPolicy, policy);
expect(result).toEqual({ ...DisabledPassphraseGeneratorPolicy, [input]: value });
});
});

View File

@ -0,0 +1,27 @@
import { PolicyType } from "@bitwarden/common/admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { PassphraseGeneratorPolicy } from "../types";
/** Reduces a policy into an accumulator by accepting the most restrictive
* values from each policy.
* @param acc the accumulator
* @param policy the policy to reduce
* @returns the most restrictive values between the policy and accumulator.
*/
export function passphraseLeastPrivilege(
acc: PassphraseGeneratorPolicy,
policy: Policy,
): PassphraseGeneratorPolicy {
if (policy.type !== PolicyType.PasswordGenerator) {
return acc;
}
return {
minNumberWords: Math.max(acc.minNumberWords, policy.data.minNumberWords ?? acc.minNumberWords),
capitalize: policy.data.capitalize || acc.capitalize,
includeNumber: policy.data.includeNumber || acc.includeNumber,
};
}

View File

@ -0,0 +1,765 @@
import { DefaultPasswordBoundaries, DisabledPasswordGeneratorPolicy } from "../data";
import { PasswordGenerationOptions } from "../types";
import { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator";
describe("Password generator options builder", () => {
const defaultOptions = Object.freeze({ minLength: 0 });
describe("constructor()", () => {
it("should set the policy object to a copy of the input policy", () => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
policy.minLength = 10; // arbitrary change for deep equality check
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(builder.policy).toEqual(policy);
expect(builder.policy).not.toBe(policy);
});
it("should set default boundaries when a default policy is used", () => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(builder.length).toEqual(DefaultPasswordBoundaries.length);
expect(builder.minDigits).toEqual(DefaultPasswordBoundaries.minDigits);
expect(builder.minSpecialCharacters).toEqual(DefaultPasswordBoundaries.minSpecialCharacters);
});
it.each([1, 2, 3, 4])(
"should use the default length boundaries when they are greater than `policy.minLength` (= %i)",
(minLength) => {
expect(minLength).toBeLessThan(DefaultPasswordBoundaries.length.min);
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
policy.minLength = minLength;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(builder.length).toEqual(DefaultPasswordBoundaries.length);
},
);
it.each([8, 20, 100])(
"should use `policy.minLength` (= %i) when it is greater than the default minimum length",
(expectedLength) => {
expect(expectedLength).toBeGreaterThan(DefaultPasswordBoundaries.length.min);
expect(expectedLength).toBeLessThanOrEqual(DefaultPasswordBoundaries.length.max);
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
policy.minLength = expectedLength;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(builder.length.min).toEqual(expectedLength);
expect(builder.length.max).toEqual(DefaultPasswordBoundaries.length.max);
},
);
it.each([150, 300, 9000])(
"should use `policy.minLength` (= %i) when it is greater than the default boundaries",
(expectedLength) => {
expect(expectedLength).toBeGreaterThan(DefaultPasswordBoundaries.length.max);
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
policy.minLength = expectedLength;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(builder.length.min).toEqual(expectedLength);
expect(builder.length.max).toEqual(expectedLength);
},
);
it.each([3, 5, 8, 9])(
"should use `policy.numberCount` (= %i) when it is greater than the default minimum digits",
(expectedMinDigits) => {
expect(expectedMinDigits).toBeGreaterThan(DefaultPasswordBoundaries.minDigits.min);
expect(expectedMinDigits).toBeLessThanOrEqual(DefaultPasswordBoundaries.minDigits.max);
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
policy.numberCount = expectedMinDigits;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(builder.minDigits.min).toEqual(expectedMinDigits);
expect(builder.minDigits.max).toEqual(DefaultPasswordBoundaries.minDigits.max);
},
);
it.each([10, 20, 400])(
"should use `policy.numberCount` (= %i) when it is greater than the default digit boundaries",
(expectedMinDigits) => {
expect(expectedMinDigits).toBeGreaterThan(DefaultPasswordBoundaries.minDigits.max);
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
policy.numberCount = expectedMinDigits;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(builder.minDigits.min).toEqual(expectedMinDigits);
expect(builder.minDigits.max).toEqual(expectedMinDigits);
},
);
it.each([2, 4, 6])(
"should use `policy.specialCount` (= %i) when it is greater than the default minimum special characters",
(expectedSpecialCharacters) => {
expect(expectedSpecialCharacters).toBeGreaterThan(
DefaultPasswordBoundaries.minSpecialCharacters.min,
);
expect(expectedSpecialCharacters).toBeLessThanOrEqual(
DefaultPasswordBoundaries.minSpecialCharacters.max,
);
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
policy.specialCount = expectedSpecialCharacters;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(builder.minSpecialCharacters.min).toEqual(expectedSpecialCharacters);
expect(builder.minSpecialCharacters.max).toEqual(
DefaultPasswordBoundaries.minSpecialCharacters.max,
);
},
);
it.each([10, 20, 400])(
"should use `policy.specialCount` (= %i) when it is greater than the default special characters boundaries",
(expectedSpecialCharacters) => {
expect(expectedSpecialCharacters).toBeGreaterThan(
DefaultPasswordBoundaries.minSpecialCharacters.max,
);
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
policy.specialCount = expectedSpecialCharacters;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(builder.minSpecialCharacters.min).toEqual(expectedSpecialCharacters);
expect(builder.minSpecialCharacters.max).toEqual(expectedSpecialCharacters);
},
);
it.each([
[8, 6, 2],
[6, 2, 4],
[16, 8, 8],
])(
"should ensure the minimum length (= %i) is at least the sum of minimums (= %i + %i)",
(expectedLength, numberCount, specialCount) => {
expect(expectedLength).toBeGreaterThanOrEqual(DefaultPasswordBoundaries.length.min);
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
policy.numberCount = numberCount;
policy.specialCount = specialCount;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(builder.length.min).toBeGreaterThanOrEqual(expectedLength);
},
);
});
describe("policyInEffect", () => {
it("should return false when the policy has no effect", () => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(builder.policyInEffect).toEqual(false);
});
it("should return true when the policy has a minlength greater than the default boundary", () => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
policy.minLength = DefaultPasswordBoundaries.length.min + 1;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(builder.policyInEffect).toEqual(true);
});
it("should return true when the policy has a number count greater than the default boundary", () => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
policy.numberCount = DefaultPasswordBoundaries.minDigits.min + 1;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(builder.policyInEffect).toEqual(true);
});
it("should return true when the policy has a special character count greater than the default boundary", () => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
policy.specialCount = DefaultPasswordBoundaries.minSpecialCharacters.min + 1;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(builder.policyInEffect).toEqual(true);
});
it("should return true when the policy has uppercase enabled", () => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
policy.useUppercase = true;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(builder.policyInEffect).toEqual(true);
});
it("should return true when the policy has lowercase enabled", () => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
policy.useLowercase = true;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(builder.policyInEffect).toEqual(true);
});
it("should return true when the policy has numbers enabled", () => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
policy.useNumbers = true;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(builder.policyInEffect).toEqual(true);
});
it("should return true when the policy has special characters enabled", () => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
policy.useSpecial = true;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(builder.policyInEffect).toEqual(true);
});
});
describe("applyPolicy(options)", () => {
// All tests should freeze the options to ensure they are not modified
it.each([
[false, false],
[true, true],
[false, undefined],
])(
"should set `options.uppercase` to '%s' when `policy.useUppercase` is false and `options.uppercase` is '%s'",
(expectedUppercase, uppercase) => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
policy.useUppercase = false;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, uppercase });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.uppercase).toEqual(expectedUppercase);
},
);
it.each([false, true, undefined])(
"should set `options.uppercase` (= %s) to true when `policy.useUppercase` is true",
(uppercase) => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
policy.useUppercase = true;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, uppercase });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.uppercase).toEqual(true);
},
);
it.each([
[false, false],
[true, true],
[false, undefined],
])(
"should set `options.lowercase` to '%s' when `policy.useLowercase` is false and `options.lowercase` is '%s'",
(expectedLowercase, lowercase) => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
policy.useLowercase = false;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, lowercase });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.lowercase).toEqual(expectedLowercase);
},
);
it.each([false, true, undefined])(
"should set `options.lowercase` (= %s) to true when `policy.useLowercase` is true",
(lowercase) => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
policy.useLowercase = true;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, lowercase });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.lowercase).toEqual(true);
},
);
it.each([
[false, false],
[true, true],
[false, undefined],
])(
"should set `options.number` to '%s' when `policy.useNumbers` is false and `options.number` is '%s'",
(expectedNumber, number) => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
policy.useNumbers = false;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, number });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.number).toEqual(expectedNumber);
},
);
it.each([false, true, undefined])(
"should set `options.number` (= %s) to true when `policy.useNumbers` is true",
(number) => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
policy.useNumbers = true;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, number });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.number).toEqual(true);
},
);
it.each([
[false, false],
[true, true],
[false, undefined],
])(
"should set `options.special` to '%s' when `policy.useSpecial` is false and `options.special` is '%s'",
(expectedSpecial, special) => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
policy.useSpecial = false;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, special });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.special).toEqual(expectedSpecial);
},
);
it.each([false, true, undefined])(
"should set `options.special` (= %s) to true when `policy.useSpecial` is true",
(special) => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
policy.useSpecial = true;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, special });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.special).toEqual(true);
},
);
it.each([1, 2, 3, 4])(
"should set `options.length` (= %i) to the minimum it is less than the minimum length",
(length) => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(length).toBeLessThan(builder.length.min);
const options = Object.freeze({ ...defaultOptions, length });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.length).toEqual(builder.length.min);
},
);
it.each([5, 10, 50, 100, 128])(
"should not change `options.length` (= %i) when it is within the boundaries",
(length) => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(length).toBeGreaterThanOrEqual(builder.length.min);
expect(length).toBeLessThanOrEqual(builder.length.max);
const options = Object.freeze({ ...defaultOptions, length });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.length).toEqual(length);
},
);
it.each([129, 500, 9000])(
"should set `options.length` (= %i) to the maximum length when it is exceeded",
(length) => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(length).toBeGreaterThan(builder.length.max);
const options = Object.freeze({ ...defaultOptions, length });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.length).toEqual(builder.length.max);
},
);
it.each([
[true, 1],
[true, 3],
[true, 600],
[false, 0],
[false, -2],
[false, -600],
])(
"should set `options.number === %s` when `options.minNumber` (= %i) is set to a value greater than 0",
(expectedNumber, minNumber) => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, minNumber });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.number).toEqual(expectedNumber);
},
);
it("should set `options.minNumber` to the minimum value when `options.number` is true", () => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, number: true });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.minNumber).toEqual(builder.minDigits.min);
});
it("should set `options.minNumber` to 0 when `options.number` is false", () => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, number: false });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.minNumber).toEqual(0);
});
it.each([1, 2, 3, 4])(
"should set `options.minNumber` (= %i) to the minimum it is less than the minimum number",
(minNumber) => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
policy.numberCount = 5; // arbitrary value greater than minNumber
expect(minNumber).toBeLessThan(policy.numberCount);
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, minNumber });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.minNumber).toEqual(builder.minDigits.min);
},
);
it.each([1, 3, 5, 7, 9])(
"should not change `options.minNumber` (= %i) when it is within the boundaries",
(minNumber) => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(minNumber).toBeGreaterThanOrEqual(builder.minDigits.min);
expect(minNumber).toBeLessThanOrEqual(builder.minDigits.max);
const options = Object.freeze({ ...defaultOptions, minNumber });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.minNumber).toEqual(minNumber);
},
);
it.each([10, 20, 400])(
"should set `options.minNumber` (= %i) to the maximum digit boundary when it is exceeded",
(minNumber) => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(minNumber).toBeGreaterThan(builder.minDigits.max);
const options = Object.freeze({ ...defaultOptions, minNumber });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.minNumber).toEqual(builder.minDigits.max);
},
);
it.each([
[true, 1],
[true, 3],
[true, 600],
[false, 0],
[false, -2],
[false, -600],
])(
"should set `options.special === %s` when `options.minSpecial` (= %i) is set to a value greater than 0",
(expectedSpecial, minSpecial) => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, minSpecial });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.special).toEqual(expectedSpecial);
},
);
it("should set `options.minSpecial` to the minimum value when `options.special` is true", () => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, special: true });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.minSpecial).toEqual(builder.minDigits.min);
});
it("should set `options.minSpecial` to 0 when `options.special` is false", () => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, special: false });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.minSpecial).toEqual(0);
});
it.each([1, 2, 3, 4])(
"should set `options.minSpecial` (= %i) to the minimum it is less than the minimum special characters",
(minSpecial) => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
policy.specialCount = 5; // arbitrary value greater than minSpecial
expect(minSpecial).toBeLessThan(policy.specialCount);
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, minSpecial });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.minSpecial).toEqual(builder.minSpecialCharacters.min);
},
);
it.each([1, 3, 5, 7, 9])(
"should not change `options.minSpecial` (= %i) when it is within the boundaries",
(minSpecial) => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(minSpecial).toBeGreaterThanOrEqual(builder.minSpecialCharacters.min);
expect(minSpecial).toBeLessThanOrEqual(builder.minSpecialCharacters.max);
const options = Object.freeze({ ...defaultOptions, minSpecial });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.minSpecial).toEqual(minSpecial);
},
);
it.each([10, 20, 400])(
"should set `options.minSpecial` (= %i) to the maximum special character boundary when it is exceeded",
(minSpecial) => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(minSpecial).toBeGreaterThan(builder.minSpecialCharacters.max);
const options = Object.freeze({ ...defaultOptions, minSpecial });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.minSpecial).toEqual(builder.minSpecialCharacters.max);
},
);
it("should preserve unknown properties", () => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({
unknown: "property",
another: "unknown property",
}) as PasswordGenerationOptions;
const sanitizedOptions: any = builder.applyPolicy(options);
expect(sanitizedOptions.unknown).toEqual("property");
expect(sanitizedOptions.another).toEqual("unknown property");
});
});
describe("sanitize(options)", () => {
// All tests should freeze the options to ensure they are not modified
it.each([
[1, true],
[0, false],
])(
"should output `options.minLowercase === %i` when `options.lowercase` is %s",
(expectedMinLowercase, lowercase) => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ lowercase, ...defaultOptions });
const actual = builder.sanitize(options);
expect(actual.minLowercase).toEqual(expectedMinLowercase);
},
);
it.each([
[1, true],
[0, false],
])(
"should output `options.minUppercase === %i` when `options.uppercase` is %s",
(expectedMinUppercase, uppercase) => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ uppercase, ...defaultOptions });
const actual = builder.sanitize(options);
expect(actual.minUppercase).toEqual(expectedMinUppercase);
},
);
it.each([
[1, true],
[0, false],
])(
"should output `options.minNumber === %i` when `options.number` is %s and `options.minNumber` is not set",
(expectedMinNumber, number) => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ number, ...defaultOptions });
const actual = builder.sanitize(options);
expect(actual.minNumber).toEqual(expectedMinNumber);
},
);
it.each([
[true, 3],
[true, 2],
[true, 1],
[false, 0],
])(
"should output `options.number === %s` when `options.minNumber` is %i and `options.number` is not set",
(expectedNumber, minNumber) => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ minNumber, ...defaultOptions });
const actual = builder.sanitize(options);
expect(actual.number).toEqual(expectedNumber);
},
);
it.each([
[true, 1],
[false, 0],
])(
"should output `options.minSpecial === %i` when `options.special` is %s and `options.minSpecial` is not set",
(special, expectedMinSpecial) => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ special, ...defaultOptions });
const actual = builder.sanitize(options);
expect(actual.minSpecial).toEqual(expectedMinSpecial);
},
);
it.each([
[3, true],
[2, true],
[1, true],
[0, false],
])(
"should output `options.special === %s` when `options.minSpecial` is %i and `options.special` is not set",
(minSpecial, expectedSpecial) => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ minSpecial, ...defaultOptions });
const actual = builder.sanitize(options);
expect(actual.special).toEqual(expectedSpecial);
},
);
it.each([
[0, 0, 0, 0],
[1, 1, 0, 0],
[0, 0, 1, 1],
[1, 1, 1, 1],
])(
"should set `options.minLength` to the minimum boundary when the sum of minimums (%i + %i + %i + %i) is less than the default minimum length.",
(minLowercase, minUppercase, minNumber, minSpecial) => {
const sumOfMinimums = minLowercase + minUppercase + minNumber + minSpecial;
expect(sumOfMinimums).toBeLessThan(DefaultPasswordBoundaries.length.min);
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({
minLowercase,
minUppercase,
minNumber,
minSpecial,
...defaultOptions,
});
const actual = builder.sanitize(options);
expect(actual.minLength).toEqual(builder.length.min);
},
);
it.each([
[12, 3, 3, 3, 3],
[8, 2, 2, 2, 2],
[9, 3, 3, 3, 0],
])(
"should set `options.minLength === %i` to the sum of minimums (%i + %i + %i + %i) when the sum is at least the default minimum length.",
(expectedMinLength, minLowercase, minUppercase, minNumber, minSpecial) => {
expect(expectedMinLength).toBeGreaterThanOrEqual(DefaultPasswordBoundaries.length.min);
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({
minLowercase,
minUppercase,
minNumber,
minSpecial,
...defaultOptions,
});
const actual = builder.sanitize(options);
expect(actual.minLength).toEqual(expectedMinLength);
},
);
it("should preserve unknown properties", () => {
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({
unknown: "property",
another: "unknown property",
}) as PasswordGenerationOptions;
const sanitizedOptions: any = builder.sanitize(options);
expect(sanitizedOptions.unknown).toEqual("property");
expect(sanitizedOptions.another).toEqual("unknown property");
});
});
});

View File

@ -0,0 +1,157 @@
import { PolicyEvaluator } from "../abstractions";
import { DefaultPasswordBoundaries } from "../data";
import { Boundary, PasswordGeneratorPolicy, PasswordGenerationOptions } from "../types";
/** Enforces policy for password generation.
*/
export class PasswordGeneratorOptionsEvaluator
implements PolicyEvaluator<PasswordGeneratorPolicy, PasswordGenerationOptions>
{
// This design is not ideal, but it is a step towards a more robust password
// generator. Ideally, `sanitize` would be implemented on an options class,
// and `applyPolicy` would be implemented on a policy class, "mise en place".
//
// The current design of the password generator, unfortunately, would require
// a substantial rewrite to make this feasible. Hopefully this change can be
// applied when the password generator is ported to rust.
/** Boundaries for the password length. This is always large enough
* to accommodate the minimum number of digits and special characters.
*/
readonly length: Boundary;
/** Boundaries for the minimum number of digits allowed in the password.
*/
readonly minDigits: Boundary;
/** Boundaries for the minimum number of special characters allowed
* in the password.
*/
readonly minSpecialCharacters: Boundary;
/** Policy applied by the evaluator.
*/
readonly policy: PasswordGeneratorPolicy;
/** Instantiates the evaluator.
* @param policy The policy applied by the evaluator. When this conflicts with
* the defaults, the policy takes precedence.
*/
constructor(policy: PasswordGeneratorPolicy) {
function createBoundary(value: number, defaultBoundary: Boundary): Boundary {
const boundary = {
min: Math.max(defaultBoundary.min, value),
max: Math.max(defaultBoundary.max, value),
};
return boundary;
}
this.policy = structuredClone(policy);
this.minDigits = createBoundary(policy.numberCount, DefaultPasswordBoundaries.minDigits);
this.minSpecialCharacters = createBoundary(
policy.specialCount,
DefaultPasswordBoundaries.minSpecialCharacters,
);
// the overall length should be at least as long as the sum of the minimums
const minConsistentLength = this.minDigits.min + this.minSpecialCharacters.min;
const minPolicyLength =
policy.minLength > 0 ? policy.minLength : DefaultPasswordBoundaries.length.min;
const minLength = Math.max(
minPolicyLength,
minConsistentLength,
DefaultPasswordBoundaries.length.min,
);
this.length = {
min: minLength,
max: Math.max(DefaultPasswordBoundaries.length.max, minLength),
};
}
/** {@link PolicyEvaluator.policyInEffect} */
get policyInEffect(): boolean {
const policies = [
this.policy.useUppercase,
this.policy.useLowercase,
this.policy.useNumbers,
this.policy.useSpecial,
this.policy.minLength > DefaultPasswordBoundaries.length.min,
this.policy.numberCount > DefaultPasswordBoundaries.minDigits.min,
this.policy.specialCount > DefaultPasswordBoundaries.minSpecialCharacters.min,
];
return policies.includes(true);
}
/** {@link PolicyEvaluator.applyPolicy} */
applyPolicy(options: PasswordGenerationOptions): PasswordGenerationOptions {
function fitToBounds(value: number, boundaries: Boundary) {
const { min, max } = boundaries;
const withUpperBound = Math.min(value || 0, max);
const withLowerBound = Math.max(withUpperBound, min);
return withLowerBound;
}
// apply policy overrides
const uppercase = this.policy.useUppercase || options.uppercase || false;
const lowercase = this.policy.useLowercase || options.lowercase || false;
// these overrides can cascade numeric fields to boolean fields
const number = this.policy.useNumbers || options.number || options.minNumber > 0;
const special = this.policy.useSpecial || options.special || options.minSpecial > 0;
// apply boundaries; the boundaries can cascade boolean fields to numeric fields
const length = fitToBounds(options.length, this.length);
const minNumber = fitToBounds(options.minNumber, this.minDigits);
const minSpecial = fitToBounds(options.minSpecial, this.minSpecialCharacters);
return {
...options,
length,
uppercase,
lowercase,
number,
minNumber,
special,
minSpecial,
};
}
/** {@link PolicyEvaluator.sanitize} */
sanitize(options: PasswordGenerationOptions): PasswordGenerationOptions {
function cascade(enabled: boolean, value: number): [boolean, number] {
const enabledResult = enabled ?? value > 0;
const valueResult = enabledResult ? value || 1 : 0;
return [enabledResult, valueResult];
}
const [lowercase, minLowercase] = cascade(options.lowercase, options.minLowercase);
const [uppercase, minUppercase] = cascade(options.uppercase, options.minUppercase);
const [number, minNumber] = cascade(options.number, options.minNumber);
const [special, minSpecial] = cascade(options.special, options.minSpecial);
// minimums can only increase the length
const minConsistentLength = minLowercase + minUppercase + minNumber + minSpecial;
const minLength = Math.max(minConsistentLength, this.length.min);
const length = Math.max(options.length ?? minLength, minLength);
return {
...options,
length,
minLength,
lowercase,
minLowercase,
uppercase,
minUppercase,
number,
minNumber,
special,
minSpecial,
};
}
}

View File

@ -0,0 +1,57 @@
import { PolicyType } from "@bitwarden/common/admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { PolicyId } from "@bitwarden/common/types/guid";
import { DisabledPasswordGeneratorPolicy } from "../data";
import { passwordLeastPrivilege } from "./password-least-privilege";
function createPolicy(
data: any,
type: PolicyType = PolicyType.PasswordGenerator,
enabled: boolean = true,
) {
return new Policy({
id: "id" as PolicyId,
organizationId: "organizationId",
data,
enabled,
type,
});
}
describe("passwordLeastPrivilege", () => {
it("should return the accumulator when the policy type does not apply", () => {
const policy = createPolicy({}, PolicyType.RequireSso);
const result = passwordLeastPrivilege(DisabledPasswordGeneratorPolicy, policy);
expect(result).toEqual(DisabledPasswordGeneratorPolicy);
});
it("should return the accumulator when the policy is not enabled", () => {
const policy = createPolicy({}, PolicyType.PasswordGenerator, false);
const result = passwordLeastPrivilege(DisabledPasswordGeneratorPolicy, policy);
expect(result).toEqual(DisabledPasswordGeneratorPolicy);
});
it.each([
["minLength", 10, "minLength"],
["useUpper", true, "useUppercase"],
["useLower", true, "useLowercase"],
["useNumbers", true, "useNumbers"],
["minNumbers", 10, "numberCount"],
["useSpecial", true, "useSpecial"],
["minSpecial", 10, "specialCount"],
])("should take the %p from the policy", (input, value, expected) => {
const policy = createPolicy({ [input]: value });
const result = passwordLeastPrivilege(DisabledPasswordGeneratorPolicy, policy);
expect(result).toEqual({ ...DisabledPasswordGeneratorPolicy, [expected]: value });
});
});

View File

@ -0,0 +1,28 @@
import { PolicyType } from "@bitwarden/common/admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { PasswordGeneratorPolicy } from "../types";
/** Reduces a policy into an accumulator by accepting the most restrictive
* values from each policy.
* @param acc the accumulator
* @param policy the policy to reduce
* @returns the most restrictive values between the policy and accumulator.
*/
export function passwordLeastPrivilege(acc: PasswordGeneratorPolicy, policy: Policy) {
if (policy.type !== PolicyType.PasswordGenerator || !policy.enabled) {
return acc;
}
return {
minLength: Math.max(acc.minLength, policy.data.minLength ?? acc.minLength),
useUppercase: policy.data.useUpper || acc.useUppercase,
useLowercase: policy.data.useLower || acc.useLowercase,
useNumbers: policy.data.useNumbers || acc.useNumbers,
numberCount: Math.max(acc.numberCount, policy.data.minNumbers ?? acc.numberCount),
useSpecial: policy.data.useSpecial || acc.useSpecial,
specialCount: Math.max(acc.specialCount, policy.data.minSpecial ?? acc.specialCount),
};
}

View File

@ -0,0 +1,26 @@
import { map, pipe } from "rxjs";
import { reduceCollection, distinctIfShallowMatch } from "@bitwarden/common/tools/rx";
import { DefaultPolicyEvaluator } from "./policies";
import { PolicyConfiguration } from "./types";
/** Maps an administrative console policy to a policy evaluator using the provided configuration.
* @param configuration the configuration that constructs the evaluator.
*/
export function mapPolicyToEvaluator<Policy, Evaluator>(
configuration: PolicyConfiguration<Policy, Evaluator>,
) {
return pipe(
reduceCollection(configuration.combine, configuration.disabledValue),
distinctIfShallowMatch(),
map(configuration.createEvaluator),
);
}
/** Constructs a method that maps a policy to the default (no-op) policy. */
export function newDefaultEvaluator<Target>() {
return () => {
return pipe(map((_) => new DefaultPolicyEvaluator<Target>()));
};
}

View File

@ -0,0 +1,194 @@
import { mock } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom, map, pipe } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { SingleUserState } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { FakeSingleUserState, awaitAsync } from "../../../../../common/spec";
import { GeneratorStrategy, PolicyEvaluator } from "../abstractions";
import { PasswordGenerationOptions } from "../types";
import { DefaultGeneratorService } from "./default-generator.service";
function mockPolicyService(config?: { state?: BehaviorSubject<Policy[]> }) {
const service = mock<PolicyService>();
const stateValue = config?.state ?? new BehaviorSubject<Policy[]>([null]);
service.getAll$.mockReturnValue(stateValue);
return service;
}
function mockGeneratorStrategy(config?: {
userState?: SingleUserState<any>;
policy?: PolicyType;
evaluator?: any;
defaults?: any;
}) {
const durableState =
config?.userState ?? new FakeSingleUserState<PasswordGenerationOptions>(SomeUser);
const strategy = mock<GeneratorStrategy<any, any>>({
// intentionally arbitrary so that tests that need to check
// whether they're used properly are guaranteed to test
// the value from `config`.
durableState: jest.fn(() => durableState),
defaults$: jest.fn(() => new BehaviorSubject(config?.defaults)),
policy: config?.policy ?? PolicyType.DisableSend,
toEvaluator: jest.fn(() =>
pipe(map(() => config?.evaluator ?? mock<PolicyEvaluator<any, any>>())),
),
});
return strategy;
}
const SomeUser = "some user" as UserId;
const AnotherUser = "another user" as UserId;
describe("Password generator service", () => {
describe("options$", () => {
it("should retrieve durable state from the service", () => {
const policy = mockPolicyService();
const userState = new FakeSingleUserState<PasswordGenerationOptions>(SomeUser);
const strategy = mockGeneratorStrategy({ userState });
const service = new DefaultGeneratorService(strategy, policy);
const result = service.options$(SomeUser);
expect(strategy.durableState).toHaveBeenCalledWith(SomeUser);
expect(result).toBe(userState.state$);
});
});
describe("defaults$", () => {
it("should retrieve default state from the service", async () => {
const policy = mockPolicyService();
const defaults = {};
const strategy = mockGeneratorStrategy({ defaults });
const service = new DefaultGeneratorService(strategy, policy);
const result = await firstValueFrom(service.defaults$(SomeUser));
expect(strategy.defaults$).toHaveBeenCalledWith(SomeUser);
expect(result).toBe(defaults);
});
});
describe("saveOptions()", () => {
it("should trigger an options$ update", async () => {
const policy = mockPolicyService();
const userState = new FakeSingleUserState<PasswordGenerationOptions>(SomeUser, { length: 9 });
const strategy = mockGeneratorStrategy({ userState });
const service = new DefaultGeneratorService(strategy, policy);
await service.saveOptions(SomeUser, { length: 10 });
await awaitAsync();
const options = await firstValueFrom(service.options$(SomeUser));
expect(strategy.durableState).toHaveBeenCalledWith(SomeUser);
expect(options).toEqual({ length: 10 });
});
});
describe("evaluator$", () => {
it("should initialize the password generator policy", async () => {
const policy = mockPolicyService();
const strategy = mockGeneratorStrategy({ policy: PolicyType.PasswordGenerator });
const service = new DefaultGeneratorService(strategy, policy);
await firstValueFrom(service.evaluator$(SomeUser));
expect(policy.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser);
});
it("should map the policy using the generation strategy", async () => {
const policyService = mockPolicyService();
const evaluator = mock<PolicyEvaluator<any, any>>();
const strategy = mockGeneratorStrategy({ evaluator });
const service = new DefaultGeneratorService(strategy, policyService);
const policy = await firstValueFrom(service.evaluator$(SomeUser));
expect(policy).toBe(evaluator);
});
it("should update the evaluator when the password generator policy changes", async () => {
// set up dependencies
const state = new BehaviorSubject<Policy[]>([null]);
const policy = mockPolicyService({ state });
const strategy = mockGeneratorStrategy();
const service = new DefaultGeneratorService(strategy, policy);
// model responses for the observable update. The map is called multiple times,
// and the array shift ensures reference equality is maintained.
const firstEvaluator = mock<PolicyEvaluator<any, any>>();
const secondEvaluator = mock<PolicyEvaluator<any, any>>();
const evaluators = [firstEvaluator, secondEvaluator];
strategy.toEvaluator.mockReturnValueOnce(pipe(map(() => evaluators.shift())));
// act
const evaluator$ = service.evaluator$(SomeUser);
const firstResult = await firstValueFrom(evaluator$);
state.next([null]);
const secondResult = await firstValueFrom(evaluator$);
// assert
expect(firstResult).toBe(firstEvaluator);
expect(secondResult).toBe(secondEvaluator);
});
it("should cache the password generator policy", async () => {
const policy = mockPolicyService();
const strategy = mockGeneratorStrategy({ policy: PolicyType.PasswordGenerator });
const service = new DefaultGeneratorService(strategy, policy);
await firstValueFrom(service.evaluator$(SomeUser));
await firstValueFrom(service.evaluator$(SomeUser));
expect(policy.getAll$).toHaveBeenCalledTimes(1);
});
it("should cache the password generator policy for each user", async () => {
const policy = mockPolicyService();
const strategy = mockGeneratorStrategy({ policy: PolicyType.PasswordGenerator });
const service = new DefaultGeneratorService(strategy, policy);
await firstValueFrom(service.evaluator$(SomeUser));
await firstValueFrom(service.evaluator$(AnotherUser));
expect(policy.getAll$).toHaveBeenNthCalledWith(1, PolicyType.PasswordGenerator, SomeUser);
expect(policy.getAll$).toHaveBeenNthCalledWith(2, PolicyType.PasswordGenerator, AnotherUser);
});
});
describe("enforcePolicy()", () => {
it("should evaluate the policy using the generation strategy", async () => {
const policy = mockPolicyService();
const evaluator = mock<PolicyEvaluator<any, any>>();
const strategy = mockGeneratorStrategy({ evaluator });
const service = new DefaultGeneratorService(strategy, policy);
await service.enforcePolicy(SomeUser, {});
expect(evaluator.applyPolicy).toHaveBeenCalled();
expect(evaluator.sanitize).toHaveBeenCalled();
});
});
describe("generate()", () => {
it("should invoke the generation strategy", async () => {
const strategy = mockGeneratorStrategy();
const policy = mockPolicyService();
const service = new DefaultGeneratorService(strategy, policy);
await service.generate({});
expect(strategy.generate).toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,96 @@
import { firstValueFrom, share, timer, ReplaySubject, Observable } from "rxjs";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { UserId } from "@bitwarden/common/types/guid";
import { GeneratorStrategy, GeneratorService, PolicyEvaluator } from "../abstractions";
type DefaultGeneratorServiceTuning = {
/* amount of time to keep the most recent policy after a subscription ends. Once the
* cache expires, the ignoreQty and timeoutMs settings apply to the next lookup.
*/
policyCacheMs: number;
};
/** {@link GeneratorServiceAbstraction} */
export class DefaultGeneratorService<Options, Policy> implements GeneratorService<Options, Policy> {
/** Instantiates the generator service
* @param strategy tailors the service to a specific generator type
* (e.g. password, passphrase)
* @param policy provides the policy to enforce
*/
constructor(
private strategy: GeneratorStrategy<Options, Policy>,
private policy: PolicyService,
tuning: Partial<DefaultGeneratorServiceTuning> = {},
) {
this.tuning = Object.assign(
{
// a minute
policyCacheMs: 60000,
},
tuning,
);
}
private tuning: DefaultGeneratorServiceTuning;
private _evaluators$ = new Map<UserId, Observable<PolicyEvaluator<Policy, Options>>>();
/** {@link GeneratorService.options$} */
options$(userId: UserId) {
return this.strategy.durableState(userId).state$;
}
/** {@link GeneratorService.defaults$} */
defaults$(userId: UserId) {
return this.strategy.defaults$(userId);
}
/** {@link GeneratorService.saveOptions} */
async saveOptions(userId: UserId, options: Options): Promise<void> {
await this.strategy.durableState(userId).update(() => options);
}
/** {@link GeneratorService.evaluator$} */
evaluator$(userId: UserId) {
let evaluator$ = this._evaluators$.get(userId);
if (!evaluator$) {
evaluator$ = this.createEvaluator(userId);
this._evaluators$.set(userId, evaluator$);
}
return evaluator$;
}
private createEvaluator(userId: UserId) {
const evaluator$ = this.policy.getAll$(this.strategy.policy, userId).pipe(
// create the evaluator from the policies
this.strategy.toEvaluator(),
// cache evaluator in a replay subject to amortize creation cost
// and reduce GC pressure.
share({
connector: () => new ReplaySubject(1),
resetOnRefCountZero: () => timer(this.tuning.policyCacheMs),
}),
);
return evaluator$;
}
/** {@link GeneratorService.enforcePolicy} */
async enforcePolicy(userId: UserId, options: Options): Promise<Options> {
const policy = await firstValueFrom(this.evaluator$(userId));
const evaluated = policy.applyPolicy(options);
const sanitized = policy.sanitize(evaluated);
return sanitized;
}
/** {@link GeneratorService.generate} */
async generate(options: Options): Promise<string> {
return await this.strategy.generate(options);
}
}

View File

@ -0,0 +1 @@
export { DefaultGeneratorService } from "./default-generator.service";

View File

@ -0,0 +1,75 @@
import { mock } from "jest-mock-extended";
import { of, firstValueFrom } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { Randomizer } from "../abstractions";
import { DefaultCatchallOptions } from "../data";
import { DefaultPolicyEvaluator } from "../policies";
import { CatchallGeneratorStrategy } from "./catchall-generator-strategy";
import { CATCHALL_SETTINGS } from "./storage";
const SomeUser = "some user" as UserId;
const SomePolicy = mock<Policy>({
type: PolicyType.PasswordGenerator,
data: {
minLength: 10,
},
});
describe("Email subaddress list generation strategy", () => {
describe("toEvaluator()", () => {
it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])(
"should map any input (= %p) to the default policy evaluator",
async (policies) => {
const strategy = new CatchallGeneratorStrategy(null, null);
const evaluator$ = of(policies).pipe(strategy.toEvaluator());
const evaluator = await firstValueFrom(evaluator$);
expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator);
},
);
});
describe("durableState", () => {
it("should use password settings key", () => {
const provider = mock<StateProvider>();
const randomizer = mock<Randomizer>();
const strategy = new CatchallGeneratorStrategy(randomizer, provider);
strategy.durableState(SomeUser);
expect(provider.getUser).toHaveBeenCalledWith(SomeUser, CATCHALL_SETTINGS);
});
});
describe("defaults$", () => {
it("should return the default subaddress options", async () => {
const strategy = new CatchallGeneratorStrategy(null, null);
const result = await firstValueFrom(strategy.defaults$(SomeUser));
expect(result).toEqual(DefaultCatchallOptions);
});
});
describe("policy", () => {
it("should use password generator policy", () => {
const randomizer = mock<Randomizer>();
const strategy = new CatchallGeneratorStrategy(randomizer, null);
expect(strategy.policy).toBe(PolicyType.PasswordGenerator);
});
});
describe("generate()", () => {
it.todo("generate catchall email addresses");
});
});

View File

@ -0,0 +1,50 @@
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { StateProvider } from "@bitwarden/common/platform/state";
import { GeneratorStrategy, Randomizer } from "../abstractions";
import { DefaultCatchallOptions } from "../data";
import { newDefaultEvaluator } from "../rx";
import { NoPolicy, CatchallGenerationOptions } from "../types";
import { clone$PerUserId, sharedStateByUserId } from "../util";
import { CATCHALL_SETTINGS } from "./storage";
/** Strategy for creating usernames using a catchall email address */
export class CatchallGeneratorStrategy
implements GeneratorStrategy<CatchallGenerationOptions, NoPolicy>
{
/** Instantiates the generation strategy
* @param usernameService generates a catchall address for a domain
*/
constructor(
private random: Randomizer,
private stateProvider: StateProvider,
private defaultOptions: CatchallGenerationOptions = DefaultCatchallOptions,
) {}
// configuration
durableState = sharedStateByUserId(CATCHALL_SETTINGS, this.stateProvider);
defaults$ = clone$PerUserId(this.defaultOptions);
toEvaluator = newDefaultEvaluator<CatchallGenerationOptions>();
readonly policy = PolicyType.PasswordGenerator;
// algorithm
async generate(options: CatchallGenerationOptions) {
const o = Object.assign({}, DefaultCatchallOptions, options);
if (o.catchallDomain == null || o.catchallDomain === "") {
return null;
}
if (o.catchallType == null) {
o.catchallType = "random";
}
let startString = "";
if (o.catchallType === "random") {
startString = await this.random.chars(8);
} else if (o.catchallType === "website-name") {
startString = o.website;
}
return startString + "@" + o.catchallDomain;
}
}

View File

@ -0,0 +1,75 @@
import { mock } from "jest-mock-extended";
import { of, firstValueFrom } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { Randomizer } from "../abstractions";
import { DefaultEffUsernameOptions } from "../data";
import { DefaultPolicyEvaluator } from "../policies";
import { EffUsernameGeneratorStrategy } from "./eff-username-generator-strategy";
import { EFF_USERNAME_SETTINGS } from "./storage";
const SomeUser = "some user" as UserId;
const SomePolicy = mock<Policy>({
type: PolicyType.PasswordGenerator,
data: {
minLength: 10,
},
});
describe("EFF long word list generation strategy", () => {
describe("toEvaluator()", () => {
it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])(
"should map any input (= %p) to the default policy evaluator",
async (policies) => {
const strategy = new EffUsernameGeneratorStrategy(null, null);
const evaluator$ = of(policies).pipe(strategy.toEvaluator());
const evaluator = await firstValueFrom(evaluator$);
expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator);
},
);
});
describe("durableState", () => {
it("should use password settings key", () => {
const provider = mock<StateProvider>();
const randomizer = mock<Randomizer>();
const strategy = new EffUsernameGeneratorStrategy(randomizer, provider);
strategy.durableState(SomeUser);
expect(provider.getUser).toHaveBeenCalledWith(SomeUser, EFF_USERNAME_SETTINGS);
});
});
describe("defaults$", () => {
it("should return the default subaddress options", async () => {
const strategy = new EffUsernameGeneratorStrategy(null, null);
const result = await firstValueFrom(strategy.defaults$(SomeUser));
expect(result).toEqual(DefaultEffUsernameOptions);
});
});
describe("policy", () => {
it("should use password generator policy", () => {
const randomizer = mock<Randomizer>();
const strategy = new EffUsernameGeneratorStrategy(randomizer, null);
expect(strategy.policy).toBe(PolicyType.PasswordGenerator);
});
});
describe("generate()", () => {
it.todo("generate username tests");
});
});

View File

@ -0,0 +1,40 @@
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist";
import { StateProvider } from "@bitwarden/common/platform/state";
import { GeneratorStrategy, Randomizer } from "../abstractions";
import { DefaultEffUsernameOptions } from "../data";
import { newDefaultEvaluator } from "../rx";
import { EffUsernameGenerationOptions, NoPolicy } from "../types";
import { clone$PerUserId, sharedStateByUserId } from "../util";
import { EFF_USERNAME_SETTINGS } from "./storage";
/** Strategy for creating usernames from the EFF wordlist */
export class EffUsernameGeneratorStrategy
implements GeneratorStrategy<EffUsernameGenerationOptions, NoPolicy>
{
/** Instantiates the generation strategy
* @param usernameService generates a username from EFF word list
*/
constructor(
private random: Randomizer,
private stateProvider: StateProvider,
private defaultOptions: EffUsernameGenerationOptions = DefaultEffUsernameOptions,
) {}
// configuration
durableState = sharedStateByUserId(EFF_USERNAME_SETTINGS, this.stateProvider);
defaults$ = clone$PerUserId(this.defaultOptions);
toEvaluator = newDefaultEvaluator<EffUsernameGenerationOptions>();
readonly policy = PolicyType.PasswordGenerator;
// algorithm
async generate(options: EffUsernameGenerationOptions) {
const word = await this.random.pickWord(EFFLongWordList, {
titleCase: options.wordCapitalize ?? DefaultEffUsernameOptions.wordCapitalize,
number: options.wordIncludeNumber ?? DefaultEffUsernameOptions.wordIncludeNumber,
});
return word;
}
}

View File

@ -0,0 +1,110 @@
import { mock } from "jest-mock-extended";
import { of, firstValueFrom } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { StateProvider } from "@bitwarden/common/platform/state";
import { BufferedState } from "@bitwarden/common/tools/state/buffered-state";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
import { FakeStateProvider, mockAccountServiceWith } from "../../../../../common/spec";
import { DefaultDuckDuckGoOptions } from "../data";
import { DefaultPolicyEvaluator } from "../policies";
import { ApiOptions } from "../types";
import { ForwarderGeneratorStrategy } from "./forwarder-generator-strategy";
import { DUCK_DUCK_GO_FORWARDER, DUCK_DUCK_GO_BUFFER } from "./storage";
class TestForwarder extends ForwarderGeneratorStrategy<ApiOptions> {
constructor(
encryptService: EncryptService,
keyService: CryptoService,
stateProvider: StateProvider,
) {
super(encryptService, keyService, stateProvider, { website: null, token: "" });
}
get key() {
// arbitrary.
return DUCK_DUCK_GO_FORWARDER;
}
get rolloverKey() {
return DUCK_DUCK_GO_BUFFER;
}
defaults$ = (userId: UserId) => {
return of(DefaultDuckDuckGoOptions);
};
}
const SomeUser = "some user" as UserId;
const AnotherUser = "another user" as UserId;
const SomePolicy = mock<Policy>({
type: PolicyType.PasswordGenerator,
data: {
minLength: 10,
},
});
describe("ForwarderGeneratorStrategy", () => {
const encryptService = mock<EncryptService>();
const keyService = mock<CryptoService>();
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
beforeEach(() => {
const keyAvailable = of({} as UserKey);
keyService.getInMemoryUserKeyFor$.mockReturnValue(keyAvailable);
});
afterEach(() => {
jest.resetAllMocks();
});
describe("durableState", () => {
it("constructs a secret state", () => {
const strategy = new TestForwarder(encryptService, keyService, stateProvider);
const result = strategy.durableState(SomeUser);
expect(result).toBeInstanceOf(BufferedState);
});
it("returns the same secret state for a single user", () => {
const strategy = new TestForwarder(encryptService, keyService, stateProvider);
const firstResult = strategy.durableState(SomeUser);
const secondResult = strategy.durableState(SomeUser);
expect(firstResult).toBe(secondResult);
});
it("returns a different secret state for a different user", () => {
const strategy = new TestForwarder(encryptService, keyService, stateProvider);
const firstResult = strategy.durableState(SomeUser);
const secondResult = strategy.durableState(AnotherUser);
expect(firstResult).not.toBe(secondResult);
});
});
describe("toEvaluator()", () => {
it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])(
"should map any input (= %p) to the default policy evaluator",
async (policies) => {
const strategy = new TestForwarder(encryptService, keyService, stateProvider);
const evaluator$ = of(policies).pipe(strategy.toEvaluator());
const evaluator = await firstValueFrom(evaluator$);
expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator);
},
);
});
});

View File

@ -0,0 +1,95 @@
import { map } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import {
SingleUserState,
StateProvider,
UserKeyDefinition,
} from "@bitwarden/common/platform/state";
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
import { BufferedState } from "@bitwarden/common/tools/state/buffered-state";
import { PaddedDataPacker } from "@bitwarden/common/tools/state/padded-data-packer";
import { SecretClassifier } from "@bitwarden/common/tools/state/secret-classifier";
import { SecretKeyDefinition } from "@bitwarden/common/tools/state/secret-key-definition";
import { SecretState } from "@bitwarden/common/tools/state/secret-state";
import { UserKeyEncryptor } from "@bitwarden/common/tools/state/user-key-encryptor";
import { UserId } from "@bitwarden/common/types/guid";
import { GeneratorStrategy } from "../abstractions";
import { newDefaultEvaluator } from "../rx";
import { ApiOptions, NoPolicy } from "../types";
import { clone$PerUserId, sharedByUserId } from "../util";
const OPTIONS_FRAME_SIZE = 512;
/** An email forwarding service configurable through an API. */
export abstract class ForwarderGeneratorStrategy<
Options extends ApiOptions,
> extends GeneratorStrategy<Options, NoPolicy> {
/** Initializes the generator strategy
* @param encryptService protects sensitive forwarder options
* @param keyService looks up the user key when protecting data.
* @param stateProvider creates the durable state for options storage
*/
constructor(
private readonly encryptService: EncryptService,
private readonly keyService: CryptoService,
private stateProvider: StateProvider,
private readonly defaultOptions: Options,
) {
super();
}
/** configures forwarder secret storage */
protected abstract readonly key: UserKeyDefinition<Options>;
/** configures forwarder import buffer */
protected abstract readonly rolloverKey: BufferedKeyDefinition<Options, Options>;
// configuration
readonly policy = PolicyType.PasswordGenerator;
defaults$ = clone$PerUserId(this.defaultOptions);
toEvaluator = newDefaultEvaluator<Options>();
durableState = sharedByUserId((userId) => this.getUserSecrets(userId));
// per-user encrypted state
private getUserSecrets(userId: UserId): SingleUserState<Options> {
// construct the encryptor
const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE);
const encryptor = new UserKeyEncryptor(this.encryptService, this.keyService, packer);
// always exclude request properties
const classifier = SecretClassifier.allSecret<Options>().exclude("website");
// Derive the secret key definition
const key = SecretKeyDefinition.value(this.key.stateDefinition, this.key.key, classifier, {
deserializer: (d) => this.key.deserializer(d),
cleanupDelayMs: this.key.cleanupDelayMs,
clearOn: this.key.clearOn,
});
// the type parameter is explicit because type inference fails for `Omit<Options, "website">`
const secretState = SecretState.from<
Options,
void,
Options,
Record<keyof Options, never>,
Omit<Options, "website">
>(userId, key, this.stateProvider, encryptor);
// rollover should occur once the user key is available for decryption
const canDecrypt$ = this.keyService
.getInMemoryUserKeyFor$(userId)
.pipe(map((key) => key !== null));
const rolloverState = new BufferedState(
this.stateProvider,
this.rolloverKey,
secretState,
canDecrypt$,
);
return rolloverState;
}
}

View File

@ -0,0 +1,233 @@
import { firstValueFrom } from "rxjs";
import { UserId } from "@bitwarden/common/types/guid";
import { Forwarders, DefaultAddyIoOptions } from "../../data";
import { ADDY_IO_FORWARDER } from "../storage";
import { AddyIoForwarder } from "./addy-io";
import { mockApiService, mockI18nService } from "./mocks.jest";
const SomeUser = "some user" as UserId;
describe("Addy.io Forwarder", () => {
it("key returns the Addy IO forwarder key", () => {
const forwarder = new AddyIoForwarder(null, null, null, null, null);
expect(forwarder.key).toBe(ADDY_IO_FORWARDER);
});
describe("defaults$", () => {
it("should return the default subaddress options", async () => {
const strategy = new AddyIoForwarder(null, null, null, null, null);
const result = await firstValueFrom(strategy.defaults$(SomeUser));
expect(result).toEqual(DefaultAddyIoOptions);
});
});
describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => {
it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token,
domain: "example.com",
baseUrl: "https://api.example.com",
}),
).rejects.toEqual("forwaderInvalidToken");
expect(apiService.nativeFetch).not.toHaveBeenCalled();
expect(i18nService.t).toHaveBeenCalledWith("forwaderInvalidToken", Forwarders.AddyIo.name);
});
it.each([null, ""])(
"throws an error if the domain is missing (domain = %p)",
async (domain) => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token: "token",
domain,
baseUrl: "https://api.example.com",
}),
).rejects.toEqual("forwarderNoDomain");
expect(apiService.nativeFetch).not.toHaveBeenCalled();
expect(i18nService.t).toHaveBeenCalledWith("forwarderNoDomain", Forwarders.AddyIo.name);
},
);
it.each([null, ""])(
"throws an error if the baseUrl is missing (baseUrl = %p)",
async (baseUrl) => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
baseUrl,
}),
).rejects.toEqual("forwarderNoUrl");
expect(apiService.nativeFetch).not.toHaveBeenCalled();
expect(i18nService.t).toHaveBeenCalledWith("forwarderNoUrl", Forwarders.AddyIo.name);
},
);
it.each([
["forwarderGeneratedByWithWebsite", "provided", "bitwarden.com", "bitwarden.com"],
["forwarderGeneratedByWithWebsite", "provided", "httpbin.org", "httpbin.org"],
["forwarderGeneratedBy", "not provided", null, ""],
["forwarderGeneratedBy", "not provided", "", ""],
])(
"describes the website with %p when the website is %s (= %p)",
async (translationKey, _ignored, website, expectedWebsite) => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null);
await forwarder.generate({
website,
token: "token",
domain: "example.com",
baseUrl: "https://api.example.com",
});
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
expect(i18nService.t).toHaveBeenCalledWith(translationKey, expectedWebsite);
},
);
it.each([
["jane.doe@example.com", 201],
["john.doe@example.com", 201],
["jane.doe@example.com", 200],
["john.doe@example.com", 200],
])(
"returns the generated email address (= %p) if the request is successful (status = %p)",
async (email, status) => {
const apiService = mockApiService(status, { data: { email } });
const i18nService = mockI18nService();
const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null);
const result = await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
baseUrl: "https://api.example.com",
});
expect(result).toEqual(email);
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
},
);
it("throws an invalid token error if the request fails with a 401", async () => {
const apiService = mockApiService(401, {});
const i18nService = mockI18nService();
const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
baseUrl: "https://api.example.com",
}),
).rejects.toEqual("forwaderInvalidToken");
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
expect(i18nService.t).toHaveBeenNthCalledWith(
2,
"forwaderInvalidToken",
Forwarders.AddyIo.name,
);
});
it("throws an unknown error if the request fails and no status is provided", async () => {
const apiService = mockApiService(500, {});
const i18nService = mockI18nService();
const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
baseUrl: "https://api.example.com",
}),
).rejects.toEqual("forwarderUnknownError");
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
expect(i18nService.t).toHaveBeenNthCalledWith(
2,
"forwarderUnknownError",
Forwarders.AddyIo.name,
);
});
it.each([
[100, "Continue"],
[202, "Accepted"],
[300, "Multiple Choices"],
[418, "I'm a teapot"],
[500, "Internal Server Error"],
[600, "Unknown Status"],
])(
"throws an error with the status text if the request returns any other status code (= %i) and a status (= %p) is provided",
async (statusCode, statusText) => {
const apiService = mockApiService(statusCode, {}, statusText);
const i18nService = mockI18nService();
const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
baseUrl: "https://api.example.com",
}),
).rejects.toEqual("forwarderError");
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
expect(i18nService.t).toHaveBeenNthCalledWith(
2,
"forwarderError",
Forwarders.AddyIo.name,
statusText,
);
},
);
});
});

View File

@ -0,0 +1,100 @@
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { StateProvider } from "@bitwarden/common/platform/state";
import { DefaultAddyIoOptions, Forwarders } from "../../data";
import { EmailDomainOptions, SelfHostedApiOptions } from "../../types";
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
import { ADDY_IO_FORWARDER, ADDY_IO_BUFFER } from "../storage";
/** Generates a forwarding address for addy.io (formerly anon addy) */
export class AddyIoForwarder extends ForwarderGeneratorStrategy<
SelfHostedApiOptions & EmailDomainOptions
> {
/** Instantiates the forwarder
* @param apiService used for ajax requests to the forwarding service
* @param i18nService used to look up error strings
* @param encryptService protects sensitive forwarder options
* @param keyService looks up the user key when protecting data.
* @param stateProvider creates the durable state for options storage
*/
constructor(
private apiService: ApiService,
private i18nService: I18nService,
encryptService: EncryptService,
keyService: CryptoService,
stateProvider: StateProvider,
) {
super(encryptService, keyService, stateProvider, DefaultAddyIoOptions);
}
// configuration
readonly key = ADDY_IO_FORWARDER;
readonly rolloverKey = ADDY_IO_BUFFER;
// request
generate = async (options: SelfHostedApiOptions & EmailDomainOptions) => {
if (!options.token || options.token === "") {
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.AddyIo.name);
throw error;
}
if (!options.domain || options.domain === "") {
const error = this.i18nService.t("forwarderNoDomain", Forwarders.AddyIo.name);
throw error;
}
if (!options.baseUrl || options.baseUrl === "") {
const error = this.i18nService.t("forwarderNoUrl", Forwarders.AddyIo.name);
throw error;
}
let descriptionId = "forwarderGeneratedByWithWebsite";
if (!options.website || options.website === "") {
descriptionId = "forwarderGeneratedBy";
}
const description = this.i18nService.t(descriptionId, options.website ?? "");
const url = options.baseUrl + "/api/v1/aliases";
const request = new Request(url, {
redirect: "manual",
cache: "no-store",
method: "POST",
headers: new Headers({
Authorization: "Bearer " + options.token,
"Content-Type": "application/json",
"X-Requested-With": "XMLHttpRequest",
}),
body: JSON.stringify({
domain: options.domain,
description,
}),
});
const response = await this.apiService.nativeFetch(request);
if (response.status === 200 || response.status === 201) {
const json = await response.json();
return json?.data?.email;
} else if (response.status === 401) {
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.AddyIo.name);
throw error;
} else if (response?.statusText) {
const error = this.i18nService.t(
"forwarderError",
Forwarders.AddyIo.name,
response.statusText,
);
throw error;
} else {
const error = this.i18nService.t("forwarderUnknownError", Forwarders.AddyIo.name);
throw error;
}
};
}
export const DefaultOptions = Object.freeze({
website: null,
baseUrl: "https://app.addy.io",
domain: "",
token: "",
});

View File

@ -0,0 +1,144 @@
import { firstValueFrom } from "rxjs";
import { UserId } from "@bitwarden/common/types/guid";
import { Forwarders, DefaultDuckDuckGoOptions } from "../../data";
import { DUCK_DUCK_GO_FORWARDER } from "../storage";
import { DuckDuckGoForwarder } from "./duck-duck-go";
import { mockApiService, mockI18nService } from "./mocks.jest";
const SomeUser = "some user" as UserId;
describe("DuckDuckGo Forwarder", () => {
it("key returns the Duck Duck Go forwarder key", () => {
const forwarder = new DuckDuckGoForwarder(null, null, null, null, null);
expect(forwarder.key).toBe(DUCK_DUCK_GO_FORWARDER);
});
describe("defaults$", () => {
it("should return the default subaddress options", async () => {
const strategy = new DuckDuckGoForwarder(null, null, null, null, null);
const result = await firstValueFrom(strategy.defaults$(SomeUser));
expect(result).toEqual(DefaultDuckDuckGoOptions);
});
});
describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => {
it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new DuckDuckGoForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token,
}),
).rejects.toEqual("forwaderInvalidToken");
expect(apiService.nativeFetch).not.toHaveBeenCalled();
expect(i18nService.t).toHaveBeenCalledWith(
"forwaderInvalidToken",
Forwarders.DuckDuckGo.name,
);
});
it.each([
["jane.doe@duck.com", 201, "jane.doe"],
["john.doe@duck.com", 201, "john.doe"],
["jane.doe@duck.com", 200, "jane.doe"],
["john.doe@duck.com", 200, "john.doe"],
])(
"returns the generated email address (= %p) if the request is successful (status = %p)",
async (email, status, address) => {
const apiService = mockApiService(status, { address });
const i18nService = mockI18nService();
const forwarder = new DuckDuckGoForwarder(apiService, i18nService, null, null, null);
const result = await forwarder.generate({
website: null,
token: "token",
});
expect(result).toEqual(email);
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
},
);
it("throws an invalid token error if the request fails with a 401", async () => {
const apiService = mockApiService(401, {});
const i18nService = mockI18nService();
const forwarder = new DuckDuckGoForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token: "token",
}),
).rejects.toEqual("forwaderInvalidToken");
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
expect(i18nService.t).toHaveBeenCalledWith(
"forwaderInvalidToken",
Forwarders.DuckDuckGo.name,
);
});
it("throws an unknown error if the request is successful but an address isn't present", async () => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new DuckDuckGoForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token: "token",
}),
).rejects.toEqual("forwarderUnknownError");
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
expect(i18nService.t).toHaveBeenCalledWith(
"forwarderUnknownError",
Forwarders.DuckDuckGo.name,
);
});
it.each([100, 202, 300, 418, 500, 600])(
"throws an unknown error if the request returns any other status code (= %i)",
async (statusCode) => {
const apiService = mockApiService(statusCode, {});
const i18nService = mockI18nService();
const forwarder = new DuckDuckGoForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate({
website: null,
token: "token",
}),
).rejects.toEqual("forwarderUnknownError");
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
expect(i18nService.t).toHaveBeenCalledWith(
"forwarderUnknownError",
Forwarders.DuckDuckGo.name,
);
},
);
});
});

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