1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-09 05:00:10 +00:00

Merge branch 'main' into desktop/PM-27793/create-new-vault-component

This commit is contained in:
Leslie Xiong
2025-11-26 14:41:38 -05:00
committed by GitHub
19 changed files with 606 additions and 81 deletions

View File

@@ -256,6 +256,13 @@ jobs:
- name: Build application
run: npm run dist:lin
- name: Upload tar.gz artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: bitwarden_${{ env._PACKAGE_VERSION }}_x64.tar.gz
path: apps/desktop/dist/bitwarden_desktop_x64.tar.gz
if-no-files-found: error
- name: Upload .deb artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:

View File

@@ -110,6 +110,7 @@ jobs:
apps/desktop/artifacts/bitwarden_${{ env.PKG_VERSION }}_amd64.snap,
apps/desktop/artifacts/bitwarden_${{ env.PKG_VERSION }}_arm64.snap,
apps/desktop/artifacts/bitwarden_${{ env.PKG_VERSION }}_arm64.tar.gz,
apps/desktop/artifacts/bitwarden_${{ env.PKG_VERSION }}_x64.tar.gz,
apps/desktop/artifacts/Bitwarden-${{ env.PKG_VERSION }}-x86_64.AppImage,
apps/desktop/artifacts/Bitwarden-Portable-${{ env.PKG_VERSION }}.exe,
apps/desktop/artifacts/Bitwarden-Installer-${{ env.PKG_VERSION }}.exe,

View File

@@ -39,7 +39,7 @@
"clean:dist": "rimraf ./dist",
"pack:dir": "npm run clean:dist && electron-builder --dir -p never",
"pack:lin:flatpak": "flatpak-builder --repo=../../.flatpak-repo ../../.flatpak ./resources/com.bitwarden.desktop.devel.yaml --install-deps-from=flathub --force-clean && flatpak build-bundle ../../.flatpak-repo/ ./dist/com.bitwarden.desktop.flatpak com.bitwarden.desktop",
"pack:lin": "npm run clean:dist && electron-builder --linux --x64 -p never && export SNAP_FILE=$(realpath ./dist/bitwarden_*.snap) && unsquashfs -d ./dist/tmp-snap/ $SNAP_FILE && mkdir -p ./dist/tmp-snap/meta/polkit/ && cp ./resources/com.bitwarden.desktop.policy ./dist/tmp-snap/meta/polkit/polkit.com.bitwarden.desktop.policy && rm $SNAP_FILE && snap pack --compression=lzo ./dist/tmp-snap/ && mv ./*.snap ./dist/ && rm -rf ./dist/tmp-snap/",
"pack:lin": "npm run clean:dist && electron-builder --linux --x64 -p never && export SNAP_FILE=$(realpath ./dist/bitwarden_*.snap) && unsquashfs -d ./dist/tmp-snap/ $SNAP_FILE && mkdir -p ./dist/tmp-snap/meta/polkit/ && cp ./resources/com.bitwarden.desktop.policy ./dist/tmp-snap/meta/polkit/polkit.com.bitwarden.desktop.policy && rm $SNAP_FILE && snap pack --compression=lzo ./dist/tmp-snap/ && mv ./*.snap ./dist/ && rm -rf ./dist/tmp-snap/ && tar -czvf ./dist/bitwarden_desktop_x64.tar.gz -C ./dist/linux-unpacked/ .",
"pack:lin:arm64": "npm run clean:dist && electron-builder --linux --arm64 -p never && export SNAP_FILE=$(realpath ./dist/bitwarden_*.snap) && unsquashfs -d ./dist/tmp-snap/ $SNAP_FILE && mkdir -p ./dist/tmp-snap/meta/polkit/ && cp ./resources/com.bitwarden.desktop.policy ./dist/tmp-snap/meta/polkit/polkit.com.bitwarden.desktop.policy && rm $SNAP_FILE && snap pack --compression=lzo ./dist/tmp-snap/ && mv ./*.snap ./dist/ && rm -rf ./dist/tmp-snap/ && tar -czvf ./dist/bitwarden_desktop_arm64.tar.gz -C ./dist/linux-arm64-unpacked/ .",
"pack:mac": "npm run clean:dist && electron-builder --mac --universal -p never",
"pack:mac:with-extension": "npm run clean:dist && npm run build:macos-extension:mac && electron-builder --mac --universal -p never",

