1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-12 14:23:32 +00:00

[PM-13127]Breadcrumb event logs (#13386)

* Changes for the dummy event

* Add the request UI changes

* refactoring the code

* swapping out the datasources

* Put the changes behind a feature flag

* Rename the Feature flag to lowercase

* Rename the feature flag to epic

* Changes to resolve the pr comments

* Merge the two tables

* commit changes

* Remove unused code

* Add the suggested of content projection

* Resolve the failing ui issues

* remove unused code

* Resolve the repeated code
This commit is contained in:
cyprain-okeke
2025-02-25 21:49:50 +01:00
committed by GitHub
parent c7315a0790
commit 2bb86631f3
8 changed files with 213 additions and 17 deletions

View File

@@ -40,7 +40,10 @@
<bit-nav-item <bit-nav-item
[text]="'eventLogs' | i18n" [text]="'eventLogs' | i18n"
route="reporting/events" route="reporting/events"
*ngIf="organization.canAccessEventLogs" *ngIf="
(organization.canAccessEventLogs && organization.useEvents) ||
(organization.isOwner && (isBreadcrumbEventLogsEnabled$ | async))
"
></bit-nav-item> ></bit-nav-item>
<bit-nav-item <bit-nav-item
[text]="'reports' | i18n" [text]="'reports' | i18n"

View File

@@ -65,6 +65,7 @@ export class OrganizationLayoutComponent implements OnInit {
enterpriseOrganization$: Observable<boolean>; enterpriseOrganization$: Observable<boolean>;
showAccountDeprovisioningBanner$: Observable<boolean>; showAccountDeprovisioningBanner$: Observable<boolean>;
protected isBreadcrumbEventLogsEnabled$: Observable<boolean>;
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
@@ -78,6 +79,9 @@ export class OrganizationLayoutComponent implements OnInit {
) {} ) {}
async ngOnInit() { async ngOnInit() {
this.isBreadcrumbEventLogsEnabled$ = this.configService.getFeatureFlag$(
FeatureFlag.PM12276_BreadcrumbEventLogs,
);
document.body.classList.remove("layout_frontend"); document.body.classList.remove("layout_frontend");
this.organization$ = this.route.params.pipe( this.organization$ = this.route.params.pipe(

View File

@@ -1,5 +1,9 @@
<app-header></app-header> @let usePlaceHolderEvents = !organization?.useEvents && (isBreadcrumbEventLogsEnabled$ | async);
<app-header>
<span bitBadge variant="primary" slot="title-suffix" *ngIf="usePlaceHolderEvents">
{{ "upgrade" | i18n }}
</span>
</app-header>
<div class="tw-mb-4" [formGroup]="eventsForm"> <div class="tw-mb-4" [formGroup]="eventsForm">
<div class="tw-mt-4 tw-flex tw-items-center"> <div class="tw-mt-4 tw-flex tw-items-center">
<bit-form-field> <bit-form-field>
@@ -31,6 +35,7 @@
bitFormButton bitFormButton
buttonType="primary" buttonType="primary"
[bitAction]="refreshEvents" [bitAction]="refreshEvents"
[disabled]="usePlaceHolderEvents"
> >
{{ "update" | i18n }} {{ "update" | i18n }}
</button> </button>
@@ -42,7 +47,7 @@
bitButton bitButton
bitFormButton bitFormButton
[bitAction]="exportEvents" [bitAction]="exportEvents"
[disabled]="dirtyDates" [disabled]="dirtyDates || usePlaceHolderEvents"
> >
<span>{{ "export" | i18n }}</span> <span>{{ "export" | i18n }}</span>
<i class="bwi bwi-fw bwi-sign-in" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-sign-in" aria-hidden="true"></i>
@@ -50,6 +55,13 @@
</form> </form>
</div> </div>
</div> </div>
<bit-callout
type="info"
[title]="'upgradeEventLogTitle' | i18n"
*ngIf="loaded && usePlaceHolderEvents"
>
{{ "upgradeEventLogMessage" | i18n }}
</bit-callout>
<ng-container *ngIf="!loaded"> <ng-container *ngIf="!loaded">
<i <i
class="bwi bwi-spinner bwi-spin tw-text-muted" class="bwi bwi-spinner bwi-spin tw-text-muted"
@@ -59,8 +71,10 @@
<span class="tw-sr-only">{{ "loading" | i18n }}</span> <span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container> </ng-container>
<ng-container *ngIf="loaded"> <ng-container *ngIf="loaded">
<p *ngIf="!events || !events.length">{{ "noEventsInList" | i18n }}</p> @let displayedEvents = organization?.useEvents ? events : placeholderEvents;
<bit-table *ngIf="events && events.length">
<p *ngIf="!displayedEvents || !displayedEvents.length">{{ "noEventsInList" | i18n }}</p>
<bit-table *ngIf="displayedEvents && displayedEvents.length">
<ng-container header> <ng-container header>
<tr> <tr>
<th bitCell>{{ "timestamp" | i18n }}</th> <th bitCell>{{ "timestamp" | i18n }}</th>
@@ -70,8 +84,10 @@
</tr> </tr>
</ng-container> </ng-container>
<ng-template body> <ng-template body>
<tr bitRow *ngFor="let e of events" alignContent="top"> <tr bitRow *ngFor="let e of displayedEvents; index as i" alignContent="top">
<td bitCell class="tw-whitespace-nowrap">{{ e.date | date: "medium" }}</td> <td bitCell class="tw-whitespace-nowrap">
{{ i > 4 && usePlaceHolderEvents ? "******" : (e.date | date: "medium") }}
</td>
<td bitCell> <td bitCell>
<span title="{{ e.appName }}, {{ e.ip }}">{{ e.appName }}</span> <span title="{{ e.appName }}, {{ e.ip }}">{{ e.appName }}</span>
</td> </td>
@@ -92,3 +108,26 @@
{{ "loadMore" | i18n }} {{ "loadMore" | i18n }}
</button> </button>
</ng-container> </ng-container>
<ng-container *ngIf="loaded && usePlaceHolderEvents">
<div
class="tw-relative tw--top-72 tw-bg-[#ffffff] tw-bg-opacity-90 tw-pb-5 tw-flex tw-items-center tw-justify-center"
>
<div
class="tw-bg-[#ffffff] tw-max-w-xl tw-flex-col tw-justify-center tw-text-center tw-p-5 tw-px-10 tw-rounded tw-border-0 tw-border-b tw-border-secondary-300 tw-border-solid mt-5"
>
<i class="bwi bwi-2x bwi-business text-primary"></i>
<p class="tw-font-bold mt-2">
{{ "limitedEventLogs" | i18n: ProductTierType[organization?.productTierType] }}
</p>
<p>
{{ "upgradeForFullEvents" | i18n }}
</p>
<button type="button" class="tw-mt-1" bitButton buttonType="primary" (click)="changePlan()">
{{ "changeBillingPlan" | i18n }}
</button>
</div>
</div>
</ng-container>

View File

@@ -2,11 +2,12 @@
// @ts-strict-ignore // @ts-strict-ignore
import { Component, OnDestroy, OnInit } from "@angular/core"; import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router"; import { ActivatedRoute, Router } from "@angular/router";
import { concatMap, firstValueFrom, Subject, takeUntil } from "rxjs"; import { concatMap, firstValueFrom, lastValueFrom, Subject, takeUntil } from "rxjs";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common"; import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { import {
getOrganizationById, getOrganizationById,
OrganizationService, OrganizationService,
@@ -15,18 +16,29 @@ import { ProviderService } from "@bitwarden/common/admin-console/abstractions/pr
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
import { EventSystemUser } from "@bitwarden/common/enums"; import { EventSystemUser } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EventResponse } from "@bitwarden/common/models/response/event.response"; import { EventResponse } from "@bitwarden/common/models/response/event.response";
import { EventView } from "@bitwarden/common/models/view/event.view";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ToastService } from "@bitwarden/components"; import { DialogService, ToastService } from "@bitwarden/components";
import {
ChangePlanDialogResultType,
openChangePlanDialog,
} from "../../../billing/organizations/change-plan-dialog.component";
import { EventService } from "../../../core"; import { EventService } from "../../../core";
import { EventExportService } from "../../../tools/event-export"; import { EventExportService } from "../../../tools/event-export";
import { BaseEventsComponent } from "../../common/base.events.component"; import { BaseEventsComponent } from "../../common/base.events.component";
import { placeholderEvents } from "./placeholder-events";
const EVENT_SYSTEM_USER_TO_TRANSLATION: Record<EventSystemUser, string> = { const EVENT_SYSTEM_USER_TO_TRANSLATION: Record<EventSystemUser, string> = {
[EventSystemUser.SCIM]: null, // SCIM acronym not able to be translated so just display SCIM [EventSystemUser.SCIM]: null, // SCIM acronym not able to be translated so just display SCIM
[EventSystemUser.DomainVerification]: "domainVerification", [EventSystemUser.DomainVerification]: "domainVerification",
@@ -41,10 +53,19 @@ export class EventsComponent extends BaseEventsComponent implements OnInit, OnDe
exportFileName = "org-events"; exportFileName = "org-events";
organizationId: string; organizationId: string;
organization: Organization; organization: Organization;
organizationSubscription: OrganizationSubscriptionResponse;
placeholderEvents = placeholderEvents as EventView[];
private orgUsersUserIdMap = new Map<string, any>(); private orgUsersUserIdMap = new Map<string, any>();
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
readonly ProductTierType = ProductTierType;
protected isBreadcrumbEventLogsEnabled$ = this.configService.getFeatureFlag$(
FeatureFlag.PM12276_BreadcrumbEventLogs,
);
constructor( constructor(
private apiService: ApiService, private apiService: ApiService,
private route: ActivatedRoute, private route: ActivatedRoute,
@@ -57,10 +78,13 @@ export class EventsComponent extends BaseEventsComponent implements OnInit, OnDe
private userNamePipe: UserNamePipe, private userNamePipe: UserNamePipe,
private organizationService: OrganizationService, private organizationService: OrganizationService,
private organizationUserApiService: OrganizationUserApiService, private organizationUserApiService: OrganizationUserApiService,
private organizationApiService: OrganizationApiServiceAbstraction,
private providerService: ProviderService, private providerService: ProviderService,
fileDownloadService: FileDownloadService, fileDownloadService: FileDownloadService,
toastService: ToastService, toastService: ToastService,
private accountService: AccountService, private accountService: AccountService,
private dialogService: DialogService,
private configService: ConfigService,
) { ) {
super( super(
eventService, eventService,
@@ -84,10 +108,16 @@ export class EventsComponent extends BaseEventsComponent implements OnInit, OnDe
.organizations$(userId) .organizations$(userId)
.pipe(getOrganizationById(this.organizationId)), .pipe(getOrganizationById(this.organizationId)),
); );
if (this.organization == null || !this.organization.useEvents) {
await this.router.navigate(["/organizations", this.organizationId]); if (!this.organization.useEvents) {
return; this.eventsForm.get("start").disable();
this.eventsForm.get("end").disable();
this.organizationSubscription = await this.organizationApiService.getSubscription(
this.organizationId,
);
} }
await this.load(); await this.load();
}), }),
takeUntil(this.destroy$), takeUntil(this.destroy$),
@@ -126,7 +156,6 @@ export class EventsComponent extends BaseEventsComponent implements OnInit, OnDe
this.logService.warning(e); this.logService.warning(e);
} }
} }
await this.refreshEvents(); await this.refreshEvents();
this.loaded = true; this.loaded = true;
} }
@@ -186,6 +215,23 @@ export class EventsComponent extends BaseEventsComponent implements OnInit, OnDe
return id?.substring(0, 8); return id?.substring(0, 8);
} }
async changePlan() {
const reference = openChangePlanDialog(this.dialogService, {
data: {
organizationId: this.organizationId,
subscription: this.organizationSubscription,
productTierType: this.organization.productTierType,
},
});
const result = await lastValueFrom(reference.closed);
if (result === ChangePlanDialogResultType.Closed) {
return;
}
await this.load();
}
ngOnDestroy() { ngOnDestroy() {
this.destroy$.next(); this.destroy$.next();
this.destroy$.complete(); this.destroy$.complete();

View File

@@ -0,0 +1,63 @@
function getRandomDateTime() {
const now = new Date();
const past24Hours = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const randomTime =
past24Hours.getTime() + Math.random() * (now.getTime() - past24Hours.getTime());
const randomDate = new Date(randomTime);
return randomDate.toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: true,
});
}
const asteriskPlaceholders = new Array(6).fill({
appName: "***",
userName: "**********",
userEmail: "**********",
message: "**********",
});
export const placeholderEvents = [
{
date: getRandomDateTime(),
appName: "Extension - Firefox",
userName: "Alice",
userEmail: "alice@email.com",
message: "Logged in",
},
{
date: getRandomDateTime(),
appName: "Mobile - iOS",
userName: "Bob",
message: `Viewed item <span class="tw-text-code">000000</span>`,
},
{
date: getRandomDateTime(),
appName: "Desktop - Linux",
userName: "Carlos",
userEmail: "carlos@email.com",
message: "Login attempt failed with incorrect password",
},
{
date: getRandomDateTime(),
appName: "Web vault - Chrome",
userName: "Ivan",
userEmail: "ivan@email.com",
message: `Confirmed user <span class="tw-text-code">000000</span>`,
},
{
date: getRandomDateTime(),
appName: "Mobile - Android",
userName: "Franz",
userEmail: "franz@email.com",
message: `Sent item <span class="tw-text-code">000000</span> to trash`,
},
]
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
.concat(asteriskPlaceholders);

