diff --git a/clients/apps/web/src/components/CustomerPortal/CustomerPortalSettings.tsx b/clients/apps/web/src/components/CustomerPortal/CustomerPortalSettings.tsx index f96115083e..9000eb3097 100644 --- a/clients/apps/web/src/components/CustomerPortal/CustomerPortalSettings.tsx +++ b/clients/apps/web/src/components/CustomerPortal/CustomerPortalSettings.tsx @@ -2,22 +2,24 @@ import revalidate from '@/app/actions' import { - useAuthenticatedCustomer, - useCustomerPaymentMethods, -} from '@/hooks/queries' -import { createClientSideAPI } from '@/utils/client' -import { schemas } from '@polar-sh/client' -import Button from '@polar-sh/ui/components/atoms/Button' -import { Separator } from '@polar-sh/ui/components/ui/separator' -import { useThemePreset } from '@polar-sh/ui/hooks/theming' -import { useTheme } from 'next-themes' -import { twMerge } from 'tailwind-merge' -import { Modal } from '../Modal' -import { useModal } from '../Modal/useModal' -import { Well, WellContent, WellHeader } from '../Shared/Well' -import { AddPaymentMethodModal } from './AddPaymentMethodModal' -import EditBillingDetails from './EditBillingDetails' -import PaymentMethod from './PaymentMethod' + useAuthenticatedCustomer, + useCustomerPaymentMethods, + useCustomerPortalSignOut, +} from "@/hooks/queries"; +import { createClientSideAPI } from "@/utils/client"; +import { schemas } from "@polar-sh/client"; +import Button from "@polar-sh/ui/components/atoms/Button"; +import { Separator } from "@polar-sh/ui/components/ui/separator"; +import { useThemePreset } from "@polar-sh/ui/hooks/theming"; +import { useTheme } from "next-themes"; +import { useRouter } from "next/navigation"; +import { twMerge } from "tailwind-merge"; +import { Modal } from "../Modal"; +import { useModal } from "../Modal/useModal"; +import { Well, WellContent, WellHeader } from "../Shared/Well"; +import { AddPaymentMethodModal } from "./AddPaymentMethodModal"; +import EditBillingDetails from "./EditBillingDetails"; +import PaymentMethod from "./PaymentMethod"; interface CustomerPortalSettingsProps { organization: schemas['Organization'] @@ -33,15 +35,17 @@ export const CustomerPortalSettings = ({ customerSessionToken, setupIntentParams, }: CustomerPortalSettingsProps) => { - const api = createClientSideAPI(customerSessionToken) + const api = createClientSideAPI(customerSessionToken); + const router = useRouter(); - const { - isShown: isAddPaymentMethodModalOpen, - hide: hideAddPaymentMethodModal, - show: showAddPaymentMethodModal, - } = useModal(setupIntentParams !== undefined) - const { data: customer } = useAuthenticatedCustomer(api) - const { data: paymentMethods } = useCustomerPaymentMethods(api) + const { + isShown: isAddPaymentMethodModalOpen, + hide: hideAddPaymentMethodModal, + show: showAddPaymentMethodModal, + } = useModal(); + const { data: customer } = useAuthenticatedCustomer(api); + const { data: paymentMethods } = useCustomerPaymentMethods(api); + const customerPortalSignOut = useCustomerPortalSignOut(api); const theme = useTheme() const themingPreset = useThemePreset( @@ -49,9 +53,23 @@ export const CustomerPortalSettings = ({ theme.resolvedTheme as 'light' | 'dark', ) - if (!customer) { - return null - } + const handleSignOut = async () => { + try { + await customerPortalSignOut.mutateAsync(); + + const url = new URL(window.location.href); + const parts = url.pathname.split("/"); + const orgSlug = parts[1]; + + router.replace(`/${orgSlug}/portal/request`); + } catch (error) { + console.error("Failed to sign out:", error); + } + }; + + if (!customer) { + return null; + } return (
@@ -110,22 +128,31 @@ export const CustomerPortalSettings = ({ - { - revalidate(`customer_portal`) - hideAddPaymentMethodModal() - }} - setupIntentParams={setupIntentParams} - hide={hideAddPaymentMethodModal} - themingPreset={themingPreset} - /> - } - /> -
- ) -} +
+ +
+ + { + revalidate(`customer_portal`); + hideAddPaymentMethodModal(); + }} + hide={hideAddPaymentMethodModal} + themingPreset={themingPreset} + /> + } + /> + + ); +}; diff --git a/clients/apps/web/src/hooks/queries/customerPortal.ts b/clients/apps/web/src/hooks/queries/customerPortal.ts index 8bc833777d..4cd11f9419 100644 --- a/clients/apps/web/src/hooks/queries/customerPortal.ts +++ b/clients/apps/web/src/hooks/queries/customerPortal.ts @@ -48,6 +48,20 @@ export const useUpdateCustomerPortal = (api: Client) => }, }) +export const useCustomerPortalSignOut = (api: Client) => + useMutation({ + mutationFn: async () => + api.DELETE('/v1/customer-portal/customer-session/sign-out'), + onSuccess: async (result, _variables, _ctx) => { + if (result.error) { + return + } + queryClient.invalidateQueries({ + queryKey: ['customer'], + }) + }, + }) + export const useCustomerPaymentMethods = (api: Client) => useQuery({ queryKey: ['customer_payment_methods'], @@ -98,10 +112,12 @@ export const useDeleteCustomerPaymentMethod = (api: Client) => }, ) if (result.error) { + const errorMessage = typeof result.error.detail === 'string' ? result.error.detail : 'Failed to delete payment method' + throw new Error(errorMessage) } return result diff --git a/server/polar/customer_portal/endpoints/customer_session.py b/server/polar/customer_portal/endpoints/customer_session.py index 263409683a..638ebd3408 100644 --- a/server/polar/customer_portal/endpoints/customer_session.py +++ b/server/polar/customer_portal/endpoints/customer_session.py @@ -1,10 +1,13 @@ from fastapi import Depends +from sqlalchemy import delete from polar.kit.db.postgres import AsyncSession +from polar.models import CustomerSession from polar.openapi import APITag from polar.postgres import get_db_session from polar.routing import APIRouter +from .. import auth from ..schemas.customer_session import ( CustomerSessionCodeAuthenticateRequest, CustomerSessionCodeAuthenticateResponse, @@ -51,3 +54,17 @@ async def authenticate( session, authenticated_request.code ) return CustomerSessionCodeAuthenticateResponse(token=token) + + +@router.delete("/sign-out", name="customer_portal.customer_session.sign_out", status_code=204) +async def sign_out( + auth_subject: auth.CustomerPortalWrite, + session: AsyncSession = Depends(get_db_session), +) -> None: + customer = auth_subject.subject + + statement = delete(CustomerSession).where( + CustomerSession.customer_id == customer.id, + CustomerSession.deleted_at.is_(None) + ) + await session.execute(statement)