View File

@@ -12,14 +12,6 @@ if [ -e "/usr/lib/x86_64-linux-gnu/libdbus-1.so.3" ]; then
export LD_PRELOAD="/usr/lib/x86_64-linux-gnu/libdbus-1.so.3"
fi
# If running in non-snap, add libprocess_isolation.so from app path to LD_PRELOAD
# This prevents debugger / memory dumping on all desktop processes
if [ -z "$SNAP" ] && [ -f "$APP_PATH/libprocess_isolation.so" ]; then
LIBPROCESS_ISOLATION_SO="$APP_PATH/libprocess_isolation.so"
LD_PRELOAD="$LIBPROCESS_ISOLATION_SO${LD_PRELOAD:+:$LD_PRELOAD}"
export LD_PRELOAD
fi
PARAMS="--enable-features=UseOzonePlatform,WaylandWindowDecorations --ozone-platform-hint=auto"
if [ "$USE_X11" = "true" ]; then
PARAMS=""

View File

@@ -27,7 +27,7 @@ import { StateEventRunnerService } from "@bitwarden/common/platform/state";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { DialogService, RouterFocusManagerService, ToastService } from "@bitwarden/components";
import { KeyService, BiometricStateService } from "@bitwarden/key-management";
const BroadcasterSubscriptionId = "AppComponent";
@@ -76,11 +76,17 @@ export class AppComponent implements OnDestroy, OnInit {
private readonly destroy: DestroyRef,
private readonly documentLangSetter: DocumentLangSetter,
private readonly tokenService: TokenService,
private readonly routerFocusManager: RouterFocusManagerService,
) {
this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe();
const langSubscription = this.documentLangSetter.start();
this.destroy.onDestroy(() => langSubscription.unsubscribe());
this.routerFocusManager.start$.pipe(takeUntilDestroyed()).subscribe();
this.destroy.onDestroy(() => {
langSubscription.unsubscribe();
});
}
ngOnInit() {

View File

@@ -1063,6 +1063,7 @@ export class RiskInsightsOrchestratorService {
this.logService.debug("[RiskInsightsOrchestratorService] Fetching organization ciphers");
const ciphers = await this.cipherService.getAllFromApiForOrganization(
orgDetails.organizationId,
true,
);
this._ciphersSubject.next(ciphers);
this._hasCiphersSubject$.next(ciphers.length > 0);

View File

@@ -223,6 +223,7 @@ import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platfor
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory";
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
@@ -261,6 +262,7 @@ import { FileUploadService } from "@bitwarden/common/platform/services/file-uplo
import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service";
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
import { DefaultSdkService } from "@bitwarden/common/platform/services/sdk/default-sdk.service";
import { DefaultRegisterSdkService } from "@bitwarden/common/platform/services/sdk/register-sdk.service";
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service";
import { ValidationService } from "@bitwarden/common/platform/services/validation.service";
@@ -1586,6 +1588,19 @@ const safeProviders: SafeProvider[] = [
SsoLoginServiceAbstraction,
],
}),
safeProvider({
provide: RegisterSdkService,
useClass: DefaultRegisterSdkService,
deps: [
SdkClientFactory,
EnvironmentService,
PlatformUtilsServiceAbstraction,
AccountServiceAbstraction,
ApiServiceAbstraction,
StateProvider,
ConfigService,
],
}),
safeProvider({
provide: SdkService,
useClass: DefaultSdkService,

View File

@@ -75,6 +75,8 @@ export enum FeatureFlag {
/* Desktop */
DesktopUiMigrationMilestone1 = "desktop-ui-migration-milestone-1",
/* UIF */
RouterFocusManagement = "router-focus-management",
}
export type AllowedFeatureFlagTypes = boolean | number | string;
@@ -156,6 +158,8 @@ export const DefaultFeatureFlagValue = {
/* Desktop */
[FeatureFlag.DesktopUiMigrationMilestone1]: FALSE,
/* UIF */
[FeatureFlag.RouterFocusManagement]: FALSE,
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;

View File

@@ -0,0 +1,56 @@
import { Observable } from "rxjs";
import { BitwardenClient, Uuid } from "@bitwarden/sdk-internal";
import { UserId } from "../../../types/guid";
import { Rc } from "../../misc/reference-counting/rc";
import { Utils } from "../../misc/utils";
export class UserNotLoggedInError extends Error {
constructor(userId: UserId) {
super(`User (${userId}) is not logged in`);
}
}
export class InvalidUuid extends Error {
constructor(uuid: string) {
super(`Invalid UUID: ${uuid}`);
}
}
/**
* Converts a string to UUID. Will throw an error if the UUID is non valid.
*/
export function asUuid<T extends Uuid>(uuid: string): T {
if (Utils.isGuid(uuid)) {
return uuid as T;
}
throw new InvalidUuid(uuid);
}
/**
* Converts a UUID to the string representation.
*/
export function uuidAsString<T extends Uuid>(uuid: T): string {
return uuid as unknown as string;
}
export abstract class RegisterSdkService {
/**
* Retrieve a client with tokens for a specific user.
* This client is meant exclusively for registrations that require tokens, such as TDE and key-connector.
*
* - If the user is not logged when the subscription is created, the observable will complete
* immediately with {@link UserNotLoggedInError}.
* - If the user is logged in, the observable will emit the client and complete without an error
* when the user logs out.
*
* **WARNING:** Do not use `firstValueFrom(userClient$)`! Any operations on the client must be done within the observable.
* The client will be destroyed when the observable is no longer subscribed to.
* Please let platform know if you need a client that is not destroyed when the observable is no longer subscribed to.
*
* @param userId The user id for which to retrieve the client
*/
abstract registerClient$(userId: UserId): Observable<Rc<BitwardenClient>>;
}

View File

@@ -1,7 +1,8 @@
import { Observable } from "rxjs";
import { PasswordManagerClient, Uuid } from "@bitwarden/sdk-internal";
import { PasswordManagerClient, Uuid, DeviceType as SdkDeviceType } from "@bitwarden/sdk-internal";
import { DeviceType } from "../../../enums";
import { UserId } from "../../../types/guid";
import { Rc } from "../../misc/reference-counting/rc";
import { Utils } from "../../misc/utils";
@@ -18,6 +19,63 @@ export class InvalidUuid extends Error {
}
}
export function toSdkDevice(device: DeviceType): SdkDeviceType {
switch (device) {
case DeviceType.Android:
return "Android";
case DeviceType.iOS:
return "iOS";
case DeviceType.ChromeExtension:
return "ChromeExtension";
case DeviceType.FirefoxExtension:
return "FirefoxExtension";
case DeviceType.OperaExtension:
return "OperaExtension";
case DeviceType.EdgeExtension:
return "EdgeExtension";
case DeviceType.WindowsDesktop:
return "WindowsDesktop";
case DeviceType.MacOsDesktop:
return "MacOsDesktop";
case DeviceType.LinuxDesktop:
return "LinuxDesktop";
case DeviceType.ChromeBrowser:
return "ChromeBrowser";
case DeviceType.FirefoxBrowser:
return "FirefoxBrowser";
case DeviceType.OperaBrowser:
return "OperaBrowser";
case DeviceType.EdgeBrowser:
return "EdgeBrowser";
case DeviceType.IEBrowser:
return "IEBrowser";
case DeviceType.UnknownBrowser:
return "UnknownBrowser";
case DeviceType.AndroidAmazon:
return "AndroidAmazon";
case DeviceType.UWP:
return "UWP";
case DeviceType.SafariBrowser:
return "SafariBrowser";
case DeviceType.VivaldiBrowser:
return "VivaldiBrowser";
case DeviceType.VivaldiExtension:
return "VivaldiExtension";
case DeviceType.SafariExtension:
return "SafariExtension";
case DeviceType.Server:
return "Server";
case DeviceType.WindowsCLI:
return "WindowsCLI";
case DeviceType.MacOsCLI:
return "MacOsCLI";
case DeviceType.LinuxCLI:
return "LinuxCLI";
default:
return "SDK";
}
}
/**
* Converts a string to UUID. Will throw an error if the UUID is non valid.
*/

View File

@@ -22,14 +22,12 @@ import { KeyService, KdfConfigService, KdfConfig, KdfType } from "@bitwarden/key
import {
PasswordManagerClient,
ClientSettings,
DeviceType as SdkDeviceType,
TokenProvider,
UnsignedSharedKey,
} from "@bitwarden/sdk-internal";
import { ApiService } from "../../../abstractions/api.service";
import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service";
import { DeviceType } from "../../../enums/device-type.enum";
import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string";
import { SecurityStateService } from "../../../key-management/security-state/abstractions/security-state.service";
import { SignedSecurityState, WrappedSigningKey } from "../../../key-management/types";
@@ -39,7 +37,12 @@ import { Environment, EnvironmentService } from "../../abstractions/environment.
import { PlatformUtilsService } from "../../abstractions/platform-utils.service";
import { SdkClientFactory } from "../../abstractions/sdk/sdk-client-factory";
import { SdkLoadService } from "../../abstractions/sdk/sdk-load.service";
import { asUuid, SdkService, UserNotLoggedInError } from "../../abstractions/sdk/sdk.service";
import {
asUuid,
SdkService,
toSdkDevice,
UserNotLoggedInError,
} from "../../abstractions/sdk/sdk.service";
import { compareValues } from "../../misc/compare-values";
import { Rc } from "../../misc/reference-counting/rc";
import { StateProvider } from "../../state";
@@ -297,65 +300,8 @@ export class DefaultSdkService implements SdkService {
return {
apiUrl: env.getApiUrl(),
identityUrl: env.getIdentityUrl(),
deviceType: this.toDevice(this.platformUtilsService.getDevice()),
deviceType: toSdkDevice(this.platformUtilsService.getDevice()),
userAgent: this.userAgent ?? navigator.userAgent,
};
}
private toDevice(device: DeviceType): SdkDeviceType {
switch (device) {
case DeviceType.Android:
return "Android";
case DeviceType.iOS:
return "iOS";
case DeviceType.ChromeExtension:
return "ChromeExtension";
case DeviceType.FirefoxExtension:
return "FirefoxExtension";
case DeviceType.OperaExtension:
return "OperaExtension";
case DeviceType.EdgeExtension:
return "EdgeExtension";
case DeviceType.WindowsDesktop:
return "WindowsDesktop";
case DeviceType.MacOsDesktop:
return "MacOsDesktop";
case DeviceType.LinuxDesktop:
return "LinuxDesktop";
case DeviceType.ChromeBrowser:
return "ChromeBrowser";
case DeviceType.FirefoxBrowser:
return "FirefoxBrowser";
case DeviceType.OperaBrowser:
return "OperaBrowser";
case DeviceType.EdgeBrowser:
return "EdgeBrowser";
case DeviceType.IEBrowser:
return "IEBrowser";
case DeviceType.UnknownBrowser:
return "UnknownBrowser";
case DeviceType.AndroidAmazon:
return "AndroidAmazon";
case DeviceType.UWP:
return "UWP";
case DeviceType.SafariBrowser:
return "SafariBrowser";
case DeviceType.VivaldiBrowser:
return "VivaldiBrowser";
case DeviceType.VivaldiExtension:
return "VivaldiExtension";
case DeviceType.SafariExtension:
return "SafariExtension";
case DeviceType.Server:
return "Server";
case DeviceType.WindowsCLI:
return "WindowsCLI";
case DeviceType.MacOsCLI:
return "MacOsCLI";
case DeviceType.LinuxCLI:
return "LinuxCLI";
default:
return "SDK";
}
}
}

View File

@@ -0,0 +1,170 @@
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom, of } from "rxjs";
import { BitwardenClient } from "@bitwarden/sdk-internal";
import {
ObservableTracker,
FakeAccountService,
FakeStateProvider,
mockAccountServiceWith,
} from "../../../../spec";
import { ApiService } from "../../../abstractions/api.service";
import { AccountInfo } from "../../../auth/abstractions/account.service";
import { UserId } from "../../../types/guid";
import { ConfigService } from "../../abstractions/config/config.service";
import { Environment, EnvironmentService } from "../../abstractions/environment.service";
import { PlatformUtilsService } from "../../abstractions/platform-utils.service";
import { SdkClientFactory } from "../../abstractions/sdk/sdk-client-factory";
import { SdkLoadService } from "../../abstractions/sdk/sdk-load.service";
import { UserNotLoggedInError } from "../../abstractions/sdk/sdk.service";
import { Rc } from "../../misc/reference-counting/rc";
import { Utils } from "../../misc/utils";
import { DefaultRegisterSdkService } from "./register-sdk.service";
class TestSdkLoadService extends SdkLoadService {
protected override load(): Promise<void> {
// Simulate successful WASM load
return Promise.resolve();
}
}
describe("DefaultRegisterSdkService", () => {
describe("userClient$", () => {
let sdkClientFactory!: MockProxy<SdkClientFactory>;
let environmentService!: MockProxy<EnvironmentService>;
let platformUtilsService!: MockProxy<PlatformUtilsService>;
let configService!: MockProxy<ConfigService>;
let service!: DefaultRegisterSdkService;
let accountService!: FakeAccountService;
let fakeStateProvider!: FakeStateProvider;
let apiService!: MockProxy<ApiService>;
beforeEach(async () => {
await new TestSdkLoadService().loadAndInit();
sdkClientFactory = mock<SdkClientFactory>();
environmentService = mock<EnvironmentService>();
platformUtilsService = mock<PlatformUtilsService>();
apiService = mock<ApiService>();
const mockUserId = Utils.newGuid() as UserId;
accountService = mockAccountServiceWith(mockUserId);
fakeStateProvider = new FakeStateProvider(accountService);
configService = mock<ConfigService>();
configService.serverConfig$ = new BehaviorSubject(null);
// Can't use `of(mock<Environment>())` for some reason
environmentService.environment$ = new BehaviorSubject(mock<Environment>());
service = new DefaultRegisterSdkService(
sdkClientFactory,
environmentService,
platformUtilsService,
accountService,
apiService,
fakeStateProvider,
configService,
);
});
describe("given the user is logged in", () => {
const userId = "0da62ebd-98bb-4f42-a846-64e8555087d7" as UserId;
beforeEach(() => {
environmentService.getEnvironment$
.calledWith(userId)
.mockReturnValue(new BehaviorSubject(mock<Environment>()));
accountService.accounts$ = of({
[userId]: { email: "email", emailVerified: true, name: "name" } as AccountInfo,
});
});
let mockClient!: MockProxy<BitwardenClient>;
beforeEach(() => {
mockClient = createMockClient();
sdkClientFactory.createSdkClient.mockResolvedValue(mockClient);
});
it("creates an internal SDK client when called the first time", async () => {
await firstValueFrom(service.registerClient$(userId));
expect(sdkClientFactory.createSdkClient).toHaveBeenCalled();
});
it("does not create an SDK client when called the second time with same userId", async () => {
const subject_1 = new BehaviorSubject<Rc<BitwardenClient> | undefined>(undefined);
const subject_2 = new BehaviorSubject<Rc<BitwardenClient> | undefined>(undefined);
// Use subjects to ensure the subscription is kept alive
service.registerClient$(userId).subscribe(subject_1);
service.registerClient$(userId).subscribe(subject_2);
// Wait for the next tick to ensure all async operations are done
await new Promise(process.nextTick);
expect(subject_1.value.take().value).toBe(mockClient);
expect(subject_2.value.take().value).toBe(mockClient);
expect(sdkClientFactory.createSdkClient).toHaveBeenCalledTimes(1);
});
it("destroys the internal SDK client when all subscriptions are closed", async () => {
const subject_1 = new BehaviorSubject<Rc<BitwardenClient> | undefined>(undefined);
const subject_2 = new BehaviorSubject<Rc<BitwardenClient> | undefined>(undefined);
const subscription_1 = service.registerClient$(userId).subscribe(subject_1);
const subscription_2 = service.registerClient$(userId).subscribe(subject_2);
await new Promise(process.nextTick);
subscription_1.unsubscribe();
subscription_2.unsubscribe();
await new Promise(process.nextTick);
expect(mockClient.free).toHaveBeenCalledTimes(1);
});
it("destroys the internal SDK client when the account is removed (logout)", async () => {
const accounts$ = new BehaviorSubject({
[userId]: { email: "email", emailVerified: true, name: "name" } as AccountInfo,
});
accountService.accounts$ = accounts$;
const userClientTracker = new ObservableTracker(service.registerClient$(userId), false);
await userClientTracker.pauseUntilReceived(1);
accounts$.next({});
await userClientTracker.expectCompletion();
expect(mockClient.free).toHaveBeenCalledTimes(1);
});
});
describe("given the user is not logged in", () => {
const userId = "0da62ebd-98bb-4f42-a846-64e8555087d7" as UserId;
beforeEach(() => {
environmentService.getEnvironment$
.calledWith(userId)
.mockReturnValue(new BehaviorSubject(mock<Environment>()));
accountService.accounts$ = of({});
});
it("throws UserNotLoggedInError when user has no account", async () => {
const result = () => firstValueFrom(service.registerClient$(userId));
await expect(result).rejects.toThrow(UserNotLoggedInError);
});
});
});
});
function createMockClient(): MockProxy<BitwardenClient> {
const client = mock<BitwardenClient>();
client.platform.mockReturnValue({
state: jest.fn().mockReturnValue(mock()),
load_flags: jest.fn().mockReturnValue(mock()),
free: mock(),
[Symbol.dispose]: jest.fn(),
});
return client;
}

