From 6df5ef1d10c9e883699b251e16c8a42edacde661 Mon Sep 17 00:00:00 2001 From: ikusteu Date: Wed, 17 Jan 2024 11:34:41 +0100 Subject: [PATCH 1/7] * Update react-redux-firebase-firestore to subscribe to multiple bookings account (if managed by the same auth user) * Update app store logic to accommodate for (possibly) multiple bookings customer accounts * Update AthleteAvatar (it's dropdown menu) to pull other accounts (if available) * TODO: Test ^^ * Update '/customer_area' page to handle (possibly) multiple athlete accounts --- .../atoms/AddToCalendar/AddToCalendar.tsx | 2 +- .../client/src/controllers/AthleteAvatar.tsx | 3 ++ .../BookingsCountdownContainer.tsx | 4 +- .../PrivacyPolicyToast/PrivacyPolicyToast.tsx | 2 +- .../client/src/pages/customer_area/index.tsx | 51 ++++++++++++++++--- .../src/pages/customer_area/views/Book.tsx | 3 +- .../src/pages/customer_area/views/Profile.tsx | 3 +- .../store/actions/icsCalendarOperations.ts | 2 +- .../src/store/selectors/bookings/countdown.ts | 5 +- .../src/store/selectors/bookings/customer.ts | 39 ++++++++------ .../src/store/selectors/bookings/slots.ts | 8 +-- .../src/types.ts | 7 ++- .../src/utils/utils.ts | 4 +- .../AthleteAvatarMenu/AthleteAvatarMenu.tsx | 8 +-- 14 files changed, 98 insertions(+), 43 deletions(-) diff --git a/packages/client/src/components/atoms/AddToCalendar/AddToCalendar.tsx b/packages/client/src/components/atoms/AddToCalendar/AddToCalendar.tsx index a92301443..10baf88fe 100644 --- a/packages/client/src/components/atoms/AddToCalendar/AddToCalendar.tsx +++ b/packages/client/src/components/atoms/AddToCalendar/AddToCalendar.tsx @@ -25,7 +25,7 @@ const AddToCalendar: React.FC = () => { const { t } = useTranslation(); - const { email } = useSelector(getBookingsCustomer) || {}; + const { email } = useSelector(getBookingsCustomer(secretKey)) || {}; const hasBookingsForCalendar = useSelector(getHasBookingsForCalendar); const { open: openModal } = useSendICSModal({ secretKey, email }); diff --git a/packages/client/src/controllers/AthleteAvatar.tsx b/packages/client/src/controllers/AthleteAvatar.tsx index cbc2e9ea1..d657121fc 100644 --- a/packages/client/src/controllers/AthleteAvatar.tsx +++ b/packages/client/src/controllers/AthleteAvatar.tsx @@ -20,6 +20,9 @@ const AthletAvatatController: React.FC = ({ content={ + history.push(`${Routes.CustomerArea}/${secretKey}`) + } onLogout={async () => { await signOut(getAuth()); history.push(Routes.Login); diff --git a/packages/client/src/controllers/BookingsCountdown/BookingsCountdownContainer.tsx b/packages/client/src/controllers/BookingsCountdown/BookingsCountdownContainer.tsx index f49a1c631..3cec67009 100644 --- a/packages/client/src/controllers/BookingsCountdown/BookingsCountdownContainer.tsx +++ b/packages/client/src/controllers/BookingsCountdown/BookingsCountdownContainer.tsx @@ -23,11 +23,11 @@ interface Props extends React.HTMLAttributes { * state to the store (for bookings finalisation). */ const BookingsCountdownContainer: React.FC = (props) => { + const secretKey = useSelector(getSecretKey)!; const currentDate = useSelector(getCalendarDay); const countdownProps = useSelector(getCountdownProps); const isMonthEmpty = useSelector(getMonthEmptyForBooking); - const { id: customerId } = useSelector(getBookingsCustomer) || {}; - const secretKey = useSelector(getSecretKey)!; + const { id: customerId } = useSelector(getBookingsCustomer(secretKey)) || {}; const { openWithProps: openFinalizeBookingsDialog } = useFinalizeBooksingsModal(); diff --git a/packages/client/src/controllers/PrivacyPolicyToast/PrivacyPolicyToast.tsx b/packages/client/src/controllers/PrivacyPolicyToast/PrivacyPolicyToast.tsx index 696bf1f00..42f4b8a52 100644 --- a/packages/client/src/controllers/PrivacyPolicyToast/PrivacyPolicyToast.tsx +++ b/packages/client/src/controllers/PrivacyPolicyToast/PrivacyPolicyToast.tsx @@ -17,8 +17,8 @@ const PrivacyPolicyToast: React.FC = () => { const policyParams = useSelector(getPrivacyPolicy); - const bookingsCustomer = useSelector(getBookingsCustomer) || {}; const secretKey = useSelector(getSecretKey) || ""; + const bookingsCustomer = useSelector(getBookingsCustomer(secretKey)) || {}; const policyAccepted = Boolean(bookingsCustomer.privacyPolicyAccepted); const isAdmin = useSelector(getIsAdmin); diff --git a/packages/client/src/pages/customer_area/index.tsx b/packages/client/src/pages/customer_area/index.tsx index e22587ad1..256403f97 100644 --- a/packages/client/src/pages/customer_area/index.tsx +++ b/packages/client/src/pages/customer_area/index.tsx @@ -11,7 +11,10 @@ import { OrgSubCollection, } from "@eisbuk/shared"; import { Routes } from "@eisbuk/shared/ui"; -import { useFirestoreSubscribe } from "@eisbuk/react-redux-firebase-firestore"; +import { + useFirestoreSubscribe, + useUpdateSubscription, +} from "@eisbuk/react-redux-firebase-firestore"; import { getOrganization } from "@/lib/getters"; @@ -20,14 +23,17 @@ import CalendarView from "./views/Calendar"; import ProfileView from "./views/Profile"; import { useSecretKey, useDate } from "./hooks"; +import Layout from "@/controllers/Layout"; import PrivacyPolicyToast from "@/controllers/PrivacyPolicyToast"; import AthleteAvatar from "@/controllers/AthleteAvatar"; import ErrorBoundary from "@/components/atoms/ErrorBoundary"; -import Layout from "@/controllers/Layout"; - -import { getBookingsCustomer } from "@/store/selectors/bookings"; +import { + getBookingsCustomer, + getOtherBookingsAccounts, +} from "@/store/selectors/bookings"; +import { getAllSecretKeys } from "@/store/selectors/auth"; enum Views { Book = "BookView", @@ -48,21 +54,45 @@ const viewsLookup = { const CustomerArea: React.FC = () => { const secretKey = useSecretKey(); + // We're providing a fallback [secretKey] as we have multiple ways of authenticating. If authenticating + // using firebase auth, the user will have all of their secret keys in the store (this is the preferred way). + // However, user can simply use a booking link (which includes the secret key). For this method, the user doesn't have + // to authenticate with firebase auth, no secret keys will be found in auth section of the store and 'getAllSecretKeys' selector + // will return 'undefined' + const secretKeys = useSelector(getAllSecretKeys) || [secretKey]; + // Subscribe to necessary collections useFirestoreSubscribe(getOrganization(), [ { collection: OrgSubCollection.SlotsByDay }, { collection: OrgSubCollection.SlotBookingsCounts }, { collection: Collection.PublicOrgInfo }, - { collection: OrgSubCollection.Bookings, meta: { secretKey } }, + { + collection: OrgSubCollection.Bookings, + meta: { secretKeys }, + }, { collection: BookingSubCollection.BookedSlots, meta: { secretKey } }, { collection: BookingSubCollection.AttendedSlots, meta: { secretKey } }, { collection: BookingSubCollection.Calendar, meta: { secretKey } }, ]); + useUpdateSubscription( + { collection: OrgSubCollection.Bookings, meta: { secretKeys } }, + [secretKeys] + ); + useUpdateSubscription( + { collection: BookingSubCollection.BookedSlots, meta: { secretKey } }, + [secretKey] + ); + useUpdateSubscription( + { collection: BookingSubCollection.AttendedSlots, meta: { secretKey } }, + [secretKey] + ); + const calendarNavProps = useDate(); // Get customer data necessary for rendering/functionality - const userData = useSelector(getBookingsCustomer) || {}; + const currentAthlete = useSelector(getBookingsCustomer(secretKey)) || {}; + const otherAccounts = useSelector(getOtherBookingsAccounts(secretKey)); const [view, setView] = useState(Views.Book); const CustomerView = viewsLookup[view]; @@ -93,14 +123,19 @@ const CustomerArea: React.FC = () => { ); - if (secretKey && userData.deleted) { + if (secretKey && currentAthlete.deleted) { return ; } return ( } + userAvatar={ + + } > {view !== "ProfileView" && ( { const { t } = useTranslation(); - const customer = useSelector(getBookingsCustomer); + const secretKey = useSelector(getSecretKey) || ""; + const customer = useSelector(getBookingsCustomer(secretKey)); const orgEmail = useSelector(getOrgEmail); const daysToRender = useSelector(getSlotsForBooking); const date = useSelector(getCalendarDay); diff --git a/packages/client/src/pages/customer_area/views/Profile.tsx b/packages/client/src/pages/customer_area/views/Profile.tsx index ab7e4a9f3..7793a04d0 100644 --- a/packages/client/src/pages/customer_area/views/Profile.tsx +++ b/packages/client/src/pages/customer_area/views/Profile.tsx @@ -16,7 +16,8 @@ const CalendarView: React.FC = () => { const secretKey = useSelector(getSecretKey)!; const defaultCountryCode = useSelector(getDefaultCountryCode); - const customer = useSelector(getBookingsCustomer) || ({} as Customer); + const customer = + useSelector(getBookingsCustomer(secretKey)) || ({} as Customer); return ( { - const { extendedDate } = getBookingsCustomer(state) || {}; + const secretKey = getSecretKey(state) || ""; + const { extendedDate } = getBookingsCustomer(secretKey)(state) || {}; // check if extended date exists if (!extendedDate) return undefined; diff --git a/packages/client/src/store/selectors/bookings/customer.ts b/packages/client/src/store/selectors/bookings/customer.ts index 4ca8ce833..11934dfee 100644 --- a/packages/client/src/store/selectors/bookings/customer.ts +++ b/packages/client/src/store/selectors/bookings/customer.ts @@ -3,21 +3,28 @@ import { Customer } from "@eisbuk/shared"; import { LocalStore } from "@/types/store"; /** - * Get customer info for bookings from local store - * @param state Local Redux Store - * @returns customer data (Bookings meta) + * Get bookings customer (for the provided secretKey) from store. + * Since we support multiple accounts for single auth, there might be mutilple bookings + * customers in store, hence this HOF (accepting secretKey) and returning a selector. */ -export const getBookingsCustomer = (state: LocalStore): Customer => { - // get extended date (if any) - const bookingsInStore = Object.values(state.firestore?.data.bookings || {}); - if (bookingsInStore.length > 1) { - /** @TODO */ - // this shouldn't happen in production and we're working on a way to fix it completely - // this is just a reporting feature in case it happens - console.error( - "There seem to be multiple entries in 'firestore.data.bookings' part of the local store" - ); - } +export const getBookingsCustomer = + (secretKey: string) => + (state: LocalStore): Customer => + (state.firestore?.data.bookings || {})[secretKey]; + +/** + * Returns all bookings customers currently present in store. + */ +export const getAllBookingsAccounts = (state: LocalStore): Customer[] => + Object.values(state.firestore.data.bookings || {}); - return bookingsInStore[0]; -}; +/** + * Returns all bookings customers currently present in store, except for the "current" one (matched by passed secret key). + * @param currentSecretKey + */ +export const getOtherBookingsAccounts = + (currentSecretKey: string) => + (state: LocalStore): Customer[] => + getAllBookingsAccounts(state).filter( + ({ secretKey }) => secretKey !== currentSecretKey + ); diff --git a/packages/client/src/store/selectors/bookings/slots.ts b/packages/client/src/store/selectors/bookings/slots.ts index 6b8d6a075..f94dbe4e2 100644 --- a/packages/client/src/store/selectors/bookings/slots.ts +++ b/packages/client/src/store/selectors/bookings/slots.ts @@ -11,7 +11,7 @@ import { import { LocalStore } from "@/types/store"; -import { getCalendarDay } from "@/store/selectors/app"; +import { getCalendarDay, getSecretKey } from "@/store/selectors/app"; import { getBookingsCustomer } from "./customer"; import { isEmpty } from "@/utils/helpers"; @@ -41,9 +41,10 @@ type SlotsForBooking = { }[]; export const getSlotsForBooking = (state: LocalStore): SlotsForBooking => { + const secretKey = getSecretKey(state) || ""; const slotsMonth = getSlotsForCustomer(state); const bookedSlots = getBookedSlots(state); - const customerData = getBookingsCustomer(state); + const customerData = getBookingsCustomer(secretKey)(state); // Sort dates so that the final output is sorted const daysToRender = Object.keys(slotsMonth).sort((a, b) => (a < b ? -1 : 1)); @@ -74,8 +75,9 @@ export const getSlotsForBooking = (state: LocalStore): SlotsForBooking => { * Both the `category` and `date` are read directly from store. Slots booked at full capacity are filtered out. */ export const getSlotsForCustomer = (state: LocalStore): SlotsByDay => { + const secretKey = getSecretKey(state) || ""; const date = getCalendarDay(state); - const categories = getBookingsCustomer(state)?.categories; + const categories = getBookingsCustomer(secretKey)(state)?.categories; // Return early if no category found in store if (!categories) { diff --git a/packages/react-redux-firebase-firestore/src/types.ts b/packages/react-redux-firebase-firestore/src/types.ts index 6fdfa5ed3..15659a4cd 100644 --- a/packages/react-redux-firebase-firestore/src/types.ts +++ b/packages/react-redux-firebase-firestore/src/types.ts @@ -34,6 +34,7 @@ type GetState = () => GlobalStateFragment; export interface SubscriptionMeta { organization: string; secretKey?: string; + secretKeys?: string[]; currentDate: DateTime; } @@ -138,7 +139,11 @@ export type CollectionSubscription = meta?: Record; } | { - collection: OrgSubCollection.Bookings | BookingSubCollection; + collection: OrgSubCollection.Bookings; + meta: { secretKeys: string[] }; + } + | { + collection: BookingSubCollection; meta: { secretKey: string }; }; diff --git a/packages/react-redux-firebase-firestore/src/utils/utils.ts b/packages/react-redux-firebase-firestore/src/utils/utils.ts index df31eff68..b763e6604 100644 --- a/packages/react-redux-firebase-firestore/src/utils/utils.ts +++ b/packages/react-redux-firebase-firestore/src/utils/utils.ts @@ -12,7 +12,7 @@ export const getConstraintForColl = ( collection: SubscriptionWhitelist, meta: SubscriptionMeta ): FirestoreListenerConstraint | null => { - const { organization, secretKey = "", currentDate } = meta; + const { organization, secretKeys = [], currentDate } = meta; // create date range constraint const startDateISO = currentDate @@ -38,7 +38,7 @@ export const getConstraintForColl = ( [Collection.Organizations]: { documents: [organization] }, [Collection.PublicOrgInfo]: { documents: [organization] }, [OrgSubCollection.Attendance]: { range }, - [OrgSubCollection.Bookings]: { documents: [secretKey] }, + [OrgSubCollection.Bookings]: { documents: secretKeys }, [OrgSubCollection.SlotsByDay]: { documents }, [OrgSubCollection.SlotBookingsCounts]: { documents }, [OrgSubCollection.Customers]: null, diff --git a/packages/ui/src/AthleteAvatarMenu/AthleteAvatarMenu.tsx b/packages/ui/src/AthleteAvatarMenu/AthleteAvatarMenu.tsx index c26e3f465..4dc57e87f 100644 --- a/packages/ui/src/AthleteAvatarMenu/AthleteAvatarMenu.tsx +++ b/packages/ui/src/AthleteAvatarMenu/AthleteAvatarMenu.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Customer } from "@eisbuk/shared"; -import { /* Plus, */ PowerCircle } from "@eisbuk/svg"; +import { Plus, PowerCircle } from "@eisbuk/svg"; import { shortName } from "../utils/helpers"; @@ -20,7 +20,7 @@ const AthleteAvatarMenu: React.FC = ({ currentAthlete, otherAccounts, onAthleteClick = () => {}, - // onAddAccount = () => {}, + onAddAccount = () => {}, onLogout = () => {}, }) => { if (!currentAthlete) return null; @@ -50,12 +50,12 @@ const AthleteAvatarMenu: React.FC = ({

Actions

- {/* */} + - ))} -
+
+ {otherAccounts.map((profile) => ( + + ))}
) : null} -
-

