Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ EMAIL_FROM="DealSentry <noreply@dealsentry.ai>"
# Secret key for JWT token signing - CHANGE THIS IN PRODUCTION!
NEXTAUTH_SECRET="change-this-to-a-random-secure-string-in-production"

# There is no sign-in screen: every request is served as one default user.
# Optional - pick that user by email. Falls back to the first ADMIN, then to the
# first row in the User table.
# DEMO_USER_EMAIL="admin@dealsentry.ai"

# API Configuration
# Port for the Express API server
API_PORT=3001
Expand Down
4 changes: 4 additions & 0 deletions render.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ services:
sync: false

# --- Non-secret config (override in dashboard if needed) ---
# No sign-in screen: every request is served as this user. Falls back to
# the first ADMIN in the User table if unset.
- key: DEMO_USER_EMAIL
value: admin@reviewer.ai
- key: AZURE_OPENAI_DEPLOYMENT
value: gpt-4o
- key: AZURE_OPENAI_API_VERSION
Expand Down
16 changes: 11 additions & 5 deletions server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import path from 'path';
import { fileURLToPath } from 'url';

import { authLimiter, aiLimiter } from './src/api/middleware/rateLimit';
import { aiLimiter } from './src/api/middleware/rateLimit';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
Expand Down Expand Up @@ -58,12 +58,16 @@
origin: (origin, callback) => {
// Allow requests with no origin (like mobile apps, Postman, or curl)
if (!origin) return callback(null, true);

if (allowedOrigins.indexOf(origin) !== -1) {
callback(null, true);
} else {
console.warn(`CORS blocked origin: ${origin}`);
callback(new Error('Not allowed by CORS'));
// Don't throw: an unknown origin should just miss the CORS headers, not turn
// every request into a 500. In production the SPA is served by this same
// process, so same-origin calls must keep working even if PRODUCTION_URL
// is unset or misconfigured in the dashboard.
console.warn(`CORS: origin not in allow list: ${origin}`);
callback(null, false);
}
},
credentials: true,
Expand All @@ -76,7 +80,9 @@
});

// Routes
app.use('/api/auth', authLimiter, authRouter);
// authLimiter is applied per-route inside the router so the read-only /session
// lookup the SPA makes on every load isn't throttled alongside credential posts.
app.use('/api/auth', authRouter);
app.use('/api/proposals', proposalsRouter);
app.use('/api/rules', rulesRouter);
app.use('/api/templates', templatesRouter);
Expand All @@ -101,7 +107,7 @@
}

