From 0456ffa048dc3697bbf52d08af2d28f2a6a982a5 Mon Sep 17 00:00:00 2001 From: Daniel James Smith Date: Wed, 19 Mar 2025 16:53:39 +0100 Subject: [PATCH 1/4] Services Naive EventStore (will be replaced by AchievementHub) AchievementService functionality overlaps with AchievementHub, besides retrieving Achievement configuration AchievementNotifier subscribes to the AchievementService and filters on AchievementEarned and per Device - Needs to also be migrated to listen to the AchievementHub --- .../src/services/jslib-services.module.ts | 27 +++++++++ .../achievement-notifier.abstraction.ts | 3 + .../achievement-notifier.service.ts | 56 +++++++++++++++++ .../achievement.service.abstraction.ts | 12 ++++ .../default-achievement.service.ts | 60 +++++++++++++++++++ .../event-store.abstraction.service.ts | 10 ++++ .../src/tools/achievements/event-store.ts | 22 +++++++ 7 files changed, 190 insertions(+) create mode 100644 libs/angular/src/tools/achievements/achievement-notifier.abstraction.ts create mode 100644 libs/angular/src/tools/achievements/achievement-notifier.service.ts create mode 100644 libs/common/src/tools/achievements/achievement.service.abstraction.ts create mode 100644 libs/common/src/tools/achievements/default-achievement.service.ts create mode 100644 libs/common/src/tools/achievements/event-store.abstraction.service.ts create mode 100644 libs/common/src/tools/achievements/event-store.ts diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index d31cd0c84e2..9327d9df859 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -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({ diff --git a/libs/angular/src/tools/achievements/achievement-notifier.abstraction.ts b/libs/angular/src/tools/achievements/achievement-notifier.abstraction.ts new file mode 100644 index 00000000000..55daf4412c6 --- /dev/null +++ b/libs/angular/src/tools/achievements/achievement-notifier.abstraction.ts @@ -0,0 +1,3 @@ +export abstract class AchievementNotifierService { + abstract init(): Promise; +} diff --git a/libs/angular/src/tools/achievements/achievement-notifier.service.ts b/libs/angular/src/tools/achievements/achievement-notifier.service.ts new file mode 100644 index 00000000000..652d3614f6e --- /dev/null +++ b/libs/angular/src/tools/achievements/achievement-notifier.service.ts @@ -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$ + } +} diff --git a/libs/common/src/tools/achievements/achievement.service.abstraction.ts b/libs/common/src/tools/achievements/achievement.service.abstraction.ts new file mode 100644 index 00000000000..f1e67ea3a01 --- /dev/null +++ b/libs/common/src/tools/achievements/achievement.service.abstraction.ts @@ -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; + + abstract achievementsEarned$: (userId: UserId) => Observable; + abstract achievementsInProgress$: (userId: UserId) => Observable; +} diff --git a/libs/common/src/tools/achievements/default-achievement.service.ts b/libs/common/src/tools/achievements/default-achievement.service.ts new file mode 100644 index 00000000000..c4e25bce87a --- /dev/null +++ b/libs/common/src/tools/achievements/default-achievement.service.ts @@ -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; + + // Provided by the AchievementHub + achievementsEarned$: (userId: UserId) => Observable; + + // Provided by the AchievementHub + achievementsInProgress$: (userId: UserId) => Observable; + + 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, + ), + ); + }; + } +} diff --git a/libs/common/src/tools/achievements/event-store.abstraction.service.ts b/libs/common/src/tools/achievements/event-store.abstraction.service.ts new file mode 100644 index 00000000000..03252f390f0 --- /dev/null +++ b/libs/common/src/tools/achievements/event-store.abstraction.service.ts @@ -0,0 +1,10 @@ +import { Observable } from "rxjs"; + +import { AchievementEarnedEvent, AchievementProgressEvent, UserActionEvent } from "./types"; + +export abstract class EventStoreAbstraction { + abstract events$: Observable; + abstract addEvent( + event: UserActionEvent | AchievementProgressEvent | AchievementEarnedEvent, + ): boolean; +} diff --git a/libs/common/src/tools/achievements/event-store.ts b/libs/common/src/tools/achievements/event-store.ts new file mode 100644 index 00000000000..5fcbf7b3a43 --- /dev/null +++ b/libs/common/src/tools/achievements/event-store.ts @@ -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(); + + events$: Observable = + 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; + } +} From bdf0b20f7ec607d52555861d6991b9dd82c1782b Mon Sep 17 00:00:00 2001 From: Daniel James Smith Date: Wed, 19 Mar 2025 16:54:53 +0100 Subject: [PATCH 2/4] Example configurations for Achievements and Metric --- .../examples/achievements/index.ts | 2 + .../examples/achievements/login-item-added.ts | 45 +++++++++++++++++++ .../examples/achievements/vault-item-added.ts | 45 +++++++++++++++++++ .../achievements/examples/metrics/metrics.ts | 5 +++ 4 files changed, 97 insertions(+) create mode 100644 libs/common/src/tools/achievements/examples/achievements/index.ts create mode 100644 libs/common/src/tools/achievements/examples/achievements/login-item-added.ts create mode 100644 libs/common/src/tools/achievements/examples/achievements/vault-item-added.ts create mode 100644 libs/common/src/tools/achievements/examples/metrics/metrics.ts diff --git a/libs/common/src/tools/achievements/examples/achievements/index.ts b/libs/common/src/tools/achievements/examples/achievements/index.ts new file mode 100644 index 00000000000..bd37842fc0c --- /dev/null +++ b/libs/common/src/tools/achievements/examples/achievements/index.ts @@ -0,0 +1,2 @@ +export * from "./vault-item-added"; +export * from "./login-item-added"; diff --git a/libs/common/src/tools/achievements/examples/achievements/login-item-added.ts b/libs/common/src/tools/achievements/examples/achievements/login-item-added.ts new file mode 100644 index 00000000000..8c63af8a890 --- /dev/null +++ b/libs/common/src/tools/achievements/examples/achievements/login-item-added.ts @@ -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, +}; diff --git a/libs/common/src/tools/achievements/examples/achievements/vault-item-added.ts b/libs/common/src/tools/achievements/examples/achievements/vault-item-added.ts new file mode 100644 index 00000000000..ff806ec7b91 --- /dev/null +++ b/libs/common/src/tools/achievements/examples/achievements/vault-item-added.ts @@ -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, +}; diff --git a/libs/common/src/tools/achievements/examples/metrics/metrics.ts b/libs/common/src/tools/achievements/examples/metrics/metrics.ts new file mode 100644 index 00000000000..8805cbf30b6 --- /dev/null +++ b/libs/common/src/tools/achievements/examples/metrics/metrics.ts @@ -0,0 +1,5 @@ +import { MetricId } from "../../types"; + +const VaultItemCreatedProgress = "vault-item-quantity" as MetricId; + +export { VaultItemCreatedProgress }; From 9bb1a62c5d77a51cc446dded4f537d001ee7e103 Mon Sep 17 00:00:00 2001 From: Daniel James Smith Date: Wed, 19 Mar 2025 16:56:40 +0100 Subject: [PATCH 3/4] Web-specific UI testbed --- .../app/layouts/user-layout.component.html | 1 + apps/web/src/app/oss-routing.module.ts | 6 ++ .../achievements/achievements.component.html | 7 +++ .../achievements/achievements.component.ts | 55 +++++++++++++++++++ apps/web/src/locales/en/messages.json | 3 + 5 files changed, 72 insertions(+) create mode 100644 apps/web/src/app/tools/achievements/achievements.component.html create mode 100644 apps/web/src/app/tools/achievements/achievements.component.ts diff --git a/apps/web/src/app/layouts/user-layout.component.html b/apps/web/src/app/layouts/user-layout.component.html index 6a87658f172..d826301176f 100644 --- a/apps/web/src/app/layouts/user-layout.component.html +++ b/apps/web/src/app/layouts/user-layout.component.html @@ -8,6 +8,7 @@ + diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index c531f358b34..80115460376 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -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, + }, ], }, { diff --git a/apps/web/src/app/tools/achievements/achievements.component.html b/apps/web/src/app/tools/achievements/achievements.component.html new file mode 100644 index 00000000000..a4dab900c46 --- /dev/null +++ b/apps/web/src/app/tools/achievements/achievements.component.html @@ -0,0 +1,7 @@ + + + + + diff --git a/apps/web/src/app/tools/achievements/achievements.component.ts b/apps/web/src/app/tools/achievements/achievements.component.ts new file mode 100644 index 00000000000..ce2351346ab --- /dev/null +++ b/apps/web/src/app/tools/achievements/achievements.component.ts @@ -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); + } +} diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 68900b2ed74..2e8cf93e1ce 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -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" } } From 6c060b29b3d64b11ad4a0552b2b467c9872c05f1 Mon Sep 17 00:00:00 2001 From: Daniel James Smith Date: Wed, 19 Mar 2025 16:57:34 +0100 Subject: [PATCH 4/4] Browser-specifc UI testbed --- apps/browser/src/_locales/en/messages.json | 3 + .../account-switcher.component.html | 21 ++++++ .../account-switcher.component.ts | 3 +- apps/browser/src/popup/app-routing.module.ts | 7 ++ .../src/popup/services/init.service.ts | 3 + .../achievements/achievements.component.html | 13 ++++ .../achievements/achievements.component.ts | 66 +++++++++++++++++++ 7 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 apps/browser/src/tools/popup/achievements/achievements.component.html create mode 100644 apps/browser/src/tools/popup/achievements/achievements.component.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 127e07f25e8..4db8952c92c 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -5187,5 +5187,8 @@ }, "changeAtRiskPassword": { "message": "Change at-risk password" + }, + "achievements": { + "message": "Achievements" } } diff --git a/apps/browser/src/auth/popup/account-switching/account-switcher.component.html b/apps/browser/src/auth/popup/account-switching/account-switcher.component.html index de8ab4c7b08..bd8c025db5a 100644 --- a/apps/browser/src/auth/popup/account-switching/account-switcher.component.html +++ b/apps/browser/src/auth/popup/account-switching/account-switcher.component.html @@ -13,6 +13,27 @@ + + + +

