1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-10 05:30:01 +00:00

Merge branch 'achievement-notifier-with-web-and-browser-testbed' into innovation/user-achievements/event-stream-prototype

This commit is contained in:
Daniel James Smith
2025-03-20 09:29:44 +01:00
23 changed files with 474 additions and 1 deletions

View File

@@ -5187,5 +5187,8 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
},
"achievements": {
"message": "Achievements"
}
}

View File

@@ -13,6 +13,27 @@
<auth-account [account]="availableAccount" (loading)="loading = $event"></auth-account>
</div>
<ng-container *ngIf="isFirst">
<bit-section>
<bit-section-header>
<h2 bitTypography="h6" class="tw-font-semibold">Achievements</h2>
</bit-section-header>
<bit-item>
<a bit-item-content routerLink="/achievements">
See all achievements
<i slot="end" class="bwi bwi-external-link" aria-hidden="true"></i>
</a>
<!-- <button
type="button"
bit-item-content
>
See all achievements
<i slot="end" class="bwi bwi-external-link" aria-hidden="true"></i>
</button> -->
</bit-item>
</bit-section>
</ng-container>
<ng-container *ngIf="enableAccountSwitching">
<bit-section-header *ngIf="isFirst">
<h2 bitTypography="h6">{{ "availableAccounts" | i18n }}</h2>

View File

