diff --git a/.env.example b/.env.example index 312d943..ef8af87 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,11 @@ EMAIL_FROM="DealSentry " # 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 diff --git a/render.yaml b/render.yaml index 60d6abb..ba29503 100644 --- a/render.yaml +++ b/render.yaml @@ -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 diff --git a/server.ts b/server.ts index 07669b7..71e8ec1 100644 --- a/server.ts +++ b/server.ts @@ -5,7 +5,7 @@ import { config } from 'dotenv'; 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); @@ -58,12 +58,16 @@ app.use(cors({ 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, @@ -76,7 +80,9 @@ app.get('/api/health', (req, res) => { }); // 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); diff --git a/src/App.tsx b/src/App.tsx index 8459ea3..dbb843d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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"; @@ -28,25 +27,29 @@ function AppContent() { // Monitor API connection status useApiConnection(); + // No login screen: resolve the acting user once, up front. + useSession(); + return ( } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + {/* Sign-in was removed — old auth links land on the dashboard. */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> diff --git a/src/api/auth.ts b/src/api/auth.ts index 28e1b11..bf69a50 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -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; @@ -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; @@ -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). diff --git a/src/api/middleware/auth.ts b/src/api/middleware/auth.ts index 3ed510f..7bb01a2 100644 --- a/src/api/middleware/auth.ts +++ b/src/api/middleware/auth.ts @@ -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 { + 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 { 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' }); } } diff --git a/src/api/oauth.ts b/src/api/oauth.ts index e6e77b4..fdf7b12 100644 --- a/src/api/oauth.ts +++ b/src/api/oauth.ts @@ -1,37 +1,29 @@ -import { Router, Request, Response } from 'express'; +import { Router, Request, Response, NextFunction } from 'express'; import { supabase } from '../lib/supabase'; -import { requireAuth } from './middleware/auth'; +import { requireAuth, resolveSessionUser } from './middleware/auth'; import { logger } from './lib/logger'; -import jwt from 'jsonwebtoken'; const router = Router(); // Frontend URL for OAuth redirects const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:8080'; -// Middleware to check auth from token query parameter (for OAuth redirects) -const requireAuthFromQuery = (req: Request, res: Response, next: Function) => { - const token = req.query.token as string; - - if (!token) { - return res.redirect(`${FRONTEND_URL}/integrations?error=unauthorized`); - } - +/** + * Auth for OAuth redirects. These are top-level browser navigations, so no + * Authorization header is available — the token (when there is one) rides in the + * query string. With login removed there usually isn't one, and the request + * falls back to the default user, same as every other route. + */ +const requireAuthFromQuery = async (req: Request, res: Response, next: NextFunction) => { try { - const decoded = jwt.verify(token, process.env.NEXTAUTH_SECRET!) as { - userId: string; - email?: string; - role?: string; - companyId?: string | null; - }; - req.user = { - id: decoded.userId, - email: decoded.email ?? '', - role: decoded.role ?? '', - companyId: decoded.companyId ?? null, - }; + const { user } = await resolveSessionUser(req.query.token as string | undefined); + if (!user) { + return res.redirect(`${FRONTEND_URL}/integrations?error=unauthorized`); + } + req.user = user; next(); } catch (error) { + logger.error('OAuth session lookup failed', error as Error); return res.redirect(`${FRONTEND_URL}/integrations?error=unauthorized`); } }; diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx deleted file mode 100644 index 467345b..0000000 --- a/src/components/ProtectedRoute.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { ReactNode, useEffect, useState } from 'react'; -import { Navigate, useLocation } from 'react-router-dom'; -import { authApi } from '@/lib/api-client'; -import { isAuthenticated, clearAuthData } from '@/lib/auth-utils'; - -interface ProtectedRouteProps { - children: ReactNode; -} - -/** - * Wrapper component that checks if user is authenticated before rendering children. - * Redirects to /login if not authenticated. - */ -export default function ProtectedRoute({ children }: ProtectedRouteProps) { - const [isAuth, setIsAuth] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const location = useLocation(); - - useEffect(() => { - const checkAuth = async () => { - if (!isAuthenticated()) { - setIsAuth(false); - setIsLoading(false); - return; - } - - try { - // Verify token with backend - await authApi.verify(); - setIsAuth(true); - } catch (error) { - // Token is invalid or expired - console.error('Auth verification failed:', error); - clearAuthData(); - setIsAuth(false); - } finally { - setIsLoading(false); - } - }; - - checkAuth(); - }, []); - - if (isLoading) { - return ( -
-
-
- ); - } - - if (!isAuth) { - // Redirect to auth page with return URL - return ; - } - - return <>{children}; -} diff --git a/src/components/layout/ClientLayout.tsx b/src/components/layout/ClientLayout.tsx index 1ae25ff..903c9fc 100644 --- a/src/components/layout/ClientLayout.tsx +++ b/src/components/layout/ClientLayout.tsx @@ -1,14 +1,13 @@ -import { ReactNode, useEffect, useState } from "react"; -import { useNavigate, useLocation, Link } from "react-router-dom"; +import { ReactNode } from "react"; +import { useLocation, Link } from "react-router-dom"; import { motion } from "framer-motion"; -import { +import { LayoutDashboard, FileText, Shield, Link2, BarChart3, ClipboardList, - LogOut, ChevronRight, Sparkles, User, @@ -16,7 +15,7 @@ import { Home as HomeIcon, Settings as SettingsIcon } from "lucide-react"; -import { isAuthenticated, getCurrentUser, clearAuthData } from "@/lib/auth-utils"; +import { useSession } from "@/hooks/use-session"; import NotificationBell from "@/components/NotificationBell"; interface ClientLayoutProps { @@ -41,35 +40,13 @@ const navItems: NavItem[] = [ ]; export default function ClientLayout({ children }: ClientLayoutProps) { - const navigate = useNavigate(); const location = useLocation(); - const [isLoggedIn, setIsLoggedIn] = useState(false); - const [userRole, setUserRole] = useState(""); - const [userName, setUserName] = useState(""); + const user = useSession(); + const userRole = user?.role || ""; + const userName = user?.name || user?.email || "User"; - useEffect(() => { - const loggedIn = isAuthenticated(); - const currentUser = getCurrentUser(); - - setIsLoggedIn(loggedIn); - setUserRole(currentUser?.role || ""); - setUserName(currentUser?.name || currentUser?.email || "User"); - - // Allow access to home page and auth pages without authentication - const publicPaths = ["/", "/login", "/auth", "/signup"]; - if (!loggedIn && !publicPaths.includes(location.pathname)) { - navigate("/login", { replace: true, state: { from: location } }); - } - }, [location.pathname, navigate]); - - const handleLogout = () => { - clearAuthData(); - navigate("/"); - }; - - // Don't show sidebar on auth pages or home page - const authPaths = ["/login", "/auth", "/signup"]; - if (authPaths.includes(location.pathname) || location.pathname === "/") { + // Don't show the sidebar on the landing page + if (location.pathname === "/") { return <>{children}; } @@ -172,13 +149,6 @@ export default function ClientLayout({ children }: ClientLayoutProps) { - @@ -192,7 +162,7 @@ export default function ClientLayout({ children }: ClientLayoutProps) { transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }} className="absolute top-2 right-2 z-40 flex items-center gap-2" > - {isLoggedIn && } + { - if (isApiConnected || !isAuthenticated()) { + if (isApiConnected) { return; } const interval = setInterval(async () => { - if (!isAuthenticated()) return; console.log("Checking API connectivity..."); const outcome = await fetchProposals(true); if (outcome === "ok" && !isApiConnected) { @@ -74,19 +69,7 @@ export function ProposalProvider({ children }: { children: ReactNode }) { return () => clearInterval(interval); }, [isApiConnected, location.pathname]); - const fetchProposals = async ( - silent = false - ): Promise<"ok" | "unauthorized" | "offline" | "skipped"> => { - if (!isAuthenticated()) { - if (!silent) { - setLoading(false); - } - setError(null); - setProposals([]); - setIsApiConnected(false); - return "skipped"; - } - + const fetchProposals = async (silent = false): Promise<"ok" | "offline"> => { if (!silent) { setLoading(true); } @@ -98,25 +81,6 @@ export function ProposalProvider({ children }: { children: ReactNode }) { setIsApiConnected(true); return "ok"; } catch (err) { - if (err instanceof ApiError && err.status === 401) { - if (!silent) { - console.warn("Session not accepted for proposals API:", err.message); - } - setProposals([]); - setIsApiConnected(true); - if (getAuthToken()) { - clearAuthData(); - if (typeof window !== "undefined") { - const p = window.location.pathname; - const publicLanding = - p === "/" || /^\/(login|signup|auth)(\/|$)/.test(p); - if (!publicLanding) { - window.location.assign("/login"); - } - } - } - return "unauthorized"; - } if (!silent) { console.warn("API not available, using mock data:", err); } diff --git a/src/context/proposal-context.ts b/src/context/proposal-context.ts index c05af4e..8d1b31f 100644 --- a/src/context/proposal-context.ts +++ b/src/context/proposal-context.ts @@ -14,7 +14,7 @@ export interface ProposalContextType { ) => Promise; deleteProposal: (id: string) => Promise; analyzeProposal: (id: string) => Promise; - refreshProposals: () => Promise<"ok" | "unauthorized" | "offline" | "skipped">; + refreshProposals: () => Promise<"ok" | "offline">; isApiConnected: boolean; } diff --git a/src/hooks/use-session.ts b/src/hooks/use-session.ts new file mode 100644 index 0000000..827c353 --- /dev/null +++ b/src/hooks/use-session.ts @@ -0,0 +1,49 @@ +import { useEffect, useState } from 'react'; +import { authApi } from '@/lib/api-client'; +import { AuthUser, cacheSessionUser, getCurrentUser } from '@/lib/auth-utils'; + +/** + * The acting user, as reported by /api/auth/session. + * + * There is no login screen — the API resolves a default user — so this is a + * one-shot fetch shared by every consumer. It starts from the localStorage cache + * so a reload renders the right name and role before the request lands. + */ +let cached: AuthUser | null = null; +let inflight: Promise | null = null; +const subscribers = new Set<(user: AuthUser | null) => void>(); + +function loadSession(): Promise { + if (!inflight) { + inflight = authApi + .session() + .then(({ user }) => { + cached = user as AuthUser; + cacheSessionUser(cached); + subscribers.forEach((notify) => notify(cached)); + return cached; + }) + .catch((err) => { + console.warn('Could not load session:', err); + inflight = null; // allow a later mount to retry + return null; + }); + } + return inflight; +} + +export function useSession(): AuthUser | null { + const [user, setUser] = useState(() => cached ?? getCurrentUser()); + + useEffect(() => { + subscribers.add(setUser); + loadSession().then((resolved) => { + if (resolved) setUser(resolved); + }); + return () => { + subscribers.delete(setUser); + }; + }, []); + + return user; +} diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 1eb86d5..160da38 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -1,5 +1,3 @@ -import { clearAuthData } from '@/lib/auth-utils'; - /** Thrown for non-OK HTTP responses so callers can distinguish 401 from network failures. */ export class ApiError extends Error { constructor( @@ -181,17 +179,6 @@ async function apiCall( error?: string; code?: string; }; - const path = typeof window !== 'undefined' ? window.location.pathname : ''; - const isAuthPage = /^\/(auth|login|signup)(\/|$)/.test(path); - if ( - response.status === 401 && - (error.code === 'TOKEN_EXPIRED' || error.code === 'INVALID_TOKEN') && - typeof window !== 'undefined' && - !isAuthPage - ) { - clearAuthData(); - window.location.assign('/login'); - } throw new ApiError( error.error || `API error: ${response.status}`, response.status, @@ -351,24 +338,9 @@ export const usersApi = { }), }; -// Auth API -export interface AuthResponse { - token: string; - user: User; -} - +// Session API — no login screen; the server resolves the acting user. export const authApi = { - login: (email: string, password: string) => - apiCall('/api/auth/login', { - method: 'POST', - body: JSON.stringify({ email, password }), - }), - register: (email: string, password: string, name?: string) => - apiCall('/api/auth/register', { - method: 'POST', - body: JSON.stringify({ email, password, name }), - }), - verify: () => apiCall<{ user: User }>('/api/auth/verify'), + session: () => apiCall<{ user: User }>('/api/auth/session'), changePassword: (currentPassword: string, newPassword: string) => apiCall<{ message: string }>('/api/auth/change-password', { method: 'POST', diff --git a/src/lib/auth-utils.ts b/src/lib/auth-utils.ts index a0f1176..a28d353 100644 --- a/src/lib/auth-utils.ts +++ b/src/lib/auth-utils.ts @@ -1,109 +1,93 @@ -/** - * Authentication utility functions for managing user sessions - */ - -export interface AuthUser { - id: string; - email: string; - name: string | null; - role: string; - companyId?: string | null; -} - -/** - * Get the currently authenticated user from localStorage - */ -export function getCurrentUser(): AuthUser | null { - const token = localStorage.getItem('auth_token'); - const isLoggedIn = localStorage.getItem('isLoggedIn'); - - if (!token || isLoggedIn !== 'true') { - return null; - } - - const userId = localStorage.getItem('userId'); - const email = localStorage.getItem('userEmail'); - const name = localStorage.getItem('userName'); - const role = localStorage.getItem('userRole'); - const companyId = localStorage.getItem('companyId'); - - if (!userId || !email || !role) { - return null; - } - - return { - id: userId, - email, - name: name || null, - role, - companyId: companyId || null, - }; -} - -/** - * Check if user is authenticated - */ -export function isAuthenticated(): boolean { - const token = localStorage.getItem('auth_token'); - const isLoggedIn = localStorage.getItem('isLoggedIn'); - return !!token && isLoggedIn === 'true'; -} - -/** - * Get the authentication token - */ -export function getAuthToken(): string | null { - return localStorage.getItem('auth_token'); -} - -/** - * Set authentication data in localStorage - */ -export function setAuthData(token: string, user: AuthUser): void { - localStorage.setItem('auth_token', token); - localStorage.setItem('isLoggedIn', 'true'); - localStorage.setItem('userId', user.id); - localStorage.setItem('userEmail', user.email); - localStorage.setItem('userName', user.name || user.email); - localStorage.setItem('userRole', user.role); - - if (user.companyId) { - localStorage.setItem('companyId', user.companyId); - } else { - localStorage.removeItem('companyId'); - } -} - -/** - * Clear all authentication data - */ -export function clearAuthData(): void { - localStorage.removeItem('auth_token'); - localStorage.removeItem('isLoggedIn'); - localStorage.removeItem('userId'); - localStorage.removeItem('userEmail'); - localStorage.removeItem('userName'); - localStorage.removeItem('userRole'); - localStorage.removeItem('companyId'); -} - -/** - * Check if user has a specific role - */ -export function hasRole(role: string | string[]): boolean { - const currentUser = getCurrentUser(); - if (!currentUser) return false; - - if (Array.isArray(role)) { - return role.includes(currentUser.role); - } - - return currentUser.role === role; -} - -/** - * Check if user is an admin - */ -export function isAdmin(): boolean { - return hasRole('ADMIN'); -} +/** + * Session helpers. + * + * The app has no login screen: the API resolves a default user for every request + * and the SPA caches that identity in localStorage so the sidebar, role-gated nav + * and Settings can render it without an extra round trip on each page. + */ + +export interface AuthUser { + id: string; + email: string; + name: string | null; + role: string; + companyId?: string | null; +} + +/** + * Get the current user from the cached session, or null before /api/auth/session + * has resolved. + */ +export function getCurrentUser(): AuthUser | null { + if (typeof window === 'undefined') return null; + + const userId = localStorage.getItem('userId'); + const email = localStorage.getItem('userEmail'); + const role = localStorage.getItem('userRole'); + + if (!userId || !email || !role) { + return null; + } + + return { + id: userId, + email, + name: localStorage.getItem('userName') || null, + role, + companyId: localStorage.getItem('companyId') || null, + }; +} + +/** + * Always true — sign-in was removed, so every visitor is treated as a valid + * session. Kept so callers don't need to special-case the old auth flow. + */ +export function isAuthenticated(): boolean { + return true; +} + +/** Cache the session user returned by /api/auth/session. */ +export function cacheSessionUser(user: AuthUser): void { + if (typeof window === 'undefined') return; + + localStorage.setItem('userId', user.id); + localStorage.setItem('userEmail', user.email); + localStorage.setItem('userName', user.name || user.email); + localStorage.setItem('userRole', user.role); + + if (user.companyId) { + localStorage.setItem('companyId', user.companyId); + } else { + localStorage.removeItem('companyId'); + } +} + +/** Clear the cached session. */ +export function clearAuthData(): void { + if (typeof window === 'undefined') return; + + localStorage.removeItem('auth_token'); + localStorage.removeItem('isLoggedIn'); + localStorage.removeItem('userId'); + localStorage.removeItem('userEmail'); + localStorage.removeItem('userName'); + localStorage.removeItem('userRole'); + localStorage.removeItem('companyId'); +} + +/** Check if the current user has a specific role. */ +export function hasRole(role: string | string[]): boolean { + const currentUser = getCurrentUser(); + if (!currentUser) return false; + + if (Array.isArray(role)) { + return role.includes(currentUser.role); + } + + return currentUser.role === role; +} + +/** Check if the current user is an admin. */ +export function isAdmin(): boolean { + return hasRole('ADMIN'); +} diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index 13003df..1c753a0 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -4,11 +4,16 @@ import { config } from 'dotenv'; // Load environment variables config(); -const supabaseUrl = process.env.SUPABASE_URL || 'https://dbtresabyjoskapqkkun.supabase.co'; +const supabaseUrl = process.env.SUPABASE_URL || ''; const supabaseKey = process.env.SUPABASE_ANON_KEY || ''; -if (!supabaseKey) { - console.warn('Warning: SUPABASE_ANON_KEY not set. Database operations will fail.'); +// No hardcoded project fallback: pointing at a stale/deleted project makes every +// query fail with an opaque DNS error instead of naming the missing config. +if (!supabaseUrl || !supabaseKey) { + console.warn( + `Warning: ${!supabaseUrl ? 'SUPABASE_URL' : ''}${!supabaseUrl && !supabaseKey ? ' and ' : ''}${!supabaseKey ? 'SUPABASE_ANON_KEY' : ''} not set. ` + + 'Database operations will fail — set them in .env (local) or the Render dashboard.' + ); } export const supabase = createClient(supabaseUrl, supabaseKey); diff --git a/src/pages/Auth.tsx b/src/pages/Auth.tsx deleted file mode 100644 index 70fae7d..0000000 --- a/src/pages/Auth.tsx +++ /dev/null @@ -1,515 +0,0 @@ -import { useState, useEffect } from "react"; -import { useNavigate, Link, useLocation } from "react-router-dom"; -import { motion, AnimatePresence } from "framer-motion"; -import { - ArrowRight, - Sparkles, - FileCheck, - Shield, - Settings, - Eye, - EyeOff, - CheckCircle2, - XCircle, - AlertCircle, - Server -} from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { authApi, checkApiHealth } from "@/lib/api-client"; -import { setAuthData } from "@/lib/auth-utils"; -import { toast } from "sonner"; - -type AuthMode = "login" | "signup"; - -export default function Auth() { - const location = useLocation(); - const navigate = useNavigate(); - - // Determine initial mode based on route - const getInitialMode = (pathname: string): AuthMode => { - if (pathname === "/signup") return "signup"; - return "login"; - }; - - const [mode, setMode] = useState(() => getInitialMode(location.pathname)); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [name, setName] = useState(""); - const [confirmPassword, setConfirmPassword] = useState(""); - const [showPassword, setShowPassword] = useState(false); - const [showConfirmPassword, setShowConfirmPassword] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [acceptedTerms, setAcceptedTerms] = useState(false); - const [apiServerOnline, setApiServerOnline] = useState(null); - const [checkingServer, setCheckingServer] = useState(true); - - // Check API server health on component mount - useEffect(() => { - const checkServer = async () => { - setCheckingServer(true); - const isOnline = await checkApiHealth(); - setApiServerOnline(isOnline); - setCheckingServer(false); - }; - - checkServer(); - - // Check again every 10 seconds if offline - const interval = setInterval(() => { - if (apiServerOnline === false) { - checkServer(); - } - }, 10000); - - return () => clearInterval(interval); - }, [apiServerOnline]); - - // Update mode when route changes - useEffect(() => { - setMode(getInitialMode(location.pathname)); - }, [location.pathname]); - - const validatePassword = (pwd: string) => { - const minLength = pwd.length >= 8; - const hasUpper = /[A-Z]/.test(pwd); - const hasLower = /[a-z]/.test(pwd); - const hasNumber = /[0-9]/.test(pwd); - const hasSpecial = /[!@#$%^&*(),.?":{}|<>]/.test(pwd); - - return { - minLength, - hasUpper, - hasLower, - hasNumber, - hasSpecial, - isValid: minLength && hasUpper && hasLower && hasNumber, - }; - }; - - const passwordValidation = mode === "signup" ? validatePassword(password) : null; - const passwordsMatch = mode === "signup" ? password === confirmPassword : true; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (mode === "signup") { - if (!acceptedTerms) { - toast.error("Please accept the terms and conditions"); - return; - } - - if (!passwordValidation?.isValid) { - toast.error("Password does not meet requirements"); - return; - } - - if (!passwordsMatch) { - toast.error("Passwords do not match"); - return; - } - } - - setIsLoading(true); - - try { - let response; - if (mode === "login") { - response = await authApi.login(email, password); - } else { - response = await authApi.register(email, password, name || undefined); - } - - // Use the new auth utility to store auth data - setAuthData(response.token, response.user); - - toast.success(mode === "login" ? "Welcome back!" : "Account created successfully!"); - - // Redirect to the page user was trying to access, or dashboard - const from = (location.state as any)?.from?.pathname || "/dashboard"; - navigate(from, { replace: true }); - } catch (error: any) { - const message = error?.message || `${mode === "login" ? "Login" : "Registration"} failed`; - if (message.includes("Cannot reach the server")) { - toast.error("Cannot connect to API server", { - description: "Please start the API server by running 'npm run server' or use start.bat", - duration: 8000, - }); - setApiServerOnline(false); // Update server status - } else { - toast.error(message); - } - } finally { - setIsLoading(false); - } - }; - - - const switchMode = () => { - const newMode = mode === "login" ? "signup" : "login"; - setMode(newMode); - setPassword(""); - setConfirmPassword(""); - setName(""); - setAcceptedTerms(false); - // Update URL without navigation - navigate(newMode === "login" ? "/login" : "/signup", { replace: true }); - }; - - return ( -
-
-
-
-
-
-
-
-
-
-
-
-
- - -
-
- - - - - {mode === "login" ? "Welcome to Reviewer" : "Create Your Account"} - - - {mode === "login" ? "Sign in to continue" : "Get started with your free account"} - -
- - {/* Mode Toggle */} -
- - -
- - {/* API Server Status Warning */} - {apiServerOnline === false && !checkingServer && ( - -
-
-
- - API Server Offline -
- - The API server is not running. Please run start.bat or npm run server to start it. - -
- -
-
- )} - - {checkingServer && ( - - - - Checking API server status... - - - )} - -
- - {mode === "signup" && ( - - - setName(e.target.value)} - placeholder="John Doe" - /> - - )} - - -
- - setEmail(e.target.value)} - placeholder="you@company.com" - required - /> -
- -
- -
- setPassword(e.target.value)} - placeholder="••••••••" - required - className="pr-10" - /> - -
- - {/* Password Validation */} - {mode === "signup" && password && ( - -
- {passwordValidation?.minLength ? ( - - ) : ( - - )} - At least 8 characters -
-
- {passwordValidation?.hasUpper ? ( - - ) : ( - - )} - One uppercase letter -
-
- {passwordValidation?.hasLower ? ( - - ) : ( - - )} - One lowercase letter -
-
- {passwordValidation?.hasNumber ? ( - - ) : ( - - )} - One number -
-
- )} -
- - - {mode === "signup" && ( - - -
- setConfirmPassword(e.target.value)} - placeholder="••••••••" - required - className={`pr-10 ${confirmPassword && !passwordsMatch ? "border-destructive" : ""}`} - /> - -
- {confirmPassword && !passwordsMatch && ( -

Passwords do not match

- )} -
- )} -
- - {mode === "signup" && ( -
- setAcceptedTerms(checked === true)} - className="mt-0.5" - /> - -
- )} - - -
- - {mode === "login" && ( -
-
- {[ - { icon: FileCheck, text: "AI-powered compliance" }, - { icon: Shield, text: "Enterprise security" }, - { icon: Settings, text: "Custom workflows" }, - ].map((feature, index) => ( -
- - {feature.text} -
- ))} -
-
- )} - - {mode === "signup" && ( -
-

- Already have an account?{" "} - -

-
- )} -
- - - Secure enterprise proposal management - -
-
- ); -} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index a76ecc8..8b29688 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -115,17 +115,14 @@ export default function Home() {
- @@ -231,8 +228,8 @@ export default function Home() { Join enterprise teams who trust DealSentry to streamline their proposal process

diff --git a/src/pages/Integrations.tsx b/src/pages/Integrations.tsx index 863144c..25c4b55 100644 --- a/src/pages/Integrations.tsx +++ b/src/pages/Integrations.tsx @@ -201,17 +201,9 @@ export default function Integrations() { const base = getApiBaseUrl(); const url = oauthUrls[integration.type]; if (url) { - const token = localStorage.getItem('auth_token'); - if (!token) { - toast({ - title: "Authentication Required", - description: "Please login to connect integrations", - variant: "destructive", - }); - setConnecting(null); - return; - } - window.location.href = `${base}${url}?token=${encodeURIComponent(token)}`; + // Top-level navigation, so no Authorization header — the server resolves + // the acting user itself now that there is no sign-in step. + window.location.href = `${base}${url}`; } else { setConnecting(null); } diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 7159694..95a7f4d 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -7,11 +7,11 @@ import { Label } from "@/components/ui/label"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { useToast } from "@/hooks/use-toast"; import { authApi } from "@/lib/api-client"; -import { getCurrentUser } from "@/lib/auth-utils"; +import { useSession } from "@/hooks/use-session"; export default function Settings() { const { toast } = useToast(); - const currentUser = getCurrentUser(); + const currentUser = useSession(); const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); diff --git a/src/pages/UploadProposal.tsx b/src/pages/UploadProposal.tsx index 232de02..38d9318 100644 --- a/src/pages/UploadProposal.tsx +++ b/src/pages/UploadProposal.tsx @@ -32,18 +32,6 @@ export default function UploadProposal() { title: "Connected", description: "API connection restored successfully", }); - } else if (outcome === "skipped") { - toast({ - title: "Sign in required", - description: "Please sign in to connect to the API.", - variant: "destructive", - }); - } else if (outcome === "unauthorized") { - toast({ - title: "Session required", - description: "Please sign in again to use the API.", - variant: "destructive", - }); } else { toast({ title: "Still Offline", diff --git a/tests/auth.test.ts b/tests/auth.test.ts index f809fb6..d83e51d 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -5,21 +5,24 @@ import jwt from 'jsonwebtoken'; import bcrypt from 'bcryptjs'; // Shared, hoisted Supabase mock. The real client is a chainable query builder -// (`.from().select().eq().single()`); we return a single controllable result. -const { mockSingle, supabaseMock } = vi.hoisted(() => { +// (`.from().select().eq().single()` for one row, `.limit()` for a list); each +// terminal returns a single controllable result. +const { mockSingle, mockLimit, supabaseMock } = vi.hoisted(() => { const mockSingle = vi.fn(); + const mockLimit = vi.fn(); const builder: Record = {}; builder.select = vi.fn(() => builder); builder.eq = vi.fn(() => builder); builder.single = mockSingle; + builder.limit = mockLimit; const supabaseMock = { from: vi.fn(() => builder) }; - return { mockSingle, supabaseMock }; + return { mockSingle, mockLimit, supabaseMock }; }); vi.mock('../src/lib/supabase', () => ({ supabase: supabaseMock, default: supabaseMock })); // Imported after the mock is registered. -import { requireAuth, isAdmin, canAccessCompany } from '../src/api/middleware/auth'; +import { requireAuth, isAdmin, canAccessCompany, resetDefaultUser } from '../src/api/middleware/auth'; import authRouter from '../src/api/auth'; const JWT_SECRET = process.env.NEXTAUTH_SECRET as string; @@ -38,31 +41,54 @@ function signToken(payload: Record) { return jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' }); } +/** The row resolveDefaultUser() finds when the app runs without a token. */ +const DEFAULT_USER_ROW = { id: 'default', email: 'admin@b.com', role: 'ADMIN', company_id: null }; + beforeEach(() => { mockSingle.mockReset(); + mockLimit.mockReset(); + mockLimit.mockResolvedValue({ data: [DEFAULT_USER_ROW], error: null }); + resetDefaultUser(); }); +// Sign-in was removed from the product: an absent or unusable token is served as +// the default user rather than rejected. describe('requireAuth middleware', () => { - it('rejects requests with no token (401)', async () => { + it('serves requests with no token as the default user', async () => { const res = await request(buildApp()).get('/protected'); - expect(res.status).toBe(401); - expect(res.body.error).toMatch(/authentication required/i); + expect(res.status).toBe(200); + expect(res.body.user).toMatchObject({ id: 'default', role: 'ADMIN' }); }); - it('rejects an invalid/garbage token (401)', async () => { + it('falls back to the default user for an invalid/garbage token', async () => { const res = await request(buildApp()) .get('/protected') .set('Authorization', 'Bearer not-a-real-token'); - expect(res.status).toBe(401); + expect(res.status).toBe(200); + expect(res.body.user).toMatchObject({ id: 'default' }); }); - it('rejects an expired token (401, TOKEN_EXPIRED)', async () => { + it('falls back to the default user for an expired token', async () => { const expired = jwt.sign({ userId: 'u1' }, JWT_SECRET, { expiresIn: -10 }); const res = await request(buildApp()) .get('/protected') .set('Authorization', `Bearer ${expired}`); - expect(res.status).toBe(401); - expect(res.body.code).toBe('TOKEN_EXPIRED'); + expect(res.status).toBe(200); + expect(res.body.user).toMatchObject({ id: 'default' }); + }); + + it('503s when no token is sent and the User table is empty', async () => { + mockLimit.mockResolvedValue({ data: [], error: null }); + const res = await request(buildApp()).get('/protected'); + expect(res.status).toBe(503); + expect(res.body.error).toMatch(/no user records/i); + }); + + it('503s with the DB error when the database is unreachable', async () => { + mockLimit.mockRejectedValue(new Error('fetch failed')); + const res = await request(buildApp()).get('/protected'); + expect(res.status).toBe(503); + expect(res.body.error).toMatch(/database unavailable: fetch failed/i); }); it('accepts a valid token and attaches the DB user to req.user', async () => { @@ -78,13 +104,34 @@ describe('requireAuth middleware', () => { expect(res.body.user).toMatchObject({ id: 'u1', role: 'SALES_REP', companyId: 'c1' }); }); - it('rejects a valid token whose user no longer exists (401)', async () => { + it('falls back to the default user when the token user no longer exists', async () => { mockSingle.mockResolvedValue({ data: null, error: { message: 'not found' } }); const token = signToken({ userId: 'ghost' }); const res = await request(buildApp()) .get('/protected') .set('Authorization', `Bearer ${token}`); - expect(res.status).toBe(401); + expect(res.status).toBe(200); + expect(res.body.user).toMatchObject({ id: 'default' }); + }); +}); + +describe('GET /api/auth/session', () => { + it('reports the default user when no token is sent', async () => { + const res = await request(buildApp()).get('/api/auth/session'); + expect(res.status).toBe(200); + expect(res.body.user).toMatchObject({ id: 'default', email: 'admin@b.com', role: 'ADMIN' }); + }); + + it('reports the token user when a valid token is sent', async () => { + mockSingle.mockResolvedValue({ + data: { id: 'u1', email: 'a@b.com', role: 'SALES_REP', company_id: 'c1' }, + error: null, + }); + const res = await request(buildApp()) + .get('/api/auth/session') + .set('Authorization', `Bearer ${signToken({ userId: 'u1' })}`); + expect(res.status).toBe(200); + expect(res.body.user).toMatchObject({ id: 'u1', companyId: 'c1' }); }); }); diff --git a/tests/notifications.test.ts b/tests/notifications.test.ts index 11df61b..22de18a 100644 --- a/tests/notifications.test.ts +++ b/tests/notifications.test.ts @@ -3,15 +3,18 @@ import express from "express"; import request from "supertest"; import jwt from "jsonwebtoken"; -// Supabase mock (requireAuth loads the user via .single()). -const { mockSingle, supabaseMock } = vi.hoisted(() => { +// Supabase mock (requireAuth loads the token user via .single(), the default +// user via .limit()). +const { mockSingle, mockLimit, supabaseMock } = vi.hoisted(() => { const mockSingle = vi.fn(); + const mockLimit = vi.fn(); const builder: Record = {}; builder.select = vi.fn(() => builder); builder.eq = vi.fn(() => builder); builder.single = mockSingle; + builder.limit = mockLimit; const supabaseMock = { from: vi.fn(() => builder) }; - return { mockSingle, supabaseMock }; + return { mockSingle, mockLimit, supabaseMock }; }); vi.mock("../src/lib/supabase", () => ({ supabase: supabaseMock, default: supabaseMock })); @@ -20,6 +23,7 @@ const { getScopedAuditLogs } = vi.hoisted(() => ({ getScopedAuditLogs: vi.fn() } vi.mock("../src/api/lib/auditQuery", () => ({ getScopedAuditLogs })); import notificationsRouter from "../src/api/notifications"; +import { resetDefaultUser } from "../src/api/middleware/auth"; const JWT_SECRET = process.env.NEXTAUTH_SECRET as string; @@ -50,13 +54,21 @@ const log = (id: string, isoOffsetMin: number) => ({ beforeEach(() => { mockSingle.mockReset(); + mockLimit.mockReset(); + mockLimit.mockResolvedValue({ + data: [{ id: "default", email: "admin@b.com", role: "ADMIN", company_id: null }], + error: null, + }); + resetDefaultUser(); getScopedAuditLogs.mockReset(); }); describe("GET /api/notifications", () => { - it("401 without a token", async () => { + it("serves the default user's notifications without a token", async () => { + getScopedAuditLogs.mockResolvedValue([log("a", 5)]); const res = await request(buildApp()).get("/api/notifications"); - expect(res.status).toBe(401); + expect(res.status).toBe(200); + expect(res.body.items).toHaveLength(1); }); it("returns all items as unread when no `since` is given", async () => {