New token arch
This commit is contained in:
@@ -10,7 +10,7 @@ import {
|
||||
SubscriptionTier
|
||||
} from '../types/subscription';
|
||||
import { useCurrentTenant } from '../../stores';
|
||||
import { useAuthUser } from '../../stores/auth.store';
|
||||
import { useAuthUser, useJWTSubscription } from '../../stores/auth.store';
|
||||
import { useSubscriptionEvents } from '../../contexts/SubscriptionEventsContext';
|
||||
|
||||
export interface SubscriptionFeature {
|
||||
@@ -53,15 +53,42 @@ export const useSubscription = () => {
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
// Get JWT subscription data for instant rendering
|
||||
const jwtSubscription = useJWTSubscription();
|
||||
|
||||
// Derive subscription info from query data or tenant fallback
|
||||
// IMPORTANT: Memoize to prevent infinite re-renders in dependent hooks
|
||||
const subscriptionInfo: SubscriptionInfo = useMemo(() => ({
|
||||
plan: usageSummary?.plan || initialPlan,
|
||||
status: usageSummary?.status || 'active',
|
||||
features: usageSummary?.usage || {},
|
||||
loading: isLoading,
|
||||
error: error ? 'Failed to load subscription data' : undefined,
|
||||
}), [usageSummary?.plan, usageSummary?.status, usageSummary?.usage, initialPlan, isLoading, error]);
|
||||
const subscriptionInfo: SubscriptionInfo = useMemo(() => {
|
||||
// If we have fresh API data (from loadSubscriptionData), use it
|
||||
// This handles the case where token refresh failed but API call succeeded
|
||||
const apiPlan = usageSummary?.plan;
|
||||
const jwtPlan = jwtSubscription?.tier;
|
||||
|
||||
// Prefer API data if available and more recent
|
||||
// Ensure status is compatible with SubscriptionInfo interface
|
||||
const rawStatus = usageSummary?.status || jwtSubscription?.status || 'active';
|
||||
const status = (() => {
|
||||
switch (rawStatus) {
|
||||
case 'active':
|
||||
case 'inactive':
|
||||
case 'past_due':
|
||||
case 'cancelled':
|
||||
case 'trialing':
|
||||
return rawStatus;
|
||||
default:
|
||||
return 'active';
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
plan: apiPlan || jwtPlan || initialPlan,
|
||||
status: status,
|
||||
features: usageSummary?.usage || {},
|
||||
loading: isLoading && !apiPlan && !jwtPlan,
|
||||
error: error ? 'Failed to load subscription data' : undefined,
|
||||
fromJWT: !apiPlan && !!jwtPlan,
|
||||
};
|
||||
}, [jwtSubscription, usageSummary?.plan, usageSummary?.status, usageSummary?.usage, initialPlan, isLoading, error]);
|
||||
|
||||
// Check if user has a specific feature
|
||||
const hasFeature = useCallback(async (featureName: string): Promise<SubscriptionFeature> => {
|
||||
|
||||
@@ -69,6 +69,9 @@ export interface DemoSessionResponse {
|
||||
expires_at: string; // ISO datetime
|
||||
demo_config: Record<string, any>;
|
||||
session_token: string;
|
||||
subscription_tier: string; // NEW: Subscription tier from demo session
|
||||
is_enterprise: boolean; // NEW: Whether this is an enterprise demo
|
||||
tenant_name: string; // NEW: Tenant name for display
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 } from '../../../../stores/auth.store';
|
||||
import { useAuthUser, useAuthActions } from '../../../../stores/auth.store';
|
||||
import { useCurrentTenant } from '../../../../stores';
|
||||
import { showToast } from '../../../../utils/toast';
|
||||
import { subscriptionService, type UsageSummary, type AvailablePlans } from '../../../../api';
|
||||
@@ -22,6 +22,7 @@ const SubscriptionPage: React.FC = () => {
|
||||
const user = useAuthUser();
|
||||
const currentTenant = useCurrentTenant();
|
||||
const { notifySubscriptionChanged } = useSubscriptionEvents();
|
||||
const { refreshAuth } = useAuthActions();
|
||||
const { t } = useTranslation('subscription');
|
||||
|
||||
const [usageSummary, setUsageSummary] = useState<UsageSummary | null>(null);
|
||||
@@ -144,6 +145,17 @@ const SubscriptionPage: React.FC = () => {
|
||||
// Invalidate cache to ensure fresh data on next fetch
|
||||
subscriptionService.invalidateCache();
|
||||
|
||||
// NEW: Force token refresh to get new JWT with updated subscription
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast subscription change event to refresh sidebar and other components
|
||||
notifySubscriptionChanged();
|
||||
|
||||
|
||||
@@ -213,7 +213,7 @@ const DemoPage = () => {
|
||||
is_verified: true,
|
||||
created_at: new Date().toISOString(),
|
||||
tenant_id: sessionData.virtual_tenant_id,
|
||||
});
|
||||
}, tier); // NEW: Pass subscription tier to setDemoAuth
|
||||
|
||||
console.log('✅ [DemoPage] Demo auth set in store');
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { GLOBAL_USER_ROLES, type GlobalUserRole } from '../types/roles';
|
||||
import { getSubscriptionFromJWT, getTenantAccessFromJWT, getPrimaryTenantIdFromJWT, JWTSubscription } from '../utils/jwt';
|
||||
import { JWTSubscription as JWTSubscriptionType } from '../utils/jwt';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
@@ -26,6 +28,14 @@ export interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
jwtSubscription: JWTSubscription | null;
|
||||
jwtTenantAccess: Array<{
|
||||
id: string;
|
||||
role: string;
|
||||
tier: string;
|
||||
}> | null;
|
||||
primaryTenantId: string | null;
|
||||
subscription_from_jwt?: boolean;
|
||||
|
||||
// Actions
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
@@ -43,7 +53,7 @@ export interface AuthState {
|
||||
updateUser: (updates: Partial<User>) => void;
|
||||
clearError: () => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setDemoAuth: (token: string, demoUser: Partial<User>) => void;
|
||||
setDemoAuth: (token: string, demoUser: Partial<User>, subscriptionTier?: string) => void;
|
||||
|
||||
// Permission helpers
|
||||
hasPermission: (permission: string) => boolean;
|
||||
@@ -78,6 +88,11 @@ export const useAuthStore = create<AuthState>()(
|
||||
apiClient.setRefreshToken(response.refresh_token);
|
||||
}
|
||||
|
||||
// NEW: Extract subscription from JWT
|
||||
const jwtSubscription = getSubscriptionFromJWT(response.access_token);
|
||||
const jwtTenantAccess = getTenantAccessFromJWT(response.access_token);
|
||||
const primaryTenantId = getPrimaryTenantIdFromJWT(response.access_token);
|
||||
|
||||
set({
|
||||
user: response.user || null,
|
||||
token: response.access_token,
|
||||
@@ -85,6 +100,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
jwtSubscription,
|
||||
jwtTenantAccess,
|
||||
primaryTenantId,
|
||||
});
|
||||
} else {
|
||||
throw new Error('Login failed');
|
||||
@@ -192,12 +210,23 @@ export const useAuthStore = create<AuthState>()(
|
||||
apiClient.setRefreshToken(response.refresh_token);
|
||||
}
|
||||
|
||||
// NEW: Extract FRESH subscription from new JWT
|
||||
const jwtSubscription = getSubscriptionFromJWT(response.access_token);
|
||||
const jwtTenantAccess = getTenantAccessFromJWT(response.access_token);
|
||||
const primaryTenantId = getPrimaryTenantIdFromJWT(response.access_token);
|
||||
|
||||
set({
|
||||
token: response.access_token,
|
||||
refreshToken: response.refresh_token || refreshToken,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
// NEW: Update subscription from fresh JWT
|
||||
jwtSubscription,
|
||||
jwtTenantAccess,
|
||||
primaryTenantId,
|
||||
});
|
||||
|
||||
console.log('Auth refreshed with new subscription:', jwtSubscription?.tier);
|
||||
} else {
|
||||
throw new Error('Token refresh failed');
|
||||
}
|
||||
@@ -231,12 +260,19 @@ export const useAuthStore = create<AuthState>()(
|
||||
set({ isLoading: loading });
|
||||
},
|
||||
|
||||
setDemoAuth: (token: string, demoUser: Partial<User>) => {
|
||||
setDemoAuth: (token: string, demoUser: Partial<User>, subscriptionTier?: string) => {
|
||||
console.log('🔧 [Auth Store] setDemoAuth called - demo sessions use X-Demo-Session-Id header, not JWT');
|
||||
// DO NOT set API client token for demo sessions!
|
||||
// Demo authentication works via X-Demo-Session-Id header, not JWT
|
||||
// The demo middleware handles authentication server-side
|
||||
|
||||
// NEW: Create synthetic JWT subscription data for demo sessions
|
||||
const jwtSubscription = subscriptionTier ? {
|
||||
tier: subscriptionTier as 'starter' | 'professional' | 'enterprise',
|
||||
status: 'active' as const,
|
||||
valid_until: null
|
||||
} : null;
|
||||
|
||||
// Update store state so user is marked as authenticated
|
||||
set({
|
||||
token: null, // No JWT token for demo sessions
|
||||
@@ -245,8 +281,10 @@ export const useAuthStore = create<AuthState>()(
|
||||
isAuthenticated: true, // User is authenticated via demo session
|
||||
isLoading: false,
|
||||
error: null,
|
||||
jwtSubscription, // NEW: Set subscription data for demo sessions
|
||||
subscription_from_jwt: true, // NEW: Flag to indicate subscription is from JWT
|
||||
});
|
||||
console.log('✅ [Auth Store] Demo auth state updated (no JWT token)');
|
||||
console.log('✅ [Auth Store] Demo auth state updated (no JWT token)', { subscriptionTier });
|
||||
},
|
||||
|
||||
// Permission helpers - Global user permissions only
|
||||
@@ -323,6 +361,9 @@ export const useAuthUser = () => useAuthStore((state) => state.user);
|
||||
export const useIsAuthenticated = () => useAuthStore((state) => state.isAuthenticated);
|
||||
export const useAuthLoading = () => useAuthStore((state) => state.isLoading);
|
||||
export const useAuthError = () => useAuthStore((state) => state.error);
|
||||
export const useJWTSubscription = () => useAuthStore((state) => state.jwtSubscription);
|
||||
export const useJWTTenantAccess = () => useAuthStore((state) => state.jwtTenantAccess);
|
||||
export const usePrimaryTenantId = () => useAuthStore((state) => state.primaryTenantId);
|
||||
export const usePermissions = () => useAuthStore((state) => ({
|
||||
hasPermission: state.hasPermission,
|
||||
hasRole: state.hasRole,
|
||||
|
||||
76
frontend/src/utils/jwt.ts
Normal file
76
frontend/src/utils/jwt.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* JWT Subscription Utilities
|
||||
*
|
||||
* SECURITY NOTE: Subscription data extracted from JWT is for UI/UX purposes ONLY.
|
||||
* - Use for: Showing/hiding menu items, displaying tier badges, feature previews
|
||||
* - NEVER use for: Access control decisions, billing logic, feature enforcement
|
||||
*
|
||||
* All access control is enforced server-side. The backend will return 402 errors
|
||||
* if a user attempts to access features their subscription doesn't include,
|
||||
* regardless of what the frontend displays.
|
||||
*/
|
||||
|
||||
export interface JWTSubscription {
|
||||
readonly tier: 'starter' | 'professional' | 'enterprise';
|
||||
readonly status: 'active' | 'pending_cancellation' | 'inactive';
|
||||
readonly valid_until: string | null;
|
||||
}
|
||||
|
||||
export interface JWTPayload {
|
||||
user_id: string;
|
||||
email: string;
|
||||
exp: number;
|
||||
iat: number;
|
||||
iss: string;
|
||||
tenant_id?: string;
|
||||
tenant_role?: string;
|
||||
subscription?: JWTSubscription;
|
||||
tenant_access?: Array<{
|
||||
id: string;
|
||||
role: string;
|
||||
tier: string;
|
||||
}>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export function decodeJWT(token: string): JWTPayload | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const payload = parts[1];
|
||||
const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
return JSON.parse(decoded);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getSubscriptionFromJWT(token: string | null): Readonly<JWTSubscription> | null {
|
||||
if (!token) return null;
|
||||
const payload = decodeJWT(token);
|
||||
if (!payload?.subscription) return null;
|
||||
|
||||
// Return frozen object to prevent modification
|
||||
return Object.freeze({
|
||||
tier: payload.subscription.tier,
|
||||
status: payload.subscription.status,
|
||||
valid_until: payload.subscription.valid_until
|
||||
});
|
||||
}
|
||||
|
||||
export function getTenantAccessFromJWT(token: string | null): Array<{
|
||||
id: string;
|
||||
role: string;
|
||||
tier: string;
|
||||
}> | null {
|
||||
if (!token) return null;
|
||||
const payload = decodeJWT(token);
|
||||
return payload?.tenant_access ?? null;
|
||||
}
|
||||
|
||||
export function getPrimaryTenantIdFromJWT(token: string | null): string | null {
|
||||
if (!token) return null;
|
||||
const payload = decodeJWT(token);
|
||||
return payload?.tenant_id ?? null;
|
||||
}
|
||||
Reference in New Issue
Block a user