View File

@@ -0,0 +1,196 @@
import {
combineLatest,
concatMap,
Observable,
shareReplay,
map,
distinctUntilChanged,
tap,
switchMap,
BehaviorSubject,
of,
takeWhile,
throwIfEmpty,
firstValueFrom,
} from "rxjs";
import { PasswordManagerClient, ClientSettings, TokenProvider } from "@bitwarden/sdk-internal";
import { ApiService } from "../../../abstractions/api.service";
import { AccountService } from "../../../auth/abstractions/account.service";
import { ConfigService } from "../../../platform/abstractions/config/config.service";
import { UserId } from "../../../types/guid";
import { Environment, EnvironmentService } from "../../abstractions/environment.service";
import { PlatformUtilsService } from "../../abstractions/platform-utils.service";
import { RegisterSdkService } from "../../abstractions/sdk/register-sdk.service";
import { SdkClientFactory } from "../../abstractions/sdk/sdk-client-factory";
import { SdkLoadService } from "../../abstractions/sdk/sdk-load.service";
import { toSdkDevice, UserNotLoggedInError } from "../../abstractions/sdk/sdk.service";
import { Rc } from "../../misc/reference-counting/rc";
import { StateProvider } from "../../state";
import { initializeState } from "./client-managed-state";
// A symbol that represents an overridden client that is explicitly set to undefined,
// blocking the creation of an internal client for that user.
const UnsetClient = Symbol("UnsetClient");
/**
* A token provider that exposes the access token to the SDK.
*/
class JsTokenProvider implements TokenProvider {
constructor(
private apiService: ApiService,
private userId?: UserId,
) {}
async get_access_token(): Promise<string | undefined> {
if (this.userId == null) {
return undefined;
}
return await this.apiService.getActiveBearerToken(this.userId);
}
}
export class DefaultRegisterSdkService implements RegisterSdkService {
private sdkClientOverrides = new BehaviorSubject<{
[userId: UserId]: Rc<PasswordManagerClient> | typeof UnsetClient;
}>({});
private sdkClientCache = new Map<UserId, Observable<Rc<PasswordManagerClient>>>();
client$ = this.environmentService.environment$.pipe(
concatMap(async (env) => {
await SdkLoadService.Ready;
const settings = this.toSettings(env);
const client = await this.sdkClientFactory.createSdkClient(
new JsTokenProvider(this.apiService),
settings,
);
await this.loadFeatureFlags(client);
return client;
}),
shareReplay({ refCount: true, bufferSize: 1 }),
);
constructor(
private sdkClientFactory: SdkClientFactory,
private environmentService: EnvironmentService,
private platformUtilsService: PlatformUtilsService,
private accountService: AccountService,
private apiService: ApiService,
private stateProvider: StateProvider,
private configService: ConfigService,
private userAgent: string | null = null,
) {}
registerClient$(userId: UserId): Observable<Rc<PasswordManagerClient>> {
return this.sdkClientOverrides.pipe(
takeWhile((clients) => clients[userId] !== UnsetClient, false),
map((clients) => {
if (clients[userId] === UnsetClient) {
throw new Error("Encountered UnsetClient even though it should have been filtered out");
}
return clients[userId] as Rc<PasswordManagerClient>;
}),
distinctUntilChanged(),
switchMap((clientOverride) => {
if (clientOverride) {
return of(clientOverride);
}
return this.internalClient$(userId);
}),
takeWhile((client) => client !== undefined, false),
throwIfEmpty(() => new UserNotLoggedInError(userId)),
);
}
/**
* This method is used to create a client for a specific user by using the existing state of the application.
* This client is token-only and does not initialize any encryption keys.
* @param userId The user id for which to create the client
* @returns An observable that emits the client for the user
*/
private internalClient$(userId: UserId): Observable<Rc<PasswordManagerClient>> {
const cached = this.sdkClientCache.get(userId);
if (cached !== undefined) {
return cached;
}
const account$ = this.accountService.accounts$.pipe(
map((accounts) => accounts[userId]),
distinctUntilChanged(),
);
const client$ = combineLatest([
this.environmentService.getEnvironment$(userId),
account$,
SdkLoadService.Ready, // Makes sure we wait (once) for the SDK to be loaded
]).pipe(
// switchMap is required to allow the clean-up logic to be executed when `combineLatest` emits a new value.
switchMap(([env, account]) => {
// Create our own observable to be able to implement clean-up logic
return new Observable<Rc<PasswordManagerClient>>((subscriber) => {
const createAndInitializeClient = async () => {
if (env == null || account == null) {
return undefined;
}
const settings = this.toSettings(env);
const client = await this.sdkClientFactory.createSdkClient(
new JsTokenProvider(this.apiService, userId),
settings,
);
// Initialize the SDK managed database and the client managed repositories.
await initializeState(userId, client.platform().state(), this.stateProvider);
await this.loadFeatureFlags(client);
return client;
};
let client: Rc<PasswordManagerClient> | undefined;
createAndInitializeClient()
.then((c) => {
client = c === undefined ? undefined : new Rc(c);
subscriber.next(client);
})
.catch((e) => {
subscriber.error(e);
});
return () => client?.markForDisposal();
});
}),
tap({ finalize: () => this.sdkClientCache.delete(userId) }),
shareReplay({ refCount: true, bufferSize: 1 }),
);
this.sdkClientCache.set(userId, client$);
return client$;
}
private async loadFeatureFlags(client: PasswordManagerClient) {
const serverConfig = await firstValueFrom(this.configService.serverConfig$);
const featureFlagMap = new Map(
Object.entries(serverConfig?.featureStates ?? {})
.filter(([, value]) => typeof value === "boolean") // The SDK only supports boolean feature flags at this time
.map(([key, value]) => [key, value] as [string, boolean]),
);
client.platform().load_flags(featureFlagMap);
}
private toSettings(env: Environment): ClientSettings {
return {
apiUrl: env.getApiUrl(),
identityUrl: env.getIdentityUrl(),
deviceType: toSdkDevice(this.platformUtilsService.getDevice()),
userAgent: this.userAgent ?? navigator.userAgent,
};
}
}

