1 import type { SharedStartListening } from '@proton/redux-shared-store/listenerInterface';
2 import { TelemetryAccountSecurityCheckupEvents, TelemetryMeasurementGroups } from '@proton/shared/lib/api/telemetry';
3 import { SECURITY_CHECKUP_PATHS } from '@proton/shared/lib/constants';
4 import { sendTelemetryReport } from '@proton/shared/lib/helpers/metrics';
6 getIsPerfectDeviceRecoveryState,
7 getIsPerfectEmailState,
8 getIsPerfectPhoneState,
9 getIsPerfectPhraseState,
10 } from '@proton/shared/lib/helpers/securityCheckup';
11 import { MNEMONIC_STATUS, SETTINGS_STATUS } from '@proton/shared/lib/interfaces';
12 import SecurityCheckupCohort from '@proton/shared/lib/interfaces/securityCheckup/SecurityCheckupCohort';
13 import type SecurityState from '@proton/shared/lib/interfaces/securityCheckup/SecurityState';
14 import { getIsMnemonicAvailable } from '@proton/shared/lib/mnemonic';
15 import { getIsRecoveryFileAvailable } from '@proton/shared/lib/recoveryFile/recoveryFile';
17 import type { AddressesState } from '../addresses';
18 import { selectAddresses } from '../addresses';
19 import type { UserState } from '../user';
20 import { selectUser } from '../user';
21 import type { UserKeysState } from '../userKeys';
22 import { selectUserKeys } from '../userKeys';
23 import type { UserSettingsState } from '../userSettings';
24 import { selectUserSettings } from '../userSettings';
25 import getSource from './helpers/getSource';
26 import getValidSecurityCheckupSession from './helpers/getValidSecurityCheckupSession';
28 removeSecurityCheckupSessionItem,
29 setSecurityCheckupSessionItem,
30 } from './helpers/securityCheckupSessionStorage';
31 import type { SecurityCheckupReduxState } from './slice';
32 import { securityCheckupSlice, selectSecurityCheckup } from './slice';
34 interface RequiredState
35 extends UserSettingsState,
39 SecurityCheckupReduxState {}
41 export const securityCheckupListener = (startListening: SharedStartListening<RequiredState>) => {
43 * Calculate security state
46 predicate: (action, currentState, previousState) => {
47 const previousUser = selectUser(previousState);
48 const currentUser = selectUser(currentState);
50 const previousUserKeys = selectUserKeys(previousState);
51 const currentUserKeys = selectUserKeys(currentState);
53 const previousAddresses = selectAddresses(previousState).value;
54 const currentAddresses = selectAddresses(currentState).value;
56 const previousUserSettings = selectUserSettings(previousState);
57 const currentUserSettings = selectUserSettings(currentState);
60 currentUser !== previousUser ||
61 currentUserKeys !== previousUserKeys ||
62 currentAddresses !== previousAddresses ||
63 currentUserSettings !== previousUserSettings
66 effect: async (action, listenerApi) => {
73 const { user, userKeys, addresses, userSettings, securityCheckup } = getState();
74 const { actions } = securityCheckupSlice;
76 if (!user.value || !userKeys.value || !addresses.value || !userSettings.value) {
77 if (!securityCheckup.loading) {
78 dispatch(actions.setLoading({ loading: true }));
83 const isMnemonicAvailable = getIsMnemonicAvailable({
84 addresses: addresses.value,
88 const isRecoveryFileAvailable = getIsRecoveryFileAvailable({
90 addresses: addresses.value,
91 userKeys: userKeys.value,
92 appName: config.APP_NAME,
95 const securityState: SecurityState = {
97 isAvailable: isMnemonicAvailable,
98 isSet: user.value.MnemonicStatus === MNEMONIC_STATUS.SET,
99 isOutdated: user.value.MnemonicStatus === MNEMONIC_STATUS.OUTDATED,
102 value: userSettings.value.Email.Value,
103 isEnabled: !!userSettings.value.Email.Reset,
104 verified: userSettings.value.Email.Status === SETTINGS_STATUS.VERIFIED,
107 value: userSettings.value.Phone.Value,
108 isEnabled: !!userSettings.value.Phone.Reset,
109 verified: userSettings.value.Phone.Status === SETTINGS_STATUS.VERIFIED,
112 isAvailable: isRecoveryFileAvailable,
113 isEnabled: !!userSettings.value.DeviceRecovery,
117 dispatch(actions.setSecurityState({ securityState }));
119 if (securityCheckup.loading) {
120 dispatch(actions.setLoading({ loading: false }));
126 * Persist session in session storage on session change
129 predicate: (action, currentState, previousState) => {
130 const previousSecurityCheckup = selectSecurityCheckup(previousState);
131 const currentSecurityCheckup = selectSecurityCheckup(currentState);
133 return previousSecurityCheckup.session !== currentSecurityCheckup.session;
135 effect: async (action, listenerApi) => {
136 const { getState } = listenerApi;
137 const { user, securityCheckup } = getState();
139 if (!user.value || !securityCheckup.session) {
143 setSecurityCheckupSessionItem(securityCheckup.session, user.value.ID);
148 * Remove security checkup session from session storage on clear dispatch
151 predicate: (action) => {
152 return securityCheckupSlice.actions.clearSession.match(action);
154 effect: async (action, listenerApi) => {
155 const { getState } = listenerApi;
156 const { user } = getState();
162 removeSecurityCheckupSessionItem(user.value.ID);
167 * Send telemetry on cohort transition
170 predicate: (action, currentState, previousState) => {
171 const previousSecurityCheckup = selectSecurityCheckup(previousState);
172 const currentSecurityCheckup = selectSecurityCheckup(currentState);
175 previousSecurityCheckup.cohort !== undefined &&
176 currentSecurityCheckup.cohort !== undefined &&
177 previousSecurityCheckup.cohort !== currentSecurityCheckup.cohort
180 effect: async (action, listenerApi) => {
181 // Only send telemetry if we are in the security checkup
182 // The Cohort can change outside the security checkup. Ie enabling the recovery phrase on the recovery page
183 if (!listenerApi.extra.history.location.pathname.includes(SECURITY_CHECKUP_PATHS.ROOT)) {
187 const { user, securityCheckup } = listenerApi.getState();
193 const { cohort, session, securityState } = securityCheckup;
194 if (!cohort || cohort === SecurityCheckupCohort.NO_RECOVERY_METHOD) {
195 // No change should have occurred
199 const securityCheckupSession = getValidSecurityCheckupSession({
200 currentSession: session,
201 currentCohort: cohort,
204 const isPerfectPhraseState = getIsPerfectPhraseState(securityState);
205 const isPerfectEmailState = getIsPerfectEmailState(securityState);
206 const isPerfectPhoneState = getIsPerfectPhoneState(securityState);
207 const isPerfectDeviceState = getIsPerfectDeviceRecoveryState(securityState);
208 const singleMethod = (() => {
209 if (isPerfectPhraseState) {
213 if (isPerfectEmailState && isPerfectDeviceState) {
217 if (isPerfectPhoneState && isPerfectDeviceState) {
227 }: { event?: TelemetryAccountSecurityCheckupEvents; dimensions?: Record<string, string> } = (() => {
228 if (cohort === SecurityCheckupCohort.COMPLETE_RECOVERY_MULTIPLE) {
229 return { event: TelemetryAccountSecurityCheckupEvents.completeRecoveryMultiple };
232 if (cohort === SecurityCheckupCohort.COMPLETE_RECOVERY_SINGLE) {
234 event: TelemetryAccountSecurityCheckupEvents.completeRecoverySingle,
241 if (cohort === SecurityCheckupCohort.ACCOUNT_RECOVERY_ENABLED) {
242 return { event: TelemetryAccountSecurityCheckupEvents.accountRecoveryEnabled };
245 return { event: undefined };
252 void sendTelemetryReport({
253 api: listenerApi.extra.api,
254 measurementGroup: TelemetryMeasurementGroups.accountSecurityCheckup,
257 initialCohort: securityCheckupSession.initialCohort,
265 * Get touchpoint source
268 predicate: (_, currentState) => {
269 const currentSecurityCheckup = selectSecurityCheckup(currentState);
271 return !currentSecurityCheckup.source;
273 effect: async (action, listenerApi) => {
274 // Only calculate source if we are in the security checkup
275 if (!listenerApi.extra.history.location.pathname.includes(SECURITY_CHECKUP_PATHS.ROOT)) {
279 const { pathname, search } = listenerApi.extra.history.location;
280 const source = getSource({ pathname, search: new URLSearchParams(search) });
285 listenerApi.dispatch(securityCheckupSlice.actions.setSource({ source }));