Merge branch 'main' of 172.30.1.72:egullickson/motovaultpro
All checks were successful
Deploy to Staging / Build Images (push) Successful in 24s
Deploy to Staging / Deploy to Staging (push) Successful in 36s
Deploy to Staging / Verify Staging (push) Successful in 6s
Deploy to Staging / Notify Staging Ready (push) Successful in 5s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Mirror Base Images / Mirror Base Images (push) Successful in 24s

This commit is contained in:
Eric Gullickson
2026-01-04 20:05:22 -06:00
19 changed files with 1607 additions and 14 deletions

View File

@@ -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<FastifyInstance> {
const app = Fastify({
@@ -80,13 +82,16 @@ async function buildApp(): Promise<FastifyInstance> {
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<FastifyInstance> {
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<FastifyInstance> {
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) => {

View File

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

View File

@@ -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<SubscriptionTier, number> = {
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<string, FeatureConfig> = {
'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<string, FeatureConfig> {
return { ...FEATURE_TIERS };
}

View File

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

View File

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

View File

@@ -0,0 +1,205 @@
import Fastify, { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import tierGuardPlugin from '../tier-guard.plugin';
const createReply = (): Partial<FastifyReply> & { 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',
})
);
});
});
});

View File

@@ -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<void>;
}
}
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<void> => {
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
});

View File

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

View File

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

View File

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

View File

@@ -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<DocumentsService>;
const createMockRequest = (overrides: Partial<FastifyRequest> = {}): 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<FastifyReply> & { 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();
});
});
});

311
docs/TIER-GATING.md Normal file
View File

