1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-10 21:50:15 +00:00

Merge branch 'main' into anders/test-bug

This commit is contained in:
Anders Åberg
2025-07-03 09:39:41 +02:00
88 changed files with 1291 additions and 385 deletions

2
.github/CODEOWNERS vendored
View File

@@ -18,6 +18,7 @@ apps/cli/src/auth @bitwarden/team-auth-dev
apps/desktop/src/auth @bitwarden/team-auth-dev
apps/web/src/app/auth @bitwarden/team-auth-dev
libs/auth @bitwarden/team-auth-dev
libs/user-core @bitwarden/team-auth-dev
# web connectors used for auth
apps/web/src/connectors @bitwarden/team-auth-dev
bitwarden_license/bit-web/src/app/auth @bitwarden/team-auth-dev
@@ -91,6 +92,7 @@ libs/common/spec @bitwarden/team-platform-dev
libs/common/src/state-migrations @bitwarden/team-platform-dev
libs/platform @bitwarden/team-platform-dev
libs/storage-core @bitwarden/team-platform-dev
libs/logging @bitwarden/team-platform-dev
libs/storage-test-utils @bitwarden/team-platform-dev
# Web utils used across app and connectors
apps/web/src/utils/ @bitwarden/team-platform-dev

View File

@@ -268,6 +268,29 @@ jobs:
working-directory: browser-source/
run: npm link ../sdk-internal
- name: Check source file size
if: ${{ startsWith(matrix.name, 'firefox') }}
run: |
# Declare variable as indexed array
declare -a FILES
# Search for source files that are greater than 4M
TARGET_DIR='./browser-source/apps/browser'
while IFS=' ' read -r RESULT; do
FILES+=("$RESULT")
done < <(find $TARGET_DIR -size +4M)
# Validate results and provide messaging
if [[ ${#FILES[@]} -ne 0 ]]; then
echo "File(s) exceeds size limit: 4MB"
for FILE in ${FILES[@]}; do
echo "- $(du --si $FILE)"
done
echo "ERROR Firefox rejects extension uploads that contain files larger than 4MB"
# Invoke failure
exit 1
fi
- name: Build extension
run: npm run ${{ matrix.npm_command }}
working-directory: browser-source/apps/browser

View File

@@ -1,6 +1,6 @@
{
"name": "@bitwarden/browser",
"version": "2025.6.0",
"version": "2025.6.1",
"scripts": {
"build": "npm run build:chrome",
"build:chrome": "cross-env BROWSER=chrome MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack",

View File

@@ -2,7 +2,7 @@
"manifest_version": 2,
"name": "__MSG_extName__",
"short_name": "Bitwarden",
"version": "2025.6.0",
"version": "2025.6.1",
"description": "__MSG_extDesc__",
"default_locale": "en",
"author": "Bitwarden Inc.",
@@ -56,7 +56,8 @@
"unlimitedStorage",
"webNavigation",
"webRequest",
"webRequestBlocking"
"webRequestBlocking",
"notifications"
],
"__safari__permissions": [
"<all_urls>",

View File

@@ -3,7 +3,7 @@
"minimum_chrome_version": "102.0",
"name": "__MSG_extName__",
"short_name": "Bitwarden",
"version": "2025.6.0",
"version": "2025.6.1",
"description": "__MSG_extDesc__",
"default_locale": "en",
"author": "Bitwarden Inc.",

View File

@@ -4,7 +4,7 @@
[title]="((currentURIIsBlocked$ | async) ? 'itemSuggestions' : 'autofillSuggestions') | i18n"
[showRefresh]="showRefresh"
(onRefresh)="refreshCurrentTab()"
[description]="(showEmptyAutofillTip$ | async) ? ('autofillSuggestionsTip' | i18n) : null"
[description]="(showEmptyAutofillTip$ | async) ? ('autofillSuggestionsTip' | i18n) : undefined"
showAutofillButton
[disableDescriptionMargin]="showEmptyAutofillTip$ | async"
[primaryActionAutofill]="clickItemsToAutofillVaultView$ | async"

View File

@@ -1,7 +1,7 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { combineLatest, map, Observable } from "rxjs";
import { combineLatest, map, Observable, startWith } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
@@ -41,7 +41,9 @@ export class AutofillVaultListItemsComponent {
/** Flag indicating whether the login item should automatically autofill when clicked */
protected clickItemsToAutofillVaultView$: Observable<boolean> =
this.vaultSettingsService.clickItemsToAutofillVaultView$;
this.vaultSettingsService.clickItemsToAutofillVaultView$.pipe(
startWith(true), // Start with true to avoid flashing the fill button on first load
);
protected groupByType = toSignal(
this.vaultPopupItemsService.hasFilterApplied$.pipe(map((hasFilter) => !hasFilter)),
@@ -74,9 +76,7 @@ export class AutofillVaultListItemsComponent {
private vaultPopupItemsService: VaultPopupItemsService,
private vaultPopupAutofillService: VaultPopupAutofillService,
private vaultSettingsService: VaultSettingsService,
) {
// TODO: Migrate logic to show Autofill policy toast PM-8144
}
) {}
/**
* Refreshes the current tab to re-populate the autofill ciphers.

View File

@@ -1,8 +1,8 @@
<bit-section
*ngIf="cipherGroups$().length > 0 || description"
[disableMargin]="disableSectionMargin"
*ngIf="cipherGroups().length > 0 || description()"
[disableMargin]="disableSectionMargin()"
>
<ng-container *ngIf="collapsibleKey">
<ng-container *ngIf="collapsibleKey()">
<button
class="tw-group/vault-section-header hover:tw-bg-primary-100 tw-rounded-md tw-pl-1 tw-w-full tw-border-x-0 tw-border-t-0 tw-border-b tw-border-solid focus-visible:tw-outline-none focus-visible:tw-ring-inset focus-visible:tw-ring-2 focus-visible:tw-ring-primary-600"
[ngClass]="{
@@ -22,7 +22,7 @@
</bit-disclosure>
</ng-container>
<ng-container *ngIf="!collapsibleKey">
<ng-container *ngIf="!collapsibleKey()">
<div class="tw-pl-1">
<ng-container *ngTemplateOutlet="sectionHeader"></ng-container>
</div>
@@ -34,10 +34,10 @@
<ng-template #sectionHeader>
<bit-section-header class="tw-p-0.5 -tw-mx-0.5">
<h2 bitTypography="h6">
{{ title }}
{{ title() }}
</h2>
<button
*ngIf="showRefresh"
*ngIf="showRefresh()"
bitIconButton="bwi-refresh"
type="button"
size="small"
@@ -48,13 +48,13 @@
<span
[ngClass]="{
'group-hover/vault-section-header:tw-hidden group-focus-visible/vault-section-header:tw-hidden':
collapsibleKey && sectionOpenState(),
'tw-hidden': collapsibleKey && !sectionOpenState(),
collapsibleKey() && sectionOpenState(),
'tw-hidden': collapsibleKey() && !sectionOpenState(),
}"
>
{{ ciphers().length }}
</span>
<span class="tw-pr-1" *ngIf="collapsibleKey">
<span class="tw-pr-1" *ngIf="collapsibleKey()">
<i
class="bwi tw-text-main"
[ngClass]="{
@@ -71,18 +71,18 @@
<ng-template #descriptionText>
<div
*ngIf="description"
*ngIf="description()"
class="tw-text-muted tw-px-1 tw-mb-2"
[ngClass]="{ '!tw-mb-0': disableDescriptionMargin }"
[ngClass]="{ '!tw-mb-0': disableDescriptionMargin() }"
bitTypography="body2"
>
{{ description }}
{{ description() }}
</div>
</ng-template>
<ng-template #itemGroup>
<bit-item-group>
<ng-container *ngFor="let group of cipherGroups$()">
<ng-container *ngFor="let group of cipherGroups()">
<ng-container *ngIf="group.subHeaderKey">
<h3 class="tw-text-muted tw-text-xs tw-font-semibold tw-pl-1 tw-mb-1 bit-compact:tw-m-0">
{{ group.subHeaderKey | i18n }}
@@ -97,7 +97,7 @@
(click)="primaryActionOnSelect(cipher)"
(dblclick)="launchCipher(cipher)"
[appA11yTitle]="
cipherItemTitleKey(cipher) | async | i18n: cipher.name : cipher.login.username
cipherItemTitleKey()(cipher) | i18n: cipher.name : cipher.login.username
"
class="{{ itemHeightClass }}"
>
@@ -109,7 +109,7 @@
*ngIf="cipher.organizationId"
slot="default-trailing"
appOrgIcon
[tierType]="cipher.organization.productTierType"
[tierType]="cipher.organization!.productTierType"
[size]="'small'"
[appA11yTitle]="orgIconTooltip(cipher)"
></i>
@@ -122,7 +122,7 @@
</button>
<ng-container slot="end">
<bit-item-action *ngIf="!(hideAutofillButton$ | async)">
<bit-item-action *ngIf="!hideAutofillButton()">
<button
type="button"
bitBadge
@@ -134,7 +134,7 @@
{{ "fill" | i18n }}
</button>
</bit-item-action>
<bit-item-action *ngIf="!showAutofillButton && cipher.canLaunch">
<bit-item-action *ngIf="!showAutofillButton() && cipher.canLaunch">
<button
type="button"
bitIconButton="bwi-external-link"
@@ -147,8 +147,8 @@
<app-item-copy-actions [cipher]="cipher"></app-item-copy-actions>
<app-item-more-options
[cipher]="cipher"
[hideAutofillOptions]="hideAutofillOptions$ | async"
[showViewOption]="primaryActionAutofill"
[hideAutofillOptions]="hideAutofillMenuOptions()"
[showViewOption]="primaryActionAutofill()"
></app-item-more-options>
</ng-container>
</bit-item>

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CdkVirtualScrollViewport, ScrollingModule } from "@angular/cdk/scrolling";
import { CommonModule } from "@angular/common";
import {
@@ -8,18 +6,17 @@ import {
Component,
EventEmitter,
inject,
Input,
Output,
Signal,
signal,
ViewChild,
computed,
OnInit,
ChangeDetectionStrategy,
input,
} from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { Router } from "@angular/router";
import { firstValueFrom, Observable, map } from "rxjs";
import { firstValueFrom, map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -53,7 +50,10 @@ import {
import { BrowserApi } from "../../../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../../../platform/browser/browser-popup-utils";
import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service";
import { VaultPopupSectionService } from "../../../services/vault-popup-section.service";
import {
VaultPopupSectionService,
PopupSectionOpen,
} from "../../../services/vault-popup-section.service";
import { PopupCipherView } from "../../../views/popup-cipher.view";
import { ItemCopyActionsComponent } from "../item-copy-action/item-copy-actions.component";
import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options.component";
@@ -81,17 +81,25 @@ import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options
templateUrl: "vault-list-items-container.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VaultListItemsContainerComponent implements OnInit, AfterViewInit {
export class VaultListItemsContainerComponent implements AfterViewInit {
private compactModeService = inject(CompactModeService);
private vaultPopupSectionService = inject(VaultPopupSectionService);
@ViewChild(CdkVirtualScrollViewport, { static: false }) viewPort: CdkVirtualScrollViewport;
@ViewChild(DisclosureComponent) disclosure: DisclosureComponent;
@ViewChild(CdkVirtualScrollViewport, { static: false }) viewPort!: CdkVirtualScrollViewport;
@ViewChild(DisclosureComponent) disclosure!: DisclosureComponent;
/**
* Indicates whether the section should be open or closed if collapsibleKey is provided
*/
protected sectionOpenState: Signal<boolean> | undefined;
protected sectionOpenState: Signal<boolean> = computed(() => {
if (!this.collapsibleKey()) {
return true;
}
return (
this.vaultPopupSectionService.getOpenDisplayStateForSection(this.collapsibleKey()!)() ?? true
);
});
/**
* The class used to set the height of a bit item's inner content.
@@ -115,7 +123,7 @@ export class VaultListItemsContainerComponent implements OnInit, AfterViewInit {
* Timeout used to add a small delay when selecting a cipher to allow for double click to launch
* @private
*/
private viewCipherTimeout: number | null;
private viewCipherTimeout?: number;
ciphers = input<PopupCipherView[]>([]);
@@ -123,31 +131,33 @@ export class VaultListItemsContainerComponent implements OnInit, AfterViewInit {
* If true, we will group ciphers by type (Login, Card, Identity)
* within subheadings in a single container, converted to a WritableSignal.
*/
groupByType = input<boolean>(false);
groupByType = input<boolean | undefined>(false);
/**
* Computed signal for a grouped list of ciphers with an optional header
*/
cipherGroups$ = computed<
cipherGroups = computed<
{
subHeaderKey?: string | null;
subHeaderKey?: string;
ciphers: PopupCipherView[];
}[]
>(() => {
const groups: { [key: string]: CipherView[] } = {};
// Not grouping by type, return a single group with all ciphers
if (!this.groupByType()) {
return [{ ciphers: this.ciphers() }];
}
const groups: Record<string, PopupCipherView[]> = {};
this.ciphers().forEach((cipher) => {
let groupKey;
if (this.groupByType()) {
switch (cipher.type) {
case CipherType.Card:
groupKey = "cards";
break;
case CipherType.Identity:
groupKey = "identities";
break;
}
let groupKey = "all";
switch (cipher.type) {
case CipherType.Card:
groupKey = "cards";
break;
case CipherType.Identity:
groupKey = "identities";
break;
}
if (!groups[groupKey]) {
@@ -157,17 +167,16 @@ export class VaultListItemsContainerComponent implements OnInit, AfterViewInit {
groups[groupKey].push(cipher);
});
return Object.keys(groups).map((key) => ({
subHeaderKey: this.groupByType ? key : "",
ciphers: groups[key],
return Object.entries(groups).map(([key, ciphers]) => ({
subHeaderKey: key != "all" ? key : undefined,
ciphers: ciphers,
}));
});
/**
* Title for the vault list item section.
*/
@Input()
title: string;
title = input<string | undefined>(undefined);
/**
* Optionally allow the items to be collapsed.
@@ -175,21 +184,18 @@ export class VaultListItemsContainerComponent implements OnInit, AfterViewInit {
* The key must be added to the state definition in `vault-popup-section.service.ts` since the
* collapsed state is stored locally.
*/
@Input()
collapsibleKey: "favorites" | "allItems" | undefined;
collapsibleKey = input<keyof PopupSectionOpen | undefined>(undefined);
/**
* Optional description for the vault list item section. Will be shown below the title even when
* no ciphers are available.
*/
@Input()
description: string;
description = input<string | undefined>(undefined);
/**
* Option to show a refresh button in the section header.
*/
@Input({ transform: booleanAttribute })
showRefresh: boolean;
showRefresh = input(false, { transform: booleanAttribute });
/**
* Event emitted when the refresh button is clicked.
@@ -200,66 +206,61 @@ export class VaultListItemsContainerComponent implements OnInit, AfterViewInit {
/**
* Flag indicating that the current tab location is blocked
*/
currentURIIsBlocked$: Observable<boolean> =
this.vaultPopupAutofillService.currentTabIsOnBlocklist$;
currentURIIsBlocked = toSignal(this.vaultPopupAutofillService.currentTabIsOnBlocklist$);
/**
* Resolved i18n key to use for suggested cipher items
*/
cipherItemTitleKey = (cipher: CipherView) =>
this.currentURIIsBlocked$.pipe(
map((uriIsBlocked) => {
const hasUsername = cipher.login?.username != null;
const key = this.primaryActionAutofill && !uriIsBlocked ? "autofillTitle" : "viewItemTitle";
return hasUsername ? `${key}WithField` : key;
}),
);
cipherItemTitleKey = computed(() => {
return (cipher: CipherView) => {
const hasUsername = cipher.login?.username != null;
const key =
this.primaryActionAutofill() && !this.currentURIIsBlocked()
? "autofillTitle"
: "viewItemTitle";
return hasUsername ? `${key}WithField` : key;
};
});
/**
* Option to show the autofill button for each item.
*/
@Input({ transform: booleanAttribute })
showAutofillButton: boolean;
showAutofillButton = input(false, { transform: booleanAttribute });
/**
* Flag indicating whether the suggested cipher item autofill button should be shown or not
*/
hideAutofillButton$ = this.currentURIIsBlocked$.pipe(
map((uriIsBlocked) => !this.showAutofillButton || uriIsBlocked || this.primaryActionAutofill),
hideAutofillButton = computed(
() => !this.showAutofillButton() || this.currentURIIsBlocked() || this.primaryActionAutofill(),
);
/**
* Flag indicating whether the cipher item autofill options should be shown or not
* Flag indicating whether the cipher item autofill menu options should be shown or not
*/
hideAutofillOptions$: Observable<boolean> = this.currentURIIsBlocked$.pipe(
map((uriIsBlocked) => uriIsBlocked || this.showAutofillButton),
);
hideAutofillMenuOptions = computed(() => this.currentURIIsBlocked() || this.showAutofillButton());
/**
* Option to perform autofill operation as the primary action for autofill suggestions.
*/
@Input({ transform: booleanAttribute })
primaryActionAutofill: boolean;
primaryActionAutofill = input(false, { transform: booleanAttribute });
/**
* Remove the bottom margin from the bit-section in this component
* (used for containers at the end of the page where bottom margin is not needed)
*/
@Input({ transform: booleanAttribute })
disableSectionMargin: boolean = false;
disableSectionMargin = input(false, { transform: booleanAttribute });
/**
* Remove the description margin
*/
@Input({ transform: booleanAttribute })
disableDescriptionMargin: boolean = false;
disableDescriptionMargin = input(false, { transform: booleanAttribute });
/**
* The tooltip text for the organization icon for ciphers that belong to an organization.
* @param cipher
*/
orgIconTooltip(cipher: PopupCipherView) {
if (cipher.collectionIds.length > 1) {
if (cipher.collectionIds.length > 1 || !cipher.collections) {
return this.i18nService.t("nCollections", cipher.collectionIds.length);
}
@@ -279,16 +280,6 @@ export class VaultListItemsContainerComponent implements OnInit, AfterViewInit {
private accountService: AccountService,
) {}
ngOnInit(): void {
if (!this.collapsibleKey) {
return;
}
this.sectionOpenState = this.vaultPopupSectionService.getOpenDisplayStateForSection(
this.collapsibleKey,
);
}
async ngAfterViewInit() {
const autofillShortcut = await this.platformUtilsService.getAutofillKeyboardShortcut();
@@ -301,10 +292,8 @@ export class VaultListItemsContainerComponent implements OnInit, AfterViewInit {
}
}
async primaryActionOnSelect(cipher: CipherView) {
const isBlocked = await firstValueFrom(this.currentURIIsBlocked$);
return this.primaryActionAutofill && !isBlocked
primaryActionOnSelect(cipher: CipherView) {
return this.primaryActionAutofill() && !this.currentURIIsBlocked()
? this.doAutofill(cipher)
: this.onViewCipher(cipher);
}
@@ -320,7 +309,7 @@ export class VaultListItemsContainerComponent implements OnInit, AfterViewInit {
// If there is a view action pending, clear it
if (this.viewCipherTimeout != null) {
window.clearTimeout(this.viewCipherTimeout);
this.viewCipherTimeout = null;
this.viewCipherTimeout = undefined;
}
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
@@ -363,7 +352,7 @@ export class VaultListItemsContainerComponent implements OnInit, AfterViewInit {
});
} finally {
// Ensure the timeout is always cleared
this.viewCipherTimeout = null;
this.viewCipherTimeout = undefined;
}
},
cipher.canLaunch ? 200 : 0,
@@ -374,12 +363,12 @@ export class VaultListItemsContainerComponent implements OnInit, AfterViewInit {
* Update section open/close state based on user action
*/
async toggleSectionOpen() {
if (!this.collapsibleKey) {
if (!this.collapsibleKey()) {
return;
}
await this.vaultPopupSectionService.updateSectionOpenStoredState(
this.collapsibleKey,
this.collapsibleKey()!,
this.disclosure.open,
);
}

View File

@@ -1,7 +1,7 @@
{
"name": "@bitwarden/cli",
"description": "A secure and free password manager for all of your devices.",
"version": "2025.6.0",
"version": "2025.6.1",
"keywords": [
"bitwarden",
"password",

View File

@@ -51,7 +51,7 @@ impl ssh_agent::Agent<peerinfo::models::PeerInfo> for BitwardenDesktopAgent {
let request_data = match request_parser::parse_request(data) {
Ok(data) => data,
Err(e) => {
println!("[SSH Agent] Error while parsing request: {}", e);
println!("[SSH Agent] Error while parsing request: {e}");
return false;
}
};
@@ -178,7 +178,7 @@ impl BitwardenDesktopAgent {
);
}
Err(e) => {
eprintln!("[SSH Agent Native Module] Error while parsing key: {}", e);
eprintln!("[SSH Agent Native Module] Error while parsing key: {e}");
}
}
}
@@ -234,10 +234,9 @@ fn parse_key_safe(pem: &str) -> Result<ssh_key::private::PrivateKey, anyhow::Err
Ok(key) => match key.public_key().to_bytes() {
Ok(_) => Ok(key),
Err(e) => Err(anyhow::Error::msg(format!(
"Failed to parse public key: {}",
e
"Failed to parse public key: {e}"
))),
},
Err(e) => Err(anyhow::Error::msg(format!("Failed to parse key: {}", e))),
Err(e) => Err(anyhow::Error::msg(format!("Failed to parse key: {e}"))),
}
}

View File

@@ -65,16 +65,10 @@ impl BitwardenDesktopAgent {
}
};
println!(
"[SSH Agent Native Module] Starting SSH Agent server on {:?}",
ssh_path
);
println!("[SSH Agent Native Module] Starting SSH Agent server on {ssh_path:?}");
let sockname = std::path::Path::new(&ssh_path);
if let Err(e) = std::fs::remove_file(sockname) {
println!(
"[SSH Agent Native Module] Could not remove existing socket file: {}",
e
);
println!("[SSH Agent Native Module] Could not remove existing socket file: {e}");
if e.kind() != std::io::ErrorKind::NotFound {
return;
}
@@ -85,10 +79,7 @@ impl BitwardenDesktopAgent {
// Only the current user should be able to access the socket
if let Err(e) = fs::set_permissions(sockname, fs::Permissions::from_mode(0o600))
{
println!(
"[SSH Agent Native Module] Could not set socket permissions: {}",
e
);
println!("[SSH Agent Native Module] Could not set socket permissions: {e}");
return;
}
@@ -112,10 +103,7 @@ impl BitwardenDesktopAgent {
println!("[SSH Agent Native Module] SSH Agent server exited");
}
Err(e) => {
eprintln!(
"[SSH Agent Native Module] Error while starting agent server: {}",
e
);
eprintln!("[SSH Agent Native Module] Error while starting agent server: {e}");
}
}
});

View File

@@ -255,8 +255,7 @@ impl MacOSProviderClient {
.remove(&sequence_number)
{
cb.error(BitwardenError::Internal(format!(
"Error sending message: {}",
e
"Error sending message: {e}"
)));
}
}

View File

@@ -237,7 +237,7 @@ pub mod sshagent {
.expect("should be able to send auth response to agent");
}
Err(e) => {
println!("[SSH Agent Native Module] calling UI callback promise was rejected: {}", e);
println!("[SSH Agent Native Module] calling UI callback promise was rejected: {e}");
let _ = auth_response_tx_arc
.lock()
.await
@@ -246,7 +246,7 @@ pub mod sshagent {
}
},
Err(e) => {
println!("[SSH Agent Native Module] calling UI callback could not create promise: {}", e);
println!("[SSH Agent Native Module] calling UI callback could not create promise: {e}");
let _ = auth_response_tx_arc
.lock()
.await

View File

@@ -80,8 +80,7 @@ mod objc {
Ok(value) => value,
Err(e) => {
println!(
"Error: Failed to convert ObjCString to Rust string during commandReturn: {}",
e
"Error: Failed to convert ObjCString to Rust string during commandReturn: {e}"
);
return false;
@@ -91,10 +90,7 @@ mod objc {
match context.send(value) {
Ok(_) => 0,
Err(e) => {
println!(
"Error: Failed to return ObjCString from ObjC code to Rust code: {}",
e
);
println!("Error: Failed to return ObjCString from ObjC code to Rust code: {e}");
return false;
}

View File

@@ -29,12 +29,12 @@ fn init_logging(log_path: &Path, console_level: LevelFilter, file_level: LevelFi
loggers.push(simplelog::WriteLogger::new(file_level, config, file));
}
Err(e) => {
eprintln!("Can't create file: {}", e);
eprintln!("Can't create file: {e}");
}
}
if let Err(e) = CombinedLogger::init(loggers) {
eprintln!("Failed to initialize logger: {}", e);
eprintln!("Failed to initialize logger: {e}");
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "@bitwarden/desktop",
"description": "A secure and free password manager for all of your devices.",
"version": "2025.6.1",
"version": "2025.7.0",
"keywords": [
"bitwarden",
"password",

View File

@@ -256,7 +256,7 @@ export class DesktopAutofillService implements OnDestroy {
}
request.credentialId = Array.from(
parseCredentialId(decrypted.login.fido2Credentials?.[0].credentialId),
new Uint8Array(parseCredentialId(decrypted.login.fido2Credentials?.[0].credentialId)),
);
}
@@ -395,12 +395,12 @@ export class DesktopAutofillService implements OnDestroy {
response: Fido2AuthenticatorGetAssertionResult,
): autofill.PasskeyAssertionResponse {
return {
userHandle: Array.from(response.selectedCredential.userHandle),
userHandle: Array.from(new Uint8Array(response.selectedCredential.userHandle)),
rpId: request.rpId,
signature: Array.from(response.signature),
signature: Array.from(new Uint8Array(response.signature)),
clientDataHash: request.clientDataHash,
authenticatorData: Array.from(response.authenticatorData),
credentialId: Array.from(response.selectedCredential.id),
authenticatorData: Array.from(new Uint8Array(response.authenticatorData)),
credentialId: Array.from(new Uint8Array(response.selectedCredential.id)),
};
}

View File

@@ -295,6 +295,15 @@ export class WindowMain {
this.win.webContents.zoomFactor = this.windowStates[mainWindowSizeKey].zoomFactor ?? 1.0;
});
// Persist zoom changes immediately when user zooms in/out or resets zoom
// We can't depend on higher level web events (like close) to do this
// because locking the vault resets window state.
this.win.webContents.on("zoom-changed", async () => {
const newZoom = this.win.webContents.zoomFactor;
this.windowStates[mainWindowSizeKey].zoomFactor = newZoom;
await this.desktopSettingsService.setWindow(this.windowStates[mainWindowSizeKey]);
});
if (this.windowStates[mainWindowSizeKey].isMaximized) {
this.win.maximize();
}

View File

@@ -1,12 +1,12 @@
{
"name": "@bitwarden/desktop",
"version": "2025.6.1",
"version": "2025.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@bitwarden/desktop",
"version": "2025.6.1",
"version": "2025.7.0",
"license": "GPL-3.0",
"dependencies": {
"@bitwarden/desktop-napi": "file:../desktop_native/napi"

View File

@@ -2,7 +2,7 @@
"name": "@bitwarden/desktop",
"productName": "Bitwarden",
"description": "A secure and free password manager for all of your devices.",
"version": "2025.6.1",
"version": "2025.7.0",
"author": "Bitwarden Inc. <hello@bitwarden.com> (https://bitwarden.com)",
"homepage": "https://bitwarden.com",
"license": "GPL-3.0",

View File

@@ -388,6 +388,13 @@ export class VaultV2Component implements OnInit, OnDestroy, CopyClickListener {
}
async viewCipher(cipher: CipherView) {
if (cipher.decryptionFailure) {
DecryptionFailureDialogComponent.open(this.dialogService, {
cipherIds: [cipher.id as CipherId],
});
return;
}
if (await this.shouldReprompt(cipher, "view")) {
return;
}

View File

@@ -4,7 +4,7 @@
<org-switcher [filter]="orgFilter" [hideNewButton]="hideNewOrgButton$ | async"></org-switcher>
<bit-nav-group
icon="bwi-filter"
*ngIf="organization.useRiskInsights"
*ngIf="organization.useRiskInsights && organization.canAccessReports"
[text]="'accessIntelligence' | i18n"
route="access-intelligence"
>

View File

@@ -79,6 +79,7 @@ const routes: Routes = [
},
{
path: "access-intelligence",
canActivate: [organizationPermissionsGuard((org) => org.canAccessReports)],
loadChildren: () =>
import("../../dirt/access-intelligence/access-intelligence.module").then(
(m) => m.AccessIntelligenceModule,

View File

@@ -9,7 +9,9 @@ const routes: Routes = [
{ path: "", pathMatch: "full", redirectTo: "risk-insights" },
{
path: "risk-insights",
canActivate: [organizationPermissionsGuard((org) => org.useRiskInsights)],
canActivate: [
organizationPermissionsGuard((org) => org.useRiskInsights && org.canAccessReports),
],
component: RiskInsightsComponent,
data: {
titleId: "RiskInsights",

View File

@@ -14,6 +14,7 @@ import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-conso
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request";
import { UpdateTdeOffboardingPasswordRequest } from "@bitwarden/common/auth/models/request/update-tde-offboarding-password.request";
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 { KeysRequest } from "@bitwarden/common/models/request/keys.request";
@@ -28,6 +29,7 @@ import {
SetInitialPasswordService,
SetInitialPasswordCredentials,
SetInitialPasswordUserType,
SetInitialPasswordTdeOffboardingCredentials,
} from "./set-initial-password.service.abstraction";
export class DefaultSetInitialPasswordService implements SetInitialPasswordService {
@@ -245,4 +247,44 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
enrollmentRequest,
);
}
async setInitialPasswordTdeOffboarding(
credentials: SetInitialPasswordTdeOffboardingCredentials,
userId: UserId,
) {
const { newMasterKey, newServerMasterKeyHash, newPasswordHint } = credentials;
for (const [key, value] of Object.entries(credentials)) {
if (value == null) {
throw new Error(`${key} not found. Could not set password.`);
}
}
if (userId == null) {
throw new Error("userId not found. Could not set password.");
}
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
if (userKey == null) {
throw new Error("userKey not found. Could not set password.");
}
const newMasterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey(
newMasterKey,
userKey,
);
if (!newMasterKeyEncryptedUserKey[1].encryptedString) {
throw new Error("newMasterKeyEncryptedUserKey not found. Could not set password.");
}
const request = new UpdateTdeOffboardingPasswordRequest();
request.key = newMasterKeyEncryptedUserKey[1].encryptedString;
request.newMasterPasswordHash = newServerMasterKeyHash;
request.masterPasswordHint = newPasswordHint;
await this.masterPasswordApiService.putUpdateTdeOffboardingPassword(request);
// Clear force set password reason to allow navigation back to vault.
await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId);
}
}

View File

@@ -19,6 +19,7 @@ import { OrganizationKeysResponse } from "@bitwarden/common/admin-console/models
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request";
import { UpdateTdeOffboardingPasswordRequest } from "@bitwarden/common/auth/models/request/update-tde-offboarding-password.request";
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 { KeysRequest } from "@bitwarden/common/models/request/keys.request";
@@ -35,6 +36,7 @@ import { DefaultSetInitialPasswordService } from "./default-set-initial-password
import {
SetInitialPasswordCredentials,
SetInitialPasswordService,
SetInitialPasswordTdeOffboardingCredentials,
SetInitialPasswordUserType,
} from "./set-initial-password.service.abstraction";
@@ -52,6 +54,11 @@ describe("DefaultSetInitialPasswordService", () => {
let organizationUserApiService: MockProxy<OrganizationUserApiService>;
let userDecryptionOptionsService: MockProxy<InternalUserDecryptionOptionsServiceAbstraction>;
let userId: UserId;
let userKey: UserKey;
let userKeyEncString: EncString;
let masterKeyEncryptedUserKey: [UserKey, EncString];
beforeEach(() => {
apiService = mock<ApiService>();
encryptService = mock<EncryptService>();
@@ -64,6 +71,11 @@ describe("DefaultSetInitialPasswordService", () => {
organizationUserApiService = mock<OrganizationUserApiService>();
userDecryptionOptionsService = mock<InternalUserDecryptionOptionsServiceAbstraction>();
userId = "userId" as UserId;
userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
userKeyEncString = new EncString("masterKeyEncryptedUserKey");
masterKeyEncryptedUserKey = [userKey, userKeyEncString];
sut = new DefaultSetInitialPasswordService(
apiService,
encryptService,
@@ -86,13 +98,8 @@ describe("DefaultSetInitialPasswordService", () => {
// Mock function parameters
let credentials: SetInitialPasswordCredentials;
let userType: SetInitialPasswordUserType;
let userId: UserId;
// Mock other function data
let userKey: UserKey;
let userKeyEncString: EncString;
let masterKeyEncryptedUserKey: [UserKey, EncString];
let existingUserPublicKey: UserPublicKey;
let existingUserPrivateKey: UserPrivateKey;
let userKeyEncryptedPrivateKey: EncString;
@@ -121,14 +128,9 @@ describe("DefaultSetInitialPasswordService", () => {
orgId: "orgId",
resetPasswordAutoEnroll: false,
};
userId = "userId" as UserId;
userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER;
// Mock other function data
userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
userKeyEncString = new EncString("masterKeyEncryptedUserKey");
masterKeyEncryptedUserKey = [userKey, userKeyEncString];
existingUserPublicKey = Utils.fromB64ToArray("existingUserPublicKey") as UserPublicKey;
existingUserPrivateKey = Utils.fromB64ToArray("existingUserPrivateKey") as UserPrivateKey;
userKeyEncryptedPrivateKey = new EncString("userKeyEncryptedPrivateKey");
@@ -630,4 +632,114 @@ describe("DefaultSetInitialPasswordService", () => {
});
});
});
describe("setInitialPasswordTdeOffboarding(...)", () => {
// Mock function parameters
let credentials: SetInitialPasswordTdeOffboardingCredentials;
beforeEach(() => {
// Mock function parameters
credentials = {
newMasterKey: new SymmetricCryptoKey(new Uint8Array(32).buffer as CsprngArray) as MasterKey,
newServerMasterKeyHash: "newServerMasterKeyHash",
newPasswordHint: "newPasswordHint",
};
});
function setupTdeOffboardingMocks() {
keyService.userKey$.mockReturnValue(of(userKey));
keyService.encryptUserKeyWithMasterKey.mockResolvedValue(masterKeyEncryptedUserKey);
}
it("should successfully set an initial password for the TDE offboarding user", async () => {
// Arrange
setupTdeOffboardingMocks();
const request = new UpdateTdeOffboardingPasswordRequest();
request.key = masterKeyEncryptedUserKey[1].encryptedString;
request.newMasterPasswordHash = credentials.newServerMasterKeyHash;
request.masterPasswordHint = credentials.newPasswordHint;
// Act
await sut.setInitialPasswordTdeOffboarding(credentials, userId);
// Assert
expect(masterPasswordApiService.putUpdateTdeOffboardingPassword).toHaveBeenCalledTimes(1);
expect(masterPasswordApiService.putUpdateTdeOffboardingPassword).toHaveBeenCalledWith(
request,
);
});
describe("given the initial password has been successfully set", () => {
it("should clear the ForceSetPasswordReason by setting it to None", async () => {
// Arrange
setupTdeOffboardingMocks();
// Act
await sut.setInitialPasswordTdeOffboarding(credentials, userId);
// Assert
expect(masterPasswordApiService.putUpdateTdeOffboardingPassword).toHaveBeenCalledTimes(1);
expect(masterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith(
ForceSetPasswordReason.None,
userId,
);
});
});
describe("general error handling", () => {
["newMasterKey", "newServerMasterKeyHash", "newPasswordHint"].forEach((key) => {
it(`should throw if ${key} is not provided on the SetInitialPasswordTdeOffboardingCredentials object`, async () => {
// Arrange
const invalidCredentials: SetInitialPasswordTdeOffboardingCredentials = {
...credentials,
[key]: null,
};
// Act
const promise = sut.setInitialPasswordTdeOffboarding(invalidCredentials, userId);
// Assert
await expect(promise).rejects.toThrow(`${key} not found. Could not set password.`);
});
});
it(`should throw if the userId was not passed in`, async () => {
// Arrange
userId = null;
// Act
const promise = sut.setInitialPasswordTdeOffboarding(credentials, userId);
// Assert
await expect(promise).rejects.toThrow("userId not found. Could not set password.");
});
it(`should throw if the userKey was not found`, async () => {
// Arrange
keyService.userKey$.mockReturnValue(of(null));
// Act
const promise = sut.setInitialPasswordTdeOffboarding(credentials, userId);
// Assert
await expect(promise).rejects.toThrow("userKey not found. Could not set password.");
});
it(`should throw if a newMasterKeyEncryptedUserKey was not returned`, async () => {
// Arrange
masterKeyEncryptedUserKey[1].encryptedString = "" as EncryptedString;
setupTdeOffboardingMocks();
// Act
const promise = sut.setInitialPasswordTdeOffboarding(credentials, userId);
// Assert
await expect(promise).rejects.toThrow(
"newMasterKeyEncryptedUserKey not found. Could not set password.",
);
});
});
});
});

View File

@@ -21,7 +21,12 @@
[userId]="userId"
[loading]="submitting"
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions"
[primaryButtonText]="{ key: 'createAccount' }"
[primaryButtonText]="{
key:
userType === SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER
? 'setPassword'
: 'createAccount',
}"
[secondaryButtonText]="{ key: 'logOut' }"
(onPasswordFormSubmit)="handlePasswordFormSubmit($event)"
(onSecondaryButtonClick)="logout()"

View File

@@ -10,14 +10,20 @@ import {
InputPasswordFlow,
PasswordInputResult,
} from "@bitwarden/auth/angular";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { LogoutService } from "@bitwarden/auth/common";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { assertTruthy, assertNonNullish } from "@bitwarden/common/auth/utils";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { SyncService } from "@bitwarden/common/platform/sync";
@@ -33,6 +39,7 @@ import { I18nPipe } from "@bitwarden/ui-common";
import {
SetInitialPasswordCredentials,
SetInitialPasswordService,
SetInitialPasswordTdeOffboardingCredentials,
SetInitialPasswordUserType,
} from "./set-initial-password.service.abstraction";
@@ -54,6 +61,7 @@ export class SetInitialPasswordComponent implements OnInit {
protected submitting = false;
protected userId?: UserId;
protected userType?: SetInitialPasswordUserType;
protected SetInitialPasswordUserType = SetInitialPasswordUserType;
constructor(
private accountService: AccountService,
@@ -61,10 +69,13 @@ export class SetInitialPasswordComponent implements OnInit {
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
private dialogService: DialogService,
private i18nService: I18nService,
private logoutService: LogoutService,
private logService: LogService,
private masterPasswordService: InternalMasterPasswordServiceAbstraction,
private messagingService: MessagingService,
private organizationApiService: OrganizationApiServiceAbstraction,
private policyApiService: PolicyApiServiceAbstraction,
private policyService: PolicyService,
private router: Router,
private setInitialPasswordService: SetInitialPasswordService,
private ssoLoginService: SsoLoginServiceAbstraction,
@@ -80,13 +91,13 @@ export class SetInitialPasswordComponent implements OnInit {
this.userId = activeAccount?.id;
this.email = activeAccount?.email;
await this.determineUserType();
await this.handleQueryParams();
await this.establishUserType();
await this.getOrgInfo();
this.initializing = false;
}
private async determineUserType() {
private async establishUserType() {
if (!this.userId) {
throw new Error("userId not found. Could not determine user type.");
}
@@ -95,6 +106,14 @@ export class SetInitialPasswordComponent implements OnInit {
this.masterPasswordService.forceSetPasswordReason$(this.userId),
);
if (this.forceSetPasswordReason === ForceSetPasswordReason.SsoNewJitProvisionedUser) {
this.userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER;
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
pageTitle: { key: "joinOrganization" },
pageSubtitle: { key: "finishJoiningThisOrganizationBySettingAMasterPassword" },
});
}
if (
this.forceSetPasswordReason ===
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission
@@ -104,20 +123,35 @@ export class SetInitialPasswordComponent implements OnInit {
pageTitle: { key: "setMasterPassword" },
pageSubtitle: { key: "orgPermissionsUpdatedMustSetPassword" },
});
} else {
this.userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER;
}
if (this.forceSetPasswordReason === ForceSetPasswordReason.TdeOffboarding) {
this.userType = SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER;
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
pageTitle: { key: "joinOrganization" },
pageSubtitle: { key: "finishJoiningThisOrganizationBySettingAMasterPassword" },
pageTitle: { key: "setMasterPassword" },
pageSubtitle: { key: "tdeDisabledMasterPasswordRequired" },
});
}
// If we somehow end up here without a reason, navigate to root
if (this.forceSetPasswordReason === ForceSetPasswordReason.None) {
await this.router.navigate(["/"]);
}
}
private async handleQueryParams() {
private async getOrgInfo() {
if (!this.userId) {
throw new Error("userId not found. Could not handle query params.");
}
if (this.userType === SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER) {
this.masterPasswordPolicyOptions =
(await firstValueFrom(this.policyService.masterPasswordPolicyOptions$(this.userId))) ??
null;
return;
}
const qParams = await firstValueFrom(this.activatedRoute.queryParams);
this.orgSsoIdentifier =
@@ -146,38 +180,34 @@ export class SetInitialPasswordComponent implements OnInit {
protected async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) {
this.submitting = true;
if (!passwordInputResult.newMasterKey) {
throw new Error("newMasterKey not found. Could not set initial password.");
}
if (!passwordInputResult.newServerMasterKeyHash) {
throw new Error("newServerMasterKeyHash not found. Could not set initial password.");
}
if (!passwordInputResult.newLocalMasterKeyHash) {
throw new Error("newLocalMasterKeyHash not found. Could not set initial password.");
}
// newPasswordHint can have an empty string as a valid value, so we specifically check for null or undefined
if (passwordInputResult.newPasswordHint == null) {
throw new Error("newPasswordHint not found. Could not set initial password.");
}
if (!passwordInputResult.kdfConfig) {
throw new Error("kdfConfig not found. Could not set initial password.");
}
if (!this.userId) {
throw new Error("userId not found. Could not set initial password.");
}
if (!this.userType) {
throw new Error("userType not found. Could not set initial password.");
}
if (!this.orgSsoIdentifier) {
throw new Error("orgSsoIdentifier not found. Could not set initial password.");
}
if (!this.orgId) {
throw new Error("orgId not found. Could not set initial password.");
}
// resetPasswordAutoEnroll can have `false` as a valid value, so we specifically check for null or undefined
if (this.resetPasswordAutoEnroll == null) {
throw new Error("resetPasswordAutoEnroll not found. Could not set initial password.");
switch (this.userType) {
case SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER:
case SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP:
await this.setInitialPassword(passwordInputResult);
break;
case SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER:
await this.setInitialPasswordTdeOffboarding(passwordInputResult);
break;
default:
this.logService.error(
`Unexpected user type: ${this.userType}. Could not set initial password.`,
);
this.validationService.showError("Unexpected user type. Could not set initial password.");
}
}
private async setInitialPassword(passwordInputResult: PasswordInputResult) {
const ctx = "Could not set initial password.";
assertTruthy(passwordInputResult.newMasterKey, "newMasterKey", ctx);
assertTruthy(passwordInputResult.newServerMasterKeyHash, "newServerMasterKeyHash", ctx);
assertTruthy(passwordInputResult.newLocalMasterKeyHash, "newLocalMasterKeyHash", ctx);
assertTruthy(passwordInputResult.kdfConfig, "kdfConfig", ctx);
assertTruthy(this.orgSsoIdentifier, "orgSsoIdentifier", ctx);
assertTruthy(this.orgId, "orgId", ctx);
assertTruthy(this.userType, "userType", ctx);
assertTruthy(this.userId, "userId", ctx);
assertNonNullish(passwordInputResult.newPasswordHint, "newPasswordHint", ctx); // can have an empty string as a valid value, so check non-nullish
assertNonNullish(this.resetPasswordAutoEnroll, "resetPasswordAutoEnroll", ctx); // can have `false` as a valid value, so check non-nullish
try {
const credentials: SetInitialPasswordCredentials = {
@@ -202,11 +232,44 @@ export class SetInitialPasswordComponent implements OnInit {
this.submitting = false;
await this.router.navigate(["vault"]);
} catch (e) {
this.logService.error("Error setting initial password", e);
this.validationService.showError(e);
this.submitting = false;
}
}
private async setInitialPasswordTdeOffboarding(passwordInputResult: PasswordInputResult) {
const ctx = "Could not set initial password.";
assertTruthy(passwordInputResult.newMasterKey, "newMasterKey", ctx);
assertTruthy(passwordInputResult.newServerMasterKeyHash, "newServerMasterKeyHash", ctx);
assertTruthy(this.userId, "userId", ctx);
assertNonNullish(passwordInputResult.newPasswordHint, "newPasswordHint", ctx); // can have an empty string as a valid value, so check non-nullish
try {
const credentials: SetInitialPasswordTdeOffboardingCredentials = {
newMasterKey: passwordInputResult.newMasterKey,
newServerMasterKeyHash: passwordInputResult.newServerMasterKeyHash,
newPasswordHint: passwordInputResult.newPasswordHint,
};
await this.setInitialPasswordService.setInitialPasswordTdeOffboarding(
credentials,
this.userId,
);
this.showSuccessToastByUserType();
await this.logoutService.logout(this.userId);
// navigate to root so redirect guard can properly route next active user or null user to correct page
await this.router.navigate(["/"]);
} catch (e) {
this.logService.error("Error setting initial password during TDE offboarding", e);
this.validationService.showError(e);
} finally {
this.submitting = false;
}
}
private showSuccessToastByUserType() {
if (this.userType === SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER) {
this.toastService.showToast({
@@ -220,12 +283,7 @@ export class SetInitialPasswordComponent implements OnInit {
title: "",
message: this.i18nService.t("inviteAccepted"),
});
}
if (
this.userType ===
SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP
) {
} else {
this.toastService.showToast({
variant: "success",
title: "",

View File

@@ -19,6 +19,12 @@ export const _SetInitialPasswordUserType = {
*/
TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP:
"tde_org_user_reset_password_permission_requires_mp",
/**
* A user in an org that offboarded from trusted device encryption and is now a
* master-password-encryption org
*/
OFFBOARDED_TDE_ORG_USER: "offboarded_tde_org_user",
} as const;
type _SetInitialPasswordUserType = typeof _SetInitialPasswordUserType;
@@ -40,6 +46,12 @@ export interface SetInitialPasswordCredentials {
resetPasswordAutoEnroll: boolean;
}
export interface SetInitialPasswordTdeOffboardingCredentials {
newMasterKey: MasterKey;
newServerMasterKeyHash: string;
newPasswordHint: string;
}
/**
* Handles setting an initial password for an existing authed user.
*
@@ -61,4 +73,17 @@ export abstract class SetInitialPasswordService {
userType: SetInitialPasswordUserType,
userId: UserId,
) => Promise<void>;
/**
* Sets an initial password for a user who logs in after their org offboarded from
* trusted device encryption and is now a master-password-encryption org:
* - {@link SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER}
*
* @param passwordInputResult credentials object received from the `InputPasswordComponent`
* @param userId the account `userId`
*/
abstract setInitialPasswordTdeOffboarding: (
credentials: SetInitialPasswordTdeOffboardingCredentials,
userId: UserId,
) => Promise<void>;
}

View File

@@ -0,0 +1,45 @@
/**
* Asserts that a value is non-nullish (not `null` or `undefined`); throws if value is nullish.
*
* @param val the value to check
* @param name the name of the value to include in the error message
* @param ctx context to optionally append to the error message
* @throws if the value is null or undefined
*
* @example
*
* ```
* // `newPasswordHint` can have an empty string as a valid value, so we check non-nullish
* this.assertNonNullish(
* passwordInputResult.newPasswordHint,
* "newPasswordHint",
* "Could not set initial password."
* );
* // Output error message: "newPasswordHint is null or undefined. Could not set initial password."
* ```
*
* @remarks
*
* If you use this method repeatedly to check several values, it may help to assign any
* additional context (`ctx`) to a variable and pass it in to each call. This prevents the
* call from reformatting vertically via prettier in your text editor, taking up multiple lines.
*
* For example:
* ```
* const ctx = "Could not set initial password.";
*
* this.assertNonNullish(valueOne, "valueOne", ctx);
* this.assertNonNullish(valueTwo, "valueTwo", ctx);
* this.assertNonNullish(valueThree, "valueThree", ctx);
* ```
*/
export function assertNonNullish<T>(
val: T,
name: string,
ctx?: string,
): asserts val is NonNullable<T> {
if (val == null) {
// If context is provided, append it to the error message with a space before it.
throw new Error(`${name} is null or undefined.${ctx ? ` ${ctx}` : ""}`);
}
}

View File

@@ -0,0 +1,46 @@
/**
* Asserts that a value is truthy; throws if value is falsy.
*
* @param val the value to check
* @param name the name of the value to include in the error message
* @param ctx context to optionally append to the error message
* @throws if the value is falsy (`false`, `""`, `0`, `null`, `undefined`, `void`, or `NaN`)
*
* @example
*
* ```
* this.assertTruthy(
* this.organizationId,
* "organizationId",
* "Could not set initial password."
* );
* // Output error message: "organizationId is falsy. Could not set initial password."
* ```
*
* @remarks
*
* If you use this method repeatedly to check several values, it may help to assign any
* additional context (`ctx`) to a variable and pass it in to each call. This prevents the
* call from reformatting vertically via prettier in your text editor, taking up multiple lines.
*
* For example:
* ```
* const ctx = "Could not set initial password.";
*
* this.assertTruthy(valueOne, "valueOne", ctx);
* this.assertTruthy(valueTwo, "valueTwo", ctx);
* this.assertTruthy(valueThree, "valueThree", ctx);
*/
export function assertTruthy<T>(
val: T,
name: string,
ctx?: string,
): asserts val is Exclude<T, false | "" | 0 | null | undefined | void | 0n> {
// Because `NaN` is a value (not a type) of type 'number', that means we cannot add
// it to the list of falsy values in the type assertion. Instead, we check for it
// separately at runtime.
if (!val || (typeof val === "number" && Number.isNaN(val))) {
// If context is provided, append it to the error message with a space before it.
throw new Error(`${name} is falsy.${ctx ? ` ${ctx}` : ""}`);
}
}

View File

@@ -0,0 +1,2 @@
export { assertTruthy } from "./assert-truthy.util";
export { assertNonNullish } from "./assert-non-nullish.util";

View File

@@ -70,7 +70,7 @@ export class Fido2AuthenticatorError extends Error {
}
export interface PublicKeyCredentialDescriptor {
id: Uint8Array;
id: ArrayBuffer;
transports?: ("ble" | "hybrid" | "internal" | "nfc" | "usb")[];
type: "public-key";
}
@@ -155,9 +155,9 @@ export interface Fido2AuthenticatorGetAssertionParams {
export interface Fido2AuthenticatorGetAssertionResult {
selectedCredential: {
id: Uint8Array;
userHandle?: Uint8Array;
id: ArrayBuffer;
userHandle?: ArrayBuffer;
};
authenticatorData: Uint8Array;
signature: Uint8Array;
authenticatorData: ArrayBuffer;
signature: ArrayBuffer;
}

View File

@@ -1,9 +1 @@
import { LogLevelType } from "../enums/log-level-type.enum";
export abstract class LogService {
abstract debug(message?: any, ...optionalParams: any[]): void;
abstract info(message?: any, ...optionalParams: any[]): void;
abstract warning(message?: any, ...optionalParams: any[]): void;
abstract error(message?: any, ...optionalParams: any[]): void;
abstract write(level: LogLevelType, message?: any, ...optionalParams: any[]): void;
}
export { LogService } from "@bitwarden/logging";

View File

@@ -1,8 +1 @@
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum LogLevelType {
Debug,
Info,
Warning,
Error,
}
export { LogLevel as LogLevelType } from "@bitwarden/logging";

View File

@@ -31,22 +31,35 @@ export type TimeoutManager = {
class SignalRLogger implements ILogger {
constructor(private readonly logService: LogService) {}
redactMessage(message: string): string {
const ACCESS_TOKEN_TEXT = "access_token=";
// Redact the access token from the logs if it exists.
const accessTokenIndex = message.indexOf(ACCESS_TOKEN_TEXT);
if (accessTokenIndex !== -1) {
return message.substring(0, accessTokenIndex + ACCESS_TOKEN_TEXT.length) + "[REDACTED]";
}
return message;
}
log(logLevel: LogLevel, message: string): void {
const redactedMessage = `[SignalR] ${this.redactMessage(message)}`;
switch (logLevel) {
case LogLevel.Critical:
this.logService.error(message);
this.logService.error(redactedMessage);
break;
case LogLevel.Error:
this.logService.error(message);
this.logService.error(redactedMessage);
break;
case LogLevel.Warning:
this.logService.warning(message);
this.logService.warning(redactedMessage);
break;
case LogLevel.Information:
this.logService.info(message);
this.logService.info(redactedMessage);
break;
case LogLevel.Debug:
this.logService.debug(message);
this.logService.debug(redactedMessage);
break;
}
}

View File

@@ -1,6 +1,6 @@
import { interceptConsole, restoreConsole } from "../../../spec";
import { ConsoleLogService } from "@bitwarden/logging";
import { ConsoleLogService } from "./console-log.service";
import { interceptConsole, restoreConsole } from "../../../spec";
describe("ConsoleLogService", () => {
const error = new Error("this is an error");

View File

@@ -1,59 +1 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { LogService as LogServiceAbstraction } from "../abstractions/log.service";
import { LogLevelType } from "../enums/log-level-type.enum";
export class ConsoleLogService implements LogServiceAbstraction {
protected timersMap: Map<string, [number, number]> = new Map();
constructor(
protected isDev: boolean,
protected filter: (level: LogLevelType) => boolean = null,
) {}
debug(message?: any, ...optionalParams: any[]) {
if (!this.isDev) {
return;
}
this.write(LogLevelType.Debug, message, ...optionalParams);
}
info(message?: any, ...optionalParams: any[]) {
this.write(LogLevelType.Info, message, ...optionalParams);
}
warning(message?: any, ...optionalParams: any[]) {
this.write(LogLevelType.Warning, message, ...optionalParams);
}
error(message?: any, ...optionalParams: any[]) {
this.write(LogLevelType.Error, message, ...optionalParams);
}
write(level: LogLevelType, message?: any, ...optionalParams: any[]) {
if (this.filter != null && this.filter(level)) {
return;
}
switch (level) {
case LogLevelType.Debug:
// eslint-disable-next-line
console.log(message, ...optionalParams);
break;
case LogLevelType.Info:
// eslint-disable-next-line
console.log(message, ...optionalParams);
break;
case LogLevelType.Warning:
// eslint-disable-next-line
console.warn(message, ...optionalParams);
break;
case LogLevelType.Error:
// eslint-disable-next-line
console.error(message, ...optionalParams);
break;
default:
break;
}
}
}
export { ConsoleLogService } from "@bitwarden/logging";

View File

@@ -9,7 +9,7 @@ describe("credential-id-utils", () => {
new Uint8Array([
0x08, 0xd7, 0x0b, 0x74, 0xe9, 0xf5, 0x45, 0x22, 0xa4, 0x25, 0xe5, 0xdc, 0xd4, 0x01, 0x07,
0xe7,
]),
]).buffer,
);
});
@@ -20,7 +20,7 @@ describe("credential-id-utils", () => {
new Uint8Array([
0x08, 0xd7, 0x0b, 0x74, 0xe9, 0xf5, 0x45, 0x22, 0xa4, 0x25, 0xe5, 0xdc, 0xd4, 0x01, 0x07,
0xe7,
]),
]).buffer,
);
});

View File

@@ -3,13 +3,13 @@
import { Fido2Utils } from "./fido2-utils";
import { guidToRawFormat } from "./guid-utils";
export function parseCredentialId(encodedCredentialId: string): Uint8Array {
export function parseCredentialId(encodedCredentialId: string): ArrayBuffer {
try {
if (encodedCredentialId.startsWith("b64.")) {
return Fido2Utils.stringToBuffer(encodedCredentialId.slice(4));
}
return guidToRawFormat(encodedCredentialId);
return guidToRawFormat(encodedCredentialId).buffer;
} catch {
return undefined;
}
@@ -18,13 +18,16 @@ export function parseCredentialId(encodedCredentialId: string): Uint8Array {
/**
* Compares two credential IDs for equality.
*/
export function compareCredentialIds(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) {
export function compareCredentialIds(a: ArrayBuffer, b: ArrayBuffer): boolean {
if (a.byteLength !== b.byteLength) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
const viewA = new Uint8Array(a);
const viewB = new Uint8Array(b);
for (let i = 0; i < viewA.length; i++) {
if (viewA[i] !== viewB[i]) {
return false;
}
}

View File

@@ -514,7 +514,7 @@ async function getPrivateKeyFromFido2Credential(
const keyBuffer = Fido2Utils.stringToBuffer(fido2Credential.keyValue);
return await crypto.subtle.importKey(
"pkcs8",
keyBuffer,
new Uint8Array(keyBuffer),
{
name: fido2Credential.keyAlgorithm,
namedCurve: fido2Credential.keyCurve,

View File

@@ -127,9 +127,9 @@ export class Fido2ClientService<ParentWindowReference>
}
const userId = Fido2Utils.stringToBuffer(params.user.id);
if (userId.length < 1 || userId.length > 64) {
if (userId.byteLength < 1 || userId.byteLength > 64) {
this.logService?.warning(
`[Fido2Client] Invalid 'user.id' length: ${params.user.id} (${userId.length})`,
`[Fido2Client] Invalid 'user.id' length: ${params.user.id} (${userId.byteLength})`,
);
throw new TypeError("Invalid 'user.id' length");
}

View File

@@ -49,8 +49,8 @@ export class Fido2Utils {
.replace(/=/g, "");
}
static stringToBuffer(str: string): Uint8Array {
return Fido2Utils.fromB64ToArray(Fido2Utils.fromUrlB64ToB64(str));
static stringToBuffer(str: string): ArrayBuffer {
return Fido2Utils.fromB64ToArray(Fido2Utils.fromUrlB64ToB64(str)).buffer;
}
static bufferSourceToUint8Array(bufferSource: BufferSource): Uint8Array {

View File

@@ -6,12 +6,13 @@ import { any, mock } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom, map, of, timeout } from "rxjs";
import { Jsonify } from "type-fest";
import { StorageServiceProvider } from "@bitwarden/storage-core";
import { awaitAsync, trackEmissions } from "../../../../spec";
import { FakeStorageService } from "../../../../spec/fake-storage.service";
import { Account } from "../../../auth/abstractions/account.service";
import { UserId } from "../../../types/guid";
import { LogService } from "../../abstractions/log.service";
import { StorageServiceProvider } from "../../services/storage-service.provider";
import { StateDefinition } from "../state-definition";
import { StateEventRegistrarService } from "../state-event-registrar.service";
import { UserKeyDefinition } from "../user-key-definition";

View File

@@ -1,7 +1,8 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { StorageServiceProvider } from "@bitwarden/storage-core";
import { LogService } from "../../abstractions/log.service";
import { StorageServiceProvider } from "../../services/storage-service.provider";
import { GlobalState } from "../global-state";
import { GlobalStateProvider } from "../global-state.provider";
import { KeyDefinition } from "../key-definition";

View File

@@ -1,8 +1,6 @@
import { AbstractStorageService, ObservableStorageService } from "@bitwarden/storage-core";
import { LogService } from "../../abstractions/log.service";
import {
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { GlobalState } from "../global-state";
import { KeyDefinition, globalKeyBuilder } from "../key-definition";

View File

@@ -1,8 +1,9 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { StorageServiceProvider } from "@bitwarden/storage-core";
import { UserId } from "../../../types/guid";
import { LogService } from "../../abstractions/log.service";
import { StorageServiceProvider } from "../../services/storage-service.provider";
import { StateEventRegistrarService } from "../state-event-registrar.service";
import { UserKeyDefinition } from "../user-key-definition";
import { SingleUserState } from "../user-state";

View File

@@ -1,11 +1,9 @@
import { Observable, combineLatest, of } from "rxjs";
import { AbstractStorageService, ObservableStorageService } from "@bitwarden/storage-core";
import { UserId } from "../../../types/guid";
import { LogService } from "../../abstractions/log.service";
import {
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { StateEventRegistrarService } from "../state-event-registrar.service";
import { UserKeyDefinition } from "../user-key-definition";
import { CombinedState, SingleUserState } from "../user-state";

View File

@@ -1,10 +1,11 @@
import { mock } from "jest-mock-extended";
import { StorageServiceProvider } from "@bitwarden/storage-core";
import { mockAccountServiceWith } from "../../../../spec/fake-account-service";
import { FakeStorageService } from "../../../../spec/fake-storage.service";
import { UserId } from "../../../types/guid";
import { LogService } from "../../abstractions/log.service";
import { StorageServiceProvider } from "../../services/storage-service.provider";
import { KeyDefinition } from "../key-definition";
import { StateDefinition } from "../state-definition";
import { StateEventRegistrarService } from "../state-event-registrar.service";

View File

@@ -15,12 +15,10 @@ import {
} from "rxjs";
import { Jsonify } from "type-fest";
import { AbstractStorageService, ObservableStorageService } from "@bitwarden/storage-core";
import { StorageKey } from "../../../types/state";
import { LogService } from "../../abstractions/log.service";
import {
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { DebugOptions } from "../key-definition";
import { populateOptionsWithDefault, StateUpdateOptions } from "../state-update-options";

View File

@@ -1,6 +1,6 @@
import { Jsonify } from "type-fest";
import { AbstractStorageService } from "../../abstractions/storage.service";
import { AbstractStorageService } from "@bitwarden/storage-core";
export async function getStoredValue<T>(
key: string,

View File

@@ -1,8 +1,12 @@
import { mock } from "jest-mock-extended";
import {
AbstractStorageService,
ObservableStorageService,
StorageServiceProvider,
} from "@bitwarden/storage-core";
import { FakeGlobalStateProvider } from "../../../spec";
import { AbstractStorageService, ObservableStorageService } from "../abstractions/storage.service";
import { StorageServiceProvider } from "../services/storage-service.provider";
import { StateDefinition } from "./state-definition";
import { STATE_LOCK_EVENT, StateEventRegistrarService } from "./state-event-registrar.service";

View File

@@ -1,9 +1,13 @@
import { mock } from "jest-mock-extended";
import {
AbstractStorageService,
ObservableStorageService,
StorageServiceProvider,
} from "@bitwarden/storage-core";
import { FakeGlobalStateProvider } from "../../../spec";
import { UserId } from "../../types/guid";
import { AbstractStorageService, ObservableStorageService } from "../abstractions/storage.service";
import { StorageServiceProvider } from "../services/storage-service.provider";
import { STATE_LOCK_EVENT } from "./state-event-registrar.service";
import { StateEventRunnerService } from "./state-event-runner.service";

View File

@@ -2,8 +2,9 @@
// @ts-strict-ignore
import { firstValueFrom } from "rxjs";
import { StorageServiceProvider } from "@bitwarden/storage-core";
import { UserId } from "../../types/guid";
import { StorageServiceProvider } from "../services/storage-service.provider";
import { GlobalState } from "./global-state";
import { GlobalStateProvider } from "./global-state.provider";

View File

@@ -2,7 +2,9 @@ import { Opaque } from "type-fest";
export type Guid = Opaque<string, "Guid">;
export type UserId = Opaque<string, "UserId">;
// Convenience re-export of UserId from it's original location, any library that
// wants to be lower level than common should instead import it from user-core.
export { UserId } from "@bitwarden/user-core";
export type OrganizationId = Opaque<string, "OrganizationId">;
export type CollectionId = Opaque<string, "CollectionId">;
export type ProviderId = Opaque<string, "ProviderId">;

View File

@@ -72,6 +72,7 @@ export default {
decorators: [
moduleMetadata({
imports: [
A11yTitleDirective,
FormsModule,
ReactiveFormsModule,
FormFieldModule,
@@ -88,7 +89,6 @@ export default {
TextFieldModule,
BadgeModule,
],
declarations: [A11yTitleDirective],
providers: [
{
provide: I18nService,

View File

@@ -1,13 +1,6 @@
import {
AbstractControl,
FormBuilder,
FormsModule,
ReactiveFormsModule,
ValidationErrors,
ValidatorFn,
Validators,
} from "@angular/forms";
import { FormBuilder, FormsModule, ReactiveFormsModule, Validators } from "@angular/forms";
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { userEvent, getByText } from "@storybook/test";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -15,6 +8,7 @@ import { ButtonModule } from "../button";
import { CheckboxModule } from "../checkbox";
import { FormControlModule } from "../form-control";
import { FormFieldModule } from "../form-field";
import { trimValidator, forbiddenCharacters } from "../form-field/bit-validators";
import { InputModule } from "../input/input.module";
import { MultiSelectModule } from "../multi-select";
import { RadioButtonModule } from "../radio-button";
@@ -48,13 +42,19 @@ export default {
required: "required",
checkboxRequired: "Option is required",
inputRequired: "Input is required.",
inputEmail: "Input is not an email-address.",
inputEmail: "Input is not an email address.",
inputForbiddenCharacters: (char) =>
`The following characters are not allowed: "${char}"`,
inputMinValue: (min) => `Input value must be at least ${min}.`,
inputMaxValue: (max) => `Input value must not exceed ${max}.`,
inputMinLength: (min) => `Input value must be at least ${min} characters long.`,
inputMaxLength: (max) => `Input value must not exceed ${max} characters in length.`,
inputTrimValidator: `Input must not contain only whitespace.`,
multiSelectPlaceholder: "-- Type to Filter --",
multiSelectLoading: "Retrieving options...",
multiSelectNotFound: "No items found",
multiSelectClearAll: "Clear all",
fieldsNeedAttention: "__$1__ field(s) above need your attention.",
});
},
},
@@ -72,7 +72,7 @@ export default {
const fb = new FormBuilder();
const exampleFormObj = fb.group({
name: ["", [Validators.required]],
email: ["", [Validators.required, Validators.email, forbiddenNameValidator(/bit/i)]],
email: ["", [Validators.required, Validators.email, forbiddenCharacters(["#"])]],
country: [undefined as string | undefined, [Validators.required]],
groups: [],
terms: [false, [Validators.requiredTrue]],
@@ -80,14 +80,6 @@ const exampleFormObj = fb.group({
age: [null, [Validators.min(0), Validators.max(150)]],
});
// Custom error message, `message` is shown as the error message
function forbiddenNameValidator(nameRe: RegExp): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const forbidden = nameRe.test(control.value);
return forbidden ? { forbiddenName: { message: "forbiddenName" } } : null;
};
}
type Story = StoryObj;
export const FullExample: Story = {
@@ -177,3 +169,95 @@ export const FullExample: Story = {
],
},
};
const showValidationsFormObj = fb.group({
required: ["", [Validators.required]],
whitespace: [" ", trimValidator],
email: ["example?bad-email", [Validators.email]],
minLength: ["Hello", [Validators.minLength(8)]],
maxLength: ["Hello there", [Validators.maxLength(8)]],
minValue: [9, [Validators.min(10)]],
maxValue: [15, [Validators.max(10)]],
forbiddenChars: ["Th!$ value cont#in$ forbidden char$", forbiddenCharacters(["#", "!", "$"])],
});
export const Validations: Story = {
render: (args) => ({
props: {
formObj: showValidationsFormObj,
submit: () => showValidationsFormObj.markAllAsTouched(),
...args,
},
template: /*html*/ `
<form [formGroup]="formObj" (ngSubmit)="submit()">
<bit-form-field>
<bit-label>Required validation</bit-label>
<input bitInput formControlName="required" />
<bit-hint>This field is required. Submit form or blur input to see error</bit-hint>
</bit-form-field>
<bit-form-field>
<bit-label>Email validation</bit-label>
<input bitInput type="email" formControlName="email" />
<bit-hint>This field contains a malformed email address. Submit form or blur input to see error</bit-hint>
</bit-form-field>
<bit-form-field>
<bit-label>Min length validation</bit-label>
<input bitInput formControlName="minLength" />
<bit-hint>Value must be at least 8 characters. Submit form or blur input to see error</bit-hint>
</bit-form-field>
<bit-form-field>
<bit-label>Max length validation</bit-label>
<input bitInput formControlName="maxLength" />
<bit-hint>Value must be less then 8 characters. Submit form or blur input to see error</bit-hint>
</bit-form-field>
<bit-form-field>
<bit-label>Min number value validation</bit-label>
<input
bitInput
type="number"
formControlName="minValue"
/>
<bit-hint>Value must be greater than 10. Submit form or blur input to see error</bit-hint>
</bit-form-field>
<bit-form-field>
<bit-label>Max number value validation</bit-label>
<input
bitInput
type="number"
formControlName="maxValue"
/>
<bit-hint>Value must be less than than 10. Submit form or blur input to see error</bit-hint>
</bit-form-field>
<bit-form-field>
<bit-label>Forbidden characters validation</bit-label>
<input
bitInput
formControlName="forbiddenChars"
/>
<bit-hint>Value must not contain '#', '!' or '$'. Submit form or blur input to see error</bit-hint>
</bit-form-field>
<bit-form-field>
<bit-label>White space validation</bit-label>
<input bitInput formControlName="whitespace" />
<bit-hint>This input contains only white space. Submit form or blur input to see error</bit-hint>
</bit-form-field>
<button type="submit" bitButton buttonType="primary">Submit</button>
<bit-error-summary [formGroup]="formObj"></bit-error-summary>
</form>
`,
}),
play: async (context) => {
const canvas = context.canvasElement;
const submitButton = getByText(canvas, "Submit");
await userEvent.click(submitButton);
},
};

View File

@@ -142,8 +142,20 @@ If a checkbox group has more than 4 options a
<Canvas of={checkboxStories.Default} />
## Validation messages
These are examples of our default validation error messages:
<Canvas of={formStories.Validations} />
## Accessibility
### Icon Buttons in Form Fields
When adding prefix or suffix icon buttons to a form field, be sure to use the `appA11yTitle`
directive to provide a label for screenreaders. Typically, the label should follow this pattern:
`{Action} {field label}`, i.e. "Copy username".
### Required Fields
- Use "(required)" in the label of each required form field styled the same as the field's helper
@@ -152,12 +164,6 @@ If a checkbox group has more than 4 options a
helper text.
- **Example:** "Billing Email is required if owned by a business".
### Icon Buttons in Form Fields
When adding prefix or suffix icon buttons to a form field, be sure to use the `appA11yTitle`
directive to provide a label for screenreaders. Typically, the label should follow this pattern:
`{Action} {field label}`, i.e. "Copy username".
### Form Field Errors
- When a resting field is filled out, validation is triggered when the user de-focuses the field

5
libs/logging/README.md Normal file
View File

@@ -0,0 +1,5 @@
# logging
Owned by: platform
Logging primitives

View File

@@ -0,0 +1,3 @@
import baseConfig from "../../eslint.config.mjs";
export default [...baseConfig];

View File

@@ -0,0 +1,10 @@
module.exports = {
displayName: "logging",
preset: "../../jest.preset.js",
testEnvironment: "node",
transform: {
"^.+\\.[tj]s$": ["ts-jest", { tsconfig: "<rootDir>/tsconfig.spec.json" }],
},
moduleFileExtensions: ["ts", "js", "html"],
coverageDirectory: "../../coverage/libs/logging",
};

11
libs/logging/package.json Normal file
View File

@@ -0,0 +1,11 @@
{
"name": "@bitwarden/logging",
"version": "0.0.1",
"description": "Logging primitives",
"private": true,
"type": "commonjs",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "GPL-3.0",
"author": "platform"
}

33
libs/logging/project.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "logging",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/logging/src",
"projectType": "library",
"tags": [],
"targets": {
"build": {
"executor": "@nx/js:tsc",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/libs/logging",
"main": "libs/logging/src/index.ts",
"tsConfig": "libs/logging/tsconfig.lib.json",
"assets": ["libs/logging/*.md"]
}
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["libs/logging/**/*.ts"]
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/logging/jest.config.js"
}
}
}
}

View File

@@ -0,0 +1,57 @@
import { LogLevel } from "./log-level";
import { LogService } from "./log.service";
export class ConsoleLogService implements LogService {
protected timersMap: Map<string, [number, number]> = new Map();
constructor(
protected isDev: boolean,
protected filter: ((level: LogLevel) => boolean) | null = null,
) {}
debug(message?: any, ...optionalParams: any[]) {
if (!this.isDev) {
return;
}
this.write(LogLevel.Debug, message, ...optionalParams);
}
info(message?: any, ...optionalParams: any[]) {
this.write(LogLevel.Info, message, ...optionalParams);
}
warning(message?: any, ...optionalParams: any[]) {
this.write(LogLevel.Warning, message, ...optionalParams);
}
error(message?: any, ...optionalParams: any[]) {
this.write(LogLevel.Error, message, ...optionalParams);
}
write(level: LogLevel, message?: any, ...optionalParams: any[]) {
if (this.filter != null && this.filter(level)) {
return;
}
switch (level) {
case LogLevel.Debug:
// eslint-disable-next-line
console.log(message, ...optionalParams);
break;
case LogLevel.Info:
// eslint-disable-next-line
console.log(message, ...optionalParams);
break;
case LogLevel.Warning:
// eslint-disable-next-line
console.warn(message, ...optionalParams);
break;
case LogLevel.Error:
// eslint-disable-next-line
console.error(message, ...optionalParams);
break;
default:
break;
}
}
}

View File

@@ -0,0 +1,3 @@
export { LogService } from "./log.service";
export { LogLevel } from "./log-level";
export { ConsoleLogService } from "./console-log.service";

View File

@@ -0,0 +1,8 @@
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum LogLevel {
Debug,
Info,
Warning,
Error,
}

View File

@@ -0,0 +1,9 @@
import { LogLevel } from "./log-level";
export abstract class LogService {
abstract debug(message?: any, ...optionalParams: any[]): void;
abstract info(message?: any, ...optionalParams: any[]): void;
abstract warning(message?: any, ...optionalParams: any[]): void;
abstract error(message?: any, ...optionalParams: any[]): void;
abstract write(level: LogLevel, message?: any, ...optionalParams: any[]): void;
}

View File

@@ -0,0 +1,8 @@
import * as lib from "./index";
describe("logging", () => {
// This test will fail until something is exported from index.ts
it("should work", () => {
expect(lib).toBeDefined();
});
});

View File

@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"declaration": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["jest.config.js", "src/**/*.spec.ts"]
}

View File

@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../..//dist/out-tsc",
"module": "commonjs",
"moduleResolution": "node10",
"types": ["jest", "node"]
},
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts",
"src/intercept-console.ts"
]
}

6
libs/user-core/README.md Normal file
View File

@@ -0,0 +1,6 @@
# user-core
Owned by: auth
The very basic concept that constitutes a user, this needs to be very low level to facilitate
Platform keeping their own code low level.

View File

@@ -0,0 +1,3 @@
import baseConfig from "../../eslint.config.mjs";
export default [...baseConfig];

View File

@@ -0,0 +1,10 @@
module.exports = {
displayName: "user-core",
preset: "../../jest.preset.js",
testEnvironment: "node",
transform: {
"^.+\\.[tj]s$": ["ts-jest", { tsconfig: "<rootDir>/tsconfig.spec.json" }],
},
moduleFileExtensions: ["ts", "js", "html"],
coverageDirectory: "../../coverage/libs/user-core",
};

View File

@@ -0,0 +1,10 @@
{
"name": "@bitwarden/user-core",
"version": "0.0.0",
"description": "The very basic concept that constitutes a user, this needs to be very low level to facilitate Platform keeping their own code low level.",
"type": "commonjs",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "GPL-3.0",
"author": "auth"
}

View File

@@ -0,0 +1,27 @@
{
"name": "user-core",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/user-core/src",
"projectType": "library",
"tags": [],
"targets": {
"build": {
"executor": "@nx/js:tsc",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/libs/user-core",
"main": "libs/user-core/src/index.ts",
"tsConfig": "libs/user-core/tsconfig.lib.json",
"assets": ["libs/user-core/*.md"]
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/user-core/jest.config.js",
"passWithNoTests": true
}
}
}
}

View File

@@ -0,0 +1,9 @@
import { Opaque } from "type-fest";
/**
* The main identifier for a user. It is a string that should be in valid guid format.
*
* You should avoid `as UserId`-ing strings as much as possible and instead retrieve the {@see UserId} from
* a valid source instead.
*/
export type UserId = Opaque<string, "UserId">;

View File

@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"declaration": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["jest.config.js", "src/**/*.spec.ts"]
}

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"moduleResolution": "node10",
"types": ["jest", "node"]
},
"include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"]
}

View File

@@ -19,6 +19,7 @@ import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
import { NudgeStatus, NudgesService } from "@bitwarden/angular/vault";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
@@ -243,6 +244,7 @@ export default {
provide: ConfigService,
useValue: {
getFeatureFlag: () => Promise.resolve(false),
getFeatureFlag$: () => new BehaviorSubject(false),
},
},
{
@@ -253,6 +255,12 @@ export default {
},
},
},
{
provide: PolicyService,
useValue: {
policiesByType$: new BehaviorSubject([]),
},
},
],
}),
componentWrapperDecorator(

View File

@@ -3,18 +3,24 @@ import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testin
import { ReactiveFormsModule } from "@angular/forms";
import { By } from "@angular/platform-browser";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { BehaviorSubject, of } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionTypes, CollectionView } from "@bitwarden/admin-console/common";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { SelectComponent } from "@bitwarden/components";
import { CipherFormConfig } from "../../abstractions/cipher-form-config.service";
import {
CipherFormConfig,
OptionalInitialValues,
} from "../../abstractions/cipher-form-config.service";
import { CipherFormContainer } from "../../cipher-form-container";
import { ItemDetailsSectionComponent } from "./item-details-section.component";
@@ -48,6 +54,8 @@ describe("ItemDetailsSectionComponent", () => {
let fixture: ComponentFixture<ItemDetailsSectionComponent>;
let cipherFormProvider: MockProxy<CipherFormContainer>;
let i18nService: MockProxy<I18nService>;
let mockConfigService: MockProxy<ConfigService>;
let mockPolicyService: MockProxy<PolicyService>;
const activeAccount$ = new BehaviorSubject<{ email: string }>({ email: "test@example.com" });
const getInitialCipherView = jest.fn(() => null);
@@ -66,12 +74,19 @@ describe("ItemDetailsSectionComponent", () => {
compare: (a: string, b: string) => a.localeCompare(b),
} as Intl.Collator;
mockConfigService = mock<ConfigService>();
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
mockPolicyService = mock<PolicyService>();
mockPolicyService.policiesByType$.mockReturnValue(of([]));
await TestBed.configureTestingModule({
imports: [ItemDetailsSectionComponent, CommonModule, ReactiveFormsModule],
providers: [
{ provide: CipherFormContainer, useValue: cipherFormProvider },
{ provide: I18nService, useValue: i18nService },
{ provide: AccountService, useValue: { activeAccount$ } },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: PolicyService, useValue: mockPolicyService },
],
}).compileComponents();
@@ -369,7 +384,7 @@ describe("ItemDetailsSectionComponent", () => {
expect(collectionSelect).toBeNull();
});
it("should enable/show collection control when an organization is selected", async () => {
it("should enable/show collection control when an organization is selected", fakeAsync(() => {
component.config.organizationDataOwnershipDisabled = true;
component.config.organizations = [{ id: "org1" } as Organization];
component.config.collections = [
@@ -378,12 +393,12 @@ describe("ItemDetailsSectionComponent", () => {
];
fixture.detectChanges();
await fixture.whenStable();
tick();
component.itemDetailsForm.controls.organizationId.setValue("org1");
tick();
fixture.detectChanges();
await fixture.whenStable();
const collectionSelect = fixture.nativeElement.querySelector(
"bit-multi-select[formcontrolname='collectionIds']",
@@ -391,7 +406,7 @@ describe("ItemDetailsSectionComponent", () => {
expect(component.itemDetailsForm.controls.collectionIds.enabled).toBe(true);
expect(collectionSelect).not.toBeNull();
});
}));
it("should set collectionIds to originalCipher collections on first load", async () => {
component.config.mode = "clone";
@@ -488,6 +503,9 @@ describe("ItemDetailsSectionComponent", () => {
component.itemDetailsForm.controls.organizationId.setValue("org1");
fixture.detectChanges();
await fixture.whenStable();
expect(component["collectionOptions"].map((c) => c.id)).toEqual(["col1", "col2", "col3"]);
});
});
@@ -548,4 +566,27 @@ describe("ItemDetailsSectionComponent", () => {
expect(label).toBe("org1");
});
});
describe("getDefaultCollectionId", () => {
it("returns matching default when flag & policy match", async () => {
const def = createMockCollection("def1", "Def", "orgA");
component.config.collections = [def] as CollectionView[];
component.config.initialValues = { collectionIds: [] } as OptionalInitialValues;
mockConfigService.getFeatureFlag.mockResolvedValue(true);
mockPolicyService.policiesByType$.mockReturnValue(of([{ organizationId: "orgA" } as Policy]));
const id = await (component as any).getDefaultCollectionId("orgA");
expect(id).toEqual("def1");
});
it("returns undefined when no default found", async () => {
component.config.collections = [createMockCollection("c1", "C1", "orgB")] as CollectionView[];
component.config.initialValues = { collectionIds: [] } as OptionalInitialValues;
mockConfigService.getFeatureFlag.mockResolvedValue(true);
mockPolicyService.policiesByType$.mockReturnValue(of([{ organizationId: "orgA" } as Policy]));
const result = await (component as any).getDefaultCollectionId("orgA");
expect(result).toBeUndefined();
});
});
});

View File

@@ -4,15 +4,19 @@ import { CommonModule } from "@angular/common";
import { Component, DestroyRef, Input, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms";
import { concatMap, map } from "rxjs";
import { concatMap, firstValueFrom, map } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionView } from "@bitwarden/admin-console/common";
import { CollectionTypes, CollectionView } from "@bitwarden/admin-console/common";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { OrganizationUserType, PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
@@ -124,6 +128,8 @@ export class ItemDetailsSectionComponent implements OnInit {
private i18nService: I18nService,
private destroyRef: DestroyRef,
private accountService: AccountService,
private configService: ConfigService,
private policyService: PolicyService,
) {
this.cipherFormContainer.registerChildForm("itemDetails", this.itemDetailsForm);
this.itemDetailsForm.valueChanges
@@ -200,30 +206,61 @@ export class ItemDetailsSectionComponent implements OnInit {
if (prefillCipher) {
await this.initFromExistingCipher(prefillCipher);
} else {
const orgId = this.initialValues?.organizationId;
this.itemDetailsForm.setValue({
name: this.initialValues?.name || "",
organizationId: this.initialValues?.organizationId || this.defaultOwner,
organizationId: orgId || this.defaultOwner,
folderId: this.initialValues?.folderId || null,
collectionIds: [],
favorite: false,
});
await this.updateCollectionOptions(this.initialValues?.collectionIds || []);
await this.updateCollectionOptions(this.initialValues?.collectionIds);
}
if (!this.allowOwnershipChange) {
this.itemDetailsForm.controls.organizationId.disable();
}
this.itemDetailsForm.controls.organizationId.valueChanges
.pipe(
takeUntilDestroyed(this.destroyRef),
concatMap(async () => {
await this.updateCollectionOptions();
}),
concatMap(async () => await this.updateCollectionOptions()),
)
.subscribe();
}
/**
* Gets the default collection IDs for the selected organization.
* Returns null if any of the following apply:
* - the feature flag is disabled
* - no org is currently selected
* - the selected org doesn't have the "no private data policy" enabled
*/
private async getDefaultCollectionId(orgId?: OrganizationId) {
if (!orgId) {
return;
}
const isFeatureEnabled = await this.configService.getFeatureFlag(
FeatureFlag.CreateDefaultLocation,
);
if (!isFeatureEnabled) {
return;
}
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const selectedOrgHasPolicyEnabled = (
await firstValueFrom(
this.policyService.policiesByType$(PolicyType.OrganizationDataOwnership, userId),
)
).find((p) => p.organizationId);
if (!selectedOrgHasPolicyEnabled) {
return;
}
const defaultUserCollection = this.collections.find(
(c) => c.organizationId === orgId && c.type === CollectionTypes.DefaultUserCollection,
);
// If the user was added after the policy was enabled as they will not have any private data
// and will not have a default collection.
return defaultUserCollection?.id;
}
private async initFromExistingCipher(prefillCipher: CipherView) {
const { name, folderId, collectionIds } = prefillCipher;
@@ -332,6 +369,11 @@ export class ItemDetailsSectionComponent implements OnInit {
// Non-admins can only select assigned collections that are not read only. (Non-AC)
return c.assigned && !c.readOnly;
})
.sort((a, b) => {
const aIsDefaultCollection = a.type === CollectionTypes.DefaultUserCollection ? -1 : 0;
const bIsDefaultCollection = b.type === CollectionTypes.DefaultUserCollection ? -1 : 0;
return aIsDefaultCollection - bIsDefaultCollection;
})
.map((c) => ({
id: c.id,
name: c.name,
@@ -349,10 +391,17 @@ export class ItemDetailsSectionComponent implements OnInit {
return;
}
if (startingSelection.length > 0) {
if (startingSelection.filter(Boolean).length > 0) {
collectionsControl.setValue(
this.collectionOptions.filter((c) => startingSelection.includes(c.id as CollectionId)),
);
} else {
const defaultCollectionId = await this.getDefaultCollectionId(orgId);
if (defaultCollectionId) {
collectionsControl.setValue(
this.collectionOptions.filter((c) => c.id === defaultCollectionId),
);
}
}
}
}

View File

@@ -0,0 +1,95 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { BitTotpCountdownComponent } from "./totp-countdown.component";
describe("BitTotpCountdownComponent", () => {
let component: BitTotpCountdownComponent;
let fixture: ComponentFixture<BitTotpCountdownComponent>;
let totpService: jest.Mocked<TotpService>;
const mockCipher1 = {
id: "cipher-id",
name: "Test Cipher",
login: { totp: "totp-secret" },
} as CipherView;
const mockCipher2 = {
id: "cipher-id-2",
name: "Test Cipher 2",
login: { totp: "totp-secret-2" },
} as CipherView;
const mockTotpResponse1 = {
code: "123456",
period: 30,
};
const mockTotpResponse2 = {
code: "987654",
period: 10,
};
beforeEach(async () => {
totpService = mock<TotpService>({
getCode$: jest.fn().mockImplementation((totp) => {
if (totp === mockCipher1.login.totp) {
return of(mockTotpResponse1);
}
return of(mockTotpResponse2);
}),
});
await TestBed.configureTestingModule({
providers: [{ provide: TotpService, useValue: totpService }],
}).compileComponents();
fixture = TestBed.createComponent(BitTotpCountdownComponent);
component = fixture.componentInstance;
component.cipher = mockCipher1;
fixture.detectChanges();
});
it("initializes totpInfo$ observable", (done) => {
component.totpInfo$?.subscribe((info) => {
expect(info.totpCode).toBe(mockTotpResponse1.code);
expect(info.totpCodeFormatted).toBe("123 456");
done();
});
});
it("emits sendCopyCode when TOTP code is available", (done) => {
const emitter = jest.spyOn(component.sendCopyCode, "emit");
component.totpInfo$?.subscribe((info) => {
expect(emitter).toHaveBeenCalledWith({
totpCode: info.totpCode,
totpCodeFormatted: info.totpCodeFormatted,
});
done();
});
});
it("updates totpInfo$ when cipher changes", (done) => {
component.cipher = mockCipher2;
component.ngOnChanges({
cipher: {
currentValue: mockCipher2,
previousValue: mockCipher1,
firstChange: false,
isFirstChange: () => false,
},
});
component.totpInfo$?.subscribe((info) => {
expect(info.totpCode).toBe(mockTotpResponse2.code);
expect(info.totpCodeFormatted).toBe("987 654");
done();
});
});
});

View File

@@ -1,7 +1,13 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
import {
Component,
EventEmitter,
Input,
OnInit,
Output,
OnChanges,
SimpleChanges,
} from "@angular/core";
import { Observable, map, tap } from "rxjs";
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
@@ -14,8 +20,8 @@ import { TypographyModule } from "@bitwarden/components";
templateUrl: "totp-countdown.component.html",
imports: [CommonModule, TypographyModule],
})
export class BitTotpCountdownComponent implements OnInit {
@Input() cipher: CipherView;
export class BitTotpCountdownComponent implements OnInit, OnChanges {
@Input({ required: true }) cipher!: CipherView;
@Output() sendCopyCode = new EventEmitter();
/**
@@ -26,6 +32,16 @@ export class BitTotpCountdownComponent implements OnInit {
constructor(protected totpService: TotpService) {}
async ngOnInit() {
this.setTotpInfo();
}
ngOnChanges(changes: SimpleChanges) {
if (changes["cipher"]) {
this.setTotpInfo();
}
}
private setTotpInfo(): void {
this.totpInfo$ = this.cipher?.login?.totp
? this.totpService.getCode$(this.cipher.login.totp).pipe(
map((response) => {

24
package-lock.json generated
View File

@@ -197,11 +197,11 @@
},
"apps/browser": {
"name": "@bitwarden/browser",
"version": "2025.6.0"
"version": "2025.6.1"
},
"apps/cli": {
"name": "@bitwarden/cli",
"version": "2025.6.0",
"version": "2025.6.1",
"license": "SEE LICENSE IN LICENSE.txt",
"dependencies": {
"@koa/multer": "3.1.0",
@@ -288,7 +288,7 @@
},
"apps/desktop": {
"name": "@bitwarden/desktop",
"version": "2025.6.1",
"version": "2025.7.0",
"hasInstallScript": true,
"license": "GPL-3.0"
},
@@ -353,6 +353,11 @@
"version": "0.0.0",
"license": "GPL-3.0"
},
"libs/logging": {
"name": "@bitwarden/logging",
"version": "0.0.1",
"license": "GPL-3.0"
},
"libs/node": {
"name": "@bitwarden/node",
"version": "0.0.0",
@@ -423,6 +428,11 @@
"version": "0.0.0",
"license": "GPL-3.0"
},
"libs/user-core": {
"name": "@bitwarden/user-core",
"version": "0.0.0",
"license": "GPL-3.0"
},
"libs/vault": {
"name": "@bitwarden/vault",
"version": "0.0.0",
@@ -4583,6 +4593,10 @@
"resolved": "libs/key-management-ui",
"link": true
},
"node_modules/@bitwarden/logging": {
"resolved": "libs/logging",
"link": true
},
"node_modules/@bitwarden/node": {
"resolved": "libs/node",
"link": true
@@ -4632,6 +4646,10 @@
"resolved": "libs/ui/common",
"link": true
},
"node_modules/@bitwarden/user-core": {
"resolved": "libs/user-core",
"link": true
},
"node_modules/@bitwarden/vault": {
"resolved": "libs/vault",
"link": true

View File

@@ -37,6 +37,7 @@
"@bitwarden/importer-ui": ["./libs/importer/src/components"],
"@bitwarden/key-management": ["./libs/key-management/src"],
"@bitwarden/key-management-ui": ["./libs/key-management-ui/src"],
"@bitwarden/logging": ["libs/logging/src"],
"@bitwarden/node/*": ["./libs/node/src/*"],
"@bitwarden/nx-plugin": ["libs/nx-plugin/src/index.ts"],
"@bitwarden/platform": ["./libs/platform/src"],
@@ -46,6 +47,7 @@
"@bitwarden/storage-test-utils": ["libs/storage-test-utils/src/index.ts"],
"@bitwarden/ui-common": ["./libs/ui/common/src"],
"@bitwarden/ui-common/setup-jest": ["./libs/ui/common/src/setup-jest"],
"@bitwarden/user-core": ["libs/user-core/src/index.ts"],
"@bitwarden/vault": ["./libs/vault/src"],
"@bitwarden/vault-export-core": ["./libs/tools/export/vault-export/vault-export-core/src"],
"@bitwarden/vault-export-ui": ["./libs/tools/export/vault-export/vault-export-ui/src"],