View File

@@ -1,12 +1,14 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { NgModule } from "@angular/core"; import { inject, NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router"; import { CanMatchFn, RouterModule, Routes } from "@angular/router";
import { map } from "rxjs";
import { canAccessReportingTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { canAccessReportingTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
/* eslint no-restricted-imports: "off" -- Normally prohibited by Tools Team eslint rules but required here */
import { ExposedPasswordsReportComponent } from "../../../tools/reports/pages/organizations/exposed-passwords-report.component"; import { ExposedPasswordsReportComponent } from "../../../tools/reports/pages/organizations/exposed-passwords-report.component";
import { InactiveTwoFactorReportComponent } from "../../../tools/reports/pages/organizations/inactive-two-factor-report.component"; import { InactiveTwoFactorReportComponent } from "../../../tools/reports/pages/organizations/inactive-two-factor-report.component";
import { ReusedPasswordsReportComponent } from "../../../tools/reports/pages/organizations/reused-passwords-report.component"; import { ReusedPasswordsReportComponent } from "../../../tools/reports/pages/organizations/reused-passwords-report.component";
@@ -20,6 +22,11 @@ import { EventsComponent } from "../manage/events.component";
import { ReportsHomeComponent } from "./reports-home.component"; import { ReportsHomeComponent } from "./reports-home.component";
const breadcrumbEventLogsPermission$: CanMatchFn = () =>
inject(ConfigService)
.getFeatureFlag$(FeatureFlag.PM12276_BreadcrumbEventLogs)
.pipe(map((breadcrumbEventLogs) => breadcrumbEventLogs === true));
const routes: Routes = [ const routes: Routes = [
{ {
path: "", path: "",
@@ -81,6 +88,20 @@ const routes: Routes = [
}, },
], ],
}, },
// Event routing is temporarily duplicated
{
path: "events",
component: EventsComponent,
canMatch: [breadcrumbEventLogsPermission$], // if this matches, the flag is ON
canActivate: [
organizationPermissionsGuard(
(org) => (org.canAccessEventLogs && org.useEvents) || org.isOwner,
),
],
data: {
titleId: "eventLogs",
},
},
{ {
path: "events", path: "events",
component: EventsComponent, component: EventsComponent,

View File

@@ -10513,5 +10513,23 @@
}, },
"removeUnlockWithPinPolicyDesc": { "removeUnlockWithPinPolicyDesc": {
"message": "Do not allow members to unlock their account with a PIN." "message": "Do not allow members to unlock their account with a PIN."
},
"limitedEventLogs": {
"message": "$PRODUCT_TYPE$ plans do not have access to real event logs",
"placeholders": {
"product_type": {
"content": "$1",
"example": "Teams"
}
}
},
"upgradeForFullEvents": {
"message": "Get full access to organization event logs by upgrading to a Teams or Enterprise plan."
},
"upgradeEventLogTitle" : {
"message" : "Upgrade for real event log data"
},
"upgradeEventLogMessage":{
"message" : "These events are examples only and do not reflect real events within your Bitwarden organization."
} }
} }

View File

@@ -48,6 +48,7 @@ export enum FeatureFlag {
NewDeviceVerification = "new-device-verification", NewDeviceVerification = "new-device-verification",
PM15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal", PM15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal",
RecoveryCodeLogin = "pm-17128-recovery-code-login", RecoveryCodeLogin = "pm-17128-recovery-code-login",
PM12276_BreadcrumbEventLogs = "pm-12276-breadcrumbing-for-business-features",
} }
export type AllowedFeatureFlagTypes = boolean | number | string; export type AllowedFeatureFlagTypes = boolean | number | string;
@@ -106,6 +107,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.NewDeviceVerification]: FALSE, [FeatureFlag.NewDeviceVerification]: FALSE,
[FeatureFlag.PM15179_AddExistingOrgsFromProviderPortal]: FALSE, [FeatureFlag.PM15179_AddExistingOrgsFromProviderPortal]: FALSE,
[FeatureFlag.RecoveryCodeLogin]: FALSE, [FeatureFlag.RecoveryCodeLogin]: FALSE,
[FeatureFlag.PM12276_BreadcrumbEventLogs]: FALSE,
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>; } satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue; export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;