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

Merge branch 'innovation/user-achievements/event-stream-prototype' of https://github.com/bitwarden/clients into innovation/user-achievements/event-stream-prototype

Merging from the base branch
This commit is contained in:
Tom
2025-03-20 12:31:36 -04:00
18 changed files with 399 additions and 66 deletions

View File

@@ -1,6 +1,6 @@
<bit-card class="{{ cardClass }}">
<div class="tw-flex tw-items-center tw-justify-center">
<bit-icon [icon]="icon"></bit-icon>
<bit-icon [icon]="icon" class="{{ iconStyle }}"></bit-icon>
</div>
<h2 bitTypography="h6" class="tw-text-center">{{ title() }}</h2>
<h3 bitTypography="body2" class="tw-text-center">{{ description() }}</h3>

View File

@@ -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<string>();
description = input.required<string>();
@@ -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";
}
});
});

View File

@@ -0,0 +1,12 @@
<bit-item>
<button bit-item-content type="button">
<div slot="start" class="tw-justify-start tw-flex">
<bit-icon [icon]="icon" class="{{ iconStyle }}"></bit-icon>
</div>
<span>{{ title() }}</span>
<span slot="secondary">{{ description() }}</span>
@if (earned()) {
<p slot="secondary">Earned: {{ date() | date: "medium" }}</p>
}
</button>
</bit-item>

View File

@@ -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<string>();
description = input.required<string>();
earned = input<boolean>(false);
progress = input<number>(0);
date = input<Date>();
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";
}
});
});
}
}

View File

