Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,16 +97,49 @@ When USE_LIVE_AI=false, the system falls back to a mock provider for safe local

---

🧪 Development
## 🧪 Development

```bash
# Install dependencies
pnpm install

# Setup database
pnpm db:setup

# Run web app in dev mode
pnpm dev

# Build all packages
pnpm build
```

### Database Mode Toggle

The application supports switching between in-memory and API/database data sources via environment variables:

```bash
# Memory mode (default) - uses seed data
VITE_USE_DB=false

# API/Database mode - loads data from database via API
VITE_USE_DB=true
```

**Testing Different Modes:**

1. **Memory Mode (default)**:
- Set `VITE_USE_DB=false` in `.env.local`
- Sidebar tree loads from `apps/web/src/core/seed.ts`
- No database required
- Fast development iteration

2. **API/Database Mode**:
- Set `VITE_USE_DB=true` in `.env.local`
- Requires database setup: `pnpm db:setup`
- Sidebar tree loads from `/api/org/*` endpoints
- Data persisted in SQLite database

The toggle is handled in `useRepo.tsx` and maintains the same UI/UX regardless of data source.


---
12 changes: 12 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# API/Database Mode Toggle
# Set to true to load sidebar tree data from API/DB instead of in-memory seed data
# NOTE: import.meta.env is baked at build time - toggling VITE_USE_DB requires dev server restart
VITE_USE_DB=false

# Mock Authentication (Development only)
# Set to true to enable fallback to mock auth when API is unavailable
# Only works when VITE_USE_DB=true. Disabled by default for security.
VITE_MOCK_AUTH=false

