diff --git a/.env.web b/.env.web index 40a7652..5608ba0 100644 --- a/.env.web +++ b/.env.web @@ -1,5 +1,5 @@ NUXT_PORT=3001 NUXT_HOST=0.0.0.0 -# Point Nuxt to the API container (not localhost) -NUXT_PUBLIC_API_BASE_URL=https://tenant1.routebox.co +# Nitro BFF backend URL (server-only, not exposed to client) +BACKEND_URL=https://backend.routebox.co \ No newline at end of file diff --git a/backend/src/app-builder/app-builder.service.ts b/backend/src/app-builder/app-builder.service.ts index 76581e7..f6b0473 100644 --- a/backend/src/app-builder/app-builder.service.ts +++ b/backend/src/app-builder/app-builder.service.ts @@ -10,14 +10,14 @@ export class AppBuilderService { // Runtime endpoints async getApps(tenantId: string, userId: string) { - const knex = await this.tenantDbService.getTenantKnex(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(tenantId); // For now, return all apps // In production, you'd filter by user permissions return App.query(knex).withGraphFetched('pages').orderBy('label', 'asc'); } async getApp(tenantId: string, slug: string, userId: string) { - const knex = await this.tenantDbService.getTenantKnex(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(tenantId); const app = await App.query(knex) .findOne({ slug }) .withGraphFetched('pages'); @@ -35,7 +35,7 @@ export class AppBuilderService { pageSlug: string, userId: string, ) { - const knex = await this.tenantDbService.getTenantKnex(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(tenantId); const app = await this.getApp(tenantId, appSlug, userId); const page = await AppPage.query(knex).findOne({ @@ -52,12 +52,12 @@ export class AppBuilderService { // Setup endpoints async getAllApps(tenantId: string) { - const knex = await this.tenantDbService.getTenantKnex(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(tenantId); return App.query(knex).withGraphFetched('pages').orderBy('label', 'asc'); } async getAppForSetup(tenantId: string, slug: string) { - const knex = await this.tenantDbService.getTenantKnex(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(tenantId); const app = await App.query(knex) .findOne({ slug }) .withGraphFetched('pages'); @@ -77,7 +77,7 @@ export class AppBuilderService { description?: string; }, ) { - const knex = await this.tenantDbService.getTenantKnex(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(tenantId); return App.query(knex).insert({ ...data, displayOrder: 0, @@ -92,7 +92,7 @@ export class AppBuilderService { description?: string; }, ) { - const knex = await this.tenantDbService.getTenantKnex(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(tenantId); const app = await this.getAppForSetup(tenantId, slug); return App.query(knex).patchAndFetchById(app.id, data); @@ -109,7 +109,7 @@ export class AppBuilderService { sortOrder?: number; }, ) { - const knex = await this.tenantDbService.getTenantKnex(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(tenantId); const app = await this.getAppForSetup(tenantId, appSlug); return AppPage.query(knex).insert({ @@ -133,7 +133,7 @@ export class AppBuilderService { sortOrder?: number; }, ) { - const knex = await this.tenantDbService.getTenantKnex(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(tenantId); const app = await this.getAppForSetup(tenantId, appSlug); const page = await AppPage.query(knex).findOne({ diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 1188441..197f8ea 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -29,8 +29,8 @@ export class AuthService { } // Otherwise, validate as tenant user - const tenantDb = await this.tenantDbService.getTenantKnex(tenantId); - + const tenantDb = await this.tenantDbService.getTenantKnexById(tenantId); + const user = await tenantDb('users') .where({ email }) .first(); @@ -113,7 +113,7 @@ export class AuthService { } // Otherwise, register as tenant user - const tenantDb = await this.tenantDbService.getTenantKnex(tenantId); + const tenantDb = await this.tenantDbService.getTenantKnexById(tenantId); const hashedPassword = await bcrypt.hash(password, 10); diff --git a/backend/src/page-layout/page-layout.service.ts b/backend/src/page-layout/page-layout.service.ts index 6b3c8e2..ce21c88 100644 --- a/backend/src/page-layout/page-layout.service.ts +++ b/backend/src/page-layout/page-layout.service.ts @@ -7,7 +7,7 @@ export class PageLayoutService { constructor(private tenantDbService: TenantDatabaseService) {} async create(tenantId: string, createDto: CreatePageLayoutDto) { - const knex = await this.tenantDbService.getTenantKnex(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(tenantId); const layoutType = createDto.layoutType || 'detail'; // If this layout is set as default, unset other defaults for the same object and layout type @@ -32,7 +32,7 @@ export class PageLayoutService { } async findAll(tenantId: string, objectId?: string, layoutType?: PageLayoutType) { - const knex = await this.tenantDbService.getTenantKnex(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(tenantId); let query = knex('page_layouts'); @@ -49,7 +49,7 @@ export class PageLayoutService { } async findOne(tenantId: string, id: string) { - const knex = await this.tenantDbService.getTenantKnex(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(tenantId); const layout = await knex('page_layouts').where({ id }).first(); @@ -61,7 +61,7 @@ export class PageLayoutService { } async findDefaultByObject(tenantId: string, objectId: string, layoutType: PageLayoutType = 'detail') { - const knex = await this.tenantDbService.getTenantKnex(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(tenantId); const layout = await knex('page_layouts') .where({ object_id: objectId, is_default: true, layout_type: layoutType }) @@ -71,7 +71,7 @@ export class PageLayoutService { } async update(tenantId: string, id: string, updateDto: UpdatePageLayoutDto) { - const knex = await this.tenantDbService.getTenantKnex(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(tenantId); // Check if layout exists const layout = await this.findOne(tenantId, id); @@ -112,7 +112,7 @@ export class PageLayoutService { } async remove(tenantId: string, id: string) { - const knex = await this.tenantDbService.getTenantKnex(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(tenantId); await this.findOne(tenantId, id); diff --git a/backend/src/tenant/tenant.middleware.ts b/backend/src/tenant/tenant.middleware.ts index 5d4e40e..0ce9824 100644 --- a/backend/src/tenant/tenant.middleware.ts +++ b/backend/src/tenant/tenant.middleware.ts @@ -14,61 +14,61 @@ export class TenantMiddleware implements NestMiddleware { next: () => void, ) { try { - // Extract subdomain from hostname - const host = req.headers.host || ''; - const hostname = host.split(':')[0]; // Remove port if present - - // Check Origin header to get frontend subdomain (for API calls) - const origin = req.headers.origin as string; - const referer = req.headers.referer as string; - - let parts = hostname.split('.'); - - this.logger.log(`Host header: ${host}, hostname: ${hostname}, origin: ${origin}, referer: ${referer}, parts: ${JSON.stringify(parts)}`); - - // For local development, accept x-tenant-id header + // Priority 1: Check x-tenant-subdomain header from Nitro BFF proxy + // This is the primary method when using the BFF architecture + let subdomain = req.headers['x-tenant-subdomain'] as string | null; let tenantId = req.headers['x-tenant-id'] as string; - let subdomain: string | null = null; - this.logger.log(`Host header: ${host}, hostname: ${hostname}, parts: ${JSON.stringify(parts)}, x-tenant-id: ${tenantId}`); + if (subdomain) { + this.logger.log(`Using x-tenant-subdomain header: ${subdomain}`); + } - // Try to extract subdomain from Origin header first (for API calls from frontend) - if (origin) { - try { - const originUrl = new URL(origin); - const originHost = originUrl.hostname; - parts = originHost.split('.'); - this.logger.log(`Using Origin header hostname: ${originHost}, parts: ${JSON.stringify(parts)}`); - } catch (error) { - this.logger.warn(`Failed to parse origin: ${origin}`); + // Priority 2: Fall back to extracting subdomain from Origin/Host headers + // This supports direct backend access for development/testing + if (!subdomain && !tenantId) { + const host = req.headers.host || ''; + const hostname = host.split(':')[0]; + const origin = req.headers.origin as string; + const referer = req.headers.referer as string; + + let parts = hostname.split('.'); + + this.logger.log(`Host header: ${host}, hostname: ${hostname}, origin: ${origin}, referer: ${referer}`); + + // Try to extract subdomain from Origin header first (for API calls from frontend) + if (origin) { + try { + const originUrl = new URL(origin); + const originHost = originUrl.hostname; + parts = originHost.split('.'); + this.logger.log(`Using Origin header hostname: ${originHost}, parts: ${JSON.stringify(parts)}`); + } catch (error) { + this.logger.warn(`Failed to parse origin: ${origin}`); + } + } else if (referer) { + // Fallback to Referer if no Origin + try { + const refererUrl = new URL(referer); + const refererHost = refererUrl.hostname; + parts = refererHost.split('.'); + this.logger.log(`Using Referer header hostname: ${refererHost}, parts: ${JSON.stringify(parts)}`); + } catch (error) { + this.logger.warn(`Failed to parse referer: ${referer}`); + } } - } else if (referer && !tenantId) { - // Fallback to Referer if no Origin - try { - const refererUrl = new URL(referer); - const refererHost = refererUrl.hostname; - parts = refererHost.split('.'); - this.logger.log(`Using Referer header hostname: ${refererHost}, parts: ${JSON.stringify(parts)}`); - } catch (error) { - this.logger.warn(`Failed to parse referer: ${referer}`); + + // Extract subdomain (e.g., "tenant1" from "tenant1.routebox.co") + if (parts.length >= 3) { + subdomain = parts[0]; + if (subdomain === 'www') { + subdomain = null; + } + } else if (parts.length === 2 && parts[1] === 'localhost') { + subdomain = parts[0]; } } - // Extract subdomain (e.g., "tenant1" from "tenant1.routebox.co") - // For production domains with 3+ parts, extract first part as subdomain - if (parts.length >= 3) { - subdomain = parts[0]; - // Ignore www subdomain - if (subdomain === 'www') { - subdomain = null; - } - } - // For development (e.g., tenant1.localhost), also check 2 parts - else if (parts.length === 2 && parts[1] === 'localhost') { - subdomain = parts[0]; - } - - this.logger.log(`Extracted subdomain: ${subdomain}`); + this.logger.log(`Extracted subdomain: ${subdomain}, x-tenant-id: ${tenantId}`); // Always attach subdomain to request if present if (subdomain) { @@ -122,7 +122,7 @@ export class TenantMiddleware implements NestMiddleware { // Attach tenant info to request object (req as any).tenantId = tenantId; } else { - this.logger.warn(`No tenant identified from host: ${hostname}`); + this.logger.warn(`No tenant identified from host: ${subdomain}`); } next(); diff --git a/frontend/components/LoginForm.vue b/frontend/components/LoginForm.vue index 45c6ba9..00edb16 100644 --- a/frontend/components/LoginForm.vue +++ b/frontend/components/LoginForm.vue @@ -3,90 +3,32 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -const config = useRuntimeConfig() const router = useRouter() const { toast } = useToast() +const { login, isLoading } = useAuth() -// Cookie for server-side auth check -const tokenCookie = useCookie('token') - -// Extract subdomain from hostname (e.g., tenant1.localhost → tenant1) -const getSubdomain = () => { - if (!import.meta.client) return null - const hostname = window.location.hostname - const parts = hostname.split('.') - - console.log('Extracting subdomain from:', hostname, 'parts:', parts) - - // For localhost development: tenant1.localhost or localhost - if (hostname === 'localhost' || hostname === '127.0.0.1') { - return null // Use default tenant for plain localhost - } - - // For subdomains like tenant1.routebox.co or tenant1.localhost - if (parts.length >= 2 && parts[0] !== 'www') { - console.log('Using subdomain:', parts[0]) - return parts[0] // Return subdomain - } - - return null -} - -const subdomain = ref(getSubdomain()) const email = ref('') const password = ref('') -const loading = ref(false) const error = ref('') const handleLogin = async () => { try { - loading.value = true error.value = '' - const headers: Record = { - 'Content-Type': 'application/json', + // Use the BFF login endpoint via useAuth + const result = await login(email.value, password.value) + + if (result.success) { + toast.success('Login successful!') + // Redirect to home + router.push('/') + } else { + error.value = result.error || 'Login failed' + toast.error(result.error || 'Login failed') } - - // Only send x-tenant-id if we have a subdomain - if (subdomain.value) { - headers['x-tenant-id'] = subdomain.value - } - - const response = await fetch(`${config.public.apiBaseUrl}/api/auth/login`, { - method: 'POST', - headers, - body: JSON.stringify({ - email: email.value, - password: password.value, - }), - }) - - if (!response.ok) { - const data = await response.json() - throw new Error(data.message || 'Login failed') - } - - const data = await response.json() - - // Store credentials in localStorage - // Store the tenant ID that was used for login - const tenantToStore = subdomain.value || data.user?.tenantId || 'tenant1' - localStorage.setItem('tenantId', tenantToStore) - localStorage.setItem('token', data.access_token) - localStorage.setItem('user', JSON.stringify(data.user)) - - // Also store token in cookie for server-side auth check - tokenCookie.value = data.access_token - - toast.success('Login successful!') - - // Redirect to home - router.push('/') } catch (e: any) { error.value = e.message || 'Login failed' toast.error(e.message || 'Login failed') - } finally { - loading.value = false } } @@ -118,8 +60,8 @@ const handleLogin = async () => { -
diff --git a/frontend/composables/useApi.ts b/frontend/composables/useApi.ts index 087c89f..842d442 100644 --- a/frontend/composables/useApi.ts +++ b/frontend/composables/useApi.ts @@ -1,40 +1,25 @@ export const useApi = () => { - const config = useRuntimeConfig() const router = useRouter() const { toast } = useToast() const { isLoggedIn, logout } = useAuth() - // Use current domain for API calls (same subdomain routing) + /** + * API calls now go through the Nitro BFF proxy at /api/* + * The proxy handles: + * - Auth token injection from HTTP-only cookies + * - Tenant subdomain extraction and forwarding + * - Forwarding requests to the NestJS backend + */ const getApiBaseUrl = () => { - if (import.meta.client) { - // In browser, use current hostname but with port 3000 for API - const currentHost = window.location.hostname - const protocol = window.location.protocol - //return `${protocol}//${currentHost}:3000` - return `${protocol}//${currentHost}` - } - // Fallback for SSR - return config.public.apiBaseUrl + // All API calls go through Nitro proxy - works for both SSR and client + return '' } const getHeaders = () => { + // Headers are now minimal - auth and tenant are handled by the Nitro proxy const headers: Record = { 'Content-Type': 'application/json', } - - // Add tenant ID from localStorage or state - if (import.meta.client) { - const tenantId = localStorage.getItem('tenantId') - if (tenantId) { - headers['x-tenant-id'] = tenantId - } - - const token = localStorage.getItem('token') - if (token) { - headers['Authorization'] = `Bearer ${token}` - } - } - return headers } diff --git a/frontend/composables/useAuth.ts b/frontend/composables/useAuth.ts index 4758130..45c8985 100644 --- a/frontend/composables/useAuth.ts +++ b/frontend/composables/useAuth.ts @@ -1,61 +1,131 @@ +/** + * Authentication composable using BFF (Backend for Frontend) pattern + * Auth tokens are stored in HTTP-only cookies managed by Nitro server + * Tenant context is stored in a readable cookie for client-side access + */ export const useAuth = () => { - const tokenCookie = useCookie('token') const authMessageCookie = useCookie('authMessage') + const tenantCookie = useCookie('routebox_tenant') const router = useRouter() - const config = useRuntimeConfig() + // Reactive user state - populated from /api/auth/me + const user = useState('auth_user', () => null) + const isAuthenticated = useState('auth_is_authenticated', () => false) + const isLoading = useState('auth_is_loading', () => false) + + /** + * Check if user is logged in + * Uses server-side session validation via /api/auth/me + */ const isLoggedIn = () => { - if (!import.meta.client) return false - const token = localStorage.getItem('token') - const tenantId = localStorage.getItem('tenantId') - return !!(token && tenantId) + return isAuthenticated.value } - const logout = async () => { - if (import.meta.client) { - // Call backend logout endpoint - try { - const token = localStorage.getItem('token') - const tenantId = localStorage.getItem('tenantId') - - if (token) { - await fetch(`${config.public.apiBaseUrl}/api/auth/logout`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - ...(tenantId && { 'x-tenant-id': tenantId }), - }, - }) - } - } catch (error) { - console.error('Logout error:', error) + /** + * Login with email and password + * Calls the Nitro BFF login endpoint which sets HTTP-only cookies + */ + const login = async (email: string, password: string) => { + isLoading.value = true + + try { + const response = await $fetch('/api/auth/login', { + method: 'POST', + body: { email, password }, + }) + + if (response.success) { + user.value = response.user + isAuthenticated.value = true + return { success: true, user: response.user } } - // Clear local storage - localStorage.removeItem('token') - localStorage.removeItem('tenantId') - localStorage.removeItem('user') - - // Clear cookie for server-side check - tokenCookie.value = null - - // Set flash message for login page - authMessageCookie.value = 'Logged out successfully' - - // Redirect to login page - router.push('/login') + return { success: false, error: 'Login failed' } + } catch (error: any) { + const message = error.data?.message || error.message || 'Login failed' + return { success: false, error: message } + } finally { + isLoading.value = false } } + /** + * Logout user + * Calls the Nitro BFF logout endpoint which clears HTTP-only cookies + */ + const logout = async () => { + try { + await $fetch('/api/auth/logout', { + method: 'POST', + }) + } catch (error) { + console.error('Logout error:', error) + } + + // Clear local state + user.value = null + isAuthenticated.value = false + + // Set flash message for login page + authMessageCookie.value = 'Logged out successfully' + + // Redirect to login page + router.push('/login') + } + + /** + * Check current authentication status + * Validates session with backend via Nitro BFF + */ + const checkAuth = async () => { + isLoading.value = true + + try { + const response = await $fetch('/api/auth/me', { + method: 'GET', + }) + + if (response.authenticated && response.user) { + user.value = response.user + isAuthenticated.value = true + return true + } + } catch (error) { + // Session invalid or expired + user.value = null + isAuthenticated.value = false + } finally { + isLoading.value = false + } + + return false + } + + /** + * Get current user + */ const getUser = () => { - if (!import.meta.client) return null - const userStr = localStorage.getItem('user') - return userStr ? JSON.parse(userStr) : null + return user.value + } + + /** + * Get current tenant ID from cookie + */ + const getTenantId = () => { + return tenantCookie.value } return { + // State + user, + isAuthenticated, + isLoading, + // Methods isLoggedIn, + login, logout, + checkAuth, getUser, + getTenantId, } } diff --git a/frontend/middleware/auth.global.ts b/frontend/middleware/auth.global.ts index 401d177..7a447cf 100644 --- a/frontend/middleware/auth.global.ts +++ b/frontend/middleware/auth.global.ts @@ -1,4 +1,4 @@ -export default defineNuxtRouteMiddleware((to, from) => { +export default defineNuxtRouteMiddleware(async (to, from) => { // Allow pages to opt-out of auth with definePageMeta({ auth: false }) if (to.meta.auth === false) { return @@ -11,28 +11,47 @@ export default defineNuxtRouteMiddleware((to, from) => { return } - const token = useCookie('token') const authMessage = useCookie('authMessage') + // Check for session cookie (HTTP-only cookie is checked server-side via API) + const tenantCookie = useCookie('routebox_tenant') // Routes that don't need a toast message (user knows they need to login) const silentRoutes = ['/'] + + // Quick check: if no tenant cookie, likely not authenticated + // The actual session cookie is HTTP-only and can't be read client-side + // For a full check, we'd call /api/auth/me, but that's expensive for every route - // Check token cookie (works on both server and client) - if (!token.value) { + // On client side, check the reactive auth state + if (import.meta.client) { + const { isAuthenticated, checkAuth } = useAuth() + + // If we already know we're authenticated, allow + if (isAuthenticated.value) { + return + } + + // If we have a tenant cookie, try to validate the session + if (tenantCookie.value) { + const isValid = await checkAuth() + if (isValid) { + return + } + } + + // Not authenticated if (!silentRoutes.includes(to.path)) { authMessage.value = 'Please login to access this page' } return navigateTo('/login') } - // On client side, also verify localStorage is in sync - if (import.meta.client) { - const { isLoggedIn } = useAuth() - if (!isLoggedIn()) { - if (!silentRoutes.includes(to.path)) { - authMessage.value = 'Please login to access this page' - } - return navigateTo('/login') + // Server-side: check for tenant cookie as a quick indicator + // If no tenant cookie, redirect to login + if (!tenantCookie.value) { + if (!silentRoutes.includes(to.path)) { + authMessage.value = 'Please login to access this page' } + return navigateTo('/login') } }) diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index 23bb41d..62f4e34 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -24,9 +24,9 @@ export default defineNuxtConfig({ }, runtimeConfig: { - public: { - apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', - }, + // Server-only config (not exposed to client) + // Used by Nitro BFF to proxy requests to the NestJS backend + backendUrl: process.env.BACKEND_URL || 'http://localhost:3000', }, app: { diff --git a/frontend/pages/register.vue b/frontend/pages/register.vue index c0987ed..727b3e3 100644 --- a/frontend/pages/register.vue +++ b/frontend/pages/register.vue @@ -74,24 +74,8 @@ definePageMeta({ auth: false }) -const config = useRuntimeConfig() const router = useRouter() -// Extract subdomain from hostname -const getSubdomain = () => { - if (!import.meta.client) return null - const hostname = window.location.hostname - const parts = hostname.split('.') - if (hostname === 'localhost' || hostname === '127.0.0.1') { - return null - } - if (parts.length > 1 && parts[0] !== 'www') { - return parts[0] - } - return null -} - -const subdomain = ref(getSubdomain()) const email = ref('') const password = ref('') const firstName = ref('') @@ -106,30 +90,17 @@ const handleRegister = async () => { error.value = '' success.value = false - const headers: Record = { - 'Content-Type': 'application/json', - } - - if (subdomain.value) { - headers['x-tenant-id'] = subdomain.value - } - - const response = await fetch(`${config.public.apiBaseUrl}/api/auth/register`, { + // Use BFF proxy - subdomain/tenant is handled automatically by Nitro + await $fetch('/api/auth/register', { method: 'POST', - headers, - body: JSON.stringify({ + body: { email: email.value, password: password.value, firstName: firstName.value || undefined, lastName: lastName.value || undefined, - }), + }, }) - if (!response.ok) { - const data = await response.json() - throw new Error(data.message || 'Registration failed') - } - success.value = true // Redirect to login after 2 seconds @@ -137,9 +108,10 @@ const handleRegister = async () => { router.push('/login') }, 2000) } catch (e: any) { - error.value = e.message || 'Registration failed' + error.value = e.data?.message || e.message || 'Registration failed' } finally { loading.value = false } } + diff --git a/frontend/server/api/[...path].ts b/frontend/server/api/[...path].ts new file mode 100644 index 0000000..9312408 --- /dev/null +++ b/frontend/server/api/[...path].ts @@ -0,0 +1,111 @@ +import { defineEventHandler, getMethod, readBody, getQuery, createError, getHeader } from 'h3' +import { getSubdomainFromRequest } from '~/server/utils/tenant' +import { getSessionToken } from '~/server/utils/session' + +/** + * Catch-all API proxy that forwards requests to the NestJS backend + * Injects x-tenant-subdomain header and Authorization from HTTP-only cookie + */ +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig() + const method = getMethod(event) + const path = event.context.params?.path || '' + + // Get subdomain and session token + const subdomain = getSubdomainFromRequest(event) + const token = getSessionToken(event) + + const backendUrl = config.backendUrl || 'http://localhost:3000' + + // Build the full URL with query parameters + const query = getQuery(event) + const queryString = new URLSearchParams(query as Record).toString() + const fullUrl = `${backendUrl}/api/${path}${queryString ? `?${queryString}` : ''}` + + // Build headers to forward + const headers: Record = { + 'Content-Type': getHeader(event, 'content-type') || 'application/json', + } + + // Add subdomain header for backend tenant resolution + if (subdomain) { + headers['x-tenant-subdomain'] = subdomain + } + + // Add auth token from HTTP-only cookie + if (token) { + headers['Authorization'] = `Bearer ${token}` + } + + // Forward additional headers that might be needed + const acceptHeader = getHeader(event, 'accept') + if (acceptHeader) { + headers['Accept'] = acceptHeader + } + + try { + // Prepare fetch options + const fetchOptions: RequestInit = { + method, + headers, + } + + // Add body for methods that support it + if (['POST', 'PUT', 'PATCH'].includes(method)) { + const body = await readBody(event) + if (body) { + fetchOptions.body = JSON.stringify(body) + } + } + + // Make request to backend + const response = await fetch(fullUrl, fetchOptions) + + // Handle non-JSON responses (like file downloads) + const contentType = response.headers.get('content-type') + + if (!response.ok) { + // Try to get error details + let errorMessage = `Backend error: ${response.status}` + let errorData = null + + try { + errorData = await response.json() + errorMessage = errorData.message || errorMessage + } catch { + // Response wasn't JSON + } + + throw createError({ + statusCode: response.status, + statusMessage: errorMessage, + data: errorData, + }) + } + + // Return empty response for 204 No Content + if (response.status === 204) { + return null + } + + // Handle JSON responses + if (contentType?.includes('application/json')) { + return await response.json() + } + + // Handle other content types (text, etc.) + return await response.text() + + } catch (error: any) { + // Re-throw H3 errors + if (error.statusCode) { + throw error + } + + console.error(`Proxy error for ${method} /api/${path}:`, error) + throw createError({ + statusCode: 502, + statusMessage: 'Failed to connect to backend service', + }) + } +}) diff --git a/frontend/server/api/auth/login.post.ts b/frontend/server/api/auth/login.post.ts new file mode 100644 index 0000000..b5833c2 --- /dev/null +++ b/frontend/server/api/auth/login.post.ts @@ -0,0 +1,81 @@ +import { defineEventHandler, readBody, createError } from 'h3' +import { getSubdomainFromRequest, isCentralSubdomain } from '~/server/utils/tenant' +import { setSessionCookie, setTenantIdCookie } from '~/server/utils/session' + +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig() + const body = await readBody(event) + + // Extract subdomain from the request + const subdomain = getSubdomainFromRequest(event) + + if (!subdomain) { + throw createError({ + statusCode: 400, + statusMessage: 'Unable to determine tenant from subdomain', + }) + } + + // Determine the backend URL based on whether this is central admin or tenant + const isCentral = isCentralSubdomain(subdomain) + const backendUrl = config.backendUrl || 'http://localhost:3000' + const loginEndpoint = isCentral ? '/api/auth/central/login' : '/api/auth/login' + + try { + // Forward login request to NestJS backend with subdomain header + const response = await fetch(`${backendUrl}${loginEndpoint}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-tenant-subdomain': subdomain, + }, + body: JSON.stringify(body), + }) + + const data = await response.json() + + if (!response.ok) { + throw createError({ + statusCode: response.status, + statusMessage: data.message || 'Login failed', + data: data, + }) + } + + // Extract token and tenant info from response + const { access_token, user, tenantId } = data + + if (!access_token) { + throw createError({ + statusCode: 500, + statusMessage: 'No access token received from backend', + }) + } + + // Set HTTP-only cookie with the JWT token + setSessionCookie(event, access_token) + + // Set tenant ID cookie (readable by client for context) + if (tenantId) { + setTenantIdCookie(event, tenantId) + } + + // Return user info (but NOT the token - it's in HTTP-only cookie) + return { + success: true, + user, + tenantId, + } + } catch (error: any) { + // Re-throw H3 errors + if (error.statusCode) { + throw error + } + + console.error('Login proxy error:', error) + throw createError({ + statusCode: 500, + statusMessage: 'Failed to connect to authentication service', + }) + } +}) diff --git a/frontend/server/api/auth/logout.post.ts b/frontend/server/api/auth/logout.post.ts new file mode 100644 index 0000000..a31cb34 --- /dev/null +++ b/frontend/server/api/auth/logout.post.ts @@ -0,0 +1,37 @@ +import { defineEventHandler, createError } from 'h3' +import { getSubdomainFromRequest } from '~/server/utils/tenant' +import { getSessionToken, clearSessionCookie, clearTenantIdCookie } from '~/server/utils/session' + +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig() + const subdomain = getSubdomainFromRequest(event) + const token = getSessionToken(event) + + const backendUrl = config.backendUrl || 'http://localhost:3000' + + try { + // Call backend logout endpoint if we have a token + if (token) { + await fetch(`${backendUrl}/api/auth/logout`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + ...(subdomain && { 'x-tenant-subdomain': subdomain }), + }, + }) + } + } catch (error) { + // Log but don't fail - we still want to clear cookies + console.error('Backend logout error:', error) + } + + // Always clear cookies regardless of backend response + clearSessionCookie(event) + clearTenantIdCookie(event) + + return { + success: true, + message: 'Logged out successfully', + } +}) diff --git a/frontend/server/api/auth/me.get.ts b/frontend/server/api/auth/me.get.ts new file mode 100644 index 0000000..c17df4c --- /dev/null +++ b/frontend/server/api/auth/me.get.ts @@ -0,0 +1,60 @@ +import { defineEventHandler, createError } from 'h3' +import { getSubdomainFromRequest } from '~/server/utils/tenant' +import { getSessionToken } from '~/server/utils/session' + +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig() + const subdomain = getSubdomainFromRequest(event) + const token = getSessionToken(event) + + if (!token) { + throw createError({ + statusCode: 401, + statusMessage: 'Not authenticated', + }) + } + + const backendUrl = config.backendUrl || 'http://localhost:3000' + + try { + // Fetch current user from backend + const response = await fetch(`${backendUrl}/api/auth/me`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + ...(subdomain && { 'x-tenant-subdomain': subdomain }), + }, + }) + + if (!response.ok) { + if (response.status === 401) { + throw createError({ + statusCode: 401, + statusMessage: 'Session expired', + }) + } + throw createError({ + statusCode: response.status, + statusMessage: 'Failed to fetch user', + }) + } + + const user = await response.json() + + return { + authenticated: true, + user, + } + } catch (error: any) { + if (error.statusCode) { + throw error + } + + console.error('Auth check error:', error) + throw createError({ + statusCode: 500, + statusMessage: 'Failed to verify authentication', + }) + } +}) diff --git a/frontend/server/utils/session.ts b/frontend/server/utils/session.ts new file mode 100644 index 0000000..59d548a --- /dev/null +++ b/frontend/server/utils/session.ts @@ -0,0 +1,75 @@ +import type { H3Event } from 'h3' +import { getCookie, setCookie, deleteCookie } from 'h3' + +const SESSION_COOKIE_NAME = 'routebox_session' +const SESSION_MAX_AGE = 60 * 60 * 24 * 7 // 7 days + +export interface SessionData { + token: string + tenantId: string + userId: string + email: string +} + +/** + * Get the session token from HTTP-only cookie + */ +export function getSessionToken(event: H3Event): string | null { + return getCookie(event, SESSION_COOKIE_NAME) || null +} + +/** + * Set the session token in an HTTP-only cookie + */ +export function setSessionCookie(event: H3Event, token: string): void { + const isProduction = process.env.NODE_ENV === 'production' + + setCookie(event, SESSION_COOKIE_NAME, token, { + httpOnly: true, + secure: isProduction, + sameSite: 'lax', + maxAge: SESSION_MAX_AGE, + path: '/', + }) +} + +/** + * Clear the session cookie + */ +export function clearSessionCookie(event: H3Event): void { + deleteCookie(event, SESSION_COOKIE_NAME, { + path: '/', + }) +} + +/** + * Get tenant ID from a separate cookie (for SSR access) + * This is NOT the auth token - just tenant context + */ +export function getTenantIdFromCookie(event: H3Event): string | null { + return getCookie(event, 'routebox_tenant') || null +} + +/** + * Set tenant ID cookie (readable by client for context) + */ +export function setTenantIdCookie(event: H3Event, tenantId: string): void { + const isProduction = process.env.NODE_ENV === 'production' + + setCookie(event, 'routebox_tenant', tenantId, { + httpOnly: false, // Allow client to read tenant context + secure: isProduction, + sameSite: 'lax', + maxAge: SESSION_MAX_AGE, + path: '/', + }) +} + +/** + * Clear tenant ID cookie + */ +export function clearTenantIdCookie(event: H3Event): void { + deleteCookie(event, 'routebox_tenant', { + path: '/', + }) +} diff --git a/frontend/server/utils/tenant.ts b/frontend/server/utils/tenant.ts new file mode 100644 index 0000000..b76b984 --- /dev/null +++ b/frontend/server/utils/tenant.ts @@ -0,0 +1,39 @@ +import type { H3Event } from 'h3' +import { getHeader } from 'h3' + +/** + * Extract subdomain from the request Host header + * Handles production domains (tenant1.routebox.co) and development (tenant1.localhost) + */ +export function getSubdomainFromRequest(event: H3Event): string | null { + const host = getHeader(event, 'host') || '' + const hostname = host.split(':')[0] // Remove port if present + + const parts = hostname.split('.') + + // For production domains with 3+ parts (e.g., tenant1.routebox.co) + if (parts.length >= 3) { + const subdomain = parts[0] + // Ignore www subdomain + if (subdomain === 'www') { + return null + } + return subdomain + } + + // For development (e.g., tenant1.localhost) + if (parts.length === 2 && parts[1] === 'localhost') { + return parts[0] + } + + return null +} + +/** + * Check if the subdomain is a central/admin subdomain + */ +export function isCentralSubdomain(subdomain: string | null): boolean { + if (!subdomain) return false + const centralSubdomains = (process.env.CENTRAL_SUBDOMAINS || 'central,admin').split(',') + return centralSubdomains.includes(subdomain) +}