Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 49 additions & 18 deletions packages/storefront/src/lib/state/customer-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Customers>,
auth: null as SessionAuth,
});
const session = useStorage<{
customer: Partial<Customers>,
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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
}
}
Expand Down Expand Up @@ -158,6 +188,7 @@ const logout = () => {
return;
}
firebaseAuth.signOut().then(() => {
const emptySession = getEmptySession();
session.auth = emptySession.auth;
session.customer = emptySession.customer;
localStorage.removeItem(storageKey);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Customers['addresses']>[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<Customers>) => {
if (phones?.some(({ number }) => isCensoredPhone(number))) return true;
return !!addresses?.some(isCensoredAddress);
};

export const withoutCensoredFields = (customerData: Partial<Customers>) => {
const { phones, addresses, ...safeCustomer } = customerData;
const cleanCustomer: Partial<Customers> = 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;
};