1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-27 01:53:23 +00:00

Merge branch 'main' into PM-30922-Client-changes-to-encrypt-send-access-email-list

This commit is contained in:
John Harrington
2026-01-22 14:19:00 -07:00
committed by GitHub
73 changed files with 2532 additions and 496 deletions

View File

@@ -25,7 +25,6 @@
<div
class="tw-size-full tw-styled-scrollbar"
data-testid="popup-layout-scroll-region"
(scroll)="handleScroll($event)"
[ngClass]="{
'!tw-overflow-hidden': hideOverflow(),
'tw-overflow-y-auto': !hideOverflow(),

View File

@@ -3,13 +3,17 @@ import {
booleanAttribute,
ChangeDetectionStrategy,
Component,
DestroyRef,
ElementRef,
inject,
input,
signal,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { filter, switchMap, fromEvent, startWith, map } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ScrollLayoutHostDirective } from "@bitwarden/components";
import { ScrollLayoutHostDirective, ScrollLayoutService } from "@bitwarden/components";
@Component({
selector: "popup-page",
@@ -22,6 +26,8 @@ import { ScrollLayoutHostDirective } from "@bitwarden/components";
})
export class PopupPageComponent {
protected i18nService = inject(I18nService);
private scrollLayout = inject(ScrollLayoutService);
private destroyRef = inject(DestroyRef);
readonly loading = input<boolean>(false);
@@ -33,10 +39,21 @@ export class PopupPageComponent {
protected readonly scrolled = signal(false);
isScrolled = this.scrolled.asReadonly();
constructor() {
this.scrollLayout.scrollableRef$
.pipe(
filter((ref): ref is ElementRef<HTMLElement> => ref != null),
switchMap((ref) =>
fromEvent(ref.nativeElement, "scroll").pipe(
startWith(null),
map(() => ref.nativeElement.scrollTop !== 0),
),
),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((isScrolled) => this.scrolled.set(isScrolled));
}
/** Accessible loading label for the spinner. Defaults to "loading" */
readonly loadingText = input<string | undefined>(this.i18nService.t("loading"));
handleScroll(event: Event) {
this.scrolled.set((event.currentTarget as HTMLElement).scrollTop !== 0);
}
}

View File

@@ -37,7 +37,7 @@ export class PopupSizeService {
/** Begin listening for state changes */
async init() {
this.width$.subscribe((width: PopupWidthOption) => {
PopupSizeService.setStyle(width);
void PopupSizeService.setStyle(width);
localStorage.setItem(PopupSizeService.LocalStorageKey, width);
});
}
@@ -77,8 +77,9 @@ export class PopupSizeService {
}
}
private static setStyle(width: PopupWidthOption) {
if (!BrowserPopupUtils.inPopup(window)) {
private static async setStyle(width: PopupWidthOption) {
const isInTab = await BrowserPopupUtils.isInTab();
if (!BrowserPopupUtils.inPopup(window) || isInTab) {
return;
}
const pxWidth = PopupWidthOptions[width] ?? PopupWidthOptions.default;
@@ -91,6 +92,6 @@ export class PopupSizeService {
**/
static initBodyWidthFromLocalStorage() {
const storedValue = localStorage.getItem(PopupSizeService.LocalStorageKey);
this.setStyle(storedValue as any);
void this.setStyle(storedValue as any);
}
}

View File

@@ -27,8 +27,12 @@ import {
WINDOW,
} from "@bitwarden/angular/services/injection-tokens";
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
import { AUTOFILL_NUDGE_SERVICE } from "@bitwarden/angular/vault";
import { SingleNudgeService } from "@bitwarden/angular/vault/services/default-single-nudge.service";
import {
AUTOFILL_NUDGE_SERVICE,
AUTO_CONFIRM_NUDGE_SERVICE,
AutoConfirmNudgeService,
} from "@bitwarden/angular/vault";
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
import {
LoginComponentService,
TwoFactorAuthComponentService,
@@ -786,9 +790,14 @@ const safeProviders: SafeProvider[] = [
],
}),
safeProvider({
provide: AUTOFILL_NUDGE_SERVICE as SafeInjectionToken<SingleNudgeService>,
provide: AUTOFILL_NUDGE_SERVICE as SafeInjectionToken<BrowserAutofillNudgeService>,
useClass: BrowserAutofillNudgeService,
deps: [],
deps: [StateProvider, VaultProfileService, LogService],
}),
safeProvider({
provide: AUTO_CONFIRM_NUDGE_SERVICE as SafeInjectionToken<AutoConfirmNudgeService>,
useClass: AutoConfirmNudgeService,
deps: [StateProvider, AutomaticUserConfirmationService],
}),
];

View File

@@ -1,4 +1,3 @@
import { CdkVirtualScrollableElement } from "@angular/cdk/scrolling";
import { ChangeDetectionStrategy, Component, input, NO_ERRORS_SCHEMA } from "@angular/core";
import { TestBed, fakeAsync, flush, tick } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
@@ -394,21 +393,28 @@ describe("VaultV2Component", () => {
expect(values[values.length - 1]).toBe(false);
});
it("ngAfterViewInit waits for allFilters$ then starts scroll position service", fakeAsync(() => {
it("passes popup-page scroll region element to scroll position service", fakeAsync(() => {
const fixture = TestBed.createComponent(VaultV2Component);
const component = fixture.componentInstance;
const readySubject$ = component["readySubject"] as unknown as BehaviorSubject<boolean>;
const itemsLoading$ = itemsSvc.loading$ as unknown as BehaviorSubject<boolean>;
const allFilters$ = filtersSvc.allFilters$ as unknown as Subject<any>;
(component as any).virtualScrollElement = {} as CdkVirtualScrollableElement;
component.ngAfterViewInit();
expect(scrollSvc.start).not.toHaveBeenCalled();
allFilters$.next({ any: true });
fixture.detectChanges();
tick();
expect(scrollSvc.start).toHaveBeenCalledTimes(1);
expect(scrollSvc.start).toHaveBeenCalledWith((component as any).virtualScrollElement);
const scrollRegion = fixture.nativeElement.querySelector(
'[data-testid="popup-layout-scroll-region"]',
) as HTMLElement;
flush();
// Unblock loading
itemsLoading$.next(false);
readySubject$.next(true);
allFilters$.next({});
tick();
expect(scrollSvc.start).toHaveBeenCalledWith(scrollRegion);
}));
it("showPremiumDialog opens PremiumUpgradeDialogComponent", () => {

View File

@@ -1,7 +1,7 @@
import { LiveAnnouncer } from "@angular/cdk/a11y";
import { CdkVirtualScrollableElement, ScrollingModule } from "@angular/cdk/scrolling";
import { ScrollingModule } from "@angular/cdk/scrolling";
import { CommonModule } from "@angular/common";
import { AfterViewInit, Component, DestroyRef, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { Component, DestroyRef, effect, inject, OnDestroy, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Router, RouterModule } from "@angular/router";
import {
@@ -47,6 +47,7 @@ import {
ButtonModule,
DialogService,
NoItemsModule,
ScrollLayoutService,
ToastService,
TypographyModule,
} from "@bitwarden/components";
@@ -119,11 +120,7 @@ type VaultState = UnionOfValues<typeof VaultState>;
],
providers: [{ provide: VaultItemsTransferService, useClass: DefaultVaultItemsTransferService }],
})
export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild(CdkVirtualScrollableElement) virtualScrollElement?: CdkVirtualScrollableElement;
export class VaultV2Component implements OnInit, OnDestroy {
NudgeType = NudgeType;
cipherType = CipherType;
private activeUserId$ = this.accountService.activeAccount$.pipe(getUserId);
@@ -308,16 +305,21 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
});
}
ngAfterViewInit(): void {
if (this.virtualScrollElement) {
// The filters component can cause the size of the virtual scroll element to change,
// which can cause the scroll position to be land in the wrong spot. To fix this,
// wait until all filters are populated before restoring the scroll position.
this.allFilters$.pipe(take(1), takeUntilDestroyed(this.destroyRef)).subscribe(() => {
this.vaultScrollPositionService.start(this.virtualScrollElement!);
private readonly scrollLayout = inject(ScrollLayoutService);
private readonly _scrollPositionEffect = effect((onCleanup) => {
const sub = combineLatest([this.scrollLayout.scrollableRef$, this.allFilters$, this.loading$])
.pipe(
filter(([ref, _filters, loading]) => !!ref && !loading),
take(1),
takeUntilDestroyed(this.destroyRef),
)
.subscribe(([ref]) => {
this.vaultScrollPositionService.start(ref!.nativeElement);
});
}
}
onCleanup(() => sub.unsubscribe());
});
async ngOnInit() {
this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));

View File

@@ -1,4 +1,3 @@
import { CdkVirtualScrollableElement } from "@angular/cdk/scrolling";
import { fakeAsync, TestBed, tick } from "@angular/core/testing";
import { NavigationEnd, Router } from "@angular/router";
import { Subject, Subscription } from "rxjs";
@@ -66,21 +65,18 @@ describe("VaultPopupScrollPositionService", () => {
});
describe("start", () => {
const elementScrolled$ = new Subject();
const focus = jest.fn();
const nativeElement = {
scrollTop: 0,
querySelector: jest.fn(() => ({ focus })),
addEventListener: jest.fn(),
style: {
visibility: "",
},
};
const virtualElement = {
elementScrolled: () => elementScrolled$,
getElementRef: () => ({ nativeElement }),
scrollTo: jest.fn(),
} as unknown as CdkVirtualScrollableElement;
let scrollElement: HTMLElement;
beforeEach(() => {
scrollElement = document.createElement("div");
(scrollElement as any).scrollTo = jest.fn(function scrollTo(opts: { top?: number }) {
if (opts?.top != null) {
(scrollElement as any).scrollTop = opts.top;
}
});
(scrollElement as any).scrollTop = 0;
});
afterEach(() => {
// remove the actual subscription created by `.subscribe`
@@ -89,47 +85,55 @@ describe("VaultPopupScrollPositionService", () => {
describe("initial scroll position", () => {
beforeEach(() => {
(virtualElement.scrollTo as jest.Mock).mockClear();
nativeElement.querySelector.mockClear();
((scrollElement as any).scrollTo as jest.Mock).mockClear();
});
it("does not scroll when `scrollPosition` is null", () => {
service["scrollPosition"] = null;
service.start(virtualElement);
service.start(scrollElement);
expect(virtualElement.scrollTo).not.toHaveBeenCalled();
expect((scrollElement as any).scrollTo).not.toHaveBeenCalled();
});
it("scrolls the virtual element to `scrollPosition`", fakeAsync(() => {
it("scrolls the element to `scrollPosition` (async via setTimeout)", fakeAsync(() => {
service["scrollPosition"] = 500;
nativeElement.scrollTop = 500;
service.start(virtualElement);
service.start(scrollElement);
tick();
expect(virtualElement.scrollTo).toHaveBeenCalledWith({ behavior: "instant", top: 500 });
expect((scrollElement as any).scrollTo).toHaveBeenCalledWith({
behavior: "instant",
top: 500,
});
expect((scrollElement as any).scrollTop).toBe(500);
}));
});
describe("scroll listener", () => {
it("unsubscribes from any existing subscription", () => {
service.start(virtualElement);
service.start(scrollElement);
expect(unsubscribe).toHaveBeenCalled();
});
it("subscribes to `elementScrolled`", fakeAsync(() => {
virtualElement.measureScrollOffset = jest.fn(() => 455);
it("stores scrollTop on subsequent scroll events (skips first)", fakeAsync(() => {
service["scrollPosition"] = null;
service.start(virtualElement);
service.start(scrollElement);
elementScrolled$.next(null); // first subscription is skipped by `skip(1)`
elementScrolled$.next(null);
// First scroll event is intentionally ignored (equivalent to old skip(1)).
(scrollElement as any).scrollTop = 111;
scrollElement.dispatchEvent(new Event("scroll"));
tick();
expect(service["scrollPosition"]).toBeNull();
// Second scroll event should persist.
(scrollElement as any).scrollTop = 455;
scrollElement.dispatchEvent(new Event("scroll"));
tick();
expect(virtualElement.measureScrollOffset).toHaveBeenCalledTimes(1);
expect(virtualElement.measureScrollOffset).toHaveBeenCalledWith("top");
expect(service["scrollPosition"]).toBe(455);
}));
});

View File

@@ -1,8 +1,7 @@
import { CdkVirtualScrollableElement } from "@angular/cdk/scrolling";
import { inject, Injectable } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { NavigationEnd, Router } from "@angular/router";
import { filter, skip, Subscription } from "rxjs";
import { filter, fromEvent, Subscription } from "rxjs";
@Injectable({
providedIn: "root",
@@ -31,24 +30,25 @@ export class VaultPopupScrollPositionService {
}
/** Scrolls the user to the stored scroll position and starts tracking scroll of the page. */
start(virtualScrollElement: CdkVirtualScrollableElement) {
start(scrollElement: HTMLElement) {
if (this.hasScrollPosition()) {
// Use `setTimeout` to scroll after rendering is complete
setTimeout(() => {
virtualScrollElement.scrollTo({ top: this.scrollPosition!, behavior: "instant" });
scrollElement.scrollTo({ top: this.scrollPosition!, behavior: "instant" });
});
}
this.scrollSubscription?.unsubscribe();
// Skip the first scroll event to avoid settings the scroll from the above `scrollTo` call
this.scrollSubscription = virtualScrollElement
?.elementScrolled()
.pipe(skip(1))
.subscribe(() => {
const offset = virtualScrollElement.measureScrollOffset("top");
this.scrollPosition = offset;
});
let skipped = false;
this.scrollSubscription = fromEvent(scrollElement, "scroll").subscribe(() => {
if (!skipped) {
skipped = true;
return;
}
this.scrollPosition = scrollElement.scrollTop;
});
}
/** Stops the scroll listener from updating the stored location. */

View File

@@ -19,5 +19,14 @@ windows-core = { workspace = true }
[dependencies]
anyhow = { workspace = true }
[target.'cfg(windows)'.dev-dependencies]
windows = { workspace = true, features = [
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_WindowsAndMessaging",
"Win32_Foundation",
"Win32_System_LibraryLoader",
"Win32_Graphics_Gdi",
] }
[lints]
workspace = true

View File

@@ -0,0 +1,324 @@
#![cfg(target_os = "windows")]
use std::{
sync::{Arc, Mutex},
thread,
time::Duration,
};
use autotype::{get_foreground_window_title, type_input};
use serial_test::serial;
use tracing::debug;
use windows::Win32::{
Foundation::{COLORREF, HINSTANCE, HMODULE, HWND, LPARAM, LRESULT, WPARAM},
Graphics::Gdi::{CreateSolidBrush, UpdateWindow, ValidateRect, COLOR_WINDOW},
System::LibraryLoader::{GetModuleHandleA, GetModuleHandleW},
UI::WindowsAndMessaging::*,
};
use windows_core::{s, w, Result, PCSTR, PCWSTR};
struct TestWindow {
handle: HWND,
capture: Option<InputCapture>,
}
impl Drop for TestWindow {
fn drop(&mut self) {
// Clean up the InputCapture pointer
unsafe {
let capture_ptr = GetWindowLongPtrW(self.handle, GWLP_USERDATA) as *mut InputCapture;
if !capture_ptr.is_null() {
let _ = Box::from_raw(capture_ptr);
}
CloseWindow(self.handle).expect("window handle should be closeable");
DestroyWindow(self.handle).expect("window handle should be destroyable");
}
}
}
// state to capture keyboard input
#[derive(Clone)]
struct InputCapture {
chars: Arc<Mutex<Vec<char>>>,
}
impl InputCapture {
fn new() -> Self {
Self {
chars: Arc::new(Mutex::new(Vec::new())),
}
}
fn get_chars(&self) -> Vec<char> {
self.chars
.lock()
.expect("mutex should not be poisoned")
.clone()
}
}
// Custom window procedure that captures input
unsafe extern "system" fn capture_input_proc(
handle: HWND,
msg: u32,
wparam: WPARAM,
lparam: LPARAM,
) -> LRESULT {
match msg {
WM_CREATE => {
// Store the InputCapture pointer in window data
let create_struct = lparam.0 as *const CREATESTRUCTW;
let capture_ptr = (*create_struct).lpCreateParams as *mut InputCapture;
SetWindowLongPtrW(handle, GWLP_USERDATA, capture_ptr as isize);
LRESULT(0)
}
WM_CHAR => {
// Get the InputCapture from window data
let capture_ptr = GetWindowLongPtrW(handle, GWLP_USERDATA) as *mut InputCapture;
if !capture_ptr.is_null() {
let capture = &*capture_ptr;
if let Some(ch) = char::from_u32(wparam.0 as u32) {
capture
.chars
.lock()
.expect("mutex should not be poisoned")
.push(ch);
}
}
LRESULT(0)
}
WM_DESTROY => {
PostQuitMessage(0);
LRESULT(0)
}
_ => DefWindowProcW(handle, msg, wparam, lparam),
}
}
// A pointer to the window procedure
type ProcType = unsafe extern "system" fn(HWND, u32, WPARAM, LPARAM) -> LRESULT;
// <https://learn.microsoft.com/en-us/windows/win32/api/winuser/nc-winuser-wndproc>
extern "system" fn show_window_proc(
handle: HWND, // the window handle
message: u32, // the system message
wparam: WPARAM, /* additional message information. The contents of the wParam parameter
* depend on the value of the message parameter. */
lparam: LPARAM, /* additional message information. The contents of the lParam parameter
* depend on the value of the message parameter. */
) -> LRESULT {
unsafe {
match message {
WM_PAINT => {
debug!("WM_PAINT");
let res = ValidateRect(Some(handle), None);
debug_assert!(res.ok().is_ok());
LRESULT(0)
}
WM_DESTROY => {
debug!("WM_DESTROY");
PostQuitMessage(0);
LRESULT(0)
}
_ => DefWindowProcA(handle, message, wparam, lparam),
}
}
}
impl TestWindow {
fn set_foreground(&self) -> Result<()> {
unsafe {
let _ = ShowWindow(self.handle, SW_SHOW);
let _ = SetForegroundWindow(self.handle);
let _ = UpdateWindow(self.handle);
let _ = SetForegroundWindow(self.handle);
}
std::thread::sleep(std::time::Duration::from_millis(100));
Ok(())
}
fn wait_for_input(&self, timeout_ms: u64) {
let start = std::time::Instant::now();
while start.elapsed().as_millis() < timeout_ms as u128 {
process_messages();
thread::sleep(Duration::from_millis(10));
}
}
}
fn process_messages() {
unsafe {
let mut msg = MSG::default();
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
let _ = TranslateMessage(&msg);
DispatchMessageW(&msg);
}
}
}
fn create_input_window(title: PCWSTR, proc_type: ProcType) -> Result<TestWindow> {
unsafe {
let instance = GetModuleHandleW(None).unwrap_or(HMODULE(std::ptr::null_mut()));
let instance: HINSTANCE = instance.into();
debug_assert!(!instance.is_invalid());
let window_class = w!("show_window");
// Register window class with our custom proc
let wc = WNDCLASSW {
lpfnWndProc: Some(proc_type),
hInstance: instance,
lpszClassName: window_class,
hbrBackground: CreateSolidBrush(COLORREF(
(COLOR_WINDOW.0 + 1).try_into().expect("i32 to fit in u32"),
)),
..Default::default()
};
let _atom = RegisterClassW(&wc);
let capture = InputCapture::new();
// Pass InputCapture as lpParam
let capture_ptr = Box::into_raw(Box::new(capture.clone()));
// Create window
// <https://learn.microsoft.com/en-us/windows/win32/learnwin32/creating-a-window>
let handle = CreateWindowExW(
WINDOW_EX_STYLE(0),
window_class,
title,
WS_OVERLAPPEDWINDOW | WS_VISIBLE,
CW_USEDEFAULT,
CW_USEDEFAULT,
400,
300,
None,
None,
Some(instance),
Some(capture_ptr as *const _),
)
.expect("window should be created");
// Process pending messages
process_messages();
thread::sleep(Duration::from_millis(100));
Ok(TestWindow {
handle,
capture: Some(capture),
})
}
}
fn create_title_window(title: PCSTR, proc_type: ProcType) -> Result<TestWindow> {
unsafe {
let instance = GetModuleHandleA(None)?;
let instance: HINSTANCE = instance.into();
debug_assert!(!instance.is_invalid());
let window_class = s!("input_window");
// Register window class with our custom proc
// <https://learn.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-wndclassa>
let wc = WNDCLASSA {
hCursor: LoadCursorW(None, IDC_ARROW)?,
hInstance: instance,
lpszClassName: window_class,
style: CS_HREDRAW | CS_VREDRAW,
lpfnWndProc: Some(proc_type),
..Default::default()
};
let _atom = RegisterClassA(&wc);
// Create window
// <https://learn.microsoft.com/en-us/windows/win32/learnwin32/creating-a-window>
let handle = CreateWindowExA(
WINDOW_EX_STYLE::default(),
window_class,
title,
WS_OVERLAPPEDWINDOW | WS_VISIBLE,
CW_USEDEFAULT,
CW_USEDEFAULT,
800,
600,
None,
None,
Some(instance),
None,
)
.expect("window should be created");
Ok(TestWindow {
handle,
capture: None,
})
}
}
#[serial]
#[test]
fn test_get_active_window_title_success() {
let title;
{
let window = create_title_window(s!("TITLE_FOOBAR"), show_window_proc).unwrap();
window.set_foreground().unwrap();
title = get_foreground_window_title().unwrap();
}
assert_eq!(title, "TITLE_FOOBAR\0".to_owned());
thread::sleep(Duration::from_millis(100));
}
#[serial]
#[test]
fn test_get_active_window_title_doesnt_fail_if_empty_title() {
let title;
{
let window = create_title_window(s!(""), show_window_proc).unwrap();
window.set_foreground().unwrap();
title = get_foreground_window_title();
}
assert_eq!(title.unwrap(), "".to_owned());
thread::sleep(Duration::from_millis(100));
}
#[serial]
#[test]
fn test_type_input_success() {
const TAB: u16 = 0x09;
let chars;
{
let window = create_input_window(w!("foo"), capture_input_proc).unwrap();
window.set_foreground().unwrap();
type_input(
&[
0x66, 0x6F, 0x6C, 0x6C, 0x6F, 0x77, 0x5F, 0x74, 0x68, 0x65, TAB, 0x77, 0x68, 0x69,
0x74, 0x65, 0x5F, 0x72, 0x61, 0x62, 0x62, 0x69, 0x74,
],
&["Control".to_owned(), "Alt".to_owned(), "B".to_owned()],
)
.unwrap();
// Wait for and process input messages
window.wait_for_input(250);
// Verify captured input
let capture = window.capture.as_ref().unwrap();
chars = capture.get_chars();
}
assert!(!chars.is_empty(), "No input captured");
let input_str = String::from_iter(chars.iter());
let input_str = input_str.replace("\t", "_");
assert_eq!(input_str, "follow_the_white_rabbit");
thread::sleep(Duration::from_millis(100));
}

View File

@@ -85,7 +85,8 @@
"signIgnore": [
"MacOS/desktop_proxy",
"MacOS/desktop_proxy.inherit",
"Contents/Plugins/autofill-extension.appex"
"Contents/Plugins/autofill-extension.appex",
"Frameworks/Electron Framework.framework/(Electron Framework|Libraries|Resources|Versions/Current)/.*"
],
"target": ["dmg", "zip"]
},

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>LTZ2PFU5D6.com.bitwarden.desktop</string>
</array>
<key>com.apple.developer.authentication-services.autofill-credential-provider</key>
<true/>
</dict>
</plist>

View File

@@ -256,7 +256,7 @@
isa = XCBuildConfiguration;
baseConfigurationReference = D83832AD2D67B9D0003FB9F8 /* ReleaseDeveloper.xcconfig */;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = "autofill-extension/autofill_extension.entitlements";
CODE_SIGN_ENTITLEMENTS = "autofill-extension/autofill_extension_enabled.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer";
CODE_SIGN_STYLE = Manual;
@@ -409,7 +409,7 @@
isa = XCBuildConfiguration;
baseConfigurationReference = D83832AB2D67B9AE003FB9F8 /* Debug.xcconfig */;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = "autofill-extension/autofill_extension.entitlements";
CODE_SIGN_ENTITLEMENTS = "autofill-extension/autofill_extension_enabled.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer";
CODE_SIGN_STYLE = Manual;

View File

@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2610"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E1DF713B2B342F6900F29026"
BuildableName = "autofill-extension.appex"
BlueprintName = "autofill-extension"
ReferencedContainer = "container:desktop.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
</LaunchAction>
<ProfileAction
buildConfiguration = "ReleaseAppStore"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E1DF713B2B342F6900F29026"
BuildableName = "autofill-extension.appex"
BlueprintName = "autofill-extension"
ReferencedContainer = "container:desktop.xcodeproj">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "ReleaseAppStore"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -46,7 +46,7 @@
"pack:mac:with-extension": "npm run clean:dist && npm run build:macos-extension:mac && electron-builder --mac --universal -p never",
"pack:mac:arm64": "npm run clean:dist && electron-builder --mac --arm64 -p never",
"pack:mac:mas": "npm run clean:dist && npm run build:macos-extension:mas && electron-builder --mac mas --universal -p never",
"pack:mac:masdev": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never",
"pack:mac:masdev": "npm run clean:dist && electron-builder --mac mas-dev --universal -p never -c.mac.identity=null -c.mas.identity=$CSC_NAME -c.mas.provisioningProfile=bitwarden_desktop_developer_id.provisionprofile -c.mas.entitlements=resources/entitlements.mas.autofill-enabled.plist",
"pack:local:mac": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never -c.mac.provisioningProfile=\"\" -c.mas.provisioningProfile=\"\"",
"pack:win": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never",
"pack:win:beta": "npm run clean:dist && electron-builder --config electron-builder.beta.json --win --x64 --arm64 --ia32 -p never",

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.application-identifier</key>
<string>LTZ2PFU5D6.com.bitwarden.desktop</string>
<key>com.apple.developer.team-identifier</key>
<string>LTZ2PFU5D6</string>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>LTZ2PFU5D6.com.bitwarden.desktop</string>
</array>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.device.usb</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.temporary-exception.files.home-relative-path.read-write</key>
<array>
<string>/Library/Application Support/Mozilla/NativeMessagingHosts/</string>
<string>/Library/Application Support/Google/Chrome/NativeMessagingHosts/</string>
<string>/Library/Application Support/Google/Chrome Beta/NativeMessagingHosts/</string>
<string>/Library/Application Support/Google/Chrome Dev/NativeMessagingHosts/</string>
<string>/Library/Application Support/Google/Chrome Canary/NativeMessagingHosts/</string>
<string>/Library/Application Support/Chromium/NativeMessagingHosts/</string>
<string>/Library/Application Support/Microsoft Edge/NativeMessagingHosts/</string>
<string>/Library/Application Support/Microsoft Edge Beta/NativeMessagingHosts/</string>
<string>/Library/Application Support/Microsoft Edge Dev/NativeMessagingHosts/</string>
<string>/Library/Application Support/Microsoft Edge Canary/NativeMessagingHosts/</string>
<string>/Library/Application Support/Vivaldi/NativeMessagingHosts/</string>
<string>/Library/Application Support/Zen/NativeMessagingHosts/</string>
<string>/Library/Application Support/net.imput.helium</string>
</array>
<key>com.apple.developer.authentication-services.autofill-credential-provider</key>
<true/>
</dict>
</plist>

View File

@@ -12,9 +12,13 @@ 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
# A bug in Electron 39 (which now enables Wayland by default) causes a crash on
# systems using Wayland with hardware acceleration. Platform decided to
# configure Electron to use X11 (with an opt-out) until the upstream bug is
# fixed. The follow-up task is https://bitwarden.atlassian.net/browse/PM-31080.
PARAMS="--enable-features=UseOzonePlatform,WaylandWindowDecorations --ozone-platform-hint=auto"
if [ "$USE_X11" = "true" ]; then
PARAMS=""
if [ "$USE_X11" != "false" ]; then
PARAMS="--ozone-platform=x11"
fi
$APP_PATH/bitwarden-app $PARAMS "$@"

View File

@@ -16,7 +16,9 @@ async function run(context) {
const appPath = `${context.appOutDir}/${appName}.app`;
const macBuild = context.electronPlatformName === "darwin";
const copySafariExtension = ["darwin", "mas"].includes(context.electronPlatformName);
const copyAutofillExtension = ["darwin"].includes(context.electronPlatformName); // Disabled for mas builds
const isMasDevBuild =
context.electronPlatformName === "mas" && context.targets.at(0)?.name === "mas-dev";
const copyAutofillExtension = ["darwin"].includes(context.electronPlatformName) || isMasDevBuild;
let shouldResign = false;
@@ -31,7 +33,6 @@ async function run(context) {
fse.mkdirSync(path.join(appPath, "Contents/PlugIns"));
}
fse.copySync(extensionPath, path.join(appPath, "Contents/PlugIns/autofill-extension.appex"));
shouldResign = true;
}
}

View File

@@ -1,4 +1,4 @@
<bit-layout class="!tw-h-full">
<bit-layout class="!tw-h-full" rounded>
<app-side-nav slot="side-nav">
<bit-nav-logo [openIcon]="logo" route="." [label]="'passwordManager' | i18n" />

View File

@@ -89,6 +89,7 @@ import {
PlatformUtilsService,
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 { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
@@ -432,6 +433,7 @@ const safeProviders: SafeProvider[] = [
InternalUserDecryptionOptionsServiceAbstraction,
MessagingServiceAbstraction,
AccountCryptographicStateService,
RegisterSdkService,
],
}),
safeProvider({

View File

@@ -1,8 +1,10 @@
import { MockProxy, mock } from "jest-mock-extended";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import { DefaultSetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/default-set-initial-password.service.implementation";
import {
InitializeJitPasswordCredentials,
SetInitialPasswordCredentials,
SetInitialPasswordService,
SetInitialPasswordUserType,
@@ -19,12 +21,14 @@ import { AccountCryptographicStateService } from "@bitwarden/common/key-manageme
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { MasterPasswordSalt } from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
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 { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management";
@@ -45,6 +49,7 @@ describe("DesktopSetInitialPasswordService", () => {
let userDecryptionOptionsService: MockProxy<InternalUserDecryptionOptionsServiceAbstraction>;
let messagingService: MockProxy<MessagingService>;
let accountCryptographicStateService: MockProxy<AccountCryptographicStateService>;
let registerSdkService: MockProxy<RegisterSdkService>;
beforeEach(() => {
apiService = mock<ApiService>();
@@ -59,6 +64,7 @@ describe("DesktopSetInitialPasswordService", () => {
userDecryptionOptionsService = mock<InternalUserDecryptionOptionsServiceAbstraction>();
messagingService = mock<MessagingService>();
accountCryptographicStateService = mock<AccountCryptographicStateService>();
registerSdkService = mock<RegisterSdkService>();
sut = new DesktopSetInitialPasswordService(
apiService,
@@ -73,6 +79,7 @@ describe("DesktopSetInitialPasswordService", () => {
userDecryptionOptionsService,
messagingService,
accountCryptographicStateService,
registerSdkService,
);
});
@@ -179,4 +186,36 @@ describe("DesktopSetInitialPasswordService", () => {
});
});
});
describe("initializePasswordJitPasswordUserV2Encryption(...)", () => {
it("should send a 'redrawMenu' message", async () => {
// Arrange
const credentials: InitializeJitPasswordCredentials = {
newPasswordHint: "newPasswordHint",
orgSsoIdentifier: "orgSsoIdentifier",
orgId: "orgId" as OrganizationId,
resetPasswordAutoEnroll: false,
newPassword: "newPassword123!",
salt: "user@example.com" as MasterPasswordSalt,
};
const userId = "userId" as UserId;
const superSpy = jest
.spyOn(
DefaultSetInitialPasswordService.prototype,
"initializePasswordJitPasswordUserV2Encryption",
)
.mockResolvedValue(undefined);
// Act
await sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
// Assert
expect(superSpy).toHaveBeenCalledWith(credentials, userId);
expect(messagingService.send).toHaveBeenCalledTimes(1);
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
superSpy.mockRestore();
});
});
});

View File

@@ -1,6 +1,7 @@
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import { DefaultSetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/default-set-initial-password.service.implementation";
import {
InitializeJitPasswordCredentials,
SetInitialPasswordCredentials,
SetInitialPasswordService,
SetInitialPasswordUserType,
@@ -14,6 +15,7 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
import { UserId } from "@bitwarden/common/types/guid";
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
@@ -34,6 +36,7 @@ export class DesktopSetInitialPasswordService
protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
private messagingService: MessagingService,
protected accountCryptographicStateService: AccountCryptographicStateService,
protected registerSdkService: RegisterSdkService,
) {
super(
apiService,
@@ -47,6 +50,7 @@ export class DesktopSetInitialPasswordService
organizationUserApiService,
userDecryptionOptionsService,
accountCryptographicStateService,
registerSdkService,
);
}
@@ -59,4 +63,13 @@ export class DesktopSetInitialPasswordService
this.messagingService.send("redrawMenu");
}
override async initializePasswordJitPasswordUserV2Encryption(
credentials: InitializeJitPasswordCredentials,
userId: UserId,
): Promise<void> {
await super.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
this.messagingService.send("redrawMenu");
}
}

View File

@@ -1,25 +1,93 @@
<!-- Header with Send title and New button -->
<app-header>
@if (!disableSend()) {
<tools-new-send-dropdown-v2 buttonType="primary" (addSend)="addSend($event)" />
}
</app-header>
<!-- Send List Component -->
<tools-send-list
[sends]="filteredSends()"
[loading]="loading()"
[disableSend]="disableSend()"
[listState]="listState()"
[searchText]="currentSearchText()"
(editSend)="onEditSend($event)"
(copySend)="onCopySend($event)"
(deleteSend)="onDeleteSend($event)"
(removePassword)="onRemovePassword($event)"
>
<tools-new-send-dropdown-v2
slot="empty-button"
[hideIcon]="true"
buttonType="primary"
(addSend)="addSend($event)"
/>
</tools-send-list>
@if (useDrawerEditMode()) {
<div class="tw-m-4 tw-p-4">
<!-- New dialog-based layout (feature flag enabled) -->
<!-- Header with Send title and New button -->
<app-header>
@if (!disableSend()) {
<tools-new-send-dropdown-v2 buttonType="primary" (addSend)="addSend($event)" />
}
</app-header>
<!-- Send List Component -->
<tools-send-list
[sends]="filteredSends()"
[loading]="loading()"
[disableSend]="disableSend()"
[listState]="listState()"
[searchText]="currentSearchText()"
(editSend)="onEditSend($event)"
(copySend)="onCopySend($event)"
(deleteSend)="onDeleteSend($event)"
(removePassword)="onRemovePassword($event)"
>
<tools-new-send-dropdown-v2
slot="empty-button"
[hideIcon]="true"
buttonType="primary"
(addSend)="addSend($event)"
/>
</tools-send-list>
</div>
} @else {
<!-- Old split-panel layout (feature flag disabled) -->
<div id="sends" class="vault">
<div class="send-items-panel tw-w-2/5">
<!-- Header with Send title and New button -->
<app-header class="tw-block tw-pt-6 tw-px-6">
@if (!disableSend()) {
<button type="button" bitButton buttonType="primary" (click)="addSendWithoutType()">
<i class="bwi bwi-plus" aria-hidden="true"></i>
{{ "new" | i18n }}
</button>
}
</app-header>
<div class="tw-mb-4 tw-px-6">
<!-- Send List Component -->
<tools-send-list
[sends]="filteredSends()"
[loading]="loading()"
[disableSend]="disableSend()"
[listState]="listState()"
[searchText]="currentSearchText()"
(editSend)="onEditSend($event)"
(copySend)="onCopySend($event)"
(deleteSend)="onDeleteSend($event)"
(removePassword)="onRemovePassword($event)"
>
<button
slot="empty-button"
type="button"
bitButton
buttonType="primary"
(click)="addSendWithoutType()"
>
{{ "newSend" | i18n }}
</button>
</tools-send-list>
</div>
</div>
<!-- Edit/Add panel (right side) -->
@if (action() == "add" || action() == "edit") {
<app-send-add-edit
id="addEdit"
class="details"
[sendId]="sendId()"
[type]="selectedSendType()"
(onSavedSend)="savedSend($event)"
(onCancelled)="closeEditPanel()"
(onDeletedSend)="closeEditPanel()"
></app-send-add-edit>
}
<!-- Bitwarden logo (shown when no send is selected) -->
@if (!action()) {
<div class="logo tw-w-1/2">
<div class="content">
<div class="inner-content">
<img class="logo-image" alt="Bitwarden" aria-hidden="true" />
</div>
</div>
</div>
}
</div>
}

View File

@@ -49,6 +49,7 @@ describe("SendV2Component", () => {
let sendApiService: MockProxy<SendApiService>;
let toastService: MockProxy<ToastService>;
let i18nService: MockProxy<I18nService>;
let configService: MockProxy<ConfigService>;
beforeEach(async () => {
sendService = mock<SendService>();
@@ -62,6 +63,10 @@ describe("SendV2Component", () => {
sendApiService = mock<SendApiService>();
toastService = mock<ToastService>();
i18nService = mock<I18nService>();
configService = mock<ConfigService>();
// Setup configService mock - feature flag returns true to test the new drawer mode
configService.getFeatureFlag$.mockReturnValue(of(true));
// Setup environmentService mock
environmentService.getEnvironment.mockResolvedValue({
@@ -117,7 +122,7 @@ describe("SendV2Component", () => {
useValue: mock<BillingAccountProfileStateService>(),
},
{ provide: MessagingService, useValue: mock<MessagingService>() },
{ provide: ConfigService, useValue: mock<ConfigService>() },
{ provide: ConfigService, useValue: configService },
{
provide: ActivatedRoute,
useValue: {

View File

@@ -1,11 +1,13 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
effect,
inject,
signal,
viewChild,
} from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { combineLatest, map, switchMap, lastValueFrom } from "rxjs";
@@ -15,6 +17,8 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -36,12 +40,27 @@ import {
import { DesktopPremiumUpgradePromptService } from "../../../services/desktop-premium-upgrade-prompt.service";
import { DesktopHeaderComponent } from "../../layout/header";
import { AddEditComponent } from "../send/add-edit.component";
const Action = Object.freeze({
/** No action is currently active. */
None: "",
/** The user is adding a new Send. */
Add: "add",
/** The user is editing an existing Send. */
Edit: "edit",
} as const);
type Action = (typeof Action)[keyof typeof Action];
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-send-v2",
imports: [
JslibModule,
ButtonModule,
AddEditComponent,
SendListComponent,
NewSendDropdownV2Component,
DesktopHeaderComponent,
@@ -54,13 +73,19 @@ import { DesktopHeaderComponent } from "../../layout/header";
},
],
templateUrl: "./send-v2.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SendV2Component {
protected readonly addEditComponent = viewChild(AddEditComponent);
protected readonly sendId = signal<string | null>(null);
protected readonly action = signal<Action>(Action.None);
private readonly selectedSendTypeOverride = signal<SendType | undefined>(undefined);
private sendFormConfigService = inject(DefaultSendFormConfigService);
private sendItemsService = inject(SendItemsService);
private policyService = inject(PolicyService);
private accountService = inject(AccountService);
private configService = inject(ConfigService);
private i18nService = inject(I18nService);
private platformUtilsService = inject(PlatformUtilsService);
private environmentService = inject(EnvironmentService);
@@ -70,6 +95,11 @@ export class SendV2Component {
private logService = inject(LogService);
private cdr = inject(ChangeDetectorRef);
protected readonly useDrawerEditMode = toSignal(
this.configService.getFeatureFlag$(FeatureFlag.DesktopUiMigrationMilestone2),
{ initialValue: false },
);
protected readonly filteredSends = toSignal(this.sendItemsService.filteredAndSortedSends$, {
initialValue: [],
});
@@ -119,28 +149,79 @@ export class SendV2Component {
});
}
protected readonly selectedSendType = computed(() => {
const action = this.action();
const typeOverride = this.selectedSendTypeOverride();
if (action === Action.Add && typeOverride !== undefined) {
return typeOverride;
}
const sendId = this.sendId();
return this.filteredSends().find((s) => s.id === sendId)?.type;
});
protected async addSend(type: SendType): Promise<void> {
const formConfig = await this.sendFormConfigService.buildConfig("add", undefined, type);
if (this.useDrawerEditMode()) {
const formConfig = await this.sendFormConfigService.buildConfig("add", undefined, type);
const dialogRef = SendAddEditDialogComponent.openDrawer(this.dialogService, {
formConfig,
});
const dialogRef = SendAddEditDialogComponent.openDrawer(this.dialogService, {
formConfig,
});
await lastValueFrom(dialogRef.closed);
await lastValueFrom(dialogRef.closed);
} else {
this.action.set(Action.Add);
this.sendId.set(null);
this.selectedSendTypeOverride.set(type);
const component = this.addEditComponent();
if (component) {
await component.resetAndLoad();
}
}
}
protected async selectSend(sendId: SendId): Promise<void> {
const formConfig = await this.sendFormConfigService.buildConfig("edit", sendId);
/** Used by old UI to add a send without specifying type (defaults to Text) */
protected async addSendWithoutType(): Promise<void> {
await this.addSend(SendType.Text);
}
const dialogRef = SendAddEditDialogComponent.openDrawer(this.dialogService, {
formConfig,
});
protected closeEditPanel(): void {
this.action.set(Action.None);
this.sendId.set(null);
this.selectedSendTypeOverride.set(undefined);
}
await lastValueFrom(dialogRef.closed);
protected async savedSend(send: SendView): Promise<void> {
await this.selectSend(send.id);
}
protected async selectSend(sendId: string): Promise<void> {
if (this.useDrawerEditMode()) {
const formConfig = await this.sendFormConfigService.buildConfig("edit", sendId as SendId);
const dialogRef = SendAddEditDialogComponent.openDrawer(this.dialogService, {
formConfig,
});
await lastValueFrom(dialogRef.closed);
} else {
if (sendId === this.sendId() && this.action() === Action.Edit) {
return;
}
this.action.set(Action.Edit);
this.sendId.set(sendId);
const component = this.addEditComponent();
if (component) {
component.sendId = sendId;
await component.refresh();
}
}
}
protected async onEditSend(send: SendView): Promise<void> {
await this.selectSend(send.id as SendId);
await this.selectSend(send.id);
}
protected async onCopySend(send: SendView): Promise<void> {
@@ -176,6 +257,11 @@ export class SendV2Component {
title: null,
message: this.i18nService.t("removedPassword"),
});
if (!this.useDrawerEditMode() && this.sendId() === send.id) {
this.sendId.set(null);
await this.selectSend(send.id);
}
} catch (e) {
this.logService.error(e);
}
@@ -199,5 +285,9 @@ export class SendV2Component {
title: null,
message: this.i18nService.t("deletedSend"),
});
if (!this.useDrawerEditMode()) {
this.closeEditPanel();
}
}
}

View File

@@ -10,6 +10,7 @@ import {
mergeMap,
switchMap,
takeUntil,
tap,
} from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -52,6 +53,8 @@ import type { NativeWindowObject } from "./desktop-fido2-user-interface.service"
export class DesktopAutofillService implements OnDestroy {
private destroy$ = new Subject<void>();
private registrationRequest: autofill.PasskeyRegistrationRequest;
private featureFlag?: FeatureFlag;
private isEnabled: boolean = false;
constructor(
private logService: LogService,
@@ -60,19 +63,26 @@ export class DesktopAutofillService implements OnDestroy {
private fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction<NativeWindowObject>,
private accountService: AccountService,
private authService: AuthService,
private platformUtilsService: PlatformUtilsService,
) {}
platformUtilsService: PlatformUtilsService,
) {
const deviceType = platformUtilsService.getDevice();
if (deviceType === DeviceType.MacOsDesktop) {
this.featureFlag = FeatureFlag.MacOsNativeCredentialSync;
}
}
async init() {
// Currently only supported for MacOS
if (this.platformUtilsService.getDevice() !== DeviceType.MacOsDesktop) {
this.isEnabled =
this.featureFlag && (await this.configService.getFeatureFlag(this.featureFlag));
if (!this.isEnabled) {
return;
}
this.configService
.getFeatureFlag$(FeatureFlag.MacOsNativeCredentialSync)
.getFeatureFlag$(this.featureFlag)
.pipe(
distinctUntilChanged(),
tap((enabled) => (this.isEnabled = enabled)),
filter((enabled) => enabled === true), // Only proceed if feature is enabled
switchMap(() => {
return combineLatest([
@@ -199,11 +209,11 @@ export class DesktopAutofillService implements OnDestroy {
listenIpc() {
ipc.autofill.listenPasskeyRegistration(async (clientId, sequenceNumber, request, callback) => {
if (!(await this.configService.getFeatureFlag(FeatureFlag.MacOsNativeCredentialSync))) {
if (!this.isEnabled) {
this.logService.debug(
"listenPasskeyRegistration: MacOsNativeCredentialSync feature flag is disabled",
`listenPasskeyRegistration: Native credential sync feature flag (${this.featureFlag}) is disabled`,
);
callback(new Error("MacOsNativeCredentialSync feature flag is disabled"), null);
callback(new Error("Native credential sync feature flag is disabled"), null);
return;
}
@@ -230,11 +240,11 @@ export class DesktopAutofillService implements OnDestroy {
ipc.autofill.listenPasskeyAssertionWithoutUserInterface(
async (clientId, sequenceNumber, request, callback) => {
if (!(await this.configService.getFeatureFlag(FeatureFlag.MacOsNativeCredentialSync))) {
if (!this.isEnabled) {
this.logService.debug(
"listenPasskeyAssertionWithoutUserInterface: MacOsNativeCredentialSync feature flag is disabled",
`listenPasskeyAssertionWithoutUserInterface: Native credential sync feature flag (${this.featureFlag}) is disabled`,
);
callback(new Error("MacOsNativeCredentialSync feature flag is disabled"), null);
callback(new Error("Native credential sync feature flag is disabled"), null);
return;
}
@@ -297,11 +307,11 @@ export class DesktopAutofillService implements OnDestroy {
);
ipc.autofill.listenPasskeyAssertion(async (clientId, sequenceNumber, request, callback) => {
if (!(await this.configService.getFeatureFlag(FeatureFlag.MacOsNativeCredentialSync))) {
if (!this.isEnabled) {
this.logService.debug(
"listenPasskeyAssertion: MacOsNativeCredentialSync feature flag is disabled",
`listenPasskeyAssertion: Native credential sync feature flag (${this.featureFlag}) is disabled`,
);
callback(new Error("MacOsNativeCredentialSync feature flag is disabled"), null);
callback(new Error("Native credential sync feature flag is disabled"), null);
return;
}
@@ -324,9 +334,9 @@ export class DesktopAutofillService implements OnDestroy {
// Listen for native status messages
ipc.autofill.listenNativeStatus(async (clientId, sequenceNumber, status) => {
if (!(await this.configService.getFeatureFlag(FeatureFlag.MacOsNativeCredentialSync))) {
if (!this.isEnabled) {
this.logService.debug(
"listenNativeStatus: MacOsNativeCredentialSync feature flag is disabled",
`listenNativeStatus: Native credential sync feature flag (${this.featureFlag}) is disabled`,
);
return;
}

View File

@@ -32,8 +32,8 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
import { CipherType } from "@bitwarden/common/vault/enums";
import { DialogService, ToastService } from "@bitwarden/components";
import { ApproveSshRequestComponent } from "../../platform/components/approve-ssh-request";
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
import { ApproveSshRequestComponent } from "../components/approve-ssh-request";
import { SshAgentPromptType } from "../models/ssh-agent-setting";
@Injectable({

View File

@@ -0,0 +1,29 @@
/**
* Desktop UI Migration
*
* These are temporary styles during the desktop ui migration.
**/
/**
* This removes any padding applied by the bit-layout to content.
* This should be revisited once the table is migrated, and again once drawers are migrated.
**/
bit-layout {
#main-content {
padding: 0 0 0 0;
}
}
/**
* Send list panel styling for send-v2 component
* Temporary during migration - width handled by tw-w-2/5
**/
.vault > .send-items-panel {
order: 2;
min-width: 200px;
border-right: 1px solid;
@include themify($themes) {
background-color: themed("backgroundColor");
border-right-color: themed("borderColor");
}
}

View File

@@ -15,5 +15,6 @@
@import "left-nav.scss";
@import "loading.scss";
@import "plugins.scss";
@import "migration.scss";
@import "../../../../libs/angular/src/scss/icons.scss";
@import "../../../../libs/components/src/multi-select/scss/bw.theme";

View File

@@ -36,15 +36,11 @@
>
<i class="bwi bwi-undo bwi-fw bwi-lg" aria-hidden="true"></i>
</button>
<button
type="button"
class="primary"
*ngIf="cipher.id && !cipher?.organizationId && !cipher.isDeleted && action === 'view'"
(click)="clone()"
appA11yTitle="{{ 'clone' | i18n }}"
>
<i class="bwi bwi-files bwi-fw bwi-lg" aria-hidden="true"></i>
</button>
@if (showCloneOption) {
<button type="button" class="primary" (click)="clone()" appA11yTitle="{{ 'clone' | i18n }}">
<i class="bwi bwi-files bwi-fw bwi-lg" aria-hidden="true"></i>
</button>
}
</ng-container>
<div class="right" *ngIf="hasFooterAction">
<button

View File

@@ -75,6 +75,7 @@ export class ItemFooterComponent implements OnInit, OnChanges {
protected showArchiveButton = false;
protected showUnarchiveButton = false;
protected userCanArchive = false;
constructor(
protected cipherService: CipherService,
@@ -134,6 +135,16 @@ export class ItemFooterComponent implements OnInit, OnChanges {
);
}
protected get showCloneOption() {
return (
this.cipher.id &&
!this.cipher?.organizationId &&
!this.cipher.isDeleted &&
this.action === "view" &&
(!this.cipher.isArchived || this.userCanArchive)
);
}
cancel() {
this.onCancel.emit(this.cipher);
}
@@ -241,6 +252,8 @@ export class ItemFooterComponent implements OnInit, OnChanges {
),
);
this.userCanArchive = userCanArchive;
this.showArchiveButton =
cipherCanBeArchived && userCanArchive && this.action === "view" && !this.cipher.isArchived;

View File

@@ -10,69 +10,79 @@
[organizationId]="organizationId"
>
</app-vault-items-v2>
<div class="details" *ngIf="!!action">
<app-vault-item-footer
id="footer"
#footer
[cipher]="cipher()"
[action]="action"
(onEdit)="editCipher($event)"
(onRestore)="restoreCipher()"
(onClone)="cloneCipher($event)"
(onDelete)="deleteCipher()"
(onCancel)="cancelCipher($event)"
(onArchiveToggle)="refreshCurrentCipher()"
[masterPasswordAlreadyPrompted]="cipherRepromptId === cipherId"
[submitButtonText]="submitButtonText()"
></app-vault-item-footer>
<div class="content">
<div class="inner-content">
<div class="box">
<app-cipher-view
*ngIf="action === 'view'"
[cipher]="cipher()"
[collections]="collections"
>
</app-cipher-view>
<vault-cipher-form
#vaultForm
*ngIf="action === 'add' || action === 'edit' || action === 'clone'"
formId="cipherForm"
[config]="config"
(cipherSaved)="savedCipher($event)"
[submitBtn]="footer?.submitBtn"
(formStatusChange$)="formStatusChanged($event)"
>
<bit-item slot="attachment-button">
<button
bit-item-content
type="button"
(click)="openAttachmentsDialog()"
[disabled]="formDisabled"
@if (!!action) {
<div class="details">
<app-vault-item-footer
id="footer"
#footer
[cipher]="cipher()"
[action]="action"
(onEdit)="editCipher($event)"
(onRestore)="restoreCipher()"
(onClone)="cloneCipher($event)"
(onDelete)="deleteCipher()"
(onCancel)="cancelCipher($event)"
(onArchiveToggle)="refreshCurrentCipher()"
[masterPasswordAlreadyPrompted]="cipherRepromptId === cipherId"
[submitButtonText]="submitButtonText()"
></app-vault-item-footer>
<div class="content">
<div class="inner-content">
<div class="box">
@if (action === "view") {
<app-cipher-view [cipher]="cipher()" [collections]="collections"> </app-cipher-view>
}
@if (action === "add" || action === "edit" || action === "clone") {
<vault-cipher-form
#vaultForm
formId="cipherForm"
[config]="config"
(cipherSaved)="savedCipher($event)"
[submitBtn]="footer?.submitBtn"
(formStatusChange$)="formStatusChanged($event)"
>
<div class="tw-flex tw-items-center tw-gap-2">
{{ "attachments" | i18n }}
<app-premium-badge></app-premium-badge>
</div>
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</button>
</bit-item>
</vault-cipher-form>
<bit-item slot="attachment-button">
<button
bit-item-content
type="button"
(click)="openAttachmentsDialog()"
[disabled]="formDisabled"
>
<div class="tw-flex tw-items-center tw-gap-2">
{{ "attachments" | i18n }}
<app-premium-badge></app-premium-badge>
</div>
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</button>
</bit-item>
</vault-cipher-form>
}
</div>
</div>
</div>
</div>
</div>
<div
id="logo"
class="logo"
*ngIf="action !== 'add' && action !== 'edit' && action !== 'view' && action !== 'clone'"
>
<div class="content">
<div class="inner-content">
<img class="logo-image" alt="Bitwarden" aria-hidden="true" />
}
@if (action !== "add" && action !== "edit" && action !== "view" && action !== "clone") {
<div id="logo" class="logo">
<div class="content">
<div class="inner-content">
@if (activeFilter.status === "archive" && !(hasArchivedCiphers$ | async)) {
<bit-no-items [icon]="itemTypesIcon">
<div slot="title">
{{ "noItemsInArchive" | i18n }}
</div>
<p slot="description" bitTypography="body2" class="tw-max-w-md tw-text-center">
{{ "noItemsInArchiveDesc" | i18n }}
</p>
</bit-no-items>
} @else {
<img class="logo-image" alt="Bitwarden" aria-hidden="true" />
}
</div>
</div>
</div>
</div>
}
<div class="left-nav">
<app-vault-filter
class="vault-filters"

View File

@@ -27,6 +27,7 @@ import { CollectionService } from "@bitwarden/admin-console/common";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
import { VaultViewPasswordHistoryService } from "@bitwarden/angular/services/view-password-history.service";
import { VaultFilter } from "@bitwarden/angular/vault/vault-filter/models/vault-filter.model";
import { ItemTypes } from "@bitwarden/assets/svg";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
@@ -69,6 +70,7 @@ import {
ToastService,
CopyClickListener,
COPY_CLICK_LISTENER,
NoItemsModule,
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import {
@@ -124,6 +126,7 @@ const BroadcasterSubscriptionId = "VaultComponent";
NavComponent,
VaultFilterModule,
VaultItemsV2Component,
NoItemsModule,
],
providers: [
{
@@ -203,6 +206,7 @@ export class VaultV2Component<C extends CipherViewLike>
collections: CollectionView[] | null = null;
config: CipherFormConfig | null = null;
readonly userHasPremium = signal<boolean>(false);
protected itemTypesIcon = ItemTypes;
/** Tracks the disabled status of the edit cipher form */
protected formDisabled: boolean = false;
@@ -221,6 +225,12 @@ export class VaultV2Component<C extends CipherViewLike>
: this.i18nService.t("save");
});
protected hasArchivedCiphers$ = this.userId$.pipe(
switchMap((userId) =>
this.cipherArchiveService.archivedCiphers$(userId).pipe(map((ciphers) => ciphers.length > 0)),
),
);
private componentIsDestroyed$ = new Subject<boolean>();
private allOrganizations: Organization[] = [];
private allCollections: CollectionView[] = [];

View File

@@ -2,6 +2,6 @@ export { PoliciesComponent } from "./policies.component";
export { ossPolicyEditRegister } from "./policy-edit-register";
export { BasePolicyEditDefinition, BasePolicyEditComponent } from "./base-policy-edit.component";
export { POLICY_EDIT_REGISTER } from "./policy-register-token";
export { AutoConfirmPolicyDialogComponent } from "./auto-confirm-edit-policy-dialog.component";
export { AutoConfirmPolicy } from "./policy-edit-definitions";
export { PolicyEditDialogResult } from "./policy-edit-dialog.component";
export * from "./policy-edit-dialogs";

View File

@@ -15,8 +15,8 @@ import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { SharedModule } from "../../../../shared";
import { AutoConfirmPolicyDialogComponent } from "../auto-confirm-edit-policy-dialog.component";
import { BasePolicyEditDefinition, BasePolicyEditComponent } from "../base-policy-edit.component";
import { AutoConfirmPolicyDialogComponent } from "../policy-edit-dialogs/auto-confirm-edit-policy-dialog.component";
export class AutoConfirmPolicy extends BasePolicyEditDefinition {
name = "autoConfirm";

View File

@@ -1,7 +1,10 @@
export { DisableSendPolicy } from "./disable-send.component";
export { DesktopAutotypeDefaultSettingPolicy } from "./autotype-policy.component";
export { MasterPasswordPolicy } from "./master-password.component";
export { OrganizationDataOwnershipPolicy } from "./organization-data-ownership.component";
export {
OrganizationDataOwnershipPolicy,
OrganizationDataOwnershipPolicyComponent,
} from "./organization-data-ownership.component";
export { PasswordGeneratorPolicy } from "./password-generator.component";
export { RemoveUnlockWithPinPolicy } from "./remove-unlock-with-pin.component";
export { RequireSsoPolicy } from "./require-sso.component";

View File

@@ -1,8 +1,57 @@
<bit-callout type="warning">
{{ "personalOwnershipExemption" | i18n }}
</bit-callout>
<p>
{{ "organizationDataOwnershipDescContent" | i18n }}
<a
bitLink
href="https://bitwarden.com/resources/credential-lifecycle-management/"
target="_blank"
>
{{ "organizationDataOwnershipContentAnchor" | i18n }}.
</a>
</p>
<bit-form-control>
<input type="checkbox" bitCheckbox [formControl]="enabled" id="enabled" />
<bit-label>{{ "turnOn" | i18n }}</bit-label>
</bit-form-control>
<ng-template #dialog>
<bit-simple-dialog background="alt">
<span bitDialogTitle>{{ "organizationDataOwnershipWarningTitle" | i18n }}</span>
<ng-container bitDialogContent>
<div class="tw-text-left tw-overflow-hidden">
{{ "organizationDataOwnershipWarningContentTop" | i18n }}
<div class="tw-flex tw-flex-col tw-p-2">
<ul class="tw-list-disc tw-pl-5 tw-space-y-2 tw-break-words tw-mb-0">
<li>
{{ "organizationDataOwnershipWarning1" | i18n }}
</li>
<li>
{{ "organizationDataOwnershipWarning2" | i18n }}
</li>
<li>
{{ "organizationDataOwnershipWarning3" | i18n }}
</li>
</ul>
</div>
{{ "organizationDataOwnershipWarningContentBottom" | i18n }}
<a
bitLink
href="https://bitwarden.com/resources/credential-lifecycle-management/"
target="_blank"
>
{{ "organizationDataOwnershipContentAnchor" | i18n }}.
</a>
</div>
</ng-container>
<ng-container bitDialogFooter>
<span class="tw-flex tw-gap-2">
<button bitButton buttonType="primary" [bitDialogClose]="true" type="submit">
{{ "continue" | i18n }}
</button>
<button bitButton buttonType="secondary" [bitDialogClose]="false" type="button">
{{ "cancel" | i18n }}
</button>
</span>
</ng-container>
</bit-simple-dialog>
</ng-template>

View File

@@ -1,22 +1,38 @@
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { of, Observable } from "rxjs";
import { ChangeDetectionStrategy, Component, OnInit, TemplateRef, ViewChild } from "@angular/core";
import { lastValueFrom, map, Observable } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrgKey } from "@bitwarden/common/types/key";
import { CenterPositionStrategy, DialogService } from "@bitwarden/components";
import { EncString } from "@bitwarden/sdk-internal";
import { SharedModule } from "../../../../shared";
import { BasePolicyEditDefinition, BasePolicyEditComponent } from "../base-policy-edit.component";
export interface VNextPolicyRequest {
policy: PolicyRequest;
metadata: {
defaultUserCollectionName: string;
};
}
export class OrganizationDataOwnershipPolicy extends BasePolicyEditDefinition {
name = "organizationDataOwnership";
description = "personalOwnershipPolicyDesc";
description = "organizationDataOwnershipDesc";
type = PolicyType.OrganizationDataOwnership;
component = OrganizationDataOwnershipPolicyComponent;
showDescription = false;
display$(organization: Organization, configService: ConfigService): Observable<boolean> {
// TODO Remove this entire component upon verifying that it can be deleted due to its sole reliance of the CreateDefaultLocation feature flag
return of(false);
override display$(organization: Organization, configService: ConfigService): Observable<boolean> {
return configService
.getFeatureFlag$(FeatureFlag.MigrateMyVaultToMyItems)
.pipe(map((enabled) => !enabled));
}
}
@@ -26,4 +42,61 @@ export class OrganizationDataOwnershipPolicy extends BasePolicyEditDefinition {
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OrganizationDataOwnershipPolicyComponent extends BasePolicyEditComponent {}
export class OrganizationDataOwnershipPolicyComponent
extends BasePolicyEditComponent
implements OnInit
{
constructor(
private dialogService: DialogService,
private i18nService: I18nService,
private encryptService: EncryptService,
) {
super();
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild("dialog", { static: true }) warningContent!: TemplateRef<unknown>;
override async confirm(): Promise<boolean> {
if (this.policyResponse?.enabled && !this.enabled.value) {
const dialogRef = this.dialogService.open(this.warningContent, {
positionStrategy: new CenterPositionStrategy(),
});
const result = await lastValueFrom(dialogRef.closed);
return Boolean(result);
}
return true;
}
async buildVNextRequest(orgKey: OrgKey): Promise<VNextPolicyRequest> {
if (!this.policy) {
throw new Error("Policy was not found");
}
const defaultUserCollectionName = await this.getEncryptedDefaultUserCollectionName(orgKey);
const request: VNextPolicyRequest = {
policy: {
enabled: this.enabled.value ?? false,
data: this.buildRequestData(),
},
metadata: {
defaultUserCollectionName,
},
};
return request;
}
private async getEncryptedDefaultUserCollectionName(orgKey: OrgKey): Promise<EncString> {
const defaultCollectionName = this.i18nService.t("myItems");
const encrypted = await this.encryptService.encryptString(defaultCollectionName, orgKey);
if (!encrypted.encryptedString) {
throw new Error("Encryption error");
}
return encrypted.encryptedString;
}
}

View File

@@ -1,57 +1,52 @@
<p>
{{ "organizationDataOwnershipDescContent" | i18n }}
<a
bitLink
href="https://bitwarden.com/resources/credential-lifecycle-management/"
target="_blank"
>
{{ "organizationDataOwnershipContentAnchor" | i18n }}.
</a>
</p>
<ng-container [ngTemplateOutlet]="steps[step()]()"></ng-container>
<bit-form-control>
<input type="checkbox" bitCheckbox [formControl]="enabled" id="enabled" />
<bit-label>{{ "turnOn" | i18n }}</bit-label>
</bit-form-control>
<ng-template #step0>
<p>
{{ "centralizeDataOwnershipDesc" | i18n }}
<a
bitLink
href="https://bitwarden.com/resources/credential-lifecycle-management/"
target="_blank"
>
{{ "centralizeDataOwnershipContentAnchor" | i18n }}
<i class="bwi bwi-external-link"></i>
</a>
</p>
<ng-template #dialog>
<bit-simple-dialog background="alt">
<span bitDialogTitle>{{ "organizationDataOwnershipWarningTitle" | i18n }}</span>
<ng-container bitDialogContent>
<div class="tw-text-left tw-overflow-hidden">
{{ "organizationDataOwnershipWarningContentTop" | i18n }}
<div class="tw-flex tw-flex-col tw-p-2">
<ul class="tw-list-disc tw-pl-5 tw-space-y-2 tw-break-words tw-mb-0">
<li>
{{ "organizationDataOwnershipWarning1" | i18n }}
</li>
<li>
{{ "organizationDataOwnershipWarning2" | i18n }}
</li>
<li>
{{ "organizationDataOwnershipWarning3" | i18n }}
</li>
</ul>
</div>
{{ "organizationDataOwnershipWarningContentBottom" | i18n }}
<a
bitLink
href="https://bitwarden.com/resources/credential-lifecycle-management/"
target="_blank"
>
{{ "organizationDataOwnershipContentAnchor" | i18n }}.
</a>
</div>
</ng-container>
<ng-container bitDialogFooter>
<span class="tw-flex tw-gap-2">
<button bitButton buttonType="primary" [bitDialogClose]="true" type="submit">
{{ "continue" | i18n }}
</button>
<button bitButton buttonType="secondary" [bitDialogClose]="false" type="button">
{{ "cancel" | i18n }}
</button>
</span>
</ng-container>
</bit-simple-dialog>
<div class="tw-text-left tw-overflow-hidden tw-mb-2">
<strong>{{ "benefits" | i18n }}:</strong>
<ul class="tw-pl-7 tw-space-y-2 tw-pt-2">
<li>
{{ "centralizeDataOwnershipBenefit1" | i18n }}
</li>
<li>
{{ "centralizeDataOwnershipBenefit2" | i18n }}
</li>
<li>
{{ "centralizeDataOwnershipBenefit3" | i18n }}
</li>
</ul>
</div>
<bit-form-control>
<input class="tw-mt-4" type="checkbox" bitCheckbox [formControl]="enabled" id="enabled" />
<bit-label>{{ "turnOn" | i18n }}</bit-label>
</bit-form-control>
</ng-template>
<ng-template #step1>
<div class="tw-flex tw-flex-col tw-gap-2 tw-overflow-hidden">
<span>
{{ "centralizeDataOwnershipWarningDesc" | i18n }}
</span>
<a
class="tw-mt-4"
bitLink
href="https://bitwarden.com/resources/credential-lifecycle-management/"
target="_blank"
>
{{ "centralizeDataOwnershipWarningLink" | i18n }}
<i class="bwi bwi-external-link"></i>
</a>
</div>
</ng-template>

View File

@@ -1,18 +1,30 @@
import { ChangeDetectionStrategy, Component, OnInit, TemplateRef, ViewChild } from "@angular/core";
import { lastValueFrom } from "rxjs";
import {
ChangeDetectionStrategy,
Component,
OnInit,
signal,
Signal,
TemplateRef,
viewChild,
WritableSignal,
} from "@angular/core";
import { Observable } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrgKey } from "@bitwarden/common/types/key";
import { CenterPositionStrategy, DialogService } from "@bitwarden/components";
import { EncString } from "@bitwarden/sdk-internal";
import { SharedModule } from "../../../../shared";
import { BasePolicyEditDefinition, BasePolicyEditComponent } from "../base-policy-edit.component";
import { OrganizationDataOwnershipPolicyDialogComponent } from "../policy-edit-dialogs";
interface VNextPolicyRequest {
export interface VNextPolicyRequest {
policy: PolicyRequest;
metadata: {
defaultUserCollectionName: string;
@@ -20,11 +32,17 @@ interface VNextPolicyRequest {
}
export class vNextOrganizationDataOwnershipPolicy extends BasePolicyEditDefinition {
name = "organizationDataOwnership";
description = "organizationDataOwnershipDesc";
name = "centralizeDataOwnership";
description = "centralizeDataOwnershipDesc";
type = PolicyType.OrganizationDataOwnership;
component = vNextOrganizationDataOwnershipPolicyComponent;
showDescription = false;
editDialogComponent = OrganizationDataOwnershipPolicyDialogComponent;
override display$(organization: Organization, configService: ConfigService): Observable<boolean> {
return configService.getFeatureFlag$(FeatureFlag.MigrateMyVaultToMyItems);
}
}
@Component({
@@ -38,27 +56,16 @@ export class vNextOrganizationDataOwnershipPolicyComponent
implements OnInit
{
constructor(
private dialogService: DialogService,
private i18nService: I18nService,
private encryptService: EncryptService,
) {
super();
}
private readonly policyForm: Signal<TemplateRef<any> | undefined> = viewChild("step0");
private readonly warningContent: Signal<TemplateRef<any> | undefined> = viewChild("step1");
protected readonly step: WritableSignal<number> = signal(0);
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild("dialog", { static: true }) warningContent!: TemplateRef<unknown>;
override async confirm(): Promise<boolean> {
if (this.policyResponse?.enabled && !this.enabled.value) {
const dialogRef = this.dialogService.open(this.warningContent, {
positionStrategy: new CenterPositionStrategy(),
});
const result = await lastValueFrom(dialogRef.closed);
return Boolean(result);
}
return true;
}
protected steps = [this.policyForm, this.warningContent];
async buildVNextRequest(orgKey: OrgKey): Promise<VNextPolicyRequest> {
if (!this.policy) {
@@ -90,4 +97,8 @@ export class vNextOrganizationDataOwnershipPolicyComponent
return encrypted.encryptedString;
}
setStep(step: number) {
this.step.set(step);
}
}

View File

@@ -16,6 +16,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import {
DIALOG_DATA,
DialogConfig,
@@ -28,7 +29,7 @@ import { KeyService } from "@bitwarden/key-management";
import { SharedModule } from "../../../shared";
import { BasePolicyEditDefinition, BasePolicyEditComponent } from "./base-policy-edit.component";
import { vNextOrganizationDataOwnershipPolicyComponent } from "./policy-edit-definitions/vnext-organization-data-ownership.component";
import { VNextPolicyRequest } from "./policy-edit-definitions/organization-data-ownership.component";
export type PolicyEditDialogData = {
/**
@@ -73,13 +74,24 @@ export class PolicyEditDialogComponent implements AfterViewInit {
private formBuilder: FormBuilder,
protected dialogRef: DialogRef<PolicyEditDialogResult>,
protected toastService: ToastService,
private keyService: KeyService,
protected keyService: KeyService,
) {}
get policy(): BasePolicyEditDefinition {
return this.data.policy;
}
/**
* Type guard to check if the policy component has the buildVNextRequest method.
*/
private hasVNextRequest(
component: BasePolicyEditComponent,
): component is BasePolicyEditComponent & {
buildVNextRequest: (orgKey: OrgKey) => Promise<VNextPolicyRequest>;
} {
return "buildVNextRequest" in component && typeof component.buildVNextRequest === "function";
}
/**
* Instantiates the child policy component and inserts it into the view.
*/
@@ -129,7 +141,7 @@ export class PolicyEditDialogComponent implements AfterViewInit {
}
try {
if (this.policyComponent instanceof vNextOrganizationDataOwnershipPolicyComponent) {
if (this.hasVNextRequest(this.policyComponent)) {
await this.handleVNextSubmission(this.policyComponent);
} else {
await this.handleStandardSubmission();
@@ -158,7 +170,9 @@ export class PolicyEditDialogComponent implements AfterViewInit {
}
private async handleVNextSubmission(
policyComponent: vNextOrganizationDataOwnershipPolicyComponent,
policyComponent: BasePolicyEditComponent & {
buildVNextRequest: (orgKey: OrgKey) => Promise<VNextPolicyRequest>;
},
): Promise<void> {
const orgKey = await firstValueFrom(
this.accountService.activeAccount$.pipe(
@@ -173,12 +187,12 @@ export class PolicyEditDialogComponent implements AfterViewInit {
throw new Error("No encryption key for this organization.");
}
const vNextRequest = await policyComponent.buildVNextRequest(orgKey);
const request = await policyComponent.buildVNextRequest(orgKey);
await this.policyApiService.putPolicyVNext(
this.data.organizationId,
this.data.policy.type,
vNextRequest,
request,
);
}
static open = (dialogService: DialogService, config: DialogConfig<PolicyEditDialogData>) => {

View File

@@ -41,20 +41,15 @@ import {
} from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { SharedModule } from "../../../shared";
import { AutoConfirmPolicyEditComponent } from "./policy-edit-definitions/auto-confirm-policy.component";
import { SharedModule } from "../../../../shared";
import { AutoConfirmPolicyEditComponent } from "../policy-edit-definitions/auto-confirm-policy.component";
import {
PolicyEditDialogComponent,
PolicyEditDialogData,
PolicyEditDialogResult,
} from "./policy-edit-dialog.component";
} from "../policy-edit-dialog.component";
export type MultiStepSubmit = {
sideEffect: () => Promise<void>;
footerContent: Signal<TemplateRef<unknown> | undefined>;
titleContent: Signal<TemplateRef<unknown> | undefined>;
};
import { MultiStepSubmit } from "./models";
export type AutoConfirmPolicyDialogData = PolicyEditDialogData & {
firstTimeDialog?: boolean;
@@ -202,6 +197,7 @@ export class AutoConfirmPolicyDialogComponent
}
const autoConfirmRequest = await this.policyComponent.buildRequest();
await this.policyApiService.putPolicy(
this.data.organizationId,
this.data.policy.type,
@@ -235,7 +231,7 @@ export class AutoConfirmPolicyDialogComponent
data: null,
};
await this.policyApiService.putPolicy(
await this.policyApiService.putPolicyVNext(
this.data.organizationId,
PolicyType.SingleOrg,
singleOrgRequest,
@@ -260,7 +256,10 @@ export class AutoConfirmPolicyDialogComponent
try {
const multiStepSubmit = await firstValueFrom(this.multiStepSubmit);
await multiStepSubmit[this.currentStep()].sideEffect();
const sideEffect = multiStepSubmit[this.currentStep()].sideEffect;
if (sideEffect) {
await sideEffect();
}
if (this.currentStep() === multiStepSubmit.length - 1) {
this.dialogRef.close("saved");

View File

@@ -0,0 +1,3 @@
export * from "./auto-confirm-edit-policy-dialog.component";
export * from "./organization-data-ownership-edit-policy-dialog.component";
export * from "./models";

View File

@@ -0,0 +1,7 @@
import { Signal, TemplateRef } from "@angular/core";
export type MultiStepSubmit = {
sideEffect?: () => Promise<void>;
footerContent: Signal<TemplateRef<unknown> | undefined>;
titleContent: Signal<TemplateRef<unknown> | undefined>;
};

View File

@@ -0,0 +1,72 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog [loading]="loading">
<ng-container bitDialogTitle>
@let title = multiStepSubmit()[currentStep()]?.titleContent();
@if (title) {
<ng-container [ngTemplateOutlet]="title"></ng-container>
}
</ng-container>
<ng-container bitDialogContent>
@if (loading) {
<div>
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
}
<div [hidden]="loading">
@if (policy.showDescription) {
<p bitTypography="body1">{{ policy.description | i18n }}</p>
}
</div>
<ng-template #policyForm></ng-template>
</ng-container>
<ng-container bitDialogFooter>
@let footer = multiStepSubmit()[currentStep()]?.footerContent();
@if (footer) {
<ng-container [ngTemplateOutlet]="footer"></ng-container>
}
</ng-container>
</bit-dialog>
</form>
<ng-template #step0Title>
{{ policy.name | i18n }}
</ng-template>
<ng-template #step1Title>
{{ "centralizeDataOwnershipWarningTitle" | i18n }}
</ng-template>
<ng-template #step0>
<button
bitButton
buttonType="primary"
[disabled]="saveDisabled$ | async"
bitFormButton
type="submit"
>
@if (policyComponent?.policyResponse?.enabled) {
{{ "save" | i18n }}
} @else {
{{ "continue" | i18n }}
}
</button>
<button bitButton buttonType="secondary" bitDialogClose type="button">
{{ "cancel" | i18n }}
</button>
</ng-template>
<ng-template #step1>
<button bitButton buttonType="primary" bitFormButton type="submit">
{{ "continue" | i18n }}
</button>
<button bitButton buttonType="secondary" bitDialogClose type="button">
{{ "cancel" | i18n }}
</button>
</ng-template>

View File

@@ -0,0 +1,224 @@
import {
AfterViewInit,
ChangeDetectorRef,
Component,
Inject,
signal,
TemplateRef,
viewChild,
WritableSignal,
} from "@angular/core";
import { FormBuilder } from "@angular/forms";
import {
catchError,
combineLatest,
defer,
firstValueFrom,
from,
map,
Observable,
of,
startWith,
switchMap,
} from "rxjs";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { assertNonNullish } from "@bitwarden/common/auth/utils";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import {
DIALOG_DATA,
DialogConfig,
DialogRef,
DialogService,
ToastService,
} from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { SharedModule } from "../../../../shared";
import { vNextOrganizationDataOwnershipPolicyComponent } from "../policy-edit-definitions";
import {
PolicyEditDialogComponent,
PolicyEditDialogData,
PolicyEditDialogResult,
} from "../policy-edit-dialog.component";
import { MultiStepSubmit } from "./models";
/**
* Custom policy dialog component for Centralize Organization Data
* Ownership policy. Satisfies the PolicyDialogComponent interface
* structurally via its static open() function.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "organization-data-ownership-edit-policy-dialog.component.html",
imports: [SharedModule],
})
export class OrganizationDataOwnershipPolicyDialogComponent
extends PolicyEditDialogComponent
implements AfterViewInit
{
policyType = PolicyType;
protected centralizeDataOwnershipEnabled$: Observable<boolean> = defer(() =>
from(
this.policyApiService.getPolicy(
this.data.organizationId,
PolicyType.OrganizationDataOwnership,
),
).pipe(
map((policy) => policy.enabled),
catchError(() => of(false)),
),
);
protected readonly currentStep: WritableSignal<number> = signal(0);
protected readonly multiStepSubmit: WritableSignal<MultiStepSubmit[]> = signal([]);
private readonly policyForm = viewChild.required<TemplateRef<unknown>>("step0");
private readonly warningContent = viewChild.required<TemplateRef<unknown>>("step1");
private readonly policyFormTitle = viewChild.required<TemplateRef<unknown>>("step0Title");
private readonly warningTitle = viewChild.required<TemplateRef<unknown>>("step1Title");
override policyComponent: vNextOrganizationDataOwnershipPolicyComponent | undefined;
constructor(
@Inject(DIALOG_DATA) protected data: PolicyEditDialogData,
accountService: AccountService,
policyApiService: PolicyApiServiceAbstraction,
i18nService: I18nService,
cdr: ChangeDetectorRef,
formBuilder: FormBuilder,
dialogRef: DialogRef<PolicyEditDialogResult>,
toastService: ToastService,
protected keyService: KeyService,
) {
super(
data,
accountService,
policyApiService,
i18nService,
cdr,
formBuilder,
dialogRef,
toastService,
keyService,
);
}
async ngAfterViewInit() {
await super.ngAfterViewInit();
if (this.policyComponent) {
this.saveDisabled$ = combineLatest([
this.centralizeDataOwnershipEnabled$,
this.policyComponent.enabled.valueChanges.pipe(
startWith(this.policyComponent.enabled.value),
),
]).pipe(map(([policyEnabled, value]) => !policyEnabled && !value));
}
this.multiStepSubmit.set(this.buildMultiStepSubmit());
}
private buildMultiStepSubmit(): MultiStepSubmit[] {
if (this.policyComponent?.policyResponse?.enabled) {
return [
{
sideEffect: () => this.handleSubmit(),
footerContent: this.policyForm,
titleContent: this.policyFormTitle,
},
];
}
return [
{
footerContent: this.policyForm,
titleContent: this.policyFormTitle,
},
{
sideEffect: () => this.handleSubmit(),
footerContent: this.warningContent,
titleContent: this.warningTitle,
},
];
}
private async handleSubmit() {
if (!this.policyComponent) {
throw new Error("PolicyComponent not initialized.");
}
const orgKey = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.keyService.orgKeys$(userId)),
),
);
assertNonNullish(orgKey, "Org key not provided");
const request = await this.policyComponent.buildVNextRequest(
orgKey[this.data.organizationId as OrganizationId],
);
await this.policyApiService.putPolicyVNext(
this.data.organizationId,
this.data.policy.type,
request,
);
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("editedPolicyId", this.i18nService.t(this.data.policy.name)),
});
if (!this.policyComponent.enabled.value) {
this.dialogRef.close("saved");
}
}
submit = async () => {
if (!this.policyComponent) {
throw new Error("PolicyComponent not initialized.");
}
if ((await this.policyComponent.confirm()) == false) {
this.dialogRef.close();
return;
}
try {
const sideEffect = this.multiStepSubmit()[this.currentStep()].sideEffect;
if (sideEffect) {
await sideEffect();
}
if (this.currentStep() === this.multiStepSubmit().length - 1) {
this.dialogRef.close("saved");
return;
}
this.currentStep.update((value) => value + 1);
this.policyComponent.setStep(this.currentStep());
} catch (error: any) {
this.toastService.showToast({
variant: "error",
message: error.message,
});
}
};
static open = (dialogService: DialogService, config: DialogConfig<PolicyEditDialogData>) => {
return dialogService.open<PolicyEditDialogResult>(
OrganizationDataOwnershipPolicyDialogComponent,
config,
);
};
}

View File

@@ -3,6 +3,7 @@ import { BehaviorSubject, of } from "rxjs";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import {
InitializeJitPasswordCredentials,
SetInitialPasswordCredentials,
SetInitialPasswordService,
SetInitialPasswordUserType,
@@ -20,11 +21,13 @@ import { AccountCryptographicStateService } from "@bitwarden/common/key-manageme
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { MasterPasswordSalt } from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
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 { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management";
import { RouterService } from "@bitwarden/web-vault/app/core";
@@ -47,6 +50,7 @@ describe("WebSetInitialPasswordService", () => {
let organizationInviteService: MockProxy<OrganizationInviteService>;
let routerService: MockProxy<RouterService>;
let accountCryptographicStateService: MockProxy<AccountCryptographicStateService>;
let registerSdkService: MockProxy<RegisterSdkService>;
beforeEach(() => {
apiService = mock<ApiService>();
@@ -62,6 +66,7 @@ describe("WebSetInitialPasswordService", () => {
organizationInviteService = mock<OrganizationInviteService>();
routerService = mock<RouterService>();
accountCryptographicStateService = mock<AccountCryptographicStateService>();
registerSdkService = mock<RegisterSdkService>();
sut = new WebSetInitialPasswordService(
apiService,
@@ -77,6 +82,7 @@ describe("WebSetInitialPasswordService", () => {
organizationInviteService,
routerService,
accountCryptographicStateService,
registerSdkService,
);
});
@@ -208,4 +214,36 @@ describe("WebSetInitialPasswordService", () => {
});
});
});
describe("initializePasswordJitPasswordUserV2Encryption(...)", () => {
it("should call routerService.getAndClearLoginRedirectUrl() and organizationInviteService.clearOrganizationInvitation()", async () => {
// Arrange
const credentials: InitializeJitPasswordCredentials = {
newPasswordHint: "newPasswordHint",
orgSsoIdentifier: "orgSsoIdentifier",
orgId: "orgId" as OrganizationId,
resetPasswordAutoEnroll: false,
newPassword: "newPassword123!",
salt: "user@example.com" as MasterPasswordSalt,
};
const userId = "userId" as UserId;
const superSpy = jest
.spyOn(
Object.getPrototypeOf(Object.getPrototypeOf(sut)),
"initializePasswordJitPasswordUserV2Encryption",
)
.mockResolvedValue(undefined);
// Act
await sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
// Assert
expect(superSpy).toHaveBeenCalledWith(credentials, userId);
expect(routerService.getAndClearLoginRedirectUrl).toHaveBeenCalledTimes(1);
expect(organizationInviteService.clearOrganizationInvitation).toHaveBeenCalledTimes(1);
superSpy.mockRestore();
});
});
});

View File

@@ -1,6 +1,7 @@
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import { DefaultSetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/default-set-initial-password.service.implementation";
import {
InitializeJitPasswordCredentials,
SetInitialPasswordCredentials,
SetInitialPasswordService,
SetInitialPasswordUserType,
@@ -14,6 +15,7 @@ import { AccountCryptographicStateService } from "@bitwarden/common/key-manageme
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
import { UserId } from "@bitwarden/common/types/guid";
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
import { RouterService } from "@bitwarden/web-vault/app/core";
@@ -36,6 +38,7 @@ export class WebSetInitialPasswordService
private organizationInviteService: OrganizationInviteService,
private routerService: RouterService,
protected accountCryptographicStateService: AccountCryptographicStateService,
protected registerSdkService: RegisterSdkService,
) {
super(
apiService,
@@ -49,6 +52,7 @@ export class WebSetInitialPasswordService
organizationUserApiService,
userDecryptionOptionsService,
accountCryptographicStateService,
registerSdkService,
);
}
@@ -83,4 +87,15 @@ export class WebSetInitialPasswordService
await this.routerService.getAndClearLoginRedirectUrl();
await this.organizationInviteService.clearOrganizationInvitation();
}
override async initializePasswordJitPasswordUserV2Encryption(
credentials: InitializeJitPasswordCredentials,
userId: UserId,
): Promise<void> {
await super.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
// TODO: Investigate refactoring the following logic in https://bitwarden.atlassian.net/browse/PM-22615
await this.routerService.getAndClearLoginRedirectUrl();
await this.organizationInviteService.clearOrganizationInvitation();
}
}

View File

@@ -6,11 +6,11 @@ import { Router } from "@angular/router";
import {
CollectionAdminService,
DefaultCollectionAdminService,
OrganizationUserApiService,
CollectionService,
OrganizationUserService,
DefaultCollectionAdminService,
DefaultOrganizationUserService,
OrganizationUserApiService,
OrganizationUserService,
} from "@bitwarden/admin-console/common";
import { DefaultDeviceManagementComponentService } from "@bitwarden/angular/auth/device-management/default-device-management-component.service";
import { DeviceManagementComponentServiceAbstraction } from "@bitwarden/angular/auth/device-management/device-management-component.service.abstraction";
@@ -27,17 +27,17 @@ import {
OBSERVABLE_DISK_LOCAL_STORAGE,
OBSERVABLE_DISK_STORAGE,
OBSERVABLE_MEMORY_STORAGE,
SafeInjectionToken,
SECURE_STORAGE,
SYSTEM_LANGUAGE,
SafeInjectionToken,
WINDOW,
} from "@bitwarden/angular/services/injection-tokens";
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
import {
RegistrationFinishService as RegistrationFinishServiceAbstraction,
LoginComponentService,
SsoComponentService,
LoginDecryptionOptionsService,
RegistrationFinishService as RegistrationFinishServiceAbstraction,
SsoComponentService,
TwoFactorAuthDuoComponentService,
} from "@bitwarden/auth/angular";
import {
@@ -90,6 +90,7 @@ import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platfor
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } 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 { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
@@ -120,9 +121,9 @@ import { DialogService, ToastService } from "@bitwarden/components";
import { GeneratorServicesModule } from "@bitwarden/generator-components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import {
BiometricsService,
KdfConfigService,
KeyService as KeyServiceAbstraction,
BiometricsService,
} from "@bitwarden/key-management";
import {
LockComponentService,
@@ -135,17 +136,17 @@ import { WebVaultPremiumUpgradePromptService } from "@bitwarden/web-vault/app/va
import { flagEnabled } from "../../utils/flags";
import {
POLICY_EDIT_REGISTER,
ossPolicyEditRegister,
POLICY_EDIT_REGISTER,
} from "../admin-console/organizations/policies";
import {
LinkSsoService,
WebChangePasswordService,
WebRegistrationFinishService,
WebLoginComponentService,
WebLoginDecryptionOptionsService,
WebTwoFactorAuthDuoComponentService,
LinkSsoService,
WebRegistrationFinishService,
WebSetInitialPasswordService,
WebTwoFactorAuthDuoComponentService,
} from "../auth";
import { WebSsoComponentService } from "../auth/core/services/login/web-sso-component.service";
import { WebPremiumInterestStateService } from "../billing/services/premium-interest/web-premium-interest-state.service";
@@ -320,6 +321,7 @@ const safeProviders: SafeProvider[] = [
OrganizationInviteService,
RouterService,
AccountCryptographicStateService,
RegisterSdkService,
],
}),
safeProvider({

View File

@@ -5915,6 +5915,37 @@
}
}
},
"centralizeDataOwnership":{
"message": "Centralize organization ownership"
},
"centralizeDataOwnershipDesc":{
"message": "All member items will be owned and managed by the organization. Admins and owners are exempt. "
},
"centralizeDataOwnershipContentAnchor": {
"message": "Learn more about centralized ownership",
"description": "This will be used as a hyperlink"
},
"benefits":{
"message": "Benefits"
},
"centralizeDataOwnershipBenefit1":{
"message": "Gain full visibility into credential health, including shared and unshared items."
},
"centralizeDataOwnershipBenefit2":{
"message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps."
},
"centralizeDataOwnershipBenefit3":{
"message": "Give all users a dedicated \"My Items\" space for managing their own logins."
},
"centralizeDataOwnershipWarningTitle": {
"message": "Prompt members to transfer their items"
},
"centralizeDataOwnershipWarningDesc": {
"message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime."
},
"centralizeDataOwnershipWarningLink": {
"message": "Learn more about the transfer"
},
"organizationDataOwnership": {
"message": "Enforce organization data ownership"
},