1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-10-04 05:08:06 +02:00

Merge branch 'main' into autofill/pm-6426-create-alarms-manager-and-update-usage-of-long-lived-timeouts-rework

This commit is contained in:
Cesar Gonzalez 2024-05-08 13:31:24 -05:00 committed by GitHub
commit 644b1aa104
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
91 changed files with 1292 additions and 1464 deletions

View File

@ -142,7 +142,15 @@
"configDir": ".storybook", "configDir": ".storybook",
"browserTarget": "components:build", "browserTarget": "components:build",
"compodoc": true, "compodoc": true,
"compodocArgs": ["-p", "./tsconfig.json", "-e", "json", "-d", "."], "compodocArgs": [
"-p",
"./tsconfig.json",
"-e",
"json",
"-d",
".",
"--disableRoutesGraph"
],
"port": 6006 "port": 6006
} }
}, },

View File

@ -374,12 +374,21 @@
"other": { "other": {
"message": "Other" "message": "Other"
}, },
"unlockMethods": {
"message": "Unlock options"
},
"unlockMethodNeededToChangeTimeoutActionDesc": { "unlockMethodNeededToChangeTimeoutActionDesc": {
"message": "Set up an unlock method to change your vault timeout action." "message": "Set up an unlock method to change your vault timeout action."
}, },
"unlockMethodNeeded": { "unlockMethodNeeded": {
"message": "Set up an unlock method in Settings" "message": "Set up an unlock method in Settings"
}, },
"sessionTimeoutHeader": {
"message": "Session timeout"
},
"otherOptions": {
"message": "Other options"
},
"rateExtension": { "rateExtension": {
"message": "Rate the extension" "message": "Rate the extension"
}, },
@ -3023,6 +3032,12 @@
"adminConsole": { "adminConsole": {
"message": "Admin Console" "message": "Admin Console"
}, },
"accountSecurity": {
"message": "Account security"
},
"appearance": {
"message": "Appearance"
},
"errorAssigningTargetCollection": { "errorAssigningTargetCollection": {
"message": "Error assigning target collection." "message": "Error assigning target collection."
}, },

View File

@ -143,15 +143,17 @@ export class LockComponent extends BaseLockComponent {
try { try {
success = await super.unlockBiometric(); success = await super.unlockBiometric();
} catch (e) { } catch (e) {
const error = BiometricErrors[e as BiometricErrorTypes]; const error = BiometricErrors[e?.message as BiometricErrorTypes];
if (error == null) { if (error == null) {
this.logService.error("Unknown error: " + e); this.logService.error("Unknown error: " + e);
return false;
} }
this.biometricError = this.i18nService.t(error.description); this.biometricError = this.i18nService.t(error.description);
} finally {
this.pendingBiometric = false;
} }
this.pendingBiometric = false;
return success; return success;
} }

View File

