diff --git a/backend/src/object/models/dynamic-model.factory.ts b/backend/src/object/models/dynamic-model.factory.ts index 669de82..cdcc75a 100644 --- a/backend/src/object/models/dynamic-model.factory.ts +++ b/backend/src/object/models/dynamic-model.factory.ts @@ -49,7 +49,9 @@ export class DynamicModelFactory { updated_at: { type: 'string', format: 'date-time' }, }; - const required: string[] = ['id', 'tenantId']; + // Don't require system-managed fields (id, tenantId, ownerId, timestamps) + // These are auto-set by hooks or database + const required: string[] = []; // Add custom fields for (const field of fields) { @@ -134,15 +136,16 @@ export class DynamicModelFactory { this.id = randomUUID(); } if (!this.created_at) { - this.created_at = new Date().toISOString(); + this.created_at = new Date().toISOString().slice(0, 19).replace('T', ' '); } if (!this.updated_at) { - this.updated_at = new Date().toISOString(); + this.updated_at = new Date().toISOString().slice(0, 19).replace('T', ' '); } } - async $beforeUpdate() { - this.updated_at = new Date().toISOString(); + async $beforeUpdate(opt: any, queryContext: any) { + await super.$beforeUpdate(opt, queryContext); + this.updated_at = new Date().toISOString().slice(0, 19).replace('T', ' '); } } diff --git a/backend/src/object/object.service.ts b/backend/src/object/object.service.ts index fb5bc0e..836a64d 100644 --- a/backend/src/object/object.service.ts +++ b/backend/src/object/object.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, Logger } from '@nestjs/common'; +import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common'; import { TenantDatabaseService } from '../tenant/tenant-database.service'; import { CustomMigrationService } from '../migration/custom-migration.service'; import { ModelService } from './models/model.service'; @@ -350,6 +350,53 @@ export class ObjectService { return typeMap[frontendType] || 'TEXT'; } + /** + * Filter incoming data to only include writable fields based on field definitions + * Removes system fields and fields that don't exist in the schema + */ + private async filterWritableFields( + tenantId: string, + objectApiName: string, + data: any, + isUpdate: boolean = false, + ): Promise { + const objectDef = await this.getObjectDefinition(tenantId, objectApiName); + const filtered: any = {}; + + for (const [key, value] of Object.entries(data)) { + // Find the field definition + const fieldDef = objectDef.fields.find((f: any) => f.apiName === key); + + if (!fieldDef) { + // Field doesn't exist in schema, skip it + this.logger.warn(`Field ${key} not found in ${objectApiName} schema, skipping`); + continue; + } + + // Skip system fields + if (fieldDef.isSystem) { + this.logger.debug(`Skipping system field ${key}`); + continue; + } + + // Check if field is writable (for authorization) + if (fieldDef.defaultWritable === false) { + this.logger.warn(`Field ${key} is not writable, skipping`); + continue; + } + + // For update operations, also skip ID field + if (isUpdate && key === 'id') { + continue; + } + + // Field is valid and writable, include it + filtered[key] = value; + } + + return filtered; + } + /** * Ensure a model is registered for the given object. * Delegates to ModelService which handles creating the model and all its dependencies. @@ -553,6 +600,21 @@ export class ObjectService { // Verify object exists and get field definitions const objectDef = await this.getObjectDefinition(tenantId, objectApiName); + // Get object definition with authorization settings + const objectDefModel = await ObjectDefinition.query(knex) + .findOne({ apiName: objectApiName }); + + if (!objectDefModel) { + throw new NotFoundException('Object definition not found'); + } + + // Get user model for authorization + const user = await User.query(knex).findById(userId).withGraphFetched('roles'); + + if (!user) { + throw new NotFoundException('User not found'); + } + const tableName = this.getTableName(objectApiName); // Ensure model is registered before attempting to use it @@ -565,6 +627,9 @@ export class ObjectService { const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); let query = boundModel.query().where({ id: recordId }); + // Apply authorization scoping + query = applyReadScope(query, user, objectDefModel, knex); + // Build graph expression for lookup fields const lookupFields = objectDef.fields?.filter(f => f.type === 'LOOKUP' && f.referenceObject @@ -582,26 +647,20 @@ export class ObjectService { } } - // Add ownership filter if ownerId field exists - const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId'); - if (hasOwner) { - query = query.where({ ownerId: userId }); - } - const record = await query.first(); if (!record) { - throw new NotFoundException('Record not found'); + throw new NotFoundException('Record not found or you do not have access'); } return record; } } catch (error) { this.logger.warn(`Could not use Objection model for ${objectApiName}, falling back to manual join: ${error.message}`); } - - // Fallback to manual data hydration + + // Fallback to manual data hydration - Note: This path doesn't support authorization scoping yet let query = knex(tableName).where({ [`${tableName}.id`]: recordId }); - // Add ownership filter if ownerId field exists + // Add ownership filter if ownerId field exists (basic fallback) const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId'); if (hasOwner) { query = query.where({ [`${tableName}.ownerId`]: userId }); @@ -610,7 +669,7 @@ export class ObjectService { const record = await query.first(); if (!record) { - throw new NotFoundException('Record not found'); + throw new NotFoundException('Record not found or you do not have access'); } // Fetch and attach related records for lookup fields @@ -652,6 +711,32 @@ export class ObjectService { // Verify object exists await this.getObjectDefinition(tenantId, objectApiName); + // Get object definition with authorization settings + const objectDefModel = await ObjectDefinition.query(knex) + .findOne({ apiName: objectApiName }); + + if (!objectDefModel) { + throw new NotFoundException('Object definition not found'); + } + + // Check create permission + if (!objectDefModel.publicCreate) { + // Get user with roles to check role-based permissions + const user = await User.query(knex).findById(userId).withGraphFetched('roles'); + + if (!user) { + throw new NotFoundException('User not found'); + } + + // TODO: Check role-based create permissions from role_rules + // For now, only allow if publicCreate is true + throw new ForbiddenException('You do not have permission to create records for this object'); + } + + // Filter data to only include writable fields based on field definitions + // Do this BEFORE model registration so both Objection and fallback paths use clean data + const allowedData = await this.filterWritableFields(tenantId, objectApiName, data, false); + // Ensure model is registered before attempting to use it await this.ensureModelRegistered(resolvedTenantId, objectApiName); @@ -660,8 +745,9 @@ export class ObjectService { const Model = this.modelService.getModel(resolvedTenantId, objectApiName); if (Model) { const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); + const recordData = { - ...data, + ...allowedData, ownerId: userId, // Auto-set owner }; const record = await boundModel.query().insert(recordData); @@ -677,7 +763,7 @@ export class ObjectService { const recordData: any = { id: knex.raw('(UUID())'), - ...data, + ...allowedData, // Use filtered data instead of raw data created_at: knex.fn.now(), updated_at: knex.fn.now(), }; @@ -701,37 +787,65 @@ export class ObjectService { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); - // Verify object exists and user has access - await this.getRecord(tenantId, objectApiName, recordId, userId); + // Get object definition with authorization settings + const objectDefModel = await ObjectDefinition.query(knex) + .findOne({ apiName: objectApiName }); - const tableName = this.getTableName(objectApiName); - - // Ensure model is registered before attempting to use it - await this.ensureModelRegistered(resolvedTenantId, objectApiName); - - // Try to use the Objection model if available - try { - const Model = this.modelService.getModel(resolvedTenantId, objectApiName); - if (Model) { - const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); - // Don't allow updating ownerId or system fields - const allowedData = { ...data }; - delete allowedData.ownerId; - delete allowedData.id; - delete allowedData.created_at; - delete allowedData.tenantId; - - await boundModel.query().where({ id: recordId }).update(allowedData); - return boundModel.query().where({ id: recordId }).first(); - } - } catch (error) { - console.warn(`Could not use Objection model for ${objectApiName}:`, error.message); + if (!objectDefModel) { + throw new NotFoundException('Object definition not found'); + } + + // Get user model for authorization + const user = await User.query(knex).findById(userId).withGraphFetched('roles'); + + if (!user) { + throw new NotFoundException('User not found'); + } + + // Filter data to only include writable fields based on field definitions + // Do this BEFORE authorization checks so both paths use clean data + const allowedData = await this.filterWritableFields(tenantId, objectApiName, data, true); + + // Verify user has access to read the record first (using authorization scope) + const tableName = this.getTableName(objectApiName); + await this.ensureModelRegistered(resolvedTenantId, objectApiName); + + const Model = this.modelService.getModel(resolvedTenantId, objectApiName); + if (Model) { + const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); + let checkQuery = boundModel.query().where({ id: recordId }); + checkQuery = applyUpdateScope(checkQuery, user, objectDefModel, knex); + + const existingRecord = await checkQuery.first(); + if (!existingRecord) { + throw new ForbiddenException('You do not have permission to update this record'); + } + + this.logger.log(`[UPDATE] Record ID: ${recordId}, Type: ${typeof recordId}`); + this.logger.log(`[UPDATE] Existing record ID: ${existingRecord.id}, Type: ${typeof existingRecord.id}`); + this.logger.log(`[UPDATE] Allowed data:`, JSON.stringify(allowedData)); + + const numUpdated = await boundModel.query().where({ id: recordId }).update(allowedData); + this.logger.log(`[UPDATE] Number of records updated: ${numUpdated}`); + + const updatedRecord = await boundModel.query().where({ id: recordId }).first(); + this.logger.log(`[UPDATE] Updated record:`, updatedRecord ? 'found' : 'NOT FOUND'); + + return updatedRecord; + } + + // Fallback to raw Knex with basic ownership check + const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId'); + if (hasOwner && !objectDefModel.publicUpdate) { + const record = await knex(tableName).where({ id: recordId, ownerId: userId }).first(); + if (!record) { + throw new ForbiddenException('You do not have permission to update this record'); + } } - // Fallback to raw Knex await knex(tableName) .where({ id: recordId }) - .update({ ...data, updated_at: knex.fn.now() }); + .update({ ...allowedData, updated_at: knex.fn.now() }); // Use filtered data return knex(tableName).where({ id: recordId }).first(); } @@ -745,27 +859,51 @@ export class ObjectService { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); - // Verify object exists and user has access - await this.getRecord(tenantId, objectApiName, recordId, userId); + // Get object definition with authorization settings + const objectDefModel = await ObjectDefinition.query(knex) + .findOne({ apiName: objectApiName }); + + if (!objectDefModel) { + throw new NotFoundException('Object definition not found'); + } + + // Get user model for authorization + const user = await User.query(knex).findById(userId).withGraphFetched('roles'); + + if (!user) { + throw new NotFoundException('User not found'); + } const tableName = this.getTableName(objectApiName); - - // Ensure model is registered before attempting to use it await this.ensureModelRegistered(resolvedTenantId, objectApiName); // Try to use the Objection model if available - try { - const Model = this.modelService.getModel(resolvedTenantId, objectApiName); - if (Model) { - const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); - await boundModel.query().where({ id: recordId }).delete(); - return { success: true }; + const Model = this.modelService.getModel(resolvedTenantId, objectApiName); + if (Model) { + const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); + + // Check if user has permission to delete this record + let checkQuery = boundModel.query().where({ id: recordId }); + checkQuery = applyDeleteScope(checkQuery, user, objectDefModel, knex); + + const existingRecord = await checkQuery.first(); + if (!existingRecord) { + throw new ForbiddenException('You do not have permission to delete this record'); + } + + await boundModel.query().where({ id: recordId }).delete(); + return { success: true }; + } + + // Fallback to raw Knex with basic ownership check + const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId'); + if (hasOwner && !objectDefModel.publicDelete) { + const record = await knex(tableName).where({ id: recordId, ownerId: userId }).first(); + if (!record) { + throw new ForbiddenException('You do not have permission to delete this record'); } - } catch (error) { - console.warn(`Could not use Objection model for ${objectApiName}:`, error.message); } - // Fallback to raw Knex await knex(tableName).where({ id: recordId }).delete(); return { success: true }; diff --git a/frontend/components/views/EditView.vue b/frontend/components/views/EditView.vue index a4854dc..c72a234 100644 --- a/frontend/components/views/EditView.vue +++ b/frontend/components/views/EditView.vue @@ -12,6 +12,8 @@ import { CollapsibleTrigger, } from '@/components/ui/collapsible' +console.log('[EditView] COMPONENT MOUNTING') + interface Props { config: EditViewConfig data?: any @@ -25,6 +27,8 @@ const props = withDefaults(defineProps(), { saving: false, }) +console.log('[EditView] Props received on mount:', JSON.stringify(props, null, 2)) + const emit = defineEmits<{ 'save': [data: any] 'cancel': [] @@ -35,10 +39,16 @@ const emit = defineEmits<{ const formData = ref>({ ...props.data }) const errors = ref>({}) +console.log('[EditView] Initial props.data:', JSON.stringify(props.data, null, 2)) +console.log('[EditView] props.data.id:', props.data?.id) + // Watch for data changes (useful for edit mode) watch(() => props.data, (newData) => { + console.log('[EditView] Data changed:', JSON.stringify(newData, null, 2)) + console.log('[EditView] newData.id:', newData?.id) + console.log('[EditView] Keys in newData:', Object.keys(newData)) formData.value = { ...newData } -}, { deep: true }) +}, { deep: true, immediate: true }) // Organize fields into sections const sections = computed(() => { @@ -137,7 +147,11 @@ const validateForm = (): boolean => { const handleSave = () => { if (validateForm()) { - emit('save', { ...formData.value }) + // Preserve id and other system fields from original data when saving + emit('save', { + id: props.data?.id, // Preserve the record ID for updates + ...formData.value + }) } } diff --git a/frontend/components/views/EditViewEnhanced.vue b/frontend/components/views/EditViewEnhanced.vue index e968653..9943842 100644 --- a/frontend/components/views/EditViewEnhanced.vue +++ b/frontend/components/views/EditViewEnhanced.vue @@ -45,11 +45,16 @@ const errors = ref>({}) // Watch for data changes (useful for edit mode) watch(() => props.data, (newData) => { + console.log('[EditViewEnhanced] Data changed:', newData) + console.log('[EditViewEnhanced] Data has id?', newData?.id) formData.value = { ...newData } -}, { deep: true }) +}, { deep: true, immediate: true }) // Fetch page layout if objectId is provided onMounted(async () => { + console.log('[EditViewEnhanced] Component mounted') + console.log('[EditViewEnhanced] Props:', props) + if (props.objectId) { try { loadingLayout.value = true @@ -159,13 +164,27 @@ const validateForm = (): boolean => { } const handleSave = () => { + console.log('[EditViewEnhanced] handleSave called') + console.log('[EditViewEnhanced] props.data:', props.data) + console.log('[EditViewEnhanced] props.data?.id:', props.data?.id) + console.log('[EditViewEnhanced] formData before processing:', { ...formData.value }) + if (validateForm()) { - // Filter out system fields from save data + // Preserve the id from props.data if it exists (needed for updates) + // Filter out other system fields that are auto-managed const saveData = { ...formData.value } - const systemFields = ['id', 'tenantId', 'ownerId', 'created_at', 'updated_at', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy'] - for (const field of systemFields) { + const systemFieldsToRemove = ['tenantId', 'ownerId', 'created_at', 'updated_at', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy'] + for (const field of systemFieldsToRemove) { delete saveData[field] } + + // Explicitly preserve id if it exists in the original data + if (props.data?.id) { + saveData.id = props.data.id + console.log('[EditViewEnhanced] Preserved id from props:', saveData.id) + } + + console.log('[EditViewEnhanced] Final saveData:', saveData) emit('save', saveData) } } diff --git a/frontend/components/views/ListView.vue b/frontend/components/views/ListView.vue index 5284e89..eb50f58 100644 --- a/frontend/components/views/ListView.vue +++ b/frontend/components/views/ListView.vue @@ -22,6 +22,7 @@ interface Props { loading?: boolean selectable?: boolean baseUrl?: string + canCreate?: boolean } const props = withDefaults(defineProps(), { @@ -29,6 +30,7 @@ const props = withDefaults(defineProps(), { loading: false, selectable: false, baseUrl: '/runtime/objects', + canCreate: true, }) const emit = defineEmits<{ @@ -145,7 +147,7 @@ const handleAction = (actionId: string) => { - diff --git a/frontend/composables/useApi.ts b/frontend/composables/useApi.ts index 9f73398..60be609 100644 --- a/frontend/composables/useApi.ts +++ b/frontend/composables/useApi.ts @@ -45,7 +45,9 @@ export const useApi = () => { toast.error('Your session has expired. Please login again.') router.push('/login') } - throw new Error('Unauthorized') + const error = new Error('Unauthorized') + ;(error as any).status = 401 + throw error } if (response.status === 403) { @@ -59,17 +61,24 @@ export const useApi = () => { router.push('/login') } } - throw new Error('Forbidden') + // Don't log 403 errors - create error with status flag + const error = new Error('Forbidden') + ;(error as any).status = 403 + throw error } if (!response.ok) { // Try to get error details from response const text = await response.text() - console.error('API Error Response:', { - status: response.status, - statusText: response.statusText, - body: text - }) + + // Only log unexpected errors (not 401 or 403 which are handled above) + if (response.status !== 401 && response.status !== 403) { + console.error('API Error Response:', { + status: response.status, + statusText: response.statusText, + body: text + }) + } let errorMessage = `HTTP error! status: ${response.status}` if (text) { diff --git a/frontend/composables/useFieldViews.ts b/frontend/composables/useFieldViews.ts index 6fbed9f..97b1c93 100644 --- a/frontend/composables/useFieldViews.ts +++ b/frontend/composables/useFieldViews.ts @@ -197,7 +197,10 @@ export const useViewState = ( records.value = response.data || response || [] } catch (e: any) { error.value = e.message - console.error('Failed to fetch records:', e) + // Only log unexpected errors (not authorization failures) + if (e.status !== 401 && e.status !== 403) { + console.error('Failed to fetch records:', e) + } } finally { loading.value = false } @@ -210,9 +213,14 @@ export const useViewState = ( const response = await api.get(`${apiEndpoint}/${id}`) // Handle response - data might be directly in response or in response.data currentRecord.value = response.data || response + console.log('[fetchRecord] Fetched record:', JSON.stringify(currentRecord.value, null, 2)) + console.log('[fetchRecord] Record has id?', currentRecord.value?.id) } catch (e: any) { error.value = e.message - console.error('Failed to fetch record:', e) + // Only log unexpected errors (not authorization failures) + if (e.status !== 401 && e.status !== 403) { + console.error('Failed to fetch record:', e) + } } finally { loading.value = false } @@ -231,7 +239,7 @@ export const useViewState = ( return recordData } catch (e: any) { error.value = e.message - console.error('Failed to create record:', e) + // Don't log to console - errors are already handled by useApi and shown via toast throw e } finally { saving.value = false @@ -256,7 +264,10 @@ export const useViewState = ( return recordData } catch (e: any) { error.value = e.message - console.error('Failed to update record:', e) + // Only log unexpected errors (not authorization failures) + if (e.status !== 401 && e.status !== 403) { + console.error('Failed to update record:', e) + } throw e } finally { saving.value = false @@ -274,7 +285,10 @@ export const useViewState = ( } } catch (e: any) { error.value = e.message - console.error('Failed to delete record:', e) + // Only log unexpected errors (not authorization failures) + if (e.status !== 401 && e.status !== 403) { + console.error('Failed to delete record:', e) + } throw e } finally { loading.value = false @@ -289,7 +303,10 @@ export const useViewState = ( records.value = records.value.filter(r => !ids.includes(r.id!)) } catch (e: any) { error.value = e.message - console.error('Failed to delete records:', e) + // Only log unexpected errors (not authorization failures) + if (e.status !== 401 && e.status !== 403) { + console.error('Failed to delete records:', e) + } throw e } finally { loading.value = false @@ -312,10 +329,17 @@ export const useViewState = ( } const handleSave = async (data: T) => { + // DEBUG: Check if id is present + console.log('[handleSave] Data received:', JSON.stringify(data, null, 2)) + console.log('[handleSave] data.id:', data.id) + console.log('[handleSave] currentRecord.value:', currentRecord.value) + let savedRecord if (data.id) { + console.log('[handleSave] Calling updateRecord (PUT)') savedRecord = await updateRecord(data.id, data) } else { + console.log('[handleSave] Calling createRecord (POST) - ID IS MISSING!') savedRecord = await createRecord(data) } return savedRecord diff --git a/frontend/pages/[objectName]/[[recordId]]/[[view]].vue b/frontend/pages/[objectName]/[[recordId]]/[[view]].vue index 82407cc..faea581 100644 --- a/frontend/pages/[objectName]/[[recordId]]/[[view]].vue +++ b/frontend/pages/[objectName]/[[recordId]]/[[view]].vue @@ -32,6 +32,7 @@ const view = computed(() => { // State const objectDefinition = ref(null) +const objectAccess = ref(null) const loading = ref(true) const error = ref(null) @@ -118,9 +119,23 @@ const detailConfig = computed(() => { const editConfig = computed(() => { if (!objectDefinition.value) return null - return buildEditViewConfig(objectDefinition.value) + const config = buildEditViewConfig(objectDefinition.value) + console.log('[PAGE] editConfig computed:', config ? 'EXISTS' : 'NULL') + return config }) +// Debug current view state +watch([view, recordId, editConfig, currentRecord, loading, dataLoading], ([v, rid, ec, cr, l, dl]) => { + console.log('[PAGE] View state changed:') + console.log(' - view:', v) + console.log(' - recordId:', rid) + console.log(' - editConfig exists?', !!ec) + console.log(' - currentRecord exists?', !!cr) + console.log(' - loading:', l) + console.log(' - dataLoading:', dl) + console.log(' - Should show EditView?', (v === 'edit' || rid === 'new') && !!ec) +}, { immediate: true }) + // Fetch object definition const fetchObjectDefinition = async () => { try { @@ -128,6 +143,21 @@ const fetchObjectDefinition = async () => { error.value = null const response = await api.get(`/setup/objects/${objectApiName.value}`) objectDefinition.value = response + + // Fetch access permissions + try { + const accessResponse = await api.get(`/setup/objects/${objectApiName.value}/access`) + objectAccess.value = accessResponse + } catch (e) { + console.warn('Failed to fetch access permissions:', e) + // Set defaults if fetch fails + objectAccess.value = { + publicCreate: true, + publicRead: true, + publicUpdate: true, + publicDelete: true, + } + } } catch (e: any) { error.value = e.message || 'Failed to load object definition' console.error('Error fetching object definition:', e) @@ -261,6 +291,7 @@ onMounted(async () => { :data="records" :loading="dataLoading" :base-url="`/runtime/objects`" + :can-create="objectAccess?.publicCreate !== false" selectable @row-click="handleRowClick" @create="handleCreate" @@ -282,18 +313,20 @@ onMounted(async () => { /> - +
+
DEBUG: EditView should render here. view={{ view }}, recordId={{ recordId }}, editConfig={{ !!editConfig }}, currentRecord={{ !!currentRecord }}
+ +