Actions

-
- - -
+
+ +
); From c1299c807d83ae5409ed68cb58f5403be6e6376d Mon Sep 17 00:00:00 2001 From: ikusteu Date: Sat, 27 Jan 2024 09:01:24 +0100 Subject: [PATCH 6/7] Add e2e test for auth redirects, for single account, multiple athletes, phone login (it was tested only for email) --- packages/e2e/__testData__/customers.json | 21 +++++++ .../e2e/integration/auth_redirect_spec.ts | 62 ++++++++++++++++++- 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/packages/e2e/__testData__/customers.json b/packages/e2e/__testData__/customers.json index 9cbd5ee09..6413a583e 100644 --- a/packages/e2e/__testData__/customers.json +++ b/packages/e2e/__testData__/customers.json @@ -80,6 +80,27 @@ "categories": ["course-minors"], "email": "morticia@addamsfamily.com", "secretKey": "000007" + }, + + "erlich": { + "id": "erlich", + "name": "Erlich", + "surname": "Bachman", + "certificateExpiration": "2006-10-13", + "categories": ["course-minors"], + "email": "erlich@aviato.com", + "phone": "+391111111", + "secretKey": "000008" + }, + "yang": { + "id": "yang", + "name": "Yang", + "surname": "Jian", + "certificateExpiration": "2006-10-13", + "categories": ["course-minors"], + "email": "mike.hunt@isyourrefrigeratorrunning.com", + "phone": "+391111111", + "secretKey": "000009" } } } diff --git a/packages/e2e/integration/auth_redirect_spec.ts b/packages/e2e/integration/auth_redirect_spec.ts index 645e9799e..9c05ab6ab 100644 --- a/packages/e2e/integration/auth_redirect_spec.ts +++ b/packages/e2e/integration/auth_redirect_spec.ts @@ -3,6 +3,7 @@ import { PrivateRoutes, Routes } from "@eisbuk/shared/ui"; import i18n, { ActionButton, AttendanceNavigationLabel, + AuthTitle, } from "@eisbuk/translations"; import { customers } from "../__testData__/customers.json"; @@ -97,9 +98,9 @@ describe("auth-related redirects", () => { // cy.contains(`${name} ${surname}`); }); - it("multiple secret keys: redirects to customer area page from any of the private routes", () => { - // Here, Morticia manages accounts for both herself and Wednesday. - // Meaning: there are more than one secretKey associated with her account. + it("multiple secret keys: email: redirects to select account page from any of the private routes", () => { + // Here, Morticia manages accounts for both herself and Wednesday (using the email). + // Meaning: there are more than one secretKey associated with her email. const { email, password } = customers.morticia; cy.signUp(email, password); @@ -132,6 +133,61 @@ describe("auth-related redirects", () => { cy.visit(Routes.CustomerArea); cy.url().should("include", Routes.SelectAccount); }); + + it("multiple secret keys: phone: redirects to select account page from any of the private routes", () => { + const { phone } = customers.erlich; + // Here, Erlich manages accounts for both himself and Jian Yang (using the phone). + // Meaning: there are more than one secretKey associated with his phone. + // + // Use the UI to log in using phone number (there's no other way to do this) + cy.visit("/"); + cy.clickButton(i18n.t(AuthTitle.SignInWithPhone)); + cy.contains(i18n.t(AuthTitle.SignInWithPhone) as string); + cy.getAttrWith("id", "dialCode").select("IT (+39)"); + cy.getAttrWith("id", "phone").type(phone.replace("+39", "")); + cy.clickButton(i18n.t(ActionButton.Verify)); + cy.contains(i18n.t(AuthTitle.EnterCode) as string); + cy.getRecaptchaCode(phone).then((code) => { + cy.getAttrWith("id", "code").type(code); + return cy.clickButton(i18n.t(ActionButton.Submit)); + }); + + // Check that the account selection page contains both accounts + // + // Names are broken up in to multiple rows, hence the one check per name/surname + cy.contains(customers.erlich.name); + cy.contains(customers.erlich.surname); + cy.contains(customers.yang.name); + cy.contains(customers.yang.surname); + + // Check for /athletes page + cy.visit(PrivateRoutes.Athletes); + cy.url().should("include", Routes.SelectAccount); + + // Check for /athletes/new page + cy.visit(PrivateRoutes.NewAthlete); + cy.url().should("include", Routes.SelectAccount); + + // Check for /athletes/:id page + cy.visit([PrivateRoutes.Athletes, customers.morticia.id].join("/")); + cy.url().should("include", Routes.SelectAccount); + + // Check for attendance ("/") page + cy.visit(PrivateRoutes.Root); + cy.url().should("include", Routes.SelectAccount); + + // Check for slots page + cy.visit(PrivateRoutes.Slots); + cy.url().should("include", Routes.SelectAccount); + + // Check for admin preferences page + cy.visit(PrivateRoutes.AdminPreferences); + cy.url().should("include", Routes.SelectAccount); + + // If landing on 'customer_area' page, should automatically be redirected to their own customer area page (with their secret key) + cy.visit(Routes.CustomerArea); + cy.url().should("include", Routes.SelectAccount); + }); }); describe("authenticated, but not fully registered (no secret key)", () => { From 70f4bafdfb6c42242ddce5fd8b86d7bf062a078c Mon Sep 17 00:00:00 2001 From: ikusteu Date: Sat, 27 Jan 2024 09:07:57 +0100 Subject: [PATCH 7/7] Small cleanup: remove TODOs from multiple athlete e2e test spec --- packages/e2e/integration/multiple_accounts_spec.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/e2e/integration/multiple_accounts_spec.ts b/packages/e2e/integration/multiple_accounts_spec.ts index 6c0145fb7..f2fac6e0c 100644 --- a/packages/e2e/integration/multiple_accounts_spec.ts +++ b/packages/e2e/integration/multiple_accounts_spec.ts @@ -44,7 +44,6 @@ describe("Management of multiple athletes with single account", () => { it("naviagates between athletes managed by the given auth account", () => { // Start by visiting the customer area page for Morticia - // TODO: In the future, we'll want to use the account selection page cy.visit([Routes.CustomerArea, morticia.secretKey].join("/")); // Navigate to March 2021 @@ -82,7 +81,6 @@ describe("Management of multiple athletes with single account", () => { // that there's no overlap between slots of different categories (and athletes in question are of different categories). it("books for selected athlete (athletes with different categories)", () => { // Start by visiting the customer area page for Morticia - // TODO: In the future, we'll want to use the account selection page cy.visit([Routes.CustomerArea, morticia.secretKey].join("/")); // Navigate to March 2021 @@ -128,7 +126,6 @@ describe("Management of multiple athletes with single account", () => { // This tests for overlapping slots (customers are of same category) but ensures that the booking state changes between customer views. it("books for selected athlete (athletes with different categories)", () => { // Start by visiting the customer area page for Morticia - // TODO: In the future, we'll want to use the account selection page cy.visit([Routes.CustomerArea, morticia.secretKey].join("/")); // Navigate to March 2021