@@ -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(

View File

@@ -6,15 +6,15 @@
<span bitTypography="body1" slot="end">{{ allAchievementCards.length }}</span>
</bit-section-header>
@for (achievement of allAchievementCards; track achievement.name) {
<div class="tw-mb-3">
<achievement-card
<bit-item-group>
@for (achievement of allAchievementCards; track achievement.name) {
<achievement-item
[title]="achievement.name"
[description]="achievement.description"
[earned]="achievement.earned"
[date]="achievement.date"
[progress]="achievement.progress"
></achievement-card>
</div>
}
></achievement-item>
}
</bit-item-group>
</bit-section>

View File

@@ -1,6 +1,6 @@
import { svgIcon } from "@bitwarden/components";
export const AchievementIcon = svgIcon`<svg width="68" height="68" viewBox="0 0 68 68" fill="none" xmlns="http://www.w3.org/2000/svg">
export const AchievementIcon = svgIcon`<svg width="68" height="68" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="34" cy="34" r="32" fill="#F3F6F9"/>
<path d="M42.6164 48.0626L9.85283 61.7141C8.83254 62.1392 7.65655 61.9066 6.87498 61.125C6.09341 60.3434 5.86077 59.1674 6.28589 58.1472L19.9374 25.3836C20.9555 22.94 23.6558 21.6613 26.1913 22.422L27.1144 22.6989C35.8481 25.319 42.681 32.1519 45.3011 40.8856L45.578 41.8087C46.3387 44.3442 45.0599 47.0445 42.6164 48.0626Z" fill="#FFBF00"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M22.1188 56.6033C20.1706 55.1191 18.2516 53.4464 16.4026 51.5974C14.5536 49.7484 12.8809 47.8294 11.3967 45.8812L11.9732 44.4976C13.5288 46.6019 15.3143 48.6824 17.3159 50.6841C19.3176 52.6857 21.3981 54.4712 23.5024 56.0268L22.1188 56.6033ZM16.6688 58.8741C15.3416 57.7421 14.0317 56.5334 12.7491 55.2509C11.4665 53.9683 10.2579 52.6584 9.1259 51.3312L8.57013 52.665C9.60045 53.8495 10.6897 55.0181 11.8358 56.1642C12.9819 57.3103 14.1505 58.3995 15.335 59.4299L16.6688 58.8741ZM29.4362 53.5544C26.5843 51.8514 23.7238 49.6325 21.0456 46.9543C18.3675 44.2762 16.1486 41.4157 14.4456 38.5638L15.0702 37.0649C16.765 40.0862 19.0817 43.1637 21.959 46.041C24.8363 48.9183 27.9138 51.235 30.9351 52.9298L29.4362 53.5544ZM17.8871 30.3043C18.9585 34.2111 21.5916 38.6119 25.4899 42.5101C29.3881 46.4084 33.7889 49.0415 37.6958 50.1129L39.7899 49.2403C39.1254 49.1428 38.4227 48.986 37.685 48.7664C34.1175 47.7049 30.0542 45.2478 26.4032 41.5968C22.7522 37.9458 20.2951 33.8825 19.2335 30.315C19.014 29.5773 18.8572 28.8746 18.7597 28.2101L17.8871 30.3043Z" fill="white"/>

View File

@@ -0,0 +1,29 @@
import { svgIcon } from "@bitwarden/components";
export const AchievementIconSmall = svgIcon`<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="16" cy="16" r="14" fill="#F3F6F9"/>
<path d="M19.6133 21.8972L5.87375 27.622C5.44589 27.8003 4.95274 27.7028 4.62498 27.375C4.29722 27.0472 4.19966 26.5541 4.37794 26.1262L10.1028 12.3867C10.5297 11.362 11.6621 10.8257 12.7254 11.1447L13.1125 11.2608C16.775 12.3596 19.6404 15.225 20.7392 18.8875L20.8553 19.2746C21.1743 20.3379 20.638 21.4703 19.6133 21.8972Z" fill="#FFBF00"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.0176 25.4788C10.2006 24.8564 9.39583 24.1549 8.62046 23.3796C7.84508 22.6042 7.14364 21.7994 6.52122 20.9824L6.76298 20.4022C7.4153 21.2846 8.16406 22.1571 9.00347 22.9965C9.84288 23.836 10.7154 24.5847 11.5978 25.237L11.0176 25.4788ZM8.7321 26.4311C8.17554 25.9563 7.62621 25.4495 7.08835 24.9117C6.5505 24.3738 6.04367 23.8245 5.56894 23.2679L5.33588 23.8273C5.76795 24.324 6.22472 24.8141 6.70534 25.2947C7.18596 25.7753 7.67605 26.2321 8.17274 26.6641L8.7321 26.4311ZM14.0861 24.2002C12.8902 23.4861 11.6906 22.5556 10.5675 21.4325C9.44445 20.3094 8.51392 19.1098 7.79979 17.9139L8.0617 17.2853C8.77242 18.5523 9.74395 19.8428 10.9506 21.0494C12.1572 22.2561 13.4477 23.2276 14.7147 23.9383L14.0861 24.2002ZM9.243 14.4502C9.69229 16.0885 10.7965 17.934 12.4312 19.5688C14.066 21.2035 15.9115 22.3077 17.5498 22.757L18.428 22.3911C18.1494 22.3502 17.8547 22.2844 17.5453 22.1924C16.0493 21.7472 14.3453 20.7168 12.8143 19.1857C11.2832 17.6547 10.2528 15.9507 9.80763 14.4547C9.71557 14.1453 9.6498 13.8506 9.60891 13.572L9.243 14.4502Z" fill="white"/>
<path d="M17.2612 14.7388C19.4823 16.9599 20.597 19.4464 19.7508 20.2925C18.9047 21.1386 16.4182 20.024 14.1971 17.8029C11.976 15.5818 10.8613 13.0953 11.7075 12.2492C12.5536 11.403 15.0401 12.5177 17.2612 14.7388Z" fill="#99BAF4"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.162 18.2528C18.7447 17.3219 17.9544 16.198 16.8782 15.1218C15.802 14.0456 14.6781 13.2553 13.7472 12.838C13.2799 12.6286 12.8883 12.5253 12.5916 12.5098C12.2947 12.4943 12.1556 12.5671 12.0905 12.6322C12.0254 12.6972 11.9526 12.8364 11.9681 13.1333C11.9836 13.43 12.0869 13.8216 12.2963 14.2888C12.7136 15.2197 13.5039 16.3437 14.5801 17.4199C15.6563 18.4961 16.7802 19.2863 17.7111 19.7036C18.1784 19.9131 18.57 20.0164 18.8667 20.0319C19.1636 20.0474 19.3027 19.9745 19.3678 19.9095C19.4329 19.8444 19.5057 19.7052 19.4902 19.4084C19.4747 19.1117 19.3714 18.7201 19.162 18.2528ZM19.7508 20.2925C20.597 19.4464 19.4823 16.9599 17.2612 14.7388C15.0401 12.5177 12.5536 11.403 11.7075 12.2492C10.8613 13.0953 11.976 15.5818 14.1971 17.8029C16.4182 20.024 18.9047 21.1386 19.7508 20.2925Z" fill="#0E3781"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.6476 11.4041C11.7172 11.125 10.7264 11.5942 10.3528 12.4908L4.62797 26.2304C4.4919 26.5569 4.56636 26.9333 4.81651 27.1835C5.06667 27.4336 5.44306 27.5081 5.76961 27.372L19.5092 21.6472C20.4058 21.2736 20.875 20.2828 20.5959 19.3525L20.4798 18.9653C19.4072 15.39 16.61 12.5928 13.0347 11.5202L12.6476 11.4041ZM9.85279 12.2825C10.3331 11.1297 11.607 10.5264 12.8032 10.8853L13.1903 11.0014C16.94 12.1263 19.8737 15.06 20.9986 18.8097L21.1147 19.1968C21.4736 20.393 20.8703 21.6669 19.7175 22.1472L5.97795 27.872C5.44878 28.0925 4.83886 27.9719 4.4335 27.5665C4.02814 27.1611 3.90748 26.5512 4.12797 26.0221L9.85279 12.2825Z" fill="#0E3781"/>
<path d="M15.8006 4.24159C15.8222 4.14891 15.9048 4.08333 16 4.08333C16.0952 4.08333 16.1778 4.14891 16.1994 4.24159L16.2095 4.28503C16.35 4.88776 16.8206 5.35838 17.4233 5.49883L17.4667 5.50895C17.5594 5.53055 17.625 5.61317 17.625 5.70833C17.625 5.8035 17.5594 5.88612 17.4667 5.90771L17.4233 5.91783C16.8206 6.05829 16.35 6.52891 16.2095 7.13164L16.1994 7.17507C16.1778 7.26776 16.0952 7.33333 16 7.33333C15.9048 7.33333 15.8222 7.26776 15.8006 7.17507L15.7905 7.13164C15.65 6.52891 15.1794 6.05829 14.5767 5.91783L14.5333 5.90771C14.4406 5.88612 14.375 5.8035 14.375 5.70833C14.375 5.61317 14.4406 5.53055 14.5333 5.50895L14.5767 5.49883C15.1794 5.35838 15.65 4.88776 15.7905 4.28503L15.8006 4.24159Z" fill="#0E3781"/>
<path d="M26.0923 8.30409C26.1139 8.21141 26.1965 8.14583 26.2917 8.14583C26.3869 8.14583 26.4695 8.21141 26.4911 8.30409L26.5012 8.34753C26.6416 8.95026 27.1123 9.42088 27.715 9.56133L27.7584 9.57145C27.8511 9.59305 27.9167 9.67567 27.9167 9.77083C27.9167 9.866 27.8511 9.94862 27.7584 9.97021L27.715 9.98033C27.1123 10.1208 26.6416 10.5914 26.5012 11.1941L26.4911 11.2376C26.4695 11.3303 26.3869 11.3958 26.2917 11.3958C26.1965 11.3958 26.1139 11.3303 26.0923 11.2376L26.0822 11.1941C25.9417 10.5914 25.4711 10.1208 24.8684 9.98033L24.8249 9.97021C24.7323 9.94862 24.6667 9.866 24.6667 9.77083C24.6667 9.67567 24.7323 9.59305 24.8249 9.57145L24.8684 9.56133C25.4711 9.42088 25.9417 8.95026 26.0822 8.34753L26.0923 8.30409Z" fill="#0E3781"/>
<path d="M22.5714 24.8249C22.593 24.7322 22.6756 24.6667 22.7708 24.6667C22.866 24.6667 22.9486 24.7322 22.9702 24.8249L22.9803 24.8684C23.1208 25.4711 23.5914 25.9417 24.1941 26.0822L24.2376 26.0923C24.3302 26.1139 24.3958 26.1965 24.3958 26.2917C24.3958 26.3868 24.3302 26.4694 24.2376 26.491L24.1941 26.5012C23.5914 26.6416 23.1208 27.1122 22.9803 27.715L22.9702 27.7584C22.9486 27.8511 22.866 27.9167 22.7708 27.9167C22.6756 27.9167 22.593 27.8511 22.5714 27.7584L22.5613 27.715C22.4209 27.1122 21.9502 26.6416 21.3475 26.5012L21.3041 26.491C21.2114 26.4694 21.1458 26.3868 21.1458 26.2917C21.1458 26.1965 21.2114 26.1139 21.3041 26.0923L21.3475 26.0822C21.9502 25.9417 22.4209 25.4711 22.5613 24.8684L22.5714 24.8249Z" fill="#0E3781"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.831 17.1819C24.0906 16.4415 22.8902 16.4415 22.1499 17.1819C22.0441 17.2877 21.8726 17.2877 21.7668 17.1819C21.6611 17.0761 21.6611 16.9047 21.7668 16.7989C22.7187 15.847 24.2621 15.847 25.214 16.7989C25.3198 16.9047 25.3198 17.0761 25.214 17.1819C25.1082 17.2877 24.9367 17.2877 24.831 17.1819Z" fill="#0E3781"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.0547 14.4609C24.5129 13.5708 22.5414 14.099 21.6512 15.6408C21.5764 15.7704 21.4108 15.8148 21.2813 15.74C21.1517 15.6652 21.1073 15.4995 21.1821 15.37C22.2219 13.5691 24.5247 12.9521 26.3256 13.9918C26.4551 14.0666 26.4995 14.2323 26.4247 14.3618C26.3499 14.4913 26.1843 14.5357 26.0547 14.4609Z" fill="#0E3781"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.5 7.875C10.547 7.875 11.3958 8.72379 11.3958 9.77083C11.3958 9.92041 11.5171 10.0417 11.6667 10.0417C11.8162 10.0417 11.9375 9.92041 11.9375 9.77083C11.9375 8.42464 10.8462 7.33333 9.5 7.33333C9.35042 7.33333 9.22917 7.45459 9.22917 7.60417C9.22917 7.75374 9.35042 7.875 9.5 7.875Z" fill="#0E3781"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.9747 5.69127C11.9558 6.057 12.4546 7.14881 12.0889 8.1299C12.0367 8.27006 12.1079 8.42603 12.2481 8.47828C12.3882 8.53052 12.5442 8.45926 12.5965 8.3191C13.0667 7.0577 12.4253 5.65395 11.1639 5.18373C11.0238 5.13148 10.8678 5.20274 10.8155 5.3429C10.7633 5.48305 10.8346 5.63903 10.9747 5.69127Z" fill="#0E3781"/>
<path d="M20.6042 8.6875C20.6042 9.28581 20.1191 9.77083 19.5208 9.77083C18.9225 9.77083 18.4375 9.28581 18.4375 8.6875C18.4375 8.08919 18.9225 7.60417 19.5208 7.60417C20.1191 7.60417 20.6042 8.08919 20.6042 8.6875Z" fill="#99BAF4"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.5208 9.22917C19.82 9.22917 20.0625 8.98665 20.0625 8.6875C20.0625 8.38835 19.82 8.14583 19.5208 8.14583C19.2217 8.14583 18.9792 8.38835 18.9792 8.6875C18.9792 8.98665 19.2217 9.22917 19.5208 9.22917ZM19.5208 9.77083C20.1191 9.77083 20.6042 9.28581 20.6042 8.6875C20.6042 8.08919 20.1191 7.60417 19.5208 7.60417C18.9225 7.60417 18.4375 8.08919 18.4375 8.6875C18.4375 9.28581 18.9225 9.77083 19.5208 9.77083Z" fill="#0E3781"/>
<path d="M22.2292 11.9375C22.2292 12.3862 21.8654 12.75 21.4167 12.75C20.968 12.75 20.6042 12.3862 20.6042 11.9375C20.6042 11.4888 20.968 11.125 21.4167 11.125C21.8654 11.125 22.2292 11.4888 22.2292 11.9375Z" fill="#DBE5F6"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M21.4167 12.2083C21.5663 12.2083 21.6875 12.0871 21.6875 11.9375C21.6875 11.7879 21.5663 11.6667 21.4167 11.6667C21.2671 11.6667 21.1459 11.7879 21.1459 11.9375C21.1459 12.0871 21.2671 12.2083 21.4167 12.2083ZM21.4167 12.75C21.8654 12.75 22.2292 12.3862 22.2292 11.9375C22.2292 11.4888 21.8654 11.125 21.4167 11.125C20.968 11.125 20.6042 11.4888 20.6042 11.9375C20.6042 12.3862 20.968 12.75 21.4167 12.75Z" fill="#0E3781"/>
<path d="M19.25 25.2083C19.25 25.6571 18.8862 26.0208 18.4375 26.0208C17.9888 26.0208 17.625 25.6571 17.625 25.2083C17.625 24.7596 17.9888 24.3958 18.4375 24.3958C18.8862 24.3958 19.25 24.7596 19.25 25.2083Z" fill="#DBE5F6"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.4375 25.4792C18.5871 25.4792 18.7083 25.3579 18.7083 25.2083C18.7083 25.0588 18.5871 24.9375 18.4375 24.9375C18.2879 24.9375 18.1667 25.0588 18.1667 25.2083C18.1667 25.3579 18.2879 25.4792 18.4375 25.4792ZM18.4375 26.0208C18.8862 26.0208 19.25 25.6571 19.25 25.2083C19.25 24.7596 18.8862 24.3958 18.4375 24.3958C17.9888 24.3958 17.625 24.7596 17.625 25.2083C17.625 25.6571 17.9888 26.0208 18.4375 26.0208Z" fill="#0E3781"/>
<path d="M26.8333 20.3333C26.8333 21.0812 26.2271 21.6875 25.4792 21.6875C24.7313 21.6875 24.125 21.0812 24.125 20.3333C24.125 19.5854 24.7313 18.9792 25.4792 18.9792C26.2271 18.9792 26.8333 19.5854 26.8333 20.3333Z" fill="#FFBF00"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M25.4792 21.1458C25.9279 21.1458 26.2917 20.7821 26.2917 20.3333C26.2917 19.8846 25.9279 19.5208 25.4792 19.5208C25.0304 19.5208 24.6667 19.8846 24.6667 20.3333C24.6667 20.7821 25.0304 21.1458 25.4792 21.1458ZM25.4792 21.6875C26.2271 21.6875 26.8333 21.0812 26.8333 20.3333C26.8333 19.5854 26.2271 18.9792 25.4792 18.9792C24.7313 18.9792 24.125 19.5854 24.125 20.3333C24.125 21.0812 24.7313 21.6875 25.4792 21.6875Z" fill="#0E3781"/>
<path d="M17.8958 10.4479C17.8958 10.9714 17.4714 11.3958 16.9479 11.3958C16.4244 11.3958 16 10.9714 16 10.4479C16 9.9244 16.4244 9.5 16.9479 9.5C17.4714 9.5 17.8958 9.9244 17.8958 10.4479Z" fill="#FFBF00"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.9479 10.8542C17.1723 10.8542 17.3542 10.6723 17.3542 10.4479C17.3542 10.2236 17.1723 10.0417 16.9479 10.0417C16.7236 10.0417 16.5417 10.2236 16.5417 10.4479C16.5417 10.6723 16.7236 10.8542 16.9479 10.8542ZM16.9479 11.3958C17.4714 11.3958 17.8958 10.9714 17.8958 10.4479C17.8958 9.9244 17.4714 9.5 16.9479 9.5C16.4244 9.5 16 9.9244 16 10.4479C16 10.9714 16.4244 11.3958 16.9479 11.3958Z" fill="#0E3781"/>
<path d="M25.75 5.97917C25.75 7.02621 24.9012 7.875 23.8541 7.875C22.8071 7.875 21.9583 7.02621 21.9583 5.97917C21.9583 4.93213 22.8071 4.08333 23.8541 4.08333C24.9012 4.08333 25.75 4.93213 25.75 5.97917Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.8541 7.33333C24.602 7.33333 25.2083 6.72705 25.2083 5.97917C25.2083 5.23128 24.602 4.625 23.8541 4.625C23.1063 4.625 22.5 5.23128 22.5 5.97917C22.5 6.72705 23.1063 7.33333 23.8541 7.33333ZM23.8541 7.875C24.9012 7.875 25.75 7.02621 25.75 5.97917C25.75 4.93213 24.9012 4.08333 23.8541 4.08333C22.8071 4.08333 21.9583 4.93213 21.9583 5.97917C21.9583 7.02621 22.8071 7.875 23.8541 7.875Z" fill="#0E3781"/>
</svg>`;

View File

@@ -1,5 +0,0 @@
import { svgIcon } from "@bitwarden/components";
export const NotAchievedIcon = svgIcon`<svg width="68" height="68" viewBox="0 0 68 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="34" cy="34" r="32" fill="#F3F6F9"/>
</svg>`;

View File

@@ -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<AchievementValidator[]>();
const events$ = new Subject<UserActionEvent>();
const achievements$ = new Subject<AchievementEvent>();
const hub = new AchievementHub(validators$, events$, achievements$);
const results$ = new ReplaySubject<AchievementEvent>(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<AchievementValidator[]>([TotallyAttachedValidator]);
const events$ = new Subject<UserActionEvent>();
const achievements$ = new Subject<AchievementEvent>();
const hub = new AchievementHub(validators$, events$, achievements$, 10, testLog);
const results$ = new ReplaySubject<AchievementEvent>(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<AchievementValidator[]>();
const events$ = new Subject<UserActionEvent>();
const achievements$ = new Subject<AchievementEvent>();
const hub = new AchievementHub(validators$, events$, achievements$);
const results$ = new ReplaySubject<AchievementEvent>(3);
hub.new$().subscribe(results$);
});
});
describe("earned$", () => {
it("", async () => {
const validators$ = new Subject<AchievementValidator[]>();
const events$ = new Subject<UserActionEvent>();
const achievements$ = new Subject<AchievementEvent>();
const hub = new AchievementHub(validators$, events$, achievements$);
const results$ = new ReplaySubject<Map<AchievementId, AchievementEarnedEvent>>(1);
hub.earned$().subscribe(results$);
});
});
describe("metrics$", () => {
it("", async () => {
const validators$ = new Subject<AchievementValidator[]>();
const events$ = new Subject<UserActionEvent>();
const achievements$ = new Subject<AchievementEvent>();
const hub = new AchievementHub(validators$, events$, achievements$);
const results$ = new ReplaySubject<Map<MetricId, AchievementProgressEvent>>(1);
hub.metrics$().subscribe(results$);
});
});
});

