WIP - added front end auth

This commit is contained in:
Francisco Gaona
2025-12-21 09:38:51 +01:00
parent fbfaf7bb9f
commit 1d610f0d2b
22 changed files with 558 additions and 88 deletions

View File

@@ -1,5 +1,10 @@
<script setup lang="ts">
import { Toaster } from 'vue-sonner'
</script>
<template>
<div>
<Toaster position="top-right" :duration="4000" richColors />
<NuxtPage />
</div>
</template>

View File

@@ -5,8 +5,34 @@ import { Label } from '@/components/ui/label'
const config = useRuntimeConfig()
const router = useRouter()
const { toast } = useToast()
const tenantId = ref('123')
// 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)
@@ -17,12 +43,18 @@ const handleLogin = async () => {
loading.value = true
error.value = ''
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
// 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: {
'Content-Type': 'application/json',
'x-tenant-id': tenantId.value,
},
headers,
body: JSON.stringify({
email: email.value,
password: password.value,
@@ -36,15 +68,23 @@ const handleLogin = async () => {
const data = await response.json()
// Store credentials
localStorage.setItem('tenantId', tenantId.value)
// 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
}
@@ -65,10 +105,6 @@ const handleLogin = async () => {
</div>
<div class="grid gap-6">
<div class="grid gap-2">
<Label for="tenantId">Tenant ID</Label>
<Input id="tenantId" v-model="tenantId" type="text" placeholder="123" required />
</div>
<div class="grid gap-2">
<Label for="email">Email</Label>
<Input id="email" v-model="email" type="email" placeholder="m@example.com" required />

View File

@@ -1,5 +1,8 @@
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)
const getApiBaseUrl = () => {
@@ -34,13 +37,44 @@ export const useApi = () => {
return headers
}
const handleResponse = async (response: Response) => {
if (response.status === 401) {
// Unauthorized - not authenticated
if (import.meta.client) {
logout()
toast.error('Your session has expired. Please login again.')
router.push('/login')
}
throw new Error('Unauthorized')
}
if (response.status === 403) {
// Forbidden - not authorized
if (import.meta.client) {
toast.error('You do not have permission to perform this action.')
// Redirect to home if logged in, otherwise to login
if (isLoggedIn()) {
router.push('/')
} else {
router.push('/login')
}
}
throw new Error('Forbidden')
}
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
return response.json()
}
const api = {
async get(path: string) {
const response = await fetch(`${getApiBaseUrl()}/api${path}`, {
headers: getHeaders(),
})
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
return response.json()
return handleResponse(response)
},
async post(path: string, data: any) {
@@ -49,8 +83,7 @@ export const useApi = () => {
headers: getHeaders(),
body: JSON.stringify(data),
})
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
return response.json()
return handleResponse(response)
},
async put(path: string, data: any) {
@@ -59,8 +92,7 @@ export const useApi = () => {
headers: getHeaders(),
body: JSON.stringify(data),
})
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
return response.json()
return handleResponse(response)
},
async delete(path: string) {
@@ -68,8 +100,7 @@ export const useApi = () => {
method: 'DELETE',
headers: getHeaders(),
})
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
return response.json()
return handleResponse(response)
},
}

View File

@@ -0,0 +1,32 @@
export const useAuth = () => {
const tokenCookie = useCookie('token')
const isLoggedIn = () => {
if (!import.meta.client) return false
const token = localStorage.getItem('token')
const tenantId = localStorage.getItem('tenantId')
return !!(token && tenantId)
}
const logout = () => {
if (import.meta.client) {
localStorage.removeItem('token')
localStorage.removeItem('tenantId')
localStorage.removeItem('user')
}
// Clear cookie for server-side check
tokenCookie.value = null
}
const getUser = () => {
if (!import.meta.client) return null
const userStr = localStorage.getItem('user')
return userStr ? JSON.parse(userStr) : null
}
return {
isLoggedIn,
logout,
getUser,
}
}

View File