@@ -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 (
<>
<button
disabled={!hasAccess('document.scanMaintenanceSchedule')}
onClick={() => {
if (!hasAccess('document.scanMaintenanceSchedule')) {
setUpgradeDialogOpen(true);
} else {
// Perform action
}
}}
>
Premium Feature
</button>
<UpgradeRequiredDialog
featureKey="document.scanMaintenanceSchedule"
open={upgradeDialogOpen}
onClose={() => setUpgradeDialogOpen(false)}
/>
</>
);
};
```
### Gating a Checkbox (Example)
```tsx
const { hasAccess } = useTierAccess();
const canScanMaintenance = hasAccess('document.scanMaintenanceSchedule');
<label className={canScanMaintenance ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'}>
<input
type="checkbox"
checked={canScanMaintenance ? scanForMaintenance : false}
onChange={(e) => canScanMaintenance && setScanForMaintenance(e.target.checked)}
disabled={!canScanMaintenance}
/>
<span>Scan for Maintenance Schedule</span>
</label>
{!canScanMaintenance && (
<button onClick={() => setUpgradeDialogOpen(true)}>
<LockIcon />
</button>
)}
```
## 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 <UpgradePrompt featureKey="reports.advancedAnalytics" />;
}
```
## 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

View File

@@ -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';

View File

@@ -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<SubscriptionTier, number>;
features: Record<string, FeatureConfig>;
}
interface AccessCheckResult {
allowed: boolean;
requiredTier: SubscriptionTier | null;
config: FeatureConfig | null;
}
// Tier hierarchy for comparison
const TIER_LEVELS: Record<SubscriptionTier, number> = {
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<FeatureTiersResponse>('/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;

View File

@@ -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<DocumentFormProps> = ({ onSuccess, onCancel
const [file, setFile] = React.useState<File | null>(null);
const [uploadProgress, setUploadProgress] = React.useState<number>(0);
const [error, setError] = React.useState<string | null>(null);
const [upgradeDialogOpen, setUpgradeDialogOpen] = React.useState<boolean>(false);
const { data: vehicles } = useVehicles();
const create = useCreateDocument();
const { hasAccess } = useTierAccess();
const canScanMaintenance = hasAccess('document.scanMaintenanceSchedule');
const resetForm = () => {
setTitle('');
@@ -156,7 +162,7 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
return (
<LocalizationProvider dateAdapter={AdapterDayjs}>
<form onSubmit={handleSubmit} className="w-full">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pb-4">
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 dark:text-avus mb-1">Vehicle</label>
<select
@@ -349,18 +355,31 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
{documentType === 'manual' && (
<div className="flex items-center md:col-span-2">
<label className="flex items-center cursor-pointer">
<label className={`flex items-center ${canScanMaintenance ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'}`}>
<input
type="checkbox"
checked={scanForMaintenance}
onChange={(e) => setScanForMaintenance(e.target.checked)}
className="w-5 h-5 rounded border-slate-300 text-primary-600 focus:ring-primary-500 dark:border-silverstone dark:focus:ring-abudhabi"
checked={canScanMaintenance ? scanForMaintenance : false}
onChange={(e) => canScanMaintenance && setScanForMaintenance(e.target.checked)}
disabled={!canScanMaintenance}
className="w-5 h-5 rounded border-slate-300 text-primary-600 focus:ring-primary-500 dark:border-silverstone dark:focus:ring-abudhabi disabled:opacity-50"
/>
<span className="ml-2 text-sm font-medium text-slate-700 dark:text-avus">
Scan for Maintenance Schedule
</span>
</label>
<span className="ml-2 text-xs text-slate-500 dark:text-titanio">(Coming soon)</span>
{!canScanMaintenance && (
<button
type="button"
onClick={() => setUpgradeDialogOpen(true)}
className="ml-2 p-1 text-slate-500 hover:text-primary-600 dark:text-titanio dark:hover:text-abudhabi transition-colors"
title="Upgrade to Pro to unlock this feature"
>
<LockOutlinedIcon fontSize="small" />
</button>
)}
{canScanMaintenance && (
<span className="ml-2 text-xs text-slate-500 dark:text-titanio">(Coming soon)</span>
)}
</div>
)}
@@ -375,12 +394,14 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
<div className="flex flex-col md:col-span-2">
<label className="text-sm font-medium text-slate-700 dark:text-avus mb-1">Upload image/PDF</label>
<input
className="h-11 min-h-[44px] rounded-lg border px-3 py-2 bg-white text-gray-900 border-slate-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi dark:focus:border-abudhabi file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-500/10 file:text-primary-600 dark:file:bg-abudhabi/20 dark:file:text-abudhabi"
type="file"
accept="image/jpeg,image/png,application/pdf"
onChange={(e) => setFile(e.target.files?.[0] || null)}
/>
<div className="flex items-center h-11 min-h-[44px] rounded-lg border px-3 bg-white border-slate-300 focus-within:outline-none focus-within:ring-2 focus-within:ring-primary-500 dark:bg-scuro dark:border-silverstone dark:focus-within:ring-abudhabi dark:focus-within:border-abudhabi">
<input
className="flex-1 text-gray-900 dark:text-avus file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-500/10 file:text-primary-600 dark:file:bg-abudhabi/20 dark:file:text-abudhabi file:cursor-pointer cursor-pointer"
type="file"
accept="image/jpeg,image/png,application/pdf"
onChange={(e) => setFile(e.target.files?.[0] || null)}
/>
</div>
{uploadProgress > 0 && uploadProgress < 100 && (
<div className="text-sm text-slate-600 dark:text-titanio mt-1">Uploading... {uploadProgress}%</div>
)}
@@ -395,6 +416,12 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
<Button type="submit" className="min-h-[44px]">Create Document</Button>
<Button type="button" variant="secondary" onClick={onCancel} className="min-h-[44px]">Cancel</Button>
</div>
<UpgradeRequiredDialog
featureKey="document.scanMaintenanceSchedule"
open={upgradeDialogOpen}
onClose={() => setUpgradeDialogOpen(false)}
/>
</form>
</LocalizationProvider>
);

View File

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

View File

@@ -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<SubscriptionTier, string> = {
free: 'Free',
pro: 'Pro',
enterprise: 'Enterprise',
};
const tierColors: Record<SubscriptionTier, 'default' | 'primary' | 'secondary'> = {
free: 'default',
pro: 'primary',
enterprise: 'secondary',
};
export const UpgradeRequiredDialog: React.FC<UpgradeRequiredDialogProps> = ({
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 (
<Dialog
open={open}
onClose={onClose}
fullScreen={isSmall}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
maxHeight: isSmall ? '100%' : '90vh',
m: isSmall ? 0 : 2,
},
}}
>
{isSmall && (
<IconButton
aria-label="close"
onClick={onClose}
sx={{
position: 'absolute',
right: 8,
top: 8,
color: (theme) => theme.palette.grey[500],
}}
>
<CloseIcon />
</IconButton>
)}
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<LockOutlinedIcon color="action" />
Upgrade Required
</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Feature name */}
<Box>
<Typography variant="h6" gutterBottom>
{config?.name || 'Premium Feature'}
</Typography>
<Typography variant="body2" color="text.secondary">
{config?.upgradePrompt || 'This feature requires a higher subscription tier.'}
</Typography>
</Box>
{/* Tier comparison */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
py: 2,
px: 3,
bgcolor: 'action.hover',
borderRadius: 1,
}}
>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="caption" color="text.secondary" display="block">
Your Plan
</Typography>
<Chip
label={tierDisplayNames[tier]}
color={tierColors[tier]}
size="small"
sx={{ mt: 0.5 }}
/>
</Box>
<Typography variant="h5" color="text.secondary">
</Typography>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="caption" color="text.secondary" display="block">
Required
</Typography>
<Chip
label={requiredTier ? tierDisplayNames[requiredTier] : 'Pro'}
color={requiredTier ? tierColors[requiredTier] : 'primary'}
size="small"
sx={{ mt: 0.5 }}
/>
</Box>
</Box>
{/* Benefits preview */}
<Typography variant="body2" color="text.secondary">
Upgrade to unlock this feature and more premium capabilities.
</Typography>
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 3, pt: 1, flexDirection: isSmall ? 'column' : 'row', gap: 1 }}>
<Button
onClick={onClose}
variant="outlined"
fullWidth={isSmall}
sx={{ order: isSmall ? 2 : 1 }}
>
Maybe Later
</Button>
<Button
onClick={handleUpgradeClick}
variant="contained"
color="primary"
fullWidth={isSmall}
sx={{ order: isSmall ? 1 : 2 }}
>
Upgrade (Coming Soon)
</Button>
</DialogActions>
</Dialog>
);
};
export default UpgradeRequiredDialog;

