mirror of
https://github.com/bitwarden/browser
synced 2025-12-15 07:43:35 +00:00
[PM-5266] Create Avatar Service (#7905)
* rename file, move, and update imports * refactor and implement StateProvider * remove comments * add migration * use 'disk-local' for web * add JSDoc comments * move AvatarService before SyncService * create factory * replace old method with observable in story * fix tests * add tests for migration * receive most recent avatarColor emission * move logic to component * fix CLI dependency * remove BehaviorSubject * cleanup * use UserKeyDefinition * avoid extra write * convert to observable * fix tests
This commit is contained in:
@@ -1,8 +0,0 @@
|
||||
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>;
|
||||
}
|
||||
29
libs/common/src/auth/abstractions/avatar.service.ts
Normal file
29
libs/common/src/auth/abstractions/avatar.service.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserId } from "../../types/guid";
|
||||
|
||||
export abstract class AvatarService {
|
||||
/**
|
||||
* An observable monitoring the active user's avatar color.
|
||||
* The observable updates when the avatar color changes.
|
||||
*/
|
||||
avatarColor$: Observable<string | null>;
|
||||
/**
|
||||
* Sets the avatar color of the active user
|
||||
*
|
||||
* @param color the color to set the avatar color to
|
||||
* @returns a promise that resolves when the avatar color is set
|
||||
*/
|
||||
abstract setAvatarColor(color: string): Promise<void>;
|
||||
/**
|
||||
* Gets the avatar color of the specified user.
|
||||
*
|
||||
* @remarks This is most useful for account switching where we show an
|
||||
* avatar for each account. If you only need the active user's
|
||||
* avatar color, use the avatarColor$ observable above instead.
|
||||
*
|
||||
* @param userId the userId of the user whose avatar color should be retreived
|
||||
* @return an Observable that emits a string of the avatar color of the specified user
|
||||
*/
|
||||
abstract getUserAvatarColor$(userId: UserId): Observable<string | null>;
|
||||
}
|
||||
33
libs/common/src/auth/services/avatar.service.ts
Normal file
33
libs/common/src/auth/services/avatar.service.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { ApiService } from "../../abstractions/api.service";
|
||||
import { UpdateAvatarRequest } from "../../models/request/update-avatar.request";
|
||||
import { AVATAR_DISK, StateProvider, UserKeyDefinition } from "../../platform/state";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { AvatarService as AvatarServiceAbstraction } from "../abstractions/avatar.service";
|
||||
|
||||
const AVATAR_COLOR = new UserKeyDefinition<string>(AVATAR_DISK, "avatarColor", {
|
||||
deserializer: (value) => value,
|
||||
clearOn: [],
|
||||
});
|
||||
|
||||
export class AvatarService implements AvatarServiceAbstraction {
|
||||
avatarColor$: Observable<string>;
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private stateProvider: StateProvider,
|
||||
) {
|
||||
this.avatarColor$ = this.stateProvider.getActive(AVATAR_COLOR).state$;
|
||||
}
|
||||
|
||||
async setAvatarColor(color: string): Promise<void> {
|
||||
const { avatarColor } = await this.apiService.putAvatar(new UpdateAvatarRequest(color));
|
||||
|
||||
await this.stateProvider.setUserState(AVATAR_COLOR, avatarColor);
|
||||
}
|
||||
|
||||
getUserAvatarColor$(userId: UserId): Observable<string | null> {
|
||||
return this.stateProvider.getUser(userId, AVATAR_COLOR).state$;
|
||||
}
|
||||
}
|
||||
@@ -359,9 +359,6 @@ 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>;
|
||||
/**
|
||||
* fetches string value of URL user tried to navigate to while unauthenticated.
|
||||
* @param options Defines the storage options for the URL; Defaults to session Storage.
|
||||
|
||||
@@ -1854,23 +1854,6 @@ 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()),
|
||||
);
|
||||
}
|
||||
|
||||
async getDeepLinkRedirectUrl(options?: StorageOptions): Promise<string> {
|
||||
return (
|
||||
await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
|
||||
|
||||
@@ -26,6 +26,7 @@ export const PROVIDERS_DISK = new StateDefinition("providers", "disk");
|
||||
// Auth
|
||||
|
||||
export const ACCOUNT_MEMORY = new StateDefinition("account", "memory");
|
||||
export const AVATAR_DISK = new StateDefinition("avatar", "disk", { web: "disk-local" });
|
||||
export const SSO_DISK = new StateDefinition("ssoLogin", "disk");
|
||||
export const LOGIN_STRATEGY_MEMORY = new StateDefinition("loginStrategy", "memory");
|
||||
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { BehaviorSubject, Observable } from "rxjs";
|
||||
|
||||
import { AvatarUpdateService as AvatarUpdateServiceAbstraction } from "../../abstractions/account/avatar-update.service";
|
||||
import { ApiService } from "../../abstractions/api.service";
|
||||
import { UpdateAvatarRequest } from "../../models/request/update-avatar.request";
|
||||
import { ProfileResponse } from "../../models/response/profile.response";
|
||||
import { StateService } from "../../platform/abstractions/state.service";
|
||||
|
||||
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,
|
||||
) {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
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) => {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.stateService.setAvatarColor(response.avatarColor);
|
||||
this._avatarUpdate$.next(response.avatarColor);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import { AppIdMigrator } from "./migrations/33-move-app-id-to-state-providers";
|
||||
import { DomainSettingsMigrator } from "./migrations/34-move-domain-settings-to-state-providers";
|
||||
import { MoveThemeToStateProviderMigrator } from "./migrations/35-move-theme-to-state-providers";
|
||||
import { VaultSettingsKeyMigrator } from "./migrations/36-move-show-card-and-identity-to-state-provider";
|
||||
import { AvatarColorMigrator } from "./migrations/37-move-avatar-color-to-state-providers";
|
||||
import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked";
|
||||
import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys";
|
||||
import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key";
|
||||
@@ -41,7 +42,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting
|
||||
import { MinVersionMigrator } from "./migrations/min-version";
|
||||
|
||||
export const MIN_VERSION = 2;
|
||||
export const CURRENT_VERSION = 36;
|
||||
export const CURRENT_VERSION = 37;
|
||||
export type MinVersion = typeof MIN_VERSION;
|
||||
|
||||
export function createMigrationBuilder() {
|
||||
@@ -80,7 +81,8 @@ export function createMigrationBuilder() {
|
||||
.with(AppIdMigrator, 32, 33)
|
||||
.with(DomainSettingsMigrator, 33, 34)
|
||||
.with(MoveThemeToStateProviderMigrator, 34, 35)
|
||||
.with(VaultSettingsKeyMigrator, 35, CURRENT_VERSION);
|
||||
.with(VaultSettingsKeyMigrator, 35, 36)
|
||||
.with(AvatarColorMigrator, 36, CURRENT_VERSION);
|
||||
}
|
||||
|
||||
export async function currentVersion(
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper, runMigrator } from "../migration-helper.spec";
|
||||
|
||||
import { AvatarColorMigrator } from "./37-move-avatar-color-to-state-providers";
|
||||
|
||||
function rollbackJSON() {
|
||||
return {
|
||||
authenticatedAccounts: ["user-1", "user-2"],
|
||||
"user_user-1_avatar_avatarColor": "#ff0000",
|
||||
"user_user-2_avatar_avatarColor": "#cccccc",
|
||||
"user-1": {
|
||||
settings: {
|
||||
extra: "data",
|
||||
},
|
||||
extra: "data",
|
||||
},
|
||||
"user-2": {
|
||||
settings: {
|
||||
extra: "data",
|
||||
},
|
||||
extra: "data",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("AvatarColorMigrator", () => {
|
||||
const migrator = new AvatarColorMigrator(36, 37);
|
||||
|
||||
it("should migrate the avatarColor property from the account settings object to a user StorageKey", async () => {
|
||||
const output = await runMigrator(migrator, {
|
||||
authenticatedAccounts: ["user-1", "user-2"] as const,
|
||||
"user-1": {
|
||||
settings: {
|
||||
avatarColor: "#ff0000",
|
||||
extra: "data",
|
||||
},
|
||||
extra: "data",
|
||||
},
|
||||
"user-2": {
|
||||
settings: {
|
||||
avatarColor: "#cccccc",
|
||||
extra: "data",
|
||||
},
|
||||
extra: "data",
|
||||
},
|
||||
});
|
||||
|
||||
expect(output).toEqual({
|
||||
authenticatedAccounts: ["user-1", "user-2"],
|
||||
"user_user-1_avatar_avatarColor": "#ff0000",
|
||||
"user_user-2_avatar_avatarColor": "#cccccc",
|
||||
"user-1": {
|
||||
settings: {
|
||||
extra: "data",
|
||||
},
|
||||
extra: "data",
|
||||
},
|
||||
"user-2": {
|
||||
settings: {
|
||||
extra: "data",
|
||||
},
|
||||
extra: "data",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle missing parts", async () => {
|
||||
const output = await runMigrator(migrator, {
|
||||
authenticatedAccounts: ["user-1", "user-2"],
|
||||
global: {
|
||||
extra: "data",
|
||||
},
|
||||
"user-1": {
|
||||
extra: "data",
|
||||
settings: {
|
||||
extra: "data",
|
||||
},
|
||||
},
|
||||
"user-2": null,
|
||||
});
|
||||
|
||||
expect(output).toEqual({
|
||||
authenticatedAccounts: ["user-1", "user-2"],
|
||||
global: {
|
||||
extra: "data",
|
||||
},
|
||||
"user-1": {
|
||||
extra: "data",
|
||||
settings: {
|
||||
extra: "data",
|
||||
},
|
||||
},
|
||||
"user-2": null,
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: AvatarColorMigrator;
|
||||
|
||||
const keyDefinitionLike = {
|
||||
key: "avatarColor",
|
||||
stateDefinition: {
|
||||
name: "avatar",
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(rollbackJSON(), 37);
|
||||
sut = new AvatarColorMigrator(36, 37);
|
||||
});
|
||||
|
||||
it("should null out the avatarColor user StorageKey for each account", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledTimes(2);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, null);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith("user-2", keyDefinitionLike, null);
|
||||
});
|
||||
|
||||
it("should add the avatarColor property back to the account settings object", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(2);
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
settings: {
|
||||
avatarColor: "#ff0000",
|
||||
extra: "data",
|
||||
},
|
||||
extra: "data",
|
||||
});
|
||||
expect(helper.set).toHaveBeenCalledWith("user-2", {
|
||||
settings: {
|
||||
avatarColor: "#cccccc",
|
||||
extra: "data",
|
||||
},
|
||||
extra: "data",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { KeyDefinitionLike, MigrationHelper, StateDefinitionLike } from "../migration-helper";
|
||||
import { Migrator } from "../migrator";
|
||||
|
||||
type ExpectedAccountState = {
|
||||
settings?: { avatarColor?: string };
|
||||
};
|
||||
|
||||
const AVATAR_COLOR_STATE: StateDefinitionLike = { name: "avatar" };
|
||||
|
||||
const AVATAR_COLOR_KEY: KeyDefinitionLike = {
|
||||
key: "avatarColor",
|
||||
stateDefinition: AVATAR_COLOR_STATE,
|
||||
};
|
||||
|
||||
export class AvatarColorMigrator extends Migrator<36, 37> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const legacyAccounts = await helper.getAccounts<ExpectedAccountState>();
|
||||
|
||||
await Promise.all(
|
||||
legacyAccounts.map(async ({ userId, account }) => {
|
||||
// Move account avatarColor
|
||||
if (account?.settings?.avatarColor != null) {
|
||||
await helper.setToUser(userId, AVATAR_COLOR_KEY, account.settings.avatarColor);
|
||||
|
||||
// Delete old account avatarColor property
|
||||
delete account?.settings?.avatarColor;
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
async function rollbackUser(userId: string, account: ExpectedAccountState) {
|
||||
let updatedAccount = false;
|
||||
const userAvatarColor = await helper.getFromUser<string>(userId, AVATAR_COLOR_KEY);
|
||||
|
||||
if (userAvatarColor) {
|
||||
if (!account) {
|
||||
account = {};
|
||||
}
|
||||
|
||||
updatedAccount = true;
|
||||
account.settings.avatarColor = userAvatarColor;
|
||||
await helper.setToUser(userId, AVATAR_COLOR_KEY, null);
|
||||
}
|
||||
|
||||
if (updatedAccount) {
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
}
|
||||
|
||||
const accounts = await helper.getAccounts<ExpectedAccountState>();
|
||||
|
||||
await Promise.all(accounts.map(({ userId, account }) => rollbackUser(userId, account)));
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { OrganizationData } from "../../../admin-console/models/data/organizatio
|
||||
import { PolicyData } from "../../../admin-console/models/data/policy.data";
|
||||
import { ProviderData } from "../../../admin-console/models/data/provider.data";
|
||||
import { PolicyResponse } from "../../../admin-console/models/response/policy.response";
|
||||
import { AvatarService } from "../../../auth/abstractions/avatar.service";
|
||||
import { KeyConnectorService } from "../../../auth/abstractions/key-connector.service";
|
||||
import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason";
|
||||
import { DomainSettingsService } from "../../../autofill/services/domain-settings.service";
|
||||
@@ -59,6 +60,7 @@ export class SyncService implements SyncServiceAbstraction {
|
||||
private folderApiService: FolderApiServiceAbstraction,
|
||||
private organizationService: InternalOrganizationServiceAbstraction,
|
||||
private sendApiService: SendApiService,
|
||||
private avatarService: AvatarService,
|
||||
private logoutCallback: (expired: boolean) => Promise<void>,
|
||||
) {}
|
||||
|
||||
@@ -309,7 +311,7 @@ export class SyncService implements SyncServiceAbstraction {
|
||||
await this.cryptoService.setPrivateKey(response.privateKey);
|
||||
await this.cryptoService.setProviderKeys(response.providers);
|
||||
await this.cryptoService.setOrgKeys(response.organizations, response.providerOrganizations);
|
||||
await this.stateService.setAvatarColor(response.avatarColor);
|
||||
await this.avatarService.setAvatarColor(response.avatarColor);
|
||||
await this.stateService.setSecurityStamp(response.securityStamp);
|
||||
await this.stateService.setEmailVerified(response.emailVerified);
|
||||
await this.stateService.setHasPremiumPersonally(response.premiumPersonally);
|
||||
|
||||
Reference in New Issue
Block a user