1 import type { Reducer } from 'redux';
8 getUserFeaturesSuccess,
13 } from '@proton/pass/store/actions';
15 confirmPendingAuthDevice,
17 rejectPendingAuthDevice,
18 } from '@proton/pass/store/actions/creators/sso';
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 };
43 Value: SETTINGS_PROTON_SENTINEL_STATE;
47 Password: { Mode: SETTINGS_PASSWORD_MODE };
51 export type UserData = {
52 defaultShareId: MaybeNull<string>;
53 aliasSyncEnabled: boolean;
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
61 pendingAliasToSync: number;
64 export type UserAccessState = {
65 plan: MaybeNull<PassPlanResponse>;
66 waitingNewUserInvites: number;
67 monitor: MaybeNull<UserMonitorStatusResponse>;
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[];
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 => ({
88 monitor: { ProtonAddress: true, Aliases: true },
91 userData: { defaultShareId: null, aliasSyncEnabled: false, pendingAliasToSync: 0 },
93 waitingNewUserInvites: 0,
96 export const INITIAL_HIGHSECURITY_SETTINGS = {
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,
118 : state.userSettings;
120 const addresses = Addresses.reduce(
121 (acc, { Action, ID, Address }) =>
122 Action === EventActions.DELETE ? objectDelete(acc, ID) : merge(acc, { [ID]: Address }),
126 const devices = AuthDevices
127 ? updateCollection({ model: state.devices, events: AuthDevices, itemKey: 'AuthDevice' })
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;
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;
163 if (getUserSettings.success.match(action)) {
164 const settings = action.payload;
165 return partialMerge(state, {
167 Email: { Status: settings.Email.Status },
168 HighSecurity: settings.HighSecurity,
169 Locale: settings.Locale,
171 Password: { Mode: settings.Password.Mode },
172 Telemetry: settings.Telemetry,
177 if (getUserFeaturesSuccess.match(action)) {
178 const next: UserState = { ...state, features: null }; /* wipe all features before merge */
179 return partialMerge(next, { features: action.payload });
182 if (sentinelToggle.success.match(action)) {
183 return partialMerge(state, { userSettings: { HighSecurity: { Value: action.payload.value } } });
186 if (monitorToggle.success.match(action)) {
187 const { ProtonAddress, Aliases } = action.payload;
188 return partialMerge(state, { monitor: { ProtonAddress: ProtonAddress ?? false, Aliases: Aliases ?? false } });
191 if (aliasSyncEnable.success.match(action)) {
192 const { shareId } = action.payload;
193 return partialMerge(state, { userData: { aliasSyncEnabled: true, defaultShareId: shareId } });
196 if (aliasSyncStatus.success.match(action)) {
197 const { PendingAliasCount, Enabled } = action.payload;
198 return partialMerge(state, { userData: { pendingAliasToSync: PendingAliasCount, aliasSyncEnabled: Enabled } });
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 } });
207 if (getAuthDevices.success.match(action)) {
208 return partialMerge(state, { devices: action.payload });
211 if (or(confirmPendingAuthDevice.success.match, rejectPendingAuthDevice.success.match)(action)) {
212 return partialMerge(state, { devices: state.devices.filter(({ ID }) => ID !== action.payload) });
218 export default reducer;