Added auth functionality, initial work with views and field types

This commit is contained in:
Francisco Gaona
2025-12-22 03:31:55 +01:00
parent 859dca6c84
commit 0fe56c0e03
170 changed files with 11599 additions and 435 deletions

View File

@@ -1,6 +1,20 @@
export const useApi = () => {
const config = useRuntimeConfig()
const apiBaseUrl = config.public.apiBaseUrl
const router = useRouter()
const { toast } = useToast()
const { isLoggedIn, logout } = useAuth()
// Use current domain for API calls (same subdomain routing)
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`
}
// Fallback for SSR
return config.public.apiBaseUrl
}
const getHeaders = () => {
const headers: Record<string, string> = {
@@ -23,42 +37,70 @@ 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(`${apiBaseUrl}/api${path}`, {
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) {
const response = await fetch(`${apiBaseUrl}/api${path}`, {
const response = await fetch(`${getApiBaseUrl()}/api${path}`, {
method: 'POST',
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) {
const response = await fetch(`${apiBaseUrl}/api${path}`, {
const response = await fetch(`${getApiBaseUrl()}/api${path}`, {
method: 'PUT',
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) {
const response = await fetch(`${apiBaseUrl}/api${path}`, {
const response = await fetch(`${getApiBaseUrl()}/api${path}`, {
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,61 @@
export const useAuth = () => {
const tokenCookie = useCookie('token')
const authMessageCookie = useCookie('authMessage')
const router = useRouter()
const config = useRuntimeConfig()
const isLoggedIn = () => {
if (!import.meta.client) return false
const token = localStorage.getItem('token')
const tenantId = localStorage.getItem('tenantId')
return !!(token && tenantId)
}
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)
}
// 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')
}
}
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,326 @@
import { computed, ref } from 'vue'
import type { FieldConfig, ListViewConfig, DetailViewConfig, EditViewConfig, ViewMode } from '@/types/field-types'
/**
* Composable for working with dynamic fields and views
* Helps convert backend field definitions to frontend field configs
*/
export const useFields = () => {
/**
* Convert backend field definition to frontend FieldConfig
*/
const mapFieldDefinitionToConfig = (fieldDef: any): FieldConfig => {
return {
id: fieldDef.id,
apiName: fieldDef.apiName,
label: fieldDef.label,
type: fieldDef.type,
// Default values
placeholder: fieldDef.uiMetadata?.placeholder || fieldDef.description,
helpText: fieldDef.uiMetadata?.helpText || fieldDef.description,
defaultValue: fieldDef.defaultValue,
// Validation
isRequired: fieldDef.isRequired,
isReadOnly: fieldDef.isSystem || fieldDef.uiMetadata?.isReadOnly,
validationRules: fieldDef.uiMetadata?.validationRules || [],
// View options
showOnList: fieldDef.uiMetadata?.showOnList ?? true,
showOnDetail: fieldDef.uiMetadata?.showOnDetail ?? true,
showOnEdit: fieldDef.uiMetadata?.showOnEdit ?? !fieldDef.isSystem,
sortable: fieldDef.uiMetadata?.sortable ?? true,
// Field type specific
options: fieldDef.uiMetadata?.options,
rows: fieldDef.uiMetadata?.rows,
min: fieldDef.uiMetadata?.min,
max: fieldDef.uiMetadata?.max,
step: fieldDef.uiMetadata?.step,
accept: fieldDef.uiMetadata?.accept,
relationObject: fieldDef.referenceObject,
relationDisplayField: fieldDef.uiMetadata?.relationDisplayField,
// Formatting
format: fieldDef.uiMetadata?.format,
prefix: fieldDef.uiMetadata?.prefix,
suffix: fieldDef.uiMetadata?.suffix,
// Advanced
dependsOn: fieldDef.uiMetadata?.dependsOn,
computedValue: fieldDef.uiMetadata?.computedValue,
}
}
/**
* Build a ListView configuration from object definition
*/
const buildListViewConfig = (
objectDef: any,
customConfig?: Partial<ListViewConfig>
): ListViewConfig => {
const fields = objectDef.fields?.map(mapFieldDefinitionToConfig) || []
return {
objectApiName: objectDef.apiName,
mode: 'list' as ViewMode,
fields,
pageSize: 25,
searchable: true,
filterable: true,
exportable: true,
...customConfig,
}
}
/**
* Build a DetailView configuration from object definition
*/
const buildDetailViewConfig = (
objectDef: any,
customConfig?: Partial<DetailViewConfig>
): DetailViewConfig => {
const fields = objectDef.fields?.map(mapFieldDefinitionToConfig) || []
return {
objectApiName: objectDef.apiName,
mode: 'detail' as ViewMode,
fields,
...customConfig,
}
}
/**
* Build an EditView configuration from object definition
*/
const buildEditViewConfig = (
objectDef: any,
customConfig?: Partial<EditViewConfig>
): EditViewConfig => {
const fields = objectDef.fields?.map(mapFieldDefinitionToConfig) || []
return {
objectApiName: objectDef.apiName,
mode: 'edit' as ViewMode,
fields,
submitLabel: 'Save',
cancelLabel: 'Cancel',
...customConfig,
}
}
/**
* Auto-generate sections based on field types or custom logic
*/
const generateSections = (fields: FieldConfig[]) => {
// Group fields by some logic - this is a simple example
const basicFields = fields.filter(f =>
['text', 'email', 'password', 'number'].includes(f.type)
)
const relationFields = fields.filter(f =>
['belongsTo', 'hasMany', 'manyToMany'].includes(f.type)
)
const otherFields = fields.filter(f =>
!basicFields.includes(f) && !relationFields.includes(f)
)
const sections = []
if (basicFields.length > 0) {
sections.push({
title: 'Basic Information',
fields: basicFields.map(f => f.apiName),
})
}
if (relationFields.length > 0) {
sections.push({
title: 'Related Records',
fields: relationFields.map(f => f.apiName),
collapsible: true,
})
}
if (otherFields.length > 0) {
sections.push({
title: 'Additional Information',
fields: otherFields.map(f => f.apiName),
collapsible: true,
defaultCollapsed: true,
})
}
return sections
}
return {
mapFieldDefinitionToConfig,
buildListViewConfig,
buildDetailViewConfig,
buildEditViewConfig,
generateSections,
}
}
/**
* Composable for managing view state (CRUD operations)
*/
export const useViewState = <T extends { id?: string }>(
apiEndpoint: string
) => {
const records = ref<T[]>([])
const currentRecord = ref<T | null>(null)
const currentView = ref<'list' | 'detail' | 'edit'>('list')
const loading = ref(false)
const saving = ref(false)
const error = ref<string | null>(null)
const api = useApi()
const fetchRecords = async (params?: Record<string, any>) => {
loading.value = true
error.value = null
try {
const response = await api.get(apiEndpoint, { params })
records.value = response.data
} catch (e: any) {
error.value = e.message
console.error('Failed to fetch records:', e)
} finally {
loading.value = false
}
}
const fetchRecord = async (id: string) => {
loading.value = true
error.value = null
try {
const response = await api.get(`${apiEndpoint}/${id}`)
currentRecord.value = response.data
} catch (e: any) {
error.value = e.message
console.error('Failed to fetch record:', e)
} finally {
loading.value = false
}
}
const createRecord = async (data: Partial<T>) => {
saving.value = true
error.value = null
try {
const response = await api.post(apiEndpoint, data)
records.value.push(response.data)
currentRecord.value = response.data
return response.data
} catch (e: any) {
error.value = e.message
console.error('Failed to create record:', e)
throw e
} finally {
saving.value = false
}
}
const updateRecord = async (id: string, data: Partial<T>) => {
saving.value = true
error.value = null
try {
const response = await api.put(`${apiEndpoint}/${id}`, data)
const idx = records.value.findIndex(r => r.id === id)
if (idx !== -1) {
records.value[idx] = response.data
}
currentRecord.value = response.data
return response.data
} catch (e: any) {
error.value = e.message
console.error('Failed to update record:', e)
throw e
} finally {
saving.value = false
}
}
const deleteRecord = async (id: string) => {
loading.value = true
error.value = null
try {
await api.delete(`${apiEndpoint}/${id}`)
records.value = records.value.filter(r => r.id !== id)
if (currentRecord.value?.id === id) {
currentRecord.value = null
}
} catch (e: any) {
error.value = e.message
console.error('Failed to delete record:', e)
throw e
} finally {
loading.value = false
}
}
const deleteRecords = async (ids: string[]) => {
loading.value = true
error.value = null
try {
await Promise.all(ids.map(id => api.delete(`${apiEndpoint}/${id}`)))
records.value = records.value.filter(r => !ids.includes(r.id!))
} catch (e: any) {
error.value = e.message
console.error('Failed to delete records:', e)
throw e
} finally {
loading.value = false
}
}
const showList = () => {
currentView.value = 'list'
currentRecord.value = null
}
const showDetail = (record: T) => {
currentRecord.value = record
currentView.value = 'detail'
}
const showEdit = (record?: T) => {
currentRecord.value = record || ({} as T)
currentView.value = 'edit'
}
const handleSave = async (data: T) => {
if (data.id) {
await updateRecord(data.id, data)
} else {
await createRecord(data)
}
showDetail(currentRecord.value!)
}
return {
// State
records,
currentRecord,
currentView,
loading,
saving,
error,
// Methods
fetchRecords,
fetchRecord,
createRecord,
updateRecord,
deleteRecord,
deleteRecords,
// Navigation
showList,
showDetail,
showEdit,
handleSave,
}
}

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