mirror of
https://github.com/bitwarden/browser
synced 2026-02-15 16:05:03 +00:00
Merge branch 'main' into feature/passkey-provider
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
<div class="tw-mr-[5px] tw-mt-1">
|
||||
<div class="tw-me-2 tw-mt-1">
|
||||
<button
|
||||
*ngIf="currentAccount$ | async as currentAccount; else defaultButton"
|
||||
type="button"
|
||||
|
||||
@@ -245,6 +245,7 @@ export type OverlayBackgroundExtensionMessageHandlers = {
|
||||
editedCipher: () => void;
|
||||
deletedCipher: () => void;
|
||||
bgSaveCipher: () => void;
|
||||
updateOverlayCiphers: () => void;
|
||||
fido2AbortRequest: ({ message, sender }: BackgroundOnMessageHandlerParams) => void;
|
||||
};
|
||||
|
||||
|
||||
@@ -191,6 +191,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
|
||||
editedCipher: () => this.updateOverlayCiphers(),
|
||||
deletedCipher: () => this.updateOverlayCiphers(),
|
||||
bgSaveCipher: () => this.updateOverlayCiphers(),
|
||||
updateOverlayCiphers: () => this.updateOverlayCiphers(),
|
||||
fido2AbortRequest: ({ sender }) => this.abortFido2ActiveRequest(sender.tab.id),
|
||||
};
|
||||
private readonly inlineMenuButtonPortMessageHandlers: InlineMenuButtonPortMessageHandlers = {
|
||||
|
||||
@@ -898,6 +898,7 @@ export default class MainBackground {
|
||||
this.accountService,
|
||||
this.logService,
|
||||
this.cipherEncryptionService,
|
||||
this.messagingService,
|
||||
);
|
||||
this.folderService = new FolderService(
|
||||
this.keyService,
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
<!--
|
||||
end padding is less than start padding to prioritize visual alignment when icon buttons are used at the end of the end slot.
|
||||
other elements used at the end of the end slot may need to add their own margin/padding to achieve visual alignment.
|
||||
-->
|
||||
<header
|
||||
class="tw-p-3 bit-compact:tw-p-2 tw-pl-4 bit-compact:tw-pl-3 tw-transition-colors tw-duration-200 tw-border-0 tw-border-b tw-border-solid"
|
||||
class="tw-py-3 bit-compact:tw-py-2 tw-pe-1 bit-compact:tw-pe-0.5 tw-transition-colors tw-duration-200 tw-border-0 tw-border-b tw-border-solid"
|
||||
[ngClass]="{
|
||||
'tw-bg-background-alt tw-border-transparent':
|
||||
this.background === 'alt' && !pageContentScrolled(),
|
||||
'tw-bg-background tw-border-secondary-300':
|
||||
(this.background === 'alt' && pageContentScrolled()) || this.background === 'default',
|
||||
'tw-ps-4 bit-compact:tw-ps-3': !showBackButton,
|
||||
'tw-ps-1 bit-compact:tw-ps-0': showBackButton,
|
||||
}"
|
||||
>
|
||||
<div class="tw-max-w-screen-sm tw-mx-auto tw-flex tw-justify-between tw-w-full">
|
||||
<div class="tw-inline-flex tw-items-center tw-gap-2 tw-h-9">
|
||||
<button
|
||||
class="-tw-ml-1"
|
||||
bitIconButton="bwi-angle-left"
|
||||
type="button"
|
||||
*ngIf="showBackButton"
|
||||
|
||||
@@ -117,7 +117,7 @@ class MockPopoutButtonComponent {}
|
||||
@Component({
|
||||
selector: "mock-current-account",
|
||||
template: `
|
||||
<button class="tw-bg-transparent tw-border-none" type="button">
|
||||
<button class="tw-bg-transparent tw-border-none tw-p-0 tw-me-1" type="button">
|
||||
<bit-avatar text="Ash Ketchum" size="small"></bit-avatar>
|
||||
</button>
|
||||
`,
|
||||
@@ -654,7 +654,7 @@ export const WithVirtualScrollChild: Story = {
|
||||
<bit-section>
|
||||
@defer (on immediate) {
|
||||
<bit-item-group aria-label="Mock Vault Items">
|
||||
<cdk-virtual-scroll-viewport itemSize="61" bitScrollLayout>
|
||||
<cdk-virtual-scroll-viewport itemSize="59" bitScrollLayout>
|
||||
<bit-item *cdkVirtualFor="let item of data; index as i">
|
||||
<button type="button" bit-item-content>
|
||||
<i
|
||||
|
||||
@@ -723,6 +723,7 @@ export class ServiceContainer {
|
||||
this.accountService,
|
||||
this.logService,
|
||||
this.cipherEncryptionService,
|
||||
this.messagingService,
|
||||
);
|
||||
|
||||
this.folderService = new FolderService(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@bitwarden/desktop",
|
||||
"description": "A secure and free password manager for all of your devices.",
|
||||
"version": "2025.7.0",
|
||||
"version": "2025.8.0",
|
||||
"keywords": [
|
||||
"bitwarden",
|
||||
"password",
|
||||
|
||||
@@ -91,6 +91,7 @@ export class InitService {
|
||||
containerService.attachToGlobal(this.win);
|
||||
|
||||
await this.autofillService.init();
|
||||
await this.autotypeService.init();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ import {
|
||||
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
||||
import { ClientType, DeviceType } from "@bitwarden/common/enums";
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
|
||||
import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
@@ -456,17 +456,15 @@ const safeProviders: SafeProvider[] = [
|
||||
}),
|
||||
safeProvider({
|
||||
provide: DesktopAutotypeService,
|
||||
useFactory: (
|
||||
configService: ConfigService,
|
||||
globalStateProvider: GlobalStateProvider,
|
||||
platformUtilsService: PlatformUtilsServiceAbstraction,
|
||||
) =>
|
||||
new DesktopAutotypeService(
|
||||
configService,
|
||||
globalStateProvider,
|
||||
platformUtilsService.getDevice() === DeviceType.WindowsDesktop,
|
||||
),
|
||||
deps: [ConfigService, GlobalStateProvider, PlatformUtilsServiceAbstraction],
|
||||
useClass: DesktopAutotypeService,
|
||||
deps: [
|
||||
AccountService,
|
||||
AuthService,
|
||||
CipherServiceAbstraction,
|
||||
ConfigService,
|
||||
GlobalStateProvider,
|
||||
PlatformUtilsServiceAbstraction,
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
|
||||
@@ -1,33 +1,70 @@
|
||||
import { autotype } from "@bitwarden/desktop-napi";
|
||||
import { ipcMain, globalShortcut } from "electron";
|
||||
|
||||
import { DesktopAutotypeService } from "../services/desktop-autotype.service";
|
||||
import { autotype } from "@bitwarden/desktop-napi";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { WindowMain } from "../../main/window.main";
|
||||
import { stringIsNotUndefinedNullAndEmpty } from "../../utils";
|
||||
|
||||
export class MainDesktopAutotypeService {
|
||||
constructor(private desktopAutotypeService: DesktopAutotypeService) {}
|
||||
keySequence: string = "Alt+CommandOrControl+I";
|
||||
|
||||
constructor(
|
||||
private logService: LogService,
|
||||
private windowMain: WindowMain,
|
||||
) {}
|
||||
|
||||
init() {
|
||||
this.desktopAutotypeService.autotypeEnabled$.subscribe((enabled) => {
|
||||
if (enabled) {
|
||||
ipcMain.on("autofill.configureAutotype", (event, data) => {
|
||||
if (data.enabled === true && !globalShortcut.isRegistered(this.keySequence)) {
|
||||
this.enableAutotype();
|
||||
} else {
|
||||
} else if (data.enabled === false && globalShortcut.isRegistered(this.keySequence)) {
|
||||
this.disableAutotype();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on("autofill.completeAutotypeRequest", (event, data) => {
|
||||
const { response } = data;
|
||||
|
||||
if (
|
||||
stringIsNotUndefinedNullAndEmpty(response.username) &&
|
||||
stringIsNotUndefinedNullAndEmpty(response.password)
|
||||
) {
|
||||
this.doAutotype(response.username, response.password);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: this will call into desktop native code
|
||||
private enableAutotype() {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("Enabling Autotype...");
|
||||
disableAutotype() {
|
||||
if (globalShortcut.isRegistered(this.keySequence)) {
|
||||
globalShortcut.unregister(this.keySequence);
|
||||
}
|
||||
|
||||
const result = autotype.getForegroundWindowTitle();
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("Window Title: " + result);
|
||||
this.logService.info("Autotype disabled.");
|
||||
}
|
||||
|
||||
// TODO: this will call into desktop native code
|
||||
private disableAutotype() {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("Disabling Autotype...");
|
||||
private enableAutotype() {
|
||||
const result = globalShortcut.register(this.keySequence, () => {
|
||||
const windowTitle = autotype.getForegroundWindowTitle();
|
||||
|
||||
this.windowMain.win.webContents.send("autofill.listenAutotypeRequest", {
|
||||
windowTitle,
|
||||
});
|
||||
});
|
||||
|
||||
result
|
||||
? this.logService.info("Autotype enabled.")
|
||||
: this.logService.info("Enabling autotype failed.");
|
||||
}
|
||||
|
||||
private doAutotype(username: string, password: string) {
|
||||
const inputPattern = username + "\t" + password;
|
||||
const inputArray = new Array<number>(inputPattern.length);
|
||||
|
||||
for (let i = 0; i < inputPattern.length; i++) {
|
||||
inputArray[i] = inputPattern.charCodeAt(i);
|
||||
}
|
||||
|
||||
autotype.typeInput(inputArray);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +144,45 @@ export default {
|
||||
},
|
||||
) => {
|
||||
const { clientId, sequenceNumber, status } = data;
|
||||
fn(clientId, sequenceNumber, status);
|
||||
fn(clientId, sequenceNumber, status);
|
||||
},
|
||||
);
|
||||
configureAutotype: (enabled: boolean) => {
|
||||
ipcRenderer.send("autofill.configureAutotype", { enabled });
|
||||
},
|
||||
listenAutotypeRequest: (
|
||||
fn: (
|
||||
windowTitle: string,
|
||||
completeCallback: (
|
||||
error: Error | null,
|
||||
response: { username?: string; password?: string },
|
||||
) => void,
|
||||
) => void,
|
||||
) => {
|
||||
ipcRenderer.on(
|
||||
"autofill.listenAutotypeRequest",
|
||||
(
|
||||
event,
|
||||
data: {
|
||||
windowTitle: string;
|
||||
},
|
||||
) => {
|
||||
const { windowTitle } = data;
|
||||
|
||||
fn(windowTitle, (error, response) => {
|
||||
if (error) {
|
||||
ipcRenderer.send("autofill.completeError", {
|
||||
windowTitle,
|
||||
error: error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
ipcRenderer.send("autofill.completeAutotypeRequest", {
|
||||
windowTitle,
|
||||
response,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { combineLatest, map, Observable, of } from "rxjs";
|
||||
import { combineLatest, filter, firstValueFrom, map, Observable, of, switchMap } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { DeviceType } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import {
|
||||
GlobalStateProvider,
|
||||
AUTOTYPE_SETTINGS_DISK,
|
||||
KeyDefinition,
|
||||
} from "@bitwarden/common/platform/state";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
export const AUTOTYPE_ENABLED = new KeyDefinition<boolean>(
|
||||
AUTOTYPE_SETTINGS_DISK,
|
||||
@@ -20,28 +28,83 @@ export class DesktopAutotypeService {
|
||||
autotypeEnabled$: Observable<boolean> = of(false);
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private authService: AuthService,
|
||||
private cipherService: CipherService,
|
||||
private configService: ConfigService,
|
||||
private globalStateProvider: GlobalStateProvider,
|
||||
private isWindows: boolean,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
) {
|
||||
if (this.isWindows) {
|
||||
ipc.autofill.listenAutotypeRequest(async (windowTitle, callback) => {
|
||||
const possibleCiphers = await this.matchCiphersToWindowTitle(windowTitle);
|
||||
const firstCipher = possibleCiphers?.at(0);
|
||||
|
||||
return callback(null, {
|
||||
username: firstCipher?.login?.username,
|
||||
password: firstCipher?.login?.password,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this.platformUtilsService.getDevice() === DeviceType.WindowsDesktop) {
|
||||
this.autotypeEnabled$ = combineLatest([
|
||||
this.autotypeEnabledState.state$,
|
||||
this.configService.getFeatureFlag$(FeatureFlag.WindowsDesktopAutotype),
|
||||
this.accountService.activeAccount$.pipe(
|
||||
map((account) => account?.id),
|
||||
switchMap((userId) => this.authService.authStatusFor$(userId)),
|
||||
),
|
||||
]).pipe(
|
||||
map(
|
||||
([autotypeEnabled, windowsDesktopAutotypeFeatureFlag]) =>
|
||||
autotypeEnabled && windowsDesktopAutotypeFeatureFlag,
|
||||
([autotypeEnabled, windowsDesktopAutotypeFeatureFlag, authStatus]) =>
|
||||
autotypeEnabled &&
|
||||
windowsDesktopAutotypeFeatureFlag &&
|
||||
authStatus == AuthenticationStatus.Unlocked,
|
||||
),
|
||||
);
|
||||
|
||||
this.autotypeEnabled$.subscribe((enabled) => {
|
||||
ipc.autofill.configureAutotype(enabled);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
init() {}
|
||||
|
||||
async setAutotypeEnabledState(enabled: boolean): Promise<void> {
|
||||
await this.autotypeEnabledState.update(() => enabled, {
|
||||
shouldUpdate: (currentlyEnabled) => currentlyEnabled !== enabled,
|
||||
});
|
||||
}
|
||||
|
||||
async matchCiphersToWindowTitle(windowTitle: string): Promise<CipherView[]> {
|
||||
const URI_PREFIX = "APP:";
|
||||
windowTitle = windowTitle.toLowerCase();
|
||||
|
||||
const ciphers = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(
|
||||
map((account) => account?.id),
|
||||
filter((userId): userId is UserId => userId != null),
|
||||
switchMap((userId) => this.cipherService.cipherViews$(userId)),
|
||||
),
|
||||
);
|
||||
|
||||
const possibleCiphers = ciphers.filter((c) => {
|
||||
return (
|
||||
c.login?.username &&
|
||||
c.login?.password &&
|
||||
c.deletedDate == null &&
|
||||
c.login?.uris.some((u) => {
|
||||
if (u.uri?.indexOf(URI_PREFIX) !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const uri = u.uri.substring(4).toLowerCase();
|
||||
|
||||
return windowTitle.indexOf(uri) > -1;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
return possibleCiphers;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ import { NodeCryptoFunctionService } from "@bitwarden/node/services/node-crypto-
|
||||
import { MainDesktopAutotypeService } from "./autofill/main/main-desktop-autotype.service";
|
||||
import { MainSshAgentService } from "./autofill/main/main-ssh-agent.service";
|
||||
import { DesktopAutofillSettingsService } from "./autofill/services/desktop-autofill-settings.service";
|
||||
import { DesktopAutotypeService } from "./autofill/services/desktop-autotype.service";
|
||||
import { DesktopBiometricsService } from "./key-management/biometrics/desktop.biometrics.service";
|
||||
import { MainBiometricsIPCListener } from "./key-management/biometrics/main-biometrics-ipc.listener";
|
||||
import { MainBiometricsService } from "./key-management/biometrics/main-biometrics.service";
|
||||
@@ -48,7 +47,6 @@ import { PowerMonitorMain } from "./main/power-monitor.main";
|
||||
import { TrayMain } from "./main/tray.main";
|
||||
import { UpdaterMain } from "./main/updater.main";
|
||||
import { WindowMain } from "./main/window.main";
|
||||
import { SlimConfigService } from "./platform/config/slim-config.service";
|
||||
import { NativeAutofillMain } from "./platform/main/autofill/native-autofill.main";
|
||||
import { ClipboardMain } from "./platform/main/clipboard.main";
|
||||
import { DesktopCredentialStorageListener } from "./platform/main/desktop-credential-storage-listener";
|
||||
@@ -307,13 +305,22 @@ export class Main {
|
||||
void this.nativeAutofillMain.init();
|
||||
|
||||
this.mainDesktopAutotypeService = new MainDesktopAutotypeService(
|
||||
new DesktopAutotypeService(
|
||||
new SlimConfigService(this.environmentService, globalStateProvider),
|
||||
globalStateProvider,
|
||||
process.platform === "win32",
|
||||
),
|
||||
this.logService,
|
||||
this.windowMain,
|
||||
);
|
||||
this.mainDesktopAutotypeService.init();
|
||||
|
||||
app
|
||||
.whenReady()
|
||||
.then(() => {
|
||||
this.mainDesktopAutotypeService.init();
|
||||
})
|
||||
.catch((reason) => {
|
||||
this.logService.error("Error initializing Autotype.", reason);
|
||||
});
|
||||
|
||||
app.on("will-quit", () => {
|
||||
this.mainDesktopAutotypeService.disableAutotype();
|
||||
});
|
||||
}
|
||||
|
||||
bootstrap() {
|
||||
|
||||
4
apps/desktop/src/package-lock.json
generated
4
apps/desktop/src/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@bitwarden/desktop",
|
||||
"version": "2025.7.0",
|
||||
"version": "2025.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@bitwarden/desktop",
|
||||
"version": "2025.7.1",
|
||||
"version": "2025.8.0",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"@bitwarden/desktop-napi": "file:../desktop_native/napi"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@bitwarden/desktop",
|
||||
"productName": "Bitwarden",
|
||||
"description": "A secure and free password manager for all of your devices.",
|
||||
"version": "2025.7.0",
|
||||
"version": "2025.8.0",
|
||||
"author": "Bitwarden Inc. <hello@bitwarden.com> (https://bitwarden.com)",
|
||||
"homepage": "https://bitwarden.com",
|
||||
"license": "GPL-3.0",
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { combineLatest, map, Observable, throwError } from "rxjs";
|
||||
import { SemVer } from "semver";
|
||||
|
||||
import {
|
||||
FeatureFlag,
|
||||
FeatureFlagValueType,
|
||||
getFeatureFlagValue,
|
||||
} from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config";
|
||||
import {
|
||||
EnvironmentService,
|
||||
Region,
|
||||
} from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { ServerSettings } from "@bitwarden/common/platform/models/domain/server-settings";
|
||||
import { GLOBAL_SERVER_CONFIGURATIONS } from "@bitwarden/common/platform/services/config/default-config.service";
|
||||
import { GlobalStateProvider } from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
/*
|
||||
NOT FOR GENERAL USE
|
||||
|
||||
If you have more uses for the config service in the main process,
|
||||
please reach out to platform.
|
||||
*/
|
||||
export class SlimConfigService implements ConfigService {
|
||||
constructor(
|
||||
private environmentService: EnvironmentService,
|
||||
private globalStateProvider: GlobalStateProvider,
|
||||
) {}
|
||||
|
||||
serverConfig$: Observable<ServerConfig> = throwError(() => {
|
||||
return new Error("Method not implemented.");
|
||||
});
|
||||
serverSettings$: Observable<ServerSettings> = throwError(() => {
|
||||
return new Error("Method not implemented.");
|
||||
});
|
||||
cloudRegion$: Observable<Region> = throwError(() => {
|
||||
return new Error("Method not implemented.");
|
||||
});
|
||||
getFeatureFlag$<Flag extends FeatureFlag>(key: Flag): Observable<FeatureFlagValueType<Flag>> {
|
||||
return combineLatest([
|
||||
this.environmentService.environment$,
|
||||
this.globalStateProvider.get(GLOBAL_SERVER_CONFIGURATIONS).state$,
|
||||
]).pipe(
|
||||
map(([environment, serverConfigMap]) =>
|
||||
getFeatureFlagValue(serverConfigMap?.[environment.getApiUrl()], key),
|
||||
),
|
||||
);
|
||||
}
|
||||
userCachedFeatureFlag$<Flag extends FeatureFlag>(
|
||||
key: Flag,
|
||||
userId: UserId,
|
||||
): Observable<FeatureFlagValueType<Flag>> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
getFeatureFlag<Flag extends FeatureFlag>(key: Flag): Promise<FeatureFlagValueType<Flag>> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
checkServerMeetsVersionRequirement$(minimumRequiredServerVersion: SemVer): Observable<boolean> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
ensureConfigFetched(): Promise<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
}
|
||||
@@ -99,3 +99,11 @@ export function cleanUserAgent(userAgent: string): string {
|
||||
.replace(userAgentItem("Bitwarden", " "), "")
|
||||
.replace(userAgentItem("Electron", " "), "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if the provided string is not undefined, not null, and not empty.
|
||||
* Otherwise, returns `false`.
|
||||
*/
|
||||
export function stringIsNotUndefinedNullAndEmpty(str: string): boolean {
|
||||
return str?.length > 0;
|
||||
}
|
||||
|
||||
@@ -466,15 +466,24 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedCipher = await this.cipherService.get(
|
||||
this.cipherId as CipherId,
|
||||
this.activeUserId as UserId,
|
||||
);
|
||||
const updatedCipherView = await this.cipherService.decrypt(
|
||||
updatedCipher,
|
||||
this.activeUserId as UserId,
|
||||
// The encrypted state of ciphers is updated when an attachment is added,
|
||||
// but the cache is also cleared. Depending on timing, `cipherService.get` can return the
|
||||
// old cipher. Retrieve the updated cipher from `cipherViews$`,
|
||||
// which refreshes after the cached is cleared.
|
||||
const updatedCipherView = await firstValueFrom(
|
||||
this.cipherService.cipherViews$(this.activeUserId!).pipe(
|
||||
filter((c) => !!c),
|
||||
map((ciphers) => ciphers.find((c) => c.id === this.cipherId)),
|
||||
),
|
||||
);
|
||||
|
||||
// `find` can return undefined but that shouldn't happen as
|
||||
// this would mean that the cipher was deleted.
|
||||
// To make TypeScript happy, exit early if it isn't found.
|
||||
if (!updatedCipherView) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cipherFormComponent.patchCipher((currentCipher) => {
|
||||
currentCipher.attachments = updatedCipherView.attachments;
|
||||
currentCipher.revisionDate = updatedCipherView.revisionDate;
|
||||
@@ -499,7 +508,6 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
|
||||
if (cipher.decryptionFailure) {
|
||||
invokeMenu(menu);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cipher.isDeleted) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -89,8 +89,8 @@ export class GroupsComponent {
|
||||
protected searchControl = new FormControl("");
|
||||
|
||||
// Fixed sizes used for cdkVirtualScroll
|
||||
protected rowHeight = 52;
|
||||
protected rowHeightClass = `tw-h-[52px]`;
|
||||
protected rowHeight = 50;
|
||||
protected rowHeightClass = `tw-h-[50px]`;
|
||||
|
||||
protected ModalTabType = GroupAddEditTabType;
|
||||
private refreshGroups$ = new BehaviorSubject<void>(null);
|
||||
|
||||
@@ -11,10 +11,13 @@ import {
|
||||
OrganizationUserBulkResponse,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { ProviderUserBulkPublicKeyResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user-bulk-public-key.response";
|
||||
import { ProviderUserBulkResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user-bulk.response";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
@@ -23,11 +26,13 @@ import { OrgKey } from "@bitwarden/common/types/key";
|
||||
import { DIALOG_DATA, DialogConfig, DialogService } from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { OrganizationUserService } from "../../services/organization-user/organization-user.service";
|
||||
|
||||
import { BaseBulkConfirmComponent } from "./base-bulk-confirm.component";
|
||||
import { BulkUserDetails } from "./bulk-status.component";
|
||||
|
||||
type BulkConfirmDialogParams = {
|
||||
organizationId: string;
|
||||
organization: Organization;
|
||||
users: BulkUserDetails[];
|
||||
};
|
||||
|
||||
@@ -36,7 +41,7 @@ type BulkConfirmDialogParams = {
|
||||
standalone: false,
|
||||
})
|
||||
export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent {
|
||||
organizationId: string;
|
||||
organization: Organization;
|
||||
organizationKey$: Observable<OrgKey>;
|
||||
users: BulkUserDetails[];
|
||||
|
||||
@@ -47,13 +52,15 @@ export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent {
|
||||
private organizationUserApiService: OrganizationUserApiService,
|
||||
protected i18nService: I18nService,
|
||||
private stateProvider: StateProvider,
|
||||
private organizationUserService: OrganizationUserService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
super(keyService, encryptService, i18nService);
|
||||
|
||||
this.organizationId = dialogParams.organizationId;
|
||||
this.organization = dialogParams.organization;
|
||||
this.organizationKey$ = this.stateProvider.activeUserId$.pipe(
|
||||
switchMap((userId) => this.keyService.orgKeys$(userId)),
|
||||
map((organizationKeysById) => organizationKeysById[this.organizationId as OrganizationId]),
|
||||
map((organizationKeysById) => organizationKeysById[this.organization.id as OrganizationId]),
|
||||
takeUntilDestroyed(),
|
||||
);
|
||||
this.users = dialogParams.users;
|
||||
@@ -66,7 +73,7 @@ export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent {
|
||||
ListResponse<OrganizationUserBulkPublicKeyResponse | ProviderUserBulkPublicKeyResponse>
|
||||
> =>
|
||||
await this.organizationUserApiService.postOrganizationUsersPublicKey(
|
||||
this.organizationId,
|
||||
this.organization.id,
|
||||
this.filteredUsers.map((user) => user.id),
|
||||
);
|
||||
|
||||
@@ -76,11 +83,19 @@ export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent {
|
||||
protected postConfirmRequest = async (
|
||||
userIdsWithKeys: { id: string; key: string }[],
|
||||
): Promise<ListResponse<OrganizationUserBulkResponse | ProviderUserBulkResponse>> => {
|
||||
const request = new OrganizationUserBulkConfirmRequest(userIdsWithKeys);
|
||||
return await this.organizationUserApiService.postOrganizationUserBulkConfirm(
|
||||
this.organizationId,
|
||||
request,
|
||||
);
|
||||
if (
|
||||
await firstValueFrom(this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation))
|
||||
) {
|
||||
return await firstValueFrom(
|
||||
this.organizationUserService.bulkConfirmUsers(this.organization, userIdsWithKeys),
|
||||
);
|
||||
} else {
|
||||
const request = new OrganizationUserBulkConfirmRequest(userIdsWithKeys);
|
||||
return await this.organizationUserApiService.postOrganizationUserBulkConfirm(
|
||||
this.organization.id,
|
||||
request,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
static open(dialogService: DialogService, config: DialogConfig<BulkConfirmDialogParams>) {
|
||||
|
||||
@@ -111,8 +111,8 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
protected showUserManagementControls$: Observable<boolean>;
|
||||
|
||||
// Fixed sizes used for cdkVirtualScroll
|
||||
protected rowHeight = 69;
|
||||
protected rowHeightClass = `tw-h-[69px]`;
|
||||
protected rowHeight = 66;
|
||||
protected rowHeightClass = `tw-h-[66px]`;
|
||||
|
||||
private organizationUsersCount = 0;
|
||||
|
||||
@@ -721,7 +721,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
|
||||
const dialogRef = BulkConfirmDialogComponent.open(this.dialogService, {
|
||||
data: {
|
||||
organizationId: this.organization.id,
|
||||
organization: this.organization,
|
||||
users: this.dataSource.getCheckedUsers(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import {
|
||||
OrganizationUserConfirmRequest,
|
||||
OrganizationUserBulkConfirmRequest,
|
||||
OrganizationUserApiService,
|
||||
OrganizationUserBulkResponse,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { OrgKey } from "@bitwarden/common/types/key";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { OrganizationUserView } from "../../../core/views/organization-user.view";
|
||||
|
||||
import { OrganizationUserService } from "./organization-user.service";
|
||||
|
||||
describe("OrganizationUserService", () => {
|
||||
let service: OrganizationUserService;
|
||||
let keyService: jest.Mocked<KeyService>;
|
||||
let encryptService: jest.Mocked<EncryptService>;
|
||||
let organizationUserApiService: jest.Mocked<OrganizationUserApiService>;
|
||||
let accountService: jest.Mocked<AccountService>;
|
||||
let i18nService: jest.Mocked<I18nService>;
|
||||
|
||||
const mockOrganization = new Organization();
|
||||
mockOrganization.id = "org-123" as OrganizationId;
|
||||
|
||||
const mockOrganizationUser = new OrganizationUserView();
|
||||
mockOrganizationUser.id = "user-123";
|
||||
|
||||
const mockPublicKey = new Uint8Array(64) as CsprngArray;
|
||||
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
|
||||
const mockOrgKey = new SymmetricCryptoKey(mockRandomBytes) as OrgKey;
|
||||
const mockEncryptedKey = { encryptedString: "encrypted-key" } as EncString;
|
||||
const mockEncryptedCollectionName = { encryptedString: "encrypted-collection-name" } as EncString;
|
||||
const mockDefaultCollectionName = "My Items";
|
||||
|
||||
const setupCommonMocks = () => {
|
||||
keyService.orgKeys$.mockReturnValue(
|
||||
of({ [mockOrganization.id]: mockOrgKey } as Record<OrganizationId, OrgKey>),
|
||||
);
|
||||
encryptService.encryptString.mockResolvedValue(mockEncryptedCollectionName);
|
||||
i18nService.t.mockReturnValue(mockDefaultCollectionName);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
keyService = {
|
||||
orgKeys$: jest.fn(),
|
||||
} as any;
|
||||
|
||||
encryptService = {
|
||||
encryptString: jest.fn(),
|
||||
encapsulateKeyUnsigned: jest.fn(),
|
||||
} as any;
|
||||
|
||||
organizationUserApiService = {
|
||||
postOrganizationUserConfirm: jest.fn(),
|
||||
postOrganizationUserBulkConfirm: jest.fn(),
|
||||
} as any;
|
||||
|
||||
accountService = {
|
||||
activeAccount$: of({ id: "user-123" }),
|
||||
} as any;
|
||||
|
||||
i18nService = {
|
||||
t: jest.fn(),
|
||||
} as any;
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
OrganizationUserService,
|
||||
{ provide: KeyService, useValue: keyService },
|
||||
{ provide: EncryptService, useValue: encryptService },
|
||||
{ provide: OrganizationUserApiService, useValue: organizationUserApiService },
|
||||
{ provide: AccountService, useValue: accountService },
|
||||
{ provide: I18nService, useValue: i18nService },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(OrganizationUserService);
|
||||
});
|
||||
|
||||
describe("confirmUser", () => {
|
||||
beforeEach(() => {
|
||||
setupCommonMocks();
|
||||
encryptService.encapsulateKeyUnsigned.mockResolvedValue(mockEncryptedKey);
|
||||
organizationUserApiService.postOrganizationUserConfirm.mockReturnValue(Promise.resolve());
|
||||
});
|
||||
|
||||
it("should confirm a user successfully", (done) => {
|
||||
service.confirmUser(mockOrganization, mockOrganizationUser, mockPublicKey).subscribe({
|
||||
next: () => {
|
||||
expect(i18nService.t).toHaveBeenCalledWith("myItems");
|
||||
|
||||
expect(encryptService.encryptString).toHaveBeenCalledWith(
|
||||
mockDefaultCollectionName,
|
||||
mockOrgKey,
|
||||
);
|
||||
expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith(
|
||||
mockOrgKey,
|
||||
mockPublicKey,
|
||||
);
|
||||
|
||||
expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith(
|
||||
mockOrganization.id,
|
||||
mockOrganizationUser.id,
|
||||
{
|
||||
key: mockEncryptedKey.encryptedString,
|
||||
defaultUserCollectionName: mockEncryptedCollectionName.encryptedString,
|
||||
} as OrganizationUserConfirmRequest,
|
||||
);
|
||||
|
||||
done();
|
||||
},
|
||||
error: done,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulkConfirmUsers", () => {
|
||||
const mockUserIdsWithKeys = [
|
||||
{ id: "user-1", key: "key-1" },
|
||||
{ id: "user-2", key: "key-2" },
|
||||
];
|
||||
|
||||
const mockBulkResponse = {
|
||||
data: [
|
||||
{ id: "user-1", error: null } as OrganizationUserBulkResponse,
|
||||
{ id: "user-2", error: null } as OrganizationUserBulkResponse,
|
||||
],
|
||||
} as ListResponse<OrganizationUserBulkResponse>;
|
||||
|
||||
beforeEach(() => {
|
||||
setupCommonMocks();
|
||||
organizationUserApiService.postOrganizationUserBulkConfirm.mockReturnValue(
|
||||
Promise.resolve(mockBulkResponse),
|
||||
);
|
||||
});
|
||||
|
||||
it("should bulk confirm users successfully", (done) => {
|
||||
service.bulkConfirmUsers(mockOrganization, mockUserIdsWithKeys).subscribe({
|
||||
next: (response) => {
|
||||
expect(i18nService.t).toHaveBeenCalledWith("myItems");
|
||||
|
||||
expect(encryptService.encryptString).toHaveBeenCalledWith(
|
||||
mockDefaultCollectionName,
|
||||
mockOrgKey,
|
||||
);
|
||||
|
||||
expect(organizationUserApiService.postOrganizationUserBulkConfirm).toHaveBeenCalledWith(
|
||||
mockOrganization.id,
|
||||
new OrganizationUserBulkConfirmRequest(
|
||||
mockUserIdsWithKeys,
|
||||
mockEncryptedCollectionName.encryptedString,
|
||||
),
|
||||
);
|
||||
|
||||
expect(response).toEqual(mockBulkResponse);
|
||||
|
||||
done();
|
||||
},
|
||||
error: done,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,12 +3,15 @@ import { combineLatest, filter, map, Observable, switchMap } from "rxjs";
|
||||
|
||||
import {
|
||||
OrganizationUserConfirmRequest,
|
||||
OrganizationUserBulkConfirmRequest,
|
||||
OrganizationUserApiService,
|
||||
OrganizationUserBulkResponse,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
@@ -41,11 +44,7 @@ export class OrganizationUserService {
|
||||
user: OrganizationUserView,
|
||||
publicKey: Uint8Array,
|
||||
): Observable<void> {
|
||||
const encryptedCollectionName$ = this.orgKey$(organization).pipe(
|
||||
switchMap((orgKey) =>
|
||||
this.encryptService.encryptString(this.i18nService.t("myItems"), orgKey),
|
||||
),
|
||||
);
|
||||
const encryptedCollectionName$ = this.getEncryptedDefaultCollectionName$(organization);
|
||||
|
||||
const encryptedKey$ = this.orgKey$(organization).pipe(
|
||||
switchMap((orgKey) => this.encryptService.encapsulateKeyUnsigned(orgKey, publicKey)),
|
||||
@@ -66,4 +65,31 @@ export class OrganizationUserService {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
bulkConfirmUsers(
|
||||
organization: Organization,
|
||||
userIdsWithKeys: { id: string; key: string }[],
|
||||
): Observable<ListResponse<OrganizationUserBulkResponse>> {
|
||||
return this.getEncryptedDefaultCollectionName$(organization).pipe(
|
||||
switchMap((collectionName) => {
|
||||
const request = new OrganizationUserBulkConfirmRequest(
|
||||
userIdsWithKeys,
|
||||
collectionName.encryptedString,
|
||||
);
|
||||
|
||||
return this.organizationUserApiService.postOrganizationUserBulkConfirm(
|
||||
organization.id,
|
||||
request,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private getEncryptedDefaultCollectionName$(organization: Organization) {
|
||||
return this.orgKey$(organization).pipe(
|
||||
switchMap((orgKey) =>
|
||||
this.encryptService.encryptString(this.i18nService.t("myItems"), orgKey),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// @ts-strict-ignore
|
||||
import { MockProxy } from "jest-mock-extended";
|
||||
import mock from "jest-mock-extended/lib/Mock";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
@@ -14,9 +15,9 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey, MasterKey } from "@bitwarden/common/types/key";
|
||||
import { UserKey, MasterKey, UserPrivateKey } from "@bitwarden/common/types/key";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { KdfType, KeyService } from "@bitwarden/key-management";
|
||||
import { Argon2KdfConfig, KdfType, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
|
||||
|
||||
import { EmergencyAccessStatusType } from "../enums/emergency-access-status-type";
|
||||
import { EmergencyAccessType } from "../enums/emergency-access-type";
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
EmergencyAccessGranteeDetailsResponse,
|
||||
EmergencyAccessGrantorDetailsResponse,
|
||||
EmergencyAccessTakeoverResponse,
|
||||
EmergencyAccessViewResponse,
|
||||
} from "../response/emergency-access.response";
|
||||
|
||||
import { EmergencyAccessApiService } from "./emergency-access-api.service";
|
||||
@@ -142,88 +144,306 @@ describe("EmergencyAccessService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getViewOnlyCiphers", () => {
|
||||
const params = {
|
||||
id: "emergency-access-id",
|
||||
activeUserId: Utils.newGuid() as UserId,
|
||||
};
|
||||
|
||||
it("throws an error is the active user's private key isn't available", async () => {
|
||||
keyService.userPrivateKey$.mockReturnValue(of(null));
|
||||
|
||||
await expect(
|
||||
emergencyAccessService.getViewOnlyCiphers(params.id, params.activeUserId),
|
||||
).rejects.toThrow("Active user does not have a private key, cannot get view only ciphers.");
|
||||
});
|
||||
|
||||
it("should return decrypted and sorted ciphers", async () => {
|
||||
const emergencyAccessViewResponse = {
|
||||
keyEncrypted: "mockKeyEncrypted",
|
||||
ciphers: [
|
||||
{ id: "cipher1", name: "encryptedName1" },
|
||||
{ id: "cipher2", name: "encryptedName2" },
|
||||
],
|
||||
} as EmergencyAccessViewResponse;
|
||||
|
||||
const mockEncryptedCipher1 = {
|
||||
id: "cipher1",
|
||||
decrypt: jest.fn().mockResolvedValue({ id: "cipher1", decrypted: true }),
|
||||
};
|
||||
const mockEncryptedCipher2 = {
|
||||
id: "cipher2",
|
||||
decrypt: jest.fn().mockResolvedValue({ id: "cipher2", decrypted: true }),
|
||||
};
|
||||
emergencyAccessViewResponse.ciphers.map = jest.fn().mockImplementation(() => {
|
||||
return [mockEncryptedCipher1, mockEncryptedCipher2];
|
||||
});
|
||||
cipherService.getLocaleSortingFunction.mockReturnValue((a: any, b: any) =>
|
||||
a.id.localeCompare(b.id),
|
||||
);
|
||||
emergencyAccessApiService.postEmergencyAccessView.mockResolvedValue(
|
||||
emergencyAccessViewResponse,
|
||||
);
|
||||
|
||||
const mockPrivateKey = new Uint8Array(64) as UserPrivateKey;
|
||||
keyService.userPrivateKey$.mockReturnValue(of(mockPrivateKey));
|
||||
|
||||
const mockDecryptedGrantorUserKey = new SymmetricCryptoKey(new Uint8Array(64));
|
||||
encryptService.decapsulateKeyUnsigned.mockResolvedValueOnce(mockDecryptedGrantorUserKey);
|
||||
const mockGrantorUserKey = mockDecryptedGrantorUserKey as UserKey;
|
||||
|
||||
const result = await emergencyAccessService.getViewOnlyCiphers(
|
||||
params.id,
|
||||
params.activeUserId,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: "cipher1", decrypted: true },
|
||||
{ id: "cipher2", decrypted: true },
|
||||
]);
|
||||
expect(mockEncryptedCipher1.decrypt).toHaveBeenCalledWith(mockGrantorUserKey);
|
||||
expect(mockEncryptedCipher2.decrypt).toHaveBeenCalledWith(mockGrantorUserKey);
|
||||
expect(emergencyAccessApiService.postEmergencyAccessView).toHaveBeenCalledWith(params.id);
|
||||
expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
|
||||
expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith(
|
||||
new EncString(emergencyAccessViewResponse.keyEncrypted),
|
||||
mockPrivateKey,
|
||||
);
|
||||
expect(cipherService.getLocaleSortingFunction).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("takeover", () => {
|
||||
const mockId = "emergencyAccessId";
|
||||
const mockEmail = "emergencyAccessEmail";
|
||||
const mockName = "emergencyAccessName";
|
||||
const params = {
|
||||
id: "emergencyAccessId",
|
||||
masterPassword: "mockPassword",
|
||||
email: "emergencyAccessEmail",
|
||||
activeUserId: Utils.newGuid() as UserId,
|
||||
};
|
||||
|
||||
const takeoverResponse = {
|
||||
keyEncrypted: "EncryptedKey",
|
||||
kdf: KdfType.PBKDF2_SHA256,
|
||||
kdfIterations: 500,
|
||||
} as EmergencyAccessTakeoverResponse;
|
||||
|
||||
const userPrivateKey = new Uint8Array(64) as UserPrivateKey;
|
||||
const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as MasterKey;
|
||||
const mockMasterKeyHash = "mockMasterKeyHash";
|
||||
let mockGrantorUserKey: UserKey;
|
||||
|
||||
// must mock [UserKey, EncString] return from keyService.encryptUserKeyWithMasterKey
|
||||
// where UserKey is the decrypted grantor user key
|
||||
const mockMasterKeyEncryptedUserKey = new EncString(
|
||||
EncryptionType.AesCbc256_HmacSha256_B64,
|
||||
"mockMasterKeyEncryptedUserKey",
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce(takeoverResponse);
|
||||
keyService.userPrivateKey$.mockReturnValue(of(userPrivateKey));
|
||||
|
||||
const mockDecryptedGrantorUserKey = new SymmetricCryptoKey(new Uint8Array(64));
|
||||
encryptService.decapsulateKeyUnsigned.mockResolvedValueOnce(mockDecryptedGrantorUserKey);
|
||||
mockGrantorUserKey = mockDecryptedGrantorUserKey as UserKey;
|
||||
|
||||
keyService.makeMasterKey.mockResolvedValueOnce(mockMasterKey);
|
||||
keyService.hashMasterKey.mockResolvedValueOnce(mockMasterKeyHash);
|
||||
keyService.encryptUserKeyWithMasterKey.mockResolvedValueOnce([
|
||||
mockGrantorUserKey,
|
||||
mockMasterKeyEncryptedUserKey,
|
||||
]);
|
||||
});
|
||||
|
||||
it("posts a new password when decryption succeeds", async () => {
|
||||
// Arrange
|
||||
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce({
|
||||
keyEncrypted: "EncryptedKey",
|
||||
kdf: KdfType.PBKDF2_SHA256,
|
||||
kdfIterations: 500,
|
||||
} as EmergencyAccessTakeoverResponse);
|
||||
|
||||
const mockDecryptedGrantorUserKey = new Uint8Array(64);
|
||||
keyService.getPrivateKey.mockResolvedValue(new Uint8Array(64));
|
||||
encryptService.decapsulateKeyUnsigned.mockResolvedValueOnce(
|
||||
new SymmetricCryptoKey(mockDecryptedGrantorUserKey),
|
||||
);
|
||||
|
||||
const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as MasterKey;
|
||||
|
||||
keyService.makeMasterKey.mockResolvedValueOnce(mockMasterKey);
|
||||
|
||||
const mockMasterKeyHash = "mockMasterKeyHash";
|
||||
keyService.hashMasterKey.mockResolvedValueOnce(mockMasterKeyHash);
|
||||
|
||||
// must mock [UserKey, EncString] return from keyService.encryptUserKeyWithMasterKey
|
||||
// where UserKey is the decrypted grantor user key
|
||||
const mockMasterKeyEncryptedUserKey = new EncString(
|
||||
EncryptionType.AesCbc256_HmacSha256_B64,
|
||||
"mockMasterKeyEncryptedUserKey",
|
||||
);
|
||||
|
||||
const mockUserKey = new SymmetricCryptoKey(mockDecryptedGrantorUserKey) as UserKey;
|
||||
|
||||
keyService.encryptUserKeyWithMasterKey.mockResolvedValueOnce([
|
||||
mockUserKey,
|
||||
mockMasterKeyEncryptedUserKey,
|
||||
]);
|
||||
const expectedKdfConfig = new PBKDF2KdfConfig(takeoverResponse.kdfIterations);
|
||||
|
||||
const expectedEmergencyAccessPasswordRequest = new EmergencyAccessPasswordRequest();
|
||||
expectedEmergencyAccessPasswordRequest.newMasterPasswordHash = mockMasterKeyHash;
|
||||
expectedEmergencyAccessPasswordRequest.key = mockMasterKeyEncryptedUserKey.encryptedString;
|
||||
|
||||
// Act
|
||||
await emergencyAccessService.takeover(mockId, mockEmail, mockName);
|
||||
await emergencyAccessService.takeover(
|
||||
params.id,
|
||||
params.masterPassword,
|
||||
params.email,
|
||||
params.activeUserId,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
|
||||
expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith(
|
||||
new EncString(takeoverResponse.keyEncrypted),
|
||||
userPrivateKey,
|
||||
);
|
||||
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
|
||||
params.masterPassword,
|
||||
params.email,
|
||||
expectedKdfConfig,
|
||||
);
|
||||
expect(keyService.hashMasterKey).toHaveBeenCalledWith(params.masterPassword, mockMasterKey);
|
||||
expect(keyService.encryptUserKeyWithMasterKey).toHaveBeenCalledWith(
|
||||
mockMasterKey,
|
||||
mockGrantorUserKey,
|
||||
);
|
||||
expect(emergencyAccessApiService.postEmergencyAccessPassword).toHaveBeenCalledWith(
|
||||
mockId,
|
||||
params.id,
|
||||
expectedEmergencyAccessPasswordRequest,
|
||||
);
|
||||
});
|
||||
|
||||
it("should not post a new password if decryption fails", async () => {
|
||||
encryptService.rsaDecrypt.mockResolvedValueOnce(null);
|
||||
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce({
|
||||
it("uses argon2 KDF if takeover response is argon2", async () => {
|
||||
const argon2TakeoverResponse = {
|
||||
keyEncrypted: "EncryptedKey",
|
||||
kdf: KdfType.PBKDF2_SHA256,
|
||||
kdfIterations: 500,
|
||||
} as EmergencyAccessTakeoverResponse);
|
||||
keyService.getPrivateKey.mockResolvedValue(new Uint8Array(64));
|
||||
kdf: KdfType.Argon2id,
|
||||
kdfIterations: 3,
|
||||
kdfMemory: 64,
|
||||
kdfParallelism: 4,
|
||||
} as EmergencyAccessTakeoverResponse;
|
||||
emergencyAccessApiService.postEmergencyAccessTakeover.mockReset();
|
||||
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce(
|
||||
argon2TakeoverResponse,
|
||||
);
|
||||
|
||||
const expectedKdfConfig = new Argon2KdfConfig(
|
||||
argon2TakeoverResponse.kdfIterations,
|
||||
argon2TakeoverResponse.kdfMemory,
|
||||
argon2TakeoverResponse.kdfParallelism,
|
||||
);
|
||||
|
||||
const expectedEmergencyAccessPasswordRequest = new EmergencyAccessPasswordRequest();
|
||||
expectedEmergencyAccessPasswordRequest.newMasterPasswordHash = mockMasterKeyHash;
|
||||
expectedEmergencyAccessPasswordRequest.key = mockMasterKeyEncryptedUserKey.encryptedString;
|
||||
|
||||
await emergencyAccessService.takeover(
|
||||
params.id,
|
||||
params.masterPassword,
|
||||
params.email,
|
||||
params.activeUserId,
|
||||
);
|
||||
|
||||
expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
|
||||
expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith(
|
||||
new EncString(argon2TakeoverResponse.keyEncrypted),
|
||||
userPrivateKey,
|
||||
);
|
||||
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
|
||||
params.masterPassword,
|
||||
params.email,
|
||||
expectedKdfConfig,
|
||||
);
|
||||
expect(keyService.hashMasterKey).toHaveBeenCalledWith(params.masterPassword, mockMasterKey);
|
||||
expect(keyService.encryptUserKeyWithMasterKey).toHaveBeenCalledWith(
|
||||
mockMasterKey,
|
||||
mockGrantorUserKey,
|
||||
);
|
||||
expect(emergencyAccessApiService.postEmergencyAccessPassword).toHaveBeenCalledWith(
|
||||
params.id,
|
||||
expectedEmergencyAccessPasswordRequest,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws an error if masterKeyEncryptedUserKey is not found", async () => {
|
||||
keyService.encryptUserKeyWithMasterKey.mockReset();
|
||||
keyService.encryptUserKeyWithMasterKey.mockResolvedValueOnce(null);
|
||||
const expectedKdfConfig = new PBKDF2KdfConfig(takeoverResponse.kdfIterations);
|
||||
|
||||
await expect(
|
||||
emergencyAccessService.takeover(mockId, mockEmail, mockName),
|
||||
).rejects.toThrowError("Failed to decrypt grantor key");
|
||||
emergencyAccessService.takeover(
|
||||
params.id,
|
||||
params.masterPassword,
|
||||
params.email,
|
||||
params.activeUserId,
|
||||
),
|
||||
).rejects.toThrow("masterKeyEncryptedUserKey not found");
|
||||
|
||||
expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
|
||||
expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith(
|
||||
new EncString(takeoverResponse.keyEncrypted),
|
||||
userPrivateKey,
|
||||
);
|
||||
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
|
||||
params.masterPassword,
|
||||
params.email,
|
||||
expectedKdfConfig,
|
||||
);
|
||||
expect(keyService.hashMasterKey).toHaveBeenCalledWith(params.masterPassword, mockMasterKey);
|
||||
expect(keyService.encryptUserKeyWithMasterKey).toHaveBeenCalledWith(
|
||||
mockMasterKey,
|
||||
mockGrantorUserKey,
|
||||
);
|
||||
expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not post a new password if decryption fails", async () => {
|
||||
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce(takeoverResponse);
|
||||
encryptService.decapsulateKeyUnsigned.mockReset();
|
||||
encryptService.decapsulateKeyUnsigned.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
emergencyAccessService.takeover(
|
||||
params.id,
|
||||
params.masterPassword,
|
||||
params.email,
|
||||
params.activeUserId,
|
||||
),
|
||||
).rejects.toThrow("Failed to decrypt grantor key");
|
||||
|
||||
expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
|
||||
expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith(
|
||||
new EncString(takeoverResponse.keyEncrypted),
|
||||
userPrivateKey,
|
||||
);
|
||||
expect(keyService.makeMasterKey).not.toHaveBeenCalled();
|
||||
expect(keyService.hashMasterKey).not.toHaveBeenCalled();
|
||||
expect(keyService.encryptUserKeyWithMasterKey).not.toHaveBeenCalled();
|
||||
expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not post a new password if decryption throws", async () => {
|
||||
encryptService.decapsulateKeyUnsigned.mockReset();
|
||||
encryptService.decapsulateKeyUnsigned.mockImplementationOnce(() => {
|
||||
throw new Error("Failed to unwrap grantor key");
|
||||
});
|
||||
|
||||
await expect(
|
||||
emergencyAccessService.takeover(
|
||||
params.id,
|
||||
params.masterPassword,
|
||||
params.email,
|
||||
params.activeUserId,
|
||||
),
|
||||
).rejects.toThrowError("Failed to unwrap grantor key");
|
||||
|
||||
expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
|
||||
expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith(
|
||||
new EncString(takeoverResponse.keyEncrypted),
|
||||
userPrivateKey,
|
||||
);
|
||||
expect(keyService.makeMasterKey).not.toHaveBeenCalled();
|
||||
expect(keyService.hashMasterKey).not.toHaveBeenCalled();
|
||||
expect(keyService.encryptUserKeyWithMasterKey).not.toHaveBeenCalled();
|
||||
expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw an error if the users private key cannot be retrieved", async () => {
|
||||
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce({
|
||||
keyEncrypted: "EncryptedKey",
|
||||
kdf: KdfType.PBKDF2_SHA256,
|
||||
kdfIterations: 500,
|
||||
} as EmergencyAccessTakeoverResponse);
|
||||
keyService.getPrivateKey.mockResolvedValue(null);
|
||||
keyService.userPrivateKey$.mockReturnValue(of(null));
|
||||
|
||||
await expect(emergencyAccessService.takeover(mockId, mockEmail, mockName)).rejects.toThrow(
|
||||
"user does not have a private key",
|
||||
);
|
||||
await expect(
|
||||
emergencyAccessService.takeover(
|
||||
params.id,
|
||||
params.masterPassword,
|
||||
params.email,
|
||||
params.activeUserId,
|
||||
),
|
||||
).rejects.toThrow("user does not have a private key");
|
||||
|
||||
expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
|
||||
expect(encryptService.decapsulateKeyUnsigned).not.toHaveBeenCalled();
|
||||
expect(keyService.makeMasterKey).not.toHaveBeenCalled();
|
||||
expect(keyService.hashMasterKey).not.toHaveBeenCalled();
|
||||
expect(keyService.encryptUserKeyWithMasterKey).not.toHaveBeenCalled();
|
||||
expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data";
|
||||
@@ -237,11 +238,14 @@ export class EmergencyAccessService
|
||||
* Gets the grantor ciphers for an emergency access in view mode.
|
||||
* Intended for grantee.
|
||||
* @param id emergency access id
|
||||
* @param activeUserId the user id of the active user
|
||||
*/
|
||||
async getViewOnlyCiphers(id: string): Promise<CipherView[]> {
|
||||
async getViewOnlyCiphers(id: string, activeUserId: UserId): Promise<CipherView[]> {
|
||||
const response = await this.emergencyAccessApiService.postEmergencyAccessView(id);
|
||||
|
||||
const activeUserPrivateKey = await this.keyService.getPrivateKey();
|
||||
const activeUserPrivateKey = await firstValueFrom(
|
||||
this.keyService.userPrivateKey$(activeUserId),
|
||||
);
|
||||
|
||||
if (activeUserPrivateKey == null) {
|
||||
throw new Error("Active user does not have a private key, cannot get view only ciphers.");
|
||||
@@ -264,11 +268,14 @@ export class EmergencyAccessService
|
||||
* @param id emergency access id
|
||||
* @param masterPassword new master password
|
||||
* @param email email address of grantee (must be consistent or login will fail)
|
||||
* @param activeUserId the user id of the active user
|
||||
*/
|
||||
async takeover(id: string, masterPassword: string, email: string) {
|
||||
async takeover(id: string, masterPassword: string, email: string, activeUserId: UserId) {
|
||||
const takeoverResponse = await this.emergencyAccessApiService.postEmergencyAccessTakeover(id);
|
||||
|
||||
const activeUserPrivateKey = await this.keyService.getPrivateKey();
|
||||
const activeUserPrivateKey = await firstValueFrom(
|
||||
this.keyService.userPrivateKey$(activeUserId),
|
||||
);
|
||||
|
||||
if (activeUserPrivateKey == null) {
|
||||
throw new Error("Active user does not have a private key, cannot complete a takeover.");
|
||||
@@ -312,9 +319,7 @@ export class EmergencyAccessService
|
||||
request.newMasterPasswordHash = masterKeyHash;
|
||||
request.key = encKey[1].encryptedString;
|
||||
|
||||
// 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.emergencyAccessApiService.postEmergencyAccessPassword(id, request);
|
||||
await this.emergencyAccessApiService.postEmergencyAccessPassword(id, request);
|
||||
}
|
||||
|
||||
private async getEmergencyAccessData(): Promise<EmergencyAccessGranteeDetailsResponse[]> {
|
||||
|
||||
@@ -115,10 +115,12 @@ export class EmergencyAccessTakeoverDialogComponent implements OnInit {
|
||||
this.parentSubmittingBehaviorSubject.next(true);
|
||||
|
||||
try {
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
await this.emergencyAccessService.takeover(
|
||||
this.dialogData.emergencyAccessId,
|
||||
passwordInputResult.newPassword,
|
||||
this.dialogData.grantorEmail,
|
||||
activeUserId,
|
||||
);
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Component, OnInit } from "@angular/core";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { EmergencyAccessId } from "@bitwarden/common/types/guid";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
@@ -27,6 +29,7 @@ export class EmergencyAccessViewComponent implements OnInit {
|
||||
private route: ActivatedRoute,
|
||||
private emergencyAccessService: EmergencyAccessService,
|
||||
private dialogService: DialogService,
|
||||
private accountService: AccountService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -37,7 +40,8 @@ export class EmergencyAccessViewComponent implements OnInit {
|
||||
}
|
||||
|
||||
this.id = qParams.id;
|
||||
this.ciphers = await this.emergencyAccessService.getViewOnlyCiphers(qParams.id);
|
||||
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||
this.ciphers = await this.emergencyAccessService.getViewOnlyCiphers(qParams.id, userId);
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,6 @@
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
buttonType="secondary"
|
||||
[bitMenuTriggerFor]="appListDropdown"
|
||||
class="tw-border-0 tw-bg-transparent tw-p-0"
|
||||
></button>
|
||||
|
||||
@@ -26,14 +26,7 @@
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="pageState === BrowserPromptState.ManualOpen">
|
||||
<p bitTypography="body1" class="tw-mb-0 tw-text-xl">
|
||||
{{ "openExtensionManuallyPart1" | i18n }}
|
||||
<bit-icon
|
||||
[icon]="BitwardenIcon"
|
||||
class="[&>svg]:tw-align-baseline [&>svg]:-tw-mb-[2px]"
|
||||
></bit-icon>
|
||||
{{ "openExtensionManuallyPart2" | i18n }}
|
||||
</p>
|
||||
<vault-manually-open-extension></vault-manually-open-extension>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="pageState === BrowserPromptState.MobileBrowser">
|
||||
|
||||
@@ -3,17 +3,17 @@ import { Component, Inject, OnDestroy, OnInit } from "@angular/core";
|
||||
|
||||
import { ButtonComponent, IconModule } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
import { VaultIcons } from "@bitwarden/vault";
|
||||
|
||||
import {
|
||||
BrowserExtensionPromptService,
|
||||
BrowserPromptState,
|
||||
} from "../../services/browser-extension-prompt.service";
|
||||
import { ManuallyOpenExtensionComponent } from "../manually-open-extension/manually-open-extension.component";
|
||||
|
||||
@Component({
|
||||
selector: "vault-browser-extension-prompt",
|
||||
templateUrl: "./browser-extension-prompt.component.html",
|
||||
imports: [CommonModule, I18nPipe, ButtonComponent, IconModule],
|
||||
imports: [CommonModule, I18nPipe, ButtonComponent, IconModule, ManuallyOpenExtensionComponent],
|
||||
})
|
||||
export class BrowserExtensionPromptComponent implements OnInit, OnDestroy {
|
||||
/** Current state of the prompt page */
|
||||
@@ -22,8 +22,6 @@ export class BrowserExtensionPromptComponent implements OnInit, OnDestroy {
|
||||
/** All available page states */
|
||||
protected BrowserPromptState = BrowserPromptState;
|
||||
|
||||
protected BitwardenIcon = VaultIcons.BitwardenIcon;
|
||||
|
||||
/** Content of the meta[name="viewport"] element */
|
||||
private viewportContent: string | null = null;
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<p bitTypography="body1" class="tw-mb-0 tw-text-xl">
|
||||
{{ "openExtensionManuallyPart1" | i18n }}
|
||||
<bit-icon
|
||||
[icon]="BitwardenIcon"
|
||||
class="[&>svg]:tw-align-baseline [&>svg]:-tw-mb-[2px]"
|
||||
></bit-icon>
|
||||
{{ "openExtensionManuallyPart2" | i18n }}
|
||||
</p>
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
import { IconModule } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
import { VaultIcons } from "@bitwarden/vault";
|
||||
|
||||
@Component({
|
||||
selector: "vault-manually-open-extension",
|
||||
templateUrl: "./manually-open-extension.component.html",
|
||||
imports: [I18nPipe, IconModule],
|
||||
})
|
||||
export class ManuallyOpenExtensionComponent {
|
||||
protected BitwardenIcon = VaultIcons.BitwardenIcon;
|
||||
}
|
||||
@@ -34,8 +34,8 @@ export class AddExtensionVideosComponent {
|
||||
/** CSS classes for the video container, pulled into the class only for readability. */
|
||||
protected videoContainerClass = [
|
||||
"tw-absolute tw-left-0 tw-top-0 tw-w-[15rem] tw-opacity-0 md:tw-opacity-100 md:tw-relative lg:tw-w-[17rem] tw-max-w-full tw-aspect-[0.807]",
|
||||
`[${this.cssOverlayVariable}:0.7] after:tw-absolute after:tw-top-0 after:tw-left-0 after:tw-size-full after:tw-bg-primary-100 after:tw-content-[''] after:tw-rounded-lg after:tw-opacity-[--overlay-opacity]`,
|
||||
`[${this.cssBorderVariable}:0] before:tw-absolute before:tw-top-0 before:tw-left-0 before:tw-w-full before:tw-h-2 before:tw-bg-primary-600 before:tw-content-[''] before:tw-rounded-t-lg before:tw-opacity-[--border-opacity]`,
|
||||
`[--overlay-opacity:0.7] after:tw-absolute after:tw-top-0 after:tw-left-0 after:tw-size-full after:tw-bg-primary-100 after:tw-content-[''] after:tw-rounded-lg after:tw-opacity-[--overlay-opacity]`,
|
||||
`[--border-opacity:0] before:tw-absolute before:tw-top-0 before:tw-left-0 before:tw-w-full before:tw-h-2 before:tw-bg-primary-600 before:tw-content-[''] before:tw-rounded-t-lg before:tw-opacity-[--border-opacity]`,
|
||||
"after:tw-transition-opacity after:tw-duration-400 after:tw-ease-linear",
|
||||
"before:tw-transition-opacity before:tw-duration-400 before:tw-ease-linear",
|
||||
].join(" ");
|
||||
|
||||
@@ -54,3 +54,7 @@
|
||||
</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section *ngIf="state === SetupExtensionState.ManualOpen" aria-live="polite" class="tw-text-center">
|
||||
<vault-manually-open-extension></vault-manually-open-extension>
|
||||
</section>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { Router, RouterModule } from "@angular/router";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
@@ -11,10 +11,12 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { AnonLayoutWrapperDataService } from "@bitwarden/components";
|
||||
import { VaultIcons } from "@bitwarden/vault";
|
||||
|
||||
import { WebBrowserInteractionService } from "../../services/web-browser-interaction.service";
|
||||
|
||||
import { SetupExtensionComponent } from "./setup-extension.component";
|
||||
import { SetupExtensionComponent, SetupExtensionState } from "./setup-extension.component";
|
||||
|
||||
describe("SetupExtensionComponent", () => {
|
||||
let fixture: ComponentFixture<SetupExtensionComponent>;
|
||||
@@ -24,12 +26,14 @@ describe("SetupExtensionComponent", () => {
|
||||
const navigate = jest.fn().mockResolvedValue(true);
|
||||
const openExtension = jest.fn().mockResolvedValue(true);
|
||||
const update = jest.fn().mockResolvedValue(true);
|
||||
const setAnonLayoutWrapperData = jest.fn();
|
||||
const extensionInstalled$ = new BehaviorSubject<boolean | null>(null);
|
||||
|
||||
beforeEach(async () => {
|
||||
navigate.mockClear();
|
||||
openExtension.mockClear();
|
||||
update.mockClear();
|
||||
setAnonLayoutWrapperData.mockClear();
|
||||
getFeatureFlag.mockClear().mockResolvedValue(true);
|
||||
window.matchMedia = jest.fn().mockReturnValue(false);
|
||||
|
||||
@@ -40,6 +44,7 @@ describe("SetupExtensionComponent", () => {
|
||||
{ provide: ConfigService, useValue: { getFeatureFlag } },
|
||||
{ provide: WebBrowserInteractionService, useValue: { extensionInstalled$, openExtension } },
|
||||
{ provide: PlatformUtilsService, useValue: { getDevice: () => DeviceType.UnknownBrowser } },
|
||||
{ provide: AnonLayoutWrapperDataService, useValue: { setAnonLayoutWrapperData } },
|
||||
{
|
||||
provide: AccountService,
|
||||
useValue: { activeAccount$: new BehaviorSubject({ account: { id: "account-id" } }) },
|
||||
@@ -136,6 +141,27 @@ describe("SetupExtensionComponent", () => {
|
||||
it("dismisses the extension page", () => {
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows error state when extension fails to open", fakeAsync(() => {
|
||||
openExtension.mockRejectedValueOnce(new Error("Failed to open extension"));
|
||||
|
||||
const openExtensionButton = fixture.debugElement.query(By.css("button"));
|
||||
|
||||
openExtensionButton.triggerEventHandler("click");
|
||||
|
||||
tick();
|
||||
|
||||
expect(component["state"]).toBe(SetupExtensionState.ManualOpen);
|
||||
expect(setAnonLayoutWrapperData).toHaveBeenCalledWith({
|
||||
pageTitle: {
|
||||
key: "somethingWentWrong",
|
||||
},
|
||||
pageIcon: VaultIcons.BrowserExtensionIcon,
|
||||
hideIcon: false,
|
||||
hideCardWrapper: false,
|
||||
maxWidth: "md",
|
||||
});
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
|
||||
import { getWebStoreUrl } from "@bitwarden/common/vault/utils/get-web-store-url";
|
||||
import {
|
||||
AnonLayoutWrapperDataService,
|
||||
ButtonComponent,
|
||||
DialogRef,
|
||||
DialogService,
|
||||
@@ -25,6 +26,7 @@ import { VaultIcons } from "@bitwarden/vault";
|
||||
|
||||
import { SETUP_EXTENSION_DISMISSED } from "../../guards/setup-extension-redirect.guard";
|
||||
import { WebBrowserInteractionService } from "../../services/web-browser-interaction.service";
|
||||
import { ManuallyOpenExtensionComponent } from "../manually-open-extension/manually-open-extension.component";
|
||||
|
||||
import {
|
||||
AddExtensionLaterDialogComponent,
|
||||
@@ -32,10 +34,11 @@ import {
|
||||
} from "./add-extension-later-dialog.component";
|
||||
import { AddExtensionVideosComponent } from "./add-extension-videos.component";
|
||||
|
||||
const SetupExtensionState = {
|
||||
export const SetupExtensionState = {
|
||||
Loading: "loading",
|
||||
NeedsExtension: "needs-extension",
|
||||
Success: "success",
|
||||
ManualOpen: "manual-open",
|
||||
} as const;
|
||||
|
||||
type SetupExtensionState = UnionOfValues<typeof SetupExtensionState>;
|
||||
@@ -51,6 +54,7 @@ type SetupExtensionState = UnionOfValues<typeof SetupExtensionState>;
|
||||
IconModule,
|
||||
RouterModule,
|
||||
AddExtensionVideosComponent,
|
||||
ManuallyOpenExtensionComponent,
|
||||
],
|
||||
})
|
||||
export class SetupExtensionComponent implements OnInit, OnDestroy {
|
||||
@@ -63,6 +67,7 @@ export class SetupExtensionComponent implements OnInit, OnDestroy {
|
||||
private stateProvider = inject(StateProvider);
|
||||
private accountService = inject(AccountService);
|
||||
private document = inject(DOCUMENT);
|
||||
private anonLayoutWrapperDataService = inject(AnonLayoutWrapperDataService);
|
||||
|
||||
protected SetupExtensionState = SetupExtensionState;
|
||||
protected PartyIcon = VaultIcons.Party;
|
||||
@@ -153,8 +158,21 @@ export class SetupExtensionComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
/** Opens the browser extension */
|
||||
openExtension() {
|
||||
void this.webBrowserExtensionInteractionService.openExtension();
|
||||
async openExtension() {
|
||||
await this.webBrowserExtensionInteractionService.openExtension().catch(() => {
|
||||
this.state = SetupExtensionState.ManualOpen;
|
||||
|
||||
// Update the anon layout data to show the proper error design
|
||||
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
|
||||
pageTitle: {
|
||||
key: "somethingWentWrong",
|
||||
},
|
||||
pageIcon: VaultIcons.BrowserExtensionIcon,
|
||||
hideIcon: false,
|
||||
hideCardWrapper: false,
|
||||
maxWidth: "md",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Update local state to never show this page again. */
|
||||
|
||||
@@ -28,8 +28,8 @@ import { VaultItem } from "./vault-item";
|
||||
import { VaultItemEvent } from "./vault-item-event";
|
||||
|
||||
// Fixed manual row height required due to how cdk-virtual-scroll works
|
||||
export const RowHeight = 75.5;
|
||||
export const RowHeightClass = `tw-h-[75.5px]`;
|
||||
export const RowHeight = 75;
|
||||
export const RowHeightClass = `tw-h-[75px]`;
|
||||
|
||||
const MaxSelectionCount = 500;
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum
|
||||
* used to allow for the extension to open and then emit to the message.
|
||||
* NOTE: This value isn't computed by any means, it is just a reasonable timeout for the extension to respond.
|
||||
*/
|
||||
const OPEN_RESPONSE_TIMEOUT_MS = 1500;
|
||||
const OPEN_RESPONSE_TIMEOUT_MS = 2000;
|
||||
|
||||
/**
|
||||
* Timeout for checking if the extension is installed.
|
||||
|
||||
@@ -52,8 +52,8 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
|
||||
dataSource = new MembersTableDataSource();
|
||||
loading = true;
|
||||
providerId: string;
|
||||
rowHeight = 69;
|
||||
rowHeightClass = `tw-h-[69px]`;
|
||||
rowHeight = 70;
|
||||
rowHeightClass = `tw-h-[70px]`;
|
||||
status: ProviderUserStatusType = null;
|
||||
|
||||
userStatusType = ProviderUserStatusType;
|
||||
|
||||
@@ -12,9 +12,12 @@ import { ProviderStatusType, ProviderType } from "@bitwarden/common/admin-consol
|
||||
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { Icon, IconModule } from "@bitwarden/components";
|
||||
import { BusinessUnitPortalLogo } from "@bitwarden/web-vault/app/admin-console/icons/business-unit-portal-logo.icon";
|
||||
import { ProviderPortalLogo } from "@bitwarden/web-vault/app/admin-console/icons/provider-portal-logo";
|
||||
import {
|
||||
Icon,
|
||||
IconModule,
|
||||
ProviderPortalLogo,
|
||||
BusinessUnitPortalLogo,
|
||||
} from "@bitwarden/components";
|
||||
import { WebLayoutModule } from "@bitwarden/web-vault/app/layouts/web-layout.module";
|
||||
|
||||
import { ProviderWarningsService } from "../../billing/providers/services/provider-warnings.service";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { EncString as SdkEncString } from "@bitwarden/sdk-internal";
|
||||
|
||||
type OrganizationUserBulkRequestEntry = {
|
||||
id: string;
|
||||
key: string;
|
||||
@@ -5,8 +7,10 @@ type OrganizationUserBulkRequestEntry = {
|
||||
|
||||
export class OrganizationUserBulkConfirmRequest {
|
||||
keys: OrganizationUserBulkRequestEntry[];
|
||||
defaultUserCollectionName: SdkEncString | undefined;
|
||||
|
||||
constructor(keys: OrganizationUserBulkRequestEntry[]) {
|
||||
constructor(keys: OrganizationUserBulkRequestEntry[], defaultUserCollectionName?: SdkEncString) {
|
||||
this.keys = keys;
|
||||
this.defaultUserCollectionName = defaultUserCollectionName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,6 +537,7 @@ const safeProviders: SafeProvider[] = [
|
||||
accountService: AccountServiceAbstraction,
|
||||
logService: LogService,
|
||||
cipherEncryptionService: CipherEncryptionService,
|
||||
messagingService: MessagingServiceAbstraction,
|
||||
) =>
|
||||
new CipherService(
|
||||
keyService,
|
||||
@@ -553,6 +554,7 @@ const safeProviders: SafeProvider[] = [
|
||||
accountService,
|
||||
logService,
|
||||
cipherEncryptionService,
|
||||
messagingService,
|
||||
),
|
||||
deps: [
|
||||
KeyService,
|
||||
@@ -569,6 +571,7 @@ const safeProviders: SafeProvider[] = [
|
||||
AccountServiceAbstraction,
|
||||
LogService,
|
||||
CipherEncryptionService,
|
||||
MessagingServiceAbstraction,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div
|
||||
class="tw-rounded-2xl tw-bg-primary-100 tw-border-primary-600 tw-border-solid tw-border tw-p-4 tw-pt-3 tw-flex tw-flex-col tw-gap-2 tw-mb-4"
|
||||
class="tw-rounded-2xl tw-bg-primary-100 tw-border-primary-600 tw-border-solid tw-border tw-p-4 tw-pt-2 tw-flex tw-flex-col tw-gap-2 tw-mb-4"
|
||||
>
|
||||
<div class="tw-flex tw-justify-between tw-items-start tw-flex-grow">
|
||||
<div>
|
||||
@@ -20,6 +20,7 @@
|
||||
(click)="handleDismiss()"
|
||||
[attr.title]="'close' | i18n"
|
||||
[attr.aria-label]="'close' | i18n"
|
||||
class="-tw-me-2"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, map, of } from "rxjs";
|
||||
import { BehaviorSubject, filter, firstValueFrom, map, of } from "rxjs";
|
||||
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
@@ -7,6 +7,7 @@ import { CipherResponse } from "@bitwarden/common/vault/models/response/cipher.r
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CipherDecryptionKeys, KeyService } from "@bitwarden/key-management";
|
||||
import { MessageSender } from "@bitwarden/messaging";
|
||||
|
||||
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
|
||||
import { FakeStateProvider } from "../../../spec/fake-state-provider";
|
||||
@@ -106,6 +107,7 @@ describe("Cipher Service", () => {
|
||||
const logService = mock<LogService>();
|
||||
const stateProvider = new FakeStateProvider(accountService);
|
||||
const cipherEncryptionService = mock<CipherEncryptionService>();
|
||||
const messageSender = mock<MessageSender>();
|
||||
|
||||
const userId = "TestUserId" as UserId;
|
||||
const orgId = "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b2" as OrganizationId;
|
||||
@@ -134,6 +136,7 @@ describe("Cipher Service", () => {
|
||||
accountService,
|
||||
logService,
|
||||
cipherEncryptionService,
|
||||
messageSender,
|
||||
);
|
||||
|
||||
encryptionContext = { cipher: new Cipher(cipherData), encryptedFor: userId };
|
||||
@@ -551,6 +554,23 @@ describe("Cipher Service", () => {
|
||||
newUserKey,
|
||||
);
|
||||
});
|
||||
|
||||
it("sends overlay update when cipherViews$ emits", async () => {
|
||||
(cipherService.cipherViews$ as jest.Mock)?.mockRestore();
|
||||
|
||||
const decryptedView = new CipherView(encryptionContext.cipher);
|
||||
jest.spyOn(cipherService, "getAllDecrypted").mockResolvedValue([decryptedView]);
|
||||
|
||||
const sendSpy = jest.spyOn(messageSender, "send");
|
||||
|
||||
await firstValueFrom(
|
||||
cipherService
|
||||
.cipherViews$(mockUserId)
|
||||
.pipe(filter((cipherViews): cipherViews is CipherView[] => cipherViews != null)),
|
||||
);
|
||||
expect(sendSpy).toHaveBeenCalledWith("updateOverlayCiphers");
|
||||
expect(sendSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decrypt", () => {
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { combineLatest, filter, firstValueFrom, map, Observable, Subject, switchMap } from "rxjs";
|
||||
import {
|
||||
combineLatest,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
map,
|
||||
Observable,
|
||||
Subject,
|
||||
switchMap,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
import { SemVer } from "semver";
|
||||
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessageSender } from "@bitwarden/common/platform/messaging";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
@@ -109,6 +119,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
private accountService: AccountService,
|
||||
private logService: LogService,
|
||||
private cipherEncryptionService: CipherEncryptionService,
|
||||
private messageSender: MessageSender,
|
||||
) {}
|
||||
|
||||
localData$(userId: UserId): Observable<Record<CipherId, LocalData>> {
|
||||
@@ -174,6 +185,10 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
]).pipe(
|
||||
filter(([ciphers, _, keys]) => ciphers != null && keys != null), // Skip if ciphers haven't been loaded yor synced yet
|
||||
switchMap(() => this.getAllDecrypted(userId)),
|
||||
tap(async (decrypted) => {
|
||||
await this.searchService.indexCiphers(userId, decrypted);
|
||||
this.messageSender.send("updateOverlayCiphers");
|
||||
}),
|
||||
);
|
||||
}, this.clearCipherViewsForUser$);
|
||||
|
||||
|
||||
@@ -157,6 +157,14 @@ export class AnonLayoutWrapperComponent implements OnInit {
|
||||
this.hideCardWrapper = data.hideCardWrapper;
|
||||
}
|
||||
|
||||
if (data.hideIcon !== undefined) {
|
||||
this.hideIcon = data.hideIcon;
|
||||
}
|
||||
|
||||
if (data.maxWidth !== undefined) {
|
||||
this.maxWidth = data.maxWidth;
|
||||
}
|
||||
|
||||
// Manually fire change detection to avoid ExpressionChangedAfterItHasBeenCheckedError
|
||||
// when setting the page data from a service
|
||||
this.changeDetectorRef.detectChanges();
|
||||
|
||||
@@ -35,7 +35,7 @@ const template = `
|
||||
<button class="tw-me-2" type="button" buttonType="secondary" bitButton bitFormButton>Cancel</button>
|
||||
<button class="tw-me-2" type="button" buttonType="danger" bitButton bitFormButton [bitAction]="delete">Delete</button>
|
||||
<button class="tw-me-2" type="button" buttonType="secondary" bitButton bitFormButton [disabled]="true">Disabled</button>
|
||||
<button class="tw-me-2" type="button" buttonType="secondary" bitIconButton="bwi-star" bitFormButton [bitAction]="delete">Delete</button>
|
||||
<button class="tw-me-2" type="button" buttonType="muted" bitIconButton="bwi-star" bitFormButton [bitAction]="delete">Delete</button>
|
||||
</form>`;
|
||||
|
||||
@Component({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div
|
||||
class="tw-flex tw-items-center tw-gap-2 tw-p-2 tw-ps-4 tw-text-main tw-border-transparent tw-bg-clip-padding tw-border-solid tw-border-b tw-border-0"
|
||||
class="tw-flex tw-items-center tw-gap-4 tw-p-2 tw-ps-4 tw-text-main tw-border-transparent tw-bg-clip-padding tw-border-solid tw-border-b tw-border-0"
|
||||
[ngClass]="bannerClass"
|
||||
[attr.role]="useAlertRole() ? 'status' : null"
|
||||
[attr.aria-live]="useAlertRole() ? 'polite' : null"
|
||||
@@ -14,11 +14,10 @@
|
||||
<!-- Overriding hover and focus-visible colors for a11y against colored background -->
|
||||
@if (showClose()) {
|
||||
<button
|
||||
class="hover:tw-border-text-main focus-visible:before:tw-ring-text-main"
|
||||
type="button"
|
||||
bitIconButton="bwi-close"
|
||||
buttonType="main"
|
||||
size="default"
|
||||
size="small"
|
||||
(click)="onClose.emit()"
|
||||
[attr.title]="'close' | i18n"
|
||||
[attr.aria-label]="'close' | i18n"
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
type="button"
|
||||
[attr.aria-label]="'removeItem' | i18n: label"
|
||||
[disabled]="disabled"
|
||||
class="tw-bg-transparent hover:tw-bg-transparent tw-outline-none tw-rounded-full tw-py-0.5 tw-px-1 tw-me-1 tw-text-[color:inherit] tw-text-[length:inherit] tw-border-solid tw-border tw-border-transparent hover:tw-border-text-contrast hover:disabled:tw-border-transparent tw-flex tw-items-center tw-justify-center focus-visible:tw-ring-2 tw-ring-text-contrast focus-visible:hover:tw-border-transparent"
|
||||
class="tw-bg-transparent hover:tw-bg-hover-contrast tw-outline-none tw-rounded-full tw-py-0.5 tw-px-1 tw-me-1 tw-text-[color:inherit] tw-text-[length:inherit] tw-border-solid tw-border tw-border-transparent tw-flex tw-items-center tw-justify-center focus-visible:tw-ring-2 tw-ring-text-contrast hover:disabled:tw-bg-transparent"
|
||||
[ngClass]="{
|
||||
'tw-cursor-not-allowed': disabled,
|
||||
}"
|
||||
|
||||
@@ -93,7 +93,10 @@ class StoryDialogContentComponent {
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<bit-dialog title="Dialog Title" dialogSize="large">
|
||||
<bit-dialog
|
||||
title="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore"
|
||||
dialogSize="large"
|
||||
>
|
||||
<span bitDialogContent>
|
||||
Dialog body text goes here.
|
||||
<br />
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
<header
|
||||
class="tw-flex tw-justify-between tw-items-center tw-gap-4 tw-border-0 tw-border-b tw-border-solid"
|
||||
[ngClass]="{
|
||||
'tw-p-4': !isDrawer,
|
||||
'tw-p-6 tw-pb-4': isDrawer,
|
||||
'tw-p-4 has-[[biticonbutton]]:tw-pe-2': !isDrawer,
|
||||
'tw-px-6 tw-py-4 has-[[biticonbutton]]:tw-pe-4': isDrawer,
|
||||
'tw-border-secondary-300': showHeaderBorder,
|
||||
'tw-border-transparent': !showHeaderBorder,
|
||||
}"
|
||||
@@ -75,7 +75,7 @@
|
||||
[ngClass]="[isDrawer ? 'tw-px-6 tw-py-4' : 'tw-p-4']"
|
||||
[ngClass]="{
|
||||
'tw-px-6 tw-py-4': isDrawer,
|
||||
'tw-p-4': !isDrawer,
|
||||
'tw-p-4 has-[[biticonbutton]]:tw-pe-2': !isDrawer,
|
||||
'tw-border-secondary-300': showFooterBorder,
|
||||
'tw-border-transparent': !showFooterBorder,
|
||||
}"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<header class="tw-flex tw-justify-between tw-items-center">
|
||||
<div class="tw-flex tw-items-center tw-gap-1 tw-overflow-auto">
|
||||
<header class="tw-flex tw-justify-between tw-items-center tw-gap-4">
|
||||
<div class="tw-flex tw-items-center tw-gap-4 tw-overflow-auto">
|
||||
<ng-content select="[slot=start]"></ng-content>
|
||||
<h2 bitTypography="h3" noMargin class="tw-text-main tw-mb-0 tw-truncate" [attr.title]="title()">
|
||||
{{ title() }}
|
||||
|
||||
@@ -50,13 +50,13 @@
|
||||
>
|
||||
<div
|
||||
#prefixContainer
|
||||
class="tw-flex tw-items-center tw-gap-1 tw-ps-3 tw-py-2"
|
||||
class="tw-flex tw-items-center tw-gap-1 tw-ps-3 has-[[biticonbutton]]:tw-ps-1 tw-py-1"
|
||||
[hidden]="!prefixHasChildren()"
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="prefixContent"></ng-container>
|
||||
</div>
|
||||
<div
|
||||
class="tw-w-full tw-relative tw-py-2 has-[bit-select]:tw-p-0 has-[bit-multi-select]:tw-p-0 has-[input:read-only:not([hidden])]:tw-bg-secondary-100 has-[textarea:read-only:not([hidden])]:tw-bg-secondary-100"
|
||||
class="tw-w-full tw-relative tw-py-1 has-[bit-select]:tw-p-0 has-[bit-multi-select]:tw-p-0 has-[input:read-only:not([hidden])]:tw-bg-secondary-100 has-[textarea:read-only:not([hidden])]:tw-bg-secondary-100"
|
||||
data-default-content
|
||||
[ngClass]="[
|
||||
prefixHasChildren() ? '' : 'tw-rounded-s-lg tw-ps-3',
|
||||
@@ -67,7 +67,7 @@
|
||||
</div>
|
||||
<div
|
||||
#suffixContainer
|
||||
class="tw-flex tw-items-center tw-gap-1 tw-pe-3 tw-py-2"
|
||||
class="tw-flex tw-items-center tw-pe-3 has-[[biticonbutton]]:tw-pe-1 tw-py-1"
|
||||
[hidden]="!suffixHasChildren()"
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="suffixContent"></ng-container>
|
||||
@@ -102,11 +102,7 @@
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="defaultContent"></ng-container>
|
||||
</div>
|
||||
<div
|
||||
#suffixContainer
|
||||
[hidden]="!suffixHasChildren()"
|
||||
class="tw-flex tw-items-center tw-gap-1 tw-pe-1"
|
||||
>
|
||||
<div #suffixContainer [hidden]="!suffixHasChildren()" class="tw-flex tw-items-center tw-pe-1">
|
||||
<ng-container *ngTemplateOutlet="suffixContent"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -350,9 +350,7 @@ export const ButtonInputGroup: Story = {
|
||||
<input bitInput placeholder="Placeholder" />
|
||||
<button bitSuffix bitIconButton="bwi-eye" [appA11yTitle]="'Hide Label'"></button>
|
||||
<button bitSuffix bitIconButton="bwi-clone" [appA11yTitle]="'Clone Label'"></button>
|
||||
<button bitSuffix bitLink>
|
||||
Apply
|
||||
</button>
|
||||
<button bitSuffix bitIconButton="bwi-ellipsis-v" [appA11yTitle]="'Menu Label'"></button>
|
||||
</bit-form-field>
|
||||
`,
|
||||
}),
|
||||
@@ -369,9 +367,8 @@ export const DisabledButtonInputGroup: Story = {
|
||||
<input bitInput placeholder="Placeholder" disabled />
|
||||
<button bitSuffix bitIconButton="bwi-eye" disabled [appA11yTitle]="'Hide Label'"></button>
|
||||
<button bitSuffix bitIconButton="bwi-clone" disabled [appA11yTitle]="'Clone Label'"></button>
|
||||
<button bitSuffix bitLink disabled>
|
||||
Apply
|
||||
</button>
|
||||
<button bitSuffix bitIconButton="bwi-ellipsis-v" disabled [appA11yTitle]="'Menu Label'"></button>
|
||||
|
||||
</bit-form-field>
|
||||
`,
|
||||
}),
|
||||
@@ -387,9 +384,7 @@ export const PartiallyDisabledButtonInputGroup: Story = {
|
||||
<input bitInput placeholder="Placeholder" disabled />
|
||||
<button bitSuffix bitIconButton="bwi-eye" [appA11yTitle]="'Hide Label'"></button>
|
||||
<button bitSuffix bitIconButton="bwi-clone" [appA11yTitle]="'Clone Label'"></button>
|
||||
<button bitSuffix bitLink disabled>
|
||||
Apply
|
||||
</button>
|
||||
<button bitSuffix bitIconButton="bwi-ellipsis-v" disabled [appA11yTitle]="'Menu Label'"></button>
|
||||
</bit-form-field>
|
||||
`,
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<span class="tw-relative">
|
||||
<span [ngClass]="{ 'tw-invisible': showLoadingStyle() }">
|
||||
<span class="tw-relative tw-inline-block tw-leading-[0px]">
|
||||
<span class="tw-inline-block tw-leading-[0px]" [ngClass]="{ 'tw-invisible': showLoadingStyle() }">
|
||||
<i class="bwi" [ngClass]="iconClass" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span
|
||||
|
||||
@@ -5,10 +5,10 @@ import { Component, computed, ElementRef, HostBinding, input, model } from "@ang
|
||||
import { toObservable, toSignal } from "@angular/core/rxjs-interop";
|
||||
import { debounce, interval } from "rxjs";
|
||||
|
||||
import { ButtonLikeAbstraction, ButtonType } from "../shared/button-like.abstraction";
|
||||
import { ButtonLikeAbstraction } from "../shared/button-like.abstraction";
|
||||
import { FocusableElement } from "../shared/focusable-element";
|
||||
|
||||
export type IconButtonType = ButtonType | "contrast" | "main" | "muted" | "light";
|
||||
export type IconButtonType = "primary" | "danger" | "contrast" | "main" | "muted" | "nav-contrast";
|
||||
|
||||
const focusRing = [
|
||||
// Workaround for box-shadow with transparent offset issue:
|
||||
@@ -20,7 +20,7 @@ const focusRing = [
|
||||
"before:tw-content-['']",
|
||||
"before:tw-block",
|
||||
"before:tw-absolute",
|
||||
"before:-tw-inset-[2px]",
|
||||
"before:-tw-inset-[1px]",
|
||||
"before:tw-rounded-lg",
|
||||
"before:tw-transition",
|
||||
"before:tw-ring-2",
|
||||
@@ -30,122 +30,38 @@ const focusRing = [
|
||||
|
||||
const styles: Record<IconButtonType, string[]> = {
|
||||
contrast: [
|
||||
"tw-bg-transparent",
|
||||
"!tw-text-contrast",
|
||||
"tw-border-transparent",
|
||||
"hover:tw-bg-transparent-hover",
|
||||
"hover:tw-border-text-contrast",
|
||||
"hover:!tw-bg-hover-contrast",
|
||||
"focus-visible:before:tw-ring-text-contrast",
|
||||
...focusRing,
|
||||
],
|
||||
main: [
|
||||
"tw-bg-transparent",
|
||||
"!tw-text-main",
|
||||
"tw-border-transparent",
|
||||
"hover:tw-bg-transparent-hover",
|
||||
"hover:tw-border-primary-600",
|
||||
"focus-visible:before:tw-ring-primary-600",
|
||||
...focusRing,
|
||||
],
|
||||
main: ["!tw-text-main", "focus-visible:before:tw-ring-primary-600", ...focusRing],
|
||||
muted: [
|
||||
"tw-bg-transparent",
|
||||
"!tw-text-muted",
|
||||
"tw-border-transparent",
|
||||
"aria-expanded:tw-bg-text-muted",
|
||||
"aria-expanded:!tw-text-contrast",
|
||||
"hover:tw-bg-transparent-hover",
|
||||
"hover:tw-border-primary-600",
|
||||
"focus-visible:before:tw-ring-primary-600",
|
||||
"aria-expanded:hover:tw-bg-secondary-700",
|
||||
"aria-expanded:hover:tw-border-secondary-700",
|
||||
...focusRing,
|
||||
],
|
||||
primary: [
|
||||
"tw-bg-primary-600",
|
||||
"!tw-text-contrast",
|
||||
"tw-border-primary-600",
|
||||
"hover:tw-bg-primary-600",
|
||||
"hover:tw-border-primary-600",
|
||||
"focus-visible:before:tw-ring-primary-600",
|
||||
...focusRing,
|
||||
],
|
||||
secondary: [
|
||||
"tw-bg-transparent",
|
||||
"!tw-text-muted",
|
||||
"tw-border-text-muted",
|
||||
"hover:!tw-text-contrast",
|
||||
"hover:tw-bg-text-muted",
|
||||
"focus-visible:before:tw-ring-primary-600",
|
||||
...focusRing,
|
||||
],
|
||||
danger: [
|
||||
"tw-bg-transparent",
|
||||
"!tw-text-danger-600",
|
||||
"tw-border-transparent",
|
||||
"hover:!tw-text-danger-600",
|
||||
"hover:tw-bg-transparent",
|
||||
"hover:tw-border-primary-600",
|
||||
"focus-visible:before:tw-ring-primary-600",
|
||||
...focusRing,
|
||||
],
|
||||
light: [
|
||||
"tw-bg-transparent",
|
||||
primary: ["!tw-text-primary-600", "focus-visible:before:tw-ring-primary-600", ...focusRing],
|
||||
danger: ["!tw-text-danger-600", "focus-visible:before:tw-ring-primary-600", ...focusRing],
|
||||
"nav-contrast": [
|
||||
"!tw-text-alt2",
|
||||
"tw-border-transparent",
|
||||
"hover:tw-bg-transparent-hover",
|
||||
"hover:tw-border-text-alt2",
|
||||
"hover:!tw-bg-hover-contrast",
|
||||
"focus-visible:before:tw-ring-text-alt2",
|
||||
...focusRing,
|
||||
],
|
||||
unstyled: [],
|
||||
};
|
||||
|
||||
const disabledStyles: Record<IconButtonType, string[]> = {
|
||||
contrast: [
|
||||
"disabled:tw-opacity-60",
|
||||
"disabled:hover:tw-border-transparent",
|
||||
"disabled:hover:tw-bg-transparent",
|
||||
],
|
||||
main: [
|
||||
"disabled:!tw-text-secondary-300",
|
||||
"disabled:hover:tw-border-transparent",
|
||||
"disabled:hover:tw-bg-transparent",
|
||||
],
|
||||
muted: [
|
||||
"disabled:!tw-text-secondary-300",
|
||||
"disabled:hover:tw-border-transparent",
|
||||
"disabled:hover:tw-bg-transparent",
|
||||
],
|
||||
primary: [
|
||||
"disabled:tw-opacity-60",
|
||||
"disabled:hover:tw-border-primary-600",
|
||||
"disabled:hover:tw-bg-primary-600",
|
||||
],
|
||||
secondary: [
|
||||
"disabled:tw-opacity-60",
|
||||
"disabled:hover:tw-border-text-muted",
|
||||
"disabled:hover:tw-bg-transparent",
|
||||
"disabled:hover:!tw-text-muted",
|
||||
],
|
||||
danger: [
|
||||
"disabled:!tw-text-secondary-300",
|
||||
"disabled:hover:tw-border-transparent",
|
||||
"disabled:hover:tw-bg-transparent",
|
||||
"disabled:hover:!tw-text-secondary-300",
|
||||
],
|
||||
light: [
|
||||
"disabled:tw-opacity-60",
|
||||
"disabled:hover:tw-border-transparent",
|
||||
"disabled:hover:tw-bg-transparent",
|
||||
],
|
||||
unstyled: [],
|
||||
};
|
||||
|
||||
export type IconButtonSize = "default" | "small";
|
||||
|
||||
const sizes: Record<IconButtonSize, string[]> = {
|
||||
default: ["tw-px-2.5", "tw-py-1.5"],
|
||||
small: ["tw-leading-none", "tw-text-base", "tw-p-1"],
|
||||
default: ["tw-text-xl", "tw-p-2.5", "tw-rounded-md"],
|
||||
small: ["tw-text-base", "tw-p-2", "tw-rounded"],
|
||||
};
|
||||
/**
|
||||
* Icon buttons are used when no text accompanies the button. It consists of an icon that may be updated to any icon in the `bwi-font`, a `title` attribute, and an `aria-label`.
|
||||
@@ -164,6 +80,13 @@ const sizes: Record<IconButtonSize, string[]> = {
|
||||
imports: [NgClass],
|
||||
host: {
|
||||
"[attr.disabled]": "disabledAttr()",
|
||||
/**
|
||||
* When the `bitIconButton` input is dynamic from a consumer, Angular doesn't put the
|
||||
* `bitIconButton` attribute into the DOM. We use the attribute as a css selector in
|
||||
* a number of components, so this manual attr binding makes sure that the css selector
|
||||
* works when the input is dynamic.
|
||||
*/
|
||||
"[attr.bitIconButton]": "icon()",
|
||||
},
|
||||
})
|
||||
export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableElement {
|
||||
@@ -176,17 +99,20 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE
|
||||
@HostBinding("class") get classList() {
|
||||
return [
|
||||
"tw-font-semibold",
|
||||
"tw-border",
|
||||
"tw-border-solid",
|
||||
"tw-rounded-lg",
|
||||
"tw-leading-[0px]",
|
||||
"tw-border-none",
|
||||
"tw-transition",
|
||||
"tw-bg-transparent",
|
||||
"hover:tw-no-underline",
|
||||
"hover:tw-bg-hover-default",
|
||||
"focus:tw-outline-none",
|
||||
]
|
||||
.concat(styles[this.buttonType()])
|
||||
.concat(sizes[this.size()])
|
||||
.concat(
|
||||
this.showDisabledStyles() || this.disabled() ? disabledStyles[this.buttonType()] : [],
|
||||
this.showDisabledStyles() || this.disabled()
|
||||
? ["disabled:tw-opacity-60", "disabled:hover:!tw-bg-transparent"]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,9 +23,6 @@ Icon buttons can be found in other components such as: the
|
||||
|
||||
## Styles
|
||||
|
||||
There are 4 common styles for button main, muted, contrast, and danger. The other styles follow the
|
||||
button component styles.
|
||||
|
||||
### Main
|
||||
|
||||
Used for general icon buttons appearing on the theme’s main `background`
|
||||
@@ -59,22 +56,11 @@ square.
|
||||
|
||||
<Canvas of={stories.Primary} />
|
||||
|
||||
### Secondary
|
||||
### Nav Contrast
|
||||
|
||||
Used in place of the main button component if no text is used. This allows the button to display
|
||||
square.
|
||||
Used on the side nav background that is dark in both light theme and dark theme.
|
||||
|
||||
<Canvas of={stories.Secondary} />
|
||||
|
||||
### Light
|
||||
|
||||
Used on a background that is dark in both light theme and dark theme. Example: end user navigation
|
||||
styles.
|
||||
|
||||
<Canvas of={stories.Light} />
|
||||
|
||||
**Note:** Main and contrast styles appear on backgrounds where using `primary-700` as a focus
|
||||
indicator does not meet WCAG graphic contrast guidelines.
|
||||
<Canvas of={stories.NavContrast} />
|
||||
|
||||
## Sizes
|
||||
|
||||
|
||||
@@ -49,13 +49,6 @@ export const Primary: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const Secondary: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
buttonType: "secondary",
|
||||
},
|
||||
};
|
||||
|
||||
export const Danger: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
@@ -77,18 +70,18 @@ export const Muted: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const Light: Story = {
|
||||
export const NavContrast: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<div class="tw-bg-background-alt2 tw-p-6 tw-w-full tw-inline-block">
|
||||
<div class="tw-bg-background-alt3 tw-p-6 tw-w-full tw-inline-block">
|
||||
<!-- <div> used only to provide dark background color -->
|
||||
<button ${formatArgsForCodeSnippet<BitIconButtonComponent>(args)}>Button</button>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
buttonType: "light",
|
||||
buttonType: "nav-contrast",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
export * from "./icon.module";
|
||||
export * from "./icon";
|
||||
export * as Icons from "./icons";
|
||||
export { AdminConsoleLogo, PasswordManagerLogo, SecretsManagerLogo } from "./logos";
|
||||
export {
|
||||
AdminConsoleLogo,
|
||||
BusinessUnitPortalLogo,
|
||||
PasswordManagerLogo,
|
||||
ProviderPortalLogo,
|
||||
SecretsManagerLogo,
|
||||
} from "./logos";
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,6 @@
|
||||
export { default as AdminConsoleLogo } from "./admin-console";
|
||||
export { default as BusinessUnitPortalLogo } from "./business-unit-portal";
|
||||
export * from "./shield";
|
||||
export { default as PasswordManagerLogo } from "./password-manager";
|
||||
export { default as ProviderPortalLogo } from "./provider-portal";
|
||||
export { default as SecretsManagerLogo } from "./secrets-manager";
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -10,7 +10,7 @@ const AnonLayoutBitwardenShield = svgIcon`
|
||||
`;
|
||||
|
||||
const BitwardenShield = svgIcon`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 28 33"><path d="M26.696.403A1.274 1.274 0 0 0 25.764 0H1.83C1.467 0 1.16.137.898.403a1.294 1.294 0 0 0-.398.944v16.164c0 1.203.235 2.405.697 3.587.462 1.188 1.038 2.24 1.728 3.155.682.922 1.5 1.815 2.453 2.68a28.077 28.077 0 0 0 2.63 2.167 32.181 32.181 0 0 0 2.518 1.628c.875.511 1.493.857 1.863 1.045.37.18.661.324.882.417.163.087.348.13.54.13.192 0 .377-.043.54-.13.221-.1.52-.237.882-.417.37-.18.989-.534 1.863-1.045a34.4 34.4 0 0 0 2.517-1.628c.804-.576 1.679-1.296 2.631-2.168a20.206 20.206 0 0 0 2.454-2.68 13.599 13.599 0 0 0 1.72-3.154c.463-1.189.697-2.384.697-3.587V1.347a1.406 1.406 0 0 0-.42-.944ZM23.61 17.662c0 5.849-9.813 10.89-9.813 10.89V3.458h9.813v14.205Z" class="tw-fill-marketing-logo"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="26" height="32" fill="none"><g clip-path="url(#a)"><path class="tw-fill-text-alt2" d="M22.01 17.055V4.135h-9.063v22.954c1.605-.848 3.041-1.77 4.31-2.766 3.169-2.476 4.753-4.899 4.753-7.268Zm3.884-15.504v15.504a9.256 9.256 0 0 1-.677 3.442 12.828 12.828 0 0 1-1.68 3.029 18.708 18.708 0 0 1-2.386 2.574 27.808 27.808 0 0 1-2.56 2.08 32.251 32.251 0 0 1-2.448 1.564c-.85.49-1.453.824-1.81.999-.357.175-.644.31-.86.404-.162.08-.337.12-.526.12s-.364-.04-.526-.12a22.99 22.99 0 0 1-.86-.404c-.357-.175-.96-.508-1.81-1a32.242 32.242 0 0 1-2.448-1.564 27.796 27.796 0 0 1-2.56-2.08 18.706 18.706 0 0 1-2.386-2.573 12.828 12.828 0 0 1-1.68-3.029A9.256 9.256 0 0 1 0 17.055V1.551C0 1.2.128.898.384.642.641.386.944.26 1.294.26H24.6c.35 0 .654.127.91.383s.384.559.384.909Z"/></g><defs><clipPath id="a"><path class="tw-fill-text-alt2" d="M0 0h26v32H0z"/></clipPath></defs></svg>
|
||||
`;
|
||||
|
||||
export { AnonLayoutBitwardenShield, BitwardenShield };
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Component } from "@angular/core";
|
||||
* `top` and `bottom` units should be kept in sync with `item-content.component.ts`'s y-axis padding.
|
||||
* we want this `:after` element to be the same height as the `item-content`
|
||||
*/
|
||||
"[&>button]:tw-relative [&>button:not([bit-item-content])]:after:tw-content-[''] [&>button]:after:tw-absolute [&>button]:after:tw-block bit-compact:[&>button]:after:tw-top-[-0.7rem] bit-compact:[&>button]:after:tw-bottom-[-0.7rem] [&>button]:after:tw-top-[-0.8rem] [&>button]:after:tw-bottom-[-0.80rem] [&>button]:after:tw-right-[-0.25rem] [&>button]:after:tw-left-[-0.25rem]",
|
||||
"[&>button]:tw-relative [&>button:not([bit-item-content])]:after:tw-content-[''] [&>button]:after:tw-absolute [&>button]:after:tw-block bit-compact:[&>button]:after:tw-top-[-0.7rem] bit-compact:[&>button]:after:tw-bottom-[-0.7rem] [&>button]:after:tw-top-[-0.8rem] [&>button]:after:tw-bottom-[-0.80rem] [&>button]:after:tw-right-0 [&>button]:after:tw-left-0",
|
||||
},
|
||||
})
|
||||
export class ItemActionComponent {}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<div
|
||||
#endSlot
|
||||
class="tw-p-2 tw-flex tw-gap-1 tw-items-center"
|
||||
class="tw-px-2 tw-flex tw-items-center tw-gap-2 [&_button[biticonbutton]]:-tw-mx-1"
|
||||
[hidden]="endSlot.childElementCount === 0"
|
||||
>
|
||||
<ng-content select="[slot=end]"></ng-content>
|
||||
|
||||
@@ -397,7 +397,7 @@ export const VirtualScrolling: Story = {
|
||||
data: Array.from(Array(100000).keys()),
|
||||
},
|
||||
template: /*html*/ `
|
||||
<cdk-virtual-scroll-viewport [itemSize]="59" class="tw-h-[500px]">
|
||||
<cdk-virtual-scroll-viewport [itemSize]="54" class="tw-h-[500px]">
|
||||
<bit-item-group aria-label="Virtual Scrolling">
|
||||
<bit-item *cdkVirtualFor="let item of data">
|
||||
<button bit-item-content>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
type="button"
|
||||
class="tw-ms-auto"
|
||||
[bitIconButton]="open() ? 'bwi-angle-up' : 'bwi-angle-down'"
|
||||
[buttonType]="'light'"
|
||||
[buttonType]="'nav-contrast'"
|
||||
(click)="toggle($event)"
|
||||
size="small"
|
||||
[title]="'toggleCollapse' | i18n"
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
<ng-template #anchorAndButtonContent>
|
||||
<div
|
||||
[title]="text()"
|
||||
class="tw-gap-2 tw-items-center tw-font-bold tw-h-full tw-content-center"
|
||||
[ngClass]="{ 'tw-text-center': !open, 'tw-flex': open }"
|
||||
class="tw-gap-2 tw-flex tw-items-center tw-font-bold tw-h-full"
|
||||
[ngClass]="{ 'tw-justify-center': !open }"
|
||||
>
|
||||
<i
|
||||
class="!tw-m-0 tw-w-4 tw-shrink-0 bwi bwi-fw tw-text-alt2 {{ icon() }}"
|
||||
@@ -36,7 +36,7 @@
|
||||
<!-- The `data-fvw` attribute passes focus to `this.focusVisibleWithin$` -->
|
||||
<!-- The following `class` field should match the `#isButton` class field below -->
|
||||
<a
|
||||
class="tw-size-full tw-px-3 tw-block tw-truncate tw-border-none tw-bg-transparent tw-text-start !tw-text-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none"
|
||||
class="tw-size-full tw-px-4 tw-block tw-truncate tw-border-none tw-bg-transparent tw-text-start !tw-text-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none [&_i]:tw-leading-[1.5rem]"
|
||||
data-fvw
|
||||
[routerLink]="route()"
|
||||
[relativeTo]="relativeTo()"
|
||||
@@ -56,7 +56,7 @@
|
||||
<!-- Class field should match `#isAnchor` class field above -->
|
||||
<button
|
||||
type="button"
|
||||
class="tw-size-full tw-px-3 tw-truncate tw-border-none tw-bg-transparent tw-text-start !tw-text-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none"
|
||||
class="tw-size-full tw-px-4 tw-pe-3 tw-truncate tw-border-none tw-bg-transparent tw-text-start !tw-text-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none [&_i]:tw-leading-[1.5rem]"
|
||||
data-fvw
|
||||
(click)="mainContentClicked.emit()"
|
||||
>
|
||||
@@ -66,7 +66,7 @@
|
||||
|
||||
@if (open) {
|
||||
<div
|
||||
class="tw-flex tw-items-center tw-pe-1.5 tw-gap-1 [&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*:hover]:!tw-border-text-alt2 [&>*]:tw-text-alt2 empty:tw-hidden"
|
||||
class="tw-flex tw-items-center tw-pe-1 tw-gap-1 [&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*:hover]:!tw-border-text-alt2 [&>*]:tw-text-alt2 empty:tw-hidden"
|
||||
>
|
||||
<ng-content select="[slot=end]"></ng-content>
|
||||
</div>
|
||||
|
||||
@@ -92,7 +92,7 @@ export const WithChildButtons: Story = {
|
||||
slot="end"
|
||||
class="tw-ms-auto"
|
||||
[bitIconButton]="'bwi-pencil-square'"
|
||||
[buttonType]="'light'"
|
||||
[buttonType]="'nav-contrast'"
|
||||
size="small"
|
||||
aria-label="option 2"
|
||||
></button>
|
||||
@@ -100,7 +100,7 @@ export const WithChildButtons: Story = {
|
||||
slot="end"
|
||||
class="tw-ms-auto"
|
||||
[bitIconButton]="'bwi-check'"
|
||||
[buttonType]="'light'"
|
||||
[buttonType]="'nav-contrast'"
|
||||
size="small"
|
||||
aria-label="option 3"
|
||||
></button>
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
<div
|
||||
[ngClass]="{
|
||||
'tw-sticky tw-top-0 tw-z-50 tw-pb-2': sideNavService.open,
|
||||
'tw-pb-5': !sideNavService.open,
|
||||
'tw-pb-[calc(theme(spacing.6)_+_2px)]': !sideNavService.open,
|
||||
}"
|
||||
class="tw-px-2 tw-pt-5"
|
||||
>
|
||||
<!-- absolutely position the link svg to avoid shifting layout when sidenav is closed -->
|
||||
<a
|
||||
[routerLink]="route()"
|
||||
class="tw-p-3 tw-block tw-rounded-md tw-bg-background-alt3 tw-outline-none focus-visible:tw-ring focus-visible:tw-ring-inset focus-visible:tw-ring-text-alt2 hover:tw-bg-hover-contrast [&_path]:tw-fill-text-alt2"
|
||||
[ngClass]="{ '[&_svg]:tw-w-[1.687rem]': !sideNavService.open }"
|
||||
class="tw-relative tw-p-3 tw-block tw-rounded-md tw-bg-background-alt3 tw-outline-none focus-visible:tw-ring focus-visible:tw-ring-inset focus-visible:tw-ring-text-alt2 hover:tw-bg-hover-contrast tw-h-[73px] [&_svg]:tw-absolute [&_svg]:tw-inset-[theme(spacing.3)] [&_svg]:tw-w-[calc(100%_-_theme(spacing.6))] [&_svg]:tw-transition-[inline-start-inset]"
|
||||
[ngClass]="{
|
||||
'!tw-h-[55px] [&_svg]:!tw-w-[26px] [&_svg]:tw-inset-y-[theme(spacing.3)]':
|
||||
!sideNavService.open,
|
||||
}"
|
||||
[attr.aria-label]="label()"
|
||||
[title]="label()"
|
||||
routerLinkActive
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
type="button"
|
||||
class="tw-mx-auto tw-block tw-max-w-fit"
|
||||
[bitIconButton]="data.open ? 'bwi-angle-left' : 'bwi-angle-right'"
|
||||
buttonType="light"
|
||||
buttonType="nav-contrast"
|
||||
size="small"
|
||||
(click)="sideNavService.toggle()"
|
||||
[attr.aria-label]="'toggleSideNavigation' | i18n"
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<div
|
||||
class="tw-relative tw-z-20 tw-w-72 tw-break-words tw-bg-background tw-pb-4 tw-pt-2 tw-text-main"
|
||||
>
|
||||
<div class="tw-mb-1 tw-me-2 tw-flex tw-items-start tw-justify-between tw-gap-4 tw-ps-4">
|
||||
<h2 bitTypography="h5" class="tw-mt-1 tw-font-semibold">
|
||||
<div class="tw-me-2 tw-flex tw-items-start tw-justify-between tw-gap-4 tw-ps-4">
|
||||
<h2 bitTypography="h5" class="tw-font-semibold tw-mt-1">
|
||||
{{ title() }}
|
||||
</h2>
|
||||
<button
|
||||
@@ -15,7 +15,6 @@
|
||||
[attr.aria-label]="'close' | i18n"
|
||||
(click)="closed.emit()"
|
||||
size="small"
|
||||
class="tw-mt-0.5"
|
||||
></button>
|
||||
</div>
|
||||
<div bitTypography="body2" class="tw-px-4">
|
||||
|
||||
@@ -68,7 +68,7 @@ const popoverContent = /*html*/ `
|
||||
<li>Esse labore veniam tempora</li>
|
||||
<li>Adipisicing elit ipsum <a href="#" bitLink>iustolaborum</a></li>
|
||||
</ul>
|
||||
<button bitButton class="tw-mt-3" (click)="triggerRef.closePopover()">Close</button>
|
||||
<button bitButton class="tw-mt-4" (click)="triggerRef.closePopover()">Close</button>
|
||||
</bit-popover>
|
||||
`;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
role="search"
|
||||
(mouseenter)="isFormHovered.set(true)"
|
||||
(mouseleave)="isFormHovered.set(false)"
|
||||
class="tw-relative tw-flex tw-items-center tw-w-full"
|
||||
class="tw-relative tw-flex tw-items-center tw-w-full tw-h-10"
|
||||
>
|
||||
<label class="tw-sr-only" [for]="id">{{ "search" | i18n }}</label>
|
||||
<label
|
||||
@@ -18,7 +18,7 @@
|
||||
[type]="inputType"
|
||||
[id]="id"
|
||||
[placeholder]="placeholder() ?? ('search' | i18n)"
|
||||
class="tw-ps-9"
|
||||
class="tw-ps-9 tw-h-full"
|
||||
name="searchText"
|
||||
[ngModel]="searchText"
|
||||
(ngModelChange)="onChange($event)"
|
||||
|
||||
@@ -19,7 +19,7 @@ import { TableDataSource, TableModule } from "../../../table";
|
||||
ScrollLayoutDirective,
|
||||
],
|
||||
template: /*html*/ `<bit-section>
|
||||
<cdk-virtual-scroll-viewport bitScrollLayout itemSize="63.5">
|
||||
<cdk-virtual-scroll-viewport bitScrollLayout itemSize="49.5">
|
||||
<bit-table [dataSource]="dataSource">
|
||||
<ng-container header>
|
||||
<tr>
|
||||
|
||||
@@ -5,6 +5,6 @@ import { Directive, HostBinding } from "@angular/core";
|
||||
})
|
||||
export class CellDirective {
|
||||
@HostBinding("class") get classList() {
|
||||
return ["tw-p-3"];
|
||||
return ["tw-p-3", "has-[[biticonbutton]]:tw-py-1"];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
bitIconButton="bwi-close"
|
||||
buttonType="main"
|
||||
type="button"
|
||||
size="small"
|
||||
(click)="this.onClose.emit()"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
@@ -294,16 +294,6 @@ export abstract class KeyService {
|
||||
* @param encPrivateKey An encrypted private key
|
||||
*/
|
||||
abstract setPrivateKey(encPrivateKey: string, userId: UserId): Promise<void>;
|
||||
/**
|
||||
* Returns the private key from memory. If not available, decrypts it
|
||||
* from storage and stores it in memory
|
||||
* @returns The user's private key
|
||||
*
|
||||
* @throws Error when no active user
|
||||
*
|
||||
* @deprecated Use {@link userPrivateKey$} instead.
|
||||
*/
|
||||
abstract getPrivateKey(): Promise<Uint8Array | null>;
|
||||
|
||||
/**
|
||||
* Gets an observable stream of the given users decrypted private key, will emit null if the user
|
||||
@@ -311,6 +301,8 @@ export abstract class KeyService {
|
||||
* encrypted private key at all.
|
||||
*
|
||||
* @param userId The user id of the user to get the data for.
|
||||
* @returns An observable stream of the decrypted private key or null.
|
||||
* @throws Error when decryption of the encrypted private key fails.
|
||||
*/
|
||||
abstract userPrivateKey$(userId: UserId): Observable<UserPrivateKey | null>;
|
||||
|
||||
|
||||
@@ -494,77 +494,79 @@ describe("keyService", () => {
|
||||
});
|
||||
|
||||
describe("userPrivateKey$", () => {
|
||||
type SetupKeysParams = {
|
||||
makeMasterKey: boolean;
|
||||
makeUserKey: boolean;
|
||||
};
|
||||
let mockUserKey: UserKey;
|
||||
let mockUserPrivateKey: Uint8Array;
|
||||
let mockEncryptedPrivateKey: EncryptedString;
|
||||
|
||||
function setupKeys({
|
||||
makeMasterKey,
|
||||
makeUserKey,
|
||||
}: SetupKeysParams): [UserKey | null, MasterKey | null] {
|
||||
const userKeyState = stateProvider.singleUser.getFake(mockUserId, USER_KEY);
|
||||
const fakeMasterKey = makeMasterKey ? makeSymmetricCryptoKey<MasterKey>(64) : null;
|
||||
masterPasswordService.masterKeySubject.next(fakeMasterKey);
|
||||
userKeyState.nextState(null);
|
||||
const fakeUserKey = makeUserKey ? makeSymmetricCryptoKey<UserKey>(64) : null;
|
||||
userKeyState.nextState(fakeUserKey);
|
||||
return [fakeUserKey, fakeMasterKey];
|
||||
}
|
||||
beforeEach(() => {
|
||||
mockUserKey = makeSymmetricCryptoKey<UserKey>(64);
|
||||
mockEncryptedPrivateKey = makeEncString("encryptedPrivateKey").encryptedString!;
|
||||
mockUserPrivateKey = makeStaticByteArray(10, 1);
|
||||
stateProvider.singleUser.getFake(mockUserId, USER_KEY).nextState(mockUserKey);
|
||||
stateProvider.singleUser
|
||||
.getFake(mockUserId, USER_ENCRYPTED_PRIVATE_KEY)
|
||||
.nextState(mockEncryptedPrivateKey);
|
||||
encryptService.unwrapDecapsulationKey.mockResolvedValue(mockUserPrivateKey);
|
||||
});
|
||||
|
||||
it("will return users decrypted private key when user has a user key and encrypted private key set", async () => {
|
||||
const [userKey] = setupKeys({
|
||||
makeMasterKey: false,
|
||||
makeUserKey: true,
|
||||
it("returns the unwrapped user private key when user key and encrypted private key are set", async () => {
|
||||
const result = await firstValueFrom(keyService.userPrivateKey$(mockUserId));
|
||||
|
||||
expect(result).toEqual(mockUserPrivateKey);
|
||||
expect(encryptService.unwrapDecapsulationKey).toHaveBeenCalledWith(
|
||||
new EncString(mockEncryptedPrivateKey),
|
||||
mockUserKey,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws an error if unwrapping encrypted private key fails", async () => {
|
||||
encryptService.unwrapDecapsulationKey.mockImplementationOnce(() => {
|
||||
throw new Error("Unwrapping failed");
|
||||
});
|
||||
|
||||
const userEncryptedPrivateKeyState = stateProvider.singleUser.getFake(
|
||||
mockUserId,
|
||||
USER_ENCRYPTED_PRIVATE_KEY,
|
||||
await expect(firstValueFrom(keyService.userPrivateKey$(mockUserId))).rejects.toThrow(
|
||||
"Unwrapping failed",
|
||||
);
|
||||
|
||||
const fakeEncryptedUserPrivateKey = makeEncString("1");
|
||||
|
||||
userEncryptedPrivateKeyState.nextState(fakeEncryptedUserPrivateKey.encryptedString!);
|
||||
|
||||
// Decryption of the user private key
|
||||
const fakeDecryptedUserPrivateKey = makeStaticByteArray(10, 1);
|
||||
encryptService.unwrapDecapsulationKey.mockResolvedValue(fakeDecryptedUserPrivateKey);
|
||||
|
||||
const fakeUserPublicKey = makeStaticByteArray(10, 2);
|
||||
cryptoFunctionService.rsaExtractPublicKey.mockResolvedValue(fakeUserPublicKey);
|
||||
|
||||
const userPrivateKey = await firstValueFrom(keyService.userPrivateKey$(mockUserId));
|
||||
|
||||
expect(encryptService.unwrapDecapsulationKey).toHaveBeenCalledWith(
|
||||
fakeEncryptedUserPrivateKey,
|
||||
userKey,
|
||||
);
|
||||
|
||||
expect(userPrivateKey).toBe(fakeDecryptedUserPrivateKey);
|
||||
});
|
||||
|
||||
it("returns null user private key when no user key is found", async () => {
|
||||
setupKeys({ makeMasterKey: false, makeUserKey: false });
|
||||
it("returns null if user key is not set", async () => {
|
||||
stateProvider.singleUser.getFake(mockUserId, USER_KEY).nextState(null);
|
||||
|
||||
const userPrivateKey = await firstValueFrom(keyService.userPrivateKey$(mockUserId));
|
||||
const result = await firstValueFrom(keyService.userPrivateKey$(mockUserId));
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(encryptService.unwrapDecapsulationKey).not.toHaveBeenCalled();
|
||||
|
||||
expect(userPrivateKey).toBeFalsy();
|
||||
});
|
||||
|
||||
it("returns null when user does not have a private key set", async () => {
|
||||
setupKeys({ makeUserKey: true, makeMasterKey: false });
|
||||
it("returns null if encrypted private key is not set", async () => {
|
||||
stateProvider.singleUser.getFake(mockUserId, USER_ENCRYPTED_PRIVATE_KEY).nextState(null);
|
||||
|
||||
const encryptedUserPrivateKeyState = stateProvider.singleUser.getFake(
|
||||
mockUserId,
|
||||
USER_ENCRYPTED_PRIVATE_KEY,
|
||||
);
|
||||
encryptedUserPrivateKeyState.nextState(null);
|
||||
const result = await firstValueFrom(keyService.userPrivateKey$(mockUserId));
|
||||
|
||||
const userPrivateKey = await firstValueFrom(keyService.userPrivateKey$(mockUserId));
|
||||
expect(userPrivateKey).toBeFalsy();
|
||||
expect(result).toBeNull();
|
||||
expect(encryptService.unwrapDecapsulationKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reacts to changes in user key or encrypted private key", async () => {
|
||||
// Initial state: both set
|
||||
let result = await firstValueFrom(keyService.userPrivateKey$(mockUserId));
|
||||
|
||||
expect(result).toEqual(mockUserPrivateKey);
|
||||
|
||||
// Change user key to null
|
||||
stateProvider.singleUser.getFake(mockUserId, USER_KEY).nextState(null);
|
||||
|
||||
result = await firstValueFrom(keyService.userPrivateKey$(mockUserId));
|
||||
|
||||
expect(result).toBeNull();
|
||||
|
||||
// Restore user key, remove encrypted private key
|
||||
stateProvider.singleUser.getFake(mockUserId, USER_KEY).nextState(mockUserKey);
|
||||
stateProvider.singleUser.getFake(mockUserId, USER_ENCRYPTED_PRIVATE_KEY).nextState(null);
|
||||
|
||||
result = await firstValueFrom(keyService.userPrivateKey$(mockUserId));
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1063,7 +1065,7 @@ describe("keyService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("userPrivateKey$", () => {
|
||||
describe("userEncryptionKeyPair$", () => {
|
||||
type SetupKeysParams = {
|
||||
makeMasterKey: boolean;
|
||||
makeUserKey: boolean;
|
||||
|
||||
@@ -501,16 +501,6 @@ export class DefaultKeyService implements KeyServiceAbstraction {
|
||||
.update(() => encPrivateKey);
|
||||
}
|
||||
|
||||
async getPrivateKey(): Promise<Uint8Array | null> {
|
||||
const activeUserId = await firstValueFrom(this.stateProvider.activeUserId$);
|
||||
|
||||
if (activeUserId == null) {
|
||||
throw new Error("User must be active while attempting to retrieve private key.");
|
||||
}
|
||||
|
||||
return await firstValueFrom(this.userPrivateKey$(activeUserId));
|
||||
}
|
||||
|
||||
// TODO: Make public key required
|
||||
async getFingerprint(fingerprintMaterial: string, publicKey?: Uint8Array): Promise<string[]> {
|
||||
if (publicKey == null) {
|
||||
|
||||
@@ -1,5 +1,83 @@
|
||||
# nx-plugin
|
||||
# @bitwarden/nx-plugin
|
||||
|
||||
Owned by: Platform
|
||||
The `@bitwarden/nx-plugin` library is a custom Nx plugin developed specifically for Bitwarden projects. It
|
||||
provides generators tailored to Bitwarden's architecture and coding standards.
|
||||
|
||||
Custom Nx tools like generators and executors for Bitwarden projects
|
||||
## Overview
|
||||
|
||||
This plugin extends Nx's capabilities with Bitwarden-specific **nx generators** that help maintain
|
||||
consistency across the codebase.
|
||||
|
||||
### What are Nx Generators?
|
||||
|
||||
Nx generators are code generation tools that follow templates to create or
|
||||
modify files in your project. They can:
|
||||
|
||||
- Create new files from templates
|
||||
- Modify existing files
|
||||
- Update configuration files
|
||||
- Ensure consistent project structure
|
||||
- Automate repetitive tasks
|
||||
|
||||
If you're familiar with the code generation tools of say, the angular CLI, then you can just think of =nx= generators as that but on a larger scale. Generators can be run using the Nx CLI with the `nx generate` command (or the shorthand `nx g`).
|
||||
|
||||
### When to Use Generators
|
||||
|
||||
Use generators when:
|
||||
|
||||
- Creating new libraries, components, or features that follow a standard pattern
|
||||
- You want to ensure consistency across similar parts of your application
|
||||
- You need to automate repetitive setup tasks
|
||||
- You want to reduce the chance of human error in project setup
|
||||
|
||||
## How `@bitwarden/nx-plugin` Fits Into the Project Architecture
|
||||
|
||||
`@bitwarden/nx-plugin` is designed to:
|
||||
|
||||
1. Enforce Bitwarden's architectural decisions and code organization
|
||||
2. Streamline the creation of new libraries and components
|
||||
3. Ensure consistent configuration across the project
|
||||
4. Automate updates to project metadata and configuration files
|
||||
5. Reduce the learning curve for new contributors
|
||||
|
||||
By using this plugin, we maintain a consistent approach to code organization and structure across
|
||||
the entire project.
|
||||
|
||||
## Installation and Setup
|
||||
|
||||
The plugin is included as a development dependency in the project. If you're working with a fresh
|
||||
clone of the repository, it will be installed when you run:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
No additional setup is required to use the generators provided by the plugin.
|
||||
|
||||
## Available Generators
|
||||
|
||||
The plugin currently includes the following generators:
|
||||
|
||||
- `basic-lib`: Creates a new library with standard configuration and structure. Specific documentation for the `basic-lib` generator can be found [here](./docs/using-the-basic-lib-generator.md)
|
||||
|
||||
Additional generators may be added in the future to support other common patterns in the Bitwarden
|
||||
codebase.
|
||||
|
||||
## Creating A Nx Generator
|
||||
|
||||
This library is maintained by platform, but anyone from any team can add a
|
||||
generator if there is any amount of value added. If you need to create a new
|
||||
generator please do so by running
|
||||
|
||||
```bash
|
||||
npx nx generate @nx/plugin:generator libs/nx-plugin/src/generators/your-generator-name-here}
|
||||
```
|
||||
|
||||
This will create a basic generator structure for you to get started with.
|
||||
|
||||
## Further Learning
|
||||
|
||||
To learn more about Nx plugins and how they work:
|
||||
|
||||
- [Nx Plugin Development](https://nx.dev/extending-nx/creating-nx-plugins)
|
||||
- [Nx Plugins Overview](https://nx.dev/extending-nx/intro)
|
||||
|
||||
237
libs/nx-plugin/docs/using-the-basic-lib-generator.md
Normal file
237
libs/nx-plugin/docs/using-the-basic-lib-generator.md
Normal file
@@ -0,0 +1,237 @@
|
||||
# Using the basic-lib Generator
|
||||
|
||||
The `basic-lib` generator creates a new library with Bitwarden's standard configuration and
|
||||
structure. It sets up all the necessary files, configurations, and hooks with global configuration
|
||||
files.
|
||||
|
||||
## Command Syntax
|
||||
|
||||
You can use the basic lib generator by running this command:
|
||||
|
||||
```bash
|
||||
npx nx g @bitwarden/nx-plugin:basic-lib
|
||||
```
|
||||
|
||||
## Available Options
|
||||
|
||||
All fields are required, but do not need to be supplied as CLI flags. Generator users will be asked
|
||||
interactively for each of these if they are not supplied as CLI flags.
|
||||
|
||||
| Option | Description | Required | Default |
|
||||
| --------------- | ----------------------------------------------- | -------- | ------- |
|
||||
| `--name` | The name of the library | Yes | - |
|
||||
| `--description` | A brief description of the library | Yes | none |
|
||||
| `--team` | The team responsible for the library | Yes | none |
|
||||
| `--directory` | The directory where the library will be created | Yes | "libs" |
|
||||
|
||||
## Step-by-Step Example
|
||||
|
||||
Let's create a new utility library called "password-insulter":
|
||||
|
||||
1. Open your terminal and navigate to the root of the Bitwarden clients repository
|
||||
2. Run the generator command:
|
||||
```bash
|
||||
nx g @bitwarden/nx-plugin:basic-lib --name=password-insulter --description="Like the password strength meter, but more judgemental" --team=tools
|
||||
```
|
||||
3. The generator will create the library structure and update necessary configuration files
|
||||
4. The new library is now ready to use
|
||||
|
||||
## What Gets Generated
|
||||
|
||||
The generator creates the following:
|
||||
|
||||
- **Library Structure**:
|
||||
- `libs/password-insulter/`
|
||||
- `src/`
|
||||
- `index.ts` - Main entry point
|
||||
- `README.md` - With the provided description
|
||||
- `package.json` - Very minimal
|
||||
- `tsconfig.json` - TypeScript configuration
|
||||
- `tsconfig.lib.json` - Library-specific TypeScript configuration
|
||||
- `tsconfig.spec.json` - Test-specific TypeScript configuration
|
||||
- `jest.config.js` - Test configuration
|
||||
- `.eslintrc.json` - Linting rules
|
||||
|
||||
- **Configuration Updates**:
|
||||
|
||||
The generator then updates `tsconfig.base.json` to reference your new library, updates CODEOWNERS to
|
||||
assign the team to the new folder, and runs `npm i` to link everything up.
|
||||
|
||||
## Post-Generation Next Steps
|
||||
|
||||
After generating your library:
|
||||
|
||||
1. Review the generated README.md and update it with more detailed information if needed
|
||||
2. Implement your library code in the `src/` directory, using whatever subfolder structure you
|
||||
prefer
|
||||
3. Export public APIs through the `src/index.ts` file
|
||||
4. Write tests for your library
|
||||
5. Build your library with `npx nx build password-insulter`
|
||||
6. Lint your library with `npx nx lint password-insulter`
|
||||
7. Test your library with `npx nx test password-insulter`
|
||||
|
||||
## Troubleshooting Common Issues
|
||||
|
||||
### Issue: Generator fails with path errors
|
||||
|
||||
**Solution**: Ensure you're running the command from the root of the repository.
|
||||
|
||||
### Issue: TypeScript path mapping not working
|
||||
|
||||
**Solution**: Run `nx reset` to clear the Nx cache, then try importing from your library again.
|
||||
|
||||
## Extending the Generated Code
|
||||
|
||||
The generated library provides a basic structure that you can extend:
|
||||
|
||||
- Add additional directories for specific features
|
||||
- Create subdirectories in `src/` for better organization
|
||||
- Modify the Jest configuration for specialized testing needs
|
||||
|
||||
## Designing a Library
|
||||
|
||||
There are a few ways you and your team may want to design a library, there are a lot of factors to
|
||||
take into account like clean organization, ease of onboarding new members, simplicity of moving
|
||||
ownership to a new team, and optimizing for module size. Below are a few ways you might want to
|
||||
design a library.
|
||||
|
||||
### Option 1: Feature-based libraries
|
||||
|
||||
One strategy to employ is a feature-scoped library.
|
||||
|
||||
The import for such a library would be `@bitwarden/[feature]`. For example the `global-state`
|
||||
feature would be imported with `@bitwarden/global-state`.
|
||||
|
||||
If the feature has both a UI component and needs to be used in the CLI it would probably result in a
|
||||
`@bitwarden/[feature]-ui` or `@bitwarden/[feature]-angular` library as well.
|
||||
|
||||
> [!NOTE]
|
||||
> With more things being added to the SDK and the CLI eventually being written directly in Rust there
|
||||
> will become less and less need to have a package with an Angular dependency and without it.
|
||||
|
||||
Pros
|
||||
|
||||
- You'll have smaller libraries that have minimal dependencies, making them easier for another team
|
||||
to depend on without a circular reference.
|
||||
- If your team is ever split or a feature you own is moved to another team this can likely be done
|
||||
with just an update to the GitHub `CODEOWNERS` file.
|
||||
- You'll have a clearer dependency graph.
|
||||
- Your modules will be smaller.
|
||||
|
||||
Cons
|
||||
|
||||
- YOu have to spend the time to think about and define what a feature is.
|
||||
- You have to create libraries somewhat often.
|
||||
- You MAY need "glue" libraries still.
|
||||
- It is not as clear who owns what feature from looking at library names.
|
||||
|
||||
> [!NOTE]
|
||||
> A "Glue" library is a library that might not exist other than the need for two teams to collaborate
|
||||
> on a cross cutting feature. The glue library might exist to hold an interface that team B is
|
||||
> expected to implement but team A will be responsible for consuming. This helps glue 2 features
|
||||
> together while still allowing for team A to consume other things that exist in team B's library but
|
||||
> still avoid a circular dependency.
|
||||
|
||||
### Option 2: Team-based libraries
|
||||
|
||||
Another strategy would be to have a library for the vast majority of your teams code in a single
|
||||
package.
|
||||
|
||||
There are many ways you may choose to design the library, but if it's one library you will need to
|
||||
be dependent on everything that makes all your features tick.
|
||||
|
||||
**If all teams go this route it will be impossible to only have these team libraries**. Why? Because
|
||||
the team grouping is very likely to result in circular dependencies. For example, if team A depends
|
||||
on team B's library then team B cannot depend on anything in team A's library. If team B did need
|
||||
something they would need to request that team A move it "downstream". Team A would need to move
|
||||
their code and come up with a name for it. If they don't want to also re-export those pieces of code
|
||||
they will need to update the import for every other place that code of used. You may also have to
|
||||
deal with the code now being separated from similar code or you may decide to move that code too.
|
||||
|
||||
Pros
|
||||
|
||||
- You have fewer libraries to maintain.
|
||||
- All your team’s code is in one place.
|
||||
|
||||
Cons
|
||||
|
||||
- You'll need to move code ad-hoc more often to make glue libraries, and each time try to think
|
||||
about how to design the package abstraction.
|
||||
- If your team splits you will need to move a lot more code.
|
||||
- You’ll have larger modules.
|
||||
|
||||
### Option 3: Type-based libraries
|
||||
|
||||
You can also split libraries based on the primary kind of file that it holds. For example, you could
|
||||
have a library holding all `type`’s, one for `abstractions`, and on more `services`. Since one
|
||||
library for all type’s would be mean having a library that has multiple owners it would be highly
|
||||
discouraged and therefore this would likely be split by team as well, resulting in packages like
|
||||
`@bitwarden/platform-types`; this library strategy is really a subset of the team-based one.
|
||||
|
||||
Pros
|
||||
|
||||
- You’ll be less likely to have circular dependencies within your team’s code, since generally Types
|
||||
< Abstractions < Services where < means lower level.
|
||||
- It’s most similar to the organization strategy we’ve had for a while.
|
||||
|
||||
Cons
|
||||
|
||||
- There is no guarantee that all the types for a given team are lower level than all the types of
|
||||
another team that they need to depend on. Circular dependencies can still happen amongst teams.
|
||||
- It’s also possible for a type to need to depend on an abstraction,
|
||||
- We are generally discouraging teams from making abstractions unless needed (link).
|
||||
|
||||
### Option 4: Feature/type hybrid
|
||||
|
||||
Another strategy could be to split libraries by the kind of item in feature-scoped libraries.
|
||||
|
||||
Pros
|
||||
|
||||
- Lowest chance of circular dependencies showing up later.
|
||||
- Pretty easy to move ownership.
|
||||
|
||||
Cons
|
||||
|
||||
- The most libraries to maintain.
|
||||
- Consumers will likely have to import multiple modules in order to use your feature.
|
||||
|
||||
### Platform Recommendation
|
||||
|
||||
Given the options available with Nx and the pros and cons of each, Platform is planning on using
|
||||
[feature-based libraries](#option-1-feature-based-libraries) and we recommend other teams do as
|
||||
well.
|
||||
|
||||
We understand that we might have a domain that is a little easier to split into features, but we
|
||||
believe that the work is worthwhile for teams to do.
|
||||
|
||||
There will be some instances that our libraries may only contain abstractions and very simple types
|
||||
which would then resemble the type-based approach. We will be forced to do this because we have some
|
||||
things like storage where it’s only really useful which the implementations made in the individual
|
||||
apps.
|
||||
|
||||
Example Platform feature libraries (all of these would be imported like `@bitwarden/[feature]`:
|
||||
|
||||
- `storage-core`
|
||||
- `user-state`
|
||||
- `global-state`
|
||||
- `state` (will have its own code but also a meta package re-exporting `user-state` and
|
||||
`global-state`)
|
||||
- `clipboard`
|
||||
- `messaging`
|
||||
- `ipc`
|
||||
- `config`
|
||||
- `http`
|
||||
- `i18n`
|
||||
- `environments`
|
||||
- `server-notifications`
|
||||
- `sync`
|
||||
|
||||
Hopefully these will give you some ideas for how to split up your own features.
|
||||
|
||||
## Further Learning
|
||||
|
||||
For more information about Nx libraries and generators:
|
||||
|
||||
- [Nx Library Generation](https://nx.dev/plugin-features/create-libraries)
|
||||
- [Nx Library Types](https://nx.dev/more-concepts/library-types)
|
||||
- [Nx Project Configuration](https://nx.dev/reference/project-configuration)
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -282,7 +282,7 @@
|
||||
},
|
||||
"apps/desktop": {
|
||||
"name": "@bitwarden/desktop",
|
||||
"version": "2025.7.0",
|
||||
"version": "2025.8.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user