diff --git a/libs/angular/src/tools/achievements/achievement-card.component.html b/libs/angular/src/tools/achievements/achievement-card.component.html index fb611b2e44d..0aadfd08473 100644 --- a/libs/angular/src/tools/achievements/achievement-card.component.html +++ b/libs/angular/src/tools/achievements/achievement-card.component.html @@ -1,6 +1,6 @@
- +

{{ title() }}

{{ description() }}

diff --git a/libs/angular/src/tools/achievements/achievement-card.component.ts b/libs/angular/src/tools/achievements/achievement-card.component.ts index e483538e3af..3b0254e98c0 100644 --- a/libs/angular/src/tools/achievements/achievement-card.component.ts +++ b/libs/angular/src/tools/achievements/achievement-card.component.ts @@ -6,21 +6,19 @@ import { CardComponent, Icon, IconModule, - ItemModule, TypographyModule, } from "@bitwarden/components"; -import { AchievementIcon } from "./achievement-icon"; -import { NotAchievedIcon } from "./not-achieved-icon"; - +import { AchievementIcon } from "./icons/achievement.icon"; @Component({ selector: "achievement-card", templateUrl: "achievement-card.component.html", standalone: true, - imports: [CommonModule, ItemModule, ButtonModule, IconModule, TypographyModule, CardComponent], + imports: [CommonModule, ButtonModule, IconModule, TypographyModule, CardComponent], }) export class AchievementCard { - protected icon: Icon = NotAchievedIcon; + protected readonly icon: Icon = AchievementIcon; + protected iconStyle: string = "tw-grayscale"; title = input.required(); description = input.required(); @@ -37,14 +35,14 @@ export class AchievementCard { untracked(() => { if (earned) { - this.icon = AchievementIcon; this.cardClass = "tw-bg-success-100"; + this.iconStyle = ""; } else if (progress > 0) { - this.icon = AchievementIcon; this.cardClass = "tw-bg-info-100"; + this.iconStyle = "tw-grayscale"; } else { - this.icon = NotAchievedIcon; this.cardClass = ""; + this.iconStyle = "tw-grayscale"; } }); }); diff --git a/libs/angular/src/tools/achievements/achievement-item.component.html b/libs/angular/src/tools/achievements/achievement-item.component.html new file mode 100644 index 00000000000..e68663c1a20 --- /dev/null +++ b/libs/angular/src/tools/achievements/achievement-item.component.html @@ -0,0 +1,12 @@ + + + diff --git a/libs/angular/src/tools/achievements/achievement-item.component.ts b/libs/angular/src/tools/achievements/achievement-item.component.ts new file mode 100644 index 00000000000..ce50b7dce1e --- /dev/null +++ b/libs/angular/src/tools/achievements/achievement-item.component.ts @@ -0,0 +1,51 @@ +import { CommonModule } from "@angular/common"; +import { Component, effect, input, untracked } from "@angular/core"; + +import { + ButtonModule, + Icon, + IconModule, + ItemModule, + TypographyModule, +} from "@bitwarden/components"; + +import { AchievementIcon } from "./icons/achievement.icon"; + +@Component({ + selector: "achievement-item", + templateUrl: "achievement-item.component.html", + standalone: true, + imports: [CommonModule, ItemModule, ButtonModule, IconModule, TypographyModule], +}) +export class AchievemenItem { + protected readonly icon: Icon = AchievementIcon; + protected iconStyle: string = "tw-grayscale"; + + title = input.required(); + description = input.required(); + + earned = input(false); + progress = input(0); + date = input(); + + protected cardClass: string; + constructor() { + effect(() => { + const earned = this.earned(); + const progress = this.progress(); + + untracked(() => { + if (earned) { + this.cardClass = "tw-bg-success-100"; + this.iconStyle = ""; + } else if (progress > 0) { + this.cardClass = "tw-bg-info-100"; + this.iconStyle = "tw-grayscale"; + } else { + this.cardClass = ""; + this.iconStyle = "tw-grayscale"; + } + }); + }); + } +} diff --git a/libs/angular/src/tools/achievements/achievement-notifier.service.ts b/libs/angular/src/tools/achievements/achievement-notifier.service.ts index b00addfb42a..5f1ae302eaa 100644 --- a/libs/angular/src/tools/achievements/achievement-notifier.service.ts +++ b/libs/angular/src/tools/achievements/achievement-notifier.service.ts @@ -6,8 +6,8 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { AchievementService } from "@bitwarden/common/tools/achievements/achievement.service.abstraction"; import { ToastService } from "@bitwarden/components"; -import { AchievementIcon } from "./achievement-icon"; import { AchievementNotifierService as AchievementNotifierServiceAbstraction } from "./achievement-notifier.abstraction"; +import { AchievementIcon } from "./icons/achievement.icon"; export class AchievementNotifierService implements AchievementNotifierServiceAbstraction { constructor( diff --git a/libs/angular/src/tools/achievements/achievements-list.component.html b/libs/angular/src/tools/achievements/achievements-list.component.html index c504cf27922..5d762969cc2 100644 --- a/libs/angular/src/tools/achievements/achievements-list.component.html +++ b/libs/angular/src/tools/achievements/achievements-list.component.html @@ -6,15 +6,15 @@ {{ allAchievementCards.length }} - @for (achievement of allAchievementCards; track achievement.name) { -
- + @for (achievement of allAchievementCards; track achievement.name) { + -
- } + > + } + diff --git a/libs/angular/src/tools/achievements/achievement-icon.ts b/libs/angular/src/tools/achievements/icons/achievement.icon.ts similarity index 99% rename from libs/angular/src/tools/achievements/achievement-icon.ts rename to libs/angular/src/tools/achievements/icons/achievement.icon.ts index 3924465e38a..e5aa6b6a284 100644 --- a/libs/angular/src/tools/achievements/achievement-icon.ts +++ b/libs/angular/src/tools/achievements/icons/achievement.icon.ts @@ -1,6 +1,6 @@ import { svgIcon } from "@bitwarden/components"; -export const AchievementIcon = svgIcon` +export const AchievementIcon = svgIcon` diff --git a/libs/angular/src/tools/achievements/icons/small-achievement.icon.ts b/libs/angular/src/tools/achievements/icons/small-achievement.icon.ts new file mode 100644 index 00000000000..5ee23858471 --- /dev/null +++ b/libs/angular/src/tools/achievements/icons/small-achievement.icon.ts @@ -0,0 +1,29 @@ +import { svgIcon } from "@bitwarden/components"; + +export const AchievementIconSmall = svgIcon` + + + + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/libs/angular/src/tools/achievements/not-achieved-icon.ts b/libs/angular/src/tools/achievements/not-achieved-icon.ts deleted file mode 100644 index e58ee1a345f..00000000000 --- a/libs/angular/src/tools/achievements/not-achieved-icon.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { svgIcon } from "@bitwarden/components"; - -export const NotAchievedIcon = svgIcon` - -`; diff --git a/libs/common/src/tools/achievements/achievement-hub.spec.ts b/libs/common/src/tools/achievements/achievement-hub.spec.ts index 457ec2fabb9..d78be5d0451 100644 --- a/libs/common/src/tools/achievements/achievement-hub.spec.ts +++ b/libs/common/src/tools/achievements/achievement-hub.spec.ts @@ -1,9 +1,92 @@ +import { BehaviorSubject, ReplaySubject, Subject, firstValueFrom } from "rxjs"; + +import { ConsoleLogService } from "../../platform/services/console-log.service"; +import { consoleSemanticLoggerProvider } from "../log"; + +import { AchievementHub } from "./achievement-hub"; +import { ItemCreatedEarnedEvent } from "./examples/achievement-events"; +import { + TotallyAttachedAchievement, + TotallyAttachedValidator, +} from "./examples/example-validators"; +import { itemAdded$ } from "./examples/user-events"; +import { + AchievementEarnedEvent, + AchievementEvent, + AchievementId, + AchievementProgressEvent, + AchievementValidator, + MetricId, + UserActionEvent, +} from "./types"; + +const testLog = consoleSemanticLoggerProvider(new ConsoleLogService(true), {}); + describe("AchievementHub", () => { - describe("earned$", () => {}); + describe("all$", () => { + it("emits achievements constructor emissions", async () => { + const validators$ = new Subject(); + const events$ = new Subject(); + const achievements$ = new Subject(); + const hub = new AchievementHub(validators$, events$, achievements$); + const results$ = new ReplaySubject(3); + hub.all$().subscribe(results$); - describe("metrics$", () => {}); + achievements$.next(ItemCreatedEarnedEvent); - describe("all$", () => {}); + const result = firstValueFrom(results$); + await expect(result).resolves.toEqual(ItemCreatedEarnedEvent); + }); - describe("named$", () => {}); + it("emits achievements derived from events", async () => { + const validators$ = new BehaviorSubject([TotallyAttachedValidator]); + const events$ = new Subject(); + const achievements$ = new Subject(); + const hub = new AchievementHub(validators$, events$, achievements$, 10, testLog); + const results$ = new ReplaySubject(3); + hub.all$().subscribe(results$); + + // hub starts listening when achievements$ completes + achievements$.complete(); + itemAdded$.subscribe(events$); + + const result = firstValueFrom(results$); + await expect(result).resolves.toMatchObject({ + achievement: { type: "earned", name: TotallyAttachedAchievement }, + }); + }); + }); + + describe("new$", () => { + it("", async () => { + const validators$ = new Subject(); + const events$ = new Subject(); + const achievements$ = new Subject(); + const hub = new AchievementHub(validators$, events$, achievements$); + const results$ = new ReplaySubject(3); + hub.new$().subscribe(results$); + }); + }); + + describe("earned$", () => { + it("", async () => { + const validators$ = new Subject(); + const events$ = new Subject(); + const achievements$ = new Subject(); + const hub = new AchievementHub(validators$, events$, achievements$); + const results$ = new ReplaySubject>(1); + hub.earned$().subscribe(results$); + }); + }); + + describe("metrics$", () => { + it("", async () => { + const validators$ = new Subject(); + const events$ = new Subject(); + const achievements$ = new Subject(); + const hub = new AchievementHub(validators$, events$, achievements$); + const results$ = new ReplaySubject>(1); + hub.metrics$().subscribe(results$); + }); + }); }); diff --git a/libs/common/src/tools/achievements/achievement-hub.ts b/libs/common/src/tools/achievements/achievement-hub.ts index 90faa7189e7..ec9366c7aac 100644 --- a/libs/common/src/tools/achievements/achievement-hub.ts +++ b/libs/common/src/tools/achievements/achievement-hub.ts @@ -2,13 +2,17 @@ import { Observable, ReplaySubject, Subject, + concat, debounceTime, filter, map, - share, + shareReplay, startWith, + tap, } from "rxjs"; +import { SemanticLogger, disabledSemanticLoggerProvider } from "../log"; + import { active } from "./achievement-manager"; import { achievements } from "./achievement-processor"; import { latestEarnedMetrics, latestProgressMetrics } from "./latest-metrics"; @@ -26,10 +30,26 @@ import { const ACHIEVEMENT_INITIAL_DEBOUNCE_MS = 100; export class AchievementHub { + /** Instantiates the achievement hub. A new achievement hub should be created + * per-user, and streams should be partitioned by user. + * @param validators$ emits the most recent achievement validator list and + * re-emits the full list when the validators change. + * @param events$ emits events captured from the system as they occur. THIS + * OBSERVABLE IS SUBSCRIBED DURING INITIALIZATION. It must emit a complete + * event to prevent the event hub from leaking the subscription. + * @param achievements$ emits the list of achievement events captured before + * initialization and then completes. THIS OBSERVABLE IS SUBSCRIBED DURING + * INITIALIZATION. Achievement processing begins once this observable + * completes. + * @param bufferSize the maximum number of achievement events retained by the + * achievement hub. + */ constructor( validators$: Observable, events$: Observable, + achievements$: Observable, bufferSize: number = 1000, + private log: SemanticLogger = disabledSemanticLoggerProvider({}), ) { this.achievements = new Subject(); this.achievementLog = new ReplaySubject(bufferSize); @@ -37,36 +57,22 @@ export class AchievementHub { const metrics$ = this.metrics$().pipe( map((m) => new Map(Array.from(m.entries(), ([k, v]) => [k, v.achievement.value] as const))), - share(), + shareReplay({ bufferSize: 1, refCount: true }), ); const earned$ = this.earned$().pipe(map((m) => new Set(m.keys()))); const active$ = validators$.pipe(active(metrics$, earned$)); - events$.pipe(achievements(active$, metrics$)).subscribe(this.achievements); + // TODO: figure out how to to unsubscribe from the event stream; + // this likely requires accepting an account-bound observable, which + // would also let the hub maintain it's "one user" invariant. + concat(achievements$, events$.pipe(achievements(active$, metrics$))).subscribe( + this.achievements, + ); } private readonly achievements: Subject; private readonly achievementLog: ReplaySubject; - earned$(): Observable> { - return this.achievementLog.pipe( - filter((e) => isEarnedEvent(e)), - map((e) => e as AchievementEarnedEvent), - latestEarnedMetrics(), - startWith(new Map()), - debounceTime(ACHIEVEMENT_INITIAL_DEBOUNCE_MS), - ); - } - - metrics$(): Observable> { - return this.achievementLog.pipe( - filter((e) => isProgressEvent(e)), - map((e) => e as AchievementProgressEvent), - latestProgressMetrics(), - startWith(new Map()), - ); - } - /** emit all achievement events */ all$(): Observable { return this.achievementLog.asObservable(); @@ -76,4 +82,26 @@ export class AchievementHub { new$(): Observable { return this.achievements.asObservable(); } + + earned$(): Observable> { + return this.achievementLog.pipe( + filter((e) => isEarnedEvent(e)), + map((e) => e as AchievementEarnedEvent), + latestEarnedMetrics(), + debounceTime(ACHIEVEMENT_INITIAL_DEBOUNCE_MS), + tap((m) => this.log.debug(m, "earned achievements update")), + startWith(new Map()), + ); + } + + metrics$(): Observable> { + return this.achievementLog.pipe( + filter((e) => isProgressEvent(e)), + map((e) => e as AchievementProgressEvent), + latestProgressMetrics(), + debounceTime(ACHIEVEMENT_INITIAL_DEBOUNCE_MS), + tap((m) => this.log.debug(m, "achievement metrics update")), + startWith(new Map()), + ); + } } 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 index ff806ec7b91..5d382bbc569 100644 --- a/libs/common/src/tools/achievements/examples/achievements/vault-item-added.ts +++ b/libs/common/src/tools/achievements/examples/achievements/vault-item-added.ts @@ -21,7 +21,7 @@ const VaultItems_10_Added_Achievement: Achievement = { const VaultItems_50_Added_Achievement: Achievement = { achievement: "50-vault-items-added" as AchievementId, - name: "It's 50/50 Vault Items Added", + name: "It's 50/50", description: "Saved your 50th item to Bitwarden", validator: "Threshold", active: { metric: VaultItemCreatedProgress, high: 50 }, diff --git a/libs/common/src/tools/log/ecs-format/core.ts b/libs/common/src/tools/log/ecs-format/core.ts index 8e9447ce041..c1d69081d9b 100644 --- a/libs/common/src/tools/log/ecs-format/core.ts +++ b/libs/common/src/tools/log/ecs-format/core.ts @@ -1,5 +1,18 @@ import { Primitive } from "type-fest"; +export type EcsEventType = + | "access" + | "admin" + | "allowed" + | "creation" + | "deletion" + | "denied" + | "end" + | "error" + | "info" + | "start" + | "user"; + /** Elastic Common Schema log format - core fields. */ export interface EcsFormat { @@ -18,18 +31,7 @@ export interface EcsFormat { event: { kind?: "alert" | "enrichment" | "event" | "metric" | "state"; category?: "api" | "authentication" | "iam" | "process" | "session"; - type?: - | "access" - | "admin" - | "allowed" - | "creation" - | "deletion" - | "denied" - | "end" - | "error" - | "info" - | "start" - | "user"; + type?: EcsEventType; outcome?: "failure" | "success" | "unknown"; }; } diff --git a/libs/common/src/tools/log/ecs-format/index.ts b/libs/common/src/tools/log/ecs-format/index.ts index e067c80135a..56f08595678 100644 --- a/libs/common/src/tools/log/ecs-format/index.ts +++ b/libs/common/src/tools/log/ecs-format/index.ts @@ -1,4 +1,4 @@ -export { EcsFormat } from "./core"; +export { EcsFormat, EcsEventType } from "./core"; export { ErrorFormat } from "./error"; export { EventFormat } from "./event"; export { LogFormat } from "./log"; diff --git a/libs/common/src/tools/log/ecs-format/service.ts b/libs/common/src/tools/log/ecs-format/service.ts index 24cf6972f06..0475abcf7ff 100644 --- a/libs/common/src/tools/log/ecs-format/service.ts +++ b/libs/common/src/tools/log/ecs-format/service.ts @@ -3,8 +3,10 @@ import { EcsFormat } from "./core"; export type ServiceFormat = EcsFormat & { /** documents the program providing the log */ service: { - /** Which kind of client is it? */ - name: "android" | "cli" | "desktop" | "extension" | "ios" | "web"; + /** Which kind of client is it? + * @remarks this contains the output of `BrowserPlatformUtilsService.getDeviceString()` in practice. + */ + name: string; /** identifies the service as a type of client device */ type: "client"; @@ -18,6 +20,6 @@ export type ServiceFormat = EcsFormat & { environment: "production" | "testing" | "development" | "local"; /** the unique identifier(s) for this client installation */ - version: "2025.3.1-innovation-sprint"; + version: string; }; }; diff --git a/libs/common/src/tools/log/logger.ts b/libs/common/src/tools/log/logger.ts new file mode 100644 index 00000000000..2494b8edce0 --- /dev/null +++ b/libs/common/src/tools/log/logger.ts @@ -0,0 +1,107 @@ +import { BehaviorSubject, SubjectLike, from, map, zip } from "rxjs"; +import { Primitive } from "type-fest"; + +import { Account } from "../../auth/abstractions/account.service"; +import { AppIdService } from "../../platform/abstractions/app-id.service"; +import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; +import { UserActionEvent } from "../achievements/types"; + +import { ServiceFormat, UserFormat, EcsEventType } from "./ecs-format"; +import { disabledSemanticLoggerProvider } from "./factory"; +import { SemanticLogger } from "./semantic-logger.abstraction"; + +export abstract class UserEventLogProvider { + abstract create: (account: Account) => UserEventLogger; +} + +type BaselineType = Omit; + +type EventInfo = { + action: string; + labels?: Record; + tags?: Array; +}; + +export class UserEventLogger { + constructor( + idService: AppIdService, + utilService: PlatformUtilsService, + account: Account, + private now: () => number, + private events$: SubjectLike, + private log: SemanticLogger = disabledSemanticLoggerProvider({}), + ) { + zip(from(idService.getAppId()), from(utilService.getApplicationVersion())) + .pipe( + map( + ([appId, version]) => + ({ + event: { + kind: "event", + category: "session", + }, + service: { + name: utilService.getDeviceString(), + type: "client", + node: { + name: appId, + }, + environment: "local", + version, + }, + user: { + // `account` verified not-null via `filter` + id: account!.id, + email: (account!.emailVerified && account!.email) || undefined, + }, + }) satisfies BaselineType, + ), + ) + .subscribe((next) => this.baseline$.next(next)); + } + + private readonly baseline$ = new BehaviorSubject(null); + + creation(event: EventInfo) { + this.collect("creation", event); + } + + deletion(event: EventInfo) { + this.collect("deletion", event); + } + + info(event: EventInfo) { + this.collect("info", event); + } + + access(event: EventInfo) { + this.collect("access", event); + } + + private collect(type: EcsEventType, info: EventInfo) { + const { value: baseline } = this.baseline$; + + if (!baseline) { + // TODO: buffer logs and stream them when `baseline$` becomes available. + this.log.error("baseline log not available; dropping user event"); + return; + } + + const event = structuredClone(this.baseline$.value) as UserActionEvent; + event["@timestamp"] = this.now(); + + event.event.type = type; + event.action = info.action; + event.tags = info.tags && info.tags.filter((t) => !!t); + + if (info.labels) { + const entries = Object.keys(info.labels) + .filter((k) => !!info.labels![k]) + .map((k) => [k, info.labels![k]] as const); + const labels = Object.fromEntries(entries); + event.labels = labels; + } + + this.events$.next(event); + } +} diff --git a/libs/common/src/tools/providers.ts b/libs/common/src/tools/providers.ts index a22a22addc5..37e98ffe8e8 100644 --- a/libs/common/src/tools/providers.ts +++ b/libs/common/src/tools/providers.ts @@ -2,6 +2,7 @@ import { PolicyService } from "../admin-console/abstractions/policy/policy.servi import { ExtensionService } from "./extension/extension.service"; import { LogProvider } from "./log"; +import { UserEventLogProvider } from "./log/logger"; /** Provides access to commonly-used cross-cutting services. */ export type SystemServiceProvider = { @@ -13,4 +14,6 @@ export type SystemServiceProvider = { /** Event monitoring and diagnostic interfaces */ readonly log: LogProvider; + + readonly event: UserEventLogProvider; }; diff --git a/libs/vault/src/cipher-form/services/default-cipher-form.service.ts b/libs/vault/src/cipher-form/services/default-cipher-form.service.ts index 98286e4bbb2..c05e9711b36 100644 --- a/libs/vault/src/cipher-form/services/default-cipher-form.service.ts +++ b/libs/vault/src/cipher-form/services/default-cipher-form.service.ts @@ -6,7 +6,9 @@ import { firstValueFrom } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { UserEventLogProvider } from "@bitwarden/common/tools/log/logger"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -22,6 +24,7 @@ export class DefaultCipherFormService implements CipherFormService { private cipherService: CipherService = inject(CipherService); private accountService: AccountService = inject(AccountService); private apiService: ApiService = inject(ApiService); + private system = inject(UserEventLogProvider); async decryptCipher(cipher: Cipher): Promise { const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); @@ -32,6 +35,7 @@ export class DefaultCipherFormService implements CipherFormService { async saveCipher(cipher: CipherView, config: CipherFormConfig): Promise { // Passing the original cipher is important here as it is responsible for appending to password history + const activeUser = await firstValueFrom(this.accountService.activeAccount$); const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); const encryptedCipher = await this.cipherService.encrypt( cipher, @@ -40,12 +44,22 @@ export class DefaultCipherFormService implements CipherFormService { null, config.originalCipher ?? null, ); + const event = this.system.create(activeUser); + const labels = { + "vault-item-type": CipherType[cipher.type], + "vault-item-uri-quantity": cipher.type === CipherType.Login ? cipher.login.uris.length : null, + }; + const tags = [ + cipher.attachments.length > 0 ? "with-attachment" : null, + cipher.folderId ? "with-folder" : null, + ]; let savedCipher: Cipher; // Creating a new cipher if (cipher.id == null) { savedCipher = await this.cipherService.createWithServer(encryptedCipher, config.admin); + event.creation({ action: "vault-item-added", labels, tags }); return await savedCipher.decrypt( await this.cipherService.getKeyForCipherKeyDecryption(savedCipher, activeUserId), ); @@ -68,10 +82,15 @@ export class DefaultCipherFormService implements CipherFormService { cipher.collectionIds, activeUserId, ); + event.info({ action: "vault-item-shared-with-organization", labels, tags }); + // If the collectionIds are the same, update the cipher normally } else if (isSetEqual(originalCollectionIds, newCollectionIds)) { savedCipher = await this.cipherService.updateWithServer(encryptedCipher, config.admin); + event.info({ action: "vault-item-updated", labels, tags }); } else { + tags.push("collection"); + // Updating a cipher with collection changes is not supported with a single request currently // First update the cipher with the original collectionIds encryptedCipher.collectionIds = config.originalCipher.collectionIds; @@ -92,12 +111,16 @@ export class DefaultCipherFormService implements CipherFormService { activeUserId, ); } + + event.deletion({ action: "vault-item-moved", labels, tags }); } // Its possible the cipher was made no longer available due to collection assignment changes // e.g. The cipher was moved to a collection that the user no longer has access to if (savedCipher == null) { return null; + } else { + event.creation({ action: "vault-item-moved", labels, tags }); } return await savedCipher.decrypt(