New token arch

This commit is contained in:
Urtzi Alfaro
2026-01-10 21:45:37 +01:00
parent cc53037552
commit bf1db7cb9e
26 changed files with 1751 additions and 107 deletions

View File

@@ -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> => {

View File

@@ -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
}
/**

View File

@@ -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();

View File

@@ -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 {

View File

@@ -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
View 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;
}