View File

@@ -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<AchievementValidator[]>,
events$: Observable<UserActionEvent>,
achievements$: Observable<AchievementEvent>,
bufferSize: number = 1000,
private log: SemanticLogger = disabledSemanticLoggerProvider({}),
) {
this.achievements = new Subject<AchievementEvent>();
this.achievementLog = new ReplaySubject<AchievementEvent>(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<AchievementEvent>;
private readonly achievementLog: ReplaySubject<AchievementEvent>;
earned$(): Observable<Map<AchievementId, AchievementEarnedEvent>> {
return this.achievementLog.pipe(
filter((e) => isEarnedEvent(e)),
map((e) => e as AchievementEarnedEvent),
latestEarnedMetrics(),
startWith(new Map<AchievementId, AchievementEarnedEvent>()),
debounceTime(ACHIEVEMENT_INITIAL_DEBOUNCE_MS),
);
}
metrics$(): Observable<Map<MetricId, AchievementProgressEvent>> {
return this.achievementLog.pipe(
filter((e) => isProgressEvent(e)),
map((e) => e as AchievementProgressEvent),
latestProgressMetrics(),
startWith(new Map<MetricId, AchievementProgressEvent>()),
);
}
/** emit all achievement events */
all$(): Observable<AchievementEvent> {
return this.achievementLog.asObservable();
@@ -76,4 +82,26 @@ export class AchievementHub {
new$(): Observable<AchievementEvent> {
return this.achievements.asObservable();
}
earned$(): Observable<Map<AchievementId, AchievementEarnedEvent>> {
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<AchievementId, AchievementEarnedEvent>()),
);
}
metrics$(): Observable<Map<MetricId, AchievementProgressEvent>> {
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<MetricId, AchievementProgressEvent>()),
);
}
}

View File

@@ -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 },

View File

@@ -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";
};
}

View File

@@ -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";

View File

@@ -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;
};
};

View File

@@ -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<ServiceFormat & UserFormat, "@timestamp">;
type EventInfo = {
action: string;
labels?: Record<string, Primitive>;
tags?: Array<string>;
};
export class UserEventLogger {
constructor(
idService: AppIdService,
utilService: PlatformUtilsService,
account: Account,
private now: () => number,
private events$: SubjectLike<UserActionEvent>,
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<BaselineType | null>(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);
}
}

View File

@@ -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;
};

View File

@@ -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<CipherView> {
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<CipherView> {
// 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(