View File

@@ -1,3 +1,4 @@
export * from "./a11y-title.directive";
export * from "./aria-disabled-click-capture.service";
export * from "./aria-disable.directive";
export * from "./router-focus-manager.service";

View File

@@ -0,0 +1,65 @@
import { inject, Injectable } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { NavigationEnd, Router } from "@angular/router";
import { skip, filter, map, combineLatestWith, tap } from "rxjs";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@Injectable({ providedIn: "root" })
export class RouterFocusManagerService {
private router = inject(Router);
private configService = inject(ConfigService);
/**
* Handles SPA route focus management. SPA apps don't automatically notify screenreader
* users that navigation has occured or move the user's focus to the content they are
* navigating to, so we need to do it.
*
* By default, we focus the `main` after an internal route navigation.
*
* Consumers can opt out of the passing the following to the `info` input:
* `<a [routerLink]="route()" [info]="{ focusMainAfterNav: false }"></a>`
*
* Or, consumers can use the autofocus directive on an applicable interactive element.
* The autofocus directive will take precedence over this route focus pipeline.
*
* Example of where you might want to manually opt out:
* - Tab component causes a route navigation, but the tab content should be focused,
* not the whole `main`
*
* Note that router events that cause a fully new page to load (like switching between
* products) will not follow this pipeline. Instead, those will automatically bring
* focus to the top of the html document as if it were a full page load. So those links
* do not need to manually opt out of this pipeline.
*/
start$ = this.router.events.pipe(
takeUntilDestroyed(),
filter((navEvent) => navEvent instanceof NavigationEnd),
/**
* On first page load, we do not want to skip the user over the navigation content,
* so we opt out of the default focus management behavior.
*/
skip(1),
combineLatestWith(this.configService.getFeatureFlag$(FeatureFlag.RouterFocusManagement)),
filter(([_navEvent, flagEnabled]) => flagEnabled),
map(() => {
const currentNavData = this.router.getCurrentNavigation()?.extras;
const info = currentNavData?.info as { focusMainAfterNav?: boolean } | undefined;
return info;
}),
filter((currentNavInfo) => {
return currentNavInfo === undefined ? true : currentNavInfo?.focusMainAfterNav !== false;
}),
tap(() => {
const mainEl = document.querySelector<HTMLElement>("main");
if (mainEl) {
mainEl.focus();
}
}),
);
}

