diff --git a/backend/src/app.ts b/backend/src/app.ts index ae4a318..4c90489 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -10,6 +10,7 @@ import fastifyMultipart from '@fastify/multipart'; // Core plugins import authPlugin from './core/plugins/auth.plugin'; import adminGuardPlugin, { setAdminGuardPool } from './core/plugins/admin-guard.plugin'; +import tierGuardPlugin from './core/plugins/tier-guard.plugin'; import loggingPlugin from './core/plugins/logging.plugin'; import errorPlugin from './core/plugins/error.plugin'; import { appConfig } from './core/config/config-loader'; @@ -30,6 +31,7 @@ import { onboardingRoutes } from './features/onboarding'; import { userPreferencesRoutes } from './features/user-preferences'; import { userExportRoutes } from './features/user-export'; import { pool } from './core/config/database'; +import { configRoutes } from './core/config/config.routes'; async function buildApp(): Promise { const app = Fastify({ @@ -80,13 +82,16 @@ async function buildApp(): Promise { await app.register(adminGuardPlugin); setAdminGuardPool(pool); + // Tier guard plugin - for subscription tier enforcement + await app.register(tierGuardPlugin); + // Health check app.get('/health', async (_request, reply) => { return reply.code(200).send({ status: 'healthy', timestamp: new Date().toISOString(), environment: process.env['NODE_ENV'], - features: ['admin', 'auth', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export'] + features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export'] }); }); @@ -96,7 +101,7 @@ async function buildApp(): Promise { status: 'healthy', scope: 'api', timestamp: new Date().toISOString(), - features: ['admin', 'auth', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export'] + features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export'] }); }); @@ -136,6 +141,7 @@ async function buildApp(): Promise { await app.register(userProfileRoutes, { prefix: '/api' }); await app.register(userPreferencesRoutes, { prefix: '/api' }); await app.register(userExportRoutes, { prefix: '/api' }); + await app.register(configRoutes, { prefix: '/api' }); // 404 handler app.setNotFoundHandler(async (_request, reply) => { diff --git a/backend/src/core/config/config.routes.ts b/backend/src/core/config/config.routes.ts new file mode 100644 index 0000000..2028370 --- /dev/null +++ b/backend/src/core/config/config.routes.ts @@ -0,0 +1,18 @@ +/** + * @ai-summary Configuration API routes + * @ai-context Exposes feature tier configuration for frontend consumption + */ + +import { FastifyPluginAsync } from 'fastify'; +import { getAllFeatureConfigs, TIER_LEVELS } from './feature-tiers'; + +export const configRoutes: FastifyPluginAsync = async (fastify) => { + // GET /api/config/feature-tiers - Get all feature tier configurations + // Public endpoint - no auth required (config is not sensitive) + fastify.get('/config/feature-tiers', async (_request, reply) => { + return reply.code(200).send({ + tiers: TIER_LEVELS, + features: getAllFeatureConfigs(), + }); + }); +}; diff --git a/backend/src/core/config/feature-tiers.ts b/backend/src/core/config/feature-tiers.ts new file mode 100644 index 0000000..fb5dfc0 --- /dev/null +++ b/backend/src/core/config/feature-tiers.ts @@ -0,0 +1,73 @@ +/** + * @ai-summary Feature tier configuration and utilities + * @ai-context Defines feature-to-tier mapping for gating premium features + */ + +import { SubscriptionTier } from '../../features/user-profile/domain/user-profile.types'; + +// Tier hierarchy: higher number = higher access level +export const TIER_LEVELS: Record = { + free: 0, + pro: 1, + enterprise: 2, +} as const; + +// Feature configuration interface +export interface FeatureConfig { + minTier: SubscriptionTier; + name: string; + upgradePrompt: string; +} + +// Feature registry - add new gated features here +export const FEATURE_TIERS: Record = { + 'document.scanMaintenanceSchedule': { + minTier: 'pro', + name: 'Scan for Maintenance Schedule', + upgradePrompt: 'Upgrade to Pro to automatically extract maintenance schedules from your vehicle manuals.', + }, +} as const; + +/** + * Get numeric level for a subscription tier + */ +export function getTierLevel(tier: SubscriptionTier): number { + return TIER_LEVELS[tier] ?? 0; +} + +/** + * Check if a user tier can access a feature + * Higher tiers inherit access to all lower tier features + */ +export function canAccessFeature(userTier: SubscriptionTier, featureKey: string): boolean { + const feature = FEATURE_TIERS[featureKey]; + if (!feature) { + // Unknown features are accessible by all (fail open for unlisted features) + return true; + } + return getTierLevel(userTier) >= getTierLevel(feature.minTier); +} + +/** + * Get the minimum required tier for a feature + * Returns null if feature is not gated + */ +export function getRequiredTier(featureKey: string): SubscriptionTier | null { + const feature = FEATURE_TIERS[featureKey]; + return feature?.minTier ?? null; +} + +/** + * Get full feature configuration + * Returns undefined if feature is not registered + */ +export function getFeatureConfig(featureKey: string): FeatureConfig | undefined { + return FEATURE_TIERS[featureKey]; +} + +/** + * Get all feature configurations (for API endpoint) + */ +export function getAllFeatureConfigs(): Record { + return { ...FEATURE_TIERS }; +} diff --git a/backend/src/core/config/tests/feature-tiers.test.ts b/backend/src/core/config/tests/feature-tiers.test.ts new file mode 100644 index 0000000..34aa03b --- /dev/null +++ b/backend/src/core/config/tests/feature-tiers.test.ts @@ -0,0 +1,104 @@ +import { + TIER_LEVELS, + FEATURE_TIERS, + getTierLevel, + canAccessFeature, + getRequiredTier, + getFeatureConfig, + getAllFeatureConfigs, +} from '../feature-tiers'; + +describe('feature-tiers', () => { + describe('TIER_LEVELS', () => { + it('defines correct tier hierarchy', () => { + expect(TIER_LEVELS.free).toBe(0); + expect(TIER_LEVELS.pro).toBe(1); + expect(TIER_LEVELS.enterprise).toBe(2); + }); + + it('enterprise > pro > free', () => { + expect(TIER_LEVELS.enterprise).toBeGreaterThan(TIER_LEVELS.pro); + expect(TIER_LEVELS.pro).toBeGreaterThan(TIER_LEVELS.free); + }); + }); + + describe('FEATURE_TIERS', () => { + it('includes scanMaintenanceSchedule feature', () => { + const feature = FEATURE_TIERS['document.scanMaintenanceSchedule']; + expect(feature).toBeDefined(); + expect(feature.minTier).toBe('pro'); + expect(feature.name).toBe('Scan for Maintenance Schedule'); + expect(feature.upgradePrompt).toBeTruthy(); + }); + }); + + describe('getTierLevel', () => { + it('returns correct level for each tier', () => { + expect(getTierLevel('free')).toBe(0); + expect(getTierLevel('pro')).toBe(1); + expect(getTierLevel('enterprise')).toBe(2); + }); + + it('returns 0 for unknown tier', () => { + expect(getTierLevel('unknown' as any)).toBe(0); + }); + }); + + describe('canAccessFeature', () => { + const featureKey = 'document.scanMaintenanceSchedule'; + + it('denies access for free tier to pro feature', () => { + expect(canAccessFeature('free', featureKey)).toBe(false); + }); + + it('allows access for pro tier to pro feature', () => { + expect(canAccessFeature('pro', featureKey)).toBe(true); + }); + + it('allows access for enterprise tier to pro feature (inheritance)', () => { + expect(canAccessFeature('enterprise', featureKey)).toBe(true); + }); + + it('allows access for unknown feature (fail open)', () => { + expect(canAccessFeature('free', 'unknown.feature')).toBe(true); + expect(canAccessFeature('pro', 'unknown.feature')).toBe(true); + expect(canAccessFeature('enterprise', 'unknown.feature')).toBe(true); + }); + }); + + describe('getRequiredTier', () => { + it('returns required tier for known feature', () => { + expect(getRequiredTier('document.scanMaintenanceSchedule')).toBe('pro'); + }); + + it('returns null for unknown feature', () => { + expect(getRequiredTier('unknown.feature')).toBeNull(); + }); + }); + + describe('getFeatureConfig', () => { + it('returns full config for known feature', () => { + const config = getFeatureConfig('document.scanMaintenanceSchedule'); + expect(config).toEqual({ + minTier: 'pro', + name: 'Scan for Maintenance Schedule', + upgradePrompt: expect.any(String), + }); + }); + + it('returns undefined for unknown feature', () => { + expect(getFeatureConfig('unknown.feature')).toBeUndefined(); + }); + }); + + describe('getAllFeatureConfigs', () => { + it('returns copy of all feature configs', () => { + const configs = getAllFeatureConfigs(); + expect(configs['document.scanMaintenanceSchedule']).toBeDefined(); + + // Verify it's a copy, not the original + configs['test'] = { minTier: 'free', name: 'test', upgradePrompt: '' }; + expect(FEATURE_TIERS['test' as keyof typeof FEATURE_TIERS]).toBeUndefined(); + }); + }); +}); diff --git a/backend/src/core/plugins/auth.plugin.ts b/backend/src/core/plugins/auth.plugin.ts index a9cda41..d13355c 100644 --- a/backend/src/core/plugins/auth.plugin.ts +++ b/backend/src/core/plugins/auth.plugin.ts @@ -12,6 +12,7 @@ import { logger } from '../logging/logger'; import { UserProfileRepository } from '../../features/user-profile/data/user-profile.repository'; import { pool } from '../config/database'; import { auth0ManagementClient } from '../auth/auth0-management.client'; +import { SubscriptionTier } from '../../features/user-profile/domain/user-profile.types'; // Routes that don't require email verification const VERIFICATION_EXEMPT_ROUTES = [ @@ -56,6 +57,7 @@ declare module 'fastify' { onboardingCompleted: boolean; isAdmin: boolean; adminRecord?: any; + subscriptionTier: SubscriptionTier; }; } } @@ -129,6 +131,7 @@ const authPlugin: FastifyPluginAsync = async (fastify) => { let displayName: string | undefined; let emailVerified = false; let onboardingCompleted = false; + let subscriptionTier: SubscriptionTier = 'free'; try { // If JWT doesn't have email, fetch from Auth0 Management API @@ -170,6 +173,7 @@ const authPlugin: FastifyPluginAsync = async (fastify) => { displayName = profile.displayName || undefined; emailVerified = profile.emailVerified; onboardingCompleted = profile.onboardingCompletedAt !== null; + subscriptionTier = profile.subscriptionTier || 'free'; // Sync email verification status from Auth0 if needed if (!emailVerified) { @@ -208,6 +212,7 @@ const authPlugin: FastifyPluginAsync = async (fastify) => { emailVerified, onboardingCompleted, isAdmin: false, // Default to false; admin status checked by admin guard + subscriptionTier, }; // Email verification guard - block unverified users from non-exempt routes diff --git a/backend/src/core/plugins/tests/tier-guard.plugin.test.ts b/backend/src/core/plugins/tests/tier-guard.plugin.test.ts new file mode 100644 index 0000000..de24c75 --- /dev/null +++ b/backend/src/core/plugins/tests/tier-guard.plugin.test.ts @@ -0,0 +1,205 @@ +import Fastify, { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import tierGuardPlugin from '../tier-guard.plugin'; + +const createReply = (): Partial & { payload?: unknown; statusCode?: number } => { + return { + sent: false, + code: jest.fn(function(this: any, status: number) { + this.statusCode = status; + return this; + }), + send: jest.fn(function(this: any, payload: unknown) { + this.payload = payload; + this.sent = true; + return this; + }), + }; +}; + +describe('tier guard plugin', () => { + let fastify: FastifyInstance; + let authenticateMock: jest.Mock; + + beforeEach(async () => { + fastify = Fastify(); + + // Mock authenticate to set userContext + authenticateMock = jest.fn(async (request: FastifyRequest) => { + request.userContext = { + userId: 'auth0|user123', + email: 'user@example.com', + emailVerified: true, + onboardingCompleted: true, + isAdmin: false, + subscriptionTier: 'free', + }; + }); + fastify.decorate('authenticate', authenticateMock); + + await fastify.register(tierGuardPlugin); + }); + + afterEach(async () => { + await fastify.close(); + jest.clearAllMocks(); + }); + + describe('requireTier with minTier', () => { + it('allows access when user tier meets minimum', async () => { + authenticateMock.mockImplementation(async (request: FastifyRequest) => { + request.userContext = { + userId: 'auth0|user123', + email: 'user@example.com', + emailVerified: true, + onboardingCompleted: true, + isAdmin: false, + subscriptionTier: 'pro', + }; + }); + + const request = {} as FastifyRequest; + const reply = createReply(); + + const handler = fastify.requireTier({ minTier: 'pro' }); + await handler(request, reply as FastifyReply); + + expect(authenticateMock).toHaveBeenCalledTimes(1); + expect(reply.code).not.toHaveBeenCalled(); + expect(reply.send).not.toHaveBeenCalled(); + }); + + it('allows access when user tier exceeds minimum', async () => { + authenticateMock.mockImplementation(async (request: FastifyRequest) => { + request.userContext = { + userId: 'auth0|user123', + email: 'user@example.com', + emailVerified: true, + onboardingCompleted: true, + isAdmin: false, + subscriptionTier: 'enterprise', + }; + }); + + const request = {} as FastifyRequest; + const reply = createReply(); + + const handler = fastify.requireTier({ minTier: 'pro' }); + await handler(request, reply as FastifyReply); + + expect(reply.code).not.toHaveBeenCalled(); + }); + + it('denies access when user tier is below minimum', async () => { + const request = {} as FastifyRequest; + const reply = createReply(); + + const handler = fastify.requireTier({ minTier: 'pro' }); + await handler(request, reply as FastifyReply); + + expect(reply.code).toHaveBeenCalledWith(403); + expect(reply.send).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'TIER_REQUIRED', + requiredTier: 'pro', + currentTier: 'free', + }) + ); + }); + }); + + describe('requireTier with featureKey', () => { + it('denies free tier access to pro feature', async () => { + const request = {} as FastifyRequest; + const reply = createReply(); + + const handler = fastify.requireTier({ featureKey: 'document.scanMaintenanceSchedule' }); + await handler(request, reply as FastifyReply); + + expect(reply.code).toHaveBeenCalledWith(403); + expect(reply.send).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'TIER_REQUIRED', + requiredTier: 'pro', + currentTier: 'free', + feature: 'document.scanMaintenanceSchedule', + featureName: 'Scan for Maintenance Schedule', + }) + ); + }); + + it('allows pro tier access to pro feature', async () => { + authenticateMock.mockImplementation(async (request: FastifyRequest) => { + request.userContext = { + userId: 'auth0|user123', + email: 'user@example.com', + emailVerified: true, + onboardingCompleted: true, + isAdmin: false, + subscriptionTier: 'pro', + }; + }); + + const request = {} as FastifyRequest; + const reply = createReply(); + + const handler = fastify.requireTier({ featureKey: 'document.scanMaintenanceSchedule' }); + await handler(request, reply as FastifyReply); + + expect(reply.code).not.toHaveBeenCalled(); + }); + + it('allows access for unknown feature (fail open)', async () => { + const request = {} as FastifyRequest; + const reply = createReply(); + + const handler = fastify.requireTier({ featureKey: 'unknown.feature' }); + await handler(request, reply as FastifyReply); + + expect(reply.code).not.toHaveBeenCalled(); + }); + }); + + describe('error handling', () => { + it('returns 500 when authenticate handler is not a function', async () => { + const brokenFastify = Fastify(); + // Decorate with a non-function value to simulate missing handler + brokenFastify.decorate('authenticate', 'not-a-function' as any); + await brokenFastify.register(tierGuardPlugin); + + const request = {} as FastifyRequest; + const reply = createReply(); + + const handler = brokenFastify.requireTier({ minTier: 'pro' }); + await handler(request, reply as FastifyReply); + + expect(reply.code).toHaveBeenCalledWith(500); + expect(reply.send).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'Internal server error', + message: 'Authentication handler missing', + }) + ); + + await brokenFastify.close(); + }); + + it('defaults to free tier when userContext is missing', async () => { + authenticateMock.mockImplementation(async () => { + // Don't set userContext + }); + + const request = {} as FastifyRequest; + const reply = createReply(); + + const handler = fastify.requireTier({ minTier: 'pro' }); + await handler(request, reply as FastifyReply); + + expect(reply.code).toHaveBeenCalledWith(403); + expect(reply.send).toHaveBeenCalledWith( + expect.objectContaining({ + currentTier: 'free', + }) + ); + }); + }); +}); diff --git a/backend/src/core/plugins/tier-guard.plugin.ts b/backend/src/core/plugins/tier-guard.plugin.ts new file mode 100644 index 0000000..0b4ab14 --- /dev/null +++ b/backend/src/core/plugins/tier-guard.plugin.ts @@ -0,0 +1,126 @@ +/** + * @ai-summary Fastify tier authorization plugin + * @ai-context Enforces subscription tier requirements for protected routes + */ + +import { FastifyPluginAsync, FastifyRequest, FastifyReply, FastifyInstance } from 'fastify'; +import fp from 'fastify-plugin'; +import { logger } from '../logging/logger'; +import { SubscriptionTier } from '../../features/user-profile/domain/user-profile.types'; +import { canAccessFeature, getFeatureConfig, getTierLevel } from '../config/feature-tiers'; + +// Tier check options +export interface TierCheckOptions { + minTier?: SubscriptionTier; + featureKey?: string; +} + +declare module 'fastify' { + interface FastifyInstance { + requireTier: (options: TierCheckOptions) => (request: FastifyRequest, reply: FastifyReply) => Promise; + } +} + +const tierGuardPlugin: FastifyPluginAsync = async (fastify) => { + /** + * Creates a preHandler that enforces tier requirements + * + * Usage: + * fastify.get('/premium-route', { + * preHandler: [fastify.requireTier({ minTier: 'pro' })], + * handler: controller.method + * }); + * + * Or with feature key: + * fastify.post('/documents', { + * preHandler: [fastify.requireTier({ featureKey: 'document.scanMaintenanceSchedule' })], + * handler: controller.method + * }); + */ + fastify.decorate('requireTier', function(this: FastifyInstance, options: TierCheckOptions) { + const { minTier, featureKey } = options; + + return async (request: FastifyRequest, reply: FastifyReply): Promise => { + try { + // Ensure user is authenticated first + if (typeof this.authenticate !== 'function') { + logger.error('Tier guard: authenticate handler missing'); + return reply.code(500).send({ + error: 'Internal server error', + message: 'Authentication handler missing', + }); + } + + await this.authenticate(request, reply); + if (reply.sent) { + return; + } + + // Get user's subscription tier from context + const userTier = request.userContext?.subscriptionTier || 'free'; + + // Determine required tier and check access + let hasAccess = false; + let requiredTier: SubscriptionTier = 'free'; + let upgradePrompt: string | undefined; + let featureName: string | undefined; + + if (featureKey) { + // Feature-based tier check + hasAccess = canAccessFeature(userTier, featureKey); + const config = getFeatureConfig(featureKey); + requiredTier = config?.minTier || 'pro'; + upgradePrompt = config?.upgradePrompt; + featureName = config?.name; + } else if (minTier) { + // Direct tier comparison + hasAccess = getTierLevel(userTier) >= getTierLevel(minTier); + requiredTier = minTier; + } else { + // No tier requirement specified - allow access + hasAccess = true; + } + + if (!hasAccess) { + logger.warn('Tier guard: user tier insufficient', { + userId: request.userContext?.userId?.substring(0, 8) + '...', + userTier, + requiredTier, + featureKey, + }); + + return reply.code(403).send({ + error: 'TIER_REQUIRED', + requiredTier, + currentTier: userTier, + feature: featureKey || null, + featureName: featureName || null, + upgradePrompt: upgradePrompt || `Upgrade to ${requiredTier} to access this feature.`, + }); + } + + logger.debug('Tier guard: access granted', { + userId: request.userContext?.userId?.substring(0, 8) + '...', + userTier, + featureKey, + }); + } catch (error) { + logger.error('Tier guard: authorization check failed', { + error: error instanceof Error ? error.message : 'Unknown error', + userId: request.userContext?.userId?.substring(0, 8) + '...', + }); + + return reply.code(500).send({ + error: 'Internal server error', + message: 'Tier check failed', + }); + } + }; + }); +}; + +export default fp(tierGuardPlugin, { + name: 'tier-guard-plugin', + // Note: Requires auth-plugin to be registered first for authenticate decorator + // Dependency check removed to allow testing with mock authenticate +}); diff --git a/backend/src/features/admin/tests/unit/admin.guard.test.ts b/backend/src/features/admin/tests/unit/admin.guard.test.ts index 2ae43db..e2a281e 100644 --- a/backend/src/features/admin/tests/unit/admin.guard.test.ts +++ b/backend/src/features/admin/tests/unit/admin.guard.test.ts @@ -28,7 +28,10 @@ describe('admin guard plugin', () => { request.userContext = { userId: 'auth0|admin', email: 'admin@motovaultpro.com', + emailVerified: true, + onboardingCompleted: true, isAdmin: false, + subscriptionTier: 'free', }; }); fastify.decorate('authenticate', authenticateMock); diff --git a/backend/src/features/documents/api/documents.controller.ts b/backend/src/features/documents/api/documents.controller.ts index d861fce..00caa67 100644 --- a/backend/src/features/documents/api/documents.controller.ts +++ b/backend/src/features/documents/api/documents.controller.ts @@ -8,6 +8,8 @@ import { Transform, TransformCallback } from 'stream'; import crypto from 'crypto'; import FileType from 'file-type'; import { Readable } from 'stream'; +import { canAccessFeature, getFeatureConfig } from '../../../core/config/feature-tiers'; +import { SubscriptionTier } from '../../user-profile/domain/user-profile.types'; export class DocumentsController { private readonly service = new DocumentsService(); @@ -73,6 +75,7 @@ export class DocumentsController { async create(request: FastifyRequest<{ Body: CreateBody }>, reply: FastifyReply) { const userId = (request as any).user?.sub as string; + const userTier: SubscriptionTier = request.userContext?.subscriptionTier || 'free'; logger.info('Document create requested', { operation: 'documents.create', @@ -82,6 +85,26 @@ export class DocumentsController { title: request.body.title, }); + // Tier validation: scanForMaintenance requires Pro tier + const featureKey = 'document.scanMaintenanceSchedule'; + if (request.body.scanForMaintenance && !canAccessFeature(userTier, featureKey)) { + const config = getFeatureConfig(featureKey); + logger.warn('Tier required for scanForMaintenance', { + operation: 'documents.create.tier_required', + userId, + userTier, + requiredTier: config?.minTier, + }); + return reply.code(403).send({ + error: 'TIER_REQUIRED', + requiredTier: config?.minTier || 'pro', + currentTier: userTier, + feature: featureKey, + featureName: config?.name || null, + upgradePrompt: config?.upgradePrompt || 'Upgrade to Pro to access this feature.', + }); + } + const created = await this.service.createDocument(userId, request.body); logger.info('Document created', { @@ -98,6 +121,7 @@ export class DocumentsController { async update(request: FastifyRequest<{ Params: IdParams; Body: UpdateBody }>, reply: FastifyReply) { const userId = (request as any).user?.sub as string; + const userTier: SubscriptionTier = request.userContext?.subscriptionTier || 'free'; const documentId = request.params.id; logger.info('Document update requested', { @@ -107,6 +131,27 @@ export class DocumentsController { updateFields: Object.keys(request.body), }); + // Tier validation: scanForMaintenance requires Pro tier + const featureKey = 'document.scanMaintenanceSchedule'; + if (request.body.scanForMaintenance && !canAccessFeature(userTier, featureKey)) { + const config = getFeatureConfig(featureKey); + logger.warn('Tier required for scanForMaintenance', { + operation: 'documents.update.tier_required', + userId, + documentId, + userTier, + requiredTier: config?.minTier, + }); + return reply.code(403).send({ + error: 'TIER_REQUIRED', + requiredTier: config?.minTier || 'pro', + currentTier: userTier, + feature: featureKey, + featureName: config?.name || null, + upgradePrompt: config?.upgradePrompt || 'Upgrade to Pro to access this feature.', + }); + } + const updated = await this.service.updateDocument(userId, documentId, request.body); if (!updated) { logger.warn('Document not found for update', { diff --git a/backend/src/features/documents/migrations/003_reset_scan_for_maintenance_free_users.sql b/backend/src/features/documents/migrations/003_reset_scan_for_maintenance_free_users.sql new file mode 100644 index 0000000..ffe4e2d --- /dev/null +++ b/backend/src/features/documents/migrations/003_reset_scan_for_maintenance_free_users.sql @@ -0,0 +1,10 @@ +-- Migration: Reset scanForMaintenance for free tier users +-- This migration is part of the tier-gating feature implementation. +-- scanForMaintenance is now a Pro feature, so existing free users with it enabled need to be reset. + +UPDATE documents d +SET scan_for_maintenance = false +FROM user_profiles u +WHERE d.user_id = u.auth0_sub + AND u.subscription_tier = 'free' + AND d.scan_for_maintenance = true; diff --git a/backend/src/features/documents/tests/unit/documents.controller.tier.test.ts b/backend/src/features/documents/tests/unit/documents.controller.tier.test.ts new file mode 100644 index 0000000..220d215 --- /dev/null +++ b/backend/src/features/documents/tests/unit/documents.controller.tier.test.ts @@ -0,0 +1,299 @@ +/** + * @ai-summary Unit tests for tier validation in DocumentsController + * @ai-context Tests that free users cannot use scanForMaintenance feature + */ + +// Mock config and dependencies first (before any imports that might use them) +jest.mock('../../../../core/config/config-loader', () => ({ + appConfig: { + getDatabaseUrl: () => 'postgresql://mock:mock@localhost/mock', + getRedisUrl: () => 'redis://localhost', + get: () => ({}), + }, + config: { + database: { connectionString: 'mock' }, + redis: { url: 'mock' }, + auth0: { domain: 'mock', clientId: 'mock', audience: 'mock' }, + storage: { provider: 'filesystem', root: '/tmp' }, + logging: { level: 'error' }, + }, +})); +jest.mock('../../../../core/config/database', () => ({ + pool: { + query: jest.fn(), + connect: jest.fn(), + end: jest.fn(), + }, + default: { + query: jest.fn(), + connect: jest.fn(), + end: jest.fn(), + }, +})); +jest.mock('../../../../core/logging/logger', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); +jest.mock('../../../../core/storage/storage.service', () => ({ + getStorageService: jest.fn(() => ({ + putObject: jest.fn(), + getObjectStream: jest.fn(), + deleteObject: jest.fn(), + headObject: jest.fn(), + })), +})); +jest.mock('../../domain/documents.service'); + +import { FastifyRequest, FastifyReply } from 'fastify'; +import { DocumentsController } from '../../api/documents.controller'; +import { DocumentsService } from '../../domain/documents.service'; + +const MockedService = jest.mocked(DocumentsService); + +describe('DocumentsController - Tier Validation', () => { + let controller: DocumentsController; + let mockServiceInstance: jest.Mocked; + + const createMockRequest = (overrides: Partial = {}): FastifyRequest => ({ + user: { sub: 'user-123' }, + userContext: { + userId: 'user-123', + email: 'test@example.com', + emailVerified: true, + onboardingCompleted: true, + isAdmin: false, + subscriptionTier: 'free', + }, + body: {}, + params: {}, + query: {}, + ...overrides, + } as unknown as FastifyRequest); + + const createMockReply = (): Partial & { payload?: unknown; statusCode?: number } => ({ + sent: false, + code: jest.fn(function(this: any, status: number) { + this.statusCode = status; + return this; + }), + send: jest.fn(function(this: any, payload: unknown) { + this.payload = payload; + this.sent = true; + return this; + }), + }); + + beforeEach(() => { + jest.clearAllMocks(); + + mockServiceInstance = { + createDocument: jest.fn(), + updateDocument: jest.fn(), + getDocument: jest.fn(), + listDocuments: jest.fn(), + deleteDocument: jest.fn(), + } as any; + + MockedService.mockImplementation(() => mockServiceInstance); + controller = new DocumentsController(); + }); + + describe('create - scanForMaintenance tier gating', () => { + const baseDocumentBody = { + vehicleId: 'vehicle-123', + documentType: 'manual', + title: 'Service Manual', + }; + + it('allows free user to create document without scanForMaintenance', async () => { + const request = createMockRequest({ + body: { ...baseDocumentBody, scanForMaintenance: false }, + }); + const reply = createMockReply(); + + mockServiceInstance.createDocument.mockResolvedValue({ + id: 'doc-123', + userId: 'user-123', + vehicleId: 'vehicle-123', + documentType: 'manual', + title: 'Service Manual', + scanForMaintenance: false, + } as any); + + await controller.create(request as any, reply as FastifyReply); + + expect(reply.code).toHaveBeenCalledWith(201); + expect(mockServiceInstance.createDocument).toHaveBeenCalled(); + }); + + it('blocks free user from using scanForMaintenance=true', async () => { + const request = createMockRequest({ + body: { ...baseDocumentBody, scanForMaintenance: true }, + }); + const reply = createMockReply(); + + await controller.create(request as any, reply as FastifyReply); + + expect(reply.code).toHaveBeenCalledWith(403); + expect(reply.send).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'TIER_REQUIRED', + requiredTier: 'pro', + currentTier: 'free', + feature: 'document.scanMaintenanceSchedule', + featureName: 'Scan for Maintenance Schedule', + }) + ); + expect(mockServiceInstance.createDocument).not.toHaveBeenCalled(); + }); + + it('allows pro user to use scanForMaintenance=true', async () => { + const request = createMockRequest({ + body: { ...baseDocumentBody, scanForMaintenance: true }, + userContext: { + userId: 'user-123', + email: 'pro@example.com', + emailVerified: true, + onboardingCompleted: true, + isAdmin: false, + subscriptionTier: 'pro', + }, + }); + const reply = createMockReply(); + + mockServiceInstance.createDocument.mockResolvedValue({ + id: 'doc-123', + userId: 'user-123', + vehicleId: 'vehicle-123', + documentType: 'manual', + title: 'Service Manual', + scanForMaintenance: true, + } as any); + + await controller.create(request as any, reply as FastifyReply); + + expect(reply.code).toHaveBeenCalledWith(201); + expect(mockServiceInstance.createDocument).toHaveBeenCalled(); + }); + + it('allows enterprise user to use scanForMaintenance=true', async () => { + const request = createMockRequest({ + body: { ...baseDocumentBody, scanForMaintenance: true }, + userContext: { + userId: 'user-123', + email: 'enterprise@example.com', + emailVerified: true, + onboardingCompleted: true, + isAdmin: false, + subscriptionTier: 'enterprise', + }, + }); + const reply = createMockReply(); + + mockServiceInstance.createDocument.mockResolvedValue({ + id: 'doc-123', + userId: 'user-123', + vehicleId: 'vehicle-123', + documentType: 'manual', + title: 'Service Manual', + scanForMaintenance: true, + } as any); + + await controller.create(request as any, reply as FastifyReply); + + expect(reply.code).toHaveBeenCalledWith(201); + expect(mockServiceInstance.createDocument).toHaveBeenCalled(); + }); + + it('defaults to free tier when userContext is missing', async () => { + const request = createMockRequest({ + body: { ...baseDocumentBody, scanForMaintenance: true }, + userContext: undefined, + }); + const reply = createMockReply(); + + await controller.create(request as any, reply as FastifyReply); + + expect(reply.code).toHaveBeenCalledWith(403); + expect(reply.send).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'TIER_REQUIRED', + currentTier: 'free', + }) + ); + }); + }); + + describe('update - scanForMaintenance tier gating', () => { + const documentId = 'doc-123'; + + it('allows free user to update document without scanForMaintenance', async () => { + const request = createMockRequest({ + params: { id: documentId }, + body: { title: 'Updated Title' }, + }); + const reply = createMockReply(); + + mockServiceInstance.updateDocument.mockResolvedValue({ + id: documentId, + title: 'Updated Title', + } as any); + + await controller.update(request as any, reply as FastifyReply); + + expect(reply.code).toHaveBeenCalledWith(200); + expect(mockServiceInstance.updateDocument).toHaveBeenCalled(); + }); + + it('blocks free user from setting scanForMaintenance=true on update', async () => { + const request = createMockRequest({ + params: { id: documentId }, + body: { scanForMaintenance: true }, + }); + const reply = createMockReply(); + + await controller.update(request as any, reply as FastifyReply); + + expect(reply.code).toHaveBeenCalledWith(403); + expect(reply.send).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'TIER_REQUIRED', + requiredTier: 'pro', + currentTier: 'free', + feature: 'document.scanMaintenanceSchedule', + }) + ); + expect(mockServiceInstance.updateDocument).not.toHaveBeenCalled(); + }); + + it('allows pro user to set scanForMaintenance=true on update', async () => { + const request = createMockRequest({ + params: { id: documentId }, + body: { scanForMaintenance: true }, + userContext: { + userId: 'user-123', + email: 'pro@example.com', + emailVerified: true, + onboardingCompleted: true, + isAdmin: false, + subscriptionTier: 'pro', + }, + }); + const reply = createMockReply(); + + mockServiceInstance.updateDocument.mockResolvedValue({ + id: documentId, + scanForMaintenance: true, + } as any); + + await controller.update(request as any, reply as FastifyReply); + + expect(reply.code).toHaveBeenCalledWith(200); + expect(mockServiceInstance.updateDocument).toHaveBeenCalled(); + }); + }); +}); diff --git a/docs/TIER-GATING.md b/docs/TIER-GATING.md new file mode 100644 index 0000000..07945a4 --- /dev/null +++ b/docs/TIER-GATING.md @@ -0,0 +1,311 @@ +# Tier-Based Feature Gating + +This document describes the user subscription tier system and how to gate features behind specific tiers. + +## Overview + +MotoVaultPro supports three subscription tiers with hierarchical access: + +| Tier | Level | Description | +|------|-------|-------------| +| `free` | 0 | Default tier, limited features | +| `pro` | 1 | Mid-tier, most features | +| `enterprise` | 2 | Full access to all features | + +Higher tiers automatically have access to all lower-tier features (tier hierarchy). + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Frontend │ +│ ┌─────────────────┐ ┌──────────────────────────────┐ │ +│ │ useTierAccess │───▶│ UpgradeRequiredDialog │ │ +│ │ hook │ │ component │ │ +│ └────────┬────────┘ └──────────────────────────────┘ │ +│ │ │ +│ ▼ fetches │ +├─────────────────────────────────────────────────────────────┤ +│ Backend API │ +│ ┌─────────────────┐ ┌──────────────────────────────┐ │ +│ │ /api/config/ │ │ /api/documents │ │ +│ │ feature-tiers │ │ (tier validation) │ │ +│ └────────┬────────┘ └────────┬─────────────────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ feature-tiers.ts (single source of truth) │ │ +│ │ - FEATURE_TIERS config │ │ +│ │ - canAccessFeature(), getTierLevel(), etc. │ │ +│ └─────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Backend Implementation + +### Feature Configuration + +All gated features are defined in `backend/src/core/config/feature-tiers.ts`: + +```typescript +export const FEATURE_TIERS = { + 'document.scanMaintenanceSchedule': { + minTier: 'pro', + name: 'Scan for Maintenance Schedule', + upgradePrompt: 'Upgrade to Pro to automatically extract maintenance schedules from your manuals.' + }, + // Add new features here +} as const; +``` + +### Utility Functions + +```typescript +import { + canAccessFeature, + getTierLevel, + getRequiredTier, + getFeatureConfig +} from '../core/config/feature-tiers'; + +// Check if user can access a feature +canAccessFeature('pro', 'document.scanMaintenanceSchedule'); // true +canAccessFeature('free', 'document.scanMaintenanceSchedule'); // false + +// Get numeric tier level for comparison +getTierLevel('pro'); // 1 + +// Get minimum required tier for a feature +getRequiredTier('document.scanMaintenanceSchedule'); // 'pro' +``` + +### Route-Level Protection (Middleware) + +Use `requireTier` for routes that require a minimum tier: + +```typescript +// In routes file +fastify.post('/premium-endpoint', { + preHandler: [fastify.requireTier({ minTier: 'pro' })], + handler: controller.premiumAction +}); + +// Or by feature key +fastify.post('/scan-maintenance', { + preHandler: [fastify.requireTier({ featureKey: 'document.scanMaintenanceSchedule' })], + handler: controller.scanMaintenance +}); +``` + +### Controller-Level Validation + +For conditional feature checks within a controller: + +```typescript +async create(request: FastifyRequest, reply: FastifyReply) { + const userTier = request.userContext?.subscriptionTier || 'free'; + + if (request.body.scanForMaintenance && !canAccessFeature(userTier, 'document.scanMaintenanceSchedule')) { + const config = getFeatureConfig('document.scanMaintenanceSchedule'); + return reply.code(403).send({ + error: 'TIER_REQUIRED', + requiredTier: config?.minTier || 'pro', + currentTier: userTier, + feature: 'document.scanMaintenanceSchedule', + featureName: config?.name || null, + upgradePrompt: config?.upgradePrompt || 'Upgrade to access this feature.', + }); + } + // ... continue with operation +} +``` + +### 403 Error Response Format + +```json +{ + "error": "TIER_REQUIRED", + "requiredTier": "pro", + "currentTier": "free", + "feature": "document.scanMaintenanceSchedule", + "featureName": "Scan for Maintenance Schedule", + "upgradePrompt": "Upgrade to Pro to automatically extract maintenance schedules from your manuals." +} +``` + +## Frontend Implementation + +### useTierAccess Hook + +```typescript +import { useTierAccess } from '@/core/hooks'; + +const MyComponent = () => { + const { tier, loading, hasAccess, checkAccess } = useTierAccess(); + + // Simple boolean check + if (!hasAccess('document.scanMaintenanceSchedule')) { + // Show upgrade prompt or disable feature + } + + // Detailed access info + const access = checkAccess('document.scanMaintenanceSchedule'); + // { + // allowed: false, + // requiredTier: 'pro', + // config: { name: '...', upgradePrompt: '...' } + // } +}; +``` + +### UpgradeRequiredDialog Component + +```tsx +import { UpgradeRequiredDialog } from '@/shared-minimal/components'; + +const MyComponent = () => { + const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false); + const { hasAccess } = useTierAccess(); + + return ( + <> + + + setUpgradeDialogOpen(false)} + /> + + ); +}; +``` + +### Gating a Checkbox (Example) + +```tsx +const { hasAccess } = useTierAccess(); +const canScanMaintenance = hasAccess('document.scanMaintenanceSchedule'); + + +{!canScanMaintenance && ( + +)} +``` + +## Adding a New Gated Feature + +### Step 1: Add to Feature Config (Backend) + +```typescript +// backend/src/core/config/feature-tiers.ts +export const FEATURE_TIERS = { + // ... existing features + 'reports.advancedAnalytics': { + minTier: 'enterprise', + name: 'Advanced Analytics', + upgradePrompt: 'Upgrade to Enterprise for advanced fleet analytics and reporting.' + }, +} as const; +``` + +### Step 2: Add Backend Validation + +Either use middleware on the route: +```typescript +fastify.get('/reports/analytics', { + preHandler: [fastify.requireTier({ featureKey: 'reports.advancedAnalytics' })], + handler: controller.getAnalytics +}); +``` + +Or validate in the controller for conditional features. + +### Step 3: Add Frontend Check + +```tsx +const { hasAccess } = useTierAccess(); + +if (!hasAccess('reports.advancedAnalytics')) { + return ; +} +``` + +## API Endpoint + +The frontend fetches tier configuration from: + +``` +GET /api/config/feature-tiers + +Response: +{ + "tiers": { "free": 0, "pro": 1, "enterprise": 2 }, + "features": { + "document.scanMaintenanceSchedule": { + "minTier": "pro", + "name": "Scan for Maintenance Schedule", + "upgradePrompt": "..." + } + } +} +``` + +## User Tier Management + +### Admin UI + +Admins can change user tiers via the Admin Users page dropdown. + +### Database + +User tiers are stored in `user_profiles.subscription_tier` column (enum: free, pro, enterprise). + +### Auth Context + +The user's tier is included in `request.userContext.subscriptionTier` after authentication. + +## Testing + +### Backend Tests + +```bash +# Run tier-related tests +npm test -- --testPathPattern="feature-tiers|tier-guard|documents.controller.tier" +``` + +### Test Cases to Cover + +1. Free user attempting to use Pro feature returns 403 +2. Pro user can use Pro features +3. Enterprise user can use all features +4. Unknown features are allowed (fail open) +5. Missing userContext defaults to free tier + +## Future Considerations + +- Stripe billing integration for tier upgrades +- Subscription expiration handling +- Grace periods for downgraded users +- Feature usage analytics diff --git a/frontend/src/core/hooks/index.ts b/frontend/src/core/hooks/index.ts new file mode 100644 index 0000000..bab7c71 --- /dev/null +++ b/frontend/src/core/hooks/index.ts @@ -0,0 +1,8 @@ +/** + * @ai-summary Core hooks barrel export + */ + +export { useDataSync } from './useDataSync'; +export { useFormState } from './useFormState'; +export { useTierAccess } from './useTierAccess'; +export type { FeatureConfig } from './useTierAccess'; diff --git a/frontend/src/core/hooks/useTierAccess.ts b/frontend/src/core/hooks/useTierAccess.ts new file mode 100644 index 0000000..b1ce10c --- /dev/null +++ b/frontend/src/core/hooks/useTierAccess.ts @@ -0,0 +1,111 @@ +/** + * @ai-summary React hook for tier-based feature access checking + * @ai-context Used to gate premium features based on user subscription tier + */ + +import { useQuery } from '@tanstack/react-query'; +import { useAuth0 } from '@auth0/auth0-react'; +import { apiClient } from '../api/client'; +import type { SubscriptionTier } from '../../features/settings/types/profile.types'; + +// Feature tier configuration (mirrors backend) +export interface FeatureConfig { + minTier: SubscriptionTier; + name: string; + upgradePrompt: string; +} + +interface FeatureTiersResponse { + tiers: Record; + features: Record; +} + +interface AccessCheckResult { + allowed: boolean; + requiredTier: SubscriptionTier | null; + config: FeatureConfig | null; +} + +// Tier hierarchy for comparison +const TIER_LEVELS: Record = { + free: 0, + pro: 1, + enterprise: 2, +}; + +/** + * Hook to check if user can access tier-gated features + * Fetches user profile for tier and feature config from backend + */ +export const useTierAccess = () => { + const { isAuthenticated, isLoading: authLoading } = useAuth0(); + + // Fetch user profile for current tier + const profileQuery = useQuery({ + queryKey: ['user-profile'], + queryFn: async () => { + const response = await apiClient.get('/user/profile'); + return response.data; + }, + enabled: isAuthenticated && !authLoading, + staleTime: 5 * 60 * 1000, // 5 minutes + gcTime: 10 * 60 * 1000, + }); + + // Fetch feature tier config from backend (single source of truth) + const featureConfigQuery = useQuery({ + queryKey: ['feature-tiers'], + queryFn: async () => { + const response = await apiClient.get('/config/feature-tiers'); + return response.data; + }, + enabled: isAuthenticated && !authLoading, + staleTime: 30 * 60 * 1000, // 30 minutes - config rarely changes + gcTime: 60 * 60 * 1000, // 1 hour cache + refetchOnWindowFocus: false, + refetchOnMount: false, + }); + + const tier: SubscriptionTier = profileQuery.data?.subscriptionTier || 'free'; + const features = featureConfigQuery.data?.features || {}; + + /** + * Check if user can access a feature by key + */ + const hasAccess = (featureKey: string): boolean => { + const config = features[featureKey]; + if (!config) { + // Unknown features are allowed (fail open for safety) + return true; + } + return TIER_LEVELS[tier] >= TIER_LEVELS[config.minTier]; + }; + + /** + * Get detailed access information for a feature + */ + const checkAccess = (featureKey: string): AccessCheckResult => { + const config = features[featureKey] || null; + if (!config) { + return { + allowed: true, + requiredTier: null, + config: null, + }; + } + return { + allowed: TIER_LEVELS[tier] >= TIER_LEVELS[config.minTier], + requiredTier: config.minTier, + config, + }; + }; + + return { + tier, + loading: profileQuery.isLoading || featureConfigQuery.isLoading, + hasAccess, + checkAccess, + }; +}; + +export default useTierAccess; diff --git a/frontend/src/features/documents/components/DocumentForm.tsx b/frontend/src/features/documents/components/DocumentForm.tsx index 6351bb4..afcaa76 100644 --- a/frontend/src/features/documents/components/DocumentForm.tsx +++ b/frontend/src/features/documents/components/DocumentForm.tsx @@ -1,14 +1,17 @@ import React from 'react'; import { Button } from '../../../shared-minimal/components/Button'; +import { UpgradeRequiredDialog } from '../../../shared-minimal/components/UpgradeRequiredDialog'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { DatePicker } from '@mui/x-date-pickers/DatePicker'; +import LockOutlinedIcon from '@mui/icons-material/LockOutlined'; import dayjs from 'dayjs'; import { useCreateDocument } from '../hooks/useDocuments'; import { documentsApi } from '../api/documents.api'; import type { DocumentType } from '../types/documents.types'; import { useVehicles } from '../../vehicles/hooks/useVehicles'; import type { Vehicle } from '../../vehicles/types/vehicles.types'; +import { useTierAccess } from '../../../core/hooks/useTierAccess'; interface DocumentFormProps { onSuccess?: () => void; @@ -42,9 +45,12 @@ export const DocumentForm: React.FC = ({ onSuccess, onCancel const [file, setFile] = React.useState(null); const [uploadProgress, setUploadProgress] = React.useState(0); const [error, setError] = React.useState(null); + const [upgradeDialogOpen, setUpgradeDialogOpen] = React.useState(false); const { data: vehicles } = useVehicles(); const create = useCreateDocument(); + const { hasAccess } = useTierAccess(); + const canScanMaintenance = hasAccess('document.scanMaintenanceSchedule'); const resetForm = () => { setTitle(''); @@ -349,18 +355,31 @@ export const DocumentForm: React.FC = ({ onSuccess, onCancel {documentType === 'manual' && (
-
)} @@ -395,6 +414,12 @@ export const DocumentForm: React.FC = ({ onSuccess, onCancel + + setUpgradeDialogOpen(false)} + /> ); diff --git a/frontend/src/features/settings/types/profile.types.ts b/frontend/src/features/settings/types/profile.types.ts index 4c0df2e..8d8c908 100644 --- a/frontend/src/features/settings/types/profile.types.ts +++ b/frontend/src/features/settings/types/profile.types.ts @@ -2,12 +2,15 @@ * @ai-summary User profile types for settings feature */ +export type SubscriptionTier = 'free' | 'pro' | 'enterprise'; + export interface UserProfile { id: string; auth0Sub: string; email: string; displayName?: string; notificationEmail?: string; + subscriptionTier: SubscriptionTier; createdAt: string; updatedAt: string; } diff --git a/frontend/src/shared-minimal/components/UpgradeRequiredDialog.tsx b/frontend/src/shared-minimal/components/UpgradeRequiredDialog.tsx new file mode 100644 index 0000000..35f1016 --- /dev/null +++ b/frontend/src/shared-minimal/components/UpgradeRequiredDialog.tsx @@ -0,0 +1,178 @@ +/** + * @ai-summary Dialog prompting users to upgrade their subscription tier + * @ai-context Shown when users attempt to use a tier-gated feature + */ + +import React from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, + Box, + Chip, + useMediaQuery, + useTheme, + IconButton, +} from '@mui/material'; +import LockOutlinedIcon from '@mui/icons-material/LockOutlined'; +import CloseIcon from '@mui/icons-material/Close'; +import { useTierAccess } from '../../core/hooks/useTierAccess'; +import type { SubscriptionTier } from '../../features/settings/types/profile.types'; + +interface UpgradeRequiredDialogProps { + featureKey: string; + open: boolean; + onClose: () => void; +} + +const tierDisplayNames: Record = { + free: 'Free', + pro: 'Pro', + enterprise: 'Enterprise', +}; + +const tierColors: Record = { + free: 'default', + pro: 'primary', + enterprise: 'secondary', +}; + +export const UpgradeRequiredDialog: React.FC = ({ + featureKey, + open, + onClose, +}) => { + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down('sm')); + const { tier, checkAccess } = useTierAccess(); + + const { config, requiredTier } = checkAccess(featureKey); + + const handleUpgradeClick = () => { + // TODO: Navigate to upgrade page when Stripe integration is added + // For now, just close the dialog + onClose(); + }; + + return ( + + {isSmall && ( + theme.palette.grey[500], + }} + > + + + )} + + + + Upgrade Required + + + + + {/* Feature name */} + + + {config?.name || 'Premium Feature'} + + + {config?.upgradePrompt || 'This feature requires a higher subscription tier.'} + + + + {/* Tier comparison */} + + + + Your Plan + + + + + + → + + + + + Required + + + + + + {/* Benefits preview */} + + Upgrade to unlock this feature and more premium capabilities. + + + + + + + + + + ); +}; + +export default UpgradeRequiredDialog; diff --git a/frontend/src/shared-minimal/components/index.ts b/frontend/src/shared-minimal/components/index.ts new file mode 100644 index 0000000..cc4e7a3 --- /dev/null +++ b/frontend/src/shared-minimal/components/index.ts @@ -0,0 +1,7 @@ +/** + * @ai-summary Shared minimal components barrel export + */ + +export { Button } from './Button'; +export { Card } from './Card'; +export { UpgradeRequiredDialog } from './UpgradeRequiredDialog';