diff --git a/packages/storefront/src/lib/state/customer-session.ts b/packages/storefront/src/lib/state/customer-session.ts index f1b43cdd1..879ad456b 100644 --- a/packages/storefront/src/lib/state/customer-session.ts +++ b/packages/storefront/src/lib/state/customer-session.ts @@ -5,25 +5,44 @@ import { nickname as getNickname } from '@ecomplus/utils'; import { ref, computed, watch } from 'vue'; import { requestIdleCallback } from '@@sf/sf-lib'; import useStorage from '@@sf/state/use-storage'; +import { + hasCensoredFields, + withoutCensoredFields, +} from '@@sf/state/customer-session/censored-session'; export const EMAIL_STORAGE_KEY = 'emailForSignIn'; const storageKey = 'ecomSession'; -const emptySession = { +type SessionAuth = null | { + access_token: string, + expires: string, + customer_id: Customers['_id'], +}; +/* Always a fresh object: `useStorage` may keep the initial value as the live +reactive state, so a shared constant would be mutated by session writes and +turn the `logout()` reset into a self-assignment no-op. */ +const getEmptySession = () => ({ customer: { display_name: '', main_email: '', - }, - auth: null, -}; + } as Partial, + auth: null as SessionAuth, +}); const session = useStorage<{ customer: Partial, - auth: null | { - access_token: string, - expires: string, - customer_id: Customers['_id'], - }, -}>(storageKey, emptySession); + auth: SessionAuth, +}>(storageKey, getEmptySession()); + +if (!import.meta.env.SSR && hasCensoredFields(session.customer)) { + /* Keep any still-valid cached fields, but drop `doc_number` so `fetchCustomer` runs + again once the customer is authenticated. Runs once per contaminated session: the + censored phones and addresses are gone afterwards, so the check turns false. */ + const cleanCustomer = withoutCensoredFields(session.customer); + delete cleanCustomer.doc_number; + cleanCustomer.display_name = cleanCustomer.display_name || ''; + cleanCustomer.main_email = cleanCustomer.main_email || ''; + session.customer = cleanCustomer; +} const isAuthenticated = computed(() => { const { auth } = session; @@ -85,8 +104,14 @@ const fetchCustomer = async () => { const { data } = await api.get(`customers/${auth.customer_id}`, { accessToken, }); - session.customer = data; - return data; + /* Saved profiles may still hold censored fields persisted by old checkouts, + they must not be cached back as valid values. */ + const cleanCustomer = withoutCensoredFields(data); + // Consumers assume these keys always set on `session.customer` + cleanCustomer.display_name = cleanCustomer.display_name || ''; + cleanCustomer.main_email = cleanCustomer.main_email || ''; + session.customer = cleanCustomer; + return session.customer; }; const isAuthReady = ref(false); @@ -119,12 +144,17 @@ const initializeFirebaseAuth = (canWaitIdle?: boolean) => { session.customer.main_email = user.email; } if (user.emailVerified) { - const isEmailChanged = user.email !== customerEmail.value; - if (isEmailChanged || !isAuthenticated.value) { - await authenticate(); - } - if (isEmailChanged || !session.customer.doc_number) { - await fetchCustomer(); + try { + const isEmailChanged = user.email !== customerEmail.value; + if (isEmailChanged || !isAuthenticated.value) { + await authenticate(); + } + if (isEmailChanged || !session.customer.doc_number) { + await fetchCustomer(); + } + } catch (err) { + // `isAuthReady` must be set anyway to unlock watching consumers + console.error(err); } } } @@ -158,6 +188,7 @@ const logout = () => { return; } firebaseAuth.signOut().then(() => { + const emptySession = getEmptySession(); session.auth = emptySession.auth; session.customer = emptySession.customer; localStorage.removeItem(storageKey); diff --git a/packages/storefront/src/lib/state/customer-session/censored-session.ts b/packages/storefront/src/lib/state/customer-session/censored-session.ts new file mode 100644 index 000000000..ef6a2ebfa --- /dev/null +++ b/packages/storefront/src/lib/state/customer-session/censored-session.ts @@ -0,0 +1,39 @@ +import type { Customers } from '@cloudcommerce/api/types'; + +/* + * Profiles identified by e-mail and document only (without verified login) used to be + * returned with censored fields: '***' surname, '000XX' phone, address holding just zip + * and province. Sessions persisted back then are still cached on customer browsers and + * get submitted on checkout, where they overwrite the saved profile and produce an + * incomplete shipping address that payment gateways reject. + */ +const CENSORED = '***'; + +type CustomerAddress = NonNullable[number]; + +// Predicates must match server side checks on `@cloudcommerce/modules` checkout.ts +const isCensoredPhone = (number?: string) => !!number && /^0{3,}\d{1,4}$/.test(number); +const isCensoredAddress = ({ name, line_address: lineAddress }: CustomerAddress) => { + return !!name?.includes(CENSORED) || !!lineAddress?.includes(CENSORED); +}; + +/* Only fields `withoutCensoredFields` actually removes may be tested here: `name` is +deliberately kept, so testing it would keep this `true` forever on a profile censored +server side, dropping `doc_number` and refetching the customer on every page load. */ +export const hasCensoredFields = ({ phones, addresses }: Partial) => { + if (phones?.some(({ number }) => isCensoredPhone(number))) return true; + return !!addresses?.some(isCensoredAddress); +}; + +export const withoutCensoredFields = (customerData: Partial) => { + const { phones, addresses, ...safeCustomer } = customerData; + const cleanCustomer: Partial = safeCustomer; + /* `name` is intentionally kept even when `family_name` is censored: it's required + on `@checkout` contract, `given_name` is preserved valid by the mask, and the + '***' marker triggers server side restore from the saved profile (checkout.ts). */ + const cleanPhones = phones?.filter(({ number }) => !isCensoredPhone(number)); + if (cleanPhones?.length) cleanCustomer.phones = cleanPhones; + const cleanAddresses = addresses?.filter((address) => !isCensoredAddress(address)); + if (cleanAddresses?.length) cleanCustomer.addresses = cleanAddresses; + return cleanCustomer; +};