View File

@@ -0,0 +1,7 @@
/**
* @ai-summary Shared minimal components barrel export
*/
export { Button } from './Button';
export { Card } from './Card';
export { UpgradeRequiredDialog } from './UpgradeRequiredDialog';

54
padding-issue.html Normal file
View File

@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark">
<title>MotoVaultPro</title>
<!-- Dark mode initialization - MUST run before any other scripts -->
<!-- This prevents iOS 26 Safari from overriding our dark mode preference -->
<script>
(function() {
try {
const stored = localStorage.getItem('motovaultpro-mobile-settings');
const settings = stored ? JSON.parse(stored) : {};
// Check user preference, fall back to system preference
const prefersDark = settings.darkMode !== undefined
? settings.darkMode
: window.matchMedia('(prefers-color-scheme: dark)').matches;
if (prefersDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
} catch (e) {
// Fallback to system preference on error
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
}
}
})();
</script>
<!-- Runtime config MUST load synchronously before any module scripts -->
<script src="/config.js"></script>
<script type="module" crossorigin src="/assets/index-BBzsRBvQ.js"></script>
<link rel="modulepreload" crossorigin href="/assets/react-vendor-ByzCDbub.js">
<link rel="modulepreload" crossorigin href="/assets/react-router-DaYFPv1Q.js">
<link rel="modulepreload" crossorigin href="/assets/utils-CzZZcuDR.js">
<link rel="modulepreload" crossorigin href="/assets/mui-core-CMlEYYIm.js">
<link rel="modulepreload" crossorigin href="/assets/data-CpPzLLzI.js">
<link rel="modulepreload" crossorigin href="/assets/animation-_z1WwDHu.js">
<link rel="modulepreload" crossorigin href="/assets/auth-DXZD2WD1.js">
<link rel="modulepreload" crossorigin href="/assets/mui-icons-C3PV0RzG.js">
<link rel="modulepreload" crossorigin href="/assets/forms-CxneQeFQ.js">
<link rel="stylesheet" crossorigin href="/assets/index-D1by4rQs.css">
</head>
<body>
<div id="root"></div>
</body>
</html>