1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-02-11 00:31:45 +01:00

Merge branch 'km/fix-bio' of github.com:bitwarden/clients into km/fix-bio

This commit is contained in:
Bernd Schoolmann 2025-01-13 18:25:55 +01:00
commit 1a6d519ddc
No known key found for this signature in database
15 changed files with 460 additions and 31 deletions

View File

@ -1,23 +1,76 @@
<bit-section *ngIf="ciphers?.length > 0 || description" [disableMargin]="disableSectionMargin">
<div class="tw-ml-1">
<bit-section-header>
<h2 bitTypography="h6">
{{ title }}
</h2>
<button
*ngIf="showRefresh"
bitIconButton="bwi-refresh"
type="button"
size="small"
(click)="onRefresh.emit()"
[appA11yTitle]="'refresh' | i18n"
></button>
<span bitTypography="body2" slot="end">{{ ciphers.length }}</span>
</bit-section-header>
</div>
<ng-container *ngIf="collapsibleKey">
<button
class="tw-group/vault-section-header hover:tw-bg-secondary-100 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-rounded-md focus-visible:tw-ring-inset focus-visible:tw-ring-2 focus-visible:tw-ring-primary-600"
[ngClass]="{
'tw-border-b-secondary-300': !sectionOpenState(),
'tw-border-b-transparent': sectionOpenState(),
}"
type="button"
[bitDisclosureTriggerFor]="disclosureRef"
(click)="toggleSectionOpen()"
>
<ng-container *ngTemplateOutlet="sectionHeader"></ng-container>
</button>
<ng-container *ngTemplateOutlet="descriptionText"></ng-container>
<bit-disclosure #disclosureRef [open]="sectionOpenState()" (openChange)="rerenderViewport()">
<ng-container *ngTemplateOutlet="itemGroup"></ng-container>
</bit-disclosure>
</ng-container>
<ng-container *ngIf="!collapsibleKey">
<div class="tw-pl-1">
<ng-container *ngTemplateOutlet="sectionHeader"></ng-container>
</div>
<ng-container *ngTemplateOutlet="descriptionText"></ng-container>
<ng-container *ngTemplateOutlet="itemGroup"></ng-container>
</ng-container>
</bit-section>
<ng-template #sectionHeader>
<bit-section-header class="tw-p-0.5 -tw-mt-0.5 -tw-mx-0.5">
<h2 bitTypography="h6">
{{ title }}
</h2>
<button
*ngIf="showRefresh"
bitIconButton="bwi-refresh"
type="button"
size="small"
(click)="onRefresh.emit()"
[appA11yTitle]="'refresh' | i18n"
></button>
<span bitTypography="body2" slot="end">
<span
[ngClass]="{
'group-hover/vault-section-header:tw-hidden group-focus-visible/vault-section-header:tw-hidden':
collapsibleKey && sectionOpenState(),
'tw-hidden': collapsibleKey && !sectionOpenState(),
}"
>
{{ ciphers.length }}
</span>
<span class="tw-pr-1" *ngIf="collapsibleKey">
<i
class="bwi"
[ngClass]="{
'bwi-angle-down tw-inline-block': !sectionOpenState(),
'bwi-angle-up tw-hidden group-hover/vault-section-header:tw-inline-block group-focus-visible/vault-section-header:tw-inline-block':
sectionOpenState(),
}"
aria-hidden="true"
></i>
</span>
</span>
</bit-section-header>
</ng-template>
<ng-template #descriptionText>
<div *ngIf="description" class="tw-text-muted tw-px-1 tw-mb-2" bitTypography="body2">
{{ description }}
</div>
</ng-template>
<ng-template #itemGroup>
<bit-item-group>
<cdk-virtual-scroll-viewport
[itemSize]="itemHeight$ | async"
@ -85,4 +138,4 @@
</bit-item>
</cdk-virtual-scroll-viewport>
</bit-item-group>
</bit-section>
</ng-template>

View File

