mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-22 11:45:59 +01:00
[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:
parent
0a734ce338
commit
d41b3b13ea
@ -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() {
|
||||
|
@ -97,6 +97,7 @@ export default class RuntimeBackground {
|
||||
await this.main.refreshBadge();
|
||||
await this.main.refreshMenu();
|
||||
}, 2000);
|
||||
this.main.avatarUpdateService.loadColorFromState();
|
||||
}
|
||||
break;
|
||||
case "openPopup":
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
||||
|
41
apps/web/src/app/components/dynamic-avatar.component.ts
Normal file
41
apps/web/src/app/components/dynamic-avatar.component.ts
Normal 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();
|
||||
}
|
||||
}
|
54
apps/web/src/app/components/selectable-avatar.component.ts
Normal file
54
apps/web/src/app/components/selectable-avatar.component.ts
Normal 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",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
@ -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">{{
|
||||
|
82
apps/web/src/app/settings/change-avatar.component.html
Normal file
82
apps/web/src/app/settings/change-avatar.component.html
Normal 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">×</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>
|
138
apps/web/src/app/settings/change-avatar.component.ts
Normal file
138
apps/web/src/app/settings/change-avatar.component.ts
Normal 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;
|
||||
}
|
@ -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>
|
||||
|
@ -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);
|
||||
|
@ -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,
|
||||
|
@ -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() {
|
||||
|
@ -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 --"
|
||||
},
|
||||
|
@ -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,
|
||||
|
@ -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>;
|
||||
}
|
@ -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>;
|
||||
|
@ -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>;
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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) {
|
||||
|
7
libs/common/src/models/request/update-avatar.request.ts
Normal file
7
libs/common/src/models/request/update-avatar.request.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export class UpdateAvatarRequest {
|
||||
avatarColor: string;
|
||||
|
||||
constructor(avatarColor: string) {
|
||||
this.avatarColor = avatarColor;
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
30
libs/common/src/services/account/avatar-update.service.ts
Normal file
30
libs/common/src/services/account/avatar-update.service.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -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)) {
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user