View File

@@ -75,13 +75,19 @@
@let isScrollable = isScrollable$ | async;
@let showFooterBorder =
(!bodyHasScrolledFrom().top && isScrollable) || bodyHasScrolledFrom().bottom;
<div
class="tw-border-0 tw-border-t tw-border-solid"
[ngClass]="{
'tw-border-transparent': !showFooterBorder,
'tw-border-secondary-100': showFooterBorder,
}"
data-chromatic="ignore"
></div>
<footer
class="tw-flex tw-flex-row tw-items-center tw-gap-2 tw-border-0 tw-border-t tw-border-solid tw-bg-background tw-py-5 tw-ps-6 tw-pe-4"
class="tw-flex tw-flex-row tw-items-center tw-gap-2 tw-bg-background tw-py-5 tw-ps-6 tw-pe-4"
[ngClass]="{
'tw-px-6 tw-py-4': isDrawer,
'tw-p-4 has-[[biticonbutton]]:tw-pe-2': !isDrawer,
'tw-border-transparent': !showFooterBorder,
'tw-border-secondary-100': showFooterBorder,
}"
>
<ng-content select="[bitDialogFooter]"></ng-content>

View File

@@ -5,6 +5,7 @@
[routerLinkActiveOptions]="routerLinkMatchOptions"
#rla="routerLinkActive"
[active]="rla.isActive"
[info]="{ focusMainAfterNav: false }"
[disabled]="disabled"
[attr.aria-disabled]="disabled"
ariaCurrentWhenActive="page"