@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ScrollingModule } from "@angular/cdk/scrolling";
import { CdkVirtualScrollViewport, ScrollingModule } from "@angular/cdk/scrolling";
import { CommonModule } from "@angular/common";
import {
AfterViewInit,
@ -9,8 +9,11 @@ import {
EventEmitter,
inject,
Input,
OnInit,
Output,
Signal,
signal,
ViewChild,
} from "@angular/core";
import { Router, RouterLink } from "@angular/router";
import { map } from "rxjs";
@ -25,6 +28,8 @@ import {
BadgeModule,
ButtonModule,
CompactModeService,
DisclosureComponent,
DisclosureTriggerForDirective,
DialogService,
IconButtonModule,
ItemModule,
@ -41,6 +46,7 @@ import {
import { BrowserApi } from "../../../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service";
import { VaultPopupSectionService } 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";
@ -61,14 +67,25 @@ import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options
ItemMoreOptionsComponent,
OrgIconDirective,
ScrollingModule,
DisclosureComponent,
DisclosureTriggerForDirective,
DecryptionFailureDialogComponent,
],
selector: "app-vault-list-items-container",
templateUrl: "vault-list-items-container.component.html",
standalone: true,
})
export class VaultListItemsContainerComponent implements AfterViewInit {
export class VaultListItemsContainerComponent implements OnInit, AfterViewInit {
private compactModeService = inject(CompactModeService);
private vaultPopupSectionService = inject(VaultPopupSectionService);
@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;
/**
* The class used to set the height of a bit item's inner content.
@ -106,6 +123,15 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
@Input()
title: string;
/**
* Optionally allow the items to be collapsed.
*
* 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;
/**
* Optional description for the vault list item section. Will be shown below the title even when
* no ciphers are available.
@ -168,6 +194,16 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
private dialogService: DialogService,
) {}
ngOnInit(): void {
if (!this.collapsibleKey) {
return;
}
this.sectionOpenState = this.vaultPopupSectionService.getOpenDisplayStateForSection(
this.collapsibleKey,
);
}
async ngAfterViewInit() {
const autofillShortcut = await this.platformUtilsService.getAutofillKeyboardShortcut();
@ -239,4 +275,30 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
cipher.canLaunch ? 200 : 0,
);
}
/**
* Update section open/close state based on user action
*/
async toggleSectionOpen() {
if (!this.collapsibleKey) {
return;
}
await this.vaultPopupSectionService.updateSectionOpenStoredState(
this.collapsibleKey,
this.disclosure.open,
);
}
/**
* Force virtual scroll to update its viewport size to avoid display bugs
*
* Angular CDK scroll has a bug when used with conditional rendering:
* https://github.com/angular/components/issues/24362
*/
protected rerenderViewport() {
setTimeout(() => {
this.viewPort.checkViewportSize();
});
}
}

View File

@ -66,12 +66,14 @@
[title]="'favorites' | i18n"
[ciphers]="favoriteCiphers$ | async"
id="favorites"
collapsibleKey="favorites"
></app-vault-list-items-container>
<app-vault-list-items-container
[title]="'allItems' | i18n"
[ciphers]="remainingCiphers$ | async"
id="allItems"
disableSectionMargin
collapsibleKey="allItems"
></app-vault-list-items-container>
</div>
</ng-container>

View File

@ -0,0 +1,129 @@
import { computed, effect, inject, Injectable, signal, Signal } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { map } from "rxjs";
import {
KeyDefinition,
StateProvider,
VAULT_SETTINGS_DISK,
} from "@bitwarden/common/platform/state";
import { VaultPopupItemsService } from "./vault-popup-items.service";
export type PopupSectionOpen = {
favorites: boolean;
allItems: boolean;
};
const SECTION_OPEN_KEY = new KeyDefinition<PopupSectionOpen>(VAULT_SETTINGS_DISK, "sectionOpen", {
deserializer: (obj) => obj,
});
const INITIAL_OPEN: PopupSectionOpen = {
favorites: true,
allItems: true,
};
@Injectable({
providedIn: "root",
})
export class VaultPopupSectionService {
private vaultPopupItemsService = inject(VaultPopupItemsService);
private stateProvider = inject(StateProvider);
private hasFilterOrSearchApplied = toSignal(
this.vaultPopupItemsService.hasFilterApplied$.pipe(map((hasFilter) => hasFilter)),
);
/**
* Used to change the open/close state without persisting it to the local disk. Reflects
* application-applied overrides.
* `null` means there is no current override
*/
private temporaryStateOverride = signal<Partial<PopupSectionOpen> | null>(null);
constructor() {
effect(
() => {
/**
* auto-open all sections when search or filter is applied, and remove
* override when search or filter is removed
*/
if (this.hasFilterOrSearchApplied()) {
this.temporaryStateOverride.set(INITIAL_OPEN);
} else {
this.temporaryStateOverride.set(null);
}
},
{
allowSignalWrites: true,
},
);
}
/**
* Stored disk state for the open/close state of the sections. Will be `null` if user has never
* opened/closed a section
*/
private sectionOpenStateProvider = this.stateProvider.getGlobal(SECTION_OPEN_KEY);
/**
* Stored disk state for the open/close state of the sections, with an initial value provided
* if the stored disk state does not yet exist.
*/
private sectionOpenStoredState = toSignal<PopupSectionOpen | null>(
this.sectionOpenStateProvider.state$.pipe(map((sectionOpen) => sectionOpen ?? INITIAL_OPEN)),
// Indicates that the state value is loading
{ initialValue: null },
);
/**
* Indicates the current open/close display state of each section, accounting for temporary
* non-persisted overrides.
*/
sectionOpenDisplayState: Signal<Partial<PopupSectionOpen>> = computed(() => ({
...this.sectionOpenStoredState(),
...this.temporaryStateOverride(),
}));
/**
* Retrieve the open/close display state for a given section.
*
* @param sectionKey section key
*/
getOpenDisplayStateForSection(sectionKey: keyof PopupSectionOpen): Signal<boolean | undefined> {
return computed(() => this.sectionOpenDisplayState()?.[sectionKey]);
}
/**
* Updates the stored open/close state of a given section. Should be called only when a user action
* is taken directly to change the open/close state.
*
* Removes any current temporary override for the given section, as direct user action should
* supersede any application-applied overrides.
*
* @param sectionKey section key
*/
async updateSectionOpenStoredState(
sectionKey: keyof PopupSectionOpen,
open: boolean,
): Promise<void> {
await this.sectionOpenStateProvider.update((currentState) => {
return {
...(currentState ?? INITIAL_OPEN),
[sectionKey]: open,
};
});
this.temporaryStateOverride.update((prev) => {
if (prev !== null) {
return {
...prev,
[sectionKey]: open,
};
}
return prev;
});
}
}

View File

@ -33,7 +33,7 @@ export class ResellerWarningService {
return {
type: "warning",
message: this.i18nService.t(
"resellerPastDueWarning",
"resellerPastDueWarningMsg",
organization.providerName,
this.formatDate(gracePeriodEnd),
),
@ -50,7 +50,7 @@ export class ResellerWarningService {
return {
type: "info",
message: this.i18nService.t(
"resellerOpenInvoiceWarning",
"resellerOpenInvoiceWarningMgs",
organization.providerName,
this.formatDate(organizationBillingMetadata.invoiceCreatedDate),
this.formatDate(organizationBillingMetadata.invoiceDueDate),
@ -68,7 +68,7 @@ export class ResellerWarningService {
return {
type: "info",
message: this.i18nService.t(
"resellerRenewalWarning",
"resellerRenewalWarningMsg",
organization.providerName,
this.formatDate(organizationBillingMetadata.subPeriodEndDate),
),

View File

@ -113,6 +113,27 @@
"atRiskMembers": {
"message": "At-risk members"
},
"atRiskMembersWithCount": {
"message": "At-risk members ($COUNT$)",
"placeholders": {
"count": {
"content": "$1",
"example": "3"
}
}
},
"atRiskMembersDescription": {
"message": "These members are logging into applications with weak, exposed, or reused passwords."
},
"atRiskMembersDescriptionWithApp": {
"message": "These members are logging into $APPNAME$ with weak, exposed, or reused passwords.",
"placeholders": {
"appname": {
"content": "$1",
"example": "Salesforce"
}
}
},
"totalMembers": {
"message": "Total members"
},
@ -10083,8 +10104,8 @@
"organizationNameMaxLength": {
"message": "Organization name cannot exceed 50 characters."
},
"resellerRenewalWarning": {
"message": "Your subscription will renew soon. To insure uninterrupted service, contact $RESELLER$ to confirm your renewal before $RENEWAL_DATE$.",
"resellerRenewalWarningMsg": {
"message": "Your subscription will renew soon. To ensure uninterrupted service, contact $RESELLER$ to confirm your renewal before $RENEWAL_DATE$.",
"placeholders": {
"reseller": {
"content": "$1",
@ -10096,8 +10117,8 @@
}
}
},
"resellerOpenInvoiceWarning": {
"message": "An invoice for your subscription was issued on $ISSUED_DATE$. To insure uninterrupted service, contact $RESELLER$ to confirm your renewal before $DUE_DATE$.",
"resellerOpenInvoiceWarningMgs": {
"message": "An invoice for your subscription was issued on $ISSUED_DATE$. To ensure uninterrupted service, contact $RESELLER$ to confirm your renewal before $DUE_DATE$.",
"placeholders": {
"reseller": {
"content": "$1",
@ -10113,8 +10134,8 @@
}
}
},
"resellerPastDueWarning": {
"message": "The invoice for your subscription has not been paid. To insure uninterrupted service, contact $RESELLER$ to confirm your renewal before $GRACE_PERIOD_END$.",
"resellerPastDueWarningMsg": {
"message": "The invoice for your subscription has not been paid. To ensure uninterrupted service, contact $RESELLER$ to confirm your renewal before $GRACE_PERIOD_END$.",
"placeholders": {
"reseller": {
"content": "$1",

View File

@ -90,3 +90,13 @@ export type MemberDetailsFlat = {
email: string;
cipherId: string;
};
/**
* Member email with the number of at risk passwords
* At risk member detail that contains the email
* and the count of at risk ciphers
*/
export type AtRiskMemberDetail = {
email: string;
atRiskPasswordCount: number;
};

View File

@ -12,6 +12,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
ApplicationHealthReportDetail,
ApplicationHealthReportSummary,
AtRiskMemberDetail,
CipherHealthReportDetail,
CipherHealthReportUriDetail,
ExposedPasswordDetail,
@ -89,6 +90,30 @@ export class RiskInsightsReportService {
return results$;
}
/**
* Generates a list of members with at-risk passwords along with the number of at-risk passwords.
*/
generateAtRiskMemberList(
cipherHealthReportDetails: ApplicationHealthReportDetail[],
): AtRiskMemberDetail[] {
const memberRiskMap = new Map<string, number>();
cipherHealthReportDetails.forEach((app) => {
app.atRiskMemberDetails.forEach((member) => {
if (memberRiskMap.has(member.email)) {
memberRiskMap.set(member.email, memberRiskMap.get(member.email) + 1);
} else {
memberRiskMap.set(member.email, 1);
}
});
});
return Array.from(memberRiskMap.entries()).map(([email, atRiskPasswordCount]) => ({
email,
atRiskPasswordCount,
}));
}
/**
* Gets the summary from the application health report. Returns total members and applications as well
* as the total at risk members and at risk applications

View File

@ -27,10 +27,11 @@
<h2 class="tw-mb-6" bitTypography="h2">{{ "allApplications" | i18n }}</h2>
<div class="tw-flex tw-gap-6">
<tools-card
class="tw-flex-1"
class="tw-flex-1 tw-cursor-pointer"
[title]="'atRiskMembers' | i18n"
[value]="applicationSummary.totalAtRiskMemberCount"
[maxValue]="applicationSummary.totalMemberCount"
(click)="showOrgAtRiskMembers()"
>
</tools-card>
<tools-card
@ -82,7 +83,7 @@
(change)="onCheckboxChange(r.id, $event)"
/>
</td>
<td bitCell>
<td class="tw-cursor-pointer" (click)="showAppAtRiskMembers(r.applicationName)" bitCell>
<span>{{ r.applicationName }}</span>
</td>
<td bitCell>

View File

@ -19,6 +19,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import {
DialogService,
Icons,
NoItemsModule,
SearchModule,
@ -30,6 +31,8 @@ import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.mod
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module";
import { openAppAtRiskMembersDialog } from "./app-at-risk-members-dialog.component";
import { OrgAtRiskMembersDialogComponent } from "./org-at-risk-members-dialog.component";
import { ApplicationsLoadingComponent } from "./risk-insights-loading.component";
@Component({
@ -99,6 +102,7 @@ export class AllApplicationsComponent implements OnInit, OnDestroy {
protected dataService: RiskInsightsDataService,
protected organizationService: OrganizationService,
protected reportService: RiskInsightsReportService,
protected dialogService: DialogService,
) {
this.searchControl.valueChanges
.pipe(debounceTime(200), takeUntilDestroyed())
@ -135,6 +139,21 @@ export class AllApplicationsComponent implements OnInit, OnDestroy {
return item.applicationName;
}
showAppAtRiskMembers = async (applicationName: string) => {
openAppAtRiskMembersDialog(this.dialogService, {
members:
this.dataSource.data.find((app) => app.applicationName === applicationName)
?.atRiskMemberDetails ?? [],
applicationName,
});
};
showOrgAtRiskMembers = async () => {
this.dialogService.open(OrgAtRiskMembersDialogComponent, {
data: this.reportService.generateAtRiskMemberList(this.dataSource.data),
});
};
onCheckboxChange(id: number, event: Event) {
const isChecked = (event.target as HTMLInputElement).checked;
if (isChecked) {

View File

@ -0,0 +1,21 @@
<bit-dialog>
<span bitDialogTitle>{{ applicationName }}</span>
<ng-container bitDialogContent>
<div class="tw-flex tw-flex-col tw-gap-2">
<span bitDialogTitle>{{ "atRiskMembersWithCount" | i18n: members.length }} </span>
<span class="tw-text-muted">{{
"atRiskMembersDescriptionWithApp" | i18n: applicationName
}}</span>
<div class="tw-mt-1">
<ng-container *ngFor="let member of members">
<div>{{ member.email }}</div>
</ng-container>
</div>
</div>
</ng-container>
<ng-container bitDialogFooter>
<button bitButton bitDialogClose buttonType="secondary" type="button">
{{ "close" | i18n }}
</button>
</ng-container>
</bit-dialog>

View File

@ -0,0 +1,35 @@
import { DIALOG_DATA } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, Inject } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { MemberDetailsFlat } from "@bitwarden/bit-common/tools/reports/risk-insights/models/password-health";
import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components";
type AppAtRiskMembersDialogParams = {
members: MemberDetailsFlat[];
applicationName: string;
};
export const openAppAtRiskMembersDialog = (
dialogService: DialogService,
dialogConfig: AppAtRiskMembersDialogParams,
) =>
dialogService.open<boolean, AppAtRiskMembersDialogParams>(AppAtRiskMembersDialogComponent, {
data: dialogConfig,
});
@Component({
standalone: true,
templateUrl: "./app-at-risk-members-dialog.component.html",
imports: [ButtonModule, CommonModule, JslibModule, DialogModule],
})
export class AppAtRiskMembersDialogComponent {
protected members: MemberDetailsFlat[];
protected applicationName: string;
constructor(@Inject(DIALOG_DATA) private params: AppAtRiskMembersDialogParams) {
this.members = params.members;
this.applicationName = params.applicationName;
}
}

View File

@ -0,0 +1,27 @@
<bit-dialog>
<ng-container bitDialogTitle>
<span bitDialogTitle>{{ "atRiskMembersWithCount" | i18n: atRiskMembers.length }} </span>
</ng-container>
<ng-container bitDialogContent>
<div class="tw-flex tw-flex-col tw-gap-2">
<span bitTypography="body2" class="tw-text-muted">{{
"atRiskMembersDescription" | i18n
}}</span>
<div class="tw-flex tw-justify-between tw-mt-2 tw-text-muted">
<div bitTypography="body2" class="tw-font-bold">{{ "email" | i18n }}</div>
<div bitTypography="body2" class="tw-font-bold">{{ "atRiskPasswords" | i18n }}</div>
</div>
<ng-container *ngFor="let member of atRiskMembers">
<div class="tw-flex tw-justify-between tw-mt-2">
<div>{{ member.email }}</div>
<div>{{ member.atRiskPasswordCount }}</div>
</div>
</ng-container>
</div>
</ng-container>
<ng-container bitDialogFooter>
<button bitButton bitDialogClose buttonType="secondary" type="button">
{{ "close" | i18n }}
</button>
</ng-container>
</bit-dialog>

View File

@ -0,0 +1,24 @@
import { DIALOG_DATA } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, Inject } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AtRiskMemberDetail } from "@bitwarden/bit-common/tools/reports/risk-insights/models/password-health";
import { ButtonModule, DialogModule, DialogService, TypographyModule } from "@bitwarden/components";
export const openOrgAtRiskMembersDialog = (
dialogService: DialogService,
dialogConfig: AtRiskMemberDetail[],
) =>
dialogService.open<boolean, AtRiskMemberDetail[]>(OrgAtRiskMembersDialogComponent, {
data: dialogConfig,
});
@Component({
standalone: true,
templateUrl: "./org-at-risk-members-dialog.component.html",
imports: [ButtonModule, CommonModule, DialogModule, JslibModule, TypographyModule],
})
export class OrgAtRiskMembersDialogComponent {
constructor(@Inject(DIALOG_DATA) protected atRiskMembers: AtRiskMemberDetail[]) {}
}

View File

@ -2,7 +2,7 @@
<div class="[&>*]:tw-mb-0 [&>*]:tw-text-main tw-flex tw-items-center tw-gap-1">
<ng-content></ng-content>
</div>
<div class="tw-text-muted has-[button]:-tw-mb-1">
<div class="tw-text-muted -tw-mb-0.5">
<ng-content select="[slot=end]"></ng-content>
</div>
</div>