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:
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -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
|
||||
|
||||
23
.github/workflows/build-browser.yml
vendored
23
.github/workflows/build-browser.yml
vendored
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}"))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -255,8 +255,7 @@ impl MacOSProviderClient {
|
||||
.remove(&sequence_number)
|
||||
{
|
||||
cb.error(BitwardenError::Internal(format!(
|
||||
"Error sending message: {}",
|
||||
e
|
||||
"Error sending message: {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
4
apps/desktop/src/package-lock.json
generated
4
apps/desktop/src/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@bitwarden/desktop",
|
||||
"version": "2025.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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()"
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
45
libs/common/src/auth/utils/assert-non-nullish.util.ts
Normal file
45
libs/common/src/auth/utils/assert-non-nullish.util.ts
Normal 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}` : ""}`);
|
||||
}
|
||||
}
|
||||
46
libs/common/src/auth/utils/assert-truthy.util.ts
Normal file
46
libs/common/src/auth/utils/assert-truthy.util.ts
Normal 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}` : ""}`);
|
||||
}
|
||||
}
|
||||
2
libs/common/src/auth/utils/index.ts
Normal file
2
libs/common/src/auth/utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { assertTruthy } from "./assert-truthy.util";
|
||||
export { assertNonNullish } from "./assert-non-nullish.util";
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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">;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
5
libs/logging/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# logging
|
||||
|
||||
Owned by: platform
|
||||
|
||||
Logging primitives
|
||||
3
libs/logging/eslint.config.mjs
Normal file
3
libs/logging/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "../../eslint.config.mjs";
|
||||
|
||||
export default [...baseConfig];
|
||||
10
libs/logging/jest.config.js
Normal file
10
libs/logging/jest.config.js
Normal 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
11
libs/logging/package.json
Normal 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
33
libs/logging/project.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
57
libs/logging/src/console-log.service.ts
Normal file
57
libs/logging/src/console-log.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
3
libs/logging/src/index.ts
Normal file
3
libs/logging/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { LogService } from "./log.service";
|
||||
export { LogLevel } from "./log-level";
|
||||
export { ConsoleLogService } from "./console-log.service";
|
||||
8
libs/logging/src/log-level.ts
Normal file
8
libs/logging/src/log-level.ts
Normal 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,
|
||||
}
|
||||
9
libs/logging/src/log.service.ts
Normal file
9
libs/logging/src/log.service.ts
Normal 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;
|
||||
}
|
||||
8
libs/logging/src/logging.spec.ts
Normal file
8
libs/logging/src/logging.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
13
libs/logging/tsconfig.json
Normal file
13
libs/logging/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
10
libs/logging/tsconfig.lib.json
Normal file
10
libs/logging/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
16
libs/logging/tsconfig.spec.json
Normal file
16
libs/logging/tsconfig.spec.json
Normal 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
6
libs/user-core/README.md
Normal 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.
|
||||
3
libs/user-core/eslint.config.mjs
Normal file
3
libs/user-core/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "../../eslint.config.mjs";
|
||||
|
||||
export default [...baseConfig];
|
||||
10
libs/user-core/jest.config.js
Normal file
10
libs/user-core/jest.config.js
Normal 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",
|
||||
};
|
||||
10
libs/user-core/package.json
Normal file
10
libs/user-core/package.json
Normal 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"
|
||||
}
|
||||
27
libs/user-core/project.json
Normal file
27
libs/user-core/project.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
libs/user-core/src/index.ts
Normal file
9
libs/user-core/src/index.ts
Normal 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">;
|
||||
13
libs/user-core/tsconfig.json
Normal file
13
libs/user-core/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
10
libs/user-core/tsconfig.lib.json
Normal file
10
libs/user-core/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
10
libs/user-core/tsconfig.spec.json
Normal file
10
libs/user-core/tsconfig.spec.json
Normal 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"]
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
24
package-lock.json
generated
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user