# AI Provider Configuration
USE_LIVE_AI=false
4 changes: 2 additions & 2 deletions apps/web/src/api/middleware/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export const ActionItemsSchema = z.object({
* Middleware to validate API responses with Zod schemas
*/
export function validateResponse<T>(schema: z.ZodSchema<T>) {
return (req: any, res: any, next: any) => {
return (_req: any, res: any, next: any) => {
const originalJson = res.json;

res.json = function(data: any) {
Expand All @@ -111,7 +111,7 @@ export function validateResponse<T>(schema: z.ZodSchema<T>) {
return originalJson.call(this, {
error: 'RESPONSE_VALIDATION_ERROR',
message: 'API response does not match expected schema',
details: error instanceof z.ZodError ? error.errors : [error.message],
details: error instanceof z.ZodError ? error.errors : [(error as Error).message],
invalidData: data,
});
}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/api/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export function apiPlugin(): Plugin {
app.use('/clients', clientRoutes);

// Error handling
app.use((err: any, req: express.Request, res: express.Response, _next: express.NextFunction) => {
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error('API Error:', err);

// Generate trace ID for debugging
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/api/services/simple-auth.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const argon2 = require('argon2');
const jwt = require('jsonwebtoken');
const path = require('path');

const dbPath = process.env.DATABASE_URL?.replace('file:', '') || path.join(__dirname, '../../packages/storage/dev.db');
const dbPath = process.env.DATABASE_URL?.replace('file:', '') || path.join(__dirname, '../../../packages/storage/dev.db');
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-key-change-in-production';
const JWT_ACCESS_EXPIRES = '15m';
const JWT_REFRESH_EXPIRES = '30d';
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/api/tests/auth-refresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,8 +300,8 @@ describe('Authentication and Refresh Token Management', () => {

describe('Rate Limiting', () => {
it('should enforce rate limiting on login attempts', async () => {
const clientIp = '127.0.0.1';
let rateLimitHit = false;
const _clientIp = '127.0.0.1';
let _rateLimitHit = false;

// Make multiple failed login attempts
for (let i = 0; i < 10; i++) {
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/api/tests/cross-org-security.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, beforeAll } from 'vitest';
import request from 'supertest';
import express from 'express';
import { authRoutes } from '../routes/auth';
Expand All @@ -16,7 +16,7 @@ describe('Cross-Organization Security (IDOR Prevention)', () => {
let demoUserToken: string;
let viewerUserToken: string;
let demoOrgClientId: string;
let viewerOrgId: string;
let _viewerOrgId: string;

beforeAll(async () => {
// Set up database and seed data
Expand Down
3 changes: 1 addition & 2 deletions apps/web/src/api/tests/zod-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ import {
ClientsOverviewSchema,
ClientSummarySchema,
ClientCallsSchema,
ActionItemsSchema,
validateResponse
ActionItemsSchema
} from '../middleware/validation';

describe('Zod Response Validation', () => {
Expand Down
106 changes: 104 additions & 2 deletions apps/web/src/auth/AuthService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,39 @@ import type {
AuthSession,
LoginCredentials,
AuthError,
AuthResponse
AuthResponse,
User,
Organization,
Membership
} from './types';
import { getAuthItem, setAuthItem, removeAuthItem, clearAuthData } from '../utils/storage';

// Mock data for fallback auth
const MOCK_USER: User = {
id: 'user-1',
email: 'demo@mudul.com',
name: 'Demo User',
avatarUrl: undefined,
createdAt: '2024-01-01T00:00:00Z',
lastLoginAt: '2024-01-01T00:00:00Z'
};

const MOCK_ORGANIZATION: Organization = {
id: 'acme',
name: 'Acme Sales Org',
planTier: 'pro',
createdAt: '2024-01-01T00:00:00Z'
};

const MOCK_MEMBERSHIPS: Membership[] = [
{
userId: 'user-1',
orgId: 'acme',
role: 'owner',
createdAt: '2024-01-01T00:00:00Z'
}
];

// HTTP client for API calls
class ApiClient {
private baseUrl = ''; // Same origin
Expand Down Expand Up @@ -96,6 +125,21 @@ class AuthService {
* Authenticate user with email/password
*/
async login(credentials: LoginCredentials): Promise<AuthResponse> {
// Check if we should use API mode
const useDb = import.meta.env.VITE_USE_DB === "true";
const useMockAuth = import.meta.env.VITE_MOCK_AUTH === "true";

if (useDb) {
return this.loginWithApi(credentials, useMockAuth);
} else {
return this.loginWithMock(credentials);
}
}

/**
* API-based login
*/
private async loginWithApi(credentials: LoginCredentials, useMockAuth: boolean = false): Promise<AuthResponse> {
await this.simulateDelay();

try {
Expand Down Expand Up @@ -132,6 +176,12 @@ class AuthService {
} catch (error: any) {
console.error('Login failed:', error);

// Only fallback to mock auth if explicitly enabled
if (useMockAuth) {
console.warn('API login failed, falling back to mock auth (dev mode)');
return this.loginWithMock(credentials);
}

if (error.message === 'INVALID_CREDENTIALS') {
throw this.createError('invalid_credentials', 'Invalid email or password');
}
Expand All @@ -144,8 +194,60 @@ class AuthService {
throw this.createError('rate_limit', 'Too many login attempts. Please try again later.');
}

throw this.createError('server_error', 'Login failed due to server error');
// For network/API errors in DB mode without mock auth, show clear error
throw this.createError('api_unavailable', 'Unable to connect to authentication service. Please check your connection or contact support.');
}
}

/**
* Mock-based login (fallback for memory mode)
*/
private async loginWithMock(credentials: LoginCredentials): Promise<AuthResponse> {
await this.simulateDelay();

// Validate demo credentials
if (credentials.email !== 'demo@mudul.com' || credentials.password !== 'password') {
throw this.createError('invalid_credentials', 'Invalid email or password');
}

// Generate mock session
const user = MOCK_USER;
const tokens = this.generateTokens(user, credentials.rememberMe);

const session: AuthSession = {
user,
organization: MOCK_ORGANIZATION,
membership: MOCK_MEMBERSHIPS[0],
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt
};

this.currentSession = session;
this.storeSession(session);

return {
session,
isFirstLogin: false
};
}

/**
* Generate mock JWT tokens (in production, these would come from the server)
*/
private generateTokens(user: User, rememberMe: boolean = false): { accessToken: string; refreshToken?: string; expiresAt: string } {
const now = new Date();
const expiresAt = new Date(now.getTime() + (15 * 60 * 1000)); // 15 minutes

// Simple mock token (in production, use proper JWT)
const accessToken = `access_${user.id}_${now.getTime()}`;
const refreshToken = rememberMe ? `refresh_${user.id}_${now.getTime()}` : undefined;

return {
accessToken,
refreshToken,
expiresAt: expiresAt.toISOString()
};
}

/**
Expand Down
53 changes: 53 additions & 0 deletions apps/web/src/core/repo.api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Simple test to verify repo.api.ts module loads correctly
import { describe, it, expect } from 'vitest';

describe('API Repo Module', () => {
it('should export required functions', async () => {
const apiRepo = await import('./repo.api');

// Check that all required functions are exported
expect(typeof apiRepo.getRoot).toBe('function');
expect(typeof apiRepo.getNode).toBe('function');
expect(typeof apiRepo.getChildren).toBe('function');
expect(typeof apiRepo.getCallByNode).toBe('function');
expect(typeof apiRepo.listCallsByClient).toBe('function');
expect(typeof apiRepo.getDashboardId).toBe('function');
expect(typeof apiRepo.getAllClients).toBe('function');
expect(typeof apiRepo.getAllCalls).toBe('function');
expect(typeof apiRepo.clearCache).toBe('function');
});

it('should handle getDashboardId correctly', async () => {
const { getDashboardId } = await import('./repo.api');

// Test static dashboard ID logic
expect(getDashboardId('root')).toBe('org-dashboard');
expect(getDashboardId('org:test')).toBe('org-dashboard');
expect(getDashboardId('client:test')).toBe('client-dashboard');
expect(getDashboardId('call:test')).toBe('sales-call-default');
expect(getDashboardId('unknown')).toBe(null);
});

it('should warn about unimplemented mutation methods', async () => {
const { upsertCall, setDashboard, hasExistingAnalysis } = await import('./repo.api');

// Mock console.warn to check if warnings are issued
const originalWarn = console.warn;
const warnCalls: string[] = [];
console.warn = (message: string) => warnCalls.push(message);

try {
const result = upsertCall('test', {});
expect(result.updated).toBe(false);
expect(result.reason).toContain('API mutations not implemented');

setDashboard('test', {});
expect(hasExistingAnalysis('test', 'hash')).toBe(false);

expect(warnCalls.length).toBeGreaterThan(0);
expect(warnCalls.some(msg => msg.includes('not implemented in API repo'))).toBe(true);
} finally {
console.warn = originalWarn;
}
});
});
Loading