@@ -0,0 +1,20 @@
import { toast as sonnerToast } from 'vue-sonner'
export const useToast = () => {
const toast = {
success: (message: string) => {
sonnerToast.success(message)
},
error: (message: string) => {
sonnerToast.error(message)
},
info: (message: string) => {
sonnerToast.info(message)
},
warning: (message: string) => {
sonnerToast.warning(message)
},
}
return { toast }
}

View File

@@ -0,0 +1,38 @@
export default defineNuxtRouteMiddleware((to, from) => {
// Allow pages to opt-out of auth with definePageMeta({ auth: false })
if (to.meta.auth === false) {
return
}
// Public routes that don't require authentication
const publicRoutes = ['/login', '/register']
if (publicRoutes.includes(to.path)) {
return
}
const token = useCookie('token')
const authMessage = useCookie('authMessage')
// Routes that don't need a toast message (user knows they need to login)
const silentRoutes = ['/']
// Check token cookie (works on both server and client)
if (!token.value) {
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')
}
}
})

View File

@@ -52,7 +52,7 @@ export default defineNuxtConfig({
hmr: {
clientPort: 3001,
},
allowedHosts: ['jupiter.routebox.co', 'localhost', '127.0.0.1'],
allowedHosts: ['.routebox.co', 'localhost', '127.0.0.1',],
},
},

View File

@@ -20,7 +20,8 @@
"shadcn-nuxt": "^2.3.3",
"tailwind-merge": "^2.2.1",
"vue": "^3.4.15",
"vue-router": "^4.2.5"
"vue-router": "^4.2.5",
"vue-sonner": "^1.3.2"
},
"devDependencies": {
"@nuxtjs/color-mode": "^3.3.2",
@@ -16035,6 +16036,12 @@
"vue": "^3.5.0"
}
},
"node_modules/vue-sonner": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/vue-sonner/-/vue-sonner-1.3.2.tgz",
"integrity": "sha512-UbZ48E9VIya3ToiRHAZUbodKute/z/M1iT8/3fU8zEbwBRE11AKuHikssv18LMk2gTTr6eMQT4qf6JoLHWuj/A==",
"license": "MIT"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",

View File

@@ -26,7 +26,8 @@
"shadcn-nuxt": "^2.3.3",
"tailwind-merge": "^2.2.1",
"vue": "^3.4.15",
"vue-router": "^4.2.5"
"vue-router": "^4.2.5",
"vue-sonner": "^1.3.2"
},
"devDependencies": {
"@nuxtjs/color-mode": "^3.3.2",

View File

@@ -1,3 +1,6 @@
<script setup lang="ts">
</script>
<template>
<NuxtLayout name="default">
<div class="text-center space-y-6">

View File

@@ -1,6 +1,25 @@
<script setup lang="ts">
import { LayoutGrid } from 'lucide-vue-next'
import LoginForm from '@/components/LoginForm.vue'
// Skip auth middleware for login page
definePageMeta({
auth: false
})
const { toast } = useToast()
// Check for auth message from cookie
const authMessage = useCookie('authMessage')
onMounted(() => {
if (authMessage.value) {
console.log('Displaying auth message: ' + authMessage.value)
toast.error(authMessage.value)
// Clear the message after displaying
authMessage.value = null
}
})
</script>
<template>

View File

@@ -17,11 +17,6 @@
</div>
<form @submit.prevent="handleRegister" class="space-y-4">
<div class="space-y-2">
<Label for="tenantId">Tenant ID</Label>
<Input id="tenantId" v-model="tenantId" type="text" required placeholder="123" />
</div>
<div class="space-y-2">
<Label for="email">Email</Label>
<Input
@@ -74,10 +69,29 @@
</template>
<script setup lang="ts">
// Skip auth middleware for register page
definePageMeta({
auth: false
})
const config = useRuntimeConfig()
const router = useRouter()
const tenantId = ref('123')
// 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('')
@@ -92,12 +106,17 @@ const handleRegister = async () => {
error.value = ''
success.value = false
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (subdomain.value) {
headers['x-tenant-id'] = subdomain.value
}
const response = await fetch(`${config.public.apiBaseUrl}/api/auth/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-tenant-id': tenantId.value,
},
headers,
body: JSON.stringify({
email: email.value,
password: password.value,