diff --git a/index.html b/index.html index 5b74076..bad2be9 100644 --- a/index.html +++ b/index.html @@ -183,6 +183,10 @@ Dark System + + + Users + Logs @@ -244,6 +248,10 @@ Индексы + + + Move + Удалить @@ -555,6 +563,93 @@ SELECT * FROM users LIMIT 10;"> + + + + Move table + + + + Folder + + + + Table name + + + + + Cancel + Save + + + + + + + + Users & access + + + + + + + + + + Username + Role + Status + Actions + + + + + + + + + Username + + + + Password + + + + Role + + viewer + moderator + admin + + + + Readable folders + + + + Editable tables + + + + Deletable folders + + + + + Disable login + + + Reset + Save user + + + + + + @@ -565,6 +660,7 @@ SELECT * FROM users LIMIT 10;"> this.currentUser = null; this.currentTable = null; this.tables = []; + this.currentRows = []; this.folderState = JSON.parse(localStorage.getItem('pg_folder_state') || '{}'); this.themePreference = localStorage.getItem('pg_theme') || 'system'; this.currentContainer = ''; @@ -710,6 +806,7 @@ SELECT * FROM users LIMIT 10;"> `${dbInfo.host}:${dbInfo.port}/${dbInfo.database}`; document.getElementById('roleBadge').textContent = this.currentUser.role || 'viewer'; document.getElementById('logsButton').classList.toggle('hidden', !this.getPermissions().canViewLogs); + document.getElementById('usersButton').classList.toggle('hidden', !this.getPermissions().canManageUsers); document.querySelector('button[onclick="app.showSQLPanel()"]').style.display = this.getPermissions().canRunSql ? '' : 'none'; document.querySelector('button[onclick="app.showCreateTableModal()"]').style.display = this.getPermissions().canCreate ? '' : 'none'; @@ -724,7 +821,9 @@ SELECT * FROM users LIMIT 10;"> canEdit: true, canDelete: true, canViewLogs: true, - canRunSql: true + canRunSql: true, + canManageUsers: true, + canMoveTables: true }; } @@ -734,7 +833,9 @@ SELECT * FROM users LIMIT 10;"> canEdit: false, canDelete: false, canViewLogs: false, - canRunSql: false + canRunSql: false, + canManageUsers: false, + canMoveTables: false }; } @@ -853,8 +954,10 @@ SELECT * FROM users LIMIT 10;"> // Update action buttons based on permissions const addBtn = document.querySelector('#tableActions button[onclick="app.showAddRecordModal()"]'); const deleteTableBtn = document.querySelector('#tableActions button[onclick="app.deleteTable()"]'); + const moveTableBtn = document.querySelector('#tableActions button[onclick="app.showMoveTableModal()"]'); if (addBtn) addBtn.style.display = this.canEditTable() ? '' : 'none'; if (deleteTableBtn) deleteTableBtn.style.display = this.canDeleteTable() ? '' : 'none'; + if (moveTableBtn) moveTableBtn.style.display = this.getPermissions().canMoveTables ? '' : 'none'; // Load table structure to get primary key await this.loadTableStructure(); @@ -890,7 +993,8 @@ SELECT * FROM users LIMIT 10;"> const body = document.getElementById('tableBody'); // Generate headers - const columns = records.length > 0 ? Object.keys(records[0]) : this.tableStructure.map(col => col.name); + this.currentRows = records; + const columns = (records.length > 0 ? Object.keys(records[0]) : this.tableStructure.map(col => col.name)).filter(col => col !== '__rowid'); headers.innerHTML = columns.map(col => { const isSorted = this.sortColumn === col; const arrow = isSorted ? (this.sortDirection === 'ASC' ? '↑' : '↓') : ''; @@ -910,7 +1014,9 @@ SELECT * FROM users LIMIT 10;"> return; } body.innerHTML = records.map(record => { - const pkValue = this.primaryKey ? record[this.primaryKey] : null; + const pkValue = this.primaryKey && record[this.primaryKey] !== undefined && record[this.primaryKey] !== null && record[this.primaryKey] !== '' + ? record[this.primaryKey] + : record.__rowid; const cells = columns.map(col => { const colDef = this.tableStructure.find(c => c.name === col); let displayValue = record[col] || ''; @@ -921,6 +1027,7 @@ SELECT * FROM users LIMIT 10;"> }).join(''); const canEdit = this.canEditTable(); const canDelete = this.canDeleteTable(); + const actionValue = JSON.stringify(String(pkValue)); const actions = pkValue ? ` ${canEdit ? `Редактировать` : ''} @@ -1054,12 +1161,26 @@ SELECT * FROM users LIMIT 10;"> async loadRecordData(pkValue) { try { - // Since we don't have a single record endpoint, we'll load all data and find the record - const params = new URLSearchParams({ page: 1, limit: 1000 }); // Load more to find the record - const response = await fetch(`/api/tables/${this.currentTable}/data?${params}`); - const data = await response.json(); - - const record = data.data.find(r => r[this.primaryKey] == pkValue); + let record = this.currentRows.find(r => { + const rowKey = this.primaryKey && r[this.primaryKey] !== undefined && r[this.primaryKey] !== null && r[this.primaryKey] !== '' + ? r[this.primaryKey] + : r.__rowid; + return String(rowKey) === String(pkValue); + }); + + if (!record) { + const params = new URLSearchParams({ page: 1, limit: 1000 }); + const response = await fetch(`/api/tables/${this.currentTable}/data?${params}`); + const data = await response.json(); + this.currentRows = data.data || []; + record = this.currentRows.find(r => { + const rowKey = this.primaryKey && r[this.primaryKey] !== undefined && r[this.primaryKey] !== null && r[this.primaryKey] !== '' + ? r[this.primaryKey] + : r.__rowid; + return String(rowKey) === String(pkValue); + }); + } + if (record) { this.tableStructure.forEach(col => { const input = document.querySelector(`[name="${col.name}"]`); @@ -1552,6 +1673,150 @@ SELECT * FROM users LIMIT 10;"> output.scrollTop = output.scrollHeight; } + parseList(value) { + return value.split(',').map(item => item.trim()).filter(Boolean); + } + + async showUsersModal() { + await this.loadUsers(); + this.resetUserForm(); + document.getElementById('usersModal').classList.remove('hidden'); + } + + async loadUsers() { + try { + const response = await fetch('/api/users'); + const users = await response.json(); + if (!response.ok) { + throw new Error(users.error || 'Failed to load users'); + } + + document.getElementById('usersTableBody').innerHTML = users.map(user => ` + + ${user.username} + ${user.role} + ${user.disabled ? 'disabled' : 'active'} + + Edit + Delete + + + `).join(''); + } catch (err) { + this.showToast(err.message, 'error'); + } + } + + editUser(serializedUser) { + const user = JSON.parse(serializedUser); + document.getElementById('userEditMode').value = user.username; + document.getElementById('userUsername').value = user.username; + document.getElementById('userUsername').disabled = true; + document.getElementById('userPassword').value = ''; + document.getElementById('userRole').value = user.role; + document.getElementById('userViewFolders').value = (user.access?.view?.folders || []).join(', '); + document.getElementById('userEditTables').value = (user.access?.edit?.tables || []).join(', '); + document.getElementById('userDeleteFolders').value = (user.access?.delete?.folders || []).join(', '); + document.getElementById('userDisabled').checked = Boolean(user.disabled); + } + + resetUserForm() { + document.getElementById('userEditMode').value = ''; + document.getElementById('userUsername').value = ''; + document.getElementById('userUsername').disabled = false; + document.getElementById('userPassword').value = ''; + document.getElementById('userRole').value = 'viewer'; + document.getElementById('userViewFolders').value = ''; + document.getElementById('userEditTables').value = ''; + document.getElementById('userDeleteFolders').value = ''; + document.getElementById('userDisabled').checked = false; + } + + async saveUser() { + const username = document.getElementById('userUsername').value.trim(); + const editMode = document.getElementById('userEditMode').value; + const password = document.getElementById('userPassword').value; + const payload = { + username, + password, + role: document.getElementById('userRole').value, + disabled: document.getElementById('userDisabled').checked, + access: { + view: { folders: this.parseList(document.getElementById('userViewFolders').value), tables: [] }, + create: { folders: [], tables: [] }, + edit: { folders: [], tables: this.parseList(document.getElementById('userEditTables').value) }, + delete: { folders: this.parseList(document.getElementById('userDeleteFolders').value), tables: [] }, + }, + }; + + try { + const response = await fetch(editMode ? `/api/users/${encodeURIComponent(editMode)}` : '/api/users', { + method: editMode ? 'PUT' : 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || 'Failed to save user'); + } + this.showToast('User saved', 'success'); + this.resetUserForm(); + this.loadUsers(); + } catch (err) { + this.showToast(err.message, 'error'); + } + } + + async deleteUser(username) { + if (!confirm(`Delete user ${username}?`)) return; + try { + const response = await fetch(`/api/users/${encodeURIComponent(username)}`, { method: 'DELETE' }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || 'Failed to delete user'); + } + this.showToast('User deleted', 'success'); + this.loadUsers(); + } catch (err) { + this.showToast(err.message, 'error'); + } + } + + showMoveTableModal() { + const parts = (this.currentTable || '').split('__'); + document.getElementById('moveTableFolder').value = parts.length > 1 ? parts[0] : ''; + document.getElementById('moveTableName').value = parts.length > 1 ? parts.slice(1).join('__') : this.currentTable || ''; + document.getElementById('moveTableModal').classList.remove('hidden'); + } + + async moveTable() { + const folder = document.getElementById('moveTableFolder').value.trim(); + const name = document.getElementById('moveTableName').value.trim(); + if (!name) { + this.showToast('Table name is required', 'error'); + return; + } + + try { + const response = await fetch(`/api/tables/${encodeURIComponent(this.currentTable)}/move`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ folder, name }), + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || 'Failed to move table'); + } + this.currentTable = data.name; + this.closeModal('moveTableModal'); + this.loadTables(); + this.selectTable(data.name); + this.showToast('Table moved', 'success'); + } catch (err) { + this.showToast(err.message, 'error'); + } + } + showIndexesModal() { document.getElementById('indexesTableName').textContent = this.currentTable; this.loadIndexes(); diff --git a/server.js b/server.js index 6d4ef6f..09f7b28 100644 --- a/server.js +++ b/server.js @@ -90,6 +90,7 @@ function normalizeUser(user) { passwordHash: typeof user.passwordHash === 'string' ? user.passwordHash : undefined, role: ['admin', 'moderator', 'viewer', 'superadmin'].includes(role) ? role : 'viewer', folders, + access: normalizeAccess(user.access, role, folders), disabled: Boolean(user.disabled), }; } @@ -98,33 +99,144 @@ function getUser(username) { return readUsersConfig().users.find((user) => user.username === username) || null; } -function getRolePermissions(role, folders = null) { +function normalizeScope(scope, fallbackFolders = null) { + if (scope === null) { + return { folders: null, tables: null }; + } + + return { + folders: Array.isArray(scope?.folders) + ? scope.folders.filter(Boolean) + : fallbackFolders, + tables: Array.isArray(scope?.tables) + ? scope.tables.filter(Boolean) + : [], + }; +} + +function normalizeAccess(access, role, folders = null) { if (role === 'superadmin') { - return { role, folders: null, canCreate: true, canEdit: true, canDelete: true, canViewLogs: true, canRunSql: true }; + return { + view: { folders: null, tables: null }, + create: { folders: null, tables: null }, + edit: { folders: null, tables: null }, + delete: { folders: null, tables: null }, + }; + } + + const baseFolders = folders && folders.length ? folders : null; + const defaultsByRole = { + admin: { + view: { folders: baseFolders, tables: [] }, + create: { folders: baseFolders, tables: [] }, + edit: { folders: baseFolders, tables: [] }, + delete: { folders: baseFolders, tables: [] }, + }, + moderator: { + view: { folders: baseFolders, tables: [] }, + create: { folders: baseFolders, tables: [] }, + edit: { folders: baseFolders, tables: [] }, + delete: { folders: [], tables: [] }, + }, + viewer: { + view: { folders: baseFolders, tables: [] }, + create: { folders: [], tables: [] }, + edit: { folders: [], tables: [] }, + delete: { folders: [], tables: [] }, + }, + }; + + const defaults = defaultsByRole[role] || defaultsByRole.viewer; + const source = access && typeof access === 'object' ? access : {}; + + return { + view: normalizeScope(source.view, defaults.view.folders), + create: normalizeScope(source.create, defaults.create.folders), + edit: normalizeScope(source.edit, defaults.edit.folders), + delete: normalizeScope(source.delete, defaults.delete.folders), + }; +} + +function getRolePermissions(role, folders = null, access = null) { + const normalizedAccess = normalizeAccess(access, role, folders); + if (role === 'superadmin') { + return { + role, + folders: null, + access: normalizedAccess, + canCreate: true, + canEdit: true, + canDelete: true, + canViewLogs: true, + canRunSql: true, + canManageUsers: true, + canMoveTables: true, + }; } if (role === 'admin') { - return { role, folders: folders && folders.length ? folders : null, canCreate: true, canEdit: true, canDelete: true, canViewLogs: true, canRunSql: true }; + return { + role, + folders: folders && folders.length ? folders : null, + access: normalizedAccess, + canCreate: true, + canEdit: true, + canDelete: true, + canViewLogs: true, + canRunSql: true, + canManageUsers: true, + canMoveTables: true, + }; } if (role === 'moderator') { - return { role, folders: folders && folders.length ? folders : null, canCreate: true, canEdit: true, canDelete: false, canViewLogs: false, canRunSql: false }; + return { + role, + folders: folders && folders.length ? folders : null, + access: normalizedAccess, + canCreate: true, + canEdit: true, + canDelete: false, + canViewLogs: false, + canRunSql: false, + canManageUsers: false, + canMoveTables: false, + }; } - return { role: 'viewer', folders: folders && folders.length ? folders : null, canCreate: false, canEdit: false, canDelete: false, canViewLogs: false, canRunSql: false }; + return { + role: 'viewer', + folders: folders && folders.length ? folders : null, + access: normalizedAccess, + canCreate: false, + canEdit: false, + canDelete: false, + canViewLogs: false, + canRunSql: false, + canManageUsers: false, + canMoveTables: false, + }; } -function canAccessTable(permissionsOrRole, tableName, folders = null) { +function isScopeAllowed(scope, tableName) { + if (!scope) return false; + if (scope.tables === null || scope.folders === null) return true; + const folder = getTableFolder(tableName); + return scope.tables.includes(tableName) || scope.folders.includes(folder); +} + +function canAccessTable(permissionsOrRole, tableName, folders = null, access = null, action = 'view') { const perms = typeof permissionsOrRole === 'string' - ? getRolePermissions(permissionsOrRole, folders) + ? getRolePermissions(permissionsOrRole, folders, access) : permissionsOrRole; - if (!perms.folders) return true; - return perms.folders.includes(getTableFolder(tableName)); + return isScopeAllowed(perms.access?.[action], tableName); } -function canAccessFolder(permissions, folder) { - if (!permissions.folders) return true; - return permissions.folders.includes(folder); +function canAccessFolder(permissions, folder, action = 'view') { + const scope = permissions.access?.[action]; + if (!scope) return false; + if (scope.folders === null) return true; + return scope.folders.includes(folder); } function isValidIdentifier(value) { @@ -138,11 +250,11 @@ function quoteIdentifier(identifier) { return `"${identifier}"`; } -function createSessionUser({ username, role, folders }) { +function createSessionUser({ username, role, folders, access }) { return { username, role, - permissions: getRolePermissions(role, folders), + permissions: getRolePermissions(role, folders, access), }; } @@ -158,6 +270,82 @@ async function verifyPassword(user, password) { return user.password === password; } +function sanitizeUser(user) { + return { + username: user.username, + role: user.role, + folders: user.folders, + access: user.access, + disabled: user.disabled, + }; +} + +function validateScopeInput(scope) { + if (scope === null) { + return { folders: null, tables: null }; + } + + return { + folders: Array.isArray(scope?.folders) ? scope.folders.filter(Boolean) : [], + tables: Array.isArray(scope?.tables) ? scope.tables.filter(Boolean) : [], + }; +} + +function validateUserPayload(payload, { allowPasswordOptional = false } = {}) { + if (!payload || typeof payload.username !== 'string' || !payload.username.trim()) { + throw new Error('Username is required'); + } + + if (!['admin', 'moderator', 'viewer'].includes(payload.role)) { + throw new Error('Invalid role'); + } + + if (!allowPasswordOptional && (!payload.password || typeof payload.password !== 'string')) { + throw new Error('Password is required'); + } + + const folders = Array.isArray(payload.folders) ? payload.folders.filter(Boolean) : null; + const access = payload.access && typeof payload.access === 'object' + ? { + view: validateScopeInput(payload.access.view), + create: validateScopeInput(payload.access.create), + edit: validateScopeInput(payload.access.edit), + delete: validateScopeInput(payload.access.delete), + } + : normalizeAccess(null, payload.role, folders); + + return { + username: payload.username.trim(), + password: typeof payload.password === 'string' ? payload.password : undefined, + role: payload.role, + folders, + access, + disabled: Boolean(payload.disabled), + }; +} + +function writeUsersConfig(users) { + const serialized = JSON.stringify({ + users: users.map((user) => { + const payload = { + username: user.username, + role: user.role, + folders: user.folders, + access: user.access, + disabled: Boolean(user.disabled), + }; + if (user.passwordHash) { + payload.passwordHash = user.passwordHash; + } else if (user.password) { + payload.password = user.password; + } + return payload; + }), + }, null, 2); + + fs.writeFileSync(USERS_FILE, serialized, 'utf8'); +} + function dockerRequest(requestPath, { stream = false } = {}) { return new Promise((resolve, reject) => { const req = http.request({ @@ -326,7 +514,7 @@ const requireTableAccess = (req, res, next) => { return res.status(400).json({ success: false, error: 'Invalid table name' }); } - if (!canAccessTable(req.currentUser.permissions, tableName)) { + if (!canAccessTable(req.currentUser.permissions, tableName, null, null, 'view')) { return res.status(403).json({ success: false, error: 'Access denied' }); } @@ -429,6 +617,83 @@ app.get('/api/session', (req, res) => { } }); +app.get('/api/users', requireAuth, requirePermission( + (permissions) => permissions.canManageUsers, + 'User management access denied' +), (req, res) => { + const config = readUsersConfig(); + res.json(config.users.map(sanitizeUser)); +}); + +app.post('/api/users', requireAuth, requirePermission( + (permissions) => permissions.canManageUsers, + 'User management access denied' +), async (req, res) => { + try { + const payload = validateUserPayload(req.body); + const config = readUsersConfig(); + if (config.users.some((user) => user.username === payload.username)) { + return res.status(409).json({ success: false, error: 'User already exists' }); + } + + const passwordHash = await bcrypt.hash(payload.password, 10); + config.users.push({ + username: payload.username, + passwordHash, + role: payload.role, + folders: payload.folders, + access: payload.access, + disabled: payload.disabled, + }); + writeUsersConfig(config.users); + res.json({ success: true, user: sanitizeUser(config.users[config.users.length - 1]) }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } +}); + +app.put('/api/users/:username', requireAuth, requirePermission( + (permissions) => permissions.canManageUsers, + 'User management access denied' +), async (req, res) => { + try { + const payload = validateUserPayload({ ...req.body, username: req.params.username }, { allowPasswordOptional: true }); + const config = readUsersConfig(); + const user = config.users.find((item) => item.username === req.params.username); + if (!user) { + return res.status(404).json({ success: false, error: 'User not found' }); + } + + user.role = payload.role; + user.folders = payload.folders; + user.access = payload.access; + user.disabled = payload.disabled; + if (payload.password) { + user.passwordHash = await bcrypt.hash(payload.password, 10); + delete user.password; + } + + writeUsersConfig(config.users); + res.json({ success: true, user: sanitizeUser(user) }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } +}); + +app.delete('/api/users/:username', requireAuth, requirePermission( + (permissions) => permissions.canManageUsers, + 'User management access denied' +), (req, res) => { + const config = readUsersConfig(); + const nextUsers = config.users.filter((user) => user.username !== req.params.username); + if (nextUsers.length === config.users.length) { + return res.status(404).json({ success: false, error: 'User not found' }); + } + + writeUsersConfig(nextUsers); + res.json({ success: true }); +}); + // Get all tables app.get('/api/tables', requireAuth, async (req, res) => { try { @@ -440,7 +705,7 @@ app.get('/api/tables', requireAuth, async (req, res) => { ORDER BY table_name `); - const accessibleTables = result.rows.filter(table => canAccessTable(req.currentUser.permissions, table.name)); + const accessibleTables = result.rows.filter(table => canAccessTable(req.currentUser.permissions, table.name, null, null, 'view')); const tablesWithCounts = await Promise.all( accessibleTables.map(async (table) => { try { @@ -512,7 +777,7 @@ app.get('/api/tables/:tableName/data', requireAuth, requireTableAccess, async (r const countResult = await pool.query(`SELECT COUNT(*)::int as total FROM ${quoteIdentifier(tableName)} ${whereClause}`, params); const total = countResult.rows[0].total; const result = await pool.query(` - SELECT * FROM ${quoteIdentifier(tableName)} + SELECT ctid::text AS "__rowid", * FROM ${quoteIdentifier(tableName)} ${whereClause} ${orderBy} LIMIT $${params.length + 1} OFFSET $${params.length + 2} @@ -562,7 +827,7 @@ app.get('/api/tables/:tableName/structure', requireAuth, requireTableAccess, asy app.post('/api/tables', requireAuth, requirePermission((permissions, req) => { const folder = getTableFolder(req.body?.name); - return permissions.canCreate && canAccessFolder(permissions, folder); + return permissions.canCreate && canAccessFolder(permissions, folder, 'create'); }, 'Access denied'), async (req, res) => { const { name, columns } = req.body || {}; @@ -590,7 +855,7 @@ app.post('/api/tables', requireAuth, requirePermission((permissions, req) => { }); app.delete('/api/tables/:tableName', requireAuth, requireTableAccess, requirePermission( - (permissions, req) => permissions.canDelete && canAccessTable(permissions, req.params.tableName), + (permissions, req) => permissions.canDelete && canAccessTable(permissions, req.params.tableName, null, null, 'delete'), 'Access denied' ), async (req, res) => { try { @@ -601,8 +866,30 @@ app.delete('/api/tables/:tableName', requireAuth, requireTableAccess, requirePer } }); +app.post('/api/tables/:tableName/move', requireAuth, requireTableAccess, requirePermission( + (permissions, req) => permissions.canMoveTables && canAccessTable(permissions, req.params.tableName, null, null, 'edit'), + 'Access denied' +), async (req, res) => { + const { tableName } = req.params; + const { folder, name } = req.body || {}; + const cleanName = String(name || '').trim(); + const cleanFolder = String(folder || '').trim(); + + if (!isValidIdentifier(cleanName) || (cleanFolder && !isValidIdentifier(cleanFolder))) { + return res.status(400).json({ success: false, error: 'Invalid table or folder name' }); + } + + const nextName = cleanFolder ? `${cleanFolder}__${cleanName}` : cleanName; + try { + await pool.query(`ALTER TABLE ${quoteIdentifier(tableName)} RENAME TO ${quoteIdentifier(nextName)}`); + res.json({ success: true, name: nextName }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + app.post('/api/tables/:tableName/records', requireAuth, requireTableAccess, requirePermission( - (permissions, req) => permissions.canEdit && canAccessTable(permissions, req.params.tableName), + (permissions, req) => permissions.canEdit && canAccessTable(permissions, req.params.tableName, null, null, 'create'), 'Access denied' ), async (req, res) => { const { tableName } = req.params; @@ -646,7 +933,7 @@ app.post('/api/tables/:tableName/records', requireAuth, requireTableAccess, requ }); app.put('/api/tables/:tableName/records/:pk', requireAuth, requireTableAccess, requirePermission( - (permissions, req) => permissions.canEdit && canAccessTable(permissions, req.params.tableName), + (permissions, req) => permissions.canEdit && canAccessTable(permissions, req.params.tableName, null, null, 'edit'), 'Access denied' ), async (req, res) => { const { tableName, pk } = req.params; @@ -658,10 +945,13 @@ app.put('/api/tables/:tableName/records/:pk', requireAuth, requireTableAccess, r } try { - const primaryKey = await getPrimaryKeyColumn(tableName) || 'id'; + const primaryKey = await getPrimaryKeyColumn(tableName); const values = columns.map((column) => data[column]); const setClause = columns.map((col, i) => `${quoteIdentifier(col)} = $${i + 1}`).join(', '); - const sql = `UPDATE ${quoteIdentifier(tableName)} SET ${setClause} WHERE ${quoteIdentifier(primaryKey)} = $${values.length + 1} RETURNING *`; + const whereClause = primaryKey + ? `${quoteIdentifier(primaryKey)} = $${values.length + 1}` + : 'ctid::text = $' + (values.length + 1); + const sql = `UPDATE ${quoteIdentifier(tableName)} SET ${setClause} WHERE ${whereClause} RETURNING *`; const result = await pool.query(sql, [...values, pk]); res.json({ success: true, data: result.rows[0] }); } catch (err) { @@ -670,14 +960,17 @@ app.put('/api/tables/:tableName/records/:pk', requireAuth, requireTableAccess, r }); app.delete('/api/tables/:tableName/records/:pk', requireAuth, requireTableAccess, requirePermission( - (permissions, req) => permissions.canDelete && canAccessTable(permissions, req.params.tableName), + (permissions, req) => permissions.canDelete && canAccessTable(permissions, req.params.tableName, null, null, 'delete'), 'Access denied' ), async (req, res) => { const { tableName, pk } = req.params; try { - const primaryKey = await getPrimaryKeyColumn(tableName) || 'id'; - await pool.query(`DELETE FROM ${quoteIdentifier(tableName)} WHERE ${quoteIdentifier(primaryKey)} = $1`, [pk]); + const primaryKey = await getPrimaryKeyColumn(tableName); + const whereClause = primaryKey + ? `${quoteIdentifier(primaryKey)} = $1` + : 'ctid::text = $1'; + await pool.query(`DELETE FROM ${quoteIdentifier(tableName)} WHERE ${whereClause}`, [pk]); res.json({ success: true }); } catch (err) { res.status(500).json({ success: false, error: err.message }); @@ -685,7 +978,7 @@ app.delete('/api/tables/:tableName/records/:pk', requireAuth, requireTableAccess }); app.post('/api/tables/:tableName/columns', requireAuth, requireTableAccess, requirePermission( - (permissions, req) => permissions.canEdit && canAccessTable(permissions, req.params.tableName), + (permissions, req) => permissions.canEdit && canAccessTable(permissions, req.params.tableName, null, null, 'edit'), 'Access denied' ), async (req, res) => { const { tableName } = req.params; @@ -711,7 +1004,7 @@ app.post('/api/tables/:tableName/columns', requireAuth, requireTableAccess, requ }); app.put('/api/tables/:tableName/columns/:columnName', requireAuth, requireTableAccess, requirePermission( - (permissions, req) => permissions.canEdit && canAccessTable(permissions, req.params.tableName), + (permissions, req) => permissions.canEdit && canAccessTable(permissions, req.params.tableName, null, null, 'edit'), 'Access denied' ), async (req, res) => { const { tableName, columnName } = req.params; @@ -749,7 +1042,7 @@ app.put('/api/tables/:tableName/columns/:columnName', requireAuth, requireTableA }); app.delete('/api/tables/:tableName/columns/:columnName', requireAuth, requireTableAccess, requirePermission( - (permissions, req) => permissions.canDelete && canAccessTable(permissions, req.params.tableName), + (permissions, req) => permissions.canDelete && canAccessTable(permissions, req.params.tableName, null, null, 'delete'), 'Access denied' ), async (req, res) => { const { tableName, columnName } = req.params; @@ -815,7 +1108,7 @@ app.get('/api/tables/:tableName/indexes', requireAuth, requireTableAccess, async }); app.post('/api/tables/:tableName/indexes', requireAuth, requireTableAccess, requirePermission( - (permissions, req) => permissions.canEdit && canAccessTable(permissions, req.params.tableName), + (permissions, req) => permissions.canEdit && canAccessTable(permissions, req.params.tableName, null, null, 'edit'), 'Access denied' ), async (req, res) => { const { tableName } = req.params;