From 86a0e53b4e898dd588bcbf743ad41a8cd8f36327 Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 14 Oct 2025 12:33:48 -0500 Subject: [PATCH 1/6] Enhance session cache management in authentication middleware This commit introduces a new function to clear the session cache for specific users, improving error handling and session validation. It ensures that the cache is cleared when sessions are invalid or when errors occur during session fetching, enhancing the overall reliability of the authentication process. --- middlewares/auth/auth.ts | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/middlewares/auth/auth.ts b/middlewares/auth/auth.ts index 42cccd1a..c154b48a 100644 --- a/middlewares/auth/auth.ts +++ b/middlewares/auth/auth.ts @@ -37,6 +37,15 @@ const sessionCache = new NodeCache({ useClones: false, }); +/** + * Limpia el caché de sesiones para un usuario específico + * Útil cuando se detectan problemas de autenticación + */ +export function clearUserSessionCache(request: NextRequest): void { + const cacheKey = getCacheKey(request); + sessionCache.del(cacheKey); +} + function getCacheKey(request: NextRequest): string { // Usar un hash simple de las cookies principales de autenticación const cookies = request.headers?.get('cookie') || ''; @@ -69,14 +78,20 @@ export async function getSession(request: NextRequest, response: NextResponse, f const session = await fetchAuthSession(contextSpec, { forceRefresh }); const result = session.tokens !== undefined ? session : null; - // Guardar en cache solo si la sesión es válida - if (result && result.tokens) { - sessionCache.set(cacheKey, result); + // Limpiar caché si la sesión no es válida + if (!result || !result.tokens) { + sessionCache.del(cacheKey); + return null; } + // Guardar en cache solo si la sesión es válida + sessionCache.set(cacheKey, result); + return result; } catch (error) { console.error('Error fetching user session:', error); + // Limpiar caché en caso de error + sessionCache.del(cacheKey); return null; } }, @@ -87,6 +102,8 @@ export async function handleAuthenticationMiddleware(request: NextRequest, respo const session = await getSession(request, response); if (!session) { + // Limpiar caché cuando no hay sesión válida + clearUserSessionCache(request); return NextResponse.redirect(new URL('/login', request.url)); } @@ -94,9 +111,12 @@ export async function handleAuthenticationMiddleware(request: NextRequest, respo } export async function handleAuthenticatedRedirectMiddleware(request: NextRequest, response: NextResponse) { - const session = await getSession(request, response, false); + // Siempre forzar refresh para verificar la sesión actual, especialmente importante + // cuando el usuario navega manualmente a /login + const session = await getSession(request, response, true); - if (session) { + if (session && typeof session === 'object' && 'tokens' in session && session.tokens) { + // Verificar que la sesión tiene tokens válidos antes de redirigir const lastStoreId = getLastVisitedStore(request); if (lastStoreId) { @@ -106,5 +126,7 @@ export async function handleAuthenticatedRedirectMiddleware(request: NextRequest } } + // Si no hay sesión válida, limpiar caché y permitir continuar (mostrar login) + clearUserSessionCache(request); return response; } From 504eea48e9bac91eabdb5364dc7a81b7aee91a83 Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 14 Oct 2025 13:21:00 -0500 Subject: [PATCH 2/6] Add autoComplete attributes to SignInForm inputs and update template directory path This commit enhances the SignInForm component by adding autoComplete attributes for email and password fields, improving user experience and accessibility. Additionally, it updates the TEMPLATE_DIR path in the upload-base-template script to reflect the new directory structure for theme files. --- app/(setup)/login/components/sing-in/SignInForm.tsx | 2 ++ scripts/upload-base-template.js | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/(setup)/login/components/sing-in/SignInForm.tsx b/app/(setup)/login/components/sing-in/SignInForm.tsx index a96044c7..1707553f 100644 --- a/app/(setup)/login/components/sing-in/SignInForm.tsx +++ b/app/(setup)/login/components/sing-in/SignInForm.tsx @@ -59,6 +59,7 @@ export function SignInForm({ onForgotPassword, onVerificationNeeded, redirectPat Correo electrónico { @@ -92,6 +93,7 @@ export function SignInForm({ onForgotPassword, onVerificationNeeded, redirectPat
Date: Tue, 14 Oct 2025 16:58:54 -0500 Subject: [PATCH 3/6] Enhance AnimatedBackground and StoreLayoutClient components with new props and layout adjustments This commit introduces new props to the AnimatedBackground component for improved customization, including containerClassName and isModal. It also updates the StoreLayoutClient to handle a checkout page layout with a blurred background and modal overlay for better user experience. Additionally, the logout function in userStore is modified to include a redirect URL, and the auth middleware is updated to support new email and nickname fields in the session payload. --- .../components/AnimatedBackground.tsx | 19 +- .../checkout/CheckoutModalClient.tsx | 81 ++++++++ .../[slug]/access_account/checkout/page.tsx | 31 +++ .../checkout-modal/CheckoutModal.tsx | 196 ++++++++++++++++++ .../checkout-modal/CheckoutModalMobile.tsx | 185 +++++++++++++++++ .../hooks/useCheckoutPayment.ts | 78 +++++++ app/store/layout/StoreLayoutClient.tsx | 17 ++ context/core/userStore.ts | 7 +- lib/utils/subscription-utils.ts | 73 +++++++ middlewares/auth/auth.ts | 2 + test/unit/integration/auth-flow.test.ts | 1 + test/unit/middlewares/auth-cache.test.ts | 1 + utils/client/routes.ts | 4 + 13 files changed, 687 insertions(+), 8 deletions(-) create mode 100644 app/store/[slug]/access_account/checkout/CheckoutModalClient.tsx create mode 100644 app/store/[slug]/access_account/checkout/page.tsx create mode 100644 app/store/components/checkout-modal/CheckoutModal.tsx create mode 100644 app/store/components/checkout-modal/CheckoutModalMobile.tsx create mode 100644 app/store/components/checkout-modal/hooks/useCheckoutPayment.ts create mode 100644 lib/utils/subscription-utils.ts diff --git a/app/(setup)/my-store/components/AnimatedBackground.tsx b/app/(setup)/my-store/components/AnimatedBackground.tsx index 59b9b8f8..1337ad3f 100644 --- a/app/(setup)/my-store/components/AnimatedBackground.tsx +++ b/app/(setup)/my-store/components/AnimatedBackground.tsx @@ -7,13 +7,17 @@ interface AnimatedBackgroundProps { backgroundColor?: string; shapeColor1?: string; shapeColor2?: string; + containerClassName?: string; + isModal?: boolean; } export function AnimatedBackground({ minWidth = '1024px', backgroundColor = 'rgba(20, 20, 20, 1)', - shapeColor1 = 'rgba(255, 123, 142, 1)', - shapeColor2 = 'rgba(123, 255, 142, 1)', + shapeColor1 = 'rgba(142, 123, 255, 1)', + shapeColor2 = 'rgba(68, 242, 235, 1)', + containerClassName = '', + isModal = false, }: AnimatedBackgroundProps) { const [shouldRender, setShouldRender] = useState(false); @@ -62,7 +66,7 @@ export function AnimatedBackground({ 75% { transform: translate(-30%, 40%) skew(15deg, 15deg) rotate(240deg); } - 100% { + to { transform: translate(-30%, 40%) rotate(-20deg); } } @@ -83,20 +87,21 @@ export function AnimatedBackground({ 80% { transform: translate(10%, -30%) rotate(180deg); } - 100% { + to { transform: translate(20%, -40%) rotate(340deg); } } .animated-wrapper { - position: fixed; + position: ${isModal ? 'absolute' : 'fixed'}; top: 0; left: 0; overflow: hidden; width: 100%; height: 100%; background: var(--background-color); - z-index: -1; + z-index: ${isModal ? '1' : '-1'}; + border-radius: ${isModal ? '1rem' : '0'}; } .shape-container { @@ -182,7 +187,7 @@ export function AnimatedBackground({ } `} -
+
diff --git a/app/store/[slug]/access_account/checkout/CheckoutModalClient.tsx b/app/store/[slug]/access_account/checkout/CheckoutModalClient.tsx new file mode 100644 index 00000000..f248083c --- /dev/null +++ b/app/store/[slug]/access_account/checkout/CheckoutModalClient.tsx @@ -0,0 +1,81 @@ +'use client'; + +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CheckoutModal } from '@/app/store/components/checkout-modal/CheckoutModal'; +import { CheckoutModalMobile } from '@/app/store/components/checkout-modal/CheckoutModalMobile'; +import { useEffect } from 'react'; +import useAuthStore from '@/context/core/userStore'; + +/** + * Componente cliente que maneja el modal de checkout bloqueante + * Previene la navegación y aplica efectos visuales al fondo + */ +export function CheckoutModalClient() { + const { logout } = useAuthStore(); + /** + * Previene la navegación del usuario mientras está en el modal de checkout + */ + useEffect(() => { + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + event.preventDefault(); + event.returnValue = ''; + }; + + const handlePopState = () => { + // Prevenir navegación hacia atrás + window.history.pushState(null, '', window.location.href); + }; + + // Agregar el estado actual al historial para prevenir navegación + window.history.pushState(null, '', window.location.href); + + // Agregar listeners para prevenir navegación + window.addEventListener('beforeunload', handleBeforeUnload); + window.addEventListener('popstate', handlePopState); + + // Bloquear el scroll del body + document.body.style.overflow = 'hidden'; + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + window.removeEventListener('popstate', handlePopState); + document.body.style.overflow = 'unset'; + }; + }, []); + + /** + * Maneja el cierre de sesión y redirige al login + */ + const handleLogout = () => { + logout(); + }; + + return ( + <> + {/* Modal para desktop */} +
+ +
+ + {/* Modal para móvil */} +
+ +
+ + ); +} diff --git a/app/store/[slug]/access_account/checkout/page.tsx b/app/store/[slug]/access_account/checkout/page.tsx new file mode 100644 index 00000000..68d09c52 --- /dev/null +++ b/app/store/[slug]/access_account/checkout/page.tsx @@ -0,0 +1,31 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CheckoutModalClient } from './CheckoutModalClient'; + +interface CheckoutPageProps { + params: Promise<{ slug: string }>; +} + +/** + * Página de demostración del modal de checkout bloqueante + * Esta página muestra el diseño visual sin lógica de autenticación + */ +export default async function CheckoutPage({ params }: CheckoutPageProps) { + const { slug: storeId } = await params; + + return ; +} diff --git a/app/store/components/checkout-modal/CheckoutModal.tsx b/app/store/components/checkout-modal/CheckoutModal.tsx new file mode 100644 index 00000000..ce3f4654 --- /dev/null +++ b/app/store/components/checkout-modal/CheckoutModal.tsx @@ -0,0 +1,196 @@ +'use client'; + +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Button, Card, Text, BlockStack } from '@shopify/polaris'; +import { ExitIcon } from '@shopify/polaris-icons'; +import { AnimatedBackground } from '@/app/(setup)/my-store/components/AnimatedBackground'; +import { useCheckoutPayment } from './hooks/useCheckoutPayment'; + +interface CheckoutModalProps { + open: boolean; + onClose?: () => void; +} + +/** + * Modal bloqueante de checkout basado en el diseño de Polar.sh + * Este modal no se puede cerrar y bloquea toda navegación hasta completar el pago + */ +export function CheckoutModal({ open, onClose }: CheckoutModalProps) { + const { isSubmitting, handlePayment } = useCheckoutPayment(); + + if (!open) { + return null; + } + + const benefits = [ + 'Asesoría por chat, email y WhatsApp', + 'Hosting y SSL gratuitos', + 'Panel de estadísticas avanzadas', + 'Soporte prioritario 24/7', + 'Integraciones con redes sociales', + ]; + + return ( +
+ {/* Backdrop con blur */} +
+ + {/* Modal */} +
+ {/* Header con botón de logout */} +
+ +
+ + {/* Contenido principal */} +
+ {/* Panel izquierdo - Información del plan */} +
+ {/* Animated Background */} + + {/* Contenido */} +
+
+ + Vuelve al negocio por $55.000 COP/mes + + +
+
+ + Plan gratuito expirado + + + ¡Buenas noticias - guardamos tu progreso! + +
+ +
+ + Hoy - $55.000 COP/mes + + + Eso es acceso completo a todas las funciones + +
+ +
+ + Siempre - Sin compromiso, cancela cuando quieras + +
+
+ + {/* Beneficios del plan */} +
+ + Con tu plan obtienes: + +
+ {benefits.map((benefit, index) => ( +
+ + {benefit} + +
+ ))} +
+
+
+ +
+ + +
+
+
+ + {/* Panel derecho - Formulario de pago */} +
+
+ +
+ + Resumen del plan + + +
+ +
+ +
+ + Plan Royal + + + Acceso completo a todas las funciones + +
+ +
+ + $55.000 COP/mes + + + Facturación mensual + +
+
+
+
+
+
+ + {/* Botón de suscripción */} + + + + Más impuestos aplicables. Se renueva mensualmente en plan Royal $55.000/mes + +
+
+
+
+
+
+ ); +} diff --git a/app/store/components/checkout-modal/CheckoutModalMobile.tsx b/app/store/components/checkout-modal/CheckoutModalMobile.tsx new file mode 100644 index 00000000..03cca648 --- /dev/null +++ b/app/store/components/checkout-modal/CheckoutModalMobile.tsx @@ -0,0 +1,185 @@ +'use client'; + +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Button, Card, Text, BlockStack } from '@shopify/polaris'; +import { ExitIcon } from '@shopify/polaris-icons'; +import { useCheckoutPayment } from './hooks/useCheckoutPayment'; + +interface CheckoutModalMobileProps { + open: boolean; + onClose?: () => void; +} + +/** + * Modal móvil de checkout optimizado para pantallas pequeñas + */ +export function CheckoutModalMobile({ open, onClose }: CheckoutModalMobileProps) { + const { isSubmitting, handlePayment } = useCheckoutPayment(); + + if (!open) { + return null; + } + + const benefits = [ + 'Asesoría por chat, email y WhatsApp', + 'Hosting y SSL gratuitos', + 'Panel de estadísticas avanzadas', + 'Soporte prioritario 24/7', + 'Integraciones con redes sociales', + ]; + + return ( +
+ {/* Backdrop con blur */} +
+ + {/* Modal móvil */} +
+ {/* Panel superior - Información del plan */} +
+ {/* Fondo personalizado para móvil */} +
+ +
+ + Vuelve al negocio por $55.000 COP/mes + + +
+
+ + Plan gratuito expirado + + + ¡Buenas noticias - guardamos tu progreso! + +
+ +
+ + Hoy - $55.000 COP/mes + + + Eso es acceso completo a todas las funciones + +
+ +
+ + Siempre - Sin compromiso, cancela cuando quieras + +
+
+ + {/* Beneficios del plan */} +
+ + Con tu plan obtienes: + +
+ {benefits.map((benefit, index) => ( +
+ + {benefit} + +
+ ))} +
+
+ +
+ + +
+
+
+ + {/* Panel inferior - Formulario de pago */} +
+ {/* Botón de logout en la parte blanca */} +
+ +
+ +
+ +
+ + Resumen del plan + + +
+ +
+ +
+ + Plan Royal + + + Acceso completo a todas las funciones + +
+ +
+ + $55.000 COP/mes + + + Facturación mensual + +
+
+
+
+
+
+ + {/* Botón de suscripción */} + + + + Más impuestos aplicables. Se renueva mensualmente en plan Royal $55.000/mes + +
+
+
+
+
+ ); +} diff --git a/app/store/components/checkout-modal/hooks/useCheckoutPayment.ts b/app/store/components/checkout-modal/hooks/useCheckoutPayment.ts new file mode 100644 index 00000000..4c613592 --- /dev/null +++ b/app/store/components/checkout-modal/hooks/useCheckoutPayment.ts @@ -0,0 +1,78 @@ +'use client'; + +import { useState } from 'react'; +import { useAuth } from '@/context/hooks/useAuth'; +import { post } from 'aws-amplify/api'; +import { useToast } from '@/app/store/context/ToastContext'; + +interface SubscriptionResponse { + checkoutUrl?: string; + error?: string; + details?: string; +} + +/** + * Hook personalizado para manejar el proceso de pago en el modal de checkout + * Integra con Polar.sh para crear la sesión de pago + */ +export function useCheckoutPayment() { + const [isSubmitting, setIsSubmitting] = useState(false); + const { user } = useAuth(); + const { showToast } = useToast(); + + /** + * Maneja el proceso de suscripción y redirige a Polar.sh + */ + const handlePayment = async () => { + if (!user) { + showToast('Debes iniciar sesión para continuar', true); + return; + } + + if (!user.userId || !user.email || !user.nickName) { + showToast('Información de usuario incompleta', true); + return; + } + + setIsSubmitting(true); + + try { + // ID del plan Royal (el plan por defecto para el checkout) + const royalPlanId = 'e02f173f-1ca5-4f7b-a900-2e5c9413d8a6'; + + const restOperation = post({ + apiName: 'SubscriptionApi', + path: 'subscribe', + options: { + body: { + userId: user.userId, + email: user.email, + name: user.nickName, + plan: { + polarId: royalPlanId, + }, + }, + }, + }); + + const { body } = await restOperation.response; + const response = (await body.json()) as SubscriptionResponse; + + if (response && response.checkoutUrl) { + // Redirigir a Polar.sh para completar el pago + window.location.href = response.checkoutUrl; + } else { + throw new Error('No se recibió URL de checkout'); + } + } catch (error) { + console.error('Error procesando suscripción:', error); + showToast('Hubo un error al procesar tu suscripción. Por favor, inténtalo de nuevo.', true); + setIsSubmitting(false); + } + }; + + return { + isSubmitting, + handlePayment, + }; +} diff --git a/app/store/layout/StoreLayoutClient.tsx b/app/store/layout/StoreLayoutClient.tsx index e7b3b63d..f1f9eab1 100644 --- a/app/store/layout/StoreLayoutClient.tsx +++ b/app/store/layout/StoreLayoutClient.tsx @@ -36,6 +36,7 @@ export const StoreLayoutClient = ({ children }: { children: React.ReactNode }) = const [prefersReducedMotion, _setPrefersReducedMotion] = useState(false); const hideSidebar = pathname.includes('/editor') || pathname.includes('/profile'); + const isCheckoutPage = pathname.includes('/access_account/checkout'); return ( @@ -44,6 +45,22 @@ export const StoreLayoutClient = ({ children }: { children: React.ReactNode }) = {hideSidebar ? (
{children}
+ ) : isCheckoutPage ? ( +
+ {/* Layout completo con blur - Sidebar, TopBar y contenido */} +
+ +
+

Panel de Administración

+

+ Este contenido está bloqueado hasta que renueves tu suscripción +

+
+
+
+ {/* Modal de checkout superpuesto */} + {children} +
) : ( {children} diff --git a/context/core/userStore.ts b/context/core/userStore.ts index c9083f98..d7da036f 100644 --- a/context/core/userStore.ts +++ b/context/core/userStore.ts @@ -177,7 +177,12 @@ const useAuthStore = create((set, get) => ({ logout: async () => { try { - await signOut(); + await signOut({ + global: true, + oauth: { + redirectUrl: '/login', + }, + }); } catch (error) { console.error('Error during logout:', error); } finally { diff --git a/lib/utils/subscription-utils.ts b/lib/utils/subscription-utils.ts new file mode 100644 index 00000000..5c478602 --- /dev/null +++ b/lib/utils/subscription-utils.ts @@ -0,0 +1,73 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Verifica si el plan del usuario está expirado o inactivo + * @param plan - Nombre del plan del usuario + * @param nextPaymentDate - Fecha del próximo pago en formato ISO string + * @returns true si el plan requiere renovación, false si está activo + */ +export function isPlanExpiredOrInactive(plan?: string, nextPaymentDate?: string): boolean { + // Planes activos que no requieren renovación + const activePlans = ['Royal', 'Majestic', 'Imperial']; + + // Si no hay plan o es plan gratuito, requiere renovación + if (!plan || plan === 'free' || !activePlans.includes(plan)) { + return true; + } + + // Si no hay fecha de próximo pago, el plan está expirado + if (!nextPaymentDate) { + return true; + } + + // Verificar si la fecha de próximo pago ya pasó + const paymentDate = new Date(nextPaymentDate); + const currentDate = new Date(); + + // Si la fecha de pago ya pasó, el plan está expirado + return paymentDate <= currentDate; +} + +/** + * Verifica si el usuario tiene un plan activo válido + * @param plan - Nombre del plan del usuario + * @param nextPaymentDate - Fecha del próximo pago en formato ISO string + * @returns true si el plan está activo, false si requiere renovación + */ +export function hasActivePlan(plan?: string, nextPaymentDate?: string): boolean { + return !isPlanExpiredOrInactive(plan, nextPaymentDate); +} + +/** + * Obtiene el estado de suscripción del usuario basado en los datos de Cognito + * @param userPlan - Plan del usuario desde Cognito + * @param subscriptionData - Datos de suscripción desde DynamoDB + * @returns objeto con el estado de la suscripción + */ +export function getSubscriptionStatus(userPlan?: string, subscriptionData?: { nextPaymentDate?: string }) { + const nextPaymentDate = subscriptionData?.nextPaymentDate; + const isExpired = isPlanExpiredOrInactive(userPlan, nextPaymentDate); + const isActive = hasActivePlan(userPlan, nextPaymentDate); + + return { + isExpired, + isActive, + plan: userPlan, + nextPaymentDate, + requiresCheckout: isExpired, + }; +} diff --git a/middlewares/auth/auth.ts b/middlewares/auth/auth.ts index c154b48a..58070f70 100644 --- a/middlewares/auth/auth.ts +++ b/middlewares/auth/auth.ts @@ -26,6 +26,8 @@ export interface AuthSession { payload?: { 'cognito:username'?: string; 'custom:plan'?: string; + email?: string; + nickname?: string; }; }; }; diff --git a/test/unit/integration/auth-flow.test.ts b/test/unit/integration/auth-flow.test.ts index 6620be2f..7b68ae0f 100644 --- a/test/unit/integration/auth-flow.test.ts +++ b/test/unit/integration/auth-flow.test.ts @@ -23,6 +23,7 @@ jest.mock('node-cache', () => { return jest.fn().mockImplementation(() => ({ get: jest.fn(), set: jest.fn(), + del: jest.fn(), flushAll: jest.fn(), })); }); diff --git a/test/unit/middlewares/auth-cache.test.ts b/test/unit/middlewares/auth-cache.test.ts index af384714..820ff44c 100644 --- a/test/unit/middlewares/auth-cache.test.ts +++ b/test/unit/middlewares/auth-cache.test.ts @@ -2,6 +2,7 @@ jest.mock('node-cache', () => { return jest.fn().mockImplementation(() => ({ get: jest.fn(), set: jest.fn(), + del: jest.fn(), flushAll: jest.fn(), })); }); diff --git a/utils/client/routes.ts b/utils/client/routes.ts index 29ae4341..a5deecfc 100644 --- a/utils/client/routes.ts +++ b/utils/client/routes.ts @@ -60,6 +60,10 @@ export const routes = { main: (storeId: string) => `/store/${storeId}/themes`, edit: (storeId: string, themeId: string) => `/store/${storeId}/themes/${themeId}`, }, + + accessAccount: { + checkout: (storeId: string) => `/store/${storeId}/access_account/checkout?no_redirect=true`, + }, }, auth: { From e96ba2f180691edb989148467d29d69da2879152 Mon Sep 17 00:00:00 2001 From: Steven Date: Wed, 15 Oct 2025 13:03:25 -0500 Subject: [PATCH 4/6] Update middleware to include status codes in redirects and enhance store access validation This commit modifies various middleware functions to include a status code of 302 in redirect responses, ensuring proper handling of client-side redirects. Additionally, it enhances the store access middleware to validate user subscriptions and store ownership more robustly, particularly for checkout routes, improving security and user experience. --- .../checkout/CheckoutModalClient.tsx | 2 +- .../[slug]/access_account/checkout/page.tsx | 6 +- middleware.ts | 4 +- middlewares/auth/auth.ts | 6 +- middlewares/ownership/collectionOwnership.ts | 12 +- middlewares/ownership/pagesOwnership.ts | 12 +- middlewares/ownership/productOwnership.ts | 12 +- middlewares/store-access/store.ts | 69 +++++++++-- middlewares/store-access/storeAccess.ts | 111 +++++++++++++++--- middlewares/subscription/subscription.ts | 2 +- 10 files changed, 180 insertions(+), 56 deletions(-) diff --git a/app/store/[slug]/access_account/checkout/CheckoutModalClient.tsx b/app/store/[slug]/access_account/checkout/CheckoutModalClient.tsx index f248083c..93abad7f 100644 --- a/app/store/[slug]/access_account/checkout/CheckoutModalClient.tsx +++ b/app/store/[slug]/access_account/checkout/CheckoutModalClient.tsx @@ -25,7 +25,7 @@ import useAuthStore from '@/context/core/userStore'; * Componente cliente que maneja el modal de checkout bloqueante * Previene la navegación y aplica efectos visuales al fondo */ -export function CheckoutModalClient() { +export function CheckoutModalClient({ storeId: _storeId }: { storeId: string }) { const { logout } = useAuthStore(); /** * Previene la navegación del usuario mientras está en el modal de checkout diff --git a/app/store/[slug]/access_account/checkout/page.tsx b/app/store/[slug]/access_account/checkout/page.tsx index 68d09c52..9a1cf87b 100644 --- a/app/store/[slug]/access_account/checkout/page.tsx +++ b/app/store/[slug]/access_account/checkout/page.tsx @@ -14,18 +14,18 @@ * limitations under the License. */ -import { CheckoutModalClient } from './CheckoutModalClient'; +import { CheckoutModalClient } from '@/app/store/[slug]/access_account/checkout/CheckoutModalClient'; interface CheckoutPageProps { params: Promise<{ slug: string }>; } /** - * Página de demostración del modal de checkout bloqueante - * Esta página muestra el diseño visual sin lógica de autenticación + * Página de checkout para usuarios con suscripción expirada que necesitan reactivar */ export default async function CheckoutPage({ params }: CheckoutPageProps) { const { slug: storeId } = await params; + // El middleware ya validó todo, solo renderizar el modal return ; } diff --git a/middleware.ts b/middleware.ts index 0ef18d28..e09eaae1 100644 --- a/middleware.ts +++ b/middleware.ts @@ -198,7 +198,7 @@ async function handleOAuthProtection( ): Promise { if (request.nextUrl.pathname === PROTECTED_ROUTES.OAUTH_CALLBACK) { if (!isValidOAuthCallback(request.nextUrl)) { - return NextResponse.redirect(new URL(PROTECTED_ROUTES.LOGIN, request.url)); + return NextResponse.redirect(new URL(PROTECTED_ROUTES.LOGIN, request.url), { status: 302 }); } } @@ -218,7 +218,7 @@ async function handleOrdersSubdomainProtection( const domainAnalysis = analyzeDomain(request); if (shouldBlockOrdersAccess(request.nextUrl.pathname, domainAnalysis.subdomain)) { - return NextResponse.redirect(new URL(PROTECTED_ROUTES.HOME, request.url)); + return NextResponse.redirect(new URL(PROTECTED_ROUTES.HOME, request.url), { status: 302 }); } return await next(); diff --git a/middlewares/auth/auth.ts b/middlewares/auth/auth.ts index 58070f70..d074a075 100644 --- a/middlewares/auth/auth.ts +++ b/middlewares/auth/auth.ts @@ -106,7 +106,7 @@ export async function handleAuthenticationMiddleware(request: NextRequest, respo if (!session) { // Limpiar caché cuando no hay sesión válida clearUserSessionCache(request); - return NextResponse.redirect(new URL('/login', request.url)); + return NextResponse.redirect(new URL('/login', request.url), { status: 302 }); } return null; // Permitir que el middleware continúe @@ -122,9 +122,9 @@ export async function handleAuthenticatedRedirectMiddleware(request: NextRequest const lastStoreId = getLastVisitedStore(request); if (lastStoreId) { - return NextResponse.redirect(new URL(`/store/${lastStoreId}/home`, request.url)); + return NextResponse.redirect(new URL(`/store/${lastStoreId}/home`, request.url), { status: 302 }); } else { - return NextResponse.redirect(new URL('/my-store', request.url)); + return NextResponse.redirect(new URL('/my-store', request.url), { status: 302 }); } } diff --git a/middlewares/ownership/collectionOwnership.ts b/middlewares/ownership/collectionOwnership.ts index d78df074..30888370 100644 --- a/middlewares/ownership/collectionOwnership.ts +++ b/middlewares/ownership/collectionOwnership.ts @@ -50,7 +50,7 @@ export async function handleCollectionOwnershipMiddleware(request: NextRequest) const userId = (session as AuthSession).tokens?.idToken?.payload?.['cognito:username']; if (!userId || typeof userId !== 'string') { - return NextResponse.redirect(new URL('/login', request.url)); + return NextResponse.redirect(new URL('/login', request.url), { status: 302 }); } // Extraer información de la URL @@ -61,7 +61,7 @@ export async function handleCollectionOwnershipMiddleware(request: NextRequest) if (!currentStoreId) { const redirectUrl = new URL('/my-store', request.url); - const response = NextResponse.redirect(redirectUrl); + const response = NextResponse.redirect(redirectUrl, { status: 302 }); response.headers.set('x-redirect-check', 'true'); return response; } @@ -87,7 +87,7 @@ export async function handleCollectionOwnershipMiddleware(request: NextRequest) if (!userStoreResult.data || userStoreResult.data.length === 0) { const redirectUrl = new URL('/my-store', request.url); - const response = NextResponse.redirect(redirectUrl); + const response = NextResponse.redirect(redirectUrl, { status: 302 }); response.headers.set('x-redirect-check', 'true'); return response; } @@ -114,14 +114,14 @@ export async function handleCollectionOwnershipMiddleware(request: NextRequest) if (!collection) { const redirectUrl = new URL(`/store/${currentStoreId}/products/collections`, request.url); - const response = NextResponse.redirect(redirectUrl); + const response = NextResponse.redirect(redirectUrl, { status: 302 }); response.headers.set('x-redirect-check', 'true'); return response; } if (collection.storeId !== currentStoreId) { const redirectUrl = new URL(`/store/${currentStoreId}/products/collections`, request.url); - const response = NextResponse.redirect(redirectUrl); + const response = NextResponse.redirect(redirectUrl, { status: 302 }); response.headers.set('x-redirect-check', 'true'); return response; } @@ -129,7 +129,7 @@ export async function handleCollectionOwnershipMiddleware(request: NextRequest) return NextResponse.next(); } catch (_error) { const redirectUrl = new URL(`/my-store`, request.url); - const response = NextResponse.redirect(redirectUrl); + const response = NextResponse.redirect(redirectUrl, { status: 302 }); response.headers.set('x-redirect-check', 'true'); return response; } diff --git a/middlewares/ownership/pagesOwnership.ts b/middlewares/ownership/pagesOwnership.ts index 3e8bff34..4fb5acdc 100644 --- a/middlewares/ownership/pagesOwnership.ts +++ b/middlewares/ownership/pagesOwnership.ts @@ -50,7 +50,7 @@ export async function handlePagesOwnershipMiddleware(request: NextRequest) { const userId = (session as AuthSession).tokens?.idToken?.payload?.['cognito:username']; if (!userId || typeof userId !== 'string') { - return NextResponse.redirect(new URL('/login', request.url)); + return NextResponse.redirect(new URL('/login', request.url), { status: 302 }); } // Extraer información de la URL @@ -61,7 +61,7 @@ export async function handlePagesOwnershipMiddleware(request: NextRequest) { if (!currentStoreId) { const redirectUrl = new URL('/my-store', request.url); - const response = NextResponse.redirect(redirectUrl); + const response = NextResponse.redirect(redirectUrl, { status: 302 }); response.headers.set('x-redirect-check', 'true'); return response; } @@ -87,7 +87,7 @@ export async function handlePagesOwnershipMiddleware(request: NextRequest) { if (!userStoreResult.data || userStoreResult.data.length === 0) { const redirectUrl = new URL('/my-store', request.url); - const response = NextResponse.redirect(redirectUrl); + const response = NextResponse.redirect(redirectUrl, { status: 302 }); response.headers.set('x-redirect-check', 'true'); return response; } @@ -114,14 +114,14 @@ export async function handlePagesOwnershipMiddleware(request: NextRequest) { if (!page) { const redirectUrl = new URL(`/store/${currentStoreId}/setup/pages`, request.url); - const response = NextResponse.redirect(redirectUrl); + const response = NextResponse.redirect(redirectUrl, { status: 302 }); response.headers.set('x-redirect-check', 'true'); return response; } if (page.storeId !== currentStoreId) { const redirectUrl = new URL(`/store/${currentStoreId}/setup/pages/${pageId}`, request.url); - const response = NextResponse.redirect(redirectUrl); + const response = NextResponse.redirect(redirectUrl, { status: 302 }); response.headers.set('x-redirect-check', 'true'); return response; } @@ -129,7 +129,7 @@ export async function handlePagesOwnershipMiddleware(request: NextRequest) { return NextResponse.next(); } catch (_error) { const redirectUrl = new URL(`/my-store`, request.url); - const response = NextResponse.redirect(redirectUrl); + const response = NextResponse.redirect(redirectUrl, { status: 302 }); response.headers.set('x-redirect-check', 'true'); return response; } diff --git a/middlewares/ownership/productOwnership.ts b/middlewares/ownership/productOwnership.ts index 72288ffa..48843429 100644 --- a/middlewares/ownership/productOwnership.ts +++ b/middlewares/ownership/productOwnership.ts @@ -50,7 +50,7 @@ export async function handleProductOwnershipMiddleware(request: NextRequest) { const userId = (session as AuthSession).tokens?.idToken?.payload?.['cognito:username']; if (!userId || typeof userId !== 'string') { - return NextResponse.redirect(new URL('/login', request.url)); + return NextResponse.redirect(new URL('/login', request.url), { status: 302 }); } // Extraer información de la URL @@ -61,7 +61,7 @@ export async function handleProductOwnershipMiddleware(request: NextRequest) { if (!currentStoreId) { const redirectUrl = new URL('/my-store', request.url); - const response = NextResponse.redirect(redirectUrl); + const response = NextResponse.redirect(redirectUrl, { status: 302 }); response.headers.set('x-redirect-check', 'true'); return response; } @@ -87,7 +87,7 @@ export async function handleProductOwnershipMiddleware(request: NextRequest) { if (!userStoreResult.data || userStoreResult.data.length === 0) { const redirectUrl = new URL('/my-store', request.url); - const response = NextResponse.redirect(redirectUrl); + const response = NextResponse.redirect(redirectUrl, { status: 302 }); response.headers.set('x-redirect-check', 'true'); return response; } @@ -117,14 +117,14 @@ export async function handleProductOwnershipMiddleware(request: NextRequest) { if (!product) { const redirectUrl = new URL(`/store/${currentStoreId}/products`, request.url); - const response = NextResponse.redirect(redirectUrl); + const response = NextResponse.redirect(redirectUrl, { status: 302 }); response.headers.set('x-redirect-check', 'true'); return response; } if (product.storeId !== currentStoreId) { const redirectUrl = new URL(`/store/${currentStoreId}/products`, request.url); - const response = NextResponse.redirect(redirectUrl); + const response = NextResponse.redirect(redirectUrl, { status: 302 }); response.headers.set('x-redirect-check', 'true'); return response; } @@ -132,7 +132,7 @@ export async function handleProductOwnershipMiddleware(request: NextRequest) { return NextResponse.next(); } catch (_error) { const redirectUrl = new URL(`/my-store`, request.url); - const response = NextResponse.redirect(redirectUrl); + const response = NextResponse.redirect(redirectUrl, { status: 302 }); response.headers.set('x-redirect-check', 'true'); return response; } diff --git a/middlewares/store-access/store.ts b/middlewares/store-access/store.ts index 83724d65..4b82388d 100644 --- a/middlewares/store-access/store.ts +++ b/middlewares/store-access/store.ts @@ -16,6 +16,7 @@ import { getSession, handleAuthenticationMiddleware, type AuthSession } from '@/middlewares/auth/auth'; import { cookiesClient } from '@/utils/client/AmplifyUtils'; +import { getLastVisitedStore } from '@/lib/cookies/last-store'; import { NextRequest, NextResponse } from 'next/server'; const STORE_LIMITS = { @@ -24,12 +25,6 @@ const STORE_LIMITS = { Royal: 1, }; -async function hasValidPlan(session: AuthSession) { - const userPlan = session.tokens?.idToken?.payload?.['custom:plan'] as string | undefined; - const allowedPlans = ['Royal', 'Majestic', 'Imperial']; - return userPlan && allowedPlans.includes(userPlan); -} - async function checkStoreLimit(userId: string, plan: string) { try { const { data: stores } = await cookiesClient.models.UserStore.listUserStoreByUserId({ @@ -52,6 +47,13 @@ async function checkStoreLimit(userId: string, plan: string) { } export async function handleStoreMiddleware(request: NextRequest, response: NextResponse) { + const path = request.nextUrl.pathname; + + // Excluir rutas de checkout completamente - no validar nada + if (path.includes('/access_account/checkout') || path.includes('/checkout')) { + return response; + } + // Verificar autenticación usando el middleware centralizado const authResponse = await handleAuthenticationMiddleware(request, response); if (authResponse) { @@ -63,24 +65,67 @@ export async function handleStoreMiddleware(request: NextRequest, response: Next const userId = (session as AuthSession).tokens?.idToken?.payload?.['cognito:username']; const userPlan = (session as AuthSession).tokens?.idToken?.payload?.['custom:plan']; - const hasValidSubscription = await hasValidPlan(session as AuthSession); - if (!hasValidSubscription) { - return NextResponse.redirect(new URL('/pricing', request.url)); + if (!userId) { + return NextResponse.redirect(new URL('/login', request.url), { status: 302 }); + } + + // Verificar plan de suscripción - misma lógica que storeAccess.ts + const validPlans = ['Royal', 'Majestic', 'Imperial']; + + if (!userPlan || !validPlans.includes(userPlan)) { + // Usuario con plan 'free' o sin plan - verificar si tiene suscripción en DB + try { + const { data: subscriptions } = await cookiesClient.models.UserSubscription.listUserSubscriptionByUserId({ + userId, + }); + + if (subscriptions && subscriptions.length > 0) { + // Tiene suscripción en DB pero plan 'free' - necesita reactivar + const lastStoreId = getLastVisitedStore(request); + if (lastStoreId) { + return NextResponse.redirect(new URL(`/store/${lastStoreId}/access_account/checkout`, request.url), { + status: 302, + }); + } else { + return NextResponse.redirect(new URL('/my-store', request.url), { status: 302 }); + } + } else { + // No tiene suscripción - usuario nuevo + const lastStoreId = getLastVisitedStore(request); + if (lastStoreId) { + return NextResponse.redirect(new URL(`/store/${lastStoreId}/access_account/checkout`, request.url), { + status: 302, + }); + } else { + return NextResponse.redirect(new URL('/my-store', request.url), { status: 302 }); + } + } + } catch (error) { + console.error('Error checking subscription:', error); + // En caso de error, redirigir a última tienda + const lastStoreId = getLastVisitedStore(request); + if (lastStoreId) { + return NextResponse.redirect(new URL(`/store/${lastStoreId}/access_account/checkout`, request.url), { + status: 302, + }); + } else { + return NextResponse.redirect(new URL('/my-store', request.url), { status: 302 }); + } + } } - const path = request.nextUrl.pathname; const { hasStores, canCreateMore } = await checkStoreLimit(userId as string, userPlan as string); if (path === '/first-steps') { if (hasStores && !canCreateMore) { - return NextResponse.redirect(new URL('/my-store', request.url)); + return NextResponse.redirect(new URL('/my-store', request.url), { status: 302 }); } } if (path === '/my-store') { if (!hasStores) { - return NextResponse.redirect(new URL('/first-steps', request.url)); + return NextResponse.redirect(new URL('/first-steps', request.url), { status: 302 }); } } diff --git a/middlewares/store-access/storeAccess.ts b/middlewares/store-access/storeAccess.ts index 1586ec7b..daeeb851 100644 --- a/middlewares/store-access/storeAccess.ts +++ b/middlewares/store-access/storeAccess.ts @@ -16,6 +16,7 @@ import { getSession, handleAuthenticationMiddleware, type AuthSession } from '@/middlewares/auth/auth'; import { cookiesClient } from '@/utils/client/AmplifyUtils'; +import { getLastVisitedStore } from '@/lib/cookies/last-store'; import { NextRequest, NextResponse } from 'next/server'; /** @@ -23,6 +24,9 @@ import { NextRequest, NextResponse } from 'next/server'; * Verifica que el usuario tenga acceso a la tienda solicitada y un plan de suscripción válido */ export async function handleStoreAccessMiddleware(request: NextRequest) { + // Extraer el ID de la tienda de la URL + const path = request.nextUrl.pathname; + // Verificar autenticación usando el middleware centralizado const authResponse = await handleAuthenticationMiddleware(request, NextResponse.next()); if (authResponse) { @@ -32,33 +36,108 @@ export async function handleStoreAccessMiddleware(request: NextRequest) { // Obtener la sesión del usuario (ya validada) const session = await getSession(request, NextResponse.next(), false); - // Verificar plan de suscripción válido ANTES de verificar acceso a tienda - const userPlan: string | undefined = (session as AuthSession).tokens?.idToken?.payload?.['custom:plan'] as - | string - | undefined; - const allowedPlans = ['Royal', 'Majestic', 'Imperial']; + // Obtener el ID del usuario y plan desde la sesión + const userId = (session as AuthSession).tokens?.idToken?.payload?.['cognito:username']; + const userPlan = (session as AuthSession).tokens?.idToken?.payload?.['custom:plan'] as string | undefined; - if (!userPlan || !allowedPlans.includes(userPlan)) { - return NextResponse.redirect(new URL('/pricing', request.url)); + if (!userId) { + return NextResponse.redirect(new URL('/login', request.url), { status: 302 }); } - // Obtener el ID del usuario desde la sesión - const userId = (session as AuthSession).tokens?.idToken?.payload?.['cognito:username']; + // Si es ruta de checkout, verificar que el usuario tenga el perfil correcto Y sea dueño de la tienda + if (path.includes('/access_account/checkout')) { + // Extraer el storeId de la URL + const storeIdMatch = path.match(/\/store\/([^\/]+)/); + if (!storeIdMatch || !storeIdMatch[1]) { + return NextResponse.redirect(new URL('/my-store', request.url), { status: 302 }); + } - if (!userId) { - return NextResponse.redirect(new URL('/login', request.url)); + const requestedStoreId = storeIdMatch[1]; + + // Si tiene plan válido, no debe estar aquí + const validPlans = ['Royal', 'Majestic', 'Imperial']; + if (userPlan && validPlans.includes(userPlan)) { + return NextResponse.redirect(new URL(`/store/${requestedStoreId}/home`, request.url), { status: 302 }); + } + + // Verificar que la tienda pertenece al usuario (CRÍTICO para seguridad) + try { + const { data: stores } = await cookiesClient.models.UserStore.listUserStoreByUserId( + { + userId: userId as string, + }, + { + filter: { + storeId: { eq: requestedStoreId }, + }, + selectionSet: ['storeId'], + } + ); + + if (!stores || stores.length === 0) { + return NextResponse.redirect(new URL('/my-store', request.url), { status: 302 }); + } + } catch (error) { + console.error('Error verifying store ownership for checkout:', error); + return NextResponse.redirect(new URL('/my-store', request.url), { status: 302 }); + } + + return NextResponse.next(); + } + + // Excluir otras rutas de checkout + if (path.includes('/checkout')) { + return NextResponse.next(); } - // Extraer el ID de la tienda de la URL - const path = request.nextUrl.pathname; const storeIdMatch = path.match(/\/store\/([^\/]+)/); if (!storeIdMatch || !storeIdMatch[1]) { - return NextResponse.redirect(new URL('/my-store', request.url)); + return NextResponse.redirect(new URL('/my-store', request.url), { status: 302 }); } const requestedStoreId = storeIdMatch[1]; + // Verificar plan de suscripción + const validPlans = ['Royal', 'Majestic', 'Imperial']; + + if (!userPlan || !validPlans.includes(userPlan)) { + // Usuario con plan 'free' o sin plan - verificar si tiene suscripción en DB + try { + const { data: subscriptions } = await cookiesClient.models.UserSubscription.listUserSubscriptionByUserId({ + userId, + }); + + if (subscriptions && subscriptions.length > 0) { + // Tiene suscripción en DB pero plan 'free' - necesita reactivar + return NextResponse.redirect(new URL(`/store/${requestedStoreId}/access_account/checkout`, request.url), { + status: 302, + }); + } else { + // No tiene suscripción - usuario nuevo - redirigir a última tienda + const lastStoreId = getLastVisitedStore(request); + if (lastStoreId) { + return NextResponse.redirect(new URL(`/store/${lastStoreId}/access_account/checkout`, request.url), { + status: 302, + }); + } else { + return NextResponse.redirect(new URL('/my-store', request.url), { status: 302 }); + } + } + } catch (error) { + console.error('Error checking subscription:', error); + // En caso de error, redirigir a última tienda + const lastStoreId = getLastVisitedStore(request); + if (lastStoreId) { + return NextResponse.redirect(new URL(`/store/${lastStoreId}/access_account/checkout`, request.url), { + status: 302, + }); + } else { + return NextResponse.redirect(new URL('/my-store', request.url), { status: 302 }); + } + } + } + try { // Verificar si la tienda pertenece al usuario const { data: stores } = await cookiesClient.models.UserStore.listUserStoreByUserId( @@ -75,13 +154,13 @@ export async function handleStoreAccessMiddleware(request: NextRequest) { // Si la tienda no pertenece al usuario, redirigir a my-store if (!stores || stores.length === 0) { - return NextResponse.redirect(new URL('/my-store', request.url)); + return NextResponse.redirect(new URL('/my-store', request.url), { status: 302 }); } // Si todo está bien (plan válido y tienda pertenece al usuario), permitir el acceso return NextResponse.next(); } catch (error) { console.error('Error verifying store access:', error); - return NextResponse.redirect(new URL('/my-store', request.url)); + return NextResponse.redirect(new URL('/my-store', request.url), { status: 302 }); } } diff --git a/middlewares/subscription/subscription.ts b/middlewares/subscription/subscription.ts index f29fcd4e..acc89e5d 100644 --- a/middlewares/subscription/subscription.ts +++ b/middlewares/subscription/subscription.ts @@ -33,7 +33,7 @@ export async function handleSubscriptionMiddleware(request: NextRequest, respons const allowedPlans = ['Royal', 'Majestic', 'Imperial']; if (!userPlan || !allowedPlans.includes(userPlan)) { - return NextResponse.redirect(new URL('/pricing', request.url)); + return NextResponse.redirect(new URL('/checkout', request.url), { status: 302 }); } return response; From 45031b7d611cc41b46f6160162abe00e4c5d93e0 Mon Sep 17 00:00:00 2001 From: Steven Date: Wed, 15 Oct 2025 13:21:54 -0500 Subject: [PATCH 5/6] Update auth middleware tests to include status codes in redirect expectations --- test/unit/middlewares/auth.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/test/unit/middlewares/auth.test.ts b/test/unit/middlewares/auth.test.ts index ce7ccba3..6dbe2cdb 100644 --- a/test/unit/middlewares/auth.test.ts +++ b/test/unit/middlewares/auth.test.ts @@ -154,7 +154,7 @@ describe('Auth Middleware', () => { const result = await handleAuthenticationMiddleware(mockRequest, mockResponse); - expect(NextResponse.redirect).toHaveBeenCalledWith(new URL('/login', mockRequest.url)); + expect(NextResponse.redirect).toHaveBeenCalledWith(new URL('/login', mockRequest.url), { status: 302 }); expect(result).toBe(mockRedirectResponse); }); @@ -175,7 +175,7 @@ describe('Auth Middleware', () => { const result = await handleAuthenticationMiddleware(mockRequest, mockResponse); - expect(NextResponse.redirect).toHaveBeenCalledWith(new URL('/login', mockRequest.url)); + expect(NextResponse.redirect).toHaveBeenCalledWith(new URL('/login', mockRequest.url), { status: 302 }); expect(result).toBe(mockRedirectResponse); consoleErrorSpy.mockRestore(); @@ -211,7 +211,9 @@ describe('Auth Middleware', () => { const result = await handleAuthenticatedRedirectMiddleware(mockRequestWithCookies, mockResponse); - expect(NextResponse.redirect).toHaveBeenCalledWith(new URL('/my-store', mockRequestWithCookies.url)); + expect(NextResponse.redirect).toHaveBeenCalledWith(new URL('/my-store', mockRequestWithCookies.url), { + status: 302, + }); expect(result).toBe(mockRedirectResponse); }); @@ -243,7 +245,7 @@ describe('Auth Middleware', () => { await handleAuthenticationMiddleware(customRequest, mockResponse); - expect(NextResponse.redirect).toHaveBeenCalledWith(new URL('/login', customRequest.url)); + expect(NextResponse.redirect).toHaveBeenCalledWith(new URL('/login', customRequest.url), { status: 302 }); }); it('should handle authenticated redirect with custom URL', async () => { @@ -269,7 +271,7 @@ describe('Auth Middleware', () => { await handleAuthenticatedRedirectMiddleware(customRequest, mockResponse); - expect(NextResponse.redirect).toHaveBeenCalledWith(new URL('/my-store', customRequest.url)); + expect(NextResponse.redirect).toHaveBeenCalledWith(new URL('/my-store', customRequest.url), { status: 302 }); }); }); }); From 7a280e147e57ab9ae89e618f5310c33ac755ff48 Mon Sep 17 00:00:00 2001 From: Steven Date: Wed, 15 Oct 2025 13:31:01 -0500 Subject: [PATCH 6/6] Refactor PricingPage and PricingCard components to remove AWS Amplify configuration and streamline subscription handling This commit removes the AWS Amplify configuration from both the PricingPage and PricingCard components, simplifying the codebase. It also refactors the subscription handling logic in PricingCard, replacing the previous subscription process with a direct navigation to the 'my-store' page upon clicking the subscribe button, enhancing user experience and reducing complexity. --- app/(www)/pricing/components/PricingCard.tsx | 175 ++++--------------- app/(www)/pricing/page.tsx | 12 -- 2 files changed, 38 insertions(+), 149 deletions(-) diff --git a/app/(www)/pricing/components/PricingCard.tsx b/app/(www)/pricing/components/PricingCard.tsx index cb44cc8a..83e79e59 100644 --- a/app/(www)/pricing/components/PricingCard.tsx +++ b/app/(www)/pricing/components/PricingCard.tsx @@ -1,26 +1,7 @@ -import outputs from '@/amplify_outputs.json'; import { Button } from '@/components/ui/button'; -import { LoadingIndicator } from '@/components/ui/loading-indicator'; -import { Toast } from '@/components/ui/toasts'; -import { useAuth } from '@/context/hooks/useAuth'; -import { useIsClient } from '@/hooks/ui/useIsClient'; -import { useToast } from '@/hooks/ui/use-toasts'; -import { Amplify } from 'aws-amplify'; -import { post } from 'aws-amplify/api'; import { motion } from 'framer-motion'; import { Check } from 'lucide-react'; import { useRouter } from 'next/navigation'; -import { useEffect, useState } from 'react'; - -Amplify.configure(outputs); -const existingConfig = Amplify.getConfig(); -Amplify.configure({ - ...existingConfig, - API: { - ...existingConfig.API, - REST: outputs.custom.APIs, - }, -}); interface PricingCardProps { plan: { @@ -36,22 +17,8 @@ interface PricingCardProps { }; } -interface SubscriptionResponse { - checkoutUrl?: string; - error?: string; - details?: string; -} - export function PricingCard({ plan }: PricingCardProps) { - const [isSubmitting, setIsSubmitting] = useState(false); - const isClient = useIsClient(); - const { user, loading } = useAuth(); - const { toasts, addToast, removeToast } = useToast(); const router = useRouter(); - - const cognitoUsername = user?.userId; - const hasActivePlan = user && user.plan ? user.plan === plan.name : false; - const formatPrice = (price: string) => { if (price === '0') return 'Gratis'; const numPrice = Number.parseInt(price, 10); @@ -63,118 +30,52 @@ export function PricingCard({ plan }: PricingCardProps) { return `$ ${formattedPrice}`; }; - const handleSubscribe = async () => { - if (!user) { - router.push('/login'); - return; - } - - setIsSubmitting(true); - try { - const restOperation = post({ - apiName: 'SubscriptionApi', - path: 'subscribe', - options: { - body: { - userId: cognitoUsername || '', - email: user.email, - name: user.nickName || '', - plan: { - polarId: plan.polarId, - }, - }, - }, - }); - - const { body } = await restOperation.response; - const response = (await body.json()) as SubscriptionResponse; - - if (response && response.checkoutUrl) { - window.location.href = response.checkoutUrl; - } else { - throw new Error('Url not found'); - } - } catch (error) { - console.error('Error processing subscription:', error); - addToast('Hubo un error al procesar tu suscripción. Por favor, inténtalo de nuevo.', 'error'); - setIsSubmitting(false); - } + const handleSubscribe = () => { + router.push('/my-store'); }; - useEffect(() => { - const handleVisibilityChange = () => { - if (!document.hidden && isSubmitting) { - setIsSubmitting(false); - } - }; - - document.addEventListener('visibilitychange', handleVisibilityChange); - return () => { - document.removeEventListener('visibilitychange', handleVisibilityChange); - }; - }, [isSubmitting]); - - if (!isClient || loading) { - return ; - } - return ( - <> - {isSubmitting && ( -
- + + {plan.popular && ( +
+
+ Más popular +
)} - - - {plan.popular && ( -
-
- Más popular -
-
- )} -
-
-

{plan.name}

-

{plan.description}

-
- {formatPrice(plan.price)} - COP al mes -

facturación mensual

-
-
- -
-

Funciones destacadas

-
    - {plan.features.map((feature) => ( -
  • - - {feature} -
  • - ))} -
+
+
+

{plan.name}

+

{plan.description}

+
+ {formatPrice(plan.price)} + COP al mes +

facturación mensual

+
- +
+

Funciones destacadas

+
    + {plan.features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
- - - + +
+ ); } diff --git a/app/(www)/pricing/page.tsx b/app/(www)/pricing/page.tsx index a62a1e9c..1516876e 100644 --- a/app/(www)/pricing/page.tsx +++ b/app/(www)/pricing/page.tsx @@ -1,25 +1,13 @@ 'use client'; -import outputs from '@/amplify_outputs.json'; import { Footer } from '@/app/(www)/landing/components/Footer'; import { faqItems } from '@/app/(www)/pricing/components/FAQItem'; import { FAQSection } from '@/app/(www)/pricing/components/FAQSection'; import { FeatureComparison } from '@/app/(www)/pricing/components/FeatureComparison'; import { plans } from '@/app/(www)/pricing/components/plans'; import { PricingCard } from '@/app/(www)/pricing/components/PricingCard'; -import { Amplify } from 'aws-amplify'; import { motion } from 'framer-motion'; -Amplify.configure(outputs); -const existingConfig = Amplify.getConfig(); -Amplify.configure({ - ...existingConfig, - API: { - ...existingConfig.API, - REST: outputs.custom.APIs, - }, -}); - export default function PricingPage() { return ( <>