@@ -1,6 +1,6 @@
import { CommonModule, Location } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { Router, RouterLink } from "@angular/router";
import { Subject, firstValueFrom, map, of, startWith, switchMap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@@ -50,6 +50,7 @@ import { AccountSwitcherService } from "./services/account-switcher.service";
SectionComponent,
SectionHeaderComponent,
TypographyModule,
RouterLink,
],
})
export class AccountSwitcherComponent implements OnInit, OnDestroy {

View File

@@ -71,6 +71,7 @@ import { NotificationsSettingsComponent } from "../autofill/popup/settings/notif
import { PremiumV2Component } from "../billing/popup/settings/premium-v2.component";
import BrowserPopupUtils from "../platform/popup/browser-popup-utils";
import { popupRouterCacheGuard } from "../platform/popup/view-cache/popup-router-cache.service";
import { AchievementsComponent } from "../tools/popup/achievements/achievements.component";
import { CredentialGeneratorHistoryComponent } from "../tools/popup/generator/credential-generator-history.component";
import { CredentialGeneratorComponent } from "../tools/popup/generator/credential-generator.component";
import { SendAddEditComponent as SendAddEditV2Component } from "../tools/popup/send-v2/add-edit/send-add-edit.component";
@@ -232,6 +233,12 @@ const routes: Routes = [
canActivate: [unauthGuardFn(unauthRouteOverrides)],
data: { elevation: 1 } satisfies RouteDataProperties,
},
{
path: "achievements",
component: AchievementsComponent,
canActivate: [authGuard],
data: { elevation: 1 } satisfies RouteDataProperties,
},
{
path: "view-cipher",
component: ViewV2Component,

View File

@@ -2,6 +2,7 @@ import { DOCUMENT } from "@angular/common";
import { inject, Inject, Injectable } from "@angular/core";
import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction";
import { AchievementNotifierService } from "@bitwarden/angular/tools/achievements/achievement-notifier.abstraction";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService as LogServiceAbstraction } from "@bitwarden/common/platform/abstractions/log.service";
@@ -26,6 +27,7 @@ export class InitService {
private logService: LogServiceAbstraction,
private themingService: AbstractThemingService,
private sdkLoadService: SdkLoadService,
private achievementNotifierService: AchievementNotifierService,
private viewCacheService: PopupViewCacheService,
@Inject(DOCUMENT) private document: Document,
) {}
@@ -38,6 +40,7 @@ export class InitService {
this.twoFactorService.init();
await this.viewCacheService.init();
await this.sizeService.init();
await this.achievementNotifierService.init();
const htmlEl = window.document.documentElement;
this.themingService.applyThemeChangesTo(this.document);

View File

@@ -0,0 +1,13 @@
<popup-page>
<popup-header slot="header" pageTitle="'achievements' | i18n" showBackButton>
<ng-container slot="end">
<app-pop-out></app-pop-out>
</ng-container>
</popup-header>
<popup-footer slot="footer">
<button bitButton type="button" bitFormButton buttonType="primary" (click)="testAchievement()">
Test achievements
</button>
</popup-footer>
</popup-page>

View File

@@ -0,0 +1,66 @@
import { CommonModule } from "@angular/common";
import { Component, OnInit } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { EventStoreAbstraction } from "@bitwarden/common/tools/achievements/event-store.abstraction.service";
import { VaultItems_10_Added_Achievement } from "@bitwarden/common/tools/achievements/examples/achievements";
import { AchievementEarnedEvent, AchievementId } from "@bitwarden/common/tools/achievements/types";
import { UserId } from "@bitwarden/common/types/guid";
import { ButtonModule, IconModule } from "@bitwarden/components";
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
import { PopupFooterComponent } from "../../../platform/popup/layout/popup-footer.component";
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
@Component({
templateUrl: "achievements.component.html",
standalone: true,
imports: [
CommonModule,
JslibModule,
PopupPageComponent,
PopupHeaderComponent,
PopupFooterComponent,
PopOutComponent,
ButtonModule,
IconModule,
],
})
export class AchievementsComponent implements OnInit {
private currentUserId: UserId;
constructor(
private eventStore: EventStoreAbstraction,
private accountService: AccountService,
) {}
async ngOnInit() {
this.currentUserId = (await firstValueFrom(this.accountService.activeAccount$)).id;
}
testAchievement() {
const earnedAchievement: AchievementEarnedEvent = {
"@timestamp": Date.now(),
event: {
kind: "alert",
category: "session",
},
service: {
name: "web",
type: "client",
node: {
name: "an-installation-identifier-for-this-client-instance",
},
environment: "local",
version: "2025.3.1-innovation-sprint",
},
user: { id: this.currentUserId },
achievement: { type: "earned", name: VaultItems_10_Added_Achievement.name as AchievementId },
};
this.eventStore.addEvent(earnedAchievement);
}
}

View File

@@ -8,6 +8,7 @@
<bit-nav-item [text]="'generator' | i18n" route="tools/generator"></bit-nav-item>
<bit-nav-item [text]="'importData' | i18n" route="tools/import"></bit-nav-item>
<bit-nav-item [text]="'exportVault' | i18n" route="tools/export"></bit-nav-item>
<bit-nav-item [text]="'achievements' | i18n" route="tools/achievements"></bit-nav-item>
</bit-nav-group>
<bit-nav-item icon="bwi-sliders" [text]="'reports' | i18n" route="reports"></bit-nav-item>
<bit-nav-group icon="bwi-cog" [text]="'settings' | i18n" route="settings">

View File

@@ -81,6 +81,7 @@ import { RequestSMAccessComponent } from "./secrets-manager/secrets-manager-land
import { SMLandingComponent } from "./secrets-manager/secrets-manager-landing/sm-landing.component";
import { DomainRulesComponent } from "./settings/domain-rules.component";
import { PreferencesComponent } from "./settings/preferences.component";
import { AchievementsComponent } from "./tools/achievements/achievements.component";
import { CredentialGeneratorComponent } from "./tools/credential-generator/credential-generator.component";
import { ReportsModule } from "./tools/reports";
import { AccessComponent, SendAccessExplainerComponent } from "./tools/send/send-access";
@@ -747,6 +748,11 @@ const routes: Routes = [
component: CredentialGeneratorComponent,
data: { titleId: "generator" } satisfies RouteDataProperties,
},
{
path: "achievements",
component: AchievementsComponent,
data: { titleId: "achievements" } satisfies RouteDataProperties,
},
],
},
{

View File

@@ -0,0 +1,7 @@
<app-header></app-header>
<bit-container>
<button bitButton type="button" bitFormButton buttonType="primary" (click)="testAchievement()">
Test achievements
</button>
</bit-container>

View File

@@ -0,0 +1,55 @@
import { Component, OnInit } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { AchievementNotifierService } from "@bitwarden/angular/tools/achievements/achievement-notifier.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { EventStoreAbstraction } from "@bitwarden/common/tools/achievements/event-store.abstraction.service";
import { VaultItems_10_Added_Achievement } from "@bitwarden/common/tools/achievements/examples/achievements";
import { AchievementEarnedEvent, AchievementId } from "@bitwarden/common/tools/achievements/types";
import { UserId } from "@bitwarden/common/types/guid";
import { HeaderModule } from "../../layouts/header/header.module";
import { SharedModule } from "../../shared";
@Component({
templateUrl: "achievements.component.html",
standalone: true,
imports: [SharedModule, HeaderModule],
})
export class AchievementsComponent implements OnInit {
private currentUserId: UserId;
constructor(
private eventStore: EventStoreAbstraction,
private achievementNotifierService: AchievementNotifierService,
private accountService: AccountService,
) {}
async ngOnInit() {
await this.achievementNotifierService.init();
this.currentUserId = (await firstValueFrom(this.accountService.activeAccount$)).id;
}
testAchievement() {
const earnedAchievement: AchievementEarnedEvent = {
"@timestamp": Date.now(),
event: {
kind: "alert",
category: "session",
},
service: {
name: "web",
type: "client",
node: {
name: "an-installation-identifier-for-this-client-instance",
},
environment: "local",
version: "2025.3.1-innovation-sprint",
},
user: { id: this.currentUserId },
achievement: { type: "earned", name: VaultItems_10_Added_Achievement.name as AchievementId },
};
this.eventStore.addEvent(earnedAchievement);
}
}

View File

@@ -10620,5 +10620,8 @@
},
"cannotCreateCollection": {
"message": "Free organizations may have up to 2 collections. Upgrade to a paid plan to add more collections."
},
"achievements": {
"message": "Achievements"
}
}