// Error handler
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {

Check warning on line 110 in server.ts

View workflow job for this annotation

GitHub Actions / build-and-test

'next' is defined but never used. Allowed unused args must match /^_/u
console.error('API Error:', err);
res.status(500).json({ error: err.message || 'Internal server error' });
});
Expand Down
39 changes: 21 additions & 18 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@ import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { ProposalProvider } from "@/context/ProposalProvider";
import { useApiConnection } from "@/hooks/use-api-connection";
import { useSession } from "@/hooks/use-session";
import ClientLayout from "@/components/layout/ClientLayout";
import ProtectedRoute from "@/components/ProtectedRoute";
import Home from "@/pages/Home";
import Auth from "@/pages/Auth";
import Dashboard from "@/pages/Dashboard";
import Proposals from "@/pages/Proposals";
import UploadProposal from "@/pages/UploadProposal";
Expand All @@ -28,25 +27,29 @@ function AppContent() {
// Monitor API connection status
useApiConnection();

// No login screen: resolve the acting user once, up front.
useSession();

return (
<ClientLayout>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/auth" element={<Auth />} />
<Route path="/login" element={<Auth />} />
<Route path="/signup" element={<Auth />} />
<Route path="/dashboard" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
<Route path="/proposals" element={<ProtectedRoute><Proposals /></ProtectedRoute>} />
<Route path="/proposals/upload" element={<ProtectedRoute><UploadProposal /></ProtectedRoute>} />
<Route path="/proposals/new" element={<ProtectedRoute><NewProposal /></ProtectedRoute>} />
<Route path="/proposals/create" element={<ProtectedRoute><CreateProposal /></ProtectedRoute>} />
<Route path="/proposals/:id/review" element={<ProtectedRoute><ProposalReview /></ProtectedRoute>} />
<Route path="/proposals/:id" element={<ProtectedRoute><ProposalReview /></ProtectedRoute>} />
<Route path="/compliance" element={<ProtectedRoute><Compliance /></ProtectedRoute>} />
<Route path="/integrations" element={<ProtectedRoute><Integrations /></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
<Route path="/audit" element={<ProtectedRoute><Audit /></ProtectedRoute>} />
<Route path="/analytics" element={<ProtectedRoute><Analytics /></ProtectedRoute>} />
{/* Sign-in was removed — old auth links land on the dashboard. */}
<Route path="/auth" element={<Navigate to="/dashboard" replace />} />
<Route path="/login" element={<Navigate to="/dashboard" replace />} />
<Route path="/signup" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/proposals" element={<Proposals />} />
<Route path="/proposals/upload" element={<UploadProposal />} />
<Route path="/proposals/new" element={<NewProposal />} />
<Route path="/proposals/create" element={<CreateProposal />} />
<Route path="/proposals/:id/review" element={<ProposalReview />} />
<Route path="/proposals/:id" element={<ProposalReview />} />
<Route path="/compliance" element={<Compliance />} />
<Route path="/integrations" element={<Integrations />} />
<Route path="/settings" element={<Settings />} />
<Route path="/audit" element={<Audit />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="*" element={<NotFound />} />
</Routes>
</ClientLayout>
Expand Down
66 changes: 12 additions & 54 deletions src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,21 @@ import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
import { supabase } from '../lib/supabase';
import { requireAuth } from './middleware/auth';
import { authLimiter } from './middleware/rateLimit';

const router = Router();

const JWT_SECRET = process.env.NEXTAUTH_SECRET || 'default-secret-change-in-production';
const SALT_ROUNDS = 10;

/** Shape of the signed JWT payload issued at login/register. */
interface JwtPayload {
userId: string;
email: string;
role: string;
companyId: string | null;
}
// GET current session. There is no login screen, so this reports whichever user
// requireAuth resolved (a valid token if one was sent, otherwise the default user).
router.get('/session', requireAuth, (req: Request, res: Response) => {
res.json({ user: req.user });
});

// POST login
router.post('/login', async (req: Request, res: Response) => {
router.post('/login', authLimiter, async (req: Request, res: Response) => {
try {
const { email, password } = req.body;

Expand Down Expand Up @@ -75,7 +74,7 @@ router.post('/login', async (req: Request, res: Response) => {
});

// POST register
router.post('/register', async (req: Request, res: Response) => {
router.post('/register', authLimiter, async (req: Request, res: Response) => {
try {
const { email, name, password } = req.body;

Expand Down Expand Up @@ -146,54 +145,13 @@ router.post('/register', async (req: Request, res: Response) => {
}
});

// GET verify token
router.get('/verify', async (req: Request, res: Response) => {
try {
const token = req.headers.authorization?.replace('Bearer ', '');

if (!token) {
return res.status(401).json({ error: 'No token provided' });
}

const decoded = jwt.verify(token, JWT_SECRET) as JwtPayload;

const { data: user, error } = await supabase
.from('User')
.select('*')
.eq('id', decoded.userId)
.single();

if (error || !user) {
return res.status(401).json({ error: 'Invalid token' });
}

const companyId = (user as { company_id?: string }).company_id ?? null;

res.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
companyId,
},
});
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
res.status(401).json({ error: 'Session expired', code: 'TOKEN_EXPIRED' });
return;
}
if (error instanceof jwt.JsonWebTokenError) {
res.status(401).json({ error: 'Invalid token', code: 'INVALID_TOKEN' });
return;
}
console.error('Error verifying token:', error);
res.status(401).json({ error: 'Invalid token' });
}
// GET verify — kept as an alias of /session for older clients.
router.get('/verify', requireAuth, (req: Request, res: Response) => {
res.json({ user: req.user });
});

// POST change password
router.post('/change-password', requireAuth, async (req: Request, res: Response) => {
router.post('/change-password', authLimiter, requireAuth, async (req: Request, res: Response) => {
try {
const { currentPassword, newPassword } = req.body;
// requireAuth populates req.user with the AuthUser shape (id, not userId).
Expand Down
149 changes: 116 additions & 33 deletions src/api/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,51 +24,134 @@ declare global {
}
}

/** Cached default session user — resolved once per process, see resolveDefaultUser(). */
let defaultUserCache: AuthUser | null = null;

/** Drop the cached default user so the next request re-resolves it (tests, re-seeding). */
export function resetDefaultUser(): void {
defaultUserCache = null;
}

/**
* The app has no login screen: requests arrive without a token and are served as
* a single default identity. Preference order: DEMO_USER_EMAIL, then any ADMIN
* (sees all companies), then the first user in the table.
*/
async function resolveDefaultUser(): Promise<{ user: AuthUser | null; dbError: string | null }> {
if (defaultUserCache) return { user: defaultUserCache, dbError: null };

const email = process.env.DEMO_USER_EMAIL;
const lookups = [
() => (email ? supabase.from('User').select('*').eq('email', email).limit(1) : null),
() => supabase.from('User').select('*').eq('role', 'ADMIN').limit(1),
() => supabase.from('User').select('*').limit(1),
];

let dbError: string | null = null;

for (const lookup of lookups) {
const query = lookup();
if (!query) continue;

// A dead or misconfigured Supabase project rejects rather than resolving, so
// catch here too — otherwise the whole request 500s with no useful message.
const { data, error } = await query.then(
(r) => r,
(err: Error) => ({ data: null, error: { message: err.message } })
);

if (error) {
dbError = error.message;
console.error('Default user lookup failed:', error.message);
continue;
}

const row = data?.[0] as { id: string; email: string; role: string; company_id?: string } | undefined;
if (row) {
defaultUserCache = {
id: row.id,
email: row.email,
role: row.role,
companyId: row.company_id ?? null,
};
return { user: defaultUserCache, dbError: null };
}
}

return { user: null, dbError };
}

/** Resolve the user a bearer token points at, or null if the token is unusable. */
async function userFromToken(token: string): Promise<AuthUser | null> {
let decoded: { userId: string };
try {
decoded = jwt.verify(token, JWT_SECRET) as { userId: string };
} catch (err) {
console.warn('Ignoring unusable token:', (err as Error).message);
return null;
}

// select('*') so missing columns (e.g. company_id) don't cause the query to fail.
// Rejections are folded into `error` so an unreachable DB falls through to the
// default-user path (and its clearer 503) rather than throwing.
const { data: user, error } = await supabase
.from('User')
.select('*')
.eq('id', decoded.userId)
.single()
.then(
(r) => r,
(err: Error) => ({ data: null, error: { message: err.message } })
);

if (error || !user) {
console.warn('Token user lookup failed:', error?.message || 'no user');
return null;
}

const u = user as { id: string; email: string; role: string; company_id?: string };
return { id: u.id, email: u.email, role: u.role, companyId: u.company_id ?? null };
}

/**
* Verify JWT and load full user (including companyId) from DB. Attach to req.user.
* Returns 401 if no token or invalid; does not call next() on failure.
* Resolve who a request is acting as.
*
* Login was removed from the product, so a missing, expired, or otherwise
* unusable token is not an error — the caller is served as the default user
* (see resolveDefaultUser). A valid token still wins, so any session issued
* before login was removed keeps its own identity.
*/
export async function resolveSessionUser(
token?: string
): Promise<{ user: AuthUser | null; dbError: string | null }> {
const tokenUser = token ? await userFromToken(token) : null;
if (tokenUser) return { user: tokenUser, dbError: null };
return resolveDefaultUser();
}

/** Message explaining why no session user could be resolved. */
export function noSessionUserMessage(dbError: string | null): string {
return dbError
? `Database unavailable: ${dbError}. Check SUPABASE_URL and SUPABASE_ANON_KEY.`
: 'No user records found. Seed the database (npm run seed) or set DEMO_USER_EMAIL.';
}

/** Load the acting user onto req.user. See resolveSessionUser. */
export async function requireAuth(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
res.status(401).json({ error: 'Authentication required' });
return;
}
const { user, dbError } = await resolveSessionUser(token);

const decoded = jwt.verify(token, JWT_SECRET) as { userId: string; email?: string; role?: string; companyId?: string };
// Use select('*') so missing columns (e.g. company_id) don't cause the query to fail
const { data: user, error } = await supabase
.from('User')
.select('*')
.eq('id', decoded.userId)
.single();

if (error || !user) {
console.error('Auth middleware: user lookup failed', error?.message || 'no user');
res.status(401).json({ error: 'Invalid token' });
if (!user) {
res.status(503).json({ error: noSessionUserMessage(dbError), code: 'NO_SESSION_USER' });
return;
}

const u = user as { id: string; email: string; role: string; company_id?: string };
(req as Request).user = {
id: u.id,
email: u.email,
role: u.role,
companyId: u.company_id ?? null,
};
req.user = user;
next();
} catch (err) {
if (err instanceof jwt.TokenExpiredError) {
res.status(401).json({ error: 'Session expired', code: 'TOKEN_EXPIRED' });
return;
}
if (err instanceof jwt.JsonWebTokenError) {
res.status(401).json({ error: 'Invalid token', code: 'INVALID_TOKEN' });
return;
}
console.error('Auth middleware error:', err);
res.status(401).json({ error: 'Invalid token' });
res.status(500).json({ error: 'Failed to resolve session' });
}
}

Expand Down
Loading
Loading