8
package-lock.json generated
View File

@@ -167,7 +167,7 @@
"process": "0.11.10",
"remark-gfm": "4.0.1",
"rimraf": "6.0.1",
"sass": "1.94.1",
"sass": "1.94.2",
"sass-loader": "16.0.6",
"storybook": "8.6.12",
"style-loader": "4.0.0",
@@ -36313,9 +36313,9 @@
}
},
"node_modules/sass": {
"version": "1.94.1",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.94.1.tgz",
"integrity": "sha512-/YVm5FRQaRlr3oNh2LLFYne1PdPlRZGyKnHh1sLleOqLcohTR4eUUvBjBIqkl1fEXd1MGOHgzJGJh+LgTtV4KQ==",
"version": "1.94.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.94.2.tgz",
"integrity": "sha512-N+7WK20/wOr7CzA2snJcUSSNTCzeCGUTFY3OgeQP3mZ1aj9NMQ0mSTXwlrnd89j33zzQJGqIN52GIOmYrfq46A==",
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.0",

View File

@@ -130,7 +130,7 @@
"process": "0.11.10",
"remark-gfm": "4.0.1",
"rimraf": "6.0.1",
"sass": "1.94.1",
"sass": "1.94.2",
"sass-loader": "16.0.6",
"storybook": "8.6.12",
"style-loader": "4.0.0",