mirror of
https://github.com/bitwarden/browser
synced 2026-02-04 10:43:47 +00:00
Merge branch 'main' into chromium-import-abe
This commit is contained in:
2
.github/workflows/chromatic.yml
vendored
2
.github/workflows/chromatic.yml
vendored
@@ -97,7 +97,7 @@ jobs:
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Publish to Chromatic
|
||||
uses: chromaui/action@e8cc4c31775280b175a3c440076c00d19a9014d7 # v11.28.2
|
||||
uses: chromaui/action@d0795df816d05c4a89c80295303970fddd247cce # v13.1.4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
projectToken: ${{ steps.get-kv-secrets.outputs.CHROMATIC-PROJECT-TOKEN }}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./risk-insights-data-mappers";
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
import {
|
||||
MemberDetailsFlat,
|
||||
CipherHealthReportDetail,
|
||||
CipherHealthReportUriDetail,
|
||||
ApplicationHealthReportDetail,
|
||||
} from "../models/password-health";
|
||||
import { MemberCipherDetailsResponse } from "../response/member-cipher-details.response";
|
||||
|
||||
export function flattenMemberDetails(
|
||||
memberCiphers: MemberCipherDetailsResponse[],
|
||||
): MemberDetailsFlat[] {
|
||||
return memberCiphers.flatMap((member) =>
|
||||
member.cipherIds.map((cipherId) => ({
|
||||
userGuid: member.userGuid,
|
||||
userName: member.userName,
|
||||
email: member.email,
|
||||
cipherId,
|
||||
})),
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Trim the cipher uris down to get the password health application.
|
||||
* The uri should only exist once after being trimmed. No duplication.
|
||||
* Example:
|
||||
* - Untrimmed Uris: https://gmail.com, gmail.com/login
|
||||
* - Both would trim to gmail.com
|
||||
* - The cipher trimmed uri list should only return on instance in the list
|
||||
* @param cipher
|
||||
* @returns distinct list of trimmed cipher uris
|
||||
*/
|
||||
export function getTrimmedCipherUris(cipher: CipherView): string[] {
|
||||
const uris = cipher.login?.uris ?? [];
|
||||
|
||||
const uniqueDomains = new Set<string>();
|
||||
|
||||
uris.forEach((u: { uri: string }) => {
|
||||
const domain = Utils.getDomain(u.uri) ?? u.uri;
|
||||
uniqueDomains.add(domain);
|
||||
});
|
||||
return Array.from(uniqueDomains);
|
||||
}
|
||||
|
||||
// Returns a deduplicated array of members by email
|
||||
export function getUniqueMembers(orgMembers: MemberDetailsFlat[]): MemberDetailsFlat[] {
|
||||
const existingEmails = new Set<string>();
|
||||
return orgMembers.filter((member) => {
|
||||
if (existingEmails.has(member.email)) {
|
||||
return false;
|
||||
}
|
||||
existingEmails.add(member.email);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a flattened member details object
|
||||
* @param userGuid User GUID
|
||||
* @param userName User name
|
||||
* @param email User email
|
||||
* @param cipherId Cipher ID
|
||||
* @returns Flattened member details
|
||||
*/
|
||||
export function getMemberDetailsFlat(
|
||||
userGuid: string,
|
||||
userName: string,
|
||||
email: string,
|
||||
cipherId: string,
|
||||
): MemberDetailsFlat {
|
||||
return {
|
||||
userGuid: userGuid,
|
||||
userName: userName,
|
||||
email: email,
|
||||
cipherId: cipherId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a flattened cipher details object for URI reporting
|
||||
* @param detail Cipher health report detail
|
||||
* @param uri Trimmed URI
|
||||
* @returns Flattened cipher health details to URI
|
||||
*/
|
||||
export function getFlattenedCipherDetails(
|
||||
detail: CipherHealthReportDetail,
|
||||
uri: string,
|
||||
): CipherHealthReportUriDetail {
|
||||
return {
|
||||
cipherId: detail.id,
|
||||
reusedPasswordCount: detail.reusedPasswordCount,
|
||||
weakPasswordDetail: detail.weakPasswordDetail,
|
||||
exposedPasswordDetail: detail.exposedPasswordDetail,
|
||||
cipherMembers: detail.cipherMembers,
|
||||
trimmedUri: uri,
|
||||
cipher: detail as CipherView,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the new application health report detail object with the details from the cipher health report uri detail object
|
||||
* update or create the at risk values if the item is at risk.
|
||||
* @param newUriDetail New cipher uri detail
|
||||
* @param isAtRisk If the cipher has a weak, exposed, or reused password it is at risk
|
||||
* @param existingUriDetail The previously processed Uri item
|
||||
* @returns The new or updated application health report detail
|
||||
*/
|
||||
export function getApplicationReportDetail(
|
||||
newUriDetail: CipherHealthReportUriDetail,
|
||||
isAtRisk: boolean,
|
||||
existingUriDetail?: ApplicationHealthReportDetail,
|
||||
): ApplicationHealthReportDetail {
|
||||
const reportDetail = {
|
||||
applicationName: existingUriDetail
|
||||
? existingUriDetail.applicationName
|
||||
: newUriDetail.trimmedUri,
|
||||
passwordCount: existingUriDetail ? existingUriDetail.passwordCount + 1 : 1,
|
||||
memberDetails: existingUriDetail
|
||||
? getUniqueMembers(existingUriDetail.memberDetails.concat(newUriDetail.cipherMembers))
|
||||
: newUriDetail.cipherMembers,
|
||||
atRiskMemberDetails: existingUriDetail ? existingUriDetail.atRiskMemberDetails : [],
|
||||
atRiskPasswordCount: existingUriDetail ? existingUriDetail.atRiskPasswordCount : 0,
|
||||
atRiskCipherIds: existingUriDetail ? existingUriDetail.atRiskCipherIds : [],
|
||||
atRiskMemberCount: existingUriDetail ? existingUriDetail.atRiskMemberDetails.length : 0,
|
||||
cipherIds: existingUriDetail
|
||||
? existingUriDetail.cipherIds.concat(newUriDetail.cipherId)
|
||||
: [newUriDetail.cipherId],
|
||||
} as ApplicationHealthReportDetail;
|
||||
|
||||
if (isAtRisk) {
|
||||
reportDetail.atRiskPasswordCount = reportDetail.atRiskPasswordCount + 1;
|
||||
reportDetail.atRiskCipherIds.push(newUriDetail.cipherId);
|
||||
|
||||
reportDetail.atRiskMemberDetails = getUniqueMembers(
|
||||
reportDetail.atRiskMemberDetails.concat(newUriDetail.cipherMembers),
|
||||
);
|
||||
reportDetail.atRiskMemberCount = reportDetail.atRiskMemberDetails.length;
|
||||
}
|
||||
|
||||
reportDetail.memberCount = reportDetail.memberDetails.length;
|
||||
|
||||
return reportDetail;
|
||||
}
|
||||
@@ -22,6 +22,13 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
import {
|
||||
getApplicationReportDetail,
|
||||
getFlattenedCipherDetails,
|
||||
getMemberDetailsFlat,
|
||||
getTrimmedCipherUris,
|
||||
getUniqueMembers,
|
||||
} from "../helpers/risk-insights-data-mappers";
|
||||
import {
|
||||
ApplicationHealthReportDetail,
|
||||
ApplicationHealthReportSummary,
|
||||
@@ -78,9 +85,7 @@ export class RiskInsightsReportService {
|
||||
const results$ = zip(allCiphers$, memberCiphers$).pipe(
|
||||
map(([allCiphers, memberCiphers]) => {
|
||||
const details: MemberDetailsFlat[] = memberCiphers.flatMap((dtl) =>
|
||||
dtl.cipherIds.map((c) =>
|
||||
this.getMemberDetailsFlat(dtl.userGuid, dtl.userName, dtl.email, c),
|
||||
),
|
||||
dtl.cipherIds.map((c) => getMemberDetailsFlat(dtl.userGuid, dtl.userName, dtl.email, c)),
|
||||
);
|
||||
return [allCiphers, details] as const;
|
||||
}),
|
||||
@@ -185,10 +190,10 @@ export class RiskInsightsReportService {
|
||||
reports: ApplicationHealthReportDetail[],
|
||||
): ApplicationHealthReportSummary {
|
||||
const totalMembers = reports.flatMap((x) => x.memberDetails);
|
||||
const uniqueMembers = this.getUniqueMembers(totalMembers);
|
||||
const uniqueMembers = getUniqueMembers(totalMembers);
|
||||
|
||||
const atRiskMembers = reports.flatMap((x) => x.atRiskMemberDetails);
|
||||
const uniqueAtRiskMembers = this.getUniqueMembers(atRiskMembers);
|
||||
const uniqueAtRiskMembers = getUniqueMembers(atRiskMembers);
|
||||
|
||||
return {
|
||||
totalMemberCount: uniqueMembers.length,
|
||||
@@ -317,7 +322,7 @@ export class RiskInsightsReportService {
|
||||
const cipherMembers = memberDetails.filter((x) => x.cipherId === cipher.id);
|
||||
|
||||
// Trim uris to host name and create the cipher health report
|
||||
const cipherTrimmedUris = this.getTrimmedCipherUris(cipher);
|
||||
const cipherTrimmedUris = getTrimmedCipherUris(cipher);
|
||||
const cipherHealth = {
|
||||
...cipher,
|
||||
weakPasswordDetail: weakPassword,
|
||||
@@ -346,7 +351,7 @@ export class RiskInsightsReportService {
|
||||
cipherHealthReport: CipherHealthReportDetail[],
|
||||
): CipherHealthReportUriDetail[] {
|
||||
return cipherHealthReport.flatMap((rpt) =>
|
||||
rpt.trimmedUris.map((u) => this.getFlattenedCipherDetails(rpt, u)),
|
||||
rpt.trimmedUris.map((u) => getFlattenedCipherDetails(rpt, u)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -369,9 +374,9 @@ export class RiskInsightsReportService {
|
||||
}
|
||||
|
||||
if (index === -1) {
|
||||
appReports.push(this.getApplicationReportDetail(uri, atRisk));
|
||||
appReports.push(getApplicationReportDetail(uri, atRisk));
|
||||
} else {
|
||||
appReports[index] = this.getApplicationReportDetail(uri, atRisk, appReports[index]);
|
||||
appReports[index] = getApplicationReportDetail(uri, atRisk, appReports[index]);
|
||||
}
|
||||
});
|
||||
return appReports;
|
||||
@@ -452,120 +457,6 @@ export class RiskInsightsReportService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the new application health report detail object with the details from the cipher health report uri detail object
|
||||
* update or create the at risk values if the item is at risk.
|
||||
* @param newUriDetail New cipher uri detail
|
||||
* @param isAtRisk If the cipher has a weak, exposed, or reused password it is at risk
|
||||
* @param existingUriDetail The previously processed Uri item
|
||||
* @returns The new or updated application health report detail
|
||||
*/
|
||||
private getApplicationReportDetail(
|
||||
newUriDetail: CipherHealthReportUriDetail,
|
||||
isAtRisk: boolean,
|
||||
existingUriDetail?: ApplicationHealthReportDetail,
|
||||
): ApplicationHealthReportDetail {
|
||||
const reportDetail = {
|
||||
applicationName: existingUriDetail
|
||||
? existingUriDetail.applicationName
|
||||
: newUriDetail.trimmedUri,
|
||||
passwordCount: existingUriDetail ? existingUriDetail.passwordCount + 1 : 1,
|
||||
memberDetails: existingUriDetail
|
||||
? this.getUniqueMembers(existingUriDetail.memberDetails.concat(newUriDetail.cipherMembers))
|
||||
: newUriDetail.cipherMembers,
|
||||
atRiskMemberDetails: existingUriDetail ? existingUriDetail.atRiskMemberDetails : [],
|
||||
atRiskPasswordCount: existingUriDetail ? existingUriDetail.atRiskPasswordCount : 0,
|
||||
atRiskCipherIds: existingUriDetail ? existingUriDetail.atRiskCipherIds : [],
|
||||
atRiskMemberCount: existingUriDetail ? existingUriDetail.atRiskMemberDetails.length : 0,
|
||||
cipherIds: existingUriDetail
|
||||
? existingUriDetail.cipherIds.concat(newUriDetail.cipherId)
|
||||
: [newUriDetail.cipherId],
|
||||
} as ApplicationHealthReportDetail;
|
||||
|
||||
if (isAtRisk) {
|
||||
reportDetail.atRiskPasswordCount = reportDetail.atRiskPasswordCount + 1;
|
||||
reportDetail.atRiskCipherIds.push(newUriDetail.cipherId);
|
||||
|
||||
reportDetail.atRiskMemberDetails = this.getUniqueMembers(
|
||||
reportDetail.atRiskMemberDetails.concat(newUriDetail.cipherMembers),
|
||||
);
|
||||
reportDetail.atRiskMemberCount = reportDetail.atRiskMemberDetails.length;
|
||||
}
|
||||
|
||||
reportDetail.memberCount = reportDetail.memberDetails.length;
|
||||
|
||||
return reportDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a distinct array of members from a combined list. Input list may contain
|
||||
* duplicate members.
|
||||
* @param orgMembers Input list of members
|
||||
* @returns Distinct array of members
|
||||
*/
|
||||
private getUniqueMembers(orgMembers: MemberDetailsFlat[]): MemberDetailsFlat[] {
|
||||
const existingEmails = new Set<string>();
|
||||
const distinctUsers = orgMembers.filter((member) => {
|
||||
if (existingEmails.has(member.email)) {
|
||||
return false;
|
||||
}
|
||||
existingEmails.add(member.email);
|
||||
return true;
|
||||
});
|
||||
return distinctUsers;
|
||||
}
|
||||
|
||||
private getFlattenedCipherDetails(
|
||||
detail: CipherHealthReportDetail,
|
||||
uri: string,
|
||||
): CipherHealthReportUriDetail {
|
||||
return {
|
||||
cipherId: detail.id,
|
||||
reusedPasswordCount: detail.reusedPasswordCount,
|
||||
weakPasswordDetail: detail.weakPasswordDetail,
|
||||
exposedPasswordDetail: detail.exposedPasswordDetail,
|
||||
cipherMembers: detail.cipherMembers,
|
||||
trimmedUri: uri,
|
||||
cipher: detail as CipherView,
|
||||
};
|
||||
}
|
||||
|
||||
private getMemberDetailsFlat(
|
||||
userGuid: string,
|
||||
userName: string,
|
||||
email: string,
|
||||
cipherId: string,
|
||||
): MemberDetailsFlat {
|
||||
return {
|
||||
userGuid: userGuid,
|
||||
userName: userName,
|
||||
email: email,
|
||||
cipherId: cipherId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim the cipher uris down to get the password health application.
|
||||
* The uri should only exist once after being trimmed. No duplication.
|
||||
* Example:
|
||||
* - Untrimmed Uris: https://gmail.com, gmail.com/login
|
||||
* - Both would trim to gmail.com
|
||||
* - The cipher trimmed uri list should only return on instance in the list
|
||||
* @param cipher
|
||||
* @returns distinct list of trimmed cipher uris
|
||||
*/
|
||||
private getTrimmedCipherUris(cipher: CipherView): string[] {
|
||||
const cipherUris: string[] = [];
|
||||
const uris = cipher.login?.uris ?? [];
|
||||
uris.map((u: { uri: string }) => {
|
||||
const uri = Utils.getDomain(u.uri) ?? u.uri;
|
||||
if (!cipherUris.includes(uri)) {
|
||||
cipherUris.push(uri);
|
||||
}
|
||||
});
|
||||
return cipherUris;
|
||||
}
|
||||
|
||||
private isUserNameNotEmpty(c: CipherView): boolean {
|
||||
return !Utils.isNullOrWhitespace(c.login.username);
|
||||
}
|
||||
|
||||
@@ -65,9 +65,10 @@
|
||||
}"
|
||||
>
|
||||
<ng-content select="[bitDialogContent]"></ng-content>
|
||||
<div #scrollBottom></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@let isScrollable = isScrollable$ | async;
|
||||
@let showFooterBorder =
|
||||
(!bodyHasScrolledFrom().top && isScrollable) || bodyHasScrolledFrom().bottom;
|
||||
<footer
|
||||
|
||||
@@ -8,13 +8,17 @@ import {
|
||||
viewChild,
|
||||
input,
|
||||
booleanAttribute,
|
||||
AfterViewInit,
|
||||
ElementRef,
|
||||
DestroyRef,
|
||||
} from "@angular/core";
|
||||
import { toObservable } from "@angular/core/rxjs-interop";
|
||||
import { combineLatest, switchMap } from "rxjs";
|
||||
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { BitIconButtonComponent } from "../../icon-button/icon-button.component";
|
||||
import { TypographyDirective } from "../../typography/typography.directive";
|
||||
import { hasScrollableContent$ } from "../../utils/";
|
||||
import { hasScrolledFrom } from "../../utils/has-scrolled-from";
|
||||
import { fadeIn } from "../animations";
|
||||
import { DialogRef } from "../dialog.service";
|
||||
@@ -39,11 +43,22 @@ import { DialogTitleContainerDirective } from "../directives/dialog-title-contai
|
||||
CdkScrollable,
|
||||
],
|
||||
})
|
||||
export class DialogComponent implements AfterViewInit {
|
||||
protected dialogRef = inject(DialogRef, { optional: true });
|
||||
export class DialogComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private scrollableBody = viewChild.required(CdkScrollable);
|
||||
private scrollBottom = viewChild.required<ElementRef<HTMLDivElement>>("scrollBottom");
|
||||
|
||||
protected dialogRef = inject(DialogRef, { optional: true });
|
||||
protected bodyHasScrolledFrom = hasScrolledFrom(this.scrollableBody);
|
||||
protected isScrollable = false;
|
||||
|
||||
private scrollableBody$ = toObservable(this.scrollableBody);
|
||||
private scrollBottom$ = toObservable(this.scrollBottom);
|
||||
|
||||
protected isScrollable$ = combineLatest([this.scrollableBody$, this.scrollBottom$]).pipe(
|
||||
switchMap(([body, bottom]) =>
|
||||
hasScrollableContent$(body.getElementRef().nativeElement, bottom.nativeElement),
|
||||
),
|
||||
);
|
||||
|
||||
/** Background color */
|
||||
readonly background = input<"default" | "alt">("default");
|
||||
@@ -105,13 +120,4 @@ export class DialogComponent implements AfterViewInit {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.isScrollable = this.canScroll();
|
||||
}
|
||||
|
||||
canScroll(): boolean {
|
||||
const el = this.scrollableBody().getElementRef().nativeElement as HTMLElement;
|
||||
return el.scrollHeight > el.clientHeight;
|
||||
}
|
||||
}
|
||||
|
||||
17
libs/components/src/utils/dom-observables.ts
Normal file
17
libs/components/src/utils/dom-observables.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
/** IntersectionObserver Observable */
|
||||
export const intersectionObserver$ = (
|
||||
target: Element,
|
||||
init: IntersectionObserverInit,
|
||||
): Observable<IntersectionObserverEntry> => {
|
||||
return new Observable((sub) => {
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
sub.next(e);
|
||||
}
|
||||
}, init);
|
||||
io.observe(target);
|
||||
return () => io.disconnect();
|
||||
});
|
||||
};
|
||||
26
libs/components/src/utils/has-scrollable-content.ts
Normal file
26
libs/components/src/utils/has-scrollable-content.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Observable, animationFrameScheduler } from "rxjs";
|
||||
import { auditTime, map, startWith, observeOn, distinctUntilChanged } from "rxjs/operators";
|
||||
|
||||
import { intersectionObserver$ } from "./dom-observables";
|
||||
/**
|
||||
* Utility to determine if an element has scrollable content.
|
||||
* Returns an Observable that emits whenever scroll/resize/layout might change visibility
|
||||
*/
|
||||
export const hasScrollableContent$ = (
|
||||
root: HTMLElement,
|
||||
target: HTMLElement,
|
||||
threshold: number = 1,
|
||||
): Observable<boolean> => {
|
||||
return intersectionObserver$(target, { root, threshold }).pipe(
|
||||
startWith(null as IntersectionObserverEntry | null),
|
||||
auditTime(0, animationFrameScheduler),
|
||||
observeOn(animationFrameScheduler),
|
||||
map((entry: IntersectionObserverEntry | null) => {
|
||||
if (!entry) {
|
||||
return root.scrollHeight > root.clientHeight;
|
||||
}
|
||||
return !entry.isIntersecting;
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./aria-disable-element";
|
||||
export * from "./function-to-observable";
|
||||
export * from "./has-scrollable-content";
|
||||
export * from "./i18n-mock.service";
|
||||
|
||||
Reference in New Issue
Block a user