Added auth functionality, initial work with views and field types
This commit is contained in:
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
61
frontend/composables/useAuth.ts
Normal file
61
frontend/composables/useAuth.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
326
frontend/composables/useFieldViews.ts
Normal file
326
frontend/composables/useFieldViews.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
20
frontend/composables/useToast.ts
Normal file
20
frontend/composables/useToast.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user