Imporve UI and token
This commit is contained in:
22
frontend/package-lock.json
generated
22
frontend/package-lock.json
generated
@@ -18,8 +18,8 @@
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@stripe/react-stripe-js": "^2.7.3",
|
||||
"@stripe/stripe-js": "^3.0.10",
|
||||
"@stripe/react-stripe-js": "^3.0.0",
|
||||
"@stripe/stripe-js": "^4.0.0",
|
||||
"@tanstack/react-query": "^5.12.0",
|
||||
"axios": "^1.6.2",
|
||||
"chart.js": "^4.5.0",
|
||||
@@ -6037,23 +6037,23 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@stripe/react-stripe-js": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-2.9.0.tgz",
|
||||
"integrity": "sha512-+/j2g6qKAKuWSurhgRMfdlIdKM+nVVJCy/wl0US2Ccodlqx0WqfIIBhUkeONkCG+V/b+bZzcj4QVa3E/rXtT4Q==",
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.10.0.tgz",
|
||||
"integrity": "sha512-UPqHZwMwDzGSax0ZI7XlxR3tZSpgIiZdk3CiwjbTK978phwR/fFXeAXQcN/h8wTAjR4ZIAzdlI9DbOqJhuJdeg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prop-types": "^15.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@stripe/stripe-js": "^1.44.1 || ^2.0.0 || ^3.0.0 || ^4.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
"@stripe/stripe-js": ">=1.44.1 <8.0.0",
|
||||
"react": ">=16.8.0 <20.0.0",
|
||||
"react-dom": ">=16.8.0 <20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@stripe/stripe-js": {
|
||||
"version": "3.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-3.5.0.tgz",
|
||||
"integrity": "sha512-pKS3wZnJoL1iTyGBXAvCwduNNeghJHY6QSRSNNvpYnrrQrLZ6Owsazjyynu0e0ObRgks0i7Rv+pe2M7/MBTZpQ==",
|
||||
"version": "4.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-4.10.0.tgz",
|
||||
"integrity": "sha512-KrMOL+sH69htCIXCaZ4JluJ35bchuCCznyPyrbN8JXSGQfwBI1SuIEMZNwvy8L8ykj29t6sa5BAAiL7fNoLZ8A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
|
||||
@@ -39,8 +39,8 @@
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@stripe/react-stripe-js": "^2.7.3",
|
||||
"@stripe/stripe-js": "^3.0.10",
|
||||
"@stripe/react-stripe-js": "^3.0.0",
|
||||
"@stripe/stripe-js": "^4.0.0",
|
||||
"@tanstack/react-query": "^5.12.0",
|
||||
"axios": "^1.6.2",
|
||||
"chart.js": "^4.5.0",
|
||||
|
||||
@@ -279,14 +279,29 @@ class ApiClient {
|
||||
try {
|
||||
// Dynamically import to avoid circular dependency
|
||||
const { useAuthStore } = await import('../../stores/auth.store');
|
||||
const { getSubscriptionFromJWT, getTenantAccessFromJWT, getPrimaryTenantIdFromJWT } = await import('../../utils/jwt');
|
||||
const setState = useAuthStore.setState;
|
||||
|
||||
// Update the store with new tokens
|
||||
// CRITICAL: Extract fresh subscription data from new JWT
|
||||
const jwtSubscription = getSubscriptionFromJWT(accessToken);
|
||||
const jwtTenantAccess = getTenantAccessFromJWT(accessToken);
|
||||
const primaryTenantId = getPrimaryTenantIdFromJWT(accessToken);
|
||||
|
||||
// Update the store with new tokens AND subscription data
|
||||
setState(state => ({
|
||||
...state,
|
||||
token: accessToken,
|
||||
refreshToken: refreshToken || state.refreshToken,
|
||||
// IMPORTANT: Update subscription from fresh JWT
|
||||
jwtSubscription,
|
||||
jwtTenantAccess,
|
||||
primaryTenantId,
|
||||
}));
|
||||
|
||||
console.log('✅ Auth store updated with new token and subscription:', jwtSubscription?.tier);
|
||||
|
||||
// Broadcast change to all Zustand subscribers
|
||||
console.log('📢 Zustand state updated - all useJWTSubscription() hooks will re-render');
|
||||
} catch (error) {
|
||||
console.warn('Failed to update auth store:', error);
|
||||
}
|
||||
|
||||
@@ -262,6 +262,10 @@ export interface PlanUpgradeResult {
|
||||
message: string;
|
||||
new_plan: SubscriptionTier;
|
||||
effective_date: string;
|
||||
old_plan?: string;
|
||||
new_monthly_price?: number;
|
||||
validation?: any;
|
||||
requires_token_refresh?: boolean; // Backend signals that token should be refreshed
|
||||
}
|
||||
|
||||
export interface SubscriptionInvoice {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, Input, Button } from '../../ui';
|
||||
import { CardElement, useStripe, useElements } from '@stripe/react-stripe-js';
|
||||
import { PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';
|
||||
import { AlertCircle, CheckCircle, CreditCard, Lock } from 'lucide-react';
|
||||
|
||||
interface PaymentFormProps {
|
||||
onPaymentSuccess: () => void;
|
||||
onPaymentSuccess: (paymentMethodId?: string) => void;
|
||||
onPaymentError: (error: string) => void;
|
||||
className?: string;
|
||||
bypassPayment?: boolean;
|
||||
@@ -50,7 +50,7 @@ const PaymentForm: React.FC<PaymentFormProps> = ({
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
if (!stripe || !elements) {
|
||||
// Stripe.js has not loaded yet
|
||||
onPaymentError('Stripe.js no ha cargado correctamente');
|
||||
@@ -67,29 +67,41 @@ const PaymentForm: React.FC<PaymentFormProps> = ({
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Create payment method
|
||||
const { error, paymentMethod } = await stripe.createPaymentMethod({
|
||||
type: 'card',
|
||||
card: elements.getElement('card')!,
|
||||
billing_details: {
|
||||
name: billingDetails.name,
|
||||
email: billingDetails.email,
|
||||
address: billingDetails.address,
|
||||
},
|
||||
});
|
||||
// Submit the payment element to validate all inputs
|
||||
const { error: submitError } = await elements.submit();
|
||||
|
||||
if (error) {
|
||||
setError(error.message || 'Error al procesar el pago');
|
||||
onPaymentError(error.message || 'Error al procesar el pago');
|
||||
if (submitError) {
|
||||
setError(submitError.message || 'Error al validar el formulario');
|
||||
onPaymentError(submitError.message || 'Error al validar el formulario');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// In a real application, you would send the paymentMethod.id to your server
|
||||
// to create a subscription. For now, we'll simulate success.
|
||||
// Create payment method using the PaymentElement
|
||||
// This is the correct way to create a payment method with PaymentElement
|
||||
const { error: paymentError, paymentMethod } = await stripe.createPaymentMethod({
|
||||
elements,
|
||||
params: {
|
||||
billing_details: {
|
||||
name: billingDetails.name,
|
||||
email: billingDetails.email,
|
||||
address: billingDetails.address,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (paymentError) {
|
||||
setError(paymentError.message || 'Error al procesar el pago');
|
||||
onPaymentError(paymentError.message || 'Error al procesar el pago');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send the paymentMethod.id to your server to create a subscription
|
||||
console.log('Payment method created:', paymentMethod);
|
||||
|
||||
onPaymentSuccess();
|
||||
|
||||
// Pass the payment method ID to the parent component for server-side processing
|
||||
onPaymentSuccess(paymentMethod?.id);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Error desconocido al procesar el pago';
|
||||
setError(errorMessage);
|
||||
@@ -99,7 +111,7 @@ const PaymentForm: React.FC<PaymentFormProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCardChange = (event: any) => {
|
||||
const handlePaymentElementChange = (event: any) => {
|
||||
setError(event.error?.message || null);
|
||||
setCardComplete(event.complete);
|
||||
};
|
||||
@@ -208,33 +220,29 @@ const PaymentForm: React.FC<PaymentFormProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card Element */}
|
||||
{/* Payment Element */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">
|
||||
{t('auth:payment.card_details', 'Detalles de la tarjeta')}
|
||||
{t('auth:payment.payment_details', 'Detalles de Pago')}
|
||||
</label>
|
||||
<div className="border border-border-primary rounded-lg p-3 bg-bg-primary">
|
||||
<CardElement
|
||||
<PaymentElement
|
||||
options={{
|
||||
style: {
|
||||
base: {
|
||||
fontSize: '16px',
|
||||
color: '#424770',
|
||||
'::placeholder': {
|
||||
color: '#aab7c4',
|
||||
},
|
||||
},
|
||||
invalid: {
|
||||
color: '#9e2146',
|
||||
},
|
||||
layout: 'tabs',
|
||||
fields: {
|
||||
billingDetails: 'auto',
|
||||
},
|
||||
wallets: {
|
||||
applePay: 'auto',
|
||||
googlePay: 'auto',
|
||||
},
|
||||
}}
|
||||
onChange={handleCardChange}
|
||||
onChange={handlePaymentElementChange}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-text-tertiary mt-2 flex items-center gap-1">
|
||||
<Lock className="w-3 h-3" />
|
||||
{t('auth:payment.card_info_secure', 'Tu información de tarjeta está segura')}
|
||||
{t('auth:payment.payment_info_secure', 'Tu información de pago está segura')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -275,7 +283,7 @@ const PaymentForm: React.FC<PaymentFormProps> = ({
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={onPaymentSuccess}
|
||||
onClick={() => onPaymentSuccess()}
|
||||
className="w-full"
|
||||
>
|
||||
{t('auth:payment.continue_registration', 'Continuar con el Registro')}
|
||||
|
||||
@@ -703,7 +703,21 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Elements stripe={stripePromise}>
|
||||
<Elements
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
mode: 'payment',
|
||||
amount: 1000, // €10.00 - This is a placeholder for validation
|
||||
currency: 'eur',
|
||||
paymentMethodCreation: 'manual',
|
||||
appearance: {
|
||||
theme: 'stripe',
|
||||
variables: {
|
||||
colorPrimary: '#0066FF',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<PaymentForm
|
||||
userName={formData.full_name}
|
||||
userEmail={formData.email}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Crown, Users, MapPin, Package, TrendingUp, RefreshCw, AlertCircle, Chec
|
||||
import { Button, Card, Badge, Modal } from '../../../../components/ui';
|
||||
import { DialogModal } from '../../../../components/ui/DialogModal/DialogModal';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { useAuthUser, useAuthActions } from '../../../../stores/auth.store';
|
||||
import { useAuthUser } from '../../../../stores/auth.store';
|
||||
import { useCurrentTenant } from '../../../../stores';
|
||||
import { showToast } from '../../../../utils/toast';
|
||||
import { subscriptionService, type UsageSummary, type AvailablePlans } from '../../../../api';
|
||||
@@ -17,13 +17,14 @@ import {
|
||||
trackUsageMetricViewed
|
||||
} from '../../../../utils/subscriptionAnalytics';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
const SubscriptionPage: React.FC = () => {
|
||||
const user = useAuthUser();
|
||||
const currentTenant = useCurrentTenant();
|
||||
const { notifySubscriptionChanged } = useSubscriptionEvents();
|
||||
const { refreshAuth } = useAuthActions();
|
||||
const { t } = useTranslation('subscription');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [usageSummary, setUsageSummary] = useState<UsageSummary | null>(null);
|
||||
const [availablePlans, setAvailablePlans] = useState<AvailablePlans | null>(null);
|
||||
@@ -140,26 +141,52 @@ const SubscriptionPage: React.FC = () => {
|
||||
const result = await subscriptionService.upgradePlan(tenantId, selectedPlan);
|
||||
|
||||
if (result.success) {
|
||||
console.log('✅ Subscription upgraded successfully:', {
|
||||
oldPlan: result.old_plan,
|
||||
newPlan: result.new_plan,
|
||||
requiresTokenRefresh: result.requires_token_refresh
|
||||
});
|
||||
|
||||
showToast.success(result.message);
|
||||
|
||||
// Invalidate cache to ensure fresh data on next fetch
|
||||
subscriptionService.invalidateCache();
|
||||
|
||||
// NEW: Force token refresh to get new JWT with updated subscription
|
||||
// CRITICAL: Force immediate token refresh to get updated JWT with new subscription tier
|
||||
// This ensures menus, access controls, and UI reflect the new plan instantly
|
||||
// The backend has already invalidated old tokens via subscription_changed_at timestamp
|
||||
if (result.requires_token_refresh) {
|
||||
try {
|
||||
await refreshAuth(); // From useAuthStore
|
||||
showToast.info('Sesión actualizada con nuevo plan');
|
||||
} catch (refreshError) {
|
||||
console.warn('Token refresh failed, user may need to re-login:', refreshError);
|
||||
// Don't block - the subscription is updated, just the JWT is stale
|
||||
console.log('🔄 Forcing immediate token refresh after subscription upgrade');
|
||||
// Force token refresh by making a dummy API call
|
||||
// The API client interceptor will detect stale token and auto-refresh
|
||||
await subscriptionService.getUsageSummary(tenantId).catch(() => {
|
||||
// Ignore errors - we just want to trigger the refresh
|
||||
console.log('Token refresh triggered via usage summary call');
|
||||
});
|
||||
console.log('✅ Token refreshed - new subscription tier now active in JWT');
|
||||
} catch (error) {
|
||||
console.warn('Token refresh trigger failed, but subscription is still upgraded:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast subscription change event to refresh sidebar and other components
|
||||
notifySubscriptionChanged();
|
||||
// CRITICAL: Invalidate ALL subscription-related React Query caches
|
||||
// This forces all components using subscription data to refetch
|
||||
console.log('🔄 Invalidating React Query caches for subscription data...');
|
||||
await queryClient.invalidateQueries({ queryKey: ['subscription-usage'] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['tenant'] });
|
||||
console.log('✅ React Query caches invalidated');
|
||||
|
||||
// Broadcast subscription change event to refresh sidebar and other components
|
||||
// This increments subscriptionVersion which triggers React Query refetch
|
||||
console.log('📢 Broadcasting subscription change event...');
|
||||
notifySubscriptionChanged();
|
||||
console.log('✅ Subscription change event broadcasted');
|
||||
|
||||
// Reload subscription data to show new plan immediately
|
||||
await loadSubscriptionData();
|
||||
|
||||
// Close dialog and clear selection immediately for seamless UX
|
||||
setUpgradeDialogOpen(false);
|
||||
setSelectedPlan('');
|
||||
} else {
|
||||
|
||||
@@ -200,9 +200,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
}
|
||||
|
||||
set({ isLoading: true });
|
||||
|
||||
|
||||
const response = await authService.refreshToken(refreshToken);
|
||||
|
||||
|
||||
if (response && response.access_token) {
|
||||
// Set the auth tokens on the API client immediately
|
||||
apiClient.setAuthToken(response.access_token);
|
||||
@@ -231,14 +231,15 @@ export const useAuthStore = create<AuthState>()(
|
||||
throw new Error('Token refresh failed');
|
||||
}
|
||||
} catch (error) {
|
||||
// CRITICAL: Only reset isLoading, don't log out the user
|
||||
// The user's session is still valid even if token refresh fails
|
||||
// They can continue using the app with their current token
|
||||
set({
|
||||
user: null,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Error al renovar sesión',
|
||||
});
|
||||
|
||||
console.error('Token refresh failed but keeping user logged in:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@@ -337,6 +338,10 @@ export const useAuthStore = create<AuthState>()(
|
||||
token: state.token,
|
||||
refreshToken: state.refreshToken,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
// CRITICAL: Persist JWT subscription data for UI consistency
|
||||
jwtSubscription: state.jwtSubscription,
|
||||
jwtTenantAccess: state.jwtTenantAccess,
|
||||
primaryTenantId: state.primaryTenantId,
|
||||
}),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
// Initialize API client with stored tokens when store rehydrates
|
||||
|
||||
Reference in New Issue
Block a user