Merge branch 'main' into ps/extension-refresh

This commit is contained in:
Will Martin 2024-06-11 16:14:12 -04:00 committed by GitHub
commit 2d412cc086
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
215 changed files with 10529 additions and 1113 deletions

View File

@ -8,16 +8,27 @@ on:
- "main"
- "rc"
- "hotfix-rc-*"
pull_request:
pull_request_target:
types: [opened, synchronize]
defaults:
run:
shell: bash
jobs:
check-run:
name: Check PR run
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
test:
name: Run tests
runs-on: ubuntu-22.04
needs: check-run
permissions:
checks: write
contents: read
pull-requests: write
steps:
- name: Checkout repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1

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;

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

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

@ -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

@ -379,6 +379,54 @@ describe("VaultPopupItemsService", () => {
});
});
describe("loading$", () => {
let tracked: ObservableTracker<boolean>;
let trackedCiphers: ObservableTracker<any>;
beforeEach(() => {
// Start tracking loading$ emissions
tracked = new ObservableTracker(service.loading$);
// Track remainingCiphers$ to make cipher observables active
trackedCiphers = new ObservableTracker(service.remainingCiphers$);
});
it("should initialize with true first", async () => {
expect(tracked.emissions[0]).toBe(true);
});
it("should emit false once ciphers are available", async () => {
expect(tracked.emissions.length).toBe(2);
expect(tracked.emissions[0]).toBe(true);
expect(tracked.emissions[1]).toBe(false);
});
it("should cycle when cipherService.ciphers$ emits", async () => {
// Restart tracking
tracked = new ObservableTracker(service.loading$);
(cipherServiceMock.ciphers$ as BehaviorSubject<any>).next(null);
await trackedCiphers.pauseUntilReceived(2);
expect(tracked.emissions.length).toBe(3);
expect(tracked.emissions[0]).toBe(false);
expect(tracked.emissions[1]).toBe(true);
expect(tracked.emissions[2]).toBe(false);
});
it("should cycle when filters are applied", async () => {
// Restart tracking
tracked = new ObservableTracker(service.loading$);
service.applyFilter("test");
await trackedCiphers.pauseUntilReceived(2);
expect(tracked.emissions.length).toBe(3);
expect(tracked.emissions[0]).toBe(false);
expect(tracked.emissions[1]).toBe(true);
expect(tracked.emissions[2]).toBe(false);
});
});
describe("applyFilter", () => {
it("should call search Service with the new search term", (done) => {
const searchText = "Hello";

View File

@ -2,6 +2,7 @@ import { inject, Injectable, NgZone } from "@angular/core";
import {
BehaviorSubject,
combineLatest,
distinctUntilChanged,
distinctUntilKeyChanged,
from,
map,
@ -12,6 +13,8 @@ import {
startWith,
Subject,
switchMap,
tap,
withLatestFrom,
} from "rxjs";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
@ -40,6 +43,13 @@ import { MY_VAULT_ID, VaultPopupListFiltersService } from "./vault-popup-list-fi
export class VaultPopupItemsService {
private _refreshCurrentTab$ = new Subject<void>();
private _searchText$ = new BehaviorSubject<string>("");
/**
* Subject that emits whenever new ciphers are being processed/filtered.
* @private
*/
private _ciphersLoading$ = new Subject<void>();
latestSearchText$: Observable<string> = this._searchText$.asObservable();
/**
@ -84,6 +94,7 @@ export class VaultPopupItemsService {
this.cipherService.localData$,
).pipe(
runInsideAngular(inject(NgZone)), // Workaround to ensure cipher$ state provider emissions are run inside Angular
tap(() => this._ciphersLoading$.next()),
switchMap(() => Utils.asyncToObservable(() => this.cipherService.getAllDecrypted())),
switchMap((ciphers) =>
combineLatest([
@ -112,6 +123,7 @@ export class VaultPopupItemsService {
this._searchText$,
this.vaultPopupListFiltersService.filterFunction$,
]).pipe(
tap(() => this._ciphersLoading$.next()),
map(([ciphers, searchText, filterFunction]): [CipherView[], string] => [
filterFunction(ciphers),
searchText,
@ -148,10 +160,8 @@ export class VaultPopupItemsService {
* List of favorite ciphers that are not currently suggested for autofill.
* Ciphers are sorted by last used date, then by name.
*/
favoriteCiphers$: Observable<PopupCipherView[]> = combineLatest([
this.autoFillCiphers$,
this._filteredCipherList$,
]).pipe(
favoriteCiphers$: Observable<PopupCipherView[]> = this.autoFillCiphers$.pipe(
withLatestFrom(this._filteredCipherList$),
map(([autoFillCiphers, ciphers]) =>
ciphers.filter((cipher) => cipher.favorite && !autoFillCiphers.includes(cipher)),
),
@ -165,12 +175,9 @@ export class VaultPopupItemsService {
* List of all remaining ciphers that are not currently suggested for autofill or marked as favorite.
* Ciphers are sorted by name.
*/
remainingCiphers$: Observable<PopupCipherView[]> = combineLatest([
this.autoFillCiphers$,
this.favoriteCiphers$,
this._filteredCipherList$,
]).pipe(
map(([autoFillCiphers, favoriteCiphers, ciphers]) =>
remainingCiphers$: Observable<PopupCipherView[]> = this.favoriteCiphers$.pipe(
withLatestFrom(this._filteredCipherList$, this.autoFillCiphers$),
map(([favoriteCiphers, ciphers, autoFillCiphers]) =>
ciphers.filter(
(cipher) => !autoFillCiphers.includes(cipher) && !favoriteCiphers.includes(cipher),
),
@ -179,6 +186,14 @@ export class VaultPopupItemsService {
shareReplay({ refCount: false, bufferSize: 1 }),
);
/**
* Observable that indicates whether the service is currently loading ciphers.
*/
loading$: Observable<boolean> = merge(
this._ciphersLoading$.pipe(map(() => true)),
this.remainingCiphers$.pipe(map(() => false)),
).pipe(startWith(true), distinctUntilChanged(), shareReplay({ refCount: false, bufferSize: 1 }));
/**
* Observable that indicates whether a filter is currently applied to the ciphers.
*/

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

@ -80,7 +80,7 @@
"papaparse": "5.4.1",
"proper-lockfile": "4.1.2",
"rxjs": "7.8.1",
"tldts": "6.1.22",
"tldts": "6.1.25",
"zxcvbn": "4.4.2"
}
}

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

@ -80,7 +80,6 @@ export class InternalGroupService extends GroupService {
async save(group: GroupView): Promise<GroupView> {
const request = new GroupRequest();
request.name = group.name;
request.accessAll = group.accessAll;
request.users = group.members;
request.collections = group.collections.map(
(c) => new SelectionReadOnlyRequest(c.id, c.readOnly, c.hidePasswords, c.manage),

View File

@ -2,7 +2,6 @@ import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models
export class GroupRequest {
name: string;
accessAll: boolean;
collections: SelectionReadOnlyRequest[] = [];
users: string[] = [];
}

View File

@ -5,11 +5,6 @@ export class GroupResponse extends BaseResponse {
id: string;
organizationId: string;
name: string;
/**
* @deprecated
* To be removed after Flexible Collections.
**/
accessAll: boolean;
externalId: string;
constructor(response: any) {
@ -17,7 +12,6 @@ export class GroupResponse extends BaseResponse {
this.id = this.getResponseProperty("Id");
this.organizationId = this.getResponseProperty("OrganizationId");
this.name = this.getResponseProperty("Name");
this.accessAll = this.getResponseProperty("AccessAll");
this.externalId = this.getResponseProperty("ExternalId");
}
}

View File

@ -41,7 +41,6 @@ export class UserAdminService {
async save(user: OrganizationUserAdminView): Promise<void> {
const request = new OrganizationUserUpdateRequest();
request.accessAll = user.accessAll;
request.permissions = user.permissions;
request.type = user.type;
request.collections = user.collections;
@ -54,7 +53,6 @@ export class UserAdminService {
async invite(emails: string[], user: OrganizationUserAdminView): Promise<void> {
const request = new OrganizationUserInviteRequest();
request.emails = emails;
request.accessAll = user.accessAll;
request.permissions = user.permissions;
request.type = user.type;
request.collections = user.collections;
@ -77,7 +75,6 @@ export class UserAdminService {
view.type = u.type;
view.status = u.status;
view.externalId = u.externalId;
view.accessAll = u.accessAll;
view.permissions = u.permissions;
view.resetPasswordEnrolled = u.resetPasswordEnrolled;
view.collections = u.collections.map((c) => ({

View File

@ -8,12 +8,6 @@ export class GroupView implements View {
id: string;
organizationId: string;
name: string;
/**
* @deprecated
* To be removed after Flexible Collections.
* This will always return `false` if Flexible Collections is enabled.
**/
accessAll: boolean;
externalId: string;
collections: CollectionAccessSelectionView[] = [];
members: string[] = [];

View File

@ -13,12 +13,6 @@ export class OrganizationUserAdminView {
type: OrganizationUserType;
status: OrganizationUserStatusType;
externalId: string;
/**
* @deprecated
* To be removed after Flexible Collections.
* This will always return `false` if Flexible Collections is enabled.
**/
accessAll: boolean;
permissions: PermissionsApi;
resetPasswordEnrolled: boolean;
hasMasterPassword: boolean;

View File

@ -12,12 +12,6 @@ export class OrganizationUserView {
userId: string;
type: OrganizationUserType;
status: OrganizationUserStatusType;
/**
* @deprecated
* To be removed after Flexible Collections.
* This will always return `false` if Flexible Collections is enabled.
**/
accessAll: boolean;
permissions: PermissionsApi;
resetPasswordEnrolled: boolean;
name: string;

View File

@ -11,7 +11,7 @@
<bit-nav-item
icon="bwi-collection"
[text]="(organization.flexibleCollections ? 'collections' : 'vault') | i18n"
[text]="'collections' | i18n"
route="vault"
*ngIf="canShowVaultTab(organization)"
>

View File

@ -45,7 +45,6 @@
[columnHeader]="'member' | i18n"
[selectorLabelText]="'selectMembers' | i18n"
[emptySelectionText]="'noMembersAdded' | i18n"
[flexibleCollectionsEnabled]="flexibleCollectionsEnabled$ | async"
></bit-access-selector>
</bit-tab>
@ -56,24 +55,14 @@
{{ "restrictedCollectionAssignmentDesc" | i18n }}
</span>
</p>
<div *ngIf="!(flexibleCollectionsEnabled$ | async)" class="tw-my-3">
<input type="checkbox" formControlName="accessAll" id="accessAll" />
<label class="tw-mb-0 tw-text-lg" for="accessAll">{{
"accessAllCollectionsDesc" | i18n
}}</label>
<p class="tw-my-0 tw-text-muted">{{ "accessAllCollectionsHelp" | i18n }}</p>
</div>
<ng-container *ngIf="!groupForm.value.accessAll">
<bit-access-selector
formControlName="collections"
[items]="collections"
[permissionMode]="PermissionMode.Edit"
[columnHeader]="'collection' | i18n"
[selectorLabelText]="'selectCollections' | i18n"
[emptySelectionText]="'noCollectionsAdded' | i18n"
[flexibleCollectionsEnabled]="flexibleCollectionsEnabled$ | async"
></bit-access-selector>
</ng-container>
<bit-access-selector
formControlName="collections"
[items]="collections"
[permissionMode]="PermissionMode.Edit"
[columnHeader]="'collection' | i18n"
[selectorLabelText]="'selectCollections' | i18n"
[emptySelectionText]="'noCollectionsAdded' | i18n"
></bit-access-selector>
</bit-tab>
</bit-tab-group>
</div>

View File

@ -96,9 +96,6 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
private organization$ = this.organizationService
.get$(this.organizationId)
.pipe(shareReplay({ refCount: true }));
protected flexibleCollectionsEnabled$ = this.organization$.pipe(
map((o) => o?.flexibleCollections),
);
private flexibleCollectionsV1Enabled$ = this.configService.getFeatureFlag$(
FeatureFlag.FlexibleCollectionsV1,
);
@ -114,7 +111,6 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
group: GroupView;
groupForm = this.formBuilder.group({
accessAll: [false],
name: ["", [Validators.required, Validators.maxLength(100)]],
externalId: this.formBuilder.control({ value: "", disabled: true }),
members: [[] as AccessItemValue[]],
@ -188,7 +184,7 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
this.flexibleCollectionsV1Enabled$,
]).pipe(
map(([organization, flexibleCollectionsV1Enabled]) => {
if (!flexibleCollectionsV1Enabled || !organization.flexibleCollections) {
if (!flexibleCollectionsV1Enabled) {
return true;
}
@ -276,7 +272,6 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
this.groupForm.patchValue({
name: this.group.name,
externalId: this.group.externalId,
accessAll: this.group.accessAll,
members: this.group.members.map((m) => ({
id: m,
type: AccessItemType.Member,
@ -328,12 +323,8 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
const formValue = this.groupForm.value;
groupView.name = formValue.name;
groupView.accessAll = formValue.accessAll;
groupView.members = formValue.members?.map((m) => m.id) ?? [];
if (!groupView.accessAll) {
groupView.collections = formValue.collections.map((c) => convertToSelectionView(c));
}
groupView.collections = formValue.collections.map((c) => convertToSelectionView(c));
await this.groupService.save(groupView);

View File

@ -74,12 +74,10 @@
</td>
<td bitCell (click)="edit(g, ModalTabType.Collections)" class="tw-cursor-pointer">
<bit-badge-list
*ngIf="!g.details.accessAll"
[items]="g.collectionNames"
[maxItems]="3"
variant="secondary"
></bit-badge-list>
<span *ngIf="g.details.accessAll">{{ "all" | i18n }}</span>
</td>
<td bitCell>
<button

View File

@ -49,14 +49,6 @@
<bit-label>{{ "user" | i18n }}</bit-label>
<bit-hint>{{ "userDesc" | i18n }}</bit-hint>
</bit-radio-button>
<bit-radio-button
*ngIf="!organization.flexibleCollections"
id="userTypeManager"
[value]="organizationUserType.Manager"
>
<bit-label>{{ "manager" | i18n }}</bit-label>
<bit-hint>{{ "managerDesc" | i18n }}</bit-hint>
</bit-radio-button>
<bit-radio-button id="userTypeAdmin" [value]="organizationUserType.Admin">
<bit-label>{{ "admin" | i18n }}</bit-label>
<bit-hint>{{ "adminDesc" | i18n }}</bit-hint>
@ -91,140 +83,64 @@
</bit-radio-button>
</bit-radio-group>
<ng-container *ngIf="customUserTypeSelected">
<ng-container *ngIf="!organization.flexibleCollections; else customPermissionsFC">
<h3 bitTypography="h3">
{{ "permissions" | i18n }}
</h3>
<div class="tw-grid tw-grid-cols-12 tw-gap-4" [formGroup]="permissionsGroup">
<div class="tw-col-span-6">
<div class="tw-mb-3">
<bit-label class="tw-font-semibold">{{
"managerPermissions" | i18n
}}</bit-label>
<hr class="tw-mb-2 tw-mr-2 tw-mt-0" />
<app-nested-checkbox
parentId="manageAssignedCollections"
[checkboxes]="permissionsGroup.controls.manageAssignedCollectionsGroup"
>
</app-nested-checkbox>
</div>
</div>
<div class="tw-col-span-6">
<div class="tw-mb-3">
<bit-label class="tw-font-semibold">{{ "adminPermissions" | i18n }}</bit-label>
<hr class="tw-mb-2 tw-mr-2 tw-mt-0" />
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="accessEventLogs" />
<bit-label>{{ "accessEventLogs" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="accessImportExport" />
<bit-label>{{ "accessImportExport" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="accessReports" />
<bit-label>{{ "accessReports" | i18n }}</bit-label>
</bit-form-control>
<app-nested-checkbox
parentId="manageAllCollections"
[checkboxes]="permissionsGroup.controls.manageAllCollectionsGroup"
>
</app-nested-checkbox>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="manageGroups" />
<bit-label>{{ "manageGroups" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="manageSso" />
<bit-label>{{ "manageSso" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="managePolicies" />
<bit-label>{{ "managePolicies" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input
id="manageUsers"
type="checkbox"
bitCheckbox
formControlName="manageUsers"
(change)="handleDependentPermissions()"
/>
<bit-label>{{ "manageUsers" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input
type="checkbox"
bitCheckbox
formControlName="manageResetPassword"
(change)="handleDependentPermissions()"
/>
<bit-label>{{ "manageAccountRecovery" | i18n }}</bit-label>
</bit-form-control>
</div>
<div class="tw-grid tw-grid-cols-12 tw-gap-4" [formGroup]="permissionsGroup">
<div class="tw-col-span-4">
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="accessEventLogs" />
<bit-label>{{ "accessEventLogs" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="accessImportExport" />
<bit-label>{{ "accessImportExport" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="accessReports" />
<bit-label>{{ "accessReports" | i18n }}</bit-label>
</bit-form-control>
</div>
<div class="tw-col-span-4">
<app-nested-checkbox
parentId="manageAllCollections"
[checkboxes]="permissionsGroup.controls.manageAllCollectionsGroup"
>
</app-nested-checkbox>
</div>
<div class="tw-col-span-4">
<div class="tw-mb-3">
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="manageGroups" />
<bit-label>{{ "manageGroups" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="manageSso" />
<bit-label>{{ "manageSso" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="managePolicies" />
<bit-label>{{ "managePolicies" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input
id="manageUsers"
type="checkbox"
bitCheckbox
formControlName="manageUsers"
(change)="handleDependentPermissions()"
/>
<bit-label>{{ "manageUsers" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input
type="checkbox"
bitCheckbox
formControlName="manageResetPassword"
(change)="handleDependentPermissions()"
/>
<bit-label>{{ "manageAccountRecovery" | i18n }}</bit-label>
</bit-form-control>
</div>
</div>
</ng-container>
<ng-template #customPermissionsFC>
<div class="tw-grid tw-grid-cols-12 tw-gap-4" [formGroup]="permissionsGroup">
<div class="tw-col-span-4">
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="accessEventLogs" />
<bit-label>{{ "accessEventLogs" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="accessImportExport" />
<bit-label>{{ "accessImportExport" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="accessReports" />
<bit-label>{{ "accessReports" | i18n }}</bit-label>
</bit-form-control>
</div>
<div class="tw-col-span-4">
<app-nested-checkbox
parentId="manageAllCollections"
[checkboxes]="permissionsGroup.controls.manageAllCollectionsGroup"
>
</app-nested-checkbox>
</div>
<div class="tw-col-span-4">
<div class="tw-mb-3">
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="manageGroups" />
<bit-label>{{ "manageGroups" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="manageSso" />
<bit-label>{{ "manageSso" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="managePolicies" />
<bit-label>{{ "managePolicies" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input
id="manageUsers"
type="checkbox"
bitCheckbox
formControlName="manageUsers"
(change)="handleDependentPermissions()"
/>
<bit-label>{{ "manageUsers" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input
type="checkbox"
bitCheckbox
formControlName="manageResetPassword"
(change)="handleDependentPermissions()"
/>
<bit-label>{{ "manageAccountRecovery" | i18n }}</bit-label>
</bit-form-control>
</div>
</div>
</div>
</ng-template>
</div>
</ng-container>
<ng-container *ngIf="organization.useSecretsManager">
<h3 class="tw-mt-4">
@ -272,7 +188,6 @@
[columnHeader]="'groups' | i18n"
[selectorLabelText]="'selectGroups' | i18n"
[emptySelectionText]="'noGroupsAdded' | i18n"
[flexibleCollectionsEnabled]="organization.flexibleCollections"
[hideMultiSelect]="restrictEditingSelf$ | async"
></bit-access-selector>
</bit-tab>
@ -294,26 +209,7 @@
{{ "restrictedCollectionAssignmentDesc" | i18n }}
</span>
</div>
<div *ngIf="!organization.flexibleCollections" class="tw-mb-6">
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="accessAllCollections" />
<bit-label>
{{ "accessAllCollectionsDesc" | i18n }}
<a
bitLink
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'learnMore' | i18n }}"
href="https://bitwarden.com/help/user-types-access-control/#access-control"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>
</bit-label>
<bit-hint>{{ "accessAllCollectionsHelp" | i18n }}</bit-hint>
</bit-form-control>
</div>
<bit-access-selector
*ngIf="!accessAllCollections"
[permissionMode]="PermissionMode.Edit"
formControlName="access"
[showGroupColumn]="organization.useGroups"
@ -321,7 +217,6 @@
[columnHeader]="'collection' | i18n"
[selectorLabelText]="'selectCollections' | i18n"
[emptySelectionText]="'noCollectionsAdded' | i18n"
[flexibleCollectionsEnabled]="organization.flexibleCollections"
[hideMultiSelect]="restrictEditingSelf$ | async"
></bit-access-selector
></bit-tab>

View File

@ -99,7 +99,6 @@ export class MemberDialogComponent implements OnDestroy {
emails: [""],
type: OrganizationUserType.User,
externalId: this.formBuilder.control({ value: "", disabled: true }),
accessAllCollections: false,
accessSecretsManager: false,
access: [[] as AccessItemValue[]],
groups: [[] as AccessItemValue[]],
@ -110,11 +109,6 @@ export class MemberDialogComponent implements OnDestroy {
protected canAssignAccessToAnyCollection$: Observable<boolean>;
protected permissionsGroup = this.formBuilder.group({
manageAssignedCollectionsGroup: this.formBuilder.group<Record<string, boolean>>({
manageAssignedCollections: false,
editAssignedCollections: false,
deleteAssignedCollections: false,
}),
manageAllCollectionsGroup: this.formBuilder.group<Record<string, boolean>>({
manageAllCollections: false,
createNewCollections: false,
@ -137,10 +131,6 @@ export class MemberDialogComponent implements OnDestroy {
return this.formGroup.value.type === OrganizationUserType.Custom;
}
get accessAllCollections(): boolean {
return this.formGroup.value.accessAllCollections;
}
constructor(
@Inject(DIALOG_DATA) protected params: MemberDialogParams,
private dialogRef: DialogRef<MemberDialogResult>,
@ -189,7 +179,7 @@ export class MemberDialogComponent implements OnDestroy {
this.configService.getFeatureFlag$(FeatureFlag.FlexibleCollectionsV1),
]).pipe(
map(([organization, flexibleCollectionsV1Enabled]) => {
if (!flexibleCollectionsV1Enabled || !organization.flexibleCollections) {
if (!flexibleCollectionsV1Enabled) {
return true;
}
@ -316,13 +306,6 @@ export class MemberDialogComponent implements OnDestroy {
this.showNoMasterPasswordWarning =
userDetails.status > OrganizationUserStatusType.Invited &&
userDetails.hasMasterPassword === false;
const assignedCollectionsPermissions = {
editAssignedCollections: userDetails.permissions.editAssignedCollections,
deleteAssignedCollections: userDetails.permissions.deleteAssignedCollections,
manageAssignedCollections:
userDetails.permissions.editAssignedCollections &&
userDetails.permissions.deleteAssignedCollections,
};
const allCollectionsPermissions = {
createNewCollections: userDetails.permissions.createNewCollections,
editAnyCollection: userDetails.permissions.editAnyCollection,
@ -342,7 +325,6 @@ export class MemberDialogComponent implements OnDestroy {
managePolicies: userDetails.permissions.managePolicies,
manageUsers: userDetails.permissions.manageUsers,
manageResetPassword: userDetails.permissions.manageResetPassword,
manageAssignedCollectionsGroup: assignedCollectionsPermissions,
manageAllCollectionsGroup: allCollectionsPermissions,
});
}
@ -378,7 +360,6 @@ export class MemberDialogComponent implements OnDestroy {
this.formGroup.patchValue({
type: userDetails.type,
externalId: userDetails.externalId,
accessAllCollections: userDetails.accessAll,
access: accessSelections,
accessSecretsManager: userDetails.accessSecretsManager,
groups: groupAccessSelections,
@ -414,10 +395,6 @@ export class MemberDialogComponent implements OnDestroy {
editAnyCollection: this.permissionsGroup.value.manageAllCollectionsGroup.editAnyCollection,
deleteAnyCollection:
this.permissionsGroup.value.manageAllCollectionsGroup.deleteAnyCollection,
editAssignedCollections:
this.permissionsGroup.value.manageAssignedCollectionsGroup.editAssignedCollections,
deleteAssignedCollections:
this.permissionsGroup.value.manageAssignedCollectionsGroup.deleteAssignedCollections,
};
return Object.assign(p, partialPermissions);
@ -467,7 +444,6 @@ export class MemberDialogComponent implements OnDestroy {
const userView = new OrganizationUserAdminView();
userView.id = this.params.organizationUserId;
userView.organizationId = this.params.organizationId;
userView.accessAll = this.accessAllCollections;
userView.type = this.formGroup.value.type;
userView.permissions = this.setRequestPermissions(
userView.permissions ?? new PermissionsApi(),

View File

@ -190,12 +190,10 @@
class="tw-cursor-pointer"
>
<bit-badge-list
*ngIf="organization.useGroups || !u.accessAll"
[items]="organization.useGroups ? u.groupNames : u.collectionNames"
[maxItems]="3"
variant="secondary"
></bit-badge-list>
<span *ngIf="!organization.useGroups && u.accessAll">{{ "all" | i18n }}</span>
</td>
<td

View File

@ -51,7 +51,7 @@
</button>
</ng-container>
<form
*ngIf="org && !loading && org.flexibleCollections"
*ngIf="org && !loading"
[bitSubmit]="submitCollectionManagement"
[formGroup]="collectionManagementFormGroup"
>

View File

@ -110,15 +110,6 @@
</ng-container>
<ng-template #readOnlyPerm>
<div
*ngIf="item.accessAllItems"
class="tw-max-w-40 tw-overflow-hidden tw-overflow-ellipsis tw-whitespace-nowrap tw-border tw-border-solid tw-border-transparent tw-font-bold tw-text-muted"
[appA11yTitle]="accessAllLabelId(item) | i18n"
>
{{ "canEdit" | i18n }}
<i class="bwi bwi-filter tw-ml-1" aria-hidden="true"></i>
</div>
<div
*ngIf="item.readonly || disabled"
class="tw-max-w-40 tw-overflow-hidden tw-overflow-ellipsis tw-whitespace-nowrap tw-font-bold tw-text-muted"

View File

@ -75,7 +75,7 @@ export class AccessSelectorComponent implements ControlValueAccessor, OnInit, On
// The enable() above also enables the permission control, so we need to disable it again
// Disable permission control if accessAllItems is enabled or not in Edit mode
if (item.accessAllItems || this.permissionMode != PermissionMode.Edit) {
if (this.permissionMode != PermissionMode.Edit) {
controlRow.controls.permission.disable();
}
}
@ -196,21 +196,11 @@ export class AccessSelectorComponent implements ControlValueAccessor, OnInit, On
*/
@Input() showGroupColumn: boolean;
/**
* Enable Flexible Collections changes (feature flag)
*/
@Input() set flexibleCollectionsEnabled(value: boolean) {
this._flexibleCollectionsEnabled = value;
this.permissionList = getPermissionList(value);
}
/**
* Hide the multi-select so that new items cannot be added
*/
@Input() hideMultiSelect = false;
private _flexibleCollectionsEnabled: boolean;
constructor(
private readonly formBuilder: FormBuilder,
private readonly i18nService: I18nService,
@ -275,7 +265,7 @@ export class AccessSelectorComponent implements ControlValueAccessor, OnInit, On
}
async ngOnInit() {
this.permissionList = getPermissionList(this._flexibleCollectionsEnabled);
this.permissionList = getPermissionList();
// Watch the internal formArray for changes and propagate them
this.selectionList.formArray.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((v) => {
if (!this.notifyOnChange || this.pauseChangeNotification) {
@ -328,12 +318,8 @@ export class AccessSelectorComponent implements ControlValueAccessor, OnInit, On
return this.permissionList.find((p) => p.perm == perm)?.labelId;
}
protected accessAllLabelId(item: AccessItemView) {
return item.type == AccessItemType.Group ? "groupAccessAll" : "memberAccessAll";
}
protected canEditItemPermission(item: AccessItemView) {
return this.permissionMode == PermissionMode.Edit && !item.readonly && !item.accessAllItems;
return this.permissionMode == PermissionMode.Edit && !item.readonly;
}
private _itemComparator(a: AccessItemView, b: AccessItemView) {

View File

@ -34,12 +34,6 @@ export enum AccessItemType {
*
*/
export type AccessItemView = SelectItemView & {
/**
* Flag that this group/member can access all items.
* This will disable the permission editor for this item.
*/
accessAllItems?: boolean;
/**
* Flag that this item cannot be modified.
* This will disable the permission editor and will keep
@ -82,16 +76,14 @@ export type Permission = {
labelId: string;
};
export const getPermissionList = (flexibleCollectionsEnabled: boolean): Permission[] => {
export const getPermissionList = (): Permission[] => {
const permissions = [
{ perm: CollectionPermission.View, labelId: "canView" },
{ perm: CollectionPermission.ViewExceptPass, labelId: "canViewExceptPass" },
{ perm: CollectionPermission.Edit, labelId: "canEdit" },
{ perm: CollectionPermission.EditExceptPass, labelId: "canEditExceptPass" },
{ perm: CollectionPermission.Manage, labelId: "canManage" },
];
if (flexibleCollectionsEnabled) {
permissions.push({ perm: CollectionPermission.Manage, labelId: "canManage" });
}
return permissions;
};
@ -142,8 +134,6 @@ export function mapGroupToAccessItemView(group: GroupView): AccessItemView {
type: AccessItemType.Group,
listName: group.name,
labelName: group.name,
accessAllItems: group.accessAll,
readonly: group.accessAll,
};
}
@ -157,7 +147,5 @@ export function mapUserToAccessItemView(user: OrganizationUserUserDetailsRespons
listName: user.name?.length > 0 ? `${user.name} (${user.email})` : user.email,
labelName: user.name ?? user.email,
status: user.status,
accessAllItems: user.accessAll,
readonly: user.accessAll,
};
}

View File

@ -253,7 +253,6 @@ MemberGroupAccess.args = {
type: AccessItemType.Group,
listName: "Admin Group",
labelName: "Admin Group",
accessAllItems: true,
},
]),
};
@ -309,7 +308,6 @@ CollectionAccess.args = {
type: AccessItemType.Group,
listName: "Admin Group",
labelName: "Admin Group",
accessAllItems: true,
readonly: true,
},
{
@ -320,7 +318,6 @@ CollectionAccess.args = {
status: OrganizationUserStatusType.Confirmed,
role: OrganizationUserType.Admin,
email: "admin@email.com",
accessAllItems: true,
readonly: true,
},
]),

View File

@ -20,8 +20,6 @@ export class UserTypePipe implements PipeTransform {
return this.i18nService.t("admin");
case OrganizationUserType.User:
return this.i18nService.t("user");
case OrganizationUserType.Manager:
return this.i18nService.t("manager");
case OrganizationUserType.Custom:
return this.i18nService.t("custom");
}

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

@ -64,7 +64,7 @@
</bit-form-field>
</bit-tab>
<bit-tab label="{{ 'access' | i18n }}">
<div class="tw-mb-3" *ngIf="organization.flexibleCollections">
<div class="tw-mb-3">
<ng-container *ngIf="dialogReadonly">
<span>{{ "readOnlyCollectionAccess" | i18n }}</span>
</ng-container>
@ -107,7 +107,6 @@
[selectorLabelText]="'selectGroupsAndMembers' | i18n"
[selectorHelpText]="'userPermissionOverrideHelperDesc' | i18n"
[emptySelectionText]="'noMembersOrGroupsAdded' | i18n"
[flexibleCollectionsEnabled]="organization.flexibleCollections"
></bit-access-selector>
<bit-access-selector
*ngIf="!organization.useGroups"
@ -117,7 +116,6 @@
[columnHeader]="'memberColumnHeader' | i18n"
[selectorLabelText]="'selectMembers' | i18n"
[emptySelectionText]="'noMembersAdded' | i18n"
[flexibleCollectionsEnabled]="organization.flexibleCollections"
></bit-access-selector>
</bit-tab>
</bit-tab-group>

View File

@ -223,7 +223,7 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
(u) => u.userId === this.organization?.userId,
)?.id;
const initialSelection: AccessItemValue[] =
currentOrgUserId !== undefined && organization.flexibleCollections
currentOrgUserId !== undefined
? [
{
id: currentOrgUserId,
@ -239,11 +239,7 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
});
}
if (
organization.flexibleCollections &&
flexibleCollectionsV1 &&
!organization.allowAdminAccessToAllCollectionItems
) {
if (flexibleCollectionsV1 && !organization.allowAdminAccessToAllCollectionItems) {
this.formGroup.controls.access.addValidators(validateCanManagePermission);
} else {
this.formGroup.controls.access.removeValidators(validateCanManagePermission);
@ -444,8 +440,7 @@ function mapGroupToAccessItemView(group: GroupView, collectionId: string): Acces
type: AccessItemType.Group,
listName: group.name,
labelName: group.name,
accessAllItems: group.accessAll,
readonly: group.accessAll,
readonly: false,
readonlyPermission:
collectionId != null
? convertToPermission(group.collections.find((gc) => gc.id == collectionId))
@ -471,8 +466,7 @@ function mapUserToAccessItemView(
listName: user.name?.length > 0 ? `${user.name} (${user.email})` : user.email,
labelName: user.name ?? user.email,
status: user.status,
accessAllItems: user.accessAll,
readonly: user.accessAll,
readonly: false,
readonlyPermission:
collectionId != null
? convertToPermission(

View File

@ -86,7 +86,7 @@ export class VaultCollectionRowComponent {
return this.i18nService.t("canEdit");
}
if ((this.collection as CollectionAdminView).assigned) {
const permissionList = getPermissionList(this.organization?.flexibleCollections);
const permissionList = getPermissionList();
return this.i18nService.t(
permissionList.find((p) => p.perm === convertToPermission(this.collection))?.labelId,
);

View File

@ -15,7 +15,6 @@ import { VaultFilter } from "../models/vault-filter.model";
})
export class VaultFilterSectionComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
protected flexibleCollectionsEnabled: boolean;
@Input() activeFilter: VaultFilter;
@Input() section: VaultFilterSection;
@ -40,12 +39,6 @@ export class VaultFilterSectionComponent implements OnInit, OnDestroy {
this.section?.data$?.pipe(takeUntil(this.destroy$)).subscribe((data) => {
this.data = data;
});
this.vaultFilterService
.getOrganizationFilter()
.pipe(takeUntil(this.destroy$))
.subscribe((org) => {
this.flexibleCollectionsEnabled = org != null ? org.flexibleCollections : false;
});
}
ngOnDestroy() {
@ -77,10 +70,9 @@ export class VaultFilterSectionComponent implements OnInit, OnDestroy {
const { organizationId, cipherTypeId, folderId, collectionId, isCollectionSelected } =
this.activeFilter;
const collectionStatus = this.flexibleCollectionsEnabled
? filterNode?.node.id === "AllCollections" &&
(isCollectionSelected || collectionId === "AllCollections")
: collectionId === filterNode?.node.id;
const collectionStatus =
filterNode?.node.id === "AllCollections" &&
(isCollectionSelected || collectionId === "AllCollections");
return (
organizationId === filterNode?.node.id ||

View File

@ -17,7 +17,6 @@
[selectorLabelText]="'selectGroupsAndMembers' | i18n"
[selectorHelpText]="'userPermissionOverrideHelperDesc' | i18n"
[emptySelectionText]="'noMembersOrGroupsAdded' | i18n"
[flexibleCollectionsEnabled]="flexibleCollectionsEnabled$ | async"
></bit-access-selector>
<bit-access-selector
*ngIf="!organization?.useGroups"
@ -27,7 +26,6 @@
[columnHeader]="'memberColumnHeader' | i18n"
[selectorLabelText]="'selectMembers' | i18n"
[emptySelectionText]="'noMembersAdded' | i18n"
[flexibleCollectionsEnabled]="flexibleCollectionsEnabled$ | async"
></bit-access-selector>
</div>

View File

@ -1,7 +1,7 @@
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject, OnDestroy } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { combineLatest, map, of, Subject, switchMap, takeUntil } from "rxjs";
import { combineLatest, of, Subject, switchMap, takeUntil } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
@ -42,10 +42,6 @@ export enum BulkCollectionsDialogResult {
standalone: true,
})
export class BulkCollectionsDialogComponent implements OnDestroy {
protected flexibleCollectionsEnabled$ = this.organizationService
.get$(this.params.organizationId)
.pipe(map((o) => o?.flexibleCollections));
protected readonly PermissionMode = PermissionMode;
protected formGroup = this.formBuilder.group({

View File

@ -103,11 +103,7 @@ export class VaultFilterComponent extends BaseVaultFilterComponent implements On
async buildAllFilters(): Promise<VaultFilterList> {
const builderFilter = {} as VaultFilterList;
builderFilter.typeFilter = await this.addTypeFilter(["favorites"]);
if (this._organization?.flexibleCollections) {
builderFilter.collectionFilter = await this.addCollectionFilter();
} else {
builderFilter.collectionFilter = await super.addCollectionFilter();
}
builderFilter.collectionFilter = await this.addCollectionFilter();
builderFilter.trashFilter = await this.addTrashFilter();
return builderFilter;
}

View File

@ -6,10 +6,7 @@
queryParamsHandling="merge"
>
{{ organization.name }}
<span *ngIf="!organization.flexibleCollections">
{{ "vault" | i18n | lowercase }}
</span>
<span *ngIf="organization.flexibleCollections">
<span>
{{ "collections" | i18n | lowercase }}
</span>
</bit-breadcrumb>

View File

@ -89,9 +89,7 @@ export class VaultHeaderComponent implements OnInit {
}
get title() {
const headerType = this.organization?.flexibleCollections
? this.i18nService.t("collections").toLowerCase()
: this.i18nService.t("vault").toLowerCase();
const headerType = this.i18nService.t("collections").toLowerCase();
if (this.collection != null) {
return this.collection.node.name;

View File

@ -65,8 +65,8 @@
[useEvents]="organization?.canAccessEventLogs"
[showAdminActions]="true"
(onEvent)="onVaultItemsEvent($event)"
[showBulkEditCollectionAccess]="organization?.flexibleCollections"
[showBulkAddToCollections]="organization?.flexibleCollections"
[showBulkEditCollectionAccess]="true"
[showBulkAddToCollections]="true"
[viewingOrgVault]="true"
[flexibleCollectionsV1Enabled]="flexibleCollectionsV1Enabled"
[addAccessStatus]="addAccessStatus$ | async"

View File

@ -156,7 +156,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private _flexibleCollectionsV1FlagEnabled: boolean;
protected get flexibleCollectionsV1Enabled(): boolean {
return this._flexibleCollectionsV1FlagEnabled && this.organization?.flexibleCollections;
return this._flexibleCollectionsV1FlagEnabled;
}
protected orgRevokedUsers: OrganizationUserUserDetailsResponse[];

View File

@ -722,6 +722,9 @@
"logIn": {
"message": "Log in"
},
"verifyIdentity": {
"message": "Verify your Identity"
},
"logInInitiated": {
"message": "Log in initiated"
},
@ -2794,12 +2797,6 @@
"userDesc": {
"message": "Access and add items to assigned collections"
},
"manager": {
"message": "Manager"
},
"managerDesc": {
"message": "Create, delete, and manage access in assigned collections"
},
"all": {
"message": "All"
},
@ -4576,12 +4573,6 @@
"permission": {
"message": "Permission"
},
"managerPermissions": {
"message": "Manager Permissions"
},
"adminPermissions": {
"message": "Admin Permissions"
},
"accessEventLogs": {
"message": "Access event logs"
},
@ -4606,9 +4597,6 @@
"deleteAnyCollection": {
"message": "Delete any collection"
},
"manageAssignedCollections": {
"message": "Manage assigned collections"
},
"editAssignedCollections": {
"message": "Edit assigned collections"
},
@ -6669,12 +6657,6 @@
"restrictedCollectionAssignmentDesc": {
"message": "You can only assign collections you manage."
},
"accessAllCollectionsDesc": {
"message": "Grant access to all current and future collections."
},
"accessAllCollectionsHelp": {
"message": "If checked, this will replace all other collection permissions."
},
"selectMembers": {
"message": "Select members"
},
@ -6717,12 +6699,6 @@
"group": {
"message": "Group"
},
"groupAccessAll": {
"message": "This group can access and modify all items."
},
"memberAccessAll": {
"message": "This member can access and modify all items."
},
"domainVerification": {
"message": "Domain verification"
},
@ -8357,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

@ -20,8 +20,6 @@ export class UserTypePipe implements PipeTransform {
return this.i18nService.t("admin");
case OrganizationUserType.User:
return this.i18nService.t("user");
case OrganizationUserType.Manager:
return this.i18nService.t("manager");
case OrganizationUserType.Custom:
return this.i18nService.t("custom");
}

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

@ -1,4 +1,4 @@
import { Observable, Subject, Subscription, firstValueFrom, throwError, timeout } from "rxjs";
import { firstValueFrom, Observable, Subject, Subscription, throwError, timeout } from "rxjs";
/** Test class to enable async awaiting of observable emissions */
export class ObservableTracker<T> {
@ -43,6 +43,9 @@ export class ObservableTracker<T> {
private trackEmissions(observable: Observable<T>): T[] {
const emissions: T[] = [];
this.emissionReceived.subscribe((value) => {
emissions.push(value);
});
this.subscription = observable.subscribe((value) => {
if (value == null) {
this.emissionReceived.next(null);
@ -64,9 +67,7 @@ export class ObservableTracker<T> {
}
}
});
this.emissionReceived.subscribe((value) => {
emissions.push(value);
});
return emissions;
}
}

View File

@ -5,7 +5,6 @@ import { SelectionReadOnlyRequest } from "../../../models/request/selection-read
export class OrganizationUserInviteRequest {
emails: string[] = [];
type: OrganizationUserType;
accessAll: boolean;
accessSecretsManager: boolean;
collections: SelectionReadOnlyRequest[] = [];
groups: string[];

View File

@ -4,7 +4,6 @@ import { SelectionReadOnlyRequest } from "../../../models/request/selection-read
export class OrganizationUserUpdateRequest {
type: OrganizationUserType;
accessAll: boolean;
accessSecretsManager: boolean;
collections: SelectionReadOnlyRequest[] = [];
groups: string[] = [];

View File

@ -10,11 +10,6 @@ export class OrganizationUserResponse extends BaseResponse {
type: OrganizationUserType;
status: OrganizationUserStatusType;
externalId: string;
/**
* @deprecated
* To be removed after Flexible Collections.
**/
accessAll: boolean;
accessSecretsManager: boolean;
permissions: PermissionsApi;
resetPasswordEnrolled: boolean;
@ -30,7 +25,6 @@ export class OrganizationUserResponse extends BaseResponse {
this.status = this.getResponseProperty("Status");
this.permissions = new PermissionsApi(this.getResponseProperty("Permissions"));
this.externalId = this.getResponseProperty("ExternalId");
this.accessAll = this.getResponseProperty("AccessAll");
this.accessSecretsManager = this.getResponseProperty("AccessSecretsManager");
this.resetPasswordEnrolled = this.getResponseProperty("ResetPasswordEnrolled");
this.hasMasterPassword = this.getResponseProperty("HasMasterPassword");

View File

@ -7,7 +7,7 @@ import { OrganizationData } from "../../models/data/organization.data";
import { Organization } from "../../models/domain/organization";
export function canAccessVaultTab(org: Organization): boolean {
return org.canViewAssignedCollections || org.canViewAllCollections;
return org.canViewAllCollections;
}
export function canAccessSettingsTab(org: Organization): boolean {
@ -77,10 +77,7 @@ export function canAccessImportExport(i18nService: I18nService) {
export function canAccessImport(i18nService: I18nService) {
return map<Organization[], Organization[]>((orgs) =>
orgs
.filter(
(org) =>
org.canAccessImportExport || (org.canCreateNewCollections && org.flexibleCollections),
)
.filter((org) => org.canAccessImportExport || org.canCreateNewCollections)
.sort(Utils.getSortFunction(i18nService, "name")),
);
}

View File

@ -142,16 +142,6 @@ export class Organization {
return this.enabled && this.status === OrganizationUserStatusType.Confirmed;
}
/**
* Whether a user has Manager permissions or greater
*
* @deprecated
* This is deprecated with the introduction of Flexible Collections.
*/
get isManager() {
return this.type === OrganizationUserType.Manager || this.isAdmin;
}
/**
* Whether a user has Admin permissions or greater
*/
@ -179,19 +169,13 @@ export class Organization {
}
get canCreateNewCollections() {
if (this.flexibleCollections) {
return (
!this.limitCollectionCreationDeletion ||
this.isAdmin ||
this.permissions.createNewCollections
);
}
return this.isManager || this.permissions.createNewCollections;
return (
!this.limitCollectionCreationDeletion || this.isAdmin || this.permissions.createNewCollections
);
}
canEditAnyCollection(flexibleCollectionsV1Enabled: boolean) {
if (!this.flexibleCollections || !flexibleCollectionsV1Enabled) {
if (!flexibleCollectionsV1Enabled) {
// Pre-Flexible Collections v1 logic
return this.isAdmin || this.permissions.editAnyCollection;
}
@ -221,8 +205,8 @@ export class Organization {
flexibleCollectionsV1Enabled: boolean,
restrictProviderAccessFlagEnabled: boolean,
) {
// Before Flexible Collections, any admin or anyone with editAnyCollection permission could edit all ciphers
if (!this.flexibleCollections || !flexibleCollectionsV1Enabled || !this.flexibleCollections) {
// Before Flexible Collections V1, any admin or anyone with editAnyCollection permission could edit all ciphers
if (!flexibleCollectionsV1Enabled) {
return this.isAdmin || this.permissions.editAnyCollection;
}
@ -269,33 +253,6 @@ export class Organization {
);
}
/**
* @deprecated
* This is deprecated with the introduction of Flexible Collections.
* This will always return false if FlexibleCollections flag is on.
*/
get canEditAssignedCollections() {
return this.isManager || this.permissions.editAssignedCollections;
}
/**
* @deprecated
* This is deprecated with the introduction of Flexible Collections.
* This will always return false if FlexibleCollections flag is on.
*/
get canDeleteAssignedCollections() {
return this.isManager || this.permissions.deleteAssignedCollections;
}
/**
* @deprecated
* This is deprecated with the introduction of Flexible Collections.
* This will always return false if FlexibleCollections flag is on.
*/
get canViewAssignedCollections() {
return this.canDeleteAssignedCollections || this.canEditAssignedCollections;
}
get canManageGroups() {
return (this.isAdmin || this.permissions.manageGroups) && this.useGroups;
}

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

@ -49,15 +49,11 @@ export class CollectionView implements View, ITreeNodeObject {
);
}
if (org?.flexibleCollections) {
return (
org?.canEditAllCiphers(v1FlexibleCollections, restrictProviderAccess) ||
this.manage ||
(this.assigned && !this.readOnly)
);
}
return org?.canEditAnyCollection(false) || (org?.canEditAssignedCollections && this.assigned);
return (
org?.canEditAllCiphers(v1FlexibleCollections, restrictProviderAccess) ||
this.manage ||
(this.assigned && !this.readOnly)
);
}
/**

View File

@ -256,17 +256,14 @@ export class ImportComponent implements OnInit, OnDestroy {
if (!this._importBlockedByPolicy) {
this.formGroup.controls.targetSelector.enable();
}
const flexCollectionEnabled =
organizations.find((x) => x.id == this.organizationId)?.flexibleCollections ?? false;
if (value) {
this.collections$ = Utils.asyncToObservable(() =>
this.collectionService
.getAllDecrypted()
.then((decryptedCollections) =>
decryptedCollections
.filter(
(c2) => c2.organizationId === value && (!flexCollectionEnabled || c2.manage),
)
.filter((c2) => c2.organizationId === value && c2.manage)
.sort(Utils.getSortFunction(this.i18nService, "name")),
),
);

View File

@ -167,11 +167,7 @@ export class ExportComponent implements OnInit, OnDestroy {
}
this.organizations$ = this.organizationService.memberOrganizations$.pipe(
map((orgs) =>
orgs
.filter((org) => org.flexibleCollections)
.sort(Utils.getSortFunction(this.i18nService, "name")),
),
map((orgs) => orgs.sort(Utils.getSortFunction(this.i18nService, "name"))),
);
this.exportForm.controls.vaultSelector.valueChanges

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: "",
});

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