[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>
This commit is contained in:
Brandon Maharaj 2023-01-01 10:30:09 -05:00 committed by GitHub
parent 0a734ce338
commit d41b3b13ea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 533 additions and 14 deletions

View File

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

View File

@ -97,6 +97,7 @@ export default class RuntimeBackground {
await this.main.refreshBadge();
await this.main.refreshMenu();
}, 2000);
this.main.avatarUpdateService.loadColorFromState();
}
break;
case "openPopup":

View File

@ -12,6 +12,7 @@
<app-avatar
[text]="activeAccount.name"
[id]="activeAccount.id"
[color]="activeAccount.avatarColor"
[size]="25"
[circle]="true"
[fontSize]="14"
@ -65,6 +66,7 @@
[circle]="true"
[fontSize]="14"
[dynamic]="true"
[color]="a.value.avatarColor"
*ngIf="a.value.profile.email != null"
aria-hidden="true"
></app-avatar>

View File

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

View File

@ -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: `<span [title]="title">
<bit-avatar
appStopClick
[text]="text"
[size]="size"
[color]="color$ | async"
[border]="border"
[id]="id"
[title]="title"
>
</bit-avatar>
</span>`,
})
export class DynamicAvatarComponent implements OnDestroy {
@Input() border = false;
@Input() id: string;
@Input() text: string;
@Input() title: string;
@Input() size: SizeTypes = "default";
color$: Observable<string | null>;
private destroy$ = new Subject<void>();
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();
}
}

View File

@ -0,0 +1,54 @@
import { Component, EventEmitter, Input, Output } from "@angular/core";
@Component({
selector: "selectable-avatar",
template: `<span
[title]="title"
(click)="onFire()"
(keyup.enter)="onFire()"
tabindex="0"
[ngClass]="classList"
>
<bit-avatar
appStopClick
[text]="text"
size="xlarge"
[text]="text"
[color]="color"
[border]="false"
[id]="id"
[border]="border"
[title]="title"
>
</bit-avatar>
</span>`,
})
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<string>();
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",
]
);
}
}

View File

@ -45,7 +45,7 @@
[bitMenuTriggerFor]="accountMenu"
class="tw-border-0 tw-bg-transparent tw-text-alt2 tw-opacity-70 hover:tw-opacity-90"
>
<i class="bwi bwi-user-circle bwi-lg" aria-hidden="true"></i>
<dynamic-avatar [text]="name" size="xsmall" aria-hidden="true"></dynamic-avatar>
<i class="bwi bwi-caret-down bwi-sm" aria-hidden="true"></i>
</button>
<bit-menu class="dropdown-menu" #accountMenu>
@ -55,7 +55,7 @@
*ngIf="name"
appStopProp
>
<bit-avatar [text]="name" [id]="userId" size="small"></bit-avatar>
<dynamic-avatar [text]="name" size="small"></dynamic-avatar>
<div class="tw-ml-2 tw-block tw-overflow-hidden tw-whitespace-nowrap">
<span>{{ "loggedInAs" | i18n }}</span>
<small class="tw-block tw-overflow-hidden tw-whitespace-nowrap tw-text-muted">{{

View File

@ -0,0 +1,82 @@
<div class="modal fade" role="dialog" aria-modal="true" aria-labelledby="customizeTitle">
<div class="modal-dialog modal-dialog-scrollable tw-w-[600px] tw-max-w-none" role="document">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title" id="customizeTitle">{{ "customizeAvatar" | i18n }}</h2>
<button
type="button"
class="close"
data-dismiss="modal"
appA11yTitle="{{ 'close' | i18n }}"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="card-body text-center" *ngIf="loading">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
{{ "loading" | i18n }}
</div>
<app-callout type="error" *ngIf="error">
{{ error }}
</app-callout>
<p class="tw-text-lg">{{ "pickAnAvatarColor" | i18n }}</p>
<div class="tw-flex tw-flex-wrap tw-justify-center tw-gap-8 tw-gap-y-8">
<ng-container *ngFor="let c of defaultColorPalette">
<selectable-avatar
appStopClick
(select)="setSelection(c.color)"
[selected]="c.selected"
[title]="c.name"
text="{{ profile | userName }}"
[color]="c.color"
[border]="true"
>
</selectable-avatar>
</ng-container>
<span>
<span
[tabIndex]="0"
(keyup.enter)="showCustomPicker()"
(click)="showCustomPicker()"
title="{{ 'customColor' | i18n }}"
[ngClass]="{
'!tw-outline-[3px] tw-outline-primary-500 hover:tw-outline-[3px] hover:tw-outline-primary-500':
customColorSelected
}"
class="tw-outline-solid tw-bg-white tw-relative tw-inline-block tw-flex tw-h-24 tw-w-24 tw-cursor-pointer tw-place-content-center tw-content-center tw-justify-center tw-rounded-full tw-border tw-border-solid tw-border-secondary-500 tw-outline tw-outline-0 tw-outline-offset-1 hover:tw-outline-1 hover:tw-outline-primary-300 focus:tw-outline-2 focus:tw-outline-primary-500"
[style.background-color]="customColor$ | async"
>
<i
[style.color]="customTextColor$ | async"
class="bwi bwi-pencil tw-m-auto tw-text-3xl"
></i>
<input
tabindex="-1"
class="tw-absolute tw-right-0 tw-bottom-0 tw-h-px tw-w-px tw-border-none tw-bg-transparent tw-opacity-0"
#colorPicker
type="color"
[ngModel]="customColor$ | async"
(ngModelChange)="customColor$.next($event)"
/>
</span>
</span>
</div>
</div>
<div class="modal-footer">
<button
type="submit"
class="btn btn-primary btn-submit"
[disabled]="loading"
(click)="submit()"
>
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "save" | i18n }}</span>
</button>
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">
{{ "close" | i18n }}
</button>
</div>
</div>
</div>
</div>

