WIP - BFF

This commit is contained in:
Francisco Gaona
2026-02-04 00:21:06 +01:00
parent f68321c802
commit 0e2f3dddbc
17 changed files with 645 additions and 254 deletions

View File

@@ -1,5 +1,5 @@
NUXT_PORT=3001 NUXT_PORT=3001
NUXT_HOST=0.0.0.0 NUXT_HOST=0.0.0.0
# Point Nuxt to the API container (not localhost) # Nitro BFF backend URL (server-only, not exposed to client)
NUXT_PUBLIC_API_BASE_URL=https://tenant1.routebox.co BACKEND_URL=https://backend.routebox.co

View File

@@ -10,14 +10,14 @@ export class AppBuilderService {
// Runtime endpoints // Runtime endpoints
async getApps(tenantId: string, userId: string) { 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 // For now, return all apps
// In production, you'd filter by user permissions // In production, you'd filter by user permissions
return App.query(knex).withGraphFetched('pages').orderBy('label', 'asc'); return App.query(knex).withGraphFetched('pages').orderBy('label', 'asc');
} }
async getApp(tenantId: string, slug: string, userId: string) { 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) const app = await App.query(knex)
.findOne({ slug }) .findOne({ slug })
.withGraphFetched('pages'); .withGraphFetched('pages');
@@ -35,7 +35,7 @@ export class AppBuilderService {
pageSlug: string, pageSlug: string,
userId: 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 app = await this.getApp(tenantId, appSlug, userId);
const page = await AppPage.query(knex).findOne({ const page = await AppPage.query(knex).findOne({
@@ -52,12 +52,12 @@ export class AppBuilderService {
// Setup endpoints // Setup endpoints
async getAllApps(tenantId: string) { 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'); return App.query(knex).withGraphFetched('pages').orderBy('label', 'asc');
} }
async getAppForSetup(tenantId: string, slug: string) { 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) const app = await App.query(knex)
.findOne({ slug }) .findOne({ slug })
.withGraphFetched('pages'); .withGraphFetched('pages');
@@ -77,7 +77,7 @@ export class AppBuilderService {
description?: string; description?: string;
}, },
) { ) {
const knex = await this.tenantDbService.getTenantKnex(tenantId); const knex = await this.tenantDbService.getTenantKnexById(tenantId);
return App.query(knex).insert({ return App.query(knex).insert({
...data, ...data,
displayOrder: 0, displayOrder: 0,
@@ -92,7 +92,7 @@ export class AppBuilderService {
description?: string; description?: string;
}, },
) { ) {
const knex = await this.tenantDbService.getTenantKnex(tenantId); const knex = await this.tenantDbService.getTenantKnexById(tenantId);
const app = await this.getAppForSetup(tenantId, slug); const app = await this.getAppForSetup(tenantId, slug);
return App.query(knex).patchAndFetchById(app.id, data); return App.query(knex).patchAndFetchById(app.id, data);
@@ -109,7 +109,7 @@ export class AppBuilderService {
sortOrder?: number; sortOrder?: number;
}, },
) { ) {
const knex = await this.tenantDbService.getTenantKnex(tenantId); const knex = await this.tenantDbService.getTenantKnexById(tenantId);
const app = await this.getAppForSetup(tenantId, appSlug); const app = await this.getAppForSetup(tenantId, appSlug);
return AppPage.query(knex).insert({ return AppPage.query(knex).insert({
@@ -133,7 +133,7 @@ export class AppBuilderService {
sortOrder?: number; sortOrder?: number;
}, },
) { ) {
const knex = await this.tenantDbService.getTenantKnex(tenantId); const knex = await this.tenantDbService.getTenantKnexById(tenantId);
const app = await this.getAppForSetup(tenantId, appSlug); const app = await this.getAppForSetup(tenantId, appSlug);
const page = await AppPage.query(knex).findOne({ const page = await AppPage.query(knex).findOne({

View File

@@ -29,7 +29,7 @@ export class AuthService {
} }
// Otherwise, validate as tenant user // Otherwise, validate as tenant user
const tenantDb = await this.tenantDbService.getTenantKnex(tenantId); const tenantDb = await this.tenantDbService.getTenantKnexById(tenantId);
const user = await tenantDb('users') const user = await tenantDb('users')
.where({ email }) .where({ email })
@@ -113,7 +113,7 @@ export class AuthService {
} }
// Otherwise, register as tenant user // 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); const hashedPassword = await bcrypt.hash(password, 10);

View File

@@ -7,7 +7,7 @@ export class PageLayoutService {
constructor(private tenantDbService: TenantDatabaseService) {} constructor(private tenantDbService: TenantDatabaseService) {}
async create(tenantId: string, createDto: CreatePageLayoutDto) { 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'; const layoutType = createDto.layoutType || 'detail';
// If this layout is set as default, unset other defaults for the same object and layout type // 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) { 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'); let query = knex('page_layouts');
@@ -49,7 +49,7 @@ export class PageLayoutService {
} }
async findOne(tenantId: string, id: string) { 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(); 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') { 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') const layout = await knex('page_layouts')
.where({ object_id: objectId, is_default: true, layout_type: layoutType }) .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) { 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 // Check if layout exists
const layout = await this.findOne(tenantId, id); const layout = await this.findOne(tenantId, id);
@@ -112,7 +112,7 @@ export class PageLayoutService {
} }
async remove(tenantId: string, id: string) { 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); await this.findOne(tenantId, id);

View File

@@ -14,23 +14,26 @@ export class TenantMiddleware implements NestMiddleware {
next: () => void, next: () => void,
) { ) {
try { try {
// Extract subdomain from hostname // Priority 1: Check x-tenant-subdomain header from Nitro BFF proxy
const host = req.headers.host || ''; // This is the primary method when using the BFF architecture
const hostname = host.split(':')[0]; // Remove port if present let subdomain = req.headers['x-tenant-subdomain'] as string | null;
let tenantId = req.headers['x-tenant-id'] as string;
// Check Origin header to get frontend subdomain (for API calls) if (subdomain) {
this.logger.log(`Using x-tenant-subdomain header: ${subdomain}`);
}
// 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 origin = req.headers.origin as string;
const referer = req.headers.referer as string; const referer = req.headers.referer as string;
let parts = hostname.split('.'); let parts = hostname.split('.');
this.logger.log(`Host header: ${host}, hostname: ${hostname}, origin: ${origin}, referer: ${referer}, parts: ${JSON.stringify(parts)}`); this.logger.log(`Host header: ${host}, hostname: ${hostname}, origin: ${origin}, referer: ${referer}`);
// For local development, accept x-tenant-id header
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}`);
// Try to extract subdomain from Origin header first (for API calls from frontend) // Try to extract subdomain from Origin header first (for API calls from frontend)
if (origin) { if (origin) {
@@ -42,7 +45,7 @@ export class TenantMiddleware implements NestMiddleware {
} catch (error) { } catch (error) {
this.logger.warn(`Failed to parse origin: ${origin}`); this.logger.warn(`Failed to parse origin: ${origin}`);
} }
} else if (referer && !tenantId) { } else if (referer) {
// Fallback to Referer if no Origin // Fallback to Referer if no Origin
try { try {
const refererUrl = new URL(referer); const refererUrl = new URL(referer);
@@ -55,20 +58,17 @@ export class TenantMiddleware implements NestMiddleware {
} }
// Extract subdomain (e.g., "tenant1" from "tenant1.routebox.co") // Extract subdomain (e.g., "tenant1" from "tenant1.routebox.co")
// For production domains with 3+ parts, extract first part as subdomain
if (parts.length >= 3) { if (parts.length >= 3) {
subdomain = parts[0]; subdomain = parts[0];
// Ignore www subdomain
if (subdomain === 'www') { if (subdomain === 'www') {
subdomain = null; subdomain = null;
} }
} } else if (parts.length === 2 && parts[1] === 'localhost') {
// For development (e.g., tenant1.localhost), also check 2 parts
else if (parts.length === 2 && parts[1] === 'localhost') {
subdomain = parts[0]; 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 // Always attach subdomain to request if present
if (subdomain) { if (subdomain) {
@@ -122,7 +122,7 @@ export class TenantMiddleware implements NestMiddleware {
// Attach tenant info to request object // Attach tenant info to request object
(req as any).tenantId = tenantId; (req as any).tenantId = tenantId;
} else { } else {
this.logger.warn(`No tenant identified from host: ${hostname}`); this.logger.warn(`No tenant identified from host: ${subdomain}`);
} }
next(); next();

View File

@@ -3,90 +3,32 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
const config = useRuntimeConfig()
const router = useRouter() const router = useRouter()
const { toast } = useToast() 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 email = ref('')
const password = ref('') const password = ref('')
const loading = ref(false)
const error = ref('') const error = ref('')
const handleLogin = async () => { const handleLogin = async () => {
try { try {
loading.value = true
error.value = '' error.value = ''
const headers: Record<string, string> = { // Use the BFF login endpoint via useAuth
'Content-Type': 'application/json', const result = await login(email.value, password.value)
}
// 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
if (result.success) {
toast.success('Login successful!') toast.success('Login successful!')
// Redirect to home // Redirect to home
router.push('/') router.push('/')
} else {
error.value = result.error || 'Login failed'
toast.error(result.error || 'Login failed')
}
} catch (e: any) { } catch (e: any) {
error.value = e.message || 'Login failed' error.value = e.message || 'Login failed'
toast.error(e.message || 'Login failed') toast.error(e.message || 'Login failed')
} finally {
loading.value = false
} }
} }
</script> </script>
@@ -118,8 +60,8 @@ const handleLogin = async () => {
</div> </div>
<Input id="password" v-model="password" type="password" required /> <Input id="password" v-model="password" type="password" required />
</div> </div>
<Button type="submit" class="w-full" :disabled="loading"> <Button type="submit" class="w-full" :disabled="isLoading">
{{ loading ? 'Logging in...' : 'Login' }} {{ isLoading ? 'Logging in...' : 'Login' }}
</Button> </Button>
</div> </div>
<div class="text-center text-sm"> <div class="text-center text-sm">

View File

@@ -1,40 +1,25 @@
export const useApi = () => { export const useApi = () => {
const config = useRuntimeConfig()
const router = useRouter() const router = useRouter()
const { toast } = useToast() const { toast } = useToast()
const { isLoggedIn, logout } = useAuth() 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 = () => { const getApiBaseUrl = () => {
if (import.meta.client) { // All API calls go through Nitro proxy - works for both SSR and client
// In browser, use current hostname but with port 3000 for API return ''
const currentHost = window.location.hostname
const protocol = window.location.protocol
//return `${protocol}//${currentHost}:3000`
return `${protocol}//${currentHost}`
}
// Fallback for SSR
return config.public.apiBaseUrl
} }
const getHeaders = () => { const getHeaders = () => {
// Headers are now minimal - auth and tenant are handled by the Nitro proxy
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', '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 return headers
} }

View File

@@ -1,43 +1,70 @@
/**
* 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 = () => { export const useAuth = () => {
const tokenCookie = useCookie('token')
const authMessageCookie = useCookie('authMessage') const authMessageCookie = useCookie('authMessage')
const tenantCookie = useCookie('routebox_tenant')
const router = useRouter() const router = useRouter()
const config = useRuntimeConfig()
// Reactive user state - populated from /api/auth/me
const user = useState<any>('auth_user', () => null)
const isAuthenticated = useState<boolean>('auth_is_authenticated', () => false)
const isLoading = useState<boolean>('auth_is_loading', () => false)
/**
* Check if user is logged in
* Uses server-side session validation via /api/auth/me
*/
const isLoggedIn = () => { const isLoggedIn = () => {
if (!import.meta.client) return false return isAuthenticated.value
const token = localStorage.getItem('token')
const tenantId = localStorage.getItem('tenantId')
return !!(token && tenantId)
} }
const logout = async () => { /**
if (import.meta.client) { * Login with email and password
// Call backend logout endpoint * Calls the Nitro BFF login endpoint which sets HTTP-only cookies
*/
const login = async (email: string, password: string) => {
isLoading.value = true
try { try {
const token = localStorage.getItem('token') const response = await $fetch('/api/auth/login', {
const tenantId = localStorage.getItem('tenantId')
if (token) {
await fetch(`${config.public.apiBaseUrl}/api/auth/logout`, {
method: 'POST', method: 'POST',
headers: { body: { email, password },
'Authorization': `Bearer ${token}`,
...(tenantId && { 'x-tenant-id': tenantId }),
},
}) })
if (response.success) {
user.value = response.user
isAuthenticated.value = true
return { success: true, user: response.user }
} }
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) { } catch (error) {
console.error('Logout error:', error) console.error('Logout error:', error)
} }
// Clear local storage // Clear local state
localStorage.removeItem('token') user.value = null
localStorage.removeItem('tenantId') isAuthenticated.value = false
localStorage.removeItem('user')
// Clear cookie for server-side check
tokenCookie.value = null
// Set flash message for login page // Set flash message for login page
authMessageCookie.value = 'Logged out successfully' authMessageCookie.value = 'Logged out successfully'
@@ -45,17 +72,60 @@ export const useAuth = () => {
// Redirect to login page // Redirect to login page
router.push('/login') 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 = () => { const getUser = () => {
if (!import.meta.client) return null return user.value
const userStr = localStorage.getItem('user') }
return userStr ? JSON.parse(userStr) : null
/**
* Get current tenant ID from cookie
*/
const getTenantId = () => {
return tenantCookie.value
} }
return { return {
// State
user,
isAuthenticated,
isLoading,
// Methods
isLoggedIn, isLoggedIn,
login,
logout, logout,
checkAuth,
getUser, getUser,
getTenantId,
} }
} }

View File

@@ -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 }) // Allow pages to opt-out of auth with definePageMeta({ auth: false })
if (to.meta.auth === false) { if (to.meta.auth === false) {
return return
@@ -11,28 +11,47 @@ export default defineNuxtRouteMiddleware((to, from) => {
return return
} }
const token = useCookie('token')
const authMessage = useCookie('authMessage') 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) // Routes that don't need a toast message (user knows they need to login)
const silentRoutes = ['/'] const silentRoutes = ['/']
// Check token cookie (works on both server and client) // Quick check: if no tenant cookie, likely not authenticated
if (!token.value) { // 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
// 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)) { if (!silentRoutes.includes(to.path)) {
authMessage.value = 'Please login to access this page' authMessage.value = 'Please login to access this page'
} }
return navigateTo('/login') return navigateTo('/login')
} }
// On client side, also verify localStorage is in sync // Server-side: check for tenant cookie as a quick indicator
if (import.meta.client) { // If no tenant cookie, redirect to login
const { isLoggedIn } = useAuth() if (!tenantCookie.value) {
if (!isLoggedIn()) {
if (!silentRoutes.includes(to.path)) { if (!silentRoutes.includes(to.path)) {
authMessage.value = 'Please login to access this page' authMessage.value = 'Please login to access this page'
} }
return navigateTo('/login') return navigateTo('/login')
} }
}
}) })

View File

@@ -24,9 +24,9 @@ export default defineNuxtConfig({
}, },
runtimeConfig: { runtimeConfig: {
public: { // Server-only config (not exposed to client)
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', // Used by Nitro BFF to proxy requests to the NestJS backend
}, backendUrl: process.env.BACKEND_URL || 'http://localhost:3000',
}, },
app: { app: {

View File

@@ -74,24 +74,8 @@ definePageMeta({
auth: false auth: false
}) })
const config = useRuntimeConfig()
const router = useRouter() 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 email = ref('')
const password = ref('') const password = ref('')
const firstName = ref('') const firstName = ref('')
@@ -106,30 +90,17 @@ const handleRegister = async () => {
error.value = '' error.value = ''
success.value = false success.value = false
const headers: Record<string, string> = { // Use BFF proxy - subdomain/tenant is handled automatically by Nitro
'Content-Type': 'application/json', await $fetch('/api/auth/register', {
}
if (subdomain.value) {
headers['x-tenant-id'] = subdomain.value
}
const response = await fetch(`${config.public.apiBaseUrl}/api/auth/register`, {
method: 'POST', method: 'POST',
headers, body: {
body: JSON.stringify({
email: email.value, email: email.value,
password: password.value, password: password.value,
firstName: firstName.value || undefined, firstName: firstName.value || undefined,
lastName: lastName.value || undefined, lastName: lastName.value || undefined,
}), },
}) })
if (!response.ok) {
const data = await response.json()
throw new Error(data.message || 'Registration failed')
}
success.value = true success.value = true
// Redirect to login after 2 seconds // Redirect to login after 2 seconds
@@ -137,9 +108,10 @@ const handleRegister = async () => {
router.push('/login') router.push('/login')
}, 2000) }, 2000)
} catch (e: any) { } catch (e: any) {
error.value = e.message || 'Registration failed' error.value = e.data?.message || e.message || 'Registration failed'
} finally { } finally {
loading.value = false loading.value = false
} }
} }
</script> </script>

View File

@@ -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<string, string>).toString()
const fullUrl = `${backendUrl}/api/${path}${queryString ? `?${queryString}` : ''}`
// Build headers to forward
const headers: Record<string, string> = {
'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',
})
}
})

View File

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

View File

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

View File

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

View File

@@ -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: '/',
})
}

View File

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