Achievements

+
+ + + + See all achievements + + + + +
+

{{ "availableAccounts" | i18n }}

diff --git a/apps/browser/src/auth/popup/account-switching/account-switcher.component.ts b/apps/browser/src/auth/popup/account-switching/account-switcher.component.ts index 78bee121afb..c7db6f89812 100644 --- a/apps/browser/src/auth/popup/account-switching/account-switcher.component.ts +++ b/apps/browser/src/auth/popup/account-switching/account-switcher.component.ts @@ -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 { diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index b33940a68d2..05f656f2f4b 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -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, diff --git a/apps/browser/src/popup/services/init.service.ts b/apps/browser/src/popup/services/init.service.ts index fe6fba85a4b..90eff1e9499 100644 --- a/apps/browser/src/popup/services/init.service.ts +++ b/apps/browser/src/popup/services/init.service.ts @@ -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); diff --git a/apps/browser/src/tools/popup/achievements/achievements.component.html b/apps/browser/src/tools/popup/achievements/achievements.component.html new file mode 100644 index 00000000000..73defa852e9 --- /dev/null +++ b/apps/browser/src/tools/popup/achievements/achievements.component.html @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/apps/browser/src/tools/popup/achievements/achievements.component.ts b/apps/browser/src/tools/popup/achievements/achievements.component.ts new file mode 100644 index 00000000000..3d008bdc8ee --- /dev/null +++ b/apps/browser/src/tools/popup/achievements/achievements.component.ts @@ -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); + } +}