View File

@@ -245,6 +245,10 @@ import { AuditService } from "@bitwarden/common/services/audit.service";
import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service";
import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service";
import { SearchService } from "@bitwarden/common/services/search.service";
import { AchievementService as AchievementServiceAbstraction } from "@bitwarden/common/tools/achievements/achievement.service.abstraction";
import { DefaultAchievementService } from "@bitwarden/common/tools/achievements/default-achievement.service";
import { EventStore } from "@bitwarden/common/tools/achievements/event-store";
import { EventStoreAbstraction } from "@bitwarden/common/tools/achievements/event-store.abstraction.service";
import {
PasswordStrengthService,
PasswordStrengthServiceAbstraction,
@@ -329,6 +333,8 @@ import { NoopViewCacheService } from "../platform/services/noop-view-cache.servi
import { AngularThemingService } from "../platform/services/theming/angular-theming.service";
import { AbstractThemingService } from "../platform/services/theming/theming.service.abstraction";
import { safeProvider, SafeProvider } from "../platform/utils/safe-provider";
import { AchievementNotifierService as AchievementNotifierServiceAbstraction } from "../tools/achievements/achievement-notifier.abstraction";
import { AchievementNotifierService } from "../tools/achievements/achievement-notifier.service";
import {
LOCALES_DIRECTORY,
@@ -1482,6 +1488,27 @@ const safeProviders: SafeProvider[] = [
ToastService,
],
}),
safeProvider({
provide: EventStoreAbstraction,
useClass: EventStore,
deps: [],
}),
safeProvider({
provide: AchievementServiceAbstraction,
useClass: DefaultAchievementService,
deps: [EventStoreAbstraction],
}),
safeProvider({
provide: AchievementNotifierServiceAbstraction,
useClass: AchievementNotifierService,
deps: [
AccountServiceAbstraction,
AchievementServiceAbstraction,
PlatformUtilsServiceAbstraction,
I18nServiceAbstraction,
ToastService,
],
}),
];
@NgModule({

View File

@@ -0,0 +1,3 @@
export abstract class AchievementNotifierService {
abstract init(): Promise<void>;
}

View File

@@ -0,0 +1,56 @@
import { firstValueFrom, switchMap, tap } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { AchievementService } from "@bitwarden/common/tools/achievements/achievement.service.abstraction";
import { ToastService } from "@bitwarden/components";
import { AchievementNotifierService as AchievementNotifierServiceAbstraction } from "./achievement-notifier.abstraction";
export class AchievementNotifierService implements AchievementNotifierServiceAbstraction {
constructor(
private accountService: AccountService,
private achievementService: AchievementService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private toastService: ToastService,
) {}
async init() {
await this.setupListeners();
}
private async setupListeners() {
// FIXME Implement achievements earned filter and notififer
/* Get the userId from the accountService
* Subscribe to achievementService.achievementsEarned$(userId)
* Retrieve current device and filter out messages that are not for this client/device (achievements should be only shown on the device that earned them)
* Retrieve Achievement by AchievementId via the achievementService
* Use information from Achievement to fill out the options for the notification (toast)
* Invoke showing toast
*/
// FIXME getClientType browswer and achievementEarned.service.name.extension won't match
const account = await firstValueFrom(this.accountService.activeAccount$);
this.achievementService
.achievementsEarned$(account.id)
.pipe(
// Removing filter for testing purposes
// filter(achievementEarned => achievementEarned.service.name == this.platformUtilsService.getClientType())).pipe(
switchMap((earned) => this.achievementService.achievementById$(earned.achievement.name)),
tap((achievement) => {
//eslint-disable-next-line no-console
console.log(achievement);
}),
)
.subscribe((achievement) => {
this.toastService.showToast({
variant: "info",
title: achievement.name,
message: achievement.description,
});
});
// FIXME Migrate to use achievementHub.earned$() instead of achievementService.achievementsEarned$
}
}

View File

@@ -0,0 +1,12 @@
import { Observable } from "rxjs";
import { UserId } from "@bitwarden/common/types/guid";
import { Achievement, AchievementEarnedEvent, AchievementProgressEvent } from "./types";
export abstract class AchievementService {
abstract achievementById$: (achievementId: string) => Observable<Achievement>;
abstract achievementsEarned$: (userId: UserId) => Observable<AchievementEarnedEvent>;
abstract achievementsInProgress$: (userId: UserId) => Observable<AchievementProgressEvent>;
}

View File

@@ -0,0 +1,60 @@
import { filter, find, from, Observable } from "rxjs";
import { UserId } from "@bitwarden/common/types/guid";
import { AchievementService as AchievementServiceAbstraction } from "./achievement.service.abstraction";
import { EventStoreAbstraction } from "./event-store.abstraction.service";
import {
VaultItems_1_Added_Achievement,
VaultItems_10_Added_Achievement,
} from "./examples/achievements";
import { isEarnedEvent, isProgressEvent } from "./meta";
import {
Achievement,
AchievementEarnedEvent,
AchievementEvent,
AchievementId,
AchievementProgressEvent,
} from "./types";
// Service might be deprecated in favor of the AchievmentHub
// The hub is currently missing a way of listing all achievements, finding by id, but that could be possibly done via the AchievementManager
export class DefaultAchievementService implements AchievementServiceAbstraction {
private _achievements: Achievement[] = [
VaultItems_1_Added_Achievement,
VaultItems_10_Added_Achievement,
];
private _achievementsSubject = from(this._achievements);
achievementById$: (achievementId: string) => Observable<Achievement>;
// Provided by the AchievementHub
achievementsEarned$: (userId: UserId) => Observable<AchievementEarnedEvent>;
// Provided by the AchievementHub
achievementsInProgress$: (userId: UserId) => Observable<AchievementProgressEvent>;
constructor(protected eventStore: EventStoreAbstraction) {
this.achievementById$ = (achievementId: AchievementId) =>
this._achievementsSubject.pipe(find((item: Achievement) => item.name === achievementId));
this.achievementsEarned$ = (userId: UserId) => {
return this.eventStore.events$.pipe(
filter(
(event): event is AchievementEarnedEvent =>
isEarnedEvent(event as AchievementEvent) && event.user.id === userId,
),
);
};
this.achievementsInProgress$ = (userId: UserId) => {
return this.eventStore.events$.pipe(
filter(
(event): event is AchievementProgressEvent =>
isProgressEvent(event as AchievementEvent) && event.user.id === userId,
),
);
};
}
}

View File

@@ -0,0 +1,10 @@
import { Observable } from "rxjs";
import { AchievementEarnedEvent, AchievementProgressEvent, UserActionEvent } from "./types";
export abstract class EventStoreAbstraction {
abstract events$: Observable<UserActionEvent | AchievementProgressEvent | AchievementEarnedEvent>;
abstract addEvent(
event: UserActionEvent | AchievementProgressEvent | AchievementEarnedEvent,
): boolean;
}

View File

@@ -0,0 +1,22 @@
import { Observable, Subject } from "rxjs";
import { EventStoreAbstraction } from "./event-store.abstraction.service";
import { AchievementEarnedEvent, AchievementProgressEvent } from "./types";
// Will be replaced by the achievementHub
export class EventStore implements EventStoreAbstraction {
private _events = new Subject<AchievementProgressEvent | AchievementEarnedEvent>();
events$: Observable<AchievementProgressEvent | AchievementEarnedEvent> =
this._events.asObservable();
constructor() {}
addEvent(event: AchievementProgressEvent | AchievementEarnedEvent): boolean {
// FIXME Collapse existing of same metric/higher count AchievementProgressEvents
//eslint-disable-next-line no-console
console.log("EventStore.addEvent", event);
this._events.next(event);
return true;
}
}

View File

@@ -0,0 +1,2 @@
export * from "./vault-item-added";
export * from "./login-item-added";

View File

@@ -0,0 +1,45 @@
import { Achievement, AchievementId } from "../../types";
import { VaultItemCreatedProgress } from "../metrics/metrics";
const LoginItems_1_Added_Achievement: Achievement = {
achievement: "login-item-added" as AchievementId,
name: "Access granted",
description: "Saved your first login item with Bitwarden",
validator: "Threshold",
active: { metric: VaultItemCreatedProgress, high: 1 },
hidden: false,
};
const LoginItems_10_Added_Achievement: Achievement = {
achievement: "10-login-items-added" as AchievementId,
name: "10 Login Items Added",
description: "Add 10 item of type login to your vault",
validator: "Threshold",
active: { metric: VaultItemCreatedProgress, high: 10 },
hidden: false,
};
const LoginItems_50_Added_Achievement: Achievement = {
achievement: "50-login-items-added" as AchievementId,
name: "50 Login Items Added",
description: "Add 50 item of type login to your vault",
validator: "Threshold",
active: { metric: VaultItemCreatedProgress, high: 50 },
hidden: false,
};
const LoginItems_100_Added_Achievement: Achievement = {
achievement: "100-login-items-added" as AchievementId,
name: "100 Login Items Added",
description: "Add 100 item of type login to your vault",
validator: "Threshold",
active: { metric: VaultItemCreatedProgress, high: 100 },
hidden: false,
};
export {
LoginItems_1_Added_Achievement,
LoginItems_10_Added_Achievement,
LoginItems_50_Added_Achievement,
LoginItems_100_Added_Achievement,
};

View File

@@ -0,0 +1,45 @@
import { Achievement, AchievementId } from "../../types";
import { VaultItemCreatedProgress } from "../metrics/metrics";
const VaultItems_1_Added_Achievement: Achievement = {
achievement: "vault-item-added" as AchievementId,
name: "The chosen one",
description: "Saved your fist item to Bitwarden",
validator: "Threshold",
active: { metric: VaultItemCreatedProgress, high: 1 },
hidden: false,
};
const VaultItems_10_Added_Achievement: Achievement = {
achievement: "10-vault-items-added" as AchievementId,
name: "A decade of security",
description: "Saved your 10th item to Bitwarden",
validator: "Threshold",
active: { metric: VaultItemCreatedProgress, high: 10 },
hidden: false,
};
const VaultItems_50_Added_Achievement: Achievement = {
achievement: "50-vault-items-added" as AchievementId,
name: "It's 50/50 Vault Items Added",
description: "Saved your 50th item to Bitwarden",
validator: "Threshold",
active: { metric: VaultItemCreatedProgress, high: 50 },
hidden: false,
};
const VaultItems_100_Added_Achievement: Achievement = {
achievement: "100-vault-items-added" as AchievementId,
name: "Century mark, Now you are thinking with ciphers",
description: "Saved your 100th item to Bitwarden",
validator: "Threshold",
active: { metric: VaultItemCreatedProgress, high: 100 },
hidden: false,
};
export {
VaultItems_1_Added_Achievement,
VaultItems_10_Added_Achievement,
VaultItems_50_Added_Achievement,
VaultItems_100_Added_Achievement,
};

View File

@@ -0,0 +1,5 @@
import { MetricId } from "../../types";
const VaultItemCreatedProgress = "vault-item-quantity" as MetricId;
export { VaultItemCreatedProgress };