From d41b3b13ea25ae7f59518f292351969d80e43ece Mon Sep 17 00:00:00 2001 From: Brandon Maharaj Date: Sun, 1 Jan 2023 10:30:09 -0500 Subject: [PATCH] [SG-58] Avatar color selector (#3691) * changes * merge * undo * work * stuffs * chore: added custom color picker * oops * chore: everything but the broken sink * picker v2 * fix: cleanup * fix: linty * fix: use tailwind * fix: use tailwind * undo: merge error * remove: old color picker * fix: merge issue * chore: use input vs component * fix: move logic out! * fix: revert changes to bit-avatar * fix: cleanup undos * feat: color lookup for "me" badge in vault * fix: naming stuff * fix: event emitter * fix: linty * fix: protect * fix: remove v1 states work: navatar * fix: big * fix: messages merge issue * bug: differing bg colors for generated components * feat: added sync stuff * fix: cli * fix: remove service refs, use state * fix: moved from EventEmitter to Subjects * fix: srs * fix: strict stuff is nice tbh * SG-920 + SG-921 (#4342) * SG-920 + SG-921 * Update change-avatar.component.html * Update selectable-avatar.component.ts * [SG-926] [SG-58] [Defect] - Selected Avatar color does not persist in the Account Settings menu (#4359) * SG-926 * fix: comment * fix: undo * fix: imp * work: done with static values (#4272) * [SG-35] (#4361) Co-authored-by: Todd Martin <106564991+trmartin4@users.noreply.github.com> --- .../browser/src/background/main.background.ts | 5 + .../src/background/runtime.background.ts | 1 + .../layout/account-switcher.component.html | 2 + .../app/layout/account-switcher.component.ts | 7 + .../components/dynamic-avatar.component.ts | 41 ++++++ .../components/selectable-avatar.component.ts | 54 +++++++ .../web/src/app/layouts/navbar.component.html | 4 +- .../app/settings/change-avatar.component.html | 82 +++++++++++ .../app/settings/change-avatar.component.ts | 138 ++++++++++++++++++ .../src/app/settings/profile.component.html | 13 +- .../web/src/app/settings/profile.component.ts | 32 +++- .../src/app/shared/loose-components.module.ts | 9 ++ .../organization-name-badge.component.ts | 25 +++- apps/web/src/locales/en/messages.json | 39 +++++ .../src/services/jslib-services.module.ts | 7 + .../account/avatar-update.service.ts | 8 + libs/common/src/abstractions/api.service.ts | 2 + libs/common/src/abstractions/state.service.ts | 3 + libs/common/src/misc/utils.ts | 4 + libs/common/src/models/domain/account.ts | 1 + .../models/request/update-avatar.request.ts | 7 + .../src/models/response/profile.response.ts | 2 + .../services/account/avatar-update.service.ts | 30 ++++ libs/common/src/services/api.service.ts | 6 + libs/common/src/services/state.service.ts | 17 +++ libs/common/src/services/sync/sync.service.ts | 1 + .../components/src/avatar/avatar.component.ts | 7 +- 27 files changed, 533 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/app/components/dynamic-avatar.component.ts create mode 100644 apps/web/src/app/components/selectable-avatar.component.ts create mode 100644 apps/web/src/app/settings/change-avatar.component.html create mode 100644 apps/web/src/app/settings/change-avatar.component.ts create mode 100644 libs/common/src/abstractions/account/avatar-update.service.ts create mode 100644 libs/common/src/models/request/update-avatar.request.ts create mode 100644 libs/common/src/services/account/avatar-update.service.ts diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 1182124e010..05bb21ba3d9 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -1,3 +1,4 @@ +import { AvatarUpdateService as AvatarUpdateServiceAbstraction } from "@bitwarden/common/abstractions/account/avatar-update.service"; import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service"; import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/abstractions/appId.service"; import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service"; @@ -45,6 +46,7 @@ import { CipherType } from "@bitwarden/common/enums/cipherType"; import { StateFactory } from "@bitwarden/common/factories/stateFactory"; import { GlobalState } from "@bitwarden/common/models/domain/global-state"; import { CipherView } from "@bitwarden/common/models/view/cipher.view"; +import { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-update.service"; import { ApiService } from "@bitwarden/common/services/api.service"; import { AppIdService } from "@bitwarden/common/services/appId.service"; import { AuditService } from "@bitwarden/common/services/audit.service"; @@ -168,6 +170,7 @@ export default class MainBackground { policyApiService: PolicyApiServiceAbstraction; userVerificationApiService: UserVerificationApiServiceAbstraction; syncNotifierService: SyncNotifierServiceAbstraction; + avatarUpdateService: AvatarUpdateServiceAbstraction; // Passed to the popup for Safari to workaround issues with theming, downloading, etc. backgroundWindow = window; @@ -565,6 +568,8 @@ export default class MainBackground { this.stateService, this.apiService ); + + this.avatarUpdateService = new AvatarUpdateService(this.apiService, this.stateService); } async bootstrap() { diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index 309edee28ce..1e0dee9e789 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -97,6 +97,7 @@ export default class RuntimeBackground { await this.main.refreshBadge(); await this.main.refreshMenu(); }, 2000); + this.main.avatarUpdateService.loadColorFromState(); } break; case "openPopup": diff --git a/apps/desktop/src/app/layout/account-switcher.component.html b/apps/desktop/src/app/layout/account-switcher.component.html index f361c09939b..991e0dfb716 100644 --- a/apps/desktop/src/app/layout/account-switcher.component.html +++ b/apps/desktop/src/app/layout/account-switcher.component.html @@ -12,6 +12,7 @@ diff --git a/apps/desktop/src/app/layout/account-switcher.component.ts b/apps/desktop/src/app/layout/account-switcher.component.ts index 495c461a420..09222cbced0 100644 --- a/apps/desktop/src/app/layout/account-switcher.component.ts +++ b/apps/desktop/src/app/layout/account-switcher.component.ts @@ -16,6 +16,7 @@ type ActiveAccount = { id: string; name: string; email: string; + avatarColor: string; }; export class SwitcherAccount extends Account { @@ -27,6 +28,8 @@ export class SwitcherAccount extends Account { ); } + avatarColor: string; + private removeWebProtocolFromString(urlString: string) { const regex = /http(s)?(:)?(\/\/)?|(\/\/)?(www\.)?/g; return urlString.replace(regex, ""); @@ -112,6 +115,7 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy { id: await this.tokenService.getUserId(), name: (await this.tokenService.getName()) ?? (await this.tokenService.getEmail()), email: await this.tokenService.getEmail(), + avatarColor: await this.stateService.getAvatarColor(), }; } catch { this.activeAccount = undefined; @@ -162,6 +166,9 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy { userId: userId, }); switcherAccounts[userId] = new SwitcherAccount(baseAccounts[userId]); + switcherAccounts[userId].avatarColor = await this.stateService.getAvatarColor({ + userId: userId, + }); } return switcherAccounts; } diff --git a/apps/web/src/app/components/dynamic-avatar.component.ts b/apps/web/src/app/components/dynamic-avatar.component.ts new file mode 100644 index 00000000000..ccc1c57cf48 --- /dev/null +++ b/apps/web/src/app/components/dynamic-avatar.component.ts @@ -0,0 +1,41 @@ +import { Component, Input, OnDestroy } from "@angular/core"; +import { Observable, Subject } from "rxjs"; + +import { AvatarUpdateService } from "@bitwarden/common/abstractions/account/avatar-update.service"; +type SizeTypes = "xlarge" | "large" | "default" | "small" | "xsmall"; +@Component({ + selector: "dynamic-avatar", + template: ` + + + `, +}) +export class DynamicAvatarComponent implements OnDestroy { + @Input() border = false; + @Input() id: string; + @Input() text: string; + @Input() title: string; + @Input() size: SizeTypes = "default"; + color$: Observable; + private destroy$ = new Subject(); + + constructor(private accountUpdateService: AvatarUpdateService) { + if (this.text) { + this.text = this.text.toUpperCase(); + } + this.color$ = this.accountUpdateService.avatarUpdate$; + } + + async ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/apps/web/src/app/components/selectable-avatar.component.ts b/apps/web/src/app/components/selectable-avatar.component.ts new file mode 100644 index 00000000000..deb573c5db0 --- /dev/null +++ b/apps/web/src/app/components/selectable-avatar.component.ts @@ -0,0 +1,54 @@ +import { Component, EventEmitter, Input, Output } from "@angular/core"; + +@Component({ + selector: "selectable-avatar", + template: ` + + + `, +}) +export class SelectableAvatarComponent { + @Input() id: string; + @Input() text: string; + @Input() title: string; + @Input() color: string; + @Input() border = false; + @Input() selected = false; + @Output() select = new EventEmitter(); + + onFire() { + this.select.emit(this.color); + } + + get classList() { + return ["tw-rounded-full tw-inline-block"] + .concat(["tw-cursor-pointer", "tw-outline", "tw-outline-solid", "tw-outline-offset-1"]) + .concat( + this.selected + ? ["tw-outline-[3px]", "tw-outline-primary-500"] + : [ + "tw-outline-0", + "hover:tw-outline-1", + "hover:tw-outline-primary-300", + "focus:tw-outline-2", + "focus:tw-outline-primary-500", + ] + ); + } +} diff --git a/apps/web/src/app/layouts/navbar.component.html b/apps/web/src/app/layouts/navbar.component.html index 0146a83af32..e1513f050c6 100644 --- a/apps/web/src/app/layouts/navbar.component.html +++ b/apps/web/src/app/layouts/navbar.component.html @@ -45,7 +45,7 @@ [bitMenuTriggerFor]="accountMenu" class="tw-border-0 tw-bg-transparent tw-text-alt2 tw-opacity-70 hover:tw-opacity-90" > - + @@ -55,7 +55,7 @@ *ngIf="name" appStopProp > - +
{{ "loggedInAs" | i18n }} {{ diff --git a/apps/web/src/app/settings/change-avatar.component.html b/apps/web/src/app/settings/change-avatar.component.html new file mode 100644 index 00000000000..2c8ce1f141b --- /dev/null +++ b/apps/web/src/app/settings/change-avatar.component.html @@ -0,0 +1,82 @@ + diff --git a/apps/web/src/app/settings/change-avatar.component.ts b/apps/web/src/app/settings/change-avatar.component.ts new file mode 100644 index 00000000000..ba6a234cbfa --- /dev/null +++ b/apps/web/src/app/settings/change-avatar.component.ts @@ -0,0 +1,138 @@ +import { + Component, + ElementRef, + EventEmitter, + Input, + OnDestroy, + OnInit, + Output, + ViewChild, + ViewEncapsulation, +} from "@angular/core"; +import { BehaviorSubject, debounceTime, Subject, takeUntil } from "rxjs"; + +import { AvatarUpdateService } from "@bitwarden/common/abstractions/account/avatar-update.service"; +import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { Utils } from "@bitwarden/common/misc/utils"; +import { ProfileResponse } from "@bitwarden/common/models/response/profile.response"; + +@Component({ + selector: "app-change-avatar", + templateUrl: "change-avatar.component.html", + encapsulation: ViewEncapsulation.None, +}) +export class ChangeAvatarComponent implements OnInit, OnDestroy { + @Input() profile: ProfileResponse; + + @Output() changeColor: EventEmitter = new EventEmitter(); + @Output() onSaved = new EventEmitter(); + + @ViewChild("colorPicker") colorPickerElement: ElementRef; + + loading = false; + error: string; + defaultColorPalette: NamedAvatarColor[] = [ + { name: "brightBlue", color: "#16cbfc" }, + { name: "green", color: "#94cc4b" }, + { name: "orange", color: "#ffb520" }, + { name: "lavender", color: "#e5beed" }, + { name: "yellow", color: "#fcff41" }, + { name: "indigo", color: "#acbdf7" }, + { name: "teal", color: "#8ecdc5" }, + { name: "salmon", color: "#ffa3a3" }, + { name: "pink", color: "#ffa2d4" }, + ]; + customColorSelected = false; + currentSelection: string; + + protected customColor$ = new BehaviorSubject(null); + protected customTextColor$ = new BehaviorSubject("#000000"); + private destroy$ = new Subject(); + + constructor( + private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService, + private logService: LogService, + private accountUpdateService: AvatarUpdateService + ) {} + + async ngOnInit() { + //localize the default colors + this.defaultColorPalette.forEach((c) => (c.name = this.i18nService.t(c.name))); + + this.customColor$ + .pipe(debounceTime(200), takeUntil(this.destroy$)) + .subscribe((color: string | null) => { + if (color == null) { + return; + } + this.customTextColor$.next(Utils.pickTextColorBasedOnBgColor(color)); + this.customColorSelected = true; + this.currentSelection = color; + }); + + this.setSelection(await this.accountUpdateService.loadColorFromState()); + } + + async showCustomPicker() { + this.customColorSelected = true; + this.colorPickerElement.nativeElement.click(); + this.setSelection(this.customColor$.value); + } + + async generateAvatarColor() { + Utils.stringToColor(this.profile.name.toString()); + } + + async submit() { + try { + if (Utils.validateHexColor(this.currentSelection) || this.currentSelection == null) { + await this.accountUpdateService.pushUpdate(this.currentSelection); + this.changeColor.emit(this.currentSelection); + this.platformUtilsService.showToast("success", null, this.i18nService.t("avatarUpdated")); + } else { + this.platformUtilsService.showToast("error", null, this.i18nService.t("errorOccurred")); + } + } catch (e) { + this.logService.error(e); + this.platformUtilsService.showToast("error", null, this.i18nService.t("errorOccurred")); + } + } + + async ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + async setSelection(color: string | null) { + this.defaultColorPalette.filter((x) => x.selected).forEach((c) => (c.selected = false)); + + if (color == null) { + return; + } + + color = color.toLowerCase(); + + this.customColorSelected = false; + //Allow for toggle + if (this.currentSelection === color) { + this.currentSelection = null; + } else { + const selectedColorIndex = this.defaultColorPalette.findIndex((c) => c.color === color); + if (selectedColorIndex !== -1) { + this.defaultColorPalette[selectedColorIndex].selected = true; + this.currentSelection = color; + } else { + this.customColor$.next(color); + } + } + } +} + +export class NamedAvatarColor { + name: string; + color: string; + selected? = false; +} diff --git a/apps/web/src/app/settings/profile.component.html b/apps/web/src/app/settings/profile.component.html index d6c5dea6fa0..f4a887fe6e9 100644 --- a/apps/web/src/app/settings/profile.component.html +++ b/apps/web/src/app/settings/profile.component.html @@ -33,7 +33,17 @@
- + +

@@ -55,3 +65,4 @@ {{ "save" | i18n }} + diff --git a/apps/web/src/app/settings/profile.component.ts b/apps/web/src/app/settings/profile.component.ts index 229f2f5b007..1c4b1198c4f 100644 --- a/apps/web/src/app/settings/profile.component.ts +++ b/apps/web/src/app/settings/profile.component.ts @@ -1,5 +1,7 @@ -import { Component, OnInit } from "@angular/core"; +import { ViewChild, ViewContainerRef, Component, OnDestroy, OnInit } from "@angular/core"; +import { Subject, takeUntil } from "rxjs"; +import { ModalService } from "@bitwarden/angular/services/modal.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; @@ -10,16 +12,21 @@ import { StateService } from "@bitwarden/common/abstractions/state.service"; import { UpdateProfileRequest } from "@bitwarden/common/models/request/update-profile.request"; import { ProfileResponse } from "@bitwarden/common/models/response/profile.response"; +import { ChangeAvatarComponent } from "./change-avatar.component"; + @Component({ selector: "app-profile", templateUrl: "profile.component.html", }) -export class ProfileComponent implements OnInit { +export class ProfileComponent implements OnInit, OnDestroy { loading = true; profile: ProfileResponse; fingerprint: string; formPromise: Promise; + @ViewChild("avatarModalTemplate", { read: ViewContainerRef, static: true }) + avatarModalRef: ViewContainerRef; + private destroy$ = new Subject(); constructor( private apiService: ApiService, @@ -28,7 +35,8 @@ export class ProfileComponent implements OnInit { private cryptoService: CryptoService, private logService: LogService, private keyConnectorService: KeyConnectorService, - private stateService: StateService + private stateService: StateService, + private modalService: ModalService ) {} async ngOnInit() { @@ -42,6 +50,24 @@ export class ProfileComponent implements OnInit { } } + async ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + async openChangeAvatar() { + const modalOpened = await this.modalService.openViewRef( + ChangeAvatarComponent, + this.avatarModalRef, + (modal) => { + modal.profile = this.profile; + modal.changeColor.pipe(takeUntil(this.destroy$)).subscribe(() => { + modalOpened[0].close(); + }); + } + ); + } + async submit() { try { const request = new UpdateProfileRequest(this.profile.name, this.profile.masterPasswordHint); diff --git a/apps/web/src/app/shared/loose-components.module.ts b/apps/web/src/app/shared/loose-components.module.ts index a28f4dd4662..c5bcf328eca 100644 --- a/apps/web/src/app/shared/loose-components.module.ts +++ b/apps/web/src/app/shared/loose-components.module.ts @@ -16,10 +16,12 @@ import { UpdatePasswordComponent } from "../accounts/update-password.component"; import { UpdateTempPasswordComponent } from "../accounts/update-temp-password.component"; import { VerifyEmailTokenComponent } from "../accounts/verify-email-token.component"; import { VerifyRecoverDeleteComponent } from "../accounts/verify-recover-delete.component"; +import { DynamicAvatarComponent } from "../components/dynamic-avatar.component"; import { NestedCheckboxComponent } from "../components/nested-checkbox.component"; import { OrganizationSwitcherComponent } from "../components/organization-switcher.component"; import { PasswordRepromptComponent } from "../components/password-reprompt.component"; import { PremiumBadgeComponent } from "../components/premium-badge.component"; +import { SelectableAvatarComponent } from "../components/selectable-avatar.component"; import { UserVerificationPromptComponent } from "../components/user-verification-prompt.component"; import { UserVerificationComponent } from "../components/user-verification.component"; import { FooterComponent } from "../layouts/footer.component"; @@ -69,6 +71,7 @@ import { ApiKeyComponent } from "../settings/api-key.component"; import { BillingHistoryViewComponent } from "../settings/billing-history-view.component"; import { BillingHistoryComponent } from "../settings/billing-history.component"; import { BillingSyncKeyComponent } from "../settings/billing-sync-key.component"; +import { ChangeAvatarComponent } from "../settings/change-avatar.component"; import { ChangeEmailComponent } from "../settings/change-email.component"; import { ChangeKdfComponent } from "../settings/change-kdf.component"; import { ChangePasswordComponent } from "../settings/change-password.component"; @@ -167,6 +170,7 @@ import { SharedModule } from "."; DeauthorizeSessionsComponent, DeleteAccountComponent, DomainRulesComponent, + DynamicAvatarComponent, EmergencyAccessAddEditComponent, EmergencyAccessAttachmentsComponent, EmergencyAccessComponent, @@ -220,6 +224,7 @@ import { SharedModule } from "."; PremiumBadgeComponent, PremiumComponent, ProfileComponent, + ChangeAvatarComponent, ProvidersComponent, PurgeVaultComponent, RecoverDeleteComponent, @@ -227,6 +232,7 @@ import { SharedModule } from "."; RemovePasswordComponent, SecurityComponent, SecurityKeysComponent, + SelectableAvatarComponent, SendAddEditComponent, SendComponent, SendEffluxDatesComponent, @@ -290,6 +296,7 @@ import { SharedModule } from "."; DeauthorizeSessionsComponent, DeleteAccountComponent, DomainRulesComponent, + DynamicAvatarComponent, EmergencyAccessAddEditComponent, EmergencyAccessAttachmentsComponent, EmergencyAccessComponent, @@ -342,6 +349,7 @@ import { SharedModule } from "."; PremiumBadgeComponent, PremiumComponent, ProfileComponent, + ChangeAvatarComponent, ProvidersComponent, PurgeVaultComponent, RecoverDeleteComponent, @@ -349,6 +357,7 @@ import { SharedModule } from "."; RemovePasswordComponent, SecurityComponent, SecurityKeysComponent, + SelectableAvatarComponent, SendAddEditComponent, SendComponent, SendEffluxDatesComponent, diff --git a/apps/web/src/app/vault/organization-badge/organization-name-badge.component.ts b/apps/web/src/app/vault/organization-badge/organization-name-badge.component.ts index 27f19c68289..e423a2cb8dc 100644 --- a/apps/web/src/app/vault/organization-badge/organization-name-badge.component.ts +++ b/apps/web/src/app/vault/organization-badge/organization-name-badge.component.ts @@ -1,6 +1,8 @@ import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { AvatarUpdateService } from "@bitwarden/common/abstractions/account/avatar-update.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { TokenService } from "@bitwarden/common/abstractions/token.service"; import { Utils } from "@bitwarden/common/misc/utils"; @Component({ @@ -15,18 +17,29 @@ export class OrganizationNameBadgeComponent implements OnInit { color: string; textColor: string; + isMe: boolean; - constructor(private i18nService: I18nService) {} + constructor( + private i18nService: I18nService, + private avatarService: AvatarUpdateService, + private tokenService: TokenService + ) {} - ngOnInit(): void { + async ngOnInit(): Promise { if (this.organizationName == null || this.organizationName === "") { this.organizationName = this.i18nService.t("me"); - this.color = Utils.stringToColor(this.profileName.toUpperCase()); + this.isMe = true; } - if (this.color == null) { - this.color = Utils.stringToColor(this.organizationName.toUpperCase()); + if (this.isMe) { + this.color = await this.avatarService.loadColorFromState(); + if (this.color == null) { + const userName = await this.tokenService.getName(); + this.color = Utils.stringToColor(userName.toUpperCase()); + } + } else { + this.color = Utils.stringToColor(this.organizationName); } - this.textColor = Utils.pickTextColorBasedOnBgColor(this.color); + this.textColor = Utils.pickTextColorBasedOnBgColor(this.color, 135, true) + "!important"; } emitOnOrganizationClicked() { diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index b967a095ca1..798b7f7d9fc 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -5475,6 +5475,45 @@ "notYou": { "message": "Not you?" }, + "pickAnAvatarColor": { + "message": "Pick an avatar color" + }, + "customizeAvatar": { + "message": "Customize avatar" + }, + "avatarUpdated": { + "message": "Avatar updated" + }, + "brightBlue": { + "message": "Bright Blue" + }, + "green": { + "message": "Green" + }, + "orange": { + "message": "Orange" + }, + "lavender": { + "message": "Lavender" + }, + "yellow": { + "message": "Yellow" + }, + "indigo": { + "message": "Indigo" + }, + "teal": { + "message": "Teal" + }, + "salmon": { + "message": "Salmon" + }, + "pink": { + "message": "Pink" + }, + "customColor": { + "message": "Custom Color" + }, "multiSelectPlaceholder": { "message": "-- Type to Filter --" }, diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index a7d32a8909b..245a1804604 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -5,6 +5,7 @@ import { AccountService as AccountServiceAbstraction, InternalAccountService, } from "@bitwarden/common/abstractions/account/account.service"; +import { AvatarUpdateService as AccountUpdateServiceAbstraction } from "@bitwarden/common/abstractions/account/avatar-update.service"; import { AnonymousHubService as AnonymousHubServiceAbstraction } from "@bitwarden/common/abstractions/anonymousHub.service"; import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service"; import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/abstractions/appId.service"; @@ -73,6 +74,7 @@ import { Account } from "@bitwarden/common/models/domain/account"; import { GlobalState } from "@bitwarden/common/models/domain/global-state"; import { AccountApiServiceImplementation } from "@bitwarden/common/services/account/account-api.service"; import { AccountServiceImplementation } from "@bitwarden/common/services/account/account.service"; +import { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-update.service"; import { AnonymousHubService } from "@bitwarden/common/services/anonymousHub.service"; import { ApiService } from "@bitwarden/common/services/api.service"; import { AppIdService } from "@bitwarden/common/services/appId.service"; @@ -291,6 +293,11 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction"; provide: InternalAccountService, useExisting: AccountServiceAbstraction, }, + { + provide: AccountUpdateServiceAbstraction, + useClass: AvatarUpdateService, + deps: [ApiServiceAbstraction, StateServiceAbstraction], + }, { provide: LogService, useFactory: () => new ConsoleLogService(false) }, { provide: CollectionServiceAbstraction, diff --git a/libs/common/src/abstractions/account/avatar-update.service.ts b/libs/common/src/abstractions/account/avatar-update.service.ts new file mode 100644 index 00000000000..980942dfe39 --- /dev/null +++ b/libs/common/src/abstractions/account/avatar-update.service.ts @@ -0,0 +1,8 @@ +import { Observable } from "rxjs"; + +import { ProfileResponse } from "../../models/response/profile.response"; +export abstract class AvatarUpdateService { + avatarUpdate$ = new Observable(); + abstract pushUpdate(color: string): Promise; + abstract loadColorFromState(): Promise; +} diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index 83360607e1a..bff27582d6b 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -62,6 +62,7 @@ import { TaxInfoUpdateRequest } from "../models/request/tax-info-update.request" import { TwoFactorEmailRequest } from "../models/request/two-factor-email.request"; import { TwoFactorProviderRequest } from "../models/request/two-factor-provider.request"; import { TwoFactorRecoveryRequest } from "../models/request/two-factor-recovery.request"; +import { UpdateAvatarRequest } from "../models/request/update-avatar.request"; import { UpdateDomainsRequest } from "../models/request/update-domains.request"; import { UpdateKeyRequest } from "../models/request/update-key.request"; import { UpdateProfileRequest } from "../models/request/update-profile.request"; @@ -172,6 +173,7 @@ export abstract class ApiService { getUserSubscription: () => Promise; getTaxInfo: () => Promise; putProfile: (request: UpdateProfileRequest) => Promise; + putAvatar: (request: UpdateAvatarRequest) => Promise; putTaxInfo: (request: TaxInfoUpdateRequest) => Promise; postPrelogin: (request: PreloginRequest) => Promise; postEmailToken: (request: EmailTokenRequest) => Promise; diff --git a/libs/common/src/abstractions/state.service.ts b/libs/common/src/abstractions/state.service.ts index 2c0712d0299..e7974e6852c 100644 --- a/libs/common/src/abstractions/state.service.ts +++ b/libs/common/src/abstractions/state.service.ts @@ -349,4 +349,7 @@ export abstract class StateService { * @deprecated Do not call this directly, use ConfigService */ setServerConfig: (value: ServerConfigData, options?: StorageOptions) => Promise; + + getAvatarColor: (options?: StorageOptions) => Promise; + setAvatarColor: (value: string, options?: StorageOptions) => Promise; } diff --git a/libs/common/src/misc/utils.ts b/libs/common/src/misc/utils.ts index ead692d6663..5704123ca7c 100644 --- a/libs/common/src/misc/utils.ts +++ b/libs/common/src/misc/utils.ts @@ -431,6 +431,10 @@ export class Utils { return this.global.bitwardenContainerService; } + static validateHexColor(color: string) { + return /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(color); + } + /** * Converts map to a Record with the same data. Inverse of recordToMap * Useful in toJSON methods, since Maps are not serializable diff --git a/libs/common/src/models/domain/account.ts b/libs/common/src/models/domain/account.ts index 0715b0c49b6..40de21b3b68 100644 --- a/libs/common/src/models/domain/account.ts +++ b/libs/common/src/models/domain/account.ts @@ -233,6 +233,7 @@ export class AccountSettings { vaultTimeout?: number; vaultTimeoutAction?: string = "lock"; serverConfig?: ServerConfigData; + avatarColor?: string; static fromJSON(obj: Jsonify): AccountSettings { if (obj == null) { diff --git a/libs/common/src/models/request/update-avatar.request.ts b/libs/common/src/models/request/update-avatar.request.ts new file mode 100644 index 00000000000..dc85560e113 --- /dev/null +++ b/libs/common/src/models/request/update-avatar.request.ts @@ -0,0 +1,7 @@ +export class UpdateAvatarRequest { + avatarColor: string; + + constructor(avatarColor: string) { + this.avatarColor = avatarColor; + } +} diff --git a/libs/common/src/models/response/profile.response.ts b/libs/common/src/models/response/profile.response.ts index a8d50ffa4b3..1be086ea43c 100644 --- a/libs/common/src/models/response/profile.response.ts +++ b/libs/common/src/models/response/profile.response.ts @@ -14,6 +14,7 @@ export class ProfileResponse extends BaseResponse { culture: string; twoFactorEnabled: boolean; key: string; + avatarColor: string; privateKey: string; securityStamp: string; forcePasswordReset: boolean; @@ -34,6 +35,7 @@ export class ProfileResponse extends BaseResponse { this.culture = this.getResponseProperty("Culture"); this.twoFactorEnabled = this.getResponseProperty("TwoFactorEnabled"); this.key = this.getResponseProperty("Key"); + this.avatarColor = this.getResponseProperty("AvatarColor"); this.privateKey = this.getResponseProperty("PrivateKey"); this.securityStamp = this.getResponseProperty("SecurityStamp"); this.forcePasswordReset = this.getResponseProperty("ForcePasswordReset") ?? false; diff --git a/libs/common/src/services/account/avatar-update.service.ts b/libs/common/src/services/account/avatar-update.service.ts new file mode 100644 index 00000000000..7687116682c --- /dev/null +++ b/libs/common/src/services/account/avatar-update.service.ts @@ -0,0 +1,30 @@ +import { BehaviorSubject, Observable } from "rxjs"; + +import { AvatarUpdateService as AvatarUpdateServiceAbstraction } from "../../abstractions/account/avatar-update.service"; +import { ApiService } from "../../abstractions/api.service"; +import { StateService } from "../../abstractions/state.service"; +import { UpdateAvatarRequest } from "../../models/request/update-avatar.request"; +import { ProfileResponse } from "../../models/response/profile.response"; + +export class AvatarUpdateService implements AvatarUpdateServiceAbstraction { + private _avatarUpdate$ = new BehaviorSubject(null); + avatarUpdate$: Observable = this._avatarUpdate$.asObservable(); + + constructor(private apiService: ApiService, private stateService: StateService) { + this.loadColorFromState(); + } + + loadColorFromState(): Promise { + return this.stateService.getAvatarColor().then((color) => { + this._avatarUpdate$.next(color); + return color; + }); + } + + pushUpdate(color: string | null): Promise { + return this.apiService.putAvatar(new UpdateAvatarRequest(color)).then((response) => { + this.stateService.setAvatarColor(response.avatarColor); + this._avatarUpdate$.next(response.avatarColor); + }); + } +} diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 42e413aebfb..beb1a1c643f 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -70,6 +70,7 @@ import { TaxInfoUpdateRequest } from "../models/request/tax-info-update.request" import { TwoFactorEmailRequest } from "../models/request/two-factor-email.request"; import { TwoFactorProviderRequest } from "../models/request/two-factor-provider.request"; import { TwoFactorRecoveryRequest } from "../models/request/two-factor-recovery.request"; +import { UpdateAvatarRequest } from "../models/request/update-avatar.request"; import { UpdateDomainsRequest } from "../models/request/update-domains.request"; import { UpdateKeyRequest } from "../models/request/update-key.request"; import { UpdateProfileRequest } from "../models/request/update-profile.request"; @@ -290,6 +291,11 @@ export class ApiService implements ApiServiceAbstraction { return new ProfileResponse(r); } + async putAvatar(request: UpdateAvatarRequest): Promise { + const r = await this.send("PUT", "/accounts/avatar", request, true, true); + return new ProfileResponse(r); + } + putTaxInfo(request: TaxInfoUpdateRequest): Promise { return this.send("PUT", "/accounts/tax", request, true, false); } diff --git a/libs/common/src/services/state.service.ts b/libs/common/src/services/state.service.ts index 2a2ce53d622..f55ccc39314 100644 --- a/libs/common/src/services/state.service.ts +++ b/libs/common/src/services/state.service.ts @@ -2301,6 +2301,23 @@ export class StateService< )?.settings?.serverConfig; } + async getAvatarColor(options?: StorageOptions): Promise { + return ( + await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())) + )?.settings?.avatarColor; + } + + async setAvatarColor(value: string, options?: StorageOptions): Promise { + const account = await this.getAccount( + this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()) + ); + account.settings.avatarColor = value; + return await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()) + ); + } + protected async getGlobals(options: StorageOptions): Promise { let globals: TGlobalState; if (this.useMemory(options.storageLocation)) { diff --git a/libs/common/src/services/sync/sync.service.ts b/libs/common/src/services/sync/sync.service.ts index cd3f8e40ff6..7317b6c991b 100644 --- a/libs/common/src/services/sync/sync.service.ts +++ b/libs/common/src/services/sync/sync.service.ts @@ -304,6 +304,7 @@ export class SyncService implements SyncServiceAbstraction { await this.cryptoService.setEncPrivateKey(response.privateKey); await this.cryptoService.setProviderKeys(response.providers); await this.cryptoService.setOrgKeys(response.organizations, response.providerOrganizations); + await this.stateService.setAvatarColor(response.avatarColor); await this.stateService.setSecurityStamp(response.securityStamp); await this.stateService.setEmailVerified(response.emailVerified); await this.stateService.setHasPremiumPersonally(response.premiumPersonally); diff --git a/libs/components/src/avatar/avatar.component.ts b/libs/components/src/avatar/avatar.component.ts index bd1a13de86a..8e67fd66ea9 100644 --- a/libs/components/src/avatar/avatar.component.ts +++ b/libs/components/src/avatar/avatar.component.ts @@ -3,23 +3,26 @@ import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser"; import { Utils } from "@bitwarden/common/misc/utils"; -type SizeTypes = "large" | "default" | "small"; +type SizeTypes = "xlarge" | "large" | "default" | "small" | "xsmall"; const SizeClasses: Record = { + xlarge: ["tw-h-24", "tw-w-24"], large: ["tw-h-16", "tw-w-16"], default: ["tw-h-10", "tw-w-10"], small: ["tw-h-7", "tw-w-7"], + xsmall: ["tw-h-6", "tw-w-6"], }; @Component({ selector: "bit-avatar", - template: ``, + template: ``, }) export class AvatarComponent implements OnChanges { @Input() border = false; @Input() color?: string; @Input() id?: string; @Input() text?: string; + @Input() title: string; @Input() size: SizeTypes = "default"; private svgCharCount = 2;