Update selected item color in Pass menu
[ProtonMail-WebClient.git] / packages / pass / store / reducers / user.ts
blobdbacbeeed64e1db8d8404e7f59ac749015132efe
1 import type { Reducer } from 'redux';
3 import {
4     aliasSyncEnable,
5     aliasSyncPending,
6     aliasSyncStatus,
7     getUserAccessSuccess,
8     getUserFeaturesSuccess,
9     getUserSettings,
10     monitorToggle,
11     sentinelToggle,
12     userEvent,
13 } from '@proton/pass/store/actions';
14 import {
15     confirmPendingAuthDevice,
16     getAuthDevices,
17     rejectPendingAuthDevice,
18 } from '@proton/pass/store/actions/creators/sso';
19 import type {
20     BitField,
21     MaybeNull,
22     PassPlanResponse,
23     RequiredNonNull,
24     UserMonitorStatusResponse,
25 } from '@proton/pass/types';
26 import { EventActions } from '@proton/pass/types';
27 import type { PassFeature } from '@proton/pass/types/api/features';
28 import { or } from '@proton/pass/utils/fp/predicates';
29 import { objectDelete } from '@proton/pass/utils/object/delete';
30 import { merge, partialMerge } from '@proton/pass/utils/object/merge';
31 import isDeepEqual from '@proton/shared/lib/helpers/isDeepEqual';
32 import updateCollection from '@proton/shared/lib/helpers/updateCollection';
33 import type { Address, SETTINGS_PASSWORD_MODE, SETTINGS_STATUS, User } from '@proton/shared/lib/interfaces';
34 import { SETTINGS_PROTON_SENTINEL_STATE } from '@proton/shared/lib/interfaces';
35 import type { AuthDeviceOutput } from '@proton/shared/lib/keys/device';
37 export type AddressState = { [addressId: string]: Address };
38 export type FeatureFlagState = Partial<Record<PassFeature, boolean>>;
39 export type UserSettingsState = {
40     Email: { Status: SETTINGS_STATUS };
41     HighSecurity: {
42         Eligible: BitField;
43         Value: SETTINGS_PROTON_SENTINEL_STATE;
44     };
45     Locale?: string;
46     News: BitField;
47     Password: { Mode: SETTINGS_PASSWORD_MODE };
48     Telemetry: BitField;
51 export type UserData = {
52     defaultShareId: MaybeNull<string>;
53     aliasSyncEnabled: boolean;
54     /**
55      * When alias sync is disabled:
56      * - user/access: `pendingAliasToSync = 0` regardless of whether
57      *   any aliases can actually be synced
58      * - alias_status/sync: `pendingAliasToSync` reflects the number
59      *   of aliases that can potentially be synced by the client
60      */
61     pendingAliasToSync: number;
64 export type UserAccessState = {
65     plan: MaybeNull<PassPlanResponse>;
66     waitingNewUserInvites: number;
67     monitor: MaybeNull<UserMonitorStatusResponse>;
68     userData: UserData;
71 export type UserState = {
72     addresses: AddressState;
73     eventId: MaybeNull<string>;
74     features: MaybeNull<FeatureFlagState>;
75     user: MaybeNull<User>;
76     userSettings: MaybeNull<UserSettingsState>;
77     devices: AuthDeviceOutput[];
78 } & UserAccessState;
80 export type HydratedUserState = RequiredNonNull<UserState, Exclude<keyof UserState, 'organization' | 'monitor'>>;
81 export type HydratedAccessState = RequiredNonNull<UserAccessState, Exclude<keyof UserAccessState, 'monitor'>>;
83 const getInitialState = (): UserState => ({
84     addresses: {},
85     devices: [],
86     eventId: null,
87     features: null,
88     monitor: { ProtonAddress: true, Aliases: true },
89     plan: null,
90     user: null,
91     userData: { defaultShareId: null, aliasSyncEnabled: false, pendingAliasToSync: 0 },
92     userSettings: null,
93     waitingNewUserInvites: 0,
94 });
96 export const INITIAL_HIGHSECURITY_SETTINGS = {
97     Eligible: 0,
98     Value: SETTINGS_PROTON_SENTINEL_STATE.DISABLED,
101 const reducer: Reducer<UserState> = (state = getInitialState(), action) => {
102     if (userEvent.match(action)) {
103         if (action.payload.EventID === state.eventId) return state;
105         const { Addresses = [], User, EventID, UserSettings, AuthDevices } = action.payload;
106         const user = User ?? state.user;
107         const eventId = EventID ?? null;
109         const userSettings = UserSettings
110             ? merge(state.userSettings ?? {}, {
111                   Email: { Status: UserSettings.Email.Status },
112                   HighSecurity: UserSettings.HighSecurity,
113                   Locale: UserSettings.Locale,
114                   News: UserSettings.News,
115                   Password: { Mode: UserSettings.Password.Mode },
116                   Telemetry: UserSettings.Telemetry,
117               })
118             : state.userSettings;
120         const addresses = Addresses.reduce(
121             (acc, { Action, ID, Address }) =>
122                 Action === EventActions.DELETE ? objectDelete(acc, ID) : merge(acc, { [ID]: Address }),
123             state.addresses
124         );
126         const devices = AuthDevices
127             ? updateCollection({ model: state.devices, events: AuthDevices, itemKey: 'AuthDevice' })
128             : state.devices;
130         return {
131             ...state,
132             devices,
133             user,
134             eventId,
135             addresses,
136             userSettings,
137         };
138     }
140     if (getUserAccessSuccess.match(action)) {
141         const { plan, waitingNewUserInvites, monitor } = action.payload;
142         const userData = { ...action.payload.userData };
144         /** If the incoming `userData` does not have alias syncing enabled,
145          * preserve the `pendingAliasToSync` count from the current state.
146          * This accounts for a backend discrepancy where:
147          * - user/access endpoint always sets `pendingAliasToSync = 0` when
148          *   alias sync is disabled, regardless of actual sync status
149          * - alias_status/sync endpoint reflects the true number of aliases
150          *   that can potentially be synced by the client */
151         if (!userData.aliasSyncEnabled) userData.pendingAliasToSync = state.userData.pendingAliasToSync;
153         const didChange =
154             waitingNewUserInvites !== state.waitingNewUserInvites ||
155             !isDeepEqual(plan, state.plan) ||
156             !isDeepEqual(monitor, state.monitor) ||
157             !isDeepEqual(userData, state.userData);
159         /** Triggered on each popup wakeup: avoid unnecessary re-renders */
160         return didChange ? partialMerge(state, { plan, waitingNewUserInvites, userData }) : state;
161     }
163     if (getUserSettings.success.match(action)) {
164         const settings = action.payload;
165         return partialMerge(state, {
166             userSettings: {
167                 Email: { Status: settings.Email.Status },
168                 HighSecurity: settings.HighSecurity,
169                 Locale: settings.Locale,
170                 News: settings.News,
171                 Password: { Mode: settings.Password.Mode },
172                 Telemetry: settings.Telemetry,
173             },
174         });
175     }
177     if (getUserFeaturesSuccess.match(action)) {
178         const next: UserState = { ...state, features: null }; /* wipe all features before merge */
179         return partialMerge(next, { features: action.payload });
180     }
182     if (sentinelToggle.success.match(action)) {
183         return partialMerge(state, { userSettings: { HighSecurity: { Value: action.payload.value } } });
184     }
186     if (monitorToggle.success.match(action)) {
187         const { ProtonAddress, Aliases } = action.payload;
188         return partialMerge(state, { monitor: { ProtonAddress: ProtonAddress ?? false, Aliases: Aliases ?? false } });
189     }
191     if (aliasSyncEnable.success.match(action)) {
192         const { shareId } = action.payload;
193         return partialMerge(state, { userData: { aliasSyncEnabled: true, defaultShareId: shareId } });
194     }
196     if (aliasSyncStatus.success.match(action)) {
197         const { PendingAliasCount, Enabled } = action.payload;
198         return partialMerge(state, { userData: { pendingAliasToSync: PendingAliasCount, aliasSyncEnabled: Enabled } });
199     }
201     if (aliasSyncPending.success.match(action)) {
202         /** optimistically update the pending alias count on sync success */
203         const pendingAliasToSync = Math.max(0, state.userData.pendingAliasToSync - action.payload.items.length);
204         return partialMerge(state, { userData: { pendingAliasToSync } });
205     }
207     if (getAuthDevices.success.match(action)) {
208         return partialMerge(state, { devices: action.payload });
209     }
211     if (or(confirmPendingAuthDevice.success.match, rejectPendingAuthDevice.success.match)(action)) {
212         return partialMerge(state, { devices: state.devices.filter(({ ID }) => ID !== action.payload) });
213     }
215     return state;
218 export default reducer;