View File

@ -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<string | null> = new EventEmitter();
@Output() onSaved = new EventEmitter();
@ViewChild("colorPicker") colorPickerElement: ElementRef<HTMLElement>;
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<string | null>(null);
protected customTextColor$ = new BehaviorSubject<string>("#000000");
private destroy$ = new Subject<void>();
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;
}

View File

@ -33,7 +33,17 @@
</div>
<div class="col-6">
<div class="mb-3">
<bit-avatar [text]="profile | userName" [id]="profile.id" size="large"></bit-avatar>
<dynamic-avatar text="{{ profile | userName }}" [size]="'large'"> </dynamic-avatar>
<button
type="button"
class="btn btn-outline-secondary tw-ml-3.5"
appStopClick
appStopProp
(click)="openChangeAvatar()"
>
<i class="bwi bwi-lg bwi-pencil-square" aria-hidden="true"></i>
Customize
</button>
</div>
<hr />
<p *ngIf="fingerprint">
@ -55,3 +65,4 @@
<span>{{ "save" | i18n }}</span>
</button>
</form>
<ng-template #avatarModalTemplate></ng-template>

View File

@ -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<any>;
@ViewChild("avatarModalTemplate", { read: ViewContainerRef, static: true })
avatarModalRef: ViewContainerRef;
private destroy$ = new Subject<void>();
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);

View File

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

View File

@ -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<void> {
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() {

View File

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

View File

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

View File

@ -0,0 +1,8 @@
import { Observable } from "rxjs";
import { ProfileResponse } from "../../models/response/profile.response";
export abstract class AvatarUpdateService {
avatarUpdate$ = new Observable<string | null>();
abstract pushUpdate(color: string): Promise<ProfileResponse | void>;
abstract loadColorFromState(): Promise<string | null>;
}

View File

@ -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<SubscriptionResponse>;
getTaxInfo: () => Promise<TaxInfoResponse>;
putProfile: (request: UpdateProfileRequest) => Promise<ProfileResponse>;
putAvatar: (request: UpdateAvatarRequest) => Promise<ProfileResponse>;
putTaxInfo: (request: TaxInfoUpdateRequest) => Promise<any>;
postPrelogin: (request: PreloginRequest) => Promise<PreloginResponse>;
postEmailToken: (request: EmailTokenRequest) => Promise<any>;

View File

@ -349,4 +349,7 @@ export abstract class StateService<T extends Account = Account> {
* @deprecated Do not call this directly, use ConfigService
*/
setServerConfig: (value: ServerConfigData, options?: StorageOptions) => Promise<void>;
getAvatarColor: (options?: StorageOptions) => Promise<string | null | undefined>;
setAvatarColor: (value: string, options?: StorageOptions) => Promise<void>;
}

View File

@ -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<string, V> with the same data. Inverse of recordToMap
* Useful in toJSON methods, since Maps are not serializable

View File

@ -233,6 +233,7 @@ export class AccountSettings {
vaultTimeout?: number;
vaultTimeoutAction?: string = "lock";
serverConfig?: ServerConfigData;
avatarColor?: string;
static fromJSON(obj: Jsonify<AccountSettings>): AccountSettings {
if (obj == null) {

View File

@ -0,0 +1,7 @@
export class UpdateAvatarRequest {
avatarColor: string;
constructor(avatarColor: string) {
this.avatarColor = avatarColor;
}
}

View File

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

View File

@ -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<string | null>(null);
avatarUpdate$: Observable<string | null> = this._avatarUpdate$.asObservable();
constructor(private apiService: ApiService, private stateService: StateService) {
this.loadColorFromState();
}
loadColorFromState(): Promise<string | null> {
return this.stateService.getAvatarColor().then((color) => {
this._avatarUpdate$.next(color);
return color;
});
}
pushUpdate(color: string | null): Promise<ProfileResponse | void> {
return this.apiService.putAvatar(new UpdateAvatarRequest(color)).then((response) => {
this.stateService.setAvatarColor(response.avatarColor);
this._avatarUpdate$.next(response.avatarColor);
});
}
}

View File

@ -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<ProfileResponse> {
const r = await this.send("PUT", "/accounts/avatar", request, true, true);
return new ProfileResponse(r);
}
putTaxInfo(request: TaxInfoUpdateRequest): Promise<any> {
return this.send("PUT", "/accounts/tax", request, true, false);
}

View File

@ -2301,6 +2301,23 @@ export class StateService<
)?.settings?.serverConfig;
}
async getAvatarColor(options?: StorageOptions): Promise<string | null | undefined> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))
)?.settings?.avatarColor;
}
async setAvatarColor(value: string, options?: StorageOptions): Promise<void> {
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<TGlobalState> {
let globals: TGlobalState;
if (this.useMemory(options.storageLocation)) {

View File

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

View File

@ -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<SizeTypes, string[]> = {
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: `<img *ngIf="src" [src]="src" title="{{ text }}" [ngClass]="classList" />`,
template: `<img *ngIf="src" [src]="src" title="{{ title || text }}" [ngClass]="classList" />`,
})
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;