From 1c429625d73b22e84586d89b779ce37acadc1888 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20K=C3=BCndig?= Date: Sun, 17 Mar 2024 12:35:12 +0100 Subject: [PATCH] refactor(website): simplify api interaction (#775) --- .../contributions-table.tsx | 32 +--- .../me/{payments => contributions}/page.tsx | 2 +- .../donation-certificates-table.tsx | 35 +--- .../app/[lang]/[region]/(website)/me/hooks.ts | 181 ++++++++++++++++++ .../[region]/(website)/me/layout-client.tsx | 8 +- .../[lang]/[region]/(website)/me/layout.tsx | 39 ++-- .../app/[lang]/[region]/(website)/me/page.tsx | 2 +- .../me/personal-info/personal-info-form.tsx | 16 +- .../me/security/update-password-form.tsx | 5 +- .../subscriptions/billing-portal-button.tsx | 12 +- .../(website)/me/subscriptions/page.tsx | 45 ++--- .../me/subscriptions/subscriptions-client.tsx | 95 +++++++++ .../me/subscriptions/subscriptions-table.tsx | 82 -------- .../(website)/me/user-context-provider.tsx | 92 --------- .../me/work-info/add-employer-form.tsx | 34 +--- .../(website)/me/work-info/employers-list.tsx | 81 +++----- .../donate/success/stripe/[session]/page.tsx | 2 +- website/src/app/api/auth.ts | 31 +++ .../app/api/mailchimp/subscription/route.ts | 59 +++--- .../billing-portal-session/create/route.ts | 34 ++-- .../stripe/checkout-session/create/route.ts | 66 ++++--- .../src/app/api/stripe/subscriptions/route.ts | 27 ++- .../src/app/api/user/update-password/route.ts | 34 ++-- .../src/components/providers/api-provider.tsx | 23 +++ .../providers/user-context-provider.tsx | 43 +++++ website/src/firebase-admin.ts | 9 - website/src/hooks/useApi.ts | 45 +++++ 27 files changed, 623 insertions(+), 511 deletions(-) rename website/src/app/[lang]/[region]/(website)/me/{payments => contributions}/contributions-table.tsx (67%) rename website/src/app/[lang]/[region]/(website)/me/{payments => contributions}/page.tsx (95%) create mode 100644 website/src/app/[lang]/[region]/(website)/me/hooks.ts create mode 100644 website/src/app/[lang]/[region]/(website)/me/subscriptions/subscriptions-client.tsx delete mode 100644 website/src/app/[lang]/[region]/(website)/me/subscriptions/subscriptions-table.tsx delete mode 100644 website/src/app/[lang]/[region]/(website)/me/user-context-provider.tsx create mode 100644 website/src/app/api/auth.ts create mode 100644 website/src/components/providers/api-provider.tsx create mode 100644 website/src/components/providers/user-context-provider.tsx create mode 100644 website/src/hooks/useApi.ts diff --git a/website/src/app/[lang]/[region]/(website)/me/payments/contributions-table.tsx b/website/src/app/[lang]/[region]/(website)/me/contributions/contributions-table.tsx similarity index 67% rename from website/src/app/[lang]/[region]/(website)/me/payments/contributions-table.tsx rename to website/src/app/[lang]/[region]/(website)/me/contributions/contributions-table.tsx index 046482c46..c60569277 100644 --- a/website/src/app/[lang]/[region]/(website)/me/payments/contributions-table.tsx +++ b/website/src/app/[lang]/[region]/(website)/me/contributions/contributions-table.tsx @@ -1,18 +1,12 @@ 'use client'; import { DefaultParams } from '@/app/[lang]/[region]'; -import { UserContext } from '@/app/[lang]/[region]/(website)/me/user-context-provider'; +import { useContributions } from '@/app/[lang]/[region]/(website)/me/hooks'; +import { SpinnerIcon } from '@/components/logos/spinner-icon'; import { useTranslator } from '@/hooks/useTranslator'; -import { orderBy } from '@firebase/firestore'; -import { CONTRIBUTION_FIRESTORE_PATH, StatusKey } from '@socialincome/shared/src/types/contribution'; -import { USER_FIRESTORE_PATH } from '@socialincome/shared/src/types/user'; import { toDateTime } from '@socialincome/shared/src/utils/date'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Typography } from '@socialincome/ui'; -import { useQuery } from '@tanstack/react-query'; -import { collection, getDocs, query, where } from 'firebase/firestore'; import _ from 'lodash'; -import { useContext } from 'react'; -import { useFirestore } from 'reactfire'; type ContributionsTableProps = { translations: { @@ -23,24 +17,12 @@ type ContributionsTableProps = { } & DefaultParams; export function ContributionsTable({ lang, translations }: ContributionsTableProps) { - const firestore = useFirestore(); const translator = useTranslator(lang, 'website-me'); - const { user } = useContext(UserContext); - const { data: contributions } = useQuery({ - queryKey: ['ContributionsTable', user, firestore], - queryFn: async () => { - if (user && firestore) { - return await getDocs( - query( - collection(firestore, USER_FIRESTORE_PATH, user.id, CONTRIBUTION_FIRESTORE_PATH), - where('status', '==', StatusKey.SUCCEEDED), - orderBy('created', 'desc'), - ), - ); - } else return null; - }, - staleTime: 1000 * 60 * 60, // 1 hour - }); + const { contributions, loading } = useContributions(); + + if (loading) { + return ; + } return ( diff --git a/website/src/app/[lang]/[region]/(website)/me/payments/page.tsx b/website/src/app/[lang]/[region]/(website)/me/contributions/page.tsx similarity index 95% rename from website/src/app/[lang]/[region]/(website)/me/payments/page.tsx rename to website/src/app/[lang]/[region]/(website)/me/contributions/page.tsx index 3e3861fa3..003828b7b 100644 --- a/website/src/app/[lang]/[region]/(website)/me/payments/page.tsx +++ b/website/src/app/[lang]/[region]/(website)/me/contributions/page.tsx @@ -1,5 +1,5 @@ import { DefaultPageProps } from '@/app/[lang]/[region]'; -import { ContributionsTable } from '@/app/[lang]/[region]/(website)/me/payments/contributions-table'; +import { ContributionsTable } from '@/app/[lang]/[region]/(website)/me/contributions/contributions-table'; import { PlusCircleIcon } from '@heroicons/react/24/outline'; import { Translator } from '@socialincome/shared/src/utils/i18n'; import { Button } from '@socialincome/ui'; diff --git a/website/src/app/[lang]/[region]/(website)/me/donation-certificates/donation-certificates-table.tsx b/website/src/app/[lang]/[region]/(website)/me/donation-certificates/donation-certificates-table.tsx index 750c1b4b0..a39bb775f 100644 --- a/website/src/app/[lang]/[region]/(website)/me/donation-certificates/donation-certificates-table.tsx +++ b/website/src/app/[lang]/[region]/(website)/me/donation-certificates/donation-certificates-table.tsx @@ -1,17 +1,10 @@ 'use client'; import { DefaultParams } from '@/app/[lang]/[region]'; -import { UserContext } from '@/app/[lang]/[region]/(website)/me/user-context-provider'; -import { useTranslator } from '@/hooks/useTranslator'; -import { orderBy } from '@firebase/firestore'; -import { DONATION_CERTIFICATE_FIRESTORE_PATH } from '@socialincome/shared/src/types/donation-certificate'; -import { USER_FIRESTORE_PATH } from '@socialincome/shared/src/types/user'; +import { useDonationCertificates } from '@/app/[lang]/[region]/(website)/me/hooks'; +import { SpinnerIcon } from '@/components/logos/spinner-icon'; import { Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Typography } from '@socialincome/ui'; -import { useQuery } from '@tanstack/react-query'; -import { collection, getDocs, query } from 'firebase/firestore'; import Link from 'next/link'; -import { useContext } from 'react'; -import { useFirestore } from 'reactfire'; type ContributionsTableProps = { translations: { @@ -21,24 +14,12 @@ type ContributionsTableProps = { }; } & DefaultParams; -export function DonationCertificatesTable({ lang, translations }: ContributionsTableProps) { - const firestore = useFirestore(); - const translator = useTranslator(lang, 'website-me'); - const { user } = useContext(UserContext); - const { data: donationCertificates } = useQuery({ - queryKey: ['DonationCertificatesTable', user, firestore], - queryFn: async () => { - if (user && firestore) { - return await getDocs( - query( - collection(firestore, USER_FIRESTORE_PATH, user.id, DONATION_CERTIFICATE_FIRESTORE_PATH), - orderBy('year', 'desc'), - ), - ); - } else return null; - }, - staleTime: 1000 * 60 * 60, // 1 hour - }); +export function DonationCertificatesTable({ translations }: ContributionsTableProps) { + const { donationCertificates, loading } = useDonationCertificates(); + + if (loading) { + return ; + } if (donationCertificates?.size === 0) { return ; diff --git a/website/src/app/[lang]/[region]/(website)/me/hooks.ts b/website/src/app/[lang]/[region]/(website)/me/hooks.ts new file mode 100644 index 000000000..01ce298e2 --- /dev/null +++ b/website/src/app/[lang]/[region]/(website)/me/hooks.ts @@ -0,0 +1,181 @@ +import { UserContext } from '@/components/providers/user-context-provider'; +import { useApi } from '@/hooks/useApi'; +import { orderBy } from '@firebase/firestore'; +import { Status } from '@mailchimp/mailchimp_marketing'; +import { CONTRIBUTION_FIRESTORE_PATH, StatusKey } from '@socialincome/shared/src/types/contribution'; +import { DONATION_CERTIFICATE_FIRESTORE_PATH } from '@socialincome/shared/src/types/donation-certificate'; +import { EMPLOYERS_FIRESTORE_PATH, Employer } from '@socialincome/shared/src/types/employers'; +import { USER_FIRESTORE_PATH } from '@socialincome/shared/src/types/user'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { Timestamp, addDoc, collection, deleteDoc, doc, getDocs, query, updateDoc, where } from 'firebase/firestore'; +import { useContext } from 'react'; +import { useFirestore } from 'reactfire'; +import Stripe from 'stripe'; + +export const useUser = () => { + const user = useContext(UserContext); + if (!user) { + throw new Error('useUserContext must be used within a UserContextProvider'); + } + return user; +}; + +export const useContributions = () => { + const firestore = useFirestore(); + const user = useUser(); + const { + data: contributions, + isLoading, + isRefetching, + error, + } = useQuery({ + queryKey: ['me/contributions'], + queryFn: async () => { + return await getDocs( + query( + collection(firestore, USER_FIRESTORE_PATH, user.id, CONTRIBUTION_FIRESTORE_PATH), + where('status', '==', StatusKey.SUCCEEDED), + orderBy('created', 'desc'), + ), + ); + }, + staleTime: 1000 * 60 * 60, // 1 hour + }); + return { contributions, loading: isLoading || isRefetching, error }; +}; + +export const useSubscriptions = () => { + const api = useApi(); + const { + data: subscriptions, + isLoading, + isRefetching, + error, + } = useQuery({ + queryKey: ['me/subscriptions'], + queryFn: async () => { + const response = await api.get('/api/stripe/subscriptions'); + return (await response.json()) as Stripe.Subscription[]; + }, + staleTime: 1000 * 60 * 60, // 1 hour + }); + return { subscriptions, loading: isLoading || isRefetching, error }; +}; + +export const useDonationCertificates = () => { + const firestore = useFirestore(); + const user = useUser(); + const { + data: donationCertificates, + isLoading, + isRefetching, + error, + } = useQuery({ + queryKey: ['me/donation-certificates'], + queryFn: async () => { + return await getDocs( + query( + collection(firestore, USER_FIRESTORE_PATH, user.id, DONATION_CERTIFICATE_FIRESTORE_PATH), + orderBy('year', 'desc'), + ), + ); + }, + staleTime: 1000 * 60 * 60, // 1 hour + }); + return { donationCertificates, loading: isLoading || isRefetching, error }; +}; + +// Mailchimp +export const useMailchimpSubscription = () => { + const api = useApi(); + const { + data: status, + isLoading, + isRefetching, + error, + } = useQuery({ + queryKey: ['me/mailchimp'], + queryFn: async () => { + const response = await api.get('/api/mailchimp/subscription'); + return (await response.json()).status; + }, + staleTime: 1000 * 60 * 60, // 1 hour + }); + return { status, loading: isLoading || isRefetching, error }; +}; + +export const useUpsertMailchimpSubscription = () => { + const api = useApi(); + const queryClient = useQueryClient(); + + return async (status: Status) => { + const response = await api.post('/api/mailchimp/subscription', { status }); + queryClient.invalidateQueries({ queryKey: ['me/mailchimp'] }); + return response; + }; +}; + +export type EmployerWithId = { + id: string; +} & Employer; + +export const useEmployers = () => { + const firestore = useFirestore(); + const user = useUser(); + + const { + data: employers, + isLoading, + isRefetching, + error, + } = useQuery({ + queryKey: ['me/employers'], + queryFn: async () => { + const data = await getDocs( + query(collection(firestore, USER_FIRESTORE_PATH, user.id, 'employers'), orderBy('created', 'desc')), + ); + return data!.docs.map((e) => ({ id: e.id, ...e.data() }) as EmployerWithId); + }, + staleTime: 1000 * 60 * 60, // 1 hour + }); + return { employers, loading: isLoading || isRefetching, error }; +}; + +export const useArchiveEmployer = () => { + const firestore = useFirestore(); + const user = useUser(); + const queryClient = useQueryClient(); + + return async (employer_id: string) => { + const employerRef = doc(firestore, USER_FIRESTORE_PATH, user!.id, 'employers', employer_id); + await updateDoc(employerRef, { is_current: false }); + queryClient.invalidateQueries({ queryKey: ['me/employers'] }); + }; +}; + +export const useDeleteEmployer = () => { + const firestore = useFirestore(); + const user = useUser(); + const queryClient = useQueryClient(); + + return async (employer_id: string) => { + const employerRef = doc(firestore, USER_FIRESTORE_PATH, user!.id, 'employers', employer_id); + await deleteDoc(employerRef); + queryClient.invalidateQueries({ queryKey: ['me/employers'] }); + }; +}; + +export const useAddEmployer = () => { + const firestore = useFirestore(); + const user = useUser(); + const queryClient = useQueryClient(); + + return async (employer_name: string) => { + await addDoc(collection(firestore, USER_FIRESTORE_PATH, user.id, EMPLOYERS_FIRESTORE_PATH), { + employer_name: employer_name, + is_current: true, + created: Timestamp.now(), + }); + queryClient.invalidateQueries({ queryKey: ['me/employers'] }); + }; +}; diff --git a/website/src/app/[lang]/[region]/(website)/me/layout-client.tsx b/website/src/app/[lang]/[region]/(website)/me/layout-client.tsx index 98bb4c65d..ca65b8f39 100644 --- a/website/src/app/[lang]/[region]/(website)/me/layout-client.tsx +++ b/website/src/app/[lang]/[region]/(website)/me/layout-client.tsx @@ -1,7 +1,7 @@ 'use client'; import { DefaultParams } from '@/app/[lang]/[region]'; -import { UserContext } from '@/app/[lang]/[region]/(website)/me/user-context-provider'; +import { UserContext } from '@/components/providers/user-context-provider'; import { ArrowPathIcon, BriefcaseIcon, @@ -59,14 +59,14 @@ type LayoutClientProps = { export function LayoutClient({ params, translations, children }: PropsWithChildren) { const pathname = usePathname(); - const { user } = useContext(UserContext); + const user = useContext(UserContext); const [isOpen, setIsOpen] = useState(false); const navigationMenu = (
    {translations.contributionsTitle} setIsOpen(false)} > @@ -122,7 +122,7 @@ export function LayoutClient({ params, translations, children }: PropsWithChildr case `/${params.lang}/${params.region}/me/security`: title = translations.security; break; - case `/${params.lang}/${params.region}/me/payments`: + case `/${params.lang}/${params.region}/me/contributions`: title = translations.payments; break; case `/${params.lang}/${params.region}/me/subscriptions`: diff --git a/website/src/app/[lang]/[region]/(website)/me/layout.tsx b/website/src/app/[lang]/[region]/(website)/me/layout.tsx index dc2e097cb..387a14c68 100644 --- a/website/src/app/[lang]/[region]/(website)/me/layout.tsx +++ b/website/src/app/[lang]/[region]/(website)/me/layout.tsx @@ -1,6 +1,7 @@ import { DefaultLayoutProps } from '@/app/[lang]/[region]'; import { LayoutClient } from '@/app/[lang]/[region]/(website)/me/layout-client'; -import { UserContextProvider } from '@/app/[lang]/[region]/(website)/me/user-context-provider'; +import { ApiProvider } from '@/components/providers/api-provider'; +import { UserContextProvider } from '@/components/providers/user-context-provider'; import { getMetadata } from '@/metadata'; import { Translator } from '@socialincome/shared/src/utils/i18n'; import { BaseContainer } from '@socialincome/ui'; @@ -16,23 +17,25 @@ export default async function Layout({ children, params }: PropsWithChildren - - {children} - + + + {children} + + ); diff --git a/website/src/app/[lang]/[region]/(website)/me/page.tsx b/website/src/app/[lang]/[region]/(website)/me/page.tsx index d595efc49..b6dcdb601 100644 --- a/website/src/app/[lang]/[region]/(website)/me/page.tsx +++ b/website/src/app/[lang]/[region]/(website)/me/page.tsx @@ -5,6 +5,6 @@ import { useEffect } from 'react'; export default function Page() { useEffect(() => { - redirect('./me/payments'); + redirect('./me/contributions'); }, []); } diff --git a/website/src/app/[lang]/[region]/(website)/me/personal-info/personal-info-form.tsx b/website/src/app/[lang]/[region]/(website)/me/personal-info/personal-info-form.tsx index c64cbdadd..c473e58d9 100644 --- a/website/src/app/[lang]/[region]/(website)/me/personal-info/personal-info-form.tsx +++ b/website/src/app/[lang]/[region]/(website)/me/personal-info/personal-info-form.tsx @@ -4,8 +4,8 @@ import { DefaultParams } from '@/app/[lang]/[region]'; import { useMailchimpSubscription, useUpsertMailchimpSubscription, - useUserContext, -} from '@/app/[lang]/[region]/(website)/me/user-context-provider'; + useUser, +} from '@/app/[lang]/[region]/(website)/me/hooks'; import { useTranslator } from '@/hooks/useTranslator'; import { zodResolver } from '@hookform/resolvers/zod'; import { COUNTRY_CODES } from '@socialincome/shared/src/types/country'; @@ -28,6 +28,7 @@ import { SelectValue, Switch, } from '@socialincome/ui'; +import { useQueryClient } from '@tanstack/react-query'; import { doc, updateDoc } from 'firebase/firestore'; import { useEffect } from 'react'; import { useForm } from 'react-hook-form'; @@ -55,12 +56,11 @@ type PersonalInfoFormProps = { export function PersonalInfoForm({ lang, translations }: PersonalInfoFormProps) { const firestore = useFirestore(); - const { user, refetch } = useUserContext(); - + const user = useUser(); + const queryClient = useQueryClient(); const commonTranslator = useTranslator(lang, 'common'); const countryTranslator = useTranslator(lang, 'countries'); - - const { status, isLoading } = useMailchimpSubscription(); + const { status, loading } = useMailchimpSubscription(); const upsertMailchimpSubscription = useUpsertMailchimpSubscription(); const formSchema = z.object({ @@ -128,7 +128,7 @@ export function PersonalInfoForm({ lang, translations }: PersonalInfoFormProps) }, }).then(() => { toast.success(translations.userUpdatedToast); - refetch(); + queryClient.invalidateQueries({ queryKey: ['me'] }); }); }; @@ -310,7 +310,7 @@ export function PersonalInfoForm({ lang, translations }: PersonalInfoFormProps) { if (enabled) { upsertMailchimpSubscription('subscribed'); diff --git a/website/src/app/[lang]/[region]/(website)/me/security/update-password-form.tsx b/website/src/app/[lang]/[region]/(website)/me/security/update-password-form.tsx index 4bf207ef9..3f57b2b9e 100644 --- a/website/src/app/[lang]/[region]/(website)/me/security/update-password-form.tsx +++ b/website/src/app/[lang]/[region]/(website)/me/security/update-password-form.tsx @@ -49,10 +49,7 @@ export default function UpdatePasswordForm({ translations }: LoginFormProps) { const onSubmit = async (values: FormSchema) => { if (authUser === null) return; - const data: UpdatePasswordData = { - firebaseAuthToken: await authUser.getIdToken(true), - newPassword: values.password, - }; + const data: UpdatePasswordData = { newPassword: values.password }; const response = await fetch('/api/user/update-password', { method: 'POST', body: JSON.stringify(data) }); if (response.ok) { signOut(auth).then(() => { diff --git a/website/src/app/[lang]/[region]/(website)/me/subscriptions/billing-portal-button.tsx b/website/src/app/[lang]/[region]/(website)/me/subscriptions/billing-portal-button.tsx index 05687d6ff..418a7f3d6 100644 --- a/website/src/app/[lang]/[region]/(website)/me/subscriptions/billing-portal-button.tsx +++ b/website/src/app/[lang]/[region]/(website)/me/subscriptions/billing-portal-button.tsx @@ -1,10 +1,10 @@ 'use client'; +import { useApi } from '@/hooks/useApi'; import { CreditCardIcon } from '@heroicons/react/24/outline'; import { Button } from '@socialincome/ui'; import { useQuery } from '@tanstack/react-query'; import { useRouter } from 'next/navigation'; -import { useUser } from 'reactfire'; import Stripe from 'stripe'; type BillingPortalButtonProps = { @@ -15,15 +15,13 @@ type BillingPortalButtonProps = { export function BillingPortalButton({ translations }: BillingPortalButtonProps) { const router = useRouter(); - const { data: authUser } = useUser(); + const api = useApi(); const { data: billingPortalUrl } = useQuery({ - queryKey: ['BillingPortalButton', authUser?.uid], + queryKey: ['me/subscriptions-button'], queryFn: async () => { - const firebaseAuthToken = await authUser?.getIdToken(true); - const response = await fetch('/api/stripe/billing-portal-session/create', { - method: 'POST', - body: JSON.stringify({ firebaseAuthToken, returnUrl: window.location.href }), + const response = await api.post('/api/stripe/billing-portal-session/create', { + returnUrl: window.location.href, }); const { url } = (await response.json()) as Stripe.Response; return url || ''; diff --git a/website/src/app/[lang]/[region]/(website)/me/subscriptions/page.tsx b/website/src/app/[lang]/[region]/(website)/me/subscriptions/page.tsx index 376253c12..928b19a1a 100644 --- a/website/src/app/[lang]/[region]/(website)/me/subscriptions/page.tsx +++ b/website/src/app/[lang]/[region]/(website)/me/subscriptions/page.tsx @@ -1,38 +1,23 @@ import { DefaultPageProps } from '@/app/[lang]/[region]'; -import { BillingPortalButton } from '@/app/[lang]/[region]/(website)/me/subscriptions/billing-portal-button'; -import { SubscriptionsTable } from '@/app/[lang]/[region]/(website)/me/subscriptions/subscriptions-table'; -import { PlusCircleIcon } from '@heroicons/react/24/outline'; +import { SubscriptionsClient } from '@/app/[lang]/[region]/(website)/me/subscriptions/subscriptions-client'; import { Translator } from '@socialincome/shared/src/utils/i18n'; -import { Button } from '@socialincome/ui'; -import Link from 'next/link'; export default async function Page({ params: { lang, region } }: DefaultPageProps) { const translator = await Translator.getInstance({ language: lang, namespaces: ['website-me'] }); + return ( -
    - -
    - - - - -
    -
    + ); } diff --git a/website/src/app/[lang]/[region]/(website)/me/subscriptions/subscriptions-client.tsx b/website/src/app/[lang]/[region]/(website)/me/subscriptions/subscriptions-client.tsx new file mode 100644 index 000000000..55717561d --- /dev/null +++ b/website/src/app/[lang]/[region]/(website)/me/subscriptions/subscriptions-client.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { DefaultParams } from '@/app/[lang]/[region]'; +import { useSubscriptions } from '@/app/[lang]/[region]/(website)/me/hooks'; +import { BillingPortalButton } from '@/app/[lang]/[region]/(website)/me/subscriptions/billing-portal-button'; +import { SpinnerIcon } from '@/components/logos/spinner-icon'; +import { useTranslator } from '@/hooks/useTranslator'; +import { PlusCircleIcon } from '@heroicons/react/24/outline'; +import { toDateTime } from '@socialincome/shared/src/utils/date'; +import { Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Typography } from '@socialincome/ui'; +import Link from 'next/link'; + +type SubscriptionsTableProps = { + translations: { + from: string; + until: string; + status: string; + interval: string; + amount: string; + newSubscription: string; + manageSubscriptions: string; + }; +} & DefaultParams; + +export function SubscriptionsClient({ lang, region, translations }: SubscriptionsTableProps) { + const translator = useTranslator(lang, 'website-me'); + const { subscriptions, loading } = useSubscriptions(); + + if (loading) { + return ; + } + + return ( +
    +
+ + + {translations.from} + {translations.until} + {translations.status} + {translations.interval} + {translations.amount} + + + + {subscriptions?.map((subscription, index) => { + return ( + + + {toDateTime(subscription.start_date * 1000).toFormat('DD', { locale: lang })} + + + + {subscription.ended_at && toDateTime(subscription.ended_at * 1000).toFormat('DD', { locale: lang })} + + + + {translator?.t(`subscriptions.status.${subscription.status}`)} + + + + {translator?.t(`subscriptions.interval-${subscription.items.data[0].plan.interval_count}`)} + + + + + {translator?.t('subscriptions.amount-currency', { + context: { + amount: (subscription.items.data[0].plan.amount || 0) / 100, + currency: subscription.items.data[0].plan.currency, + locale: lang, + }, + })} + + + + ); + })} + +
+
+ + + + +
+ + ); +} diff --git a/website/src/app/[lang]/[region]/(website)/me/subscriptions/subscriptions-table.tsx b/website/src/app/[lang]/[region]/(website)/me/subscriptions/subscriptions-table.tsx deleted file mode 100644 index c7ee40a15..000000000 --- a/website/src/app/[lang]/[region]/(website)/me/subscriptions/subscriptions-table.tsx +++ /dev/null @@ -1,82 +0,0 @@ -'use client'; - -import { DefaultParams } from '@/app/[lang]/[region]'; -import { useTranslator } from '@/hooks/useTranslator'; -import { toDateTime } from '@socialincome/shared/src/utils/date'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Typography } from '@socialincome/ui'; -import { useQuery } from '@tanstack/react-query'; -import { useUser } from 'reactfire'; -import Stripe from 'stripe'; - -type SubscriptionsTableProps = { - translations: { - from: string; - until: string; - status: string; - interval: string; - amount: string; - }; -} & DefaultParams; - -export function SubscriptionsTable({ lang, translations }: SubscriptionsTableProps) { - const translator = useTranslator(lang, 'website-me'); - const { data: authUser } = useUser(); - const { data: subscriptions } = useQuery({ - queryKey: ['SubscriptionsTable', authUser?.uid], - queryFn: async () => { - const firebaseAuthToken = await authUser?.getIdToken(true); - const response = await fetch(`/api/stripe/subscriptions?firebaseAuthToken=${firebaseAuthToken}`); - return (await response.json()) as Stripe.Subscription[]; - }, - staleTime: 1000 * 60 * 60, // 1 hour - }); - - return ( - - - - {translations.from} - {translations.until} - {translations.status} - {translations.interval} - {translations.amount} - - - - {subscriptions?.map((subscription, index) => { - return ( - - - {toDateTime(subscription.start_date * 1000).toFormat('DD', { locale: lang })} - - - - {subscription.ended_at && toDateTime(subscription.ended_at * 1000).toFormat('DD', { locale: lang })} - - - - {translator?.t(`subscriptions.status.${subscription.status}`)} - - - - {translator?.t(`subscriptions.interval-${subscription.items.data[0].plan.interval_count}`)} - - - - - {translator?.t('subscriptions.amount-currency', { - context: { - amount: (subscription.items.data[0].plan.amount || 0) / 100, - currency: subscription.items.data[0].plan.currency, - locale: lang, - }, - })} - - - - ); - })} - -
- ); -} diff --git a/website/src/app/[lang]/[region]/(website)/me/user-context-provider.tsx b/website/src/app/[lang]/[region]/(website)/me/user-context-provider.tsx deleted file mode 100644 index 997e81387..000000000 --- a/website/src/app/[lang]/[region]/(website)/me/user-context-provider.tsx +++ /dev/null @@ -1,92 +0,0 @@ -'use client'; - -import { Status } from '@mailchimp/mailchimp_marketing'; -import { User, USER_FIRESTORE_PATH } from '@socialincome/shared/src/types/user'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { collection, getDocs, query, QueryDocumentSnapshot, where } from 'firebase/firestore'; -import { redirect } from 'next/navigation'; -import { createContext, PropsWithChildren, useContext, useEffect } from 'react'; -import { useFirestore, useUser } from 'reactfire'; - -interface UserContextProps { - user: QueryDocumentSnapshot | null | undefined; - refetch: () => void; -} - -export const UserContext = createContext(undefined!); -export const useUserContext = () => { - const { user, refetch } = useContext(UserContext); - if (!user) { - throw new Error('useUserContext must be used within a UserContextProvider'); - } - return { user, refetch }; -}; - -export function UserContextProvider({ children }: PropsWithChildren) { - const firestore = useFirestore(); - const { data: authUser, status: authUserStatus } = useUser(); - const { data: user, refetch } = useQuery({ - queryKey: ['UserContextProvider', authUser?.uid, firestore], - queryFn: async () => { - if (authUser?.uid && firestore) { - let snapshot = await getDocs( - query(collection(firestore, USER_FIRESTORE_PATH), where('auth_user_id', '==', authUser?.uid)), - ); - if (snapshot.size === 1) { - return snapshot.docs[0] as QueryDocumentSnapshot; - } - return null; - } - return null; - }, - staleTime: 1000 * 60 * 60, // 1 hour - }); - - useEffect(() => { - if (user === null && authUserStatus === 'success') { - // If the user is null, it couldn't be found in the database, so redirect to the login page. - // If the user is undefined, the query is still loading, so no redirect. - redirect('../login'); - } - }, [user, authUserStatus]); - - if (user) { - return {children}; - } -} - -export const useMailchimpSubscription = () => { - const { data: authUser } = useUser(); - const { - data: status, - isLoading, - error, - } = useQuery({ - queryKey: ['mailchimp', authUser?.uid], - queryFn: async () => { - const token = await authUser?.getIdToken(); - if (token) { - const response = await fetch(`/api/mailchimp/subscription?firebaseAuthToken=${token}`); - return (await response.json()).status; - } - return null; - }, - staleTime: 1000 * 60 * 60, // 1 hour - }); - return { status, isLoading, error }; -}; - -export const useUpsertMailchimpSubscription = () => { - const { data: authUser } = useUser(); - const queryClient = useQueryClient(); - - return async (status: Status) => { - const token = await authUser?.getIdToken(); - const response = await fetch(`/api/mailchimp/subscription`, { - method: 'POST', - body: JSON.stringify({ status, firebaseAuthToken: token }), - }); - queryClient.invalidateQueries({ queryKey: ['mailchimp'] }); - return response; - }; -}; diff --git a/website/src/app/[lang]/[region]/(website)/me/work-info/add-employer-form.tsx b/website/src/app/[lang]/[region]/(website)/me/work-info/add-employer-form.tsx index 5248afa84..24b084b93 100644 --- a/website/src/app/[lang]/[region]/(website)/me/work-info/add-employer-form.tsx +++ b/website/src/app/[lang]/[region]/(website)/me/work-info/add-employer-form.tsx @@ -1,32 +1,25 @@ 'use client'; import { zodResolver } from '@hookform/resolvers/zod'; -import { EMPLOYERS_FIRESTORE_PATH, Employer } from '@socialincome/shared/src/types/employers'; -import { USER_FIRESTORE_PATH } from '@socialincome/shared/src/types/user'; import { Button, Form, FormControl, FormField, FormItem, FormMessage, Input } from '@socialincome/ui'; -import { Timestamp, addDoc, collection } from 'firebase/firestore'; import { useForm } from 'react-hook-form'; -import { useFirestore } from 'reactfire'; import * as z from 'zod'; -import { useUserContext } from '../user-context-provider'; + +import { useAddEmployer } from '@/app/[lang]/[region]/(website)/me/hooks'; export type AddEmployerFormProps = { translations: { addEmployer: string; submitButton: string; }; - onNewEmployerSubmitted: () => void; }; -export function AddEmployerForm({ onNewEmployerSubmitted, translations }: AddEmployerFormProps) { - const firestore = useFirestore(); - const { user } = useUserContext(); - +export function AddEmployerForm({ translations }: AddEmployerFormProps) { + const addEmployer = useAddEmployer(); const formSchema = z.object({ - employer_name: z.string().trim().min(1), // TODO : security + employer_name: z.string().trim().min(1), }); type FormSchema = z.infer; - const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { @@ -35,21 +28,8 @@ export function AddEmployerForm({ onNewEmployerSubmitted, translations }: AddEmp }); const onSubmit = async (values: FormSchema) => { - if (user) { - const user_id = user!.id; - let new_employer: Employer = { - employer_name: values.employer_name, - is_current: true, - created: Timestamp.now(), - }; - - await addDoc(collection(firestore, USER_FIRESTORE_PATH, user_id, EMPLOYERS_FIRESTORE_PATH), new_employer).then( - () => { - form.reset(); - onNewEmployerSubmitted(); - }, - ); - } + await addEmployer(values.employer_name); + form.reset(); }; return ( diff --git a/website/src/app/[lang]/[region]/(website)/me/work-info/employers-list.tsx b/website/src/app/[lang]/[region]/(website)/me/work-info/employers-list.tsx index a4d240981..d67ec33e6 100644 --- a/website/src/app/[lang]/[region]/(website)/me/work-info/employers-list.tsx +++ b/website/src/app/[lang]/[region]/(website)/me/work-info/employers-list.tsx @@ -1,14 +1,13 @@ 'use client'; -import { UserContext } from '@/app/[lang]/[region]/(website)/me/user-context-provider'; -import { EMPLOYERS_FIRESTORE_PATH, Employer } from '@socialincome/shared/src/types/employers'; - -import { USER_FIRESTORE_PATH } from '@socialincome/shared/src/types/user'; -import { Table, TableBody, TableCell, TableRow, Typography } from '@socialincome/ui'; -import { useQuery } from '@tanstack/react-query'; -import { collection, deleteDoc, doc, getDocs, orderBy, query, updateDoc } from 'firebase/firestore'; -import { useContext } from 'react'; -import { useFirestore } from 'reactfire'; +import { + EmployerWithId, + useArchiveEmployer, + useDeleteEmployer, + useEmployers, +} from '@/app/[lang]/[region]/(website)/me/hooks'; +import { SpinnerIcon } from '@/components/logos/spinner-icon'; +import { Button, Table, TableBody, TableCell, TableRow, Typography } from '@socialincome/ui'; import { DefaultParams } from '../../..'; import { AddEmployerForm, AddEmployerFormProps } from './add-employer-form'; @@ -24,53 +23,17 @@ type EmployersListProps = { }; } & DefaultParams; -type EmployerWithId = { - id: string; -} & Employer; - export function EmployersList({ translations }: EmployersListProps) { - const firestore = useFirestore(); - const { user } = useContext(UserContext); - const { isLoading, data, refetch } = useQuery({ - queryKey: ['employersList'], - queryFn: async () => { - if (user && firestore) { - return await getDocs( - query( - collection(firestore, USER_FIRESTORE_PATH, user.id, EMPLOYERS_FIRESTORE_PATH), - orderBy('created', 'desc'), - ), - ); - } else return null; - }, - staleTime: 1000 * 60 * 60, // an hour - }); - - const onDeleteEmployer = async (employer_id: string) => { - const employerRef = doc(firestore, USER_FIRESTORE_PATH, user!.id, EMPLOYERS_FIRESTORE_PATH, employer_id); - await deleteDoc(employerRef).then(() => onEmployersUpdated()); - }; - - const onArchiveEmployer = async (employer_id: string) => { - // Not leveraging type system .... - const employerRef = doc(firestore, USER_FIRESTORE_PATH, user!.id, EMPLOYERS_FIRESTORE_PATH, employer_id); - await updateDoc(employerRef, { is_current: false }).then(() => onEmployersUpdated()); - }; - - const onEmployersUpdated = async () => { - await refetch(); - }; + const { employers, loading } = useEmployers(); + const archiveEmployer = useArchiveEmployer(); + const deleteEmployer = useDeleteEmployer(); - if (isLoading) { - return Loading ...; + if (loading) { + return ; } - const employers: EmployerWithId[] = data!.docs.map((e) => { - const employer: Employer = e.data() as Employer; - return { id: e.id, ...employer }; - }); - const currentEmployers: EmployerWithId[] = employers.filter((e) => e.is_current); - const pastEmployers: EmployerWithId[] = employers.filter((e) => !e.is_current); + const currentEmployers: EmployerWithId[] = employers?.filter((e) => e.is_current) || []; + const pastEmployers: EmployerWithId[] = employers?.filter((e) => !e.is_current) || []; return ( <> @@ -86,9 +49,9 @@ export function EmployersList({ translations }: EmployersListProps) { {employer.employer_name}
- +
@@ -101,7 +64,7 @@ export function EmployersList({ translations }: EmployersListProps) { {translations.employersList.emptyState} )} - + {pastEmployers.length > 0 && ( <> @@ -118,9 +81,9 @@ export function EmployersList({ translations }: EmployersListProps) { {employer.employer_name}
- +
diff --git a/website/src/app/[lang]/[region]/donate/success/stripe/[session]/page.tsx b/website/src/app/[lang]/[region]/donate/success/stripe/[session]/page.tsx index 5e4aceb09..79b10919e 100644 --- a/website/src/app/[lang]/[region]/donate/success/stripe/[session]/page.tsx +++ b/website/src/app/[lang]/[region]/donate/success/stripe/[session]/page.tsx @@ -23,7 +23,7 @@ export default async function Page({ params: { lang, region, session } }: Stripe const userDoc = await firestoreAdmin.findFirst(USER_FIRESTORE_PATH, (q) => q.where('stripe_customer_id', '==', checkoutSession.customer), ); - if (userDoc?.exists && userDoc.get('auth_user_id')) redirect(`/${lang}/${region}/me/payments`); + if (userDoc?.exists && userDoc.get('auth_user_id')) redirect(`/${lang}/${region}/me/contributions`); return (
diff --git a/website/src/app/api/auth.ts b/website/src/app/api/auth.ts new file mode 100644 index 000000000..44fbdc1a3 --- /dev/null +++ b/website/src/app/api/auth.ts @@ -0,0 +1,31 @@ +import { authAdmin, firestoreAdmin } from '@/firebase-admin'; +import { USER_FIRESTORE_PATH, User } from '@socialincome/shared/src/types/user'; + +export const getUserDocFromAuthToken = async (token: string | undefined) => { + if (!token) return undefined; + const decodedToken = await authAdmin.auth.verifyIdToken(token, true); + return await firestoreAdmin.findFirst(USER_FIRESTORE_PATH, (q) => + q.where('auth_user_id', '==', decodedToken.uid), + ); +}; + +export class AuthError extends Error {} +export const authorizeRequest = async (request: Request) => { + const { searchParams } = new URL(request.url); + const firebaseAuthToken = searchParams.get('firebaseAuthToken'); + if (!firebaseAuthToken) { + throw new Error('Missing firebaseAuthToken'); + } + const userDoc = await getUserDocFromAuthToken(firebaseAuthToken); + if (!userDoc) { + throw new Error('No user found'); + } + return userDoc; +}; + +export const handleApiError = (error: any) => { + if (error instanceof AuthError) { + return new Response(null, { status: 401, statusText: error.message }); + } + return new Response(null, { status: 500, statusText: error.message }); +}; diff --git a/website/src/app/api/mailchimp/subscription/route.ts b/website/src/app/api/mailchimp/subscription/route.ts index 462e52bca..675edfe19 100644 --- a/website/src/app/api/mailchimp/subscription/route.ts +++ b/website/src/app/api/mailchimp/subscription/route.ts @@ -1,5 +1,4 @@ -import { getUserDocFromAuthToken } from '@/firebase-admin'; - +import { authorizeRequest, handleApiError } from '@/app/api/auth'; import { Body } from '@mailchimp/mailchimp_marketing'; import { MailchimpAPI } from '@socialincome/shared/src/mailchimp/MailchimpAPI'; import { NextResponse } from 'next/server'; @@ -8,40 +7,38 @@ import { NextResponse } from 'next/server'; * Get Mailchimp subscription */ export async function GET(request: Request) { - const { searchParams } = new URL(request.url); - const firebaseAuthToken = searchParams.get('firebaseAuthToken'); - if (!firebaseAuthToken) return new Response(null, { status: 403, statusText: 'Missing firebaseAuthToken' }); - - const userDoc = await getUserDocFromAuthToken(firebaseAuthToken); - if (!userDoc) return new Response(null, { status: 400, statusText: 'No user found' }); - - const mailchimpAPI = new MailchimpAPI(process.env.MAILCHIMP_API_KEY!, process.env.MAILCHIMP_SERVER!); - const subscriber = await mailchimpAPI.getSubscriber(userDoc.get('email'), process.env.MAILCHIMP_LIST_ID!); - - return NextResponse.json(subscriber ? { status: subscriber.status } : { status: 'unknown' }); + try { + const userDoc = await authorizeRequest(request); + const mailchimpAPI = new MailchimpAPI(process.env.MAILCHIMP_API_KEY!, process.env.MAILCHIMP_SERVER!); + const subscriber = await mailchimpAPI.getSubscriber(userDoc.get('email'), process.env.MAILCHIMP_LIST_ID!); + return NextResponse.json(subscriber ? { status: subscriber.status } : { status: 'unknown' }); + } catch (error: any) { + return handleApiError(error); + } } /** * Upsert Mailchimp subscription */ -export type MailchimpSubscriptionUpdate = { status: 'subscribed' | 'unsubscribed'; firebaseAuthToken: string } & Body; +export type MailchimpSubscriptionUpdate = { status: 'subscribed' | 'unsubscribed' } & Body; type MailchimpSubscriptionUpdateRequest = { json(): Promise } & Request; export async function POST(request: MailchimpSubscriptionUpdateRequest) { - const data = await request.json(); - - const userDoc = await getUserDocFromAuthToken(data.firebaseAuthToken); - if (!userDoc) return new Response(null, { status: 400, statusText: 'No user found' }); - - const mailchimpAPI = new MailchimpAPI(process.env.MAILCHIMP_API_KEY!, process.env.MAILCHIMP_SERVER!); - await mailchimpAPI.upsertSubscription( - { - email: userDoc.get('email'), - status: data.status, - firstname: userDoc.get('personal.name'), - lastname: userDoc.get('personal.lastname'), - language: userDoc.get('language'), - }, - process.env.MAILCHIMP_LIST_ID!, - ); - return new Response(null, { status: 200, statusText: 'Success' }); + try { + const userDoc = await authorizeRequest(request); + const data = await request.json(); + const mailchimpAPI = new MailchimpAPI(process.env.MAILCHIMP_API_KEY!, process.env.MAILCHIMP_SERVER!); + await mailchimpAPI.upsertSubscription( + { + email: userDoc.get('email'), + status: data.status, + firstname: userDoc.get('personal.name'), + lastname: userDoc.get('personal.lastname'), + language: userDoc.get('language'), + }, + process.env.MAILCHIMP_LIST_ID!, + ); + return new Response(null, { status: 200, statusText: 'Success' }); + } catch (error: any) { + return handleApiError(error); + } } diff --git a/website/src/app/api/stripe/billing-portal-session/create/route.ts b/website/src/app/api/stripe/billing-portal-session/create/route.ts index 513748d92..9aaf35f49 100644 --- a/website/src/app/api/stripe/billing-portal-session/create/route.ts +++ b/website/src/app/api/stripe/billing-portal-session/create/route.ts @@ -1,25 +1,25 @@ -import { getUserDocFromAuthToken } from '@/firebase-admin'; +import { authorizeRequest, handleApiError } from '@/app/api/auth'; import { initializeStripe } from '@socialincome/shared/src/stripe'; import { NextResponse } from 'next/server'; -export type CreateBillingPortalSessionData = { - returnUrl: string; - firebaseAuthToken: string; -}; - +export type CreateBillingPortalSessionData = { returnUrl: string }; type CreateBillingPortalSessionRequest = { json(): Promise } & Request; export async function POST(request: CreateBillingPortalSessionRequest) { - const { returnUrl, firebaseAuthToken } = await request.json(); - const stripe = initializeStripe(process.env.STRIPE_SECRET_KEY!); - const userDoc = await getUserDocFromAuthToken(firebaseAuthToken); - if (!userDoc || !userDoc.get('stripe_customer_id')) { - return NextResponse.json(null); + try { + const userDoc = await authorizeRequest(request); + const { returnUrl } = await request.json(); + const stripe = initializeStripe(process.env.STRIPE_SECRET_KEY!); + if (!userDoc || !userDoc.get('stripe_customer_id')) { + return NextResponse.json(null); + } + const session = await stripe.billingPortal.sessions.create({ + return_url: returnUrl, + customer: userDoc?.get('stripe_customer_id'), + locale: userDoc?.get('language'), + }); + return NextResponse.json(session); + } catch (error: any) { + return handleApiError(error); } - const session = await stripe.billingPortal.sessions.create({ - return_url: returnUrl, - customer: userDoc?.get('stripe_customer_id'), - locale: userDoc?.get('language'), - }); - return NextResponse.json(session); } diff --git a/website/src/app/api/stripe/checkout-session/create/route.ts b/website/src/app/api/stripe/checkout-session/create/route.ts index 6d4b1f05a..caf905f87 100644 --- a/website/src/app/api/stripe/checkout-session/create/route.ts +++ b/website/src/app/api/stripe/checkout-session/create/route.ts @@ -1,4 +1,4 @@ -import { getUserDocFromAuthToken } from '@/firebase-admin'; +import { getUserDocFromAuthToken, handleApiError } from '@/app/api/auth'; import { WebsiteCurrency } from '@/i18n'; import { initializeStripe } from '@socialincome/shared/src/stripe'; import { NextResponse } from 'next/server'; @@ -25,35 +25,39 @@ export async function POST(request: CreateCheckoutSessionRequest) { firebaseAuthToken, campaignId, } = await request.json(); - const stripe = initializeStripe(process.env.STRIPE_SECRET_KEY!); - const userDoc = await getUserDocFromAuthToken(firebaseAuthToken); - const customerId = userDoc?.get('stripe_customer_id'); - const price = await stripe.prices.create({ - active: true, - unit_amount: amount, - currency: currency.toLowerCase(), - product: recurring ? process.env.STRIPE_PRODUCT_RECURRING : process.env.STRIPE_PRODUCT_ONETIME, - recurring: recurring ? { interval: 'month', interval_count: intervalCount } : undefined, - }); - const metadata = campaignId - ? { - campaignId: campaignId, - } - : undefined; + try { + const userDoc = firebaseAuthToken ? await getUserDocFromAuthToken(firebaseAuthToken) : null; + const stripe = initializeStripe(process.env.STRIPE_SECRET_KEY!); + const customerId = userDoc?.get('stripe_customer_id'); + const price = await stripe.prices.create({ + active: true, + unit_amount: amount, + currency: currency.toLowerCase(), + product: recurring ? process.env.STRIPE_PRODUCT_RECURRING : process.env.STRIPE_PRODUCT_ONETIME, + recurring: recurring ? { interval: 'month', interval_count: intervalCount } : undefined, + }); + const metadata = campaignId + ? { + campaignId: campaignId, + } + : undefined; - const session = await stripe.checkout.sessions.create({ - mode: recurring ? 'subscription' : 'payment', - customer: customerId, - customer_creation: customerId || recurring ? undefined : 'always', - line_items: [ - { - price: price.id, - quantity: 1, - }, - ], - success_url: successUrl, - locale: 'auto', - metadata: metadata, - }); - return NextResponse.json(session); + const session = await stripe.checkout.sessions.create({ + mode: recurring ? 'subscription' : 'payment', + customer: customerId, + customer_creation: customerId || recurring ? undefined : 'always', + line_items: [ + { + price: price.id, + quantity: 1, + }, + ], + success_url: successUrl, + locale: 'auto', + metadata: metadata, + }); + return NextResponse.json(session); + } catch (error: any) { + handleApiError(error); + } } diff --git a/website/src/app/api/stripe/subscriptions/route.ts b/website/src/app/api/stripe/subscriptions/route.ts index d69c2c97b..fab5018c1 100644 --- a/website/src/app/api/stripe/subscriptions/route.ts +++ b/website/src/app/api/stripe/subscriptions/route.ts @@ -1,20 +1,19 @@ -import { getUserDocFromAuthToken } from '@/firebase-admin'; +import { authorizeRequest, handleApiError } from '@/app/api/auth'; import { initializeStripe } from '@socialincome/shared/src/stripe'; import { NextResponse } from 'next/server'; export async function GET(request: Request) { - const { searchParams } = new URL(request.url); - const firebaseAuthToken = searchParams.get('firebaseAuthToken'); - if (!firebaseAuthToken) { - return new Response(null, { status: 403, statusText: 'Missing firebaseAuthToken' }); - } - const userDoc = await getUserDocFromAuthToken(firebaseAuthToken); - const stripe = initializeStripe(process.env.STRIPE_SECRET_KEY!); - if (!userDoc || !userDoc.get('stripe_customer_id')) { - return NextResponse.json(null); - } else { - return NextResponse.json( - (await stripe.subscriptions.list({ customer: userDoc.get('stripe_customer_id'), status: 'all' })).data, - ); + try { + const userDoc = await authorizeRequest(request); + const stripe = initializeStripe(process.env.STRIPE_SECRET_KEY!); + if (!userDoc.get('stripe_customer_id')) { + return NextResponse.json(null); + } else { + return NextResponse.json( + (await stripe.subscriptions.list({ customer: userDoc.get('stripe_customer_id'), status: 'all' })).data, + ); + } + } catch (error: any) { + return handleApiError(error); } } diff --git a/website/src/app/api/user/update-password/route.ts b/website/src/app/api/user/update-password/route.ts index 360ae84a1..186fdb8d9 100644 --- a/website/src/app/api/user/update-password/route.ts +++ b/website/src/app/api/user/update-password/route.ts @@ -1,29 +1,17 @@ -import { authAdmin, getUserDocFromAuthToken } from '@/firebase-admin'; +import { authorizeRequest, handleApiError } from '@/app/api/auth'; +import { authAdmin } from '@/firebase-admin'; -export type UpdatePasswordData = { firebaseAuthToken: string; newPassword: string }; +export type UpdatePasswordData = { newPassword: string }; type UpdatePasswordRequest = { json(): Promise } & Request; export async function POST(request: UpdatePasswordRequest) { - const { firebaseAuthToken, newPassword } = await request.json(); - - if (!firebaseAuthToken || !newPassword) { - return new Response(null, { - status: 400, - statusText: 'Missing one of the following parameters: firebaseAuthToken, newPassword', - }); - } - const userDoc = await getUserDocFromAuthToken(firebaseAuthToken); - if (!userDoc) { - return new Response(null, { status: 400, statusText: 'User not found' }); + try { + const userDoc = await authorizeRequest(request); + const { newPassword } = await request.json(); + await authAdmin.auth.updateUser(userDoc.get('auth_user_id'), { password: newPassword }); + console.log(`Password updated for user with email ${userDoc.get('email')}`); + return new Response(null, { status: 200, statusText: 'Password updated' }); + } catch (error: any) { + return handleApiError(error); } - return authAdmin.auth - .updateUser(userDoc.get('auth_user_id'), { password: newPassword }) - .then(() => { - console.log(`Password updated for user with email ${userDoc.get('email')}`); - return new Response(null, { status: 200, statusText: 'Password updated' }); - }) - .catch((error: Error) => { - console.error(error); - return new Response(null, { status: 500, statusText: error.message }); - }); } diff --git a/website/src/components/providers/api-provider.tsx b/website/src/components/providers/api-provider.tsx new file mode 100644 index 000000000..32f35582f --- /dev/null +++ b/website/src/components/providers/api-provider.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { ApiClient } from '@/hooks/useApi'; +import { createContext, useEffect, useState } from 'react'; +import { useUser as useFirebaseUser } from 'reactfire'; + +export const ApiProviderContext = createContext(undefined!); + +export function ApiProvider({ children }: { children: React.ReactNode }) { + const { data: authUser } = useFirebaseUser(); + const [idToken, setIdToken] = useState(); + + useEffect(() => { + if (authUser) { + authUser.getIdToken().then(setIdToken); + } + }, [authUser, setIdToken]); + + if (!authUser || !idToken) { + return; + } + return {children}; +} diff --git a/website/src/components/providers/user-context-provider.tsx b/website/src/components/providers/user-context-provider.tsx new file mode 100644 index 000000000..fc2d07ac0 --- /dev/null +++ b/website/src/components/providers/user-context-provider.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { User, USER_FIRESTORE_PATH } from '@socialincome/shared/src/types/user'; +import { useQuery } from '@tanstack/react-query'; +import { collection, getDocs, query, QueryDocumentSnapshot, where } from 'firebase/firestore'; +import { redirect } from 'next/navigation'; +import { createContext, PropsWithChildren, useEffect } from 'react'; +import { useFirestore, useUser } from 'reactfire'; + +export const UserContext = createContext | null | undefined>(undefined!); + +export function UserContextProvider({ children }: PropsWithChildren) { + const firestore = useFirestore(); + const { data: authUser, status: authUserStatus } = useUser(); + const { data: user } = useQuery({ + queryKey: ['me', authUser?.uid], + queryFn: async () => { + if (authUser?.uid) { + let snapshot = await getDocs( + query(collection(firestore, USER_FIRESTORE_PATH), where('auth_user_id', '==', authUser?.uid)), + ); + if (snapshot.size === 1) { + return snapshot.docs[0] as QueryDocumentSnapshot; + } + return null; + } + return null; + }, + staleTime: 1000 * 60 * 60, // 1 hour + }); + + useEffect(() => { + if (user === null && authUserStatus === 'success') { + // If the user is null, it couldn't be found in the database, so redirect to the login page. + // If the user is undefined, the query is still loading, so no redirect. + redirect('../login'); + } + }, [user, authUserStatus]); + + if (user) { + return {children}; + } +} diff --git a/website/src/firebase-admin.ts b/website/src/firebase-admin.ts index cd61e361a..6358c98b0 100644 --- a/website/src/firebase-admin.ts +++ b/website/src/firebase-admin.ts @@ -2,7 +2,6 @@ import { getOrInitializeFirebaseAdmin } from '@socialincome/shared/src/firebase/ import { AuthAdmin } from '@socialincome/shared/src/firebase/admin/AuthAdmin'; import { FirestoreAdmin } from '@socialincome/shared/src/firebase/admin/FirestoreAdmin'; import { StorageAdmin } from '@socialincome/shared/src/firebase/admin/StorageAdmin'; -import { User, USER_FIRESTORE_PATH } from '@socialincome/shared/src/types/user'; import { credential } from 'firebase-admin'; // FIREBASE_SERVICE_ACCOUNT_JSON should only be a single line where the content of private_key contains \n characters. @@ -21,11 +20,3 @@ export const app = getOrInitializeFirebaseAdmin( export const authAdmin = new AuthAdmin(app); export const firestoreAdmin = new FirestoreAdmin(app); export const storageAdmin = new StorageAdmin(app); - -export const getUserDocFromAuthToken = async (token: string | undefined) => { - if (!token) return undefined; - const decodedToken = await authAdmin.auth.verifyIdToken(token, true); - return await firestoreAdmin.findFirst(USER_FIRESTORE_PATH, (q) => - q.where('auth_user_id', '==', decodedToken.uid), - ); -}; diff --git a/website/src/hooks/useApi.ts b/website/src/hooks/useApi.ts new file mode 100644 index 000000000..f01d4de42 --- /dev/null +++ b/website/src/hooks/useApi.ts @@ -0,0 +1,45 @@ +import { ApiProviderContext } from '@/components/providers/api-provider'; +import { useContext } from 'react'; + +export class ApiClient { + readonly token: string; + + constructor(firebaseIdToken: string) { + this.token = firebaseIdToken; + } + + get(path: string) { + return this.request('GET', path); + } + + post(path: string, body: Object) { + return this.request('POST', path, body); + } + + patch(path: string, body: any) { + return this.request('PATCH', path, body); + } + + private async request(method: 'GET' | 'POST' | 'PATCH', path: string, body?: any) { + let url: URL; + if (path.startsWith('/')) { + url = new URL(path, window.location.origin); + url.searchParams.append('firebaseAuthToken', this.token); + } else { + url = new URL(path); + } + + return fetch(url, { + method, + body: JSON.stringify(body), + }); + } +} + +export const useApi = () => { + const api = useContext(ApiProviderContext); + if (!api) { + throw new Error('useAPI used outside of ApiProvider'); + } + return api; +};