@ -0,0 +1,140 @@
<app-header>
<div class="left">
<button type="button" routerLink="/tabs/settings">
<span class="header-icon"><i class="bwi bwi-angle-left" aria-hidden="true"></i></span>
<span>{{ "back" | i18n }}</span>
</button>
</div>
<h1 class="center">
<span class="title">{{ "accountSecurity" | i18n }}</span>
</h1>
<div class="right">
<app-pop-out></app-pop-out>
</div>
</app-header>
<main tabindex="-1" [formGroup]="form">
<div class="box list">
<h2 class="box-header">{{ "unlockMethods" | i18n }}</h2>
<div class="box-content single-line">
<div class="box-content-row box-content-row-checkbox" appBoxRow *ngIf="supportsBiometric">
<label for="biometric">{{ "unlockWithBiometrics" | i18n }}</label>
<input id="biometric" type="checkbox" formControlName="biometric" />
</div>
<div
class="box-content-row box-content-row-checkbox"
appBoxRow
*ngIf="supportsBiometric && this.form.value.biometric"
>
<label for="autoBiometricsPrompt">{{ "enableAutoBiometricsPrompt" | i18n }}</label>
<input
id="autoBiometricsPrompt"
type="checkbox"
(change)="updateAutoBiometricsPrompt()"
formControlName="enableAutoBiometricsPrompt"
/>
</div>
<div class="box-content-row box-content-row-checkbox" appBoxRow>
<label for="pin">{{ "unlockWithPin" | i18n }}</label>
<input id="pin" type="checkbox" formControlName="pin" />
</div>
</div>
</div>
<div class="box list">
<h2 class="box-header">{{ "sessionTimeoutHeader" | i18n }}</h2>
<div class="box-content single-line">
<app-callout type="info" *ngIf="vaultTimeoutPolicyCallout | async as policy">
<span *ngIf="policy.timeout && policy.action">
{{
"vaultTimeoutPolicyWithActionInEffect"
| i18n: policy.timeout.hours : policy.timeout.minutes : (policy.action | i18n)
}}
</span>
<span *ngIf="policy.timeout && !policy.action">
{{ "vaultTimeoutPolicyInEffect" | i18n: policy.timeout.hours : policy.timeout.minutes }}
</span>
<span *ngIf="!policy.timeout && policy.action">
{{ "vaultTimeoutActionPolicyInEffect" | i18n: (policy.action | i18n) }}
</span>
</app-callout>
<app-vault-timeout-input
[vaultTimeoutOptions]="vaultTimeoutOptions"
[formControl]="form.controls.vaultTimeout"
ngDefaultControl
>
</app-vault-timeout-input>
<div class="box-content-row display-block" appBoxRow>
<label for="vaultTimeoutAction">{{ "vaultTimeoutAction" | i18n }}</label>
<select
id="vaultTimeoutAction"
name="VaultTimeoutActions"
formControlName="vaultTimeoutAction"
>
<option *ngFor="let action of availableVaultTimeoutActions" [ngValue]="action">
{{ action | i18n }}
</option>
</select>
</div>
<div
*ngIf="!availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock)"
id="unlockMethodHelp"
class="box-footer"
>
{{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}
</div>
</div>
</div>
<div class="box list">
<h2 class="box-header">{{ "otherOptions" | i18n }}</h2>
<div class="box-content single-line">
<button
type="button"
class="box-content-row box-content-row-flex text-default"
appStopClick
(click)="fingerprint()"
>
<div class="row-main">{{ "fingerprintPhrase" | i18n }}</div>
</button>
<button
type="button"
class="box-content-row box-content-row-flex text-default"
appStopClick
(click)="twoStep()"
>
<div class="row-main">{{ "twoStepLogin" | i18n }}</div>
<i class="bwi bwi-external-link bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
<button
type="button"
class="box-content-row box-content-row-flex text-default"
appStopClick
(click)="changePassword()"
*ngIf="showChangeMasterPass"
>
<div class="row-main">{{ "changeMasterPassword" | i18n }}</div>
<i class="bwi bwi-external-link bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
<button
*ngIf="
!accountSwitcherEnabled && availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock)
"
type="button"
class="box-content-row box-content-row-flex text-default"
appStopClick
(click)="lock()"
>
<div class="row-main">{{ "lockNow" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
<button
*ngIf="!accountSwitcherEnabled"
type="button"
class="box-content-row box-content-row-flex text-default"
appStopClick
(click)="logOut()"
>
<div class="row-main">{{ "logOut" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
</div>
</div>
</main>

View File

@ -1,6 +1,5 @@
import { ChangeDetectorRef, Component, OnInit } from "@angular/core"; import { ChangeDetectorRef, Component, OnInit } from "@angular/core";
import { FormBuilder } from "@angular/forms"; import { FormBuilder } from "@angular/forms";
import { Router } from "@angular/router";
import { import {
BehaviorSubject, BehaviorSubject,
combineLatest, combineLatest,
@ -23,7 +22,6 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { DeviceType } from "@bitwarden/common/enums";
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
@ -34,35 +32,20 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv
import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
import { SetPinComponent } from "../../auth/popup/components/set-pin.component"; import { BiometricErrors, BiometricErrorTypes } from "../../../models/biometricErrors";
import { BiometricErrors, BiometricErrorTypes } from "../../models/biometricErrors"; import { BrowserApi } from "../../../platform/browser/browser-api";
import { BrowserApi } from "../../platform/browser/browser-api"; import { enableAccountSwitching } from "../../../platform/flags";
import { enableAccountSwitching } from "../../platform/flags"; import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
import BrowserPopupUtils from "../../platform/popup/browser-popup-utils"; import { SetPinComponent } from "../components/set-pin.component";
import { AboutComponent } from "./about.component";
import { AwaitDesktopDialogComponent } from "./await-desktop-dialog.component"; import { AwaitDesktopDialogComponent } from "./await-desktop-dialog.component";
const RateUrls = {
[DeviceType.ChromeExtension]:
"https://chromewebstore.google.com/detail/bitwarden-free-password-m/nngceckbapebfimnlniiiahkandclblb/reviews",
[DeviceType.FirefoxExtension]:
"https://addons.mozilla.org/en-US/firefox/addon/bitwarden-password-manager/#reviews",
[DeviceType.OperaExtension]:
"https://addons.opera.com/en/extensions/details/bitwarden-free-password-manager/#feedback-container",
[DeviceType.EdgeExtension]:
"https://microsoftedge.microsoft.com/addons/detail/jbkfoedolllekgbhcbcoahefnbanhhlh",
[DeviceType.VivaldiExtension]:
"https://chromewebstore.google.com/detail/bitwarden-free-password-m/nngceckbapebfimnlniiiahkandclblb/reviews",
[DeviceType.SafariExtension]: "https://apps.apple.com/app/bitwarden/id1352778147",
};
@Component({ @Component({
selector: "app-settings", selector: "auth-account-security",
templateUrl: "settings.component.html", templateUrl: "account-security.component.html",
}) })
// eslint-disable-next-line rxjs-angular/prefer-takeuntil // eslint-disable-next-line rxjs-angular/prefer-takeuntil
export class SettingsComponent implements OnInit { export class AccountSecurityComponent implements OnInit {
protected readonly VaultTimeoutAction = VaultTimeoutAction; protected readonly VaultTimeoutAction = VaultTimeoutAction;
availableVaultTimeoutActions: VaultTimeoutAction[] = []; availableVaultTimeoutActions: VaultTimeoutAction[] = [];
@ -95,7 +78,6 @@ export class SettingsComponent implements OnInit {
private vaultTimeoutService: VaultTimeoutService, private vaultTimeoutService: VaultTimeoutService,
private vaultTimeoutSettingsService: VaultTimeoutSettingsService, private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
public messagingService: MessagingService, public messagingService: MessagingService,
private router: Router,
private environmentService: EnvironmentService, private environmentService: EnvironmentService,
private cryptoService: CryptoService, private cryptoService: CryptoService,
private stateService: StateService, private stateService: StateService,
@ -425,23 +407,6 @@ export class SettingsComponent implements OnInit {
); );
} }
async lock() {
await this.vaultTimeoutService.lock();
}
async logOut() {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "logOut" },
content: { key: "logOutConfirmation" },
type: "info",
});
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
if (confirmed) {
this.messagingService.send("logout", { userId: userId });
}
}
async changePassword() { async changePassword() {
const confirmed = await this.dialogService.openSimpleDialog({ const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "continueToWebApp" }, title: { key: "continueToWebApp" },
@ -468,44 +433,6 @@ export class SettingsComponent implements OnInit {
} }
} }
async share() {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "learnOrg" },
content: { key: "learnOrgConfirmation" },
type: "info",
});
if (confirmed) {
// 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.createNewTab("https://bitwarden.com/help/about-organizations/");
}
}
async webVault() {
const env = await firstValueFrom(this.environmentService.environment$);
const url = env.getWebVaultUrl();
await BrowserApi.createNewTab(url);
}
async import() {
await this.router.navigate(["/import"]);
if (await BrowserApi.isPopupOpen()) {
// 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
BrowserPopupUtils.openCurrentPagePopout(window);
}
}
export() {
// 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(["/export"]);
}
about() {
this.dialogService.open(AboutComponent);
}
async fingerprint() { async fingerprint() {
const fingerprint = await this.cryptoService.getFingerprint( const fingerprint = await this.cryptoService.getFingerprint(
await this.stateService.getUserId(), await this.stateService.getUserId(),
@ -518,11 +445,21 @@ export class SettingsComponent implements OnInit {
return firstValueFrom(dialogRef.closed); return firstValueFrom(dialogRef.closed);
} }
rate() { async lock() {
const deviceType = this.platformUtilsService.getDevice(); await this.vaultTimeoutService.lock();
// 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.createNewTab((RateUrls as any)[deviceType]); async logOut() {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "logOut" },
content: { key: "logOutConfirmation" },
type: "info",
});
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
if (confirmed) {
this.messagingService.send("logout", { userId: userId });
}
} }
ngOnDestroy() { ngOnDestroy() {

View File

@ -84,7 +84,6 @@ import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwar
import { LogService as LogServiceAbstraction } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService as LogServiceAbstraction } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { import {
AbstractMemoryStorageService,
AbstractStorageService, AbstractStorageService,
ObservableStorageService, ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service"; } from "@bitwarden/common/platform/abstractions/storage.service";
@ -249,10 +248,9 @@ export default class MainBackground {
messagingService: MessageSender; messagingService: MessageSender;
storageService: BrowserLocalStorageService; storageService: BrowserLocalStorageService;
secureStorageService: AbstractStorageService; secureStorageService: AbstractStorageService;
memoryStorageService: AbstractMemoryStorageService; memoryStorageService: AbstractStorageService;
memoryStorageForStateProviders: AbstractMemoryStorageService & ObservableStorageService; memoryStorageForStateProviders: AbstractStorageService & ObservableStorageService;
largeObjectMemoryStorageForStateProviders: AbstractMemoryStorageService & largeObjectMemoryStorageForStateProviders: AbstractStorageService & ObservableStorageService;
ObservableStorageService;
i18nService: I18nServiceAbstraction; i18nService: I18nServiceAbstraction;
platformUtilsService: PlatformUtilsServiceAbstraction; platformUtilsService: PlatformUtilsServiceAbstraction;
logService: LogServiceAbstraction; logService: LogServiceAbstraction;

View File

@ -1,5 +1,4 @@
import { import {
AbstractMemoryStorageService,
AbstractStorageService, AbstractStorageService,
ObservableStorageService, ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service"; } from "@bitwarden/common/platform/abstractions/storage.service";
@ -66,9 +65,9 @@ export function sessionStorageServiceFactory(
} }
export function memoryStorageServiceFactory( export function memoryStorageServiceFactory(
cache: { memoryStorageService?: AbstractMemoryStorageService } & CachedServices, cache: { memoryStorageService?: AbstractStorageService } & CachedServices,
opts: MemoryStorageServiceInitOptions, opts: MemoryStorageServiceInitOptions,
): Promise<AbstractMemoryStorageService> { ): Promise<AbstractStorageService> {
return factory(cache, "memoryStorageService", opts, async () => { return factory(cache, "memoryStorageService", opts, async () => {
if (BrowserApi.isManifestVersion(3)) { if (BrowserApi.isManifestVersion(3)) {
return new LocalBackedSessionStorageService( return new LocalBackedSessionStorageService(
@ -97,10 +96,10 @@ export function memoryStorageServiceFactory(
export function observableMemoryStorageServiceFactory( export function observableMemoryStorageServiceFactory(
cache: { cache: {
memoryStorageService?: AbstractMemoryStorageService & ObservableStorageService; memoryStorageService?: AbstractStorageService & ObservableStorageService;
} & CachedServices, } & CachedServices,
opts: MemoryStorageServiceInitOptions, opts: MemoryStorageServiceInitOptions,
): Promise<AbstractMemoryStorageService & ObservableStorageService> { ): Promise<AbstractStorageService & ObservableStorageService> {
return factory(cache, "memoryStorageService", opts, async () => { return factory(cache, "memoryStorageService", opts, async () => {
return new BackgroundMemoryStorageService(); return new BackgroundMemoryStorageService();
}); });

View File

@ -1,88 +0,0 @@
import { BehaviorSubject } from "rxjs";
import { AbstractMemoryStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
import { DefaultBrowserStateService } from "../../services/default-browser-state.service";
import { browserSession } from "./browser-session.decorator";
import { SessionStorable } from "./session-storable";
import { sessionSync } from "./session-sync.decorator";
// browserSession initializes SessionSyncers for each sessionSync decorated property
// We don't want to test SessionSyncers, so we'll mock them
jest.mock("./session-syncer");
describe("browserSession decorator", () => {
it("should throw if neither StateService nor MemoryStorageService is a constructor argument", () => {
@browserSession
class TestClass {}
expect(() => {
new TestClass();
}).toThrowError(
"Cannot decorate TestClass with browserSession, Browser's AbstractMemoryStorageService must be accessible through the observed classes parameters",
);
});
it("should create if StateService is a constructor argument", () => {
const stateService = Object.create(DefaultBrowserStateService.prototype, {
memoryStorageService: {
value: Object.create(MemoryStorageService.prototype, {
type: { value: MemoryStorageService.TYPE },
}),
},
});
@browserSession
class TestClass {
constructor(private stateService: DefaultBrowserStateService) {}
}
expect(new TestClass(stateService)).toBeDefined();
});
it("should create if MemoryStorageService is a constructor argument", () => {
const memoryStorageService = Object.create(MemoryStorageService.prototype, {
type: { value: MemoryStorageService.TYPE },
});
@browserSession
class TestClass {
constructor(private memoryStorageService: AbstractMemoryStorageService) {}
}
expect(new TestClass(memoryStorageService)).toBeDefined();
});
describe("interaction with @sessionSync decorator", () => {
let memoryStorageService: MemoryStorageService;
@browserSession
class TestClass {
@sessionSync({ initializer: (s: string) => s })
private behaviorSubject = new BehaviorSubject("");
constructor(private memoryStorageService: MemoryStorageService) {}
fromJSON(json: any) {
this.behaviorSubject.next(json);
}
}
beforeEach(() => {
memoryStorageService = Object.create(MemoryStorageService.prototype, {
type: { value: MemoryStorageService.TYPE },
});
});
it("should create a session syncer", () => {
const testClass = new TestClass(memoryStorageService) as any as SessionStorable;
expect(testClass.__sessionSyncers.length).toEqual(1);
});
it("should initialize the session syncer", () => {
const testClass = new TestClass(memoryStorageService) as any as SessionStorable;
expect(testClass.__sessionSyncers[0].init).toHaveBeenCalled();
});
});
});

View File

@ -1,75 +0,0 @@
import { Constructor } from "type-fest";
import { AbstractMemoryStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
import { SessionStorable } from "./session-storable";
import { SessionSyncer } from "./session-syncer";
import { SyncedItemMetadata } from "./sync-item-metadata";
/**
* Mark the class as syncing state across the browser session. This decorator finds rxjs BehaviorSubject properties
* marked with @sessionSync and syncs these values across the browser session.
*
* @param constructor
* @returns A new constructor that extends the original one to add session syncing.
*/
export function browserSession<TCtor extends Constructor<any>>(constructor: TCtor) {
return class extends constructor implements SessionStorable {
__syncedItemMetadata: SyncedItemMetadata[];
__sessionSyncers: SessionSyncer[];
constructor(...args: any[]) {
super(...args);
// Require state service to be injected
const storageService: AbstractMemoryStorageService = this.findStorageService(
[this as any].concat(args),
);
if (this.__syncedItemMetadata == null || !(this.__syncedItemMetadata instanceof Array)) {
return;
}
this.__sessionSyncers = this.__syncedItemMetadata.map((metadata) =>
this.buildSyncer(metadata, storageService),
);
}
buildSyncer(metadata: SyncedItemMetadata, storageSerice: AbstractMemoryStorageService) {
const syncer = new SessionSyncer(
(this as any)[metadata.propertyKey],
storageSerice,
metadata,
);
// 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
syncer.init();
return syncer;
}
findStorageService(args: any[]): AbstractMemoryStorageService {
const storageService = args.find(this.isMemoryStorageService);
if (storageService) {
return storageService;
}
const stateService = args.find(
(arg) =>
arg?.memoryStorageService != null &&
this.isMemoryStorageService(arg.memoryStorageService),
);
if (stateService) {
return stateService.memoryStorageService;
}
throw new Error(
`Cannot decorate ${constructor.name} with browserSession, Browser's AbstractMemoryStorageService must be accessible through the observed classes parameters`,
);
}
isMemoryStorageService(arg: any): arg is AbstractMemoryStorageService {
return arg.type != null && arg.type === AbstractMemoryStorageService.TYPE;
}
};
}

View File

@ -1,2 +0,0 @@
export { browserSession } from "./browser-session.decorator";
export { sessionSync } from "./session-sync.decorator";

View File

@ -1,7 +0,0 @@
import { SessionSyncer } from "./session-syncer";
import { SyncedItemMetadata } from "./sync-item-metadata";
export interface SessionStorable {
__syncedItemMetadata: SyncedItemMetadata[];
__sessionSyncers: SessionSyncer[];
}

View File

@ -1,57 +0,0 @@
import { BehaviorSubject } from "rxjs";
import { sessionSync } from "./session-sync.decorator";
describe("sessionSync decorator", () => {
const initializer = (s: string) => "test";
class TestClass {
@sessionSync({ initializer: initializer })
private testProperty = new BehaviorSubject("");
@sessionSync({ initializer: initializer, initializeAs: "array" })
private secondTestProperty = new BehaviorSubject("");
complete() {
this.testProperty.complete();
this.secondTestProperty.complete();
}
}
it("should add __syncedItemKeys to prototype", () => {
const testClass = new TestClass();
expect((testClass as any).__syncedItemMetadata).toEqual([
expect.objectContaining({
propertyKey: "testProperty",
sessionKey: "testProperty_0",
initializer: initializer,
}),
expect.objectContaining({
propertyKey: "secondTestProperty",
sessionKey: "secondTestProperty_1",
initializer: initializer,
initializeAs: "array",
}),
]);
testClass.complete();
});
class TestClass2 {
@sessionSync({ initializer: initializer })
private testProperty = new BehaviorSubject("");
complete() {
this.testProperty.complete();
}
}
it("should maintain sessionKey index count for other test classes", () => {
const testClass = new TestClass2();
expect((testClass as any).__syncedItemMetadata).toEqual([
expect.objectContaining({
propertyKey: "testProperty",
sessionKey: "testProperty_2",
initializer: initializer,
}),
]);
testClass.complete();
});
});

View File

@ -1,54 +0,0 @@
import { Jsonify } from "type-fest";
import { SessionStorable } from "./session-storable";
import { InitializeOptions } from "./sync-item-metadata";
class BuildOptions<T, TJson = Jsonify<T>> {
initializer?: (keyValuePair: TJson) => T;
initializeAs?: InitializeOptions;
}
// Used to ensure uniqueness for each synced observable
let index = 0;
/**
* A decorator used to indicate the BehaviorSubject should be synced for this browser session across all contexts.
*
* >**Note** This decorator does nothing if the enclosing class is not decorated with @browserSession.
*
* >**Note** The Behavior subject must be initialized with a default or in the constructor of the class. If it is not, an error will be thrown.
*
* >**!!Warning!!** If the property is overwritten at any time, the new value will not be synced across the browser session.
*
* @param buildOptions
* Builders for the value, requires either a constructor (ctor) for your BehaviorSubject type or an
* initializer function that takes a key value pair representation of the BehaviorSubject data
* and returns your instantiated BehaviorSubject value. `initializeAs can optionally be used to indicate
* the provided initializer function should be used to build an array of values. For example,
* ```ts
* \@sessionSync({ initializer: Foo.fromJSON, initializeAs: 'array' })
* ```
* is equivalent to
* ```
* \@sessionSync({ initializer: (obj: any[]) => obj.map((f) => Foo.fromJSON })
* ```
*
* @returns decorator function
*/
export function sessionSync<T>(buildOptions: BuildOptions<T>) {
return (prototype: unknown, propertyKey: string) => {
// Force prototype into SessionStorable and implement it.
const p = prototype as SessionStorable;
if (p.__syncedItemMetadata == null) {
p.__syncedItemMetadata = [];
}
p.__syncedItemMetadata.push({
propertyKey,
sessionKey: `${propertyKey}_${index++}`,
initializer: buildOptions.initializer,
initializeAs: buildOptions.initializeAs ?? "object",
});
};
}

View File

@ -1,301 +0,0 @@
import { awaitAsync } from "@bitwarden/common/../spec/utils";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, ReplaySubject } from "rxjs";
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
import { BrowserApi } from "../../browser/browser-api";
import { SessionSyncer } from "./session-syncer";
import { SyncedItemMetadata } from "./sync-item-metadata";
describe("session syncer", () => {
const propertyKey = "behaviorSubject";
const sessionKey = "Test__" + propertyKey;
const metaData: SyncedItemMetadata = {
propertyKey,
sessionKey,
initializer: (s: string) => s,
initializeAs: "object",
};
let storageService: MockProxy<MemoryStorageService>;
let sut: SessionSyncer;
let behaviorSubject: BehaviorSubject<string>;
beforeEach(() => {
behaviorSubject = new BehaviorSubject<string>("");
jest.spyOn(chrome.runtime, "getManifest").mockReturnValue({
name: "bitwarden-test",
version: "0.0.0",
manifest_version: 3,
});
storageService = mock();
storageService.has.mockResolvedValue(false);
sut = new SessionSyncer(behaviorSubject, storageService, metaData);
});
afterEach(() => {
jest.resetAllMocks();
behaviorSubject.complete();
});
describe("constructor", () => {
it("should throw if subject is not an instance of Subject", () => {
expect(() => {
new SessionSyncer({} as any, storageService, null);
}).toThrowError("subject must inherit from Subject");
});
it("should create if either ctor or initializer is provided", () => {
expect(
new SessionSyncer(behaviorSubject, storageService, {
propertyKey,
sessionKey,
initializeAs: "object",
initializer: () => null,
}),
).toBeDefined();
expect(
new SessionSyncer(behaviorSubject, storageService, {
propertyKey,
sessionKey,
initializer: (s: any) => s,
initializeAs: "object",
}),
).toBeDefined();
});
it("should throw if neither ctor or initializer is provided", () => {
expect(() => {
new SessionSyncer(behaviorSubject, storageService, {
propertyKey,
sessionKey,
initializeAs: "object",
initializer: null,
});
}).toThrowError("initializer must be provided");
});
});
describe("init", () => {
it("should ignore all updates currently in a ReplaySubject's buffer", () => {
const replaySubject = new ReplaySubject<string>(Infinity);
replaySubject.next("1");
replaySubject.next("2");
replaySubject.next("3");
sut = new SessionSyncer(replaySubject, storageService, metaData);
// block observing the subject
jest.spyOn(sut as any, "observe").mockImplementation();
// 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
sut.init();
expect(sut["ignoreNUpdates"]).toBe(3);
});
it("should ignore BehaviorSubject's initial value", () => {
const behaviorSubject = new BehaviorSubject<string>("initial");
sut = new SessionSyncer(behaviorSubject, storageService, metaData);
// block observing the subject
jest.spyOn(sut as any, "observe").mockImplementation();
// 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
sut.init();
expect(sut["ignoreNUpdates"]).toBe(1);
});
it("should grab an initial value from storage if it exists", async () => {
storageService.has.mockResolvedValue(true);
//Block a call to update
const updateSpy = jest.spyOn(sut as any, "updateFromMemory").mockImplementation();
// 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
sut.init();
await awaitAsync();
expect(updateSpy).toHaveBeenCalledWith();
});
it("should not grab an initial value from storage if it does not exist", async () => {
storageService.has.mockResolvedValue(false);
//Block a call to update
const updateSpy = jest.spyOn(sut as any, "update").mockImplementation();
// 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
sut.init();
await awaitAsync();
expect(updateSpy).not.toHaveBeenCalled();
});
});
describe("a value is emitted on the observable", () => {
let sendMessageSpy: jest.SpyInstance;
const value = "test";
const serializedValue = JSON.stringify(value);
beforeEach(() => {
sendMessageSpy = jest.spyOn(BrowserApi, "sendMessage");
// 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
sut.init();
behaviorSubject.next(value);
});
it("should update sessionSyncers in other contexts", async () => {
// await finishing of fire-and-forget operation
await new Promise((resolve) => setTimeout(resolve, 100));
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
expect(sendMessageSpy).toHaveBeenCalledWith(`${sessionKey}_update`, {
id: sut.id,
serializedValue,
});
});
});
describe("A message is received", () => {
let nextSpy: jest.SpyInstance;
let sendMessageSpy: jest.SpyInstance;
beforeEach(() => {
nextSpy = jest.spyOn(behaviorSubject, "next");
sendMessageSpy = jest.spyOn(BrowserApi, "sendMessage");
// 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
sut.init();
});
afterEach(() => {
jest.resetAllMocks();
});
it("should ignore messages with the wrong command", async () => {
await sut.updateFromMessage({ command: "wrong_command", id: sut.id });
expect(storageService.getBypassCache).not.toHaveBeenCalled();
expect(nextSpy).not.toHaveBeenCalled();
});
it("should ignore messages from itself", async () => {
await sut.updateFromMessage({ command: `${sessionKey}_update`, id: sut.id });
expect(storageService.getBypassCache).not.toHaveBeenCalled();
expect(nextSpy).not.toHaveBeenCalled();
});
it("should update from message on emit from another instance", async () => {
const builder = jest.fn();
jest.spyOn(SyncedItemMetadata, "builder").mockReturnValue(builder);
const value = "test";
const serializedValue = JSON.stringify(value);
builder.mockReturnValue(value);
// Expect no circular messaging
await awaitAsync();
expect(sendMessageSpy).toHaveBeenCalledTimes(0);
await sut.updateFromMessage({
command: `${sessionKey}_update`,
id: "different_id",
serializedValue,
});
await awaitAsync();
expect(storageService.getBypassCache).toHaveBeenCalledTimes(0);
expect(nextSpy).toHaveBeenCalledTimes(1);
expect(nextSpy).toHaveBeenCalledWith(value);
expect(behaviorSubject.value).toBe(value);
// Expect no circular messaging
expect(sendMessageSpy).toHaveBeenCalledTimes(0);
});
});
describe("memory storage", () => {
const value = "test";
const serializedValue = JSON.stringify(value);
let saveSpy: jest.SpyInstance;
const builder = jest.fn().mockReturnValue(value);
const manifestVersionSpy = jest.spyOn(BrowserApi, "manifestVersion", "get");
const isBackgroundPageSpy = jest.spyOn(BrowserApi, "isBackgroundPage");
beforeEach(async () => {
jest.spyOn(SyncedItemMetadata, "builder").mockReturnValue(builder);
saveSpy = jest.spyOn(storageService, "save");
// 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
sut.init();
await awaitAsync();
});
afterEach(() => {
jest.resetAllMocks();
});
it("should always store on observed next for manifest version 3", async () => {
manifestVersionSpy.mockReturnValue(3);
isBackgroundPageSpy.mockReturnValueOnce(true).mockReturnValueOnce(false);
behaviorSubject.next(value);
await awaitAsync();
behaviorSubject.next(value);
await awaitAsync();
expect(saveSpy).toHaveBeenCalledTimes(2);
});
it("should not store on message receive for manifest version 3", async () => {
manifestVersionSpy.mockReturnValue(3);
isBackgroundPageSpy.mockReturnValueOnce(true).mockReturnValueOnce(false);
await sut.updateFromMessage({
command: `${sessionKey}_update`,
id: "different_id",
serializedValue,
});
await awaitAsync();
expect(saveSpy).toHaveBeenCalledTimes(0);
});
it("should store on message receive for manifest version 2 for background page only", async () => {
manifestVersionSpy.mockReturnValue(2);
isBackgroundPageSpy.mockReturnValueOnce(true).mockReturnValueOnce(false);
await sut.updateFromMessage({
command: `${sessionKey}_update`,
id: "different_id",
serializedValue,
});
await awaitAsync();
await sut.updateFromMessage({
command: `${sessionKey}_update`,
id: "different_id",
serializedValue,
});
await awaitAsync();
expect(saveSpy).toHaveBeenCalledTimes(1);
});
it("should store on observed next for manifest version 2 for background page only", async () => {
manifestVersionSpy.mockReturnValue(2);
isBackgroundPageSpy.mockReturnValueOnce(true).mockReturnValueOnce(false);
behaviorSubject.next(value);
await awaitAsync();
behaviorSubject.next(value);
await awaitAsync();
expect(saveSpy).toHaveBeenCalledTimes(1);
});
});
});

View File

@ -1,125 +0,0 @@
import { BehaviorSubject, concatMap, ReplaySubject, skip, Subject, Subscription } from "rxjs";
import { AbstractMemoryStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { BrowserApi } from "../../browser/browser-api";
import { SyncedItemMetadata } from "./sync-item-metadata";
export class SessionSyncer {
subscription: Subscription;
id = Utils.newGuid();
// ignore initial values
private ignoreNUpdates = 0;
constructor(
private subject: Subject<any>,
private memoryStorageService: AbstractMemoryStorageService,
private metaData: SyncedItemMetadata,
) {
if (!(subject instanceof Subject)) {
throw new Error("subject must inherit from Subject");
}
if (metaData.initializer == null) {
throw new Error("initializer must be provided");
}
}
async init() {
switch (this.subject.constructor) {
case ReplaySubject:
// ignore all updates currently in the buffer
this.ignoreNUpdates = (this.subject as any)._buffer.length;
break;
case BehaviorSubject:
this.ignoreNUpdates = 1;
break;
default:
break;
}
await this.observe();
// must be synchronous
const hasInSessionMemory = await this.memoryStorageService.has(this.metaData.sessionKey);
if (hasInSessionMemory) {
await this.updateFromMemory();
}
this.listenForUpdates();
}
private async observe() {
const stream = this.subject.pipe(skip(this.ignoreNUpdates));
this.ignoreNUpdates = 0;
// This may be a memory leak.
// There is no good time to unsubscribe from this observable. Hopefully Manifest V3 clears memory from temporary
// contexts. If so, this is handled by destruction of the context.
this.subscription = stream
.pipe(
concatMap(async (next) => {
if (this.ignoreNUpdates > 0) {
this.ignoreNUpdates -= 1;
return;
}
await this.updateSession(next);
}),
)
.subscribe();
}
private listenForUpdates() {
// This is an unawaited promise, but it will be executed asynchronously in the background.
BrowserApi.messageListener(this.updateMessageCommand, (message) => {
// 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.updateFromMessage(message);
});
}
async updateFromMessage(message: any) {
if (message.command != this.updateMessageCommand || message.id === this.id) {
return;
}
await this.update(message.serializedValue);
}
async updateFromMemory() {
const value = await this.memoryStorageService.getBypassCache(this.metaData.sessionKey);
await this.update(value);
}
async update(serializedValue: any) {
if (!serializedValue) {
return;
}
const unBuiltValue = JSON.parse(serializedValue);
if (!BrowserApi.isManifestVersion(3) && BrowserApi.isBackgroundPage(self)) {
await this.memoryStorageService.save(this.metaData.sessionKey, serializedValue);
}
const builder = SyncedItemMetadata.builder(this.metaData);
const value = builder(unBuiltValue);
this.ignoreNUpdates = 1;
this.subject.next(value);
}
private async updateSession(value: any) {
if (!value) {
return;
}
const serializedValue = JSON.stringify(value);
if (BrowserApi.isManifestVersion(3) || BrowserApi.isBackgroundPage(self)) {
await this.memoryStorageService.save(this.metaData.sessionKey, serializedValue);
}
await BrowserApi.sendMessage(this.updateMessageCommand, { id: this.id, serializedValue });
}
private get updateMessageCommand() {
return `${this.metaData.sessionKey}_update`;
}
}

View File

@ -1,25 +0,0 @@
export type InitializeOptions = "array" | "record" | "object";
export class SyncedItemMetadata {
propertyKey: string;
sessionKey: string;
initializer: (keyValuePair: any) => any;
initializeAs: InitializeOptions;
static builder(metadata: SyncedItemMetadata): (o: any) => any {
const itemBuilder = metadata.initializer;
if (metadata.initializeAs === "array") {
return (keyValuePair: any) => keyValuePair.map((o: any) => itemBuilder(o));
} else if (metadata.initializeAs === "record") {
return (keyValuePair: any) => {
const record: Record<any, any> = {};
for (const key in keyValuePair) {
record[key] = itemBuilder(keyValuePair[key]);
}
return record;
};
} else {
return (keyValuePair: any) => itemBuilder(keyValuePair);
}
}
}

View File

@ -1,42 +0,0 @@
import { SyncedItemMetadata } from "./sync-item-metadata";
describe("builder", () => {
const propertyKey = "propertyKey";
const key = "key";
const initializer = (s: any) => "used initializer";
it("should use initializer", () => {
const metadata: SyncedItemMetadata = {
propertyKey,
sessionKey: key,
initializer,
initializeAs: "object",
};
const builder = SyncedItemMetadata.builder(metadata);
expect(builder({})).toBe("used initializer");
});
it("should honor initialize as array", () => {
const metadata: SyncedItemMetadata = {
propertyKey,
sessionKey: key,
initializer: initializer,
initializeAs: "array",
};
const builder = SyncedItemMetadata.builder(metadata);
expect(builder([{}])).toBeInstanceOf(Array);
expect(builder([{}])[0]).toBe("used initializer");
});
it("should honor initialize as record", () => {
const metadata: SyncedItemMetadata = {
propertyKey,
sessionKey: key,
initializer: initializer,
initializeAs: "record",
};
const builder = SyncedItemMetadata.builder(metadata);
expect(builder({ key: "" })).toBeInstanceOf(Object);
expect(builder({ key: "" })).toStrictEqual({ key: "used initializer" });
});
});

View File

@ -315,13 +315,13 @@ export default {
importProvidersFrom( importProvidersFrom(
RouterModule.forRoot( RouterModule.forRoot(
[ [
{ path: "", redirectTo: "vault", pathMatch: "full" }, { path: "", redirectTo: "tabs/vault", pathMatch: "full" },
{ path: "vault", component: MockVaultPageComponent }, { path: "tabs/vault", component: MockVaultPageComponent },
{ path: "generator", component: MockGeneratorPageComponent }, { path: "tabs/generator", component: MockGeneratorPageComponent },
{ path: "send", component: MockSendPageComponent }, { path: "tabs/send", component: MockSendPageComponent },
{ path: "settings", component: MockSettingsPageComponent }, { path: "tabs/settings", component: MockSettingsPageComponent },
// in case you are coming from a story that also uses the router // in case you are coming from a story that also uses the router
{ path: "**", redirectTo: "vault" }, { path: "**", redirectTo: "tabs/vault" },
], ],
{ useHash: true }, { useHash: true },
), ),

View File

@ -1,16 +1,7 @@
import { AbstractMemoryStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
import AbstractChromeStorageService from "./abstractions/abstract-chrome-storage-api.service"; import AbstractChromeStorageService from "./abstractions/abstract-chrome-storage-api.service";
export default class BrowserMemoryStorageService export default class BrowserMemoryStorageService extends AbstractChromeStorageService {
extends AbstractChromeStorageService
implements AbstractMemoryStorageService
{
constructor() { constructor() {
super(chrome.storage.session); super(chrome.storage.session);
} }
type = "MemoryStorageService" as const;
getBypassCache<T>(key: string): Promise<T> {
return this.get(key);
}
} }

View File

@ -3,10 +3,7 @@ import { mock, MockProxy } from "jest-mock-extended";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
AbstractMemoryStorageService,
AbstractStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
import { State } from "@bitwarden/common/platform/models/domain/state"; import { State } from "@bitwarden/common/platform/models/domain/state";
@ -18,9 +15,6 @@ import { Account } from "../../models/account";
import { DefaultBrowserStateService } from "./default-browser-state.service"; import { DefaultBrowserStateService } from "./default-browser-state.service";
// disable session syncing to just test class
jest.mock("../decorators/session-sync-observable/");
describe("Browser State Service", () => { describe("Browser State Service", () => {
let secureStorageService: MockProxy<AbstractStorageService>; let secureStorageService: MockProxy<AbstractStorageService>;
let diskStorageService: MockProxy<AbstractStorageService>; let diskStorageService: MockProxy<AbstractStorageService>;
@ -56,7 +50,7 @@ describe("Browser State Service", () => {
}); });
describe("state methods", () => { describe("state methods", () => {
let memoryStorageService: MockProxy<AbstractMemoryStorageService>; let memoryStorageService: MockProxy<AbstractStorageService>;
beforeEach(() => { beforeEach(() => {
memoryStorageService = mock(); memoryStorageService = mock();

View File

@ -2,10 +2,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
AbstractStorageService,
AbstractMemoryStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options"; import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options";
@ -25,7 +22,7 @@ export class DefaultBrowserStateService
constructor( constructor(
storageService: AbstractStorageService, storageService: AbstractStorageService,
secureStorageService: AbstractStorageService, secureStorageService: AbstractStorageService,
memoryStorageService: AbstractMemoryStorageService, memoryStorageService: AbstractStorageService,
logService: LogService, logService: LogService,
stateFactory: StateFactory<GlobalState, Account>, stateFactory: StateFactory<GlobalState, Account>,
accountService: AccountService, accountService: AccountService,

View File

@ -59,24 +59,12 @@ describe("LocalBackedSessionStorage", () => {
await sut.get("test"); await sut.get("test");
expect(sut["cache"]["test"]).toEqual("decrypted"); expect(sut["cache"]["test"]).toEqual("decrypted");
}); });
});
describe("getBypassCache", () => {
it("ignores cached values", async () => {
sut["cache"]["test"] = "cached";
const encrypted = makeEncString("encrypted");
localStorage.internalStore["session_test"] = encrypted.encryptedString;
encryptService.decryptToUtf8.mockResolvedValue(JSON.stringify("decrypted"));
const result = await sut.getBypassCache("test");
expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(encrypted, sessionKey);
expect(result).toEqual("decrypted");
});
it("returns a decrypted value when one is stored in local storage", async () => { it("returns a decrypted value when one is stored in local storage", async () => {
const encrypted = makeEncString("encrypted"); const encrypted = makeEncString("encrypted");
localStorage.internalStore["session_test"] = encrypted.encryptedString; localStorage.internalStore["session_test"] = encrypted.encryptedString;
encryptService.decryptToUtf8.mockResolvedValue(JSON.stringify("decrypted")); encryptService.decryptToUtf8.mockResolvedValue(JSON.stringify("decrypted"));
const result = await sut.getBypassCache("test"); const result = await sut.get("test");
expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(encrypted, sessionKey); expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(encrypted, sessionKey);
expect(result).toEqual("decrypted"); expect(result).toEqual("decrypted");
}); });
@ -85,19 +73,9 @@ describe("LocalBackedSessionStorage", () => {
const encrypted = makeEncString("encrypted"); const encrypted = makeEncString("encrypted");
localStorage.internalStore["session_test"] = encrypted.encryptedString; localStorage.internalStore["session_test"] = encrypted.encryptedString;
encryptService.decryptToUtf8.mockResolvedValue(JSON.stringify("decrypted")); encryptService.decryptToUtf8.mockResolvedValue(JSON.stringify("decrypted"));
await sut.getBypassCache("test"); await sut.get("test");
expect(sut["cache"]["test"]).toEqual("decrypted"); expect(sut["cache"]["test"]).toEqual("decrypted");
}); });
it("deserializes when a deserializer is provided", async () => {
const encrypted = makeEncString("encrypted");
localStorage.internalStore["session_test"] = encrypted.encryptedString;
encryptService.decryptToUtf8.mockResolvedValue(JSON.stringify("decrypted"));
const deserializer = jest.fn().mockReturnValue("deserialized");
const result = await sut.getBypassCache("test", { deserializer });
expect(deserializer).toHaveBeenCalledWith("decrypted");
expect(result).toEqual("deserialized");
});
}); });
describe("has", () => { describe("has", () => {

View File

@ -1,18 +1,16 @@
import { Subject } from "rxjs"; import { Subject } from "rxjs";
import { Jsonify } from "type-fest";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { import {
AbstractMemoryStorageService,
AbstractStorageService, AbstractStorageService,
ObservableStorageService, ObservableStorageService,
StorageUpdate, StorageUpdate,
} from "@bitwarden/common/platform/abstractions/storage.service"; } from "@bitwarden/common/platform/abstractions/storage.service";
import { Lazy } from "@bitwarden/common/platform/misc/lazy"; import { Lazy } from "@bitwarden/common/platform/misc/lazy";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { MemoryStorageOptions } from "@bitwarden/common/platform/models/domain/storage-options"; import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { BrowserApi } from "../browser/browser-api"; import { BrowserApi } from "../browser/browser-api";
@ -20,7 +18,7 @@ import { MemoryStoragePortMessage } from "../storage/port-messages";
import { portName } from "../storage/port-name"; import { portName } from "../storage/port-name";
export class LocalBackedSessionStorageService export class LocalBackedSessionStorageService
extends AbstractMemoryStorageService extends AbstractStorageService
implements ObservableStorageService implements ObservableStorageService
{ {
private ports: Set<chrome.runtime.Port> = new Set([]); private ports: Set<chrome.runtime.Port> = new Set([]);
@ -65,20 +63,12 @@ export class LocalBackedSessionStorageService
}); });
} }
async get<T>(key: string, options?: MemoryStorageOptions<T>): Promise<T> { async get<T>(key: string, options?: StorageOptions): Promise<T> {
if (this.cache[key] !== undefined) { if (this.cache[key] !== undefined) {
return this.cache[key] as T; return this.cache[key] as T;
} }
return await this.getBypassCache(key, options); const value = await this.getLocalSessionValue(await this.sessionKey.get(), key);
}
async getBypassCache<T>(key: string, options?: MemoryStorageOptions<T>): Promise<T> {
let value = await this.getLocalSessionValue(await this.sessionKey.get(), key);
if (options?.deserializer != null) {
value = options.deserializer(value as Jsonify<T>);
}
this.cache[key] = value; this.cache[key] = value;
return value as T; return value as T;
@ -159,7 +149,6 @@ export class LocalBackedSessionStorageService
switch (message.action) { switch (message.action) {
case "get": case "get":
case "getBypassCache":
case "has": { case "has": {
result = await this[message.action](message.key); result = await this[message.action](message.key);
break; break;

View File

@ -51,7 +51,6 @@ export class BackgroundMemoryStorageService extends MemoryStorageService {
switch (message.action) { switch (message.action) {
case "get": case "get":
case "getBypassCache":
case "has": { case "has": {
result = await this[message.action](message.key); result = await this[message.action](message.key);
break; break;

View File

@ -1,7 +1,7 @@
import { Observable, Subject, filter, firstValueFrom, map } from "rxjs"; import { Observable, Subject, filter, firstValueFrom, map } from "rxjs";
import { import {
AbstractMemoryStorageService, AbstractStorageService,
StorageUpdate, StorageUpdate,
} from "@bitwarden/common/platform/abstractions/storage.service"; } from "@bitwarden/common/platform/abstractions/storage.service";
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
@ -11,7 +11,7 @@ import { fromChromeEvent } from "../browser/from-chrome-event";
import { MemoryStoragePortMessage } from "./port-messages"; import { MemoryStoragePortMessage } from "./port-messages";
import { portName } from "./port-name"; import { portName } from "./port-name";
export class ForegroundMemoryStorageService extends AbstractMemoryStorageService { export class ForegroundMemoryStorageService extends AbstractStorageService {
private _port: chrome.runtime.Port; private _port: chrome.runtime.Port;
private _backgroundResponses$: Observable<MemoryStoragePortMessage>; private _backgroundResponses$: Observable<MemoryStoragePortMessage>;
private updatesSubject = new Subject<StorageUpdate>(); private updatesSubject = new Subject<StorageUpdate>();
@ -59,9 +59,6 @@ export class ForegroundMemoryStorageService extends AbstractMemoryStorageService
async get<T>(key: string): Promise<T> { async get<T>(key: string): Promise<T> {
return await this.delegateToBackground<T>("get", key); return await this.delegateToBackground<T>("get", key);
} }
async getBypassCache<T>(key: string): Promise<T> {
return await this.delegateToBackground<T>("getBypassCache", key);
}
async has(key: string): Promise<boolean> { async has(key: string): Promise<boolean> {
return await this.delegateToBackground<boolean>("has", key); return await this.delegateToBackground<boolean>("has", key);
} }

View File

@ -25,9 +25,9 @@ describe("foreground background memory storage interaction", () => {
jest.resetAllMocks(); jest.resetAllMocks();
}); });
test.each(["has", "get", "getBypassCache"])( test.each(["has", "get"])(
"background should respond with the correct value for %s", "background should respond with the correct value for %s",
async (action: "get" | "has" | "getBypassCache") => { async (action: "get" | "has") => {
const key = "key"; const key = "key";
const value = "value"; const value = "value";
background[action] = jest.fn().mockResolvedValue(value); background[action] = jest.fn().mockResolvedValue(value);

View File

@ -1,5 +1,5 @@
import { import {
AbstractMemoryStorageService, AbstractStorageService,
StorageUpdate, StorageUpdate,
} from "@bitwarden/common/platform/abstractions/storage.service"; } from "@bitwarden/common/platform/abstractions/storage.service";
@ -14,7 +14,7 @@ type MemoryStoragePortMessage = {
data: string | string[] | StorageUpdate; data: string | string[] | StorageUpdate;
originator: "foreground" | "background"; originator: "foreground" | "background";
action?: action?:
| keyof Pick<AbstractMemoryStorageService, "get" | "getBypassCache" | "has" | "save" | "remove"> | keyof Pick<AbstractStorageService, "get" | "has" | "save" | "remove">
| "subject_update" | "subject_update"
| "initialization"; | "initialization";
}; };

View File

@ -174,20 +174,27 @@ export const routerTransition = trigger("routerTransition", [
transition("clone-cipher => attachments, clone-cipher => collections", inSlideLeft), transition("clone-cipher => attachments, clone-cipher => collections", inSlideLeft),
transition("attachments => clone-cipher, collections => clone-cipher", outSlideRight), transition("attachments => clone-cipher, collections => clone-cipher", outSlideRight),
transition("tabs => import", inSlideLeft), transition("tabs => account-security", inSlideLeft),
transition("import => tabs", outSlideRight), transition("account-security => tabs", outSlideRight),
transition("tabs => export", inSlideLeft), // Vault settings
transition("export => tabs", outSlideRight), transition("tabs => vault-settings", inSlideLeft),
transition("vault-settings => tabs", outSlideRight),
transition("tabs => folders", inSlideLeft), transition("vault-settings => import", inSlideLeft),
transition("folders => tabs", outSlideRight), transition("import => vault-settings", outSlideRight),
transition("vault-settings => export", inSlideLeft),
transition("export => vault-settings", outSlideRight),
transition("vault-settings => folders", inSlideLeft),
transition("folders => vault-settings", outSlideRight),
transition("folders => edit-folder, folders => add-folder", inSlideUp), transition("folders => edit-folder, folders => add-folder", inSlideUp),
transition("edit-folder => folders, add-folder => folders", outSlideDown), transition("edit-folder => folders, add-folder => folders", outSlideDown),
transition("tabs => sync", inSlideLeft), transition("vault-settings => sync", inSlideLeft),
transition("sync => tabs", outSlideRight), transition("sync => vault-settings", outSlideRight),
transition("tabs => excluded-domains", inSlideLeft), transition("tabs => excluded-domains", inSlideLeft),
transition("excluded-domains => tabs", outSlideRight), transition("excluded-domains => tabs", outSlideRight),
@ -195,6 +202,10 @@ export const routerTransition = trigger("routerTransition", [
transition("tabs => options", inSlideLeft), transition("tabs => options", inSlideLeft),
transition("options => tabs", outSlideRight), transition("options => tabs", outSlideRight),
// Appearance settings
transition("tabs => appearance", inSlideLeft),
transition("appearance => tabs", outSlideRight),
transition("tabs => premium", inSlideLeft), transition("tabs => premium", inSlideLeft),
transition("premium => tabs", outSlideRight), transition("premium => tabs", outSlideRight),

View File

@ -21,6 +21,7 @@ import { LoginComponent } from "../auth/popup/login.component";
import { RegisterComponent } from "../auth/popup/register.component"; import { RegisterComponent } from "../auth/popup/register.component";
import { RemovePasswordComponent } from "../auth/popup/remove-password.component"; import { RemovePasswordComponent } from "../auth/popup/remove-password.component";
import { SetPasswordComponent } from "../auth/popup/set-password.component"; import { SetPasswordComponent } from "../auth/popup/set-password.component";
import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component";
import { SsoComponent } from "../auth/popup/sso.component"; import { SsoComponent } from "../auth/popup/sso.component";
import { TwoFactorOptionsComponent } from "../auth/popup/two-factor-options.component"; import { TwoFactorOptionsComponent } from "../auth/popup/two-factor-options.component";
import { TwoFactorComponent } from "../auth/popup/two-factor.component"; import { TwoFactorComponent } from "../auth/popup/two-factor.component";
@ -35,6 +36,7 @@ import { SendGroupingsComponent } from "../tools/popup/send/send-groupings.compo
import { SendTypeComponent } from "../tools/popup/send/send-type.component"; import { SendTypeComponent } from "../tools/popup/send/send-type.component";
import { ExportComponent } from "../tools/popup/settings/export.component"; import { ExportComponent } from "../tools/popup/settings/export.component";
import { ImportBrowserComponent } from "../tools/popup/settings/import/import-browser.component"; import { ImportBrowserComponent } from "../tools/popup/settings/import/import-browser.component";
import { SettingsComponent } from "../tools/popup/settings/settings.component";
import { Fido2Component } from "../vault/popup/components/fido2/fido2.component"; import { Fido2Component } from "../vault/popup/components/fido2/fido2.component";
import { AddEditComponent } from "../vault/popup/components/vault/add-edit.component"; import { AddEditComponent } from "../vault/popup/components/vault/add-edit.component";
import { AttachmentsComponent } from "../vault/popup/components/vault/attachments.component"; import { AttachmentsComponent } from "../vault/popup/components/vault/attachments.component";
@ -44,17 +46,19 @@ import { PasswordHistoryComponent } from "../vault/popup/components/vault/passwo
import { ShareComponent } from "../vault/popup/components/vault/share.component"; import { ShareComponent } from "../vault/popup/components/vault/share.component";
import { VaultFilterComponent } from "../vault/popup/components/vault/vault-filter.component"; import { VaultFilterComponent } from "../vault/popup/components/vault/vault-filter.component";
import { VaultItemsComponent } from "../vault/popup/components/vault/vault-items.component"; 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 { ViewComponent } from "../vault/popup/components/vault/view.component";
import { AppearanceComponent } from "../vault/popup/settings/appearance.component";
import { FolderAddEditComponent } from "../vault/popup/settings/folder-add-edit.component"; import { FolderAddEditComponent } from "../vault/popup/settings/folder-add-edit.component";
import { FoldersComponent } from "../vault/popup/settings/folders.component";
import { SyncComponent } from "../vault/popup/settings/sync.component";
import { VaultSettingsComponent } from "../vault/popup/settings/vault-settings.component";
import { extensionRefreshRedirect, extensionRefreshSwap } from "./extension-refresh-route-utils"; import { extensionRefreshRedirect, extensionRefreshSwap } from "./extension-refresh-route-utils";
import { debounceNavigationGuard } from "./services/debounce-navigation.service"; import { debounceNavigationGuard } from "./services/debounce-navigation.service";
import { ExcludedDomainsComponent } from "./settings/excluded-domains.component"; import { ExcludedDomainsComponent } from "./settings/excluded-domains.component";
import { FoldersComponent } from "./settings/folders.component";
import { HelpAndFeedbackComponent } from "./settings/help-and-feedback.component"; import { HelpAndFeedbackComponent } from "./settings/help-and-feedback.component";
import { OptionsComponent } from "./settings/options.component"; import { OptionsComponent } from "./settings/options.component";
import { SettingsComponent } from "./settings/settings.component";
import { SyncComponent } from "./settings/sync.component";
import { TabsV2Component } from "./tabs-v2.component"; import { TabsV2Component } from "./tabs-v2.component";
import { TabsComponent } from "./tabs.component"; import { TabsComponent } from "./tabs.component";
@ -246,6 +250,18 @@ const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
data: { state: "autofill" }, data: { state: "autofill" },
}, },
{
path: "account-security",
component: AccountSecurityComponent,
canActivate: [AuthGuard],
data: { state: "account-security" },
},
{
path: "vault-settings",
component: VaultSettingsComponent,
canActivate: [AuthGuard],
data: { state: "vault-settings" },
},
{ {
path: "folders", path: "folders",
component: FoldersComponent, component: FoldersComponent,
@ -288,6 +304,12 @@ const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
data: { state: "options" }, data: { state: "options" },
}, },
{
path: "appearance",
component: AppearanceComponent,
canActivate: [AuthGuard],
data: { state: "appearance" },
},
{ {
path: "clone-cipher", path: "clone-cipher",
component: AddEditComponent, component: AddEditComponent,
@ -341,12 +363,11 @@ const routes: Routes = [
data: { state: "tabs_current" }, data: { state: "tabs_current" },
runGuardsAndResolvers: "always", runGuardsAndResolvers: "always",
}, },
{ ...extensionRefreshSwap(VaultFilterComponent, VaultV2Component, {
path: "vault", path: "vault",
component: VaultFilterComponent,
canActivate: [AuthGuard], canActivate: [AuthGuard],
data: { state: "tabs_vault" }, data: { state: "tabs_vault" },
}, }),
{ {
path: "generator", path: "generator",
component: GeneratorComponent, component: GeneratorComponent,

View File

@ -30,6 +30,8 @@ import { LoginComponent } from "../auth/popup/login.component";
import { RegisterComponent } from "../auth/popup/register.component"; import { RegisterComponent } from "../auth/popup/register.component";
import { RemovePasswordComponent } from "../auth/popup/remove-password.component"; import { RemovePasswordComponent } from "../auth/popup/remove-password.component";
import { SetPasswordComponent } from "../auth/popup/set-password.component"; import { SetPasswordComponent } from "../auth/popup/set-password.component";
import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component";
import { VaultTimeoutInputComponent } from "../auth/popup/settings/vault-timeout-input.component";
import { SsoComponent } from "../auth/popup/sso.component"; import { SsoComponent } from "../auth/popup/sso.component";
import { TwoFactorOptionsComponent } from "../auth/popup/two-factor-options.component"; import { TwoFactorOptionsComponent } from "../auth/popup/two-factor-options.component";
import { TwoFactorComponent } from "../auth/popup/two-factor.component"; import { TwoFactorComponent } from "../auth/popup/two-factor.component";
@ -49,6 +51,7 @@ import { SendAddEditComponent } from "../tools/popup/send/send-add-edit.componen
import { SendGroupingsComponent } from "../tools/popup/send/send-groupings.component"; import { SendGroupingsComponent } from "../tools/popup/send/send-groupings.component";
import { SendTypeComponent } from "../tools/popup/send/send-type.component"; import { SendTypeComponent } from "../tools/popup/send/send-type.component";
import { ExportComponent } from "../tools/popup/settings/export.component"; import { ExportComponent } from "../tools/popup/settings/export.component";
import { SettingsComponent } from "../tools/popup/settings/settings.component";
import { ActionButtonsComponent } from "../vault/popup/components/action-buttons.component"; import { ActionButtonsComponent } from "../vault/popup/components/action-buttons.component";
import { CipherRowComponent } from "../vault/popup/components/cipher-row.component"; import { CipherRowComponent } from "../vault/popup/components/cipher-row.component";
import { Fido2CipherRowComponent } from "../vault/popup/components/fido2/fido2-cipher-row.component"; import { Fido2CipherRowComponent } from "../vault/popup/components/fido2/fido2-cipher-row.component";
@ -64,9 +67,14 @@ import { ShareComponent } from "../vault/popup/components/vault/share.component"
import { VaultFilterComponent } from "../vault/popup/components/vault/vault-filter.component"; import { VaultFilterComponent } from "../vault/popup/components/vault/vault-filter.component";
import { VaultItemsComponent } from "../vault/popup/components/vault/vault-items.component"; import { VaultItemsComponent } from "../vault/popup/components/vault/vault-items.component";
import { VaultSelectComponent } from "../vault/popup/components/vault/vault-select.component"; import { VaultSelectComponent } from "../vault/popup/components/vault/vault-select.component";
import { VaultV2Component } from "../vault/popup/components/vault/vault-v2.component";
import { ViewCustomFieldsComponent } from "../vault/popup/components/vault/view-custom-fields.component"; import { ViewCustomFieldsComponent } from "../vault/popup/components/vault/view-custom-fields.component";
import { ViewComponent } from "../vault/popup/components/vault/view.component"; import { ViewComponent } from "../vault/popup/components/vault/view.component";
import { AppearanceComponent } from "../vault/popup/settings/appearance.component";
import { FolderAddEditComponent } from "../vault/popup/settings/folder-add-edit.component"; import { FolderAddEditComponent } from "../vault/popup/settings/folder-add-edit.component";
import { FoldersComponent } from "../vault/popup/settings/folders.component";
import { SyncComponent } from "../vault/popup/settings/sync.component";
import { VaultSettingsComponent } from "../vault/popup/settings/vault-settings.component";
import { AppRoutingModule } from "./app-routing.module"; import { AppRoutingModule } from "./app-routing.module";
import { AppComponent } from "./app.component"; import { AppComponent } from "./app.component";
@ -74,12 +82,8 @@ import { PopOutComponent } from "./components/pop-out.component";
import { UserVerificationComponent } from "./components/user-verification.component"; import { UserVerificationComponent } from "./components/user-verification.component";
import { ServicesModule } from "./services/services.module"; import { ServicesModule } from "./services/services.module";
import { ExcludedDomainsComponent } from "./settings/excluded-domains.component"; import { ExcludedDomainsComponent } from "./settings/excluded-domains.component";
import { FoldersComponent } from "./settings/folders.component";
import { HelpAndFeedbackComponent } from "./settings/help-and-feedback.component"; import { HelpAndFeedbackComponent } from "./settings/help-and-feedback.component";
import { OptionsComponent } from "./settings/options.component"; import { OptionsComponent } from "./settings/options.component";
import { SettingsComponent } from "./settings/settings.component";
import { SyncComponent } from "./settings/sync.component";
import { VaultTimeoutInputComponent } from "./settings/vault-timeout-input.component";
import { TabsV2Component } from "./tabs-v2.component"; import { TabsV2Component } from "./tabs-v2.component";
import { TabsComponent } from "./tabs.component"; import { TabsComponent } from "./tabs.component";
@ -145,6 +149,7 @@ import "../platform/popup/locales";
LoginViaAuthRequestComponent, LoginViaAuthRequestComponent,
LoginDecryptionOptionsComponent, LoginDecryptionOptionsComponent,
OptionsComponent, OptionsComponent,
AppearanceComponent,
GeneratorComponent, GeneratorComponent,
PasswordGeneratorHistoryComponent, PasswordGeneratorHistoryComponent,
PasswordHistoryComponent, PasswordHistoryComponent,
@ -156,7 +161,9 @@ import "../platform/popup/locales";
SendListComponent, SendListComponent,
SendTypeComponent, SendTypeComponent,
SetPasswordComponent, SetPasswordComponent,
AccountSecurityComponent,
SettingsComponent, SettingsComponent,
VaultSettingsComponent,
ShareComponent, ShareComponent,
SsoComponent, SsoComponent,
SyncComponent, SyncComponent,
@ -177,6 +184,7 @@ import "../platform/popup/locales";
EnvironmentSelectorComponent, EnvironmentSelectorComponent,
CurrentAccountComponent, CurrentAccountComponent,
AccountSwitcherComponent, AccountSwitcherComponent,
VaultV2Component,
], ],
providers: [CurrencyPipe, DatePipe], providers: [CurrencyPipe, DatePipe],
bootstrap: [AppComponent], bootstrap: [AppComponent],

View File

@ -424,6 +424,10 @@ img,
.modal-title, .modal-title,
.overlay-container { .overlay-container {
user-select: none; user-select: none;
&.user-select {
user-select: auto;
}
} }
app-about .modal-body > *, app-about .modal-body > *,

View File

@ -59,7 +59,6 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService as BaseStateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; import { StateService as BaseStateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
import { import {
AbstractMemoryStorageService,
AbstractStorageService, AbstractStorageService,
ObservableStorageService, ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service"; } from "@bitwarden/common/platform/abstractions/storage.service";
@ -413,7 +412,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({ safeProvider({
provide: OBSERVABLE_LARGE_OBJECT_MEMORY_STORAGE, provide: OBSERVABLE_LARGE_OBJECT_MEMORY_STORAGE,
useFactory: ( useFactory: (
regularMemoryStorageService: AbstractMemoryStorageService & ObservableStorageService, regularMemoryStorageService: AbstractStorageService & ObservableStorageService,
) => { ) => {
if (BrowserApi.isManifestVersion(2)) { if (BrowserApi.isManifestVersion(2)) {
return regularMemoryStorageService; return regularMemoryStorageService;
@ -441,7 +440,7 @@ const safeProviders: SafeProvider[] = [
useFactory: ( useFactory: (
storageService: AbstractStorageService, storageService: AbstractStorageService,
secureStorageService: AbstractStorageService, secureStorageService: AbstractStorageService,
memoryStorageService: AbstractMemoryStorageService, memoryStorageService: AbstractStorageService,
logService: LogService, logService: LogService,
accountService: AccountServiceAbstraction, accountService: AccountServiceAbstraction,
environmentService: EnvironmentService, environmentService: EnvironmentService,

View File

@ -192,56 +192,5 @@
{{ "showIdentitiesCurrentTabDesc" | i18n }} {{ "showIdentitiesCurrentTabDesc" | i18n }}
</div> </div>
</div> </div>
<div class="box">
<div class="box-content">
<div class="box-content-row box-content-row-checkbox" appBoxRow>
<label for="favicon">{{ "enableFavicon" | i18n }}</label>
<input
id="favicon"
type="checkbox"
aria-describedby="faviconHelp"
(change)="updateFavicon()"
[(ngModel)]="enableFavicon"
/>
</div>
</div>
<div id="faviconHelp" class="box-footer">
{{ accountSwitcherEnabled ? ("faviconDescAlt" | i18n) : ("faviconDesc" | i18n) }}
</div>
</div>
<div class="box">
<div class="box-content">
<div class="box-content-row box-content-row-checkbox" appBoxRow>
<label for="badge">{{ "enableBadgeCounter" | i18n }}</label>
<input
id="badge"
type="checkbox"
aria-describedby="badgeHelp"
(change)="updateBadgeCounter()"
[(ngModel)]="enableBadgeCounter"
/>
</div>
</div>
<div id="badgeHelp" class="box-footer">{{ "badgeCounterDesc" | i18n }}</div>
</div>
<div class="box">
<div class="box-content">
<div class="box-content-row" appBoxRow>
<label for="theme">{{ "theme" | i18n }}</label>
<select
id="theme"
name="Theme"
aria-describedby="themeHelp"
[(ngModel)]="theme"
(change)="saveTheme()"
>
<option *ngFor="let o of themeOptions" [ngValue]="o.value">{{ o.name }}</option>
</select>
</div>
</div>
<div id="themeHelp" class="box-footer">
{{ accountSwitcherEnabled ? ("themeDescAlt" | i18n) : ("themeDesc" | i18n) }}
</div>
</div>
</ng-container> </ng-container>
</main> </main>

View File

@ -2,7 +2,6 @@ import { Component, OnInit } from "@angular/core";
import { firstValueFrom } from "rxjs"; import { firstValueFrom } from "rxjs";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { BadgeSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/badge-settings.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { UserNotificationSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/user-notification-settings.service"; import { UserNotificationSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/user-notification-settings.service";
import { ClearClipboardDelaySetting } from "@bitwarden/common/autofill/types"; import { ClearClipboardDelaySetting } from "@bitwarden/common/autofill/types";
@ -12,8 +11,6 @@ import {
} from "@bitwarden/common/models/domain/domain-service"; } from "@bitwarden/common/models/domain/domain-service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
import { enableAccountSwitching } from "../../platform/flags"; import { enableAccountSwitching } from "../../platform/flags";
@ -23,8 +20,6 @@ import { enableAccountSwitching } from "../../platform/flags";
templateUrl: "options.component.html", templateUrl: "options.component.html",
}) })
export class OptionsComponent implements OnInit { export class OptionsComponent implements OnInit {
enableFavicon = false;
enableBadgeCounter = true;
enableAutoFillOnPageLoad = false; enableAutoFillOnPageLoad = false;
autoFillOnPageLoadDefault = false; autoFillOnPageLoadDefault = false;
autoFillOnPageLoadOptions: any[]; autoFillOnPageLoadOptions: any[];
@ -36,8 +31,6 @@ export class OptionsComponent implements OnInit {
showCardsCurrentTab = false; showCardsCurrentTab = false;
showIdentitiesCurrentTab = false; showIdentitiesCurrentTab = false;
showClearClipboard = true; showClearClipboard = true;
theme: ThemeType;
themeOptions: any[];
defaultUriMatch: UriMatchStrategySetting = UriMatchStrategy.Domain; defaultUriMatch: UriMatchStrategySetting = UriMatchStrategy.Domain;
uriMatchOptions: any[]; uriMatchOptions: any[];
clearClipboard: ClearClipboardDelaySetting; clearClipboard: ClearClipboardDelaySetting;
@ -52,18 +45,9 @@ export class OptionsComponent implements OnInit {
private userNotificationSettingsService: UserNotificationSettingsServiceAbstraction, private userNotificationSettingsService: UserNotificationSettingsServiceAbstraction,
private autofillSettingsService: AutofillSettingsServiceAbstraction, private autofillSettingsService: AutofillSettingsServiceAbstraction,
private domainSettingsService: DomainSettingsService, private domainSettingsService: DomainSettingsService,
private badgeSettingsService: BadgeSettingsServiceAbstraction,
i18nService: I18nService, i18nService: I18nService,
private themeStateService: ThemeStateService,
private vaultSettingsService: VaultSettingsService, private vaultSettingsService: VaultSettingsService,
) { ) {
this.themeOptions = [
{ name: i18nService.t("default"), value: ThemeType.System },
{ name: i18nService.t("light"), value: ThemeType.Light },
{ name: i18nService.t("dark"), value: ThemeType.Dark },
{ name: "Nord", value: ThemeType.Nord },
{ name: i18nService.t("solarizedDark"), value: ThemeType.SolarizedDark },
];
this.uriMatchOptions = [ this.uriMatchOptions = [
{ name: i18nService.t("baseDomain"), value: UriMatchStrategy.Domain }, { name: i18nService.t("baseDomain"), value: UriMatchStrategy.Domain },
{ name: i18nService.t("host"), value: UriMatchStrategy.Host }, { name: i18nService.t("host"), value: UriMatchStrategy.Host },
@ -117,14 +101,8 @@ export class OptionsComponent implements OnInit {
this.enableAutoTotpCopy = await firstValueFrom(this.autofillSettingsService.autoCopyTotp$); this.enableAutoTotpCopy = await firstValueFrom(this.autofillSettingsService.autoCopyTotp$);
this.enableFavicon = await firstValueFrom(this.domainSettingsService.showFavicons$);
this.enableBadgeCounter = await firstValueFrom(this.badgeSettingsService.enableBadgeCounter$);
this.enablePasskeys = await firstValueFrom(this.vaultSettingsService.enablePasskeys$); this.enablePasskeys = await firstValueFrom(this.vaultSettingsService.enablePasskeys$);
this.theme = await firstValueFrom(this.themeStateService.selectedTheme$);
const defaultUriMatch = await firstValueFrom( const defaultUriMatch = await firstValueFrom(
this.domainSettingsService.defaultUriMatchStrategy$, this.domainSettingsService.defaultUriMatchStrategy$,
); );
@ -166,15 +144,6 @@ export class OptionsComponent implements OnInit {
await this.autofillSettingsService.setAutofillOnPageLoadDefault(this.autoFillOnPageLoadDefault); await this.autofillSettingsService.setAutofillOnPageLoadDefault(this.autoFillOnPageLoadDefault);
} }
async updateFavicon() {
await this.domainSettingsService.setShowFavicons(this.enableFavicon);
}
async updateBadgeCounter() {
await this.badgeSettingsService.setEnableBadgeCounter(this.enableBadgeCounter);
this.messagingService.send("bgUpdateContextMenu");
}
async updateShowCardsCurrentTab() { async updateShowCardsCurrentTab() {
await this.vaultSettingsService.setShowCardsCurrentTab(this.showCardsCurrentTab); await this.vaultSettingsService.setShowCardsCurrentTab(this.showCardsCurrentTab);
} }
@ -183,10 +152,6 @@ export class OptionsComponent implements OnInit {
await this.vaultSettingsService.setShowIdentitiesCurrentTab(this.showIdentitiesCurrentTab); await this.vaultSettingsService.setShowIdentitiesCurrentTab(this.showIdentitiesCurrentTab);
} }
async saveTheme() {
await this.themeStateService.setSelectedTheme(this.theme);
}
async saveClearClipboard() { async saveClearClipboard() {
await this.autofillSettingsService.setClearClipboardDelay(this.clearClipboard); await this.autofillSettingsService.setClearClipboardDelay(this.clearClipboard);
} }

View File

@ -1,264 +0,0 @@
<app-header>
<div class="left">
<app-pop-out></app-pop-out>
</div>
<h1 class="center">
<span class="title">{{ "settings" | i18n }}</span>
</h1>
<div class="right"></div>
</app-header>
<main tabindex="-1" [formGroup]="form">
<div class="box list">
<h2 class="box-header">{{ "manage" | i18n }}</h2>
<div class="box-content single-line">
<button
type="button"
class="box-content-row box-content-row-flex text-default"
routerLink="/autofill"
>
<div class="row-main">{{ "autofill" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
<button
type="button"
class="box-content-row box-content-row-flex text-default"
routerLink="/folders"
>
<div class="row-main">{{ "folders" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
<button
type="button"
class="box-content-row box-content-row-flex text-default"
routerLink="/sync"
>
<div class="row-main">{{ "sync" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
<button
type="button"
class="box-content-row box-content-row-flex text-default"
routerLink="/excluded-domains"
>
<div class="row-main">{{ "excludedDomains" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="box list">
<h2 class="box-header">{{ "security" | i18n }}</h2>
<div class="box-content single-line">
<app-callout type="info" *ngIf="vaultTimeoutPolicyCallout | async as policy">
<span *ngIf="policy.timeout && policy.action">
{{
"vaultTimeoutPolicyWithActionInEffect"
| i18n: policy.timeout.hours : policy.timeout.minutes : (policy.action | i18n)
}}
</span>
<span *ngIf="policy.timeout && !policy.action">
{{ "vaultTimeoutPolicyInEffect" | i18n: policy.timeout.hours : policy.timeout.minutes }}
</span>
<span *ngIf="!policy.timeout && policy.action">
{{ "vaultTimeoutActionPolicyInEffect" | i18n: (policy.action | i18n) }}
</span>
</app-callout>
<app-vault-timeout-input
[vaultTimeoutOptions]="vaultTimeoutOptions"
[formControl]="form.controls.vaultTimeout"
ngDefaultControl
>
</app-vault-timeout-input>
<div class="box-content-row display-block" appBoxRow>
<label for="vaultTimeoutAction">{{ "vaultTimeoutAction" | i18n }}</label>
<select
id="vaultTimeoutAction"
name="VaultTimeoutActions"
formControlName="vaultTimeoutAction"
>
<option *ngFor="let action of availableVaultTimeoutActions" [ngValue]="action">
{{ action | i18n }}
</option>
</select>
</div>
<div
*ngIf="!availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock)"
id="unlockMethodHelp"
class="box-footer"
>
{{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}
</div>
<div class="box-content-row box-content-row-checkbox" appBoxRow>
<label for="pin">{{ "unlockWithPin" | i18n }}</label>
<input id="pin" type="checkbox" formControlName="pin" />
</div>
<div class="box-content-row box-content-row-checkbox" appBoxRow *ngIf="supportsBiometric">
<label for="biometric">{{ "unlockWithBiometrics" | i18n }}</label>
<input id="biometric" type="checkbox" formControlName="biometric" />
</div>
<div
class="box-content-row box-content-row-checkbox"
appBoxRow
*ngIf="supportsBiometric && this.form.value.biometric"
>
<label for="autoBiometricsPrompt">{{ "enableAutoBiometricsPrompt" | i18n }}</label>
<input
id="autoBiometricsPrompt"
type="checkbox"
(change)="updateAutoBiometricsPrompt()"
formControlName="enableAutoBiometricsPrompt"
/>
</div>
<button
*ngIf="
!accountSwitcherEnabled && availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock)
"
type="button"
class="box-content-row box-content-row-flex text-default"
appStopClick
(click)="lock()"
>
<div class="row-main">{{ "lockNow" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
<button
type="button"
class="box-content-row box-content-row-flex text-default"
appStopClick
(click)="twoStep()"
>
<div class="row-main">{{ "twoStepLogin" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="box list">
<h2 class="box-header">{{ "account" | i18n }}</h2>
<div class="box-content single-line">
<button type="button" class="box-content-row" routerLink="/premium">
<div class="row-main">
<div class="icon text-primary">
<i class="bwi bwi-fw bwi-lg bwi-star-f" aria-hidden="true"></i>
</div>
<span class="text text-primary"
><b>{{ "premiumMembership" | i18n }}</b></span
>
</div>
<span><i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i></span>
</button>
<button
type="button"
class="box-content-row box-content-row-flex text-default"
appStopClick
(click)="changePassword()"
*ngIf="showChangeMasterPass"
>
<div class="row-main">{{ "changeMasterPassword" | i18n }}</div>
<i class="bwi bwi-external-link bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
<button
type="button"
class="box-content-row box-content-row-flex text-default"
appStopClick
(click)="fingerprint()"
>
<div class="row-main">{{ "fingerprintPhrase" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
<button
*ngIf="!accountSwitcherEnabled"
type="button"
class="box-content-row box-content-row-flex text-default"
appStopClick
(click)="logOut()"
>
<div class="row-main">{{ "logOut" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="box list">
<h2 class="box-header">{{ "tools" | i18n }}</h2>
<div class="box-content single-line">
<button
type="button"
class="box-content-row box-content-row-flex text-default"
appStopClick
(click)="import()"
>
<div class="row-main">{{ "importItems" | i18n }}</div>
<i
class="bwi bwi-external-link bwi-lg row-sub-icon bwi-rotate-270 bwi-fw"
aria-hidden="true"
></i>
</button>
<button
type="button"
class="box-content-row box-content-row-flex text-default"
appStopClick
(click)="export()"
>
<div class="row-main">{{ "exportVault" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
<button
type="button"
class="box-content-row box-content-row-flex text-default"
appStopClick
(click)="webVault()"
>
<div class="row-main">{{ "bitWebVault" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="box list">
<h2 class="box-header">{{ "other" | i18n }}</h2>
<div class="box-content single-line">
<button
type="button"
class="box-content-row box-content-row-flex text-default"
routerLink="/options"
>
<div class="row-main">{{ "options" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
<button
type="button"
class="box-content-row box-content-row-flex text-default"
appStopClick
(click)="about()"
>
<div class="row-main">{{ "about" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
<button
type="button"
class="box-content-row box-content-row-flex text-default"
appStopClick
(click)="share()"
>
<div class="row-main">{{ "learnOrg" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
<button
type="button"
class="box-content-row box-content-row-flex text-default"
routerLink="/help-and-feedback"
>
<div class="row-main">{{ "helpFeedback" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
<button
type="button"
aria-describedby="rateExtensionHelp"
class="box-content-row box-content-row-flex text-default"
appStopClick
(click)="rate()"
>
<div class="row-main">{{ "rateExtension" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
</div>
<div id="rateExtensionHelp" class="box-footer">{{ "rateExtensionDesc" | i18n }}</div>
</div>
</main>

View File

@ -5,7 +5,7 @@
<div bitDialogTitle>Bitwarden</div> <div bitDialogTitle>Bitwarden</div>
<div bitDialogContent> <div bitDialogContent>
<p>&copy; Bitwarden Inc. 2015-{{ year }}</p> <p>&copy; Bitwarden Inc. 2015-{{ year }}</p>
<p>{{ "version" | i18n }}: {{ version$ | async }}</p> <p class="user-select">{{ "version" | i18n }}: {{ version$ | async }}</p>
<ng-container *ngIf="data$ | async as data"> <ng-container *ngIf="data$ | async as data">
<p *ngIf="data.isCloud"> <p *ngIf="data.isCloud">
{{ "serverVersion" | i18n }}: {{ data.serverConfig?.version }} {{ "serverVersion" | i18n }}: {{ data.serverConfig?.version }}
@ -16,7 +16,7 @@
<ng-container *ngIf="!data.isCloud"> <ng-container *ngIf="!data.isCloud">
<ng-container *ngIf="data.serverConfig.server"> <ng-container *ngIf="data.serverConfig.server">
<p> <p class="user-select">
{{ "serverVersion" | i18n }} <small>({{ "thirdParty" | i18n }})</small>: {{ "serverVersion" | i18n }} <small>({{ "thirdParty" | i18n }})</small>:
{{ data.serverConfig?.version }} {{ data.serverConfig?.version }}
<span *ngIf="!data.serverConfig.isValid()"> <span *ngIf="!data.serverConfig.isValid()">
@ -28,7 +28,7 @@
</div> </div>
</ng-container> </ng-container>
<p *ngIf="!data.serverConfig.server"> <p class="user-select" *ngIf="!data.serverConfig.server">
{{ "serverVersion" | i18n }} <small>({{ "selfHostedServer" | i18n }})</small>: {{ "serverVersion" | i18n }} <small>({{ "selfHostedServer" | i18n }})</small>:
{{ data.serverConfig?.version }} {{ data.serverConfig?.version }}
<span *ngIf="!data.serverConfig.isValid()"> <span *ngIf="!data.serverConfig.isValid()">

View File

@ -1,7 +1,7 @@
<form (ngSubmit)="submit()" [formGroup]="exportForm"> <form (ngSubmit)="submit()" [formGroup]="exportForm">
<header> <header>
<div class="left"> <div class="left">
<button type="button" routerLink="/tabs/settings"> <button type="button" routerLink="/vault-settings">
<span class="header-icon" aria-hidden="true"><i class="bwi bwi-angle-left"></i></span> <span class="header-icon" aria-hidden="true"><i class="bwi bwi-angle-left"></i></span>
<span>{{ "back" | i18n }}</span> <span>{{ "back" | i18n }}</span>
</button> </button>

View File

@ -1,6 +1,6 @@
<header> <header>
<div class="left"> <div class="left">
<button type="button" routerLink="/tabs/settings"> <button type="button" routerLink="/vault-settings">
<span class="header-icon" aria-hidden="true"><i class="bwi bwi-angle-left"></i></span> <span class="header-icon" aria-hidden="true"><i class="bwi bwi-angle-left"></i></span>
<span>{{ "back" | i18n }}</span> <span>{{ "back" | i18n }}</span>
</button> </button>

View File

@ -0,0 +1,136 @@
<app-header>
<div class="left">
<app-pop-out></app-pop-out>
</div>
<h1 class="center">
<span class="title">{{ "settings" | i18n }}</span>
</h1>
<div class="right"></div>
</app-header>
<main tabindex="-1">
<div class="box list">
<h2 class="box-header">{{ "manage" | i18n }}</h2>
<div class="box-content single-line">
<button
type="button"
class="box-content-row box-content-row-flex text-default"
routerLink="/account-security"
>
<div class="row-main">{{ "accountSecurity" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
<button
type="button"
class="box-content-row box-content-row-flex text-default"
routerLink="/autofill"
>
<div class="row-main">{{ "autofill" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
<button
type="button"
class="box-content-row box-content-row-flex text-default"
routerLink="/vault-settings"
>
<div class="row-main">{{ "vault" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
<button
type="button"
class="box-content-row box-content-row-flex text-default"
routerLink="/excluded-domains"
>
<div class="row-main">{{ "excludedDomains" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="box list">
<h2 class="box-header">{{ "account" | i18n }}</h2>
<div class="box-content single-line">
<button type="button" class="box-content-row" routerLink="/premium">
<div class="row-main">
<div class="icon text-primary">
<i class="bwi bwi-fw bwi-lg bwi-star-f" aria-hidden="true"></i>
</div>
<span class="text text-primary"
><b>{{ "premiumMembership" | i18n }}</b></span
>
</div>
<span><i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i></span>
</button>
</div>
</div>
<div class="box list">
<h2 class="box-header">{{ "tools" | i18n }}</h2>
<div class="box-content single-line">
<button
type="button"
class="box-content-row box-content-row-flex text-default"
appStopClick
(click)="webVault()"
>
<div class="row-main">{{ "bitWebVault" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="box list">
<h2 class="box-header">{{ "other" | i18n }}</h2>
<div class="box-content single-line">
<button
type="button"
class="box-content-row box-content-row-flex text-default"
routerLink="/options"
>
<div class="row-main">{{ "options" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
<button
type="button"
class="box-content-row box-content-row-flex text-default"
routerLink="/appearance"
>
<div class="row-main">{{ "appearance" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
<button
type="button"
class="box-content-row box-content-row-flex text-default"
appStopClick
(click)="about()"
>
<div class="row-main">{{ "about" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
<button
type="button"
class="box-content-row box-content-row-flex text-default"
appStopClick
(click)="share()"
>
<div class="row-main">{{ "learnOrg" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
<button
type="button"
class="box-content-row box-content-row-flex text-default"
routerLink="/help-and-feedback"
>
<div class="row-main">{{ "helpFeedback" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
<button
type="button"
aria-describedby="rateExtensionHelp"
class="box-content-row box-content-row-flex text-default"
appStopClick
(click)="rate()"
>
<div class="row-main">{{ "rateExtension" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
</div>
<div id="rateExtensionHelp" class="box-footer">{{ "rateExtensionDesc" | i18n }}</div>
</div>
</main>

View File

@ -0,0 +1,101 @@
import { Component, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom, Subject } from "rxjs";
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
import { DeviceType } from "@bitwarden/common/enums";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService } from "@bitwarden/components";
import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
import { AboutComponent } from "./about/about.component";
const RateUrls = {
[DeviceType.ChromeExtension]:
"https://chromewebstore.google.com/detail/bitwarden-free-password-m/nngceckbapebfimnlniiiahkandclblb/reviews",
[DeviceType.FirefoxExtension]:
"https://addons.mozilla.org/en-US/firefox/addon/bitwarden-password-manager/#reviews",
[DeviceType.OperaExtension]:
"https://addons.opera.com/en/extensions/details/bitwarden-free-password-manager/#feedback-container",
[DeviceType.EdgeExtension]:
"https://microsoftedge.microsoft.com/addons/detail/jbkfoedolllekgbhcbcoahefnbanhhlh",
[DeviceType.VivaldiExtension]:
"https://chromewebstore.google.com/detail/bitwarden-free-password-m/nngceckbapebfimnlniiiahkandclblb/reviews",
[DeviceType.SafariExtension]: "https://apps.apple.com/app/bitwarden/id1352778147",
};
@Component({
selector: "tools-settings",
templateUrl: "settings.component.html",
})
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
export class SettingsComponent implements OnInit {
private destroy$ = new Subject<void>();
constructor(
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private vaultTimeoutService: VaultTimeoutService,
public messagingService: MessagingService,
private router: Router,
private environmentService: EnvironmentService,
private dialogService: DialogService,
) {}
async ngOnInit() {}
async share() {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "learnOrg" },
content: { key: "learnOrgConfirmation" },
type: "info",
});
if (confirmed) {
// 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.createNewTab("https://bitwarden.com/help/about-organizations/");
}
}
async webVault() {
const env = await firstValueFrom(this.environmentService.environment$);
const url = env.getWebVaultUrl();
await BrowserApi.createNewTab(url);
}
async import() {
await this.router.navigate(["/import"]);
if (await BrowserApi.isPopupOpen()) {
// 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
BrowserPopupUtils.openCurrentPagePopout(window);
}
}
export() {
// 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(["/export"]);
}
about() {
this.dialogService.open(AboutComponent);
}
rate() {
const deviceType = this.platformUtilsService.getDevice();
// 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.createNewTab((RateUrls as any)[deviceType]);
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@ -5,6 +5,7 @@ import { first } from "rxjs/operators";
import { CollectionsComponent as BaseCollectionsComponent } from "@bitwarden/angular/admin-console/components/collections.component"; import { CollectionsComponent as BaseCollectionsComponent } from "@bitwarden/angular/admin-console/components/collections.component";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@ -26,6 +27,7 @@ export class CollectionsComponent extends BaseCollectionsComponent {
private route: ActivatedRoute, private route: ActivatedRoute,
private location: Location, private location: Location,
logService: LogService, logService: LogService,
configService: ConfigService,
) { ) {
super( super(
collectionService, collectionService,
@ -34,6 +36,7 @@ export class CollectionsComponent extends BaseCollectionsComponent {
cipherService, cipherService,
organizationService, organizationService,
logService, logService,
configService,
); );
} }

View File

@ -0,0 +1 @@
<h1>Vault V2 Extension Refresh</h1>

View File

@ -0,0 +1,13 @@
import { Component, OnDestroy, OnInit } from "@angular/core";
@Component({
selector: "app-vault",
templateUrl: "vault-v2.component.html",
})
export class VaultV2Component implements OnInit, OnDestroy {
constructor() {}
ngOnInit(): void {}
ngOnDestroy(): void {}
}

View File

@ -0,0 +1,67 @@
<header>
<div class="left">
<button type="button" routerLink="/tabs/settings">
<span class="header-icon"><i class="bwi bwi-angle-left" aria-hidden="true"></i></span>
<span>{{ "back" | i18n }}</span>
</button>
</div>
<h1 class="center">
<span class="title">{{ "appearance" | i18n }}</span>
</h1>
<div class="right">
<app-pop-out></app-pop-out>
</div>
</header>
<main tabindex="-1">
<div class="box">
<div class="box-content">
<div class="box-content-row" appBoxRow>
<label for="theme">{{ "theme" | i18n }}</label>
<select
id="theme"
name="Theme"
aria-describedby="themeHelp"
[(ngModel)]="theme"
(change)="saveTheme()"
>
<option *ngFor="let o of themeOptions" [ngValue]="o.value">{{ o.name }}</option>
</select>
</div>
</div>
<div id="themeHelp" class="box-footer">
{{ accountSwitcherEnabled ? ("themeDescAlt" | i18n) : ("themeDesc" | i18n) }}
</div>
</div>
<div class="box">
<div class="box-content">
<div class="box-content-row box-content-row-checkbox" appBoxRow>
<label for="badge">{{ "enableBadgeCounter" | i18n }}</label>
<input
id="badge"
type="checkbox"
aria-describedby="badgeHelp"
(change)="updateBadgeCounter()"
[(ngModel)]="enableBadgeCounter"
/>
</div>
</div>
<div id="badgeHelp" class="box-footer">{{ "badgeCounterDesc" | i18n }}</div>
</div>
<div class="box">
<div class="box-content">
<div class="box-content-row box-content-row-checkbox" appBoxRow>
<label for="favicon">{{ "enableFavicon" | i18n }}</label>
<input
id="favicon"
type="checkbox"
aria-describedby="faviconHelp"
(change)="updateFavicon()"
[(ngModel)]="enableFavicon"
/>
</div>
</div>
<div id="faviconHelp" class="box-footer">
{{ accountSwitcherEnabled ? ("faviconDescAlt" | i18n) : ("faviconDesc" | i18n) }}
</div>
</div>
</main>

View File

@ -0,0 +1,62 @@
import { Component, OnInit } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { BadgeSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/badge-settings.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { enableAccountSwitching } from "../../../platform/flags";
@Component({
selector: "vault-appearance",
templateUrl: "appearance.component.html",
})
export class AppearanceComponent implements OnInit {
enableFavicon = false;
enableBadgeCounter = true;
theme: ThemeType;
themeOptions: any[];
accountSwitcherEnabled = false;
constructor(
private messagingService: MessagingService,
private domainSettingsService: DomainSettingsService,
private badgeSettingsService: BadgeSettingsServiceAbstraction,
i18nService: I18nService,
private themeStateService: ThemeStateService,
) {
this.themeOptions = [
{ name: i18nService.t("default"), value: ThemeType.System },
{ name: i18nService.t("light"), value: ThemeType.Light },
{ name: i18nService.t("dark"), value: ThemeType.Dark },
{ name: "Nord", value: ThemeType.Nord },
{ name: i18nService.t("solarizedDark"), value: ThemeType.SolarizedDark },
];
this.accountSwitcherEnabled = enableAccountSwitching();
}
async ngOnInit() {
this.enableFavicon = await firstValueFrom(this.domainSettingsService.showFavicons$);
this.enableBadgeCounter = await firstValueFrom(this.badgeSettingsService.enableBadgeCounter$);
this.theme = await firstValueFrom(this.themeStateService.selectedTheme$);
}
async updateFavicon() {
await this.domainSettingsService.setShowFavicons(this.enableFavicon);
}
async updateBadgeCounter() {
await this.badgeSettingsService.setEnableBadgeCounter(this.enableBadgeCounter);
this.messagingService.send("bgUpdateContextMenu");
}
async saveTheme() {
await this.themeStateService.setSelectedTheme(this.theme);
}
}

View File

@ -1,6 +1,6 @@
<header> <header>
<div class="left"> <div class="left">
<button type="button" routerLink="/tabs/settings"> <button type="button" routerLink="/vault-settings">
<span class="header-icon"><i class="bwi bwi-angle-left" aria-hidden="true"></i></span> <span class="header-icon"><i class="bwi bwi-angle-left" aria-hidden="true"></i></span>
<span>{{ "back" | i18n }}</span> <span>{{ "back" | i18n }}</span>
</button> </button>

View File

@ -1,6 +1,6 @@
<header> <header>
<div class="left"> <div class="left">
<button type="button" routerLink="/tabs/settings"> <button type="button" routerLink="/vault-settings">
<span class="header-icon"><i class="bwi bwi-angle-left" aria-hidden="true"></i></span> <span class="header-icon"><i class="bwi bwi-angle-left" aria-hidden="true"></i></span>
<span>{{ "back" | i18n }}</span> <span>{{ "back" | i18n }}</span>
</button> </button>

View File

@ -0,0 +1,56 @@
<app-header>
<div class="left">
<button type="button" routerLink="/tabs/settings">
<span class="header-icon"><i class="bwi bwi-angle-left" aria-hidden="true"></i></span>
<span>{{ "back" | i18n }}</span>
</button>
</div>
<h1 class="center">
<span class="title">{{ "vault" | i18n }}</span>
</h1>
<div class="right">
<app-pop-out></app-pop-out>
</div>
</app-header>
<main tabindex="-1">
<div class="box list">
<div class="box-content single-line">
<button
type="button"
class="box-content-row box-content-row-flex text-default"
routerLink="/folders"
>
<div class="row-main">{{ "folders" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
<button
type="button"
class="box-content-row box-content-row-flex text-default"
appStopClick
(click)="import()"
>
<div class="row-main">{{ "importItems" | i18n }}</div>
<i
class="bwi bwi-external-link bwi-lg row-sub-icon bwi-rotate-270 bwi-fw"
aria-hidden="true"
></i>
</button>
<button
type="button"
class="box-content-row box-content-row-flex text-default"
routerLink="/export"
>
<div class="row-main">{{ "exportVault" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
<button
type="button"
class="box-content-row box-content-row-flex text-default"
routerLink="/sync"
>
<div class="row-main">{{ "sync" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
</div>
</div>
</main>

View File

@ -0,0 +1,25 @@
import { Component } from "@angular/core";
import { Router } from "@angular/router";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
@Component({
selector: "vault-settings",
templateUrl: "vault-settings.component.html",
})
export class VaultSettingsComponent {
constructor(
public messagingService: MessagingService,
private router: Router,
) {}
async import() {
await this.router.navigate(["/import"]);
if (await BrowserApi.isPopupOpen()) {
await BrowserPopupUtils.openCurrentPagePopout(window);
}
}
}

View File

@ -2,6 +2,7 @@ import { Component } from "@angular/core";
import { CollectionsComponent as BaseCollectionsComponent } from "@bitwarden/angular/admin-console/components/collections.component"; import { CollectionsComponent as BaseCollectionsComponent } from "@bitwarden/angular/admin-console/components/collections.component";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@ -20,6 +21,7 @@ export class CollectionsComponent extends BaseCollectionsComponent {
platformUtilsService: PlatformUtilsService, platformUtilsService: PlatformUtilsService,
organizationService: OrganizationService, organizationService: OrganizationService,
logService: LogService, logService: LogService,
configService: ConfigService,
) { ) {
super( super(
collectionService, collectionService,
@ -28,6 +30,7 @@ export class CollectionsComponent extends BaseCollectionsComponent {
cipherService, cipherService,
organizationService, organizationService,
logService, logService,
configService,
); );
} }
} }

View File

@ -165,7 +165,22 @@ export class ChangePasswordComponent extends BaseChangePasswordComponent {
newMasterKey: MasterKey, newMasterKey: MasterKey,
newUserKey: [UserKey, EncString], newUserKey: [UserKey, EncString],
) { ) {
const masterKey = await this.cryptoService.getOrDeriveMasterKey(this.currentMasterPassword); const masterKey = await this.cryptoService.makeMasterKey(
this.currentMasterPassword,
await this.stateService.getEmail(),
await this.kdfConfigService.getKdfConfig(),
);
const userKey = await this.cryptoService.decryptUserKeyWithMasterKey(masterKey);
if (userKey == null) {
this.platformUtilsService.showToast(
"error",
null,
this.i18nService.t("invalidMasterPassword"),
);
return;
}
const request = new PasswordRequest(); const request = new PasswordRequest();
request.masterPasswordHash = await this.cryptoService.hashMasterKey( request.masterPasswordHash = await this.cryptoService.hashMasterKey(
this.currentMasterPassword, this.currentMasterPassword,

View File

@ -9,10 +9,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
AbstractMemoryStorageService,
AbstractStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options"; import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options";
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
@ -26,7 +23,7 @@ export class StateService extends BaseStateService<GlobalState, Account> {
constructor( constructor(
storageService: AbstractStorageService, storageService: AbstractStorageService,
@Inject(SECURE_STORAGE) secureStorageService: AbstractStorageService, @Inject(SECURE_STORAGE) secureStorageService: AbstractStorageService,
@Inject(MEMORY_STORAGE) memoryStorageService: AbstractMemoryStorageService, @Inject(MEMORY_STORAGE) memoryStorageService: AbstractStorageService,
logService: LogService, logService: LogService,
@Inject(STATE_FACTORY) stateFactory: StateFactory<GlobalState, Account>, @Inject(STATE_FACTORY) stateFactory: StateFactory<GlobalState, Account>,
accountService: AccountService, accountService: AccountService,

View File

@ -20,7 +20,7 @@
bitLink bitLink
[disabled]="disabled" [disabled]="disabled"
type="button" type="button"
class="tw-w-full tw-truncate tw-text-start tw-leading-snug" class="tw-flex tw-w-full tw-text-start tw-leading-snug"
linkType="secondary" linkType="secondary"
title="{{ 'viewCollectionWithName' | i18n: collection.name }}" title="{{ 'viewCollectionWithName' | i18n: collection.name }}"
[routerLink]="[]" [routerLink]="[]"
@ -28,7 +28,15 @@
queryParamsHandling="merge" queryParamsHandling="merge"
appStopProp appStopProp
> >
{{ collection.name }} <span class="tw-truncate tw-mr-1">{{ collection.name }}</span>
<div>
<span
*ngIf="collection.addAccess && collection.id !== Unassigned"
bitBadge
variant="warning"
>{{ "addAccess" | i18n }}</span
>
</div>
</button> </button>
</td> </td>
<td bitCell [ngClass]="RowHeightClass" *ngIf="showOwner"> <td bitCell [ngClass]="RowHeightClass" *ngIf="showOwner">

View File

@ -21,6 +21,7 @@ import { RowHeightClass } from "./vault-items.component";
}) })
export class VaultCollectionRowComponent { export class VaultCollectionRowComponent {
protected RowHeightClass = RowHeightClass; protected RowHeightClass = RowHeightClass;
protected Unassigned = "unassigned";
@Input() disabled: boolean; @Input() disabled: boolean;
@Input() collection: CollectionView; @Input() collection: CollectionView;

View File

@ -99,8 +99,12 @@
(checkedToggled)="selection.toggle(item)" (checkedToggled)="selection.toggle(item)"
(onEvent)="event($event)" (onEvent)="event($event)"
></tr> ></tr>
<!--
addAccessStatus check here so ciphers do not show if user
has filtered for collections with addAccess
-->
<tr <tr
*ngIf="item.cipher" *ngIf="item.cipher && (!addAccessToggle || (addAccessToggle && addAccessStatus !== 1))"
bitRow bitRow
appVaultCipherRow appVaultCipherRow
alignContent="middle" alignContent="middle"

View File

@ -1,6 +1,7 @@
import { SelectionModel } from "@angular/cdk/collections"; import { SelectionModel } from "@angular/cdk/collections";
import { Component, EventEmitter, Input, Output } from "@angular/core"; import { Component, EventEmitter, Input, Output } from "@angular/core";
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
@ -45,6 +46,8 @@ export class VaultItemsComponent {
@Input() showPermissionsColumn = false; @Input() showPermissionsColumn = false;
@Input() viewingOrgVault: boolean; @Input() viewingOrgVault: boolean;
@Input({ required: true }) flexibleCollectionsV1Enabled = false; @Input({ required: true }) flexibleCollectionsV1Enabled = false;
@Input() addAccessStatus: number;
@Input() addAccessToggle: boolean;
private _ciphers?: CipherView[] = []; private _ciphers?: CipherView[] = [];
@Input() get ciphers(): CipherView[] { @Input() get ciphers(): CipherView[] {
@ -101,6 +104,28 @@ export class VaultItemsComponent {
} }
const organization = this.allOrganizations.find((o) => o.id === collection.organizationId); const organization = this.allOrganizations.find((o) => o.id === collection.organizationId);
if (this.flexibleCollectionsV1Enabled) {
//Custom user without edit access should not see the Edit option unless that user has "Can Manage" access to a collection
if (
!collection.manage &&
organization?.type === OrganizationUserType.Custom &&
!organization?.permissions.editAnyCollection
) {
return false;
}
//Owner/Admin and Custom Users with Edit can see Edit and Access of Orphaned Collections
if (
collection.addAccess &&
collection.id !== Unassigned &&
((organization?.type === OrganizationUserType.Custom &&
organization?.permissions.editAnyCollection) ||
organization.isAdmin ||
organization.isOwner)
) {
return true;
}
}
return collection.canEdit(organization, this.flexibleCollectionsV1Enabled); return collection.canEdit(organization, this.flexibleCollectionsV1Enabled);
} }
@ -111,6 +136,32 @@ export class VaultItemsComponent {
} }
const organization = this.allOrganizations.find((o) => o.id === collection.organizationId); const organization = this.allOrganizations.find((o) => o.id === collection.organizationId);
if (this.flexibleCollectionsV1Enabled) {
//Custom user with only edit access should not see the Delete button for orphaned collections
if (
collection.addAccess &&
organization?.type === OrganizationUserType.Custom &&
!organization?.permissions.deleteAnyCollection &&
organization?.permissions.editAnyCollection
) {
return false;
}
// Owner/Admin with no access to a collection will not see Delete
if (
!collection.assigned &&
!collection.addAccess &&
(organization.isAdmin || organization.isOwner) &&
!(
organization?.type === OrganizationUserType.Custom &&
organization?.permissions.deleteAnyCollection
)
) {
return false;
}
}
return collection.canDelete(organization); return collection.canDelete(organization);
} }

View File

@ -1,3 +1,4 @@
import { OrganizationUserUserDetailsResponse } from "@bitwarden/common/admin-console/abstractions/organization-user/responses";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CollectionAccessDetailsResponse } from "@bitwarden/common/src/vault/models/response/collection.response"; import { CollectionAccessDetailsResponse } from "@bitwarden/common/src/vault/models/response/collection.response";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
@ -7,6 +8,7 @@ import { CollectionAccessSelectionView } from "../../../admin-console/organizati
export class CollectionAdminView extends CollectionView { export class CollectionAdminView extends CollectionView {
groups: CollectionAccessSelectionView[] = []; groups: CollectionAccessSelectionView[] = [];
users: CollectionAccessSelectionView[] = []; users: CollectionAccessSelectionView[] = [];
addAccess: boolean;
/** /**
* Flag indicating the user has been explicitly assigned to this Collection * Flag indicating the user has been explicitly assigned to this Collection
@ -31,6 +33,33 @@ export class CollectionAdminView extends CollectionView {
this.assigned = response.assigned; this.assigned = response.assigned;
} }
groupsCanManage() {
if (this.groups.length === 0) {
return this.groups;
}
const returnedGroups = this.groups.filter((group) => {
if (group.manage) {
return group;
}
});
return returnedGroups;
}
usersCanManage(revokedUsers: OrganizationUserUserDetailsResponse[]) {
if (this.users.length === 0) {
return this.users;
}
const returnedUsers = this.users.filter((user) => {
const isRevoked = revokedUsers.some((revoked) => revoked.id === user.id);
if (user.manage && !isRevoked) {
return user;
}
});
return returnedUsers;
}
/** /**
* Whether the current user can edit the collection, including user and group access * Whether the current user can edit the collection, including user and group access
*/ */

View File

@ -1,4 +1,4 @@
import { DialogConfig, DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core"; import { Component, Inject } from "@angular/core";
import { firstValueFrom } from "rxjs"; import { firstValueFrom } from "rxjs";
@ -56,6 +56,10 @@ export class BulkDeleteDialogComponent {
FeatureFlag.FlexibleCollectionsV1, FeatureFlag.FlexibleCollectionsV1,
); );
private restrictProviderAccess$ = this.configService.getFeatureFlag$(
FeatureFlag.RestrictProviderAccess,
);
constructor( constructor(
@Inject(DIALOG_DATA) params: BulkDeleteDialogParams, @Inject(DIALOG_DATA) params: BulkDeleteDialogParams,
private dialogRef: DialogRef<BulkDeleteDialogResult>, private dialogRef: DialogRef<BulkDeleteDialogResult>,
@ -81,10 +85,11 @@ export class BulkDeleteDialogComponent {
const deletePromises: Promise<void>[] = []; const deletePromises: Promise<void>[] = [];
if (this.cipherIds.length) { if (this.cipherIds.length) {
const flexibleCollectionsV1Enabled = await firstValueFrom(this.flexibleCollectionsV1Enabled$); const flexibleCollectionsV1Enabled = await firstValueFrom(this.flexibleCollectionsV1Enabled$);
const restrictProviderAccess = await firstValueFrom(this.restrictProviderAccess$);
if ( if (
!this.organization || !this.organization ||
!this.organization.canEditAllCiphers(flexibleCollectionsV1Enabled) !this.organization.canEditAllCiphers(flexibleCollectionsV1Enabled, restrictProviderAccess)
) { ) {
deletePromises.push(this.deleteCiphers()); deletePromises.push(this.deleteCiphers());
} else { } else {
@ -118,7 +123,11 @@ export class BulkDeleteDialogComponent {
private async deleteCiphers(): Promise<any> { private async deleteCiphers(): Promise<any> {
const flexibleCollectionsV1Enabled = await firstValueFrom(this.flexibleCollectionsV1Enabled$); const flexibleCollectionsV1Enabled = await firstValueFrom(this.flexibleCollectionsV1Enabled$);
const asAdmin = this.organization?.canEditAllCiphers(flexibleCollectionsV1Enabled); const restrictProviderAccess = await firstValueFrom(this.restrictProviderAccess$);
const asAdmin = this.organization?.canEditAllCiphers(
flexibleCollectionsV1Enabled,
restrictProviderAccess,
);
if (this.permanent) { if (this.permanent) {
await this.cipherService.deleteManyWithServer(this.cipherIds, asAdmin); await this.cipherService.deleteManyWithServer(this.cipherIds, asAdmin);
} else { } else {

View File

@ -32,7 +32,13 @@
[(ngModel)]="$any(c).checked" [(ngModel)]="$any(c).checked"
name="Collection[{{ i }}].Checked" name="Collection[{{ i }}].Checked"
appStopProp appStopProp
[disabled]="!c.canEditItems(this.organization, this.flexibleCollectionsV1Enabled)" [disabled]="
!c.canEditItems(
this.organization,
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess
)
"
/> />
{{ c.name }} {{ c.name }}
</td> </td>

View File

@ -1,8 +1,9 @@
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, OnDestroy, Inject } from "@angular/core"; import { Component, Inject, OnDestroy } from "@angular/core";
import { CollectionsComponent as BaseCollectionsComponent } from "@bitwarden/angular/admin-console/components/collections.component"; import { CollectionsComponent as BaseCollectionsComponent } from "@bitwarden/angular/admin-console/components/collections.component";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@ -23,6 +24,7 @@ export class CollectionsComponent extends BaseCollectionsComponent implements On
cipherService: CipherService, cipherService: CipherService,
organizationSerivce: OrganizationService, organizationSerivce: OrganizationService,
logService: LogService, logService: LogService,
configService: ConfigService,
protected dialogRef: DialogRef, protected dialogRef: DialogRef,
@Inject(DIALOG_DATA) params: CollectionsDialogParams, @Inject(DIALOG_DATA) params: CollectionsDialogParams,
) { ) {
@ -33,6 +35,7 @@ export class CollectionsComponent extends BaseCollectionsComponent implements On
cipherService, cipherService,
organizationSerivce, organizationSerivce,
logService, logService,
configService,
); );
this.cipherId = params?.cipherId; this.cipherId = params?.cipherId;
} }
@ -47,7 +50,13 @@ export class CollectionsComponent extends BaseCollectionsComponent implements On
} }
check(c: CollectionView, select?: boolean) { check(c: CollectionView, select?: boolean) {
if (!c.canEditItems(this.organization, this.flexibleCollectionsV1Enabled)) { if (
!c.canEditItems(
this.organization,
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
)
) {
return; return;
} }
(c as any).checked = select == null ? !(c as any).checked : select; (c as any).checked = select == null ? !(c as any).checked : select;

View File

@ -82,7 +82,12 @@ export class AddEditComponent extends BaseAddEditComponent {
} }
protected loadCollections() { protected loadCollections() {
if (!this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) { if (
!this.organization.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
)
) {
return super.loadCollections(); return super.loadCollections();
} }
return Promise.resolve(this.collections); return Promise.resolve(this.collections);
@ -93,7 +98,10 @@ export class AddEditComponent extends BaseAddEditComponent {
const firstCipherCheck = await super.loadCipher(); const firstCipherCheck = await super.loadCipher();
if ( if (
!this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled) && !this.organization.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
) &&
firstCipherCheck != null firstCipherCheck != null
) { ) {
return firstCipherCheck; return firstCipherCheck;
@ -108,14 +116,24 @@ export class AddEditComponent extends BaseAddEditComponent {
} }
protected encryptCipher() { protected encryptCipher() {
if (!this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) { if (
!this.organization.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
)
) {
return super.encryptCipher(); return super.encryptCipher();
} }
return this.cipherService.encrypt(this.cipher, null, null, this.originalCipher); return this.cipherService.encrypt(this.cipher, null, null, this.originalCipher);
} }
protected async deleteCipher() { protected async deleteCipher() {
if (!this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) { if (
!this.organization.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
)
) {
return super.deleteCipher(); return super.deleteCipher();
} }
return this.cipher.isDeleted return this.cipher.isDeleted

View File

@ -29,6 +29,7 @@ export class AttachmentsComponent extends BaseAttachmentsComponent implements On
organization: Organization; organization: Organization;
private flexibleCollectionsV1Enabled = false; private flexibleCollectionsV1Enabled = false;
private restrictProviderAccess = false;
constructor( constructor(
cipherService: CipherService, cipherService: CipherService,
@ -62,11 +63,17 @@ export class AttachmentsComponent extends BaseAttachmentsComponent implements On
this.flexibleCollectionsV1Enabled = await firstValueFrom( this.flexibleCollectionsV1Enabled = await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.FlexibleCollectionsV1), this.configService.getFeatureFlag$(FeatureFlag.FlexibleCollectionsV1),
); );
this.restrictProviderAccess = await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.RestrictProviderAccess),
);
} }
protected async reupload(attachment: AttachmentView) { protected async reupload(attachment: AttachmentView) {
if ( if (
this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled) && this.organization.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
) &&
this.showFixOldAttachments(attachment) this.showFixOldAttachments(attachment)
) { ) {
await super.reuploadCipherAttachment(attachment, true); await super.reuploadCipherAttachment(attachment, true);
@ -74,7 +81,12 @@ export class AttachmentsComponent extends BaseAttachmentsComponent implements On
} }
protected async loadCipher() { protected async loadCipher() {
if (!this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) { if (
!this.organization.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
)
) {
return await super.loadCipher(); return await super.loadCipher();
} }
const response = await this.apiService.getCipherAdmin(this.cipherId); const response = await this.apiService.getCipherAdmin(this.cipherId);
@ -85,12 +97,20 @@ export class AttachmentsComponent extends BaseAttachmentsComponent implements On
return this.cipherService.saveAttachmentWithServer( return this.cipherService.saveAttachmentWithServer(
this.cipherDomain, this.cipherDomain,
file, file,
this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled), this.organization.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
),
); );
} }
protected deleteCipherAttachment(attachmentId: string) { protected deleteCipherAttachment(attachmentId: string) {
if (!this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) { if (
!this.organization.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
)
) {
return super.deleteCipherAttachment(attachmentId); return super.deleteCipherAttachment(attachmentId);
} }
return this.apiService.deleteCipherAttachmentAdmin(this.cipherId, attachmentId); return this.apiService.deleteCipherAttachmentAdmin(this.cipherId, attachmentId);
@ -99,7 +119,10 @@ export class AttachmentsComponent extends BaseAttachmentsComponent implements On
protected showFixOldAttachments(attachment: AttachmentView) { protected showFixOldAttachments(attachment: AttachmentView) {
return ( return (
attachment.key == null && attachment.key == null &&
this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled) this.organization.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
)
); );
} }
} }

View File

@ -71,9 +71,12 @@ export class BulkCollectionAssignmentDialogComponent implements OnDestroy, OnIni
async ngOnInit() { async ngOnInit() {
const v1FCEnabled = await this.configService.getFeatureFlag(FeatureFlag.FlexibleCollectionsV1); const v1FCEnabled = await this.configService.getFeatureFlag(FeatureFlag.FlexibleCollectionsV1);
const restrictProviderAccess = await this.configService.getFeatureFlag(
FeatureFlag.RestrictProviderAccess,
);
const org = await this.organizationService.get(this.params.organizationId); const org = await this.organizationService.get(this.params.organizationId);
if (org.canEditAllCiphers(v1FCEnabled)) { if (org.canEditAllCiphers(v1FCEnabled, restrictProviderAccess)) {
this.editableItems = this.params.ciphers; this.editableItems = this.params.ciphers;
} else { } else {
this.editableItems = this.params.ciphers.filter((c) => c.edit); this.editableItems = this.params.ciphers.filter((c) => c.edit);

View File

@ -1,4 +1,4 @@
import { Component, EventEmitter, Output } from "@angular/core"; import { Component, EventEmitter, Input, Output } from "@angular/core";
import { ButtonModule, NoItemsModule, svgIcon } from "@bitwarden/components"; import { ButtonModule, NoItemsModule, svgIcon } from "@bitwarden/components";
@ -22,12 +22,18 @@ const icon = svgIcon`<svg xmlns="http://www.w3.org/2000/svg" width="120" height=
buttonType="secondary" buttonType="secondary"
type="button" type="button"
> >
<i aria-hidden="true" class="bwi bwi-pencil-square"></i> {{ "viewCollection" | i18n }} <i aria-hidden="true" class="bwi bwi-pencil-square"></i> {{ buttonText | i18n }}
</button> </button>
</bit-no-items>`, </bit-no-items>`,
}) })
export class CollectionAccessRestrictedComponent { export class CollectionAccessRestrictedComponent {
protected icon = icon; protected icon = icon;
@Input() canEditCollection = false;
@Output() viewCollectionClicked = new EventEmitter<void>(); @Output() viewCollectionClicked = new EventEmitter<void>();
get buttonText() {
return this.canEditCollection ? "editCollection" : "viewCollection";
}
} }

View File

@ -4,6 +4,7 @@ import { Component, Inject } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@ -35,6 +36,7 @@ export class CollectionsComponent extends BaseCollectionsComponent {
organizationService: OrganizationService, organizationService: OrganizationService,
private apiService: ApiService, private apiService: ApiService,
logService: LogService, logService: LogService,
configService: ConfigService,
protected dialogRef: DialogRef, protected dialogRef: DialogRef,
@Inject(DIALOG_DATA) params: OrgVaultCollectionsDialogParams, @Inject(DIALOG_DATA) params: OrgVaultCollectionsDialogParams,
) { ) {
@ -45,6 +47,7 @@ export class CollectionsComponent extends BaseCollectionsComponent {
cipherService, cipherService,
organizationService, organizationService,
logService, logService,
configService,
dialogRef, dialogRef,
params, params,
); );
@ -58,7 +61,10 @@ export class CollectionsComponent extends BaseCollectionsComponent {
protected async loadCipher() { protected async loadCipher() {
// if cipher is unassigned use apiService. We can see this by looking at this.collectionIds // if cipher is unassigned use apiService. We can see this by looking at this.collectionIds
if ( if (
!this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled) && !this.organization.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
) &&
this.collectionIds.length !== 0 this.collectionIds.length !== 0
) { ) {
return await super.loadCipher(); return await super.loadCipher();
@ -83,7 +89,10 @@ export class CollectionsComponent extends BaseCollectionsComponent {
protected saveCollections() { protected saveCollections() {
if ( if (
this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled) || this.organization.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
) ||
this.collectionIds.length === 0 this.collectionIds.length === 0
) { ) {
const request = new CipherCollectionsRequest(this.cipherDomain.collectionIds); const request = new CipherCollectionsRequest(this.cipherDomain.collectionIds);

View File

@ -73,8 +73,16 @@
</small> </small>
</ng-container> </ng-container>
<bit-search
*ngIf="organization?.isProviderUser"
class="tw-grow"
[ngModel]="searchText"
(ngModelChange)="onSearchTextChanged($event)"
[placeholder]="'searchCollection' | i18n"
></bit-search>
<div *ngIf="filter.type !== 'trash' && filter.collectionId !== Unassigned" class="tw-shrink-0"> <div *ngIf="filter.type !== 'trash' && filter.collectionId !== Unassigned" class="tw-shrink-0">
<div *ngIf="organization?.canCreateNewCollections" appListDropdown> <div *ngIf="canCreateCipher && canCreateCollection" appListDropdown>
<button <button
bitButton bitButton
buttonType="primary" buttonType="primary"
@ -97,7 +105,7 @@
</bit-menu> </bit-menu>
</div> </div>
<button <button
*ngIf="!organization?.canCreateNewCollections" *ngIf="canCreateCipher && !canCreateCollection"
type="button" type="button"
bitButton bitButton
buttonType="primary" buttonType="primary"
@ -106,5 +114,16 @@
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i> <i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "newItem" | i18n }} {{ "newItem" | i18n }}
</button> </button>
<button
*ngIf="canCreateCollection && !canCreateCipher"
type="button"
bitButton
buttonType="primary"
(click)="addCollection()"
>
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "newCollection" | i18n }}
</button>
</div> </div>
</app-header> </app-header>

View File

@ -43,6 +43,9 @@ export class VaultHeaderComponent implements OnInit {
/** Currently selected collection */ /** Currently selected collection */
@Input() collection?: TreeNode<CollectionAdminView>; @Input() collection?: TreeNode<CollectionAdminView>;
/** The current search text in the header */
@Input() searchText: string;
/** Emits an event when the new item button is clicked in the header */ /** Emits an event when the new item button is clicked in the header */
@Output() onAddCipher = new EventEmitter<void>(); @Output() onAddCipher = new EventEmitter<void>();
@ -55,10 +58,14 @@ export class VaultHeaderComponent implements OnInit {
/** Emits an event when the delete collection button is clicked in the header */ /** Emits an event when the delete collection button is clicked in the header */
@Output() onDeleteCollection = new EventEmitter<void>(); @Output() onDeleteCollection = new EventEmitter<void>();
/** Emits an event when the search text changes in the header*/
@Output() searchTextChanged = new EventEmitter<string>();
protected CollectionDialogTabType = CollectionDialogTabType; protected CollectionDialogTabType = CollectionDialogTabType;
protected organizations$ = this.organizationService.organizations$; protected organizations$ = this.organizationService.organizations$;
private flexibleCollectionsV1Enabled = false; private flexibleCollectionsV1Enabled = false;
private restrictProviderAccessFlag = false;
constructor( constructor(
private organizationService: OrganizationService, private organizationService: OrganizationService,
@ -73,6 +80,9 @@ export class VaultHeaderComponent implements OnInit {
this.flexibleCollectionsV1Enabled = await firstValueFrom( this.flexibleCollectionsV1Enabled = await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.FlexibleCollectionsV1), this.configService.getFeatureFlag$(FeatureFlag.FlexibleCollectionsV1),
); );
this.restrictProviderAccessFlag = await this.configService.getFeatureFlag(
FeatureFlag.RestrictProviderAccess,
);
} }
get title() { get title() {
@ -197,7 +207,23 @@ export class VaultHeaderComponent implements OnInit {
return this.collection.node.canDelete(this.organization); return this.collection.node.canDelete(this.organization);
} }
get canCreateCollection(): boolean {
return this.organization?.canCreateNewCollections;
}
get canCreateCipher(): boolean {
if (this.organization?.isProviderUser && this.restrictProviderAccessFlag) {
return false;
}
return true;
}
deleteCollection() { deleteCollection() {
this.onDeleteCollection.emit(); this.onDeleteCollection.emit();
} }
onSearchTextChanged(t: string) {
this.searchText = t;
this.searchTextChanged.emit(t);
}
} }

View File

@ -3,19 +3,20 @@
[loading]="refreshing" [loading]="refreshing"
[organization]="organization" [organization]="organization"
[collection]="selectedCollection" [collection]="selectedCollection"
[searchText]="currentSearchText$ | async"
(onAddCipher)="addCipher()" (onAddCipher)="addCipher()"
(onAddCollection)="addCollection()" (onAddCollection)="addCollection()"
(onEditCollection)="editCollection(selectedCollection.node, $event.tab)" (onEditCollection)="editCollection(selectedCollection.node, $event.tab)"
(onDeleteCollection)="deleteCollection(selectedCollection.node)" (onDeleteCollection)="deleteCollection(selectedCollection.node)"
(searchTextChanged)="filterSearchText($event)"
></app-org-vault-header> ></app-org-vault-header>
<div class="row"> <div class="row">
<div class="col-3"> <div class="col-3" *ngIf="!organization?.isProviderUser">
<div class="groupings"> <div class="groupings">
<div class="content"> <div class="content">
<div class="inner-content"> <div class="inner-content">
<app-organization-vault-filter <app-organization-vault-filter
#vaultFilter
[organization]="organization" [organization]="organization"
[activeFilter]="activeFilter" [activeFilter]="activeFilter"
[searchText]="currentSearchText$ | async" [searchText]="currentSearchText$ | async"
@ -25,7 +26,21 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-9"> <div [class]="organization?.isProviderUser ? 'col-12' : 'col-9'">
<bit-toggle-group
*ngIf="showAddAccessToggle && activeFilter.selectedCollectionNode"
[selected]="addAccessStatus$ | async"
(selectedChange)="addAccessToggle($event)"
[attr.aria-label]="'addAccessFilter' | i18n"
>
<bit-toggle [value]="0">
{{ "all" | i18n }}
</bit-toggle>
<bit-toggle [value]="1">
{{ "addAccess" | i18n }}
</bit-toggle>
</bit-toggle-group>
<app-callout type="warning" *ngIf="activeFilter.isDeleted" icon="bwi bwi-exclamation-triangle"> <app-callout type="warning" *ngIf="activeFilter.isDeleted" icon="bwi bwi-exclamation-triangle">
{{ trashCleanupWarning }} {{ trashCleanupWarning }}
</app-callout> </app-callout>
@ -54,6 +69,8 @@
[showBulkAddToCollections]="organization?.flexibleCollections" [showBulkAddToCollections]="organization?.flexibleCollections"
[viewingOrgVault]="true" [viewingOrgVault]="true"
[flexibleCollectionsV1Enabled]="flexibleCollectionsV1Enabled" [flexibleCollectionsV1Enabled]="flexibleCollectionsV1Enabled"
[addAccessStatus]="addAccessStatus$ | async"
[addAccessToggle]="showAddAccessToggle"
> >
</app-vault-items> </app-vault-items>
<ng-container *ngIf="!flexibleCollectionsV1Enabled"> <ng-container *ngIf="!flexibleCollectionsV1Enabled">
@ -98,8 +115,13 @@
</bit-no-items> </bit-no-items>
<collection-access-restricted <collection-access-restricted
*ngIf="showCollectionAccessRestricted" *ngIf="showCollectionAccessRestricted"
[canEditCollection]="organization.isProviderUser"
(viewCollectionClicked)=" (viewCollectionClicked)="
editCollection(selectedCollection.node, CollectionDialogTabType.Info, true) editCollection(
selectedCollection.node,
CollectionDialogTabType.Info,
!organization.isProviderUser
)
" "
> >
</collection-access-restricted> </collection-access-restricted>

View File

@ -36,6 +36,9 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
import { OrganizationUserUserDetailsResponse } from "@bitwarden/common/admin-console/abstractions/organization-user/responses";
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { EventType } from "@bitwarden/common/enums"; import { EventType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
@ -97,11 +100,15 @@ import {
BulkCollectionsDialogResult, BulkCollectionsDialogResult,
} from "./bulk-collections-dialog"; } from "./bulk-collections-dialog";
import { openOrgVaultCollectionsDialog } from "./collections.component"; import { openOrgVaultCollectionsDialog } from "./collections.component";
import { VaultFilterComponent } from "./vault-filter/vault-filter.component";
const BroadcasterSubscriptionId = "OrgVaultComponent"; const BroadcasterSubscriptionId = "OrgVaultComponent";
const SearchTextDebounceInterval = 200; const SearchTextDebounceInterval = 200;
enum AddAccessStatusType {
All = 0,
AddAccess = 1,
}
@Component({ @Component({
selector: "app-org-vault", selector: "app-org-vault",
templateUrl: "vault.component.html", templateUrl: "vault.component.html",
@ -110,8 +117,6 @@ const SearchTextDebounceInterval = 200;
export class VaultComponent implements OnInit, OnDestroy { export class VaultComponent implements OnInit, OnDestroy {
protected Unassigned = Unassigned; protected Unassigned = Unassigned;
@ViewChild("vaultFilter", { static: true })
vaultFilterComponent: VaultFilterComponent;
@ViewChild("attachments", { read: ViewContainerRef, static: true }) @ViewChild("attachments", { read: ViewContainerRef, static: true })
attachmentsModalRef: ViewContainerRef; attachmentsModalRef: ViewContainerRef;
@ViewChild("cipherAddEdit", { read: ViewContainerRef, static: true }) @ViewChild("cipherAddEdit", { read: ViewContainerRef, static: true })
@ -122,6 +127,7 @@ export class VaultComponent implements OnInit, OnDestroy {
trashCleanupWarning: string = null; trashCleanupWarning: string = null;
activeFilter: VaultFilter = new VaultFilter(); activeFilter: VaultFilter = new VaultFilter();
protected showAddAccessToggle = false;
protected noItemIcon = Icons.Search; protected noItemIcon = Icons.Search;
protected performingInitialLoad = true; protected performingInitialLoad = true;
protected refreshing = false; protected refreshing = false;
@ -142,6 +148,10 @@ export class VaultComponent implements OnInit, OnDestroy {
protected showMissingCollectionPermissionMessage: boolean; protected showMissingCollectionPermissionMessage: boolean;
protected showCollectionAccessRestricted: boolean; protected showCollectionAccessRestricted: boolean;
protected currentSearchText$: Observable<string>; protected currentSearchText$: Observable<string>;
/**
* A list of collections that the user can assign items to and edit those items within.
* @protected
*/
protected editableCollections$: Observable<CollectionView[]>; protected editableCollections$: Observable<CollectionView[]>;
protected allCollectionsWithoutUnassigned$: Observable<CollectionAdminView[]>; protected allCollectionsWithoutUnassigned$: Observable<CollectionAdminView[]>;
private _flexibleCollectionsV1FlagEnabled: boolean; private _flexibleCollectionsV1FlagEnabled: boolean;
@ -149,10 +159,17 @@ export class VaultComponent implements OnInit, OnDestroy {
protected get flexibleCollectionsV1Enabled(): boolean { protected get flexibleCollectionsV1Enabled(): boolean {
return this._flexibleCollectionsV1FlagEnabled && this.organization?.flexibleCollections; return this._flexibleCollectionsV1FlagEnabled && this.organization?.flexibleCollections;
} }
protected orgRevokedUsers: OrganizationUserUserDetailsResponse[];
private _restrictProviderAccessFlagEnabled: boolean;
protected get restrictProviderAccessEnabled(): boolean {
return this._restrictProviderAccessFlagEnabled && this.flexibleCollectionsV1Enabled;
}
private searchText$ = new Subject<string>(); private searchText$ = new Subject<string>();
private refresh$ = new BehaviorSubject<void>(null); private refresh$ = new BehaviorSubject<void>(null);
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
protected addAccessStatus$ = new BehaviorSubject<AddAccessStatusType>(0);
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
@ -181,6 +198,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private totpService: TotpService, private totpService: TotpService,
private apiService: ApiService, private apiService: ApiService,
private collectionService: CollectionService, private collectionService: CollectionService,
private organizationUserService: OrganizationUserService,
protected configService: ConfigService, protected configService: ConfigService,
) {} ) {}
@ -195,6 +213,10 @@ export class VaultComponent implements OnInit, OnDestroy {
FeatureFlag.FlexibleCollectionsV1, FeatureFlag.FlexibleCollectionsV1,
); );
this._restrictProviderAccessFlagEnabled = await this.configService.getFeatureFlag(
FeatureFlag.RestrictProviderAccess,
);
const filter$ = this.routedVaultFilterService.filter$; const filter$ = this.routedVaultFilterService.filter$;
const organizationId$ = filter$.pipe( const organizationId$ = filter$.pipe(
map((filter) => filter.organizationId), map((filter) => filter.organizationId),
@ -241,6 +263,11 @@ export class VaultComponent implements OnInit, OnDestroy {
.pipe(takeUntil(this.destroy$)) .pipe(takeUntil(this.destroy$))
.subscribe((activeFilter) => { .subscribe((activeFilter) => {
this.activeFilter = activeFilter; this.activeFilter = activeFilter;
// watch the active filters. Only show toggle when viewing the collections filter
if (!this.activeFilter.collectionId) {
this.showAddAccessToggle = false;
}
}); });
this.searchText$ this.searchText$
@ -280,10 +307,20 @@ export class VaultComponent implements OnInit, OnDestroy {
this.editableCollections$ = this.allCollectionsWithoutUnassigned$.pipe( this.editableCollections$ = this.allCollectionsWithoutUnassigned$.pipe(
map((collections) => { map((collections) => {
// Users that can edit all ciphers can implicitly edit all collections // If restricted, providers can not add items to any collections or edit those items
if (this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) { if (this.organization.isProviderUser && this.restrictProviderAccessEnabled) {
return [];
}
// Users that can edit all ciphers can implicitly add to / edit within any collection
if (
this.organization.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccessEnabled,
)
) {
return collections; return collections;
} }
// The user is only allowed to add/edit items to assigned collections that are not readonly
return collections.filter((c) => c.assigned && !c.readOnly); return collections.filter((c) => c.assigned && !c.readOnly);
}), }),
shareReplay({ refCount: true, bufferSize: 1 }), shareReplay({ refCount: true, bufferSize: 1 }),
@ -309,12 +346,25 @@ export class VaultComponent implements OnInit, OnDestroy {
const allCiphers$ = organization$.pipe( const allCiphers$ = organization$.pipe(
concatMap(async (organization) => { concatMap(async (organization) => {
// If user swaps organization reset the addAccessToggle
if (!this.showAddAccessToggle || organization) {
this.addAccessToggle(0);
}
let ciphers; let ciphers;
if (organization.isProviderUser && this.restrictProviderAccessEnabled) {
return [];
}
if (this.flexibleCollectionsV1Enabled) { if (this.flexibleCollectionsV1Enabled) {
// Flexible collections V1 logic. // Flexible collections V1 logic.
// If the user can edit all ciphers for the organization then fetch them ALL. // If the user can edit all ciphers for the organization then fetch them ALL.
if (organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) { if (
organization.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccessEnabled,
)
) {
ciphers = await this.cipherService.getAllFromApiForOrganization(organization.id); ciphers = await this.cipherService.getAllFromApiForOrganization(organization.id);
} else { } else {
// Otherwise, only fetch ciphers they have access to (includes unassigned for admins). // Otherwise, only fetch ciphers they have access to (includes unassigned for admins).
@ -322,7 +372,12 @@ export class VaultComponent implements OnInit, OnDestroy {
} }
} else { } else {
// Pre-flexible collections logic, to be removed after flexible collections is fully released // Pre-flexible collections logic, to be removed after flexible collections is fully released
if (organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) { if (
organization.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccessEnabled,
)
) {
ciphers = await this.cipherService.getAllFromApiForOrganization(organization.id); ciphers = await this.cipherService.getAllFromApiForOrganization(organization.id);
} else { } else {
ciphers = (await this.cipherService.getAllDecrypted()).filter( ciphers = (await this.cipherService.getAllDecrypted()).filter(
@ -348,9 +403,21 @@ export class VaultComponent implements OnInit, OnDestroy {
shareReplay({ refCount: true, bufferSize: 1 }), shareReplay({ refCount: true, bufferSize: 1 }),
); );
const collections$ = combineLatest([nestedCollections$, filter$, this.currentSearchText$]).pipe( // This will be passed into the usersCanManage call
this.orgRevokedUsers = (
await this.organizationUserService.getAllUsers(await firstValueFrom(organizationId$))
).data.filter((user: OrganizationUserUserDetailsResponse) => {
return user.status === -1;
});
const collections$ = combineLatest([
nestedCollections$,
filter$,
this.currentSearchText$,
this.addAccessStatus$,
]).pipe(
filter(([collections, filter]) => collections != undefined && filter != undefined), filter(([collections, filter]) => collections != undefined && filter != undefined),
concatMap(async ([collections, filter, searchText]) => { concatMap(async ([collections, filter, searchText, addAccessStatus]) => {
if ( if (
filter.collectionId === Unassigned || filter.collectionId === Unassigned ||
(filter.collectionId === undefined && filter.type !== undefined) (filter.collectionId === undefined && filter.type !== undefined)
@ -358,26 +425,30 @@ export class VaultComponent implements OnInit, OnDestroy {
return []; return [];
} }
this.showAddAccessToggle = false;
let collectionsToReturn = []; let collectionsToReturn = [];
if (filter.collectionId === undefined || filter.collectionId === All) { if (filter.collectionId === undefined || filter.collectionId === All) {
collectionsToReturn = collections.map((c) => c.node); collectionsToReturn = await this.addAccessCollectionsMap(collections);
} else { } else {
const selectedCollection = ServiceUtils.getTreeNodeObjectFromList( const selectedCollection = ServiceUtils.getTreeNodeObjectFromList(
collections, collections,
filter.collectionId, filter.collectionId,
); );
collectionsToReturn = selectedCollection?.children.map((c) => c.node) ?? []; collectionsToReturn = await this.addAccessCollectionsMap(selectedCollection?.children);
} }
if (await this.searchService.isSearchable(searchText)) { if (await this.searchService.isSearchable(searchText)) {
collectionsToReturn = this.searchPipe.transform( collectionsToReturn = this.searchPipe.transform(
collectionsToReturn, collectionsToReturn,
searchText, searchText,
(collection) => collection.name, (collection: CollectionAdminView) => collection.name,
(collection) => collection.id, (collection: CollectionAdminView) => collection.id,
); );
} }
if (addAccessStatus === 1 && this.showAddAccessToggle) {
collectionsToReturn = collectionsToReturn.filter((c: any) => c.addAccess);
}
return collectionsToReturn; return collectionsToReturn;
}), }),
takeUntil(this.destroy$), takeUntil(this.destroy$),
@ -406,9 +477,17 @@ export class VaultComponent implements OnInit, OnDestroy {
organization$, organization$,
]).pipe( ]).pipe(
map(([filter, collection, organization]) => { map(([filter, collection, organization]) => {
if (organization.isProviderUser && this.restrictProviderAccessEnabled) {
return collection != undefined || filter.collectionId === Unassigned;
}
return ( return (
(filter.collectionId === Unassigned && !organization.canEditUnassignedCiphers()) || (filter.collectionId === Unassigned &&
(!organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled) && !organization.canEditUnassignedCiphers(this.restrictProviderAccessEnabled)) ||
(!organization.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccessEnabled,
) &&
collection != undefined && collection != undefined &&
!collection.node.assigned) !collection.node.assigned)
); );
@ -453,7 +532,8 @@ export class VaultComponent implements OnInit, OnDestroy {
map(([filter, collection, organization]) => { map(([filter, collection, organization]) => {
return ( return (
// Filtering by unassigned, show message if not admin // Filtering by unassigned, show message if not admin
(filter.collectionId === Unassigned && !organization.canEditUnassignedCiphers()) || (filter.collectionId === Unassigned &&
!organization.canEditUnassignedCiphers(this.restrictProviderAccessEnabled)) ||
// Filtering by a collection, so show message if user is not assigned // Filtering by a collection, so show message if user is not assigned
(collection != undefined && (collection != undefined &&
!collection.node.assigned && !collection.node.assigned &&
@ -476,7 +556,7 @@ export class VaultComponent implements OnInit, OnDestroy {
if (this.flexibleCollectionsV1Enabled) { if (this.flexibleCollectionsV1Enabled) {
canEditCipher = canEditCipher =
organization.canEditAllCiphers(true) || organization.canEditAllCiphers(true, this.restrictProviderAccessEnabled) ||
(await firstValueFrom(allCipherMap$))[cipherId] != undefined; (await firstValueFrom(allCipherMap$))[cipherId] != undefined;
} else { } else {
canEditCipher = canEditCipher =
@ -586,6 +666,60 @@ export class VaultComponent implements OnInit, OnDestroy {
); );
} }
// Update the list of collections to see if any collection is orphaned
// and will receive the addAccess badge / be filterable by the user
async addAccessCollectionsMap(collections: TreeNode<CollectionAdminView>[]) {
let mappedCollections;
const { type, allowAdminAccessToAllCollectionItems, permissions } = this.organization;
const canEditCiphersCheck =
this._flexibleCollectionsV1FlagEnabled &&
!this.organization.canEditAllCiphers(
this._flexibleCollectionsV1FlagEnabled,
this.restrictProviderAccessEnabled,
);
// This custom type check will show addAccess badge for
// Custom users with canEdit access AND owner/admin manage access setting is OFF
const customUserCheck =
this._flexibleCollectionsV1FlagEnabled &&
!allowAdminAccessToAllCollectionItems &&
type === OrganizationUserType.Custom &&
permissions.editAnyCollection;
// If Custom user has Delete Only access they will not see Add Access toggle
const customUserOnlyDelete =
this.flexibleCollectionsV1Enabled &&
type === OrganizationUserType.Custom &&
permissions.deleteAnyCollection &&
!permissions.editAnyCollection;
if (!customUserOnlyDelete && (canEditCiphersCheck || customUserCheck)) {
mappedCollections = collections.map((c: TreeNode<CollectionAdminView>) => {
const groupsCanManage = c.node.groupsCanManage();
const usersCanManage = c.node.usersCanManage(this.orgRevokedUsers);
if (
groupsCanManage.length === 0 &&
usersCanManage.length === 0 &&
c.node.id !== Unassigned
) {
c.node.addAccess = true;
this.showAddAccessToggle = true;
} else {
c.node.addAccess = false;
}
return c.node;
});
} else {
mappedCollections = collections.map((c: TreeNode<CollectionAdminView>) => c.node);
}
return mappedCollections;
}
addAccessToggle(e: any) {
this.addAccessStatus$.next(e);
}
get loading() { get loading() {
return this.refreshing || this.processingEvent; return this.refreshing || this.processingEvent;
} }
@ -692,13 +826,13 @@ export class VaultComponent implements OnInit, OnDestroy {
map((c) => { map((c) => {
return c.sort((a, b) => { return c.sort((a, b) => {
if ( if (
a.canEditItems(this.organization, true) && a.canEditItems(this.organization, true, this.restrictProviderAccessEnabled) &&
!b.canEditItems(this.organization, true) !b.canEditItems(this.organization, true, this.restrictProviderAccessEnabled)
) { ) {
return -1; return -1;
} else if ( } else if (
!a.canEditItems(this.organization, true) && !a.canEditItems(this.organization, true, this.restrictProviderAccessEnabled) &&
b.canEditItems(this.organization, true) b.canEditItems(this.organization, true, this.restrictProviderAccessEnabled)
) { ) {
return 1; return 1;
} else { } else {
@ -714,33 +848,14 @@ export class VaultComponent implements OnInit, OnDestroy {
const dialog = openOrgVaultCollectionsDialog(this.dialogService, { const dialog = openOrgVaultCollectionsDialog(this.dialogService, {
data: { data: {
collectionIds: cipher.collectionIds, collectionIds: cipher.collectionIds,
collections: collections.filter((c) => !c.readOnly && c.id != Unassigned), collections: collections,
organization: this.organization, organization: this.organization,
cipherId: cipher.id, cipherId: cipher.id,
}, },
}); });
/**
const [modal] = await this.modalService.openViewRef(
CollectionsComponent,
this.collectionsModalRef,
(comp) => {
comp.flexibleCollectionsV1Enabled = this.flexibleCollectionsV1Enabled;
comp.collectionIds = cipher.collectionIds;
comp.collections = collections;
comp.organization = this.organization;
comp.cipherId = cipher.id;
comp.onSavedCollections.pipe(takeUntil(this.destroy$)).subscribe(() => {
modal.close();
this.refresh();
});
},
);
*/
if ((await lastValueFrom(dialog.closed)) == CollectionsDialogResult.Saved) { if ((await lastValueFrom(dialog.closed)) == CollectionsDialogResult.Saved) {
await this.refresh(); this.refresh();
} }
} }
@ -1178,7 +1293,10 @@ export class VaultComponent implements OnInit, OnDestroy {
} }
protected deleteCipherWithServer(id: string, permanent: boolean) { protected deleteCipherWithServer(id: string, permanent: boolean) {
const asAdmin = this.organization?.canEditAllCiphers(this.flexibleCollectionsV1Enabled); const asAdmin = this.organization?.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccessEnabled,
);
return permanent return permanent
? this.cipherService.deleteWithServer(id, asAdmin) ? this.cipherService.deleteWithServer(id, asAdmin)
: this.cipherService.softDeleteWithServer(id, asAdmin); : this.cipherService.softDeleteWithServer(id, asAdmin);

View File

@ -1,6 +1,6 @@
import { NgModule } from "@angular/core"; import { NgModule } from "@angular/core";
import { BreadcrumbsModule, NoItemsModule } from "@bitwarden/components"; import { BreadcrumbsModule, NoItemsModule, SearchModule } from "@bitwarden/components";
import { LooseComponentsModule } from "../../shared/loose-components.module"; import { LooseComponentsModule } from "../../shared/loose-components.module";
import { SharedModule } from "../../shared/shared.module"; import { SharedModule } from "../../shared/shared.module";
@ -32,6 +32,7 @@ import { VaultComponent } from "./vault.component";
CollectionDialogModule, CollectionDialogModule,
CollectionAccessRestrictedComponent, CollectionAccessRestrictedComponent,
NoItemsModule, NoItemsModule,
SearchModule,
], ],
declarations: [VaultComponent, VaultHeaderComponent], declarations: [VaultComponent, VaultHeaderComponent],
exports: [VaultComponent], exports: [VaultComponent],

View File

@ -2788,6 +2788,12 @@
"all": { "all": {
"message": "All" "message": "All"
}, },
"addAccess": {
"message": "Add Access"
},
"addAccessFilter": {
"message": "Add Access Filter"
},
"refresh": { "refresh": {
"message": "Refresh" "message": "Refresh"
}, },

View File

@ -62,7 +62,7 @@
{{ "addOrganization" | i18n }} {{ "addOrganization" | i18n }}
</button> </button>
<button bitButton buttonType="secondary" type="button" [bitDialogClose]="ResultType.Closed"> <button bitButton buttonType="secondary" type="button" [bitDialogClose]="ResultType.Closed">
{{ "close" | i18n }} {{ "cancel" | i18n }}
</button> </button>
</ng-container> </ng-container>
</bit-dialog> </bit-dialog>

View File

@ -2,6 +2,8 @@ import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@ -23,6 +25,7 @@ export class CollectionsComponent implements OnInit {
collections: CollectionView[] = []; collections: CollectionView[] = [];
organization: Organization; organization: Organization;
flexibleCollectionsV1Enabled: boolean; flexibleCollectionsV1Enabled: boolean;
restrictProviderAccess: boolean;
protected cipherDomain: Cipher; protected cipherDomain: Cipher;
@ -33,9 +36,16 @@ export class CollectionsComponent implements OnInit {
protected cipherService: CipherService, protected cipherService: CipherService,
protected organizationService: OrganizationService, protected organizationService: OrganizationService,
private logService: LogService, private logService: LogService,
private configService: ConfigService,
) {} ) {}
async ngOnInit() { async ngOnInit() {
this.flexibleCollectionsV1Enabled = await this.configService.getFeatureFlag(
FeatureFlag.FlexibleCollectionsV1,
);
this.restrictProviderAccess = await this.configService.getFeatureFlag(
FeatureFlag.RestrictProviderAccess,
);
await this.load(); await this.load();
} }
@ -62,7 +72,12 @@ export class CollectionsComponent implements OnInit {
async submit(): Promise<boolean> { async submit(): Promise<boolean> {
const selectedCollectionIds = this.collections const selectedCollectionIds = this.collections
.filter((c) => { .filter((c) => {
if (this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) { if (
this.organization.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
)
) {
return !!(c as any).checked; return !!(c as any).checked;
} else { } else {
return !!(c as any).checked && c.readOnly == null; return !!(c as any).checked && c.readOnly == null;

View File

@ -3,7 +3,6 @@ import { Observable, Subject } from "rxjs";
import { ClientType } from "@bitwarden/common/enums"; import { ClientType } from "@bitwarden/common/enums";
import { import {
AbstractMemoryStorageService,
AbstractStorageService, AbstractStorageService,
ObservableStorageService, ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service"; } from "@bitwarden/common/platform/abstractions/storage.service";
@ -24,7 +23,7 @@ export class SafeInjectionToken<T> extends InjectionToken<T> {
export const WINDOW = new SafeInjectionToken<Window>("WINDOW"); export const WINDOW = new SafeInjectionToken<Window>("WINDOW");
export const OBSERVABLE_MEMORY_STORAGE = new SafeInjectionToken< export const OBSERVABLE_MEMORY_STORAGE = new SafeInjectionToken<
AbstractMemoryStorageService & ObservableStorageService AbstractStorageService & ObservableStorageService
>("OBSERVABLE_MEMORY_STORAGE"); >("OBSERVABLE_MEMORY_STORAGE");
export const OBSERVABLE_DISK_STORAGE = new SafeInjectionToken< export const OBSERVABLE_DISK_STORAGE = new SafeInjectionToken<
AbstractStorageService & ObservableStorageService AbstractStorageService & ObservableStorageService
@ -32,9 +31,7 @@ export const OBSERVABLE_DISK_STORAGE = new SafeInjectionToken<
export const OBSERVABLE_DISK_LOCAL_STORAGE = new SafeInjectionToken< export const OBSERVABLE_DISK_LOCAL_STORAGE = new SafeInjectionToken<
AbstractStorageService & ObservableStorageService AbstractStorageService & ObservableStorageService
>("OBSERVABLE_DISK_LOCAL_STORAGE"); >("OBSERVABLE_DISK_LOCAL_STORAGE");
export const MEMORY_STORAGE = new SafeInjectionToken<AbstractMemoryStorageService>( export const MEMORY_STORAGE = new SafeInjectionToken<AbstractStorageService>("MEMORY_STORAGE");
"MEMORY_STORAGE",
);
export const SECURE_STORAGE = new SafeInjectionToken<AbstractStorageService>("SECURE_STORAGE"); export const SECURE_STORAGE = new SafeInjectionToken<AbstractStorageService>("SECURE_STORAGE");
export const STATE_FACTORY = new SafeInjectionToken<StateFactory>("STATE_FACTORY"); export const STATE_FACTORY = new SafeInjectionToken<StateFactory>("STATE_FACTORY");
export const LOGOUT_CALLBACK = new SafeInjectionToken< export const LOGOUT_CALLBACK = new SafeInjectionToken<

View File

@ -91,6 +91,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
private previousCipherId: string; private previousCipherId: string;
protected flexibleCollectionsV1Enabled = false; protected flexibleCollectionsV1Enabled = false;
protected restrictProviderAccess = false;
get fido2CredentialCreationDateValue(): string { get fido2CredentialCreationDateValue(): string {
const dateCreated = this.i18nService.t("dateCreated"); const dateCreated = this.i18nService.t("dateCreated");
@ -183,6 +184,9 @@ export class AddEditComponent implements OnInit, OnDestroy {
this.flexibleCollectionsV1Enabled = await this.configService.getFeatureFlag( this.flexibleCollectionsV1Enabled = await this.configService.getFeatureFlag(
FeatureFlag.FlexibleCollectionsV1, FeatureFlag.FlexibleCollectionsV1,
); );
this.restrictProviderAccess = await this.configService.getFeatureFlag(
FeatureFlag.RestrictProviderAccess,
);
this.policyService this.policyService
.policyAppliesToActiveUser$(PolicyType.PersonalOwnership) .policyAppliesToActiveUser$(PolicyType.PersonalOwnership)
@ -668,11 +672,14 @@ export class AddEditComponent implements OnInit, OnDestroy {
protected saveCipher(cipher: Cipher) { protected saveCipher(cipher: Cipher) {
const isNotClone = this.editMode && !this.cloneMode; const isNotClone = this.editMode && !this.cloneMode;
let orgAdmin = this.organization?.canEditAllCiphers(this.flexibleCollectionsV1Enabled); let orgAdmin = this.organization?.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
);
// if a cipher is unassigned we want to check if they are an admin or have permission to edit any collection // if a cipher is unassigned we want to check if they are an admin or have permission to edit any collection
if (!cipher.collectionIds) { if (!cipher.collectionIds) {
orgAdmin = this.organization?.canEditUnassignedCiphers(); orgAdmin = this.organization?.canEditUnassignedCiphers(this.restrictProviderAccess);
} }
return this.cipher.id == null return this.cipher.id == null
@ -681,14 +688,20 @@ export class AddEditComponent implements OnInit, OnDestroy {
} }
protected deleteCipher() { protected deleteCipher() {
const asAdmin = this.organization?.canEditAllCiphers(this.flexibleCollectionsV1Enabled); const asAdmin = this.organization?.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
);
return this.cipher.isDeleted return this.cipher.isDeleted
? this.cipherService.deleteWithServer(this.cipher.id, asAdmin) ? this.cipherService.deleteWithServer(this.cipher.id, asAdmin)
: this.cipherService.softDeleteWithServer(this.cipher.id, asAdmin); : this.cipherService.softDeleteWithServer(this.cipher.id, asAdmin);
} }
protected restoreCipher() { protected restoreCipher() {
const asAdmin = this.organization?.canEditAllCiphers(this.flexibleCollectionsV1Enabled); const asAdmin = this.organization?.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
);
return this.cipherService.restoreWithServer(this.cipher.id, asAdmin); return this.cipherService.restoreWithServer(this.cipher.id, asAdmin);
} }

View File

@ -203,22 +203,32 @@ export class Organization {
); );
} }
canEditUnassignedCiphers() { canEditUnassignedCiphers(restrictProviderAccessFlagEnabled: boolean) {
// TODO: Update this to exclude Providers if provider access is restricted in AC-1707 if (this.isProviderUser) {
return !restrictProviderAccessFlagEnabled;
}
return this.isAdmin || this.permissions.editAnyCollection; return this.isAdmin || this.permissions.editAnyCollection;
} }
canEditAllCiphers(flexibleCollectionsV1Enabled: boolean) { canEditAllCiphers(
flexibleCollectionsV1Enabled: boolean,
restrictProviderAccessFlagEnabled: boolean,
) {
// Before Flexible Collections, any admin or anyone with editAnyCollection permission could edit all ciphers // Before Flexible Collections, any admin or anyone with editAnyCollection permission could edit all ciphers
if (!this.flexibleCollections || !flexibleCollectionsV1Enabled) { if (!this.flexibleCollections || !flexibleCollectionsV1Enabled || !this.flexibleCollections) {
return this.isAdmin || this.permissions.editAnyCollection; return this.isAdmin || this.permissions.editAnyCollection;
} }
if (this.isProviderUser) {
return !restrictProviderAccessFlagEnabled;
}
// Post Flexible Collections V1, the allowAdminAccessToAllCollectionItems flag can restrict admins // Post Flexible Collections V1, the allowAdminAccessToAllCollectionItems flag can restrict admins
// Providers and custom users with canEditAnyCollection are not affected by allowAdminAccessToAllCollectionItems flag // Custom users with canEditAnyCollection are not affected by allowAdminAccessToAllCollectionItems flag
return ( return (
this.isProviderUser ||
(this.type === OrganizationUserType.Custom && this.permissions.editAnyCollection) || (this.type === OrganizationUserType.Custom && this.permissions.editAnyCollection) ||
(this.allowAdminAccessToAllCollectionItems && this.isAdmin) (this.allowAdminAccessToAllCollectionItems &&
(this.type === OrganizationUserType.Admin || this.type === OrganizationUserType.Owner))
); );
} }

View File

@ -17,6 +17,7 @@ export enum FeatureFlag {
UnassignedItemsBanner = "unassigned-items-banner", UnassignedItemsBanner = "unassigned-items-banner",
EnableDeleteProvider = "AC-1218-delete-provider", EnableDeleteProvider = "AC-1218-delete-provider",
ExtensionRefresh = "extension-refresh", ExtensionRefresh = "extension-refresh",
RestrictProviderAccess = "restrict-provider-access",
} }
export type AllowedFeatureFlagTypes = boolean | number | string; export type AllowedFeatureFlagTypes = boolean | number | string;
@ -44,6 +45,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.UnassignedItemsBanner]: FALSE, [FeatureFlag.UnassignedItemsBanner]: FALSE,
[FeatureFlag.EnableDeleteProvider]: FALSE, [FeatureFlag.EnableDeleteProvider]: FALSE,
[FeatureFlag.ExtensionRefresh]: FALSE, [FeatureFlag.ExtensionRefresh]: FALSE,
[FeatureFlag.RestrictProviderAccess]: FALSE,
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>; } satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue; export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;

View File

@ -1,6 +1,6 @@
import { Observable } from "rxjs"; import { Observable } from "rxjs";
import { MemoryStorageOptions, StorageOptions } from "../models/domain/storage-options"; import { StorageOptions } from "../models/domain/storage-options";
export type StorageUpdateType = "save" | "remove"; export type StorageUpdateType = "save" | "remove";
export type StorageUpdate = { export type StorageUpdate = {
@ -24,12 +24,3 @@ export abstract class AbstractStorageService {
abstract save<T>(key: string, obj: T, options?: StorageOptions): Promise<void>; abstract save<T>(key: string, obj: T, options?: StorageOptions): Promise<void>;
abstract remove(key: string, options?: StorageOptions): Promise<void>; abstract remove(key: string, options?: StorageOptions): Promise<void>;
} }
export abstract class AbstractMemoryStorageService extends AbstractStorageService {
// Used to identify the service in the session sync decorator framework
static readonly TYPE = "MemoryStorageService";
readonly type = AbstractMemoryStorageService.TYPE;
abstract get<T>(key: string, options?: MemoryStorageOptions<T>): Promise<T>;
abstract getBypassCache<T>(key: string, options?: MemoryStorageOptions<T>): Promise<T>;
}

View File

@ -1,5 +1,3 @@
import { Jsonify } from "type-fest";
import { HtmlStorageLocation, StorageLocation } from "../../enums"; import { HtmlStorageLocation, StorageLocation } from "../../enums";
export type StorageOptions = { export type StorageOptions = {
@ -9,5 +7,3 @@ export type StorageOptions = {
htmlStorageLocation?: HtmlStorageLocation; htmlStorageLocation?: HtmlStorageLocation;
keySuffix?: string; keySuffix?: string;
}; };
export type MemoryStorageOptions<T> = StorageOptions & { deserializer?: (obj: Jsonify<T>) => T };

View File

@ -1,8 +1,8 @@
import { Subject } from "rxjs"; import { Subject } from "rxjs";
import { AbstractMemoryStorageService, StorageUpdate } from "../abstractions/storage.service"; import { AbstractStorageService, StorageUpdate } from "../abstractions/storage.service";
export class MemoryStorageService extends AbstractMemoryStorageService { export class MemoryStorageService extends AbstractStorageService {
protected store = new Map<string, unknown>(); protected store = new Map<string, unknown>();
private updatesSubject = new Subject<StorageUpdate>(); private updatesSubject = new Subject<StorageUpdate>();
@ -42,8 +42,4 @@ export class MemoryStorageService extends AbstractMemoryStorageService {
this.updatesSubject.next({ key, updateType: "remove" }); this.updatesSubject.next({ key, updateType: "remove" });
return Promise.resolve(); return Promise.resolve();
} }
getBypassCache<T>(key: string): Promise<T> {
return this.get<T>(key);
}
} }

View File

@ -14,10 +14,7 @@ import {
InitOptions, InitOptions,
StateService as StateServiceAbstraction, StateService as StateServiceAbstraction,
} from "../abstractions/state.service"; } from "../abstractions/state.service";
import { import { AbstractStorageService } from "../abstractions/storage.service";
AbstractMemoryStorageService,
AbstractStorageService,
} from "../abstractions/storage.service";
import { HtmlStorageLocation, StorageLocation } from "../enums"; import { HtmlStorageLocation, StorageLocation } from "../enums";
import { StateFactory } from "../factories/state-factory"; import { StateFactory } from "../factories/state-factory";
import { Utils } from "../misc/utils"; import { Utils } from "../misc/utils";
@ -61,7 +58,7 @@ export class StateService<
constructor( constructor(
protected storageService: AbstractStorageService, protected storageService: AbstractStorageService,
protected secureStorageService: AbstractStorageService, protected secureStorageService: AbstractStorageService,
protected memoryStorageService: AbstractMemoryStorageService, protected memoryStorageService: AbstractStorageService,
protected logService: LogService, protected logService: LogService,
protected stateFactory: StateFactory<TGlobalState, TAccount>, protected stateFactory: StateFactory<TGlobalState, TAccount>,
protected accountService: AccountService, protected accountService: AccountService,
@ -1111,9 +1108,10 @@ export class StateService<
} }
protected async state(): Promise<State<TGlobalState, TAccount>> { protected async state(): Promise<State<TGlobalState, TAccount>> {
const state = await this.memoryStorageService.get<State<TGlobalState, TAccount>>(keys.state, { let state = await this.memoryStorageService.get<State<TGlobalState, TAccount>>(keys.state);
deserializer: (s) => State.fromJSON(s, this.accountDeserializer), if (this.memoryStorageService.valuesRequireDeserialization) {
}); state = State.fromJSON(state, this.accountDeserializer);
}
return state; return state;
} }

View File

@ -1,13 +1,13 @@
import { Subject } from "rxjs"; import { Subject } from "rxjs";
import { import {
AbstractMemoryStorageService, AbstractStorageService,
ObservableStorageService, ObservableStorageService,
StorageUpdate, StorageUpdate,
} from "../../abstractions/storage.service"; } from "../../abstractions/storage.service";
export class MemoryStorageService export class MemoryStorageService
extends AbstractMemoryStorageService extends AbstractStorageService
implements ObservableStorageService implements ObservableStorageService
{ {
protected store: Record<string, string> = {}; protected store: Record<string, string> = {};
@ -49,8 +49,4 @@ export class MemoryStorageService
this.updatesSubject.next({ key, updateType: "remove" }); this.updatesSubject.next({ key, updateType: "remove" });
return Promise.resolve(); return Promise.resolve();
} }
getBypassCache<T>(key: string): Promise<T> {
return this.get<T>(key);
}
} }

View File

@ -61,6 +61,7 @@ describe("Collection", () => {
const view = await collection.decrypt(); const view = await collection.decrypt();
expect(view).toEqual({ expect(view).toEqual({
addAccess: false,
externalId: "extId", externalId: "extId",
hidePasswords: false, hidePasswords: false,
id: "id", id: "id",

View File

@ -17,6 +17,7 @@ export class CollectionView implements View, ITreeNodeObject {
readOnly: boolean = null; readOnly: boolean = null;
hidePasswords: boolean = null; hidePasswords: boolean = null;
manage: boolean = null; manage: boolean = null;
addAccess: boolean = false;
assigned: boolean = null; assigned: boolean = null;
constructor(c?: Collection | CollectionAccessDetailsResponse) { constructor(c?: Collection | CollectionAccessDetailsResponse) {
@ -38,7 +39,11 @@ export class CollectionView implements View, ITreeNodeObject {
} }
} }
canEditItems(org: Organization, v1FlexibleCollections: boolean): boolean { canEditItems(
org: Organization,
v1FlexibleCollections: boolean,
restrictProviderAccess: boolean,
): boolean {
if (org != null && org.id !== this.organizationId) { if (org != null && org.id !== this.organizationId) {
throw new Error( throw new Error(
"Id of the organization provided does not match the org id of the collection.", "Id of the organization provided does not match the org id of the collection.",
@ -47,7 +52,7 @@ export class CollectionView implements View, ITreeNodeObject {
if (org?.flexibleCollections) { if (org?.flexibleCollections) {
return ( return (
org?.canEditAllCiphers(v1FlexibleCollections) || org?.canEditAllCiphers(v1FlexibleCollections, restrictProviderAccess) ||
this.manage || this.manage ||
(this.assigned && !this.readOnly) (this.assigned && !this.readOnly)
); );