This commit is contained in:
2026-03-18 17:57:59 +07:00
parent ad117fe837
commit ead769e9d1
3 changed files with 252 additions and 28 deletions

View File

@@ -257,6 +257,11 @@ SELECT * FROM users LIMIT 10;"></textarea>
</button> </button>
</div> </div>
<div class="p-6 overflow-y-auto flex-1"> <div class="p-6 overflow-y-auto flex-1">
<div class="mb-4">
<label class="block text-sm font-medium text-slate-700 mb-1">Папка</label>
<input type="text" id="newTableFolder" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" placeholder="frontend">
<p class="text-xs text-slate-500 mt-1">Если указано, имя таблицы будет создано как <code>папка__имя</code>. Оставьте пустым для создания в корне.</p>
</div>
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium text-slate-700 mb-1">Название таблицы</label> <label class="block text-sm font-medium text-slate-700 mb-1">Название таблицы</label>
<input type="text" id="newTableName" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" placeholder="users"> <input type="text" id="newTableName" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" placeholder="users">
@@ -510,6 +515,8 @@ SELECT * FROM users LIMIT 10;"></textarea>
if (data.success) { if (data.success) {
this.currentUser = { this.currentUser = {
username, username,
role: data.role,
permissions: data.permissions,
dbInfo: data.dbInfo dbInfo: data.dbInfo
}; };
localStorage.setItem('pg_admin_session', JSON.stringify(this.currentUser)); localStorage.setItem('pg_admin_session', JSON.stringify(this.currentUser));
@@ -545,6 +552,37 @@ SELECT * FROM users LIMIT 10;"></textarea>
this.loadTables(); this.loadTables();
} }
getPermissions() {
return this.currentUser?.permissions || { folders: null, canCreate: false, canEdit: false, canDelete: false };
}
getCurrentTableFolder() {
if (!this.currentTable) return null;
const parts = this.currentTable.split('__');
return parts.length > 1 ? parts[0] : 'default';
}
canCreate() {
const perms = this.getPermissions();
return perms.canCreate;
}
canEditTable() {
const perms = this.getPermissions();
const folder = this.getCurrentTableFolder();
if (!perms.canEdit) return false;
if (!perms.folders) return true;
return perms.folders.includes(folder);
}
canDeleteTable() {
const perms = this.getPermissions();
const folder = this.getCurrentTableFolder();
if (!perms.canDelete) return false;
if (!perms.folders) return true;
return perms.folders.includes(folder);
}
// Data Loading // Data Loading
async loadTables() { async loadTables() {
try { try {
@@ -563,20 +601,38 @@ SELECT * FROM users LIMIT 10;"></textarea>
renderTableList() { renderTableList() {
const container = document.getElementById('tableList'); const container = document.getElementById('tableList');
const search = document.getElementById('tableSearch').value.toLowerCase(); const search = document.getElementById('tableSearch').value.toLowerCase();
container.innerHTML = this.tables const filtered = this.tables.filter(t => t.name.toLowerCase().includes(search));
.filter(t => t.name.toLowerCase().includes(search)) const grouped = filtered.reduce((acc, table) => {
.map(table => ` const parts = table.name.split('__');
const folder = parts.length > 1 ? parts[0] : 'default';
if (!acc[folder]) acc[folder] = [];
acc[folder].push(table);
return acc;
}, {});
const folderOrder = Object.keys(grouped).sort();
container.innerHTML = folderOrder.map(folder => {
const label = folder === 'default' ? 'Общие' : folder;
const tablesHtml = grouped[folder].map(table => `
<div onclick="app.selectTable('${table.name}')" <div onclick="app.selectTable('${table.name}')"
class="sidebar-item px-4 py-3 cursor-pointer border-l-2 ${this.currentTable === table.name ? 'border-blue-500 bg-slate-800/50 text-white' : 'border-transparent hover:text-white'} transition-all flex items-center justify-between group"> class="sidebar-item px-4 py-3 cursor-pointer border-l-2 ${this.currentTable === table.name ? 'border-blue-500 bg-slate-800/50 text-white' : 'border-transparent hover:text-white'} transition-all flex items-center justify-between group">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<i data-lucide="table-2" class="w-4 h-4 opacity-70"></i> <i data-lucide="table-2" class="w-4 h-4 opacity-70"></i>
<span class="text-sm font-medium">${table.name}</span> <span class="text-sm font-medium">${table.name.replace(`${folder}__`, '')}</span>
</div> </div>
<span class="text-xs opacity-50 group-hover:opacity-100">${table.rows}</span> <span class="text-xs opacity-50 group-hover:opacity-100">${table.rows}</span>
</div> </div>
`).join(''); `).join('');
return `
<div class="mb-2">
<div class="px-4 py-2 text-xs text-slate-400 uppercase tracking-wider font-semibold">${label}</div>
${tablesHtml}
</div>
`;
}).join('');
lucide.createIcons(); lucide.createIcons();
} }
@@ -594,6 +650,12 @@ SELECT * FROM users LIMIT 10;"></textarea>
document.getElementById('emptyState').classList.add('hidden'); document.getElementById('emptyState').classList.add('hidden');
document.getElementById('sqlPanel').classList.add('hidden'); document.getElementById('sqlPanel').classList.add('hidden');
document.getElementById('dataGrid').classList.remove('hidden'); document.getElementById('dataGrid').classList.remove('hidden');
// Update action buttons based on permissions
const addBtn = document.querySelector('#tableActions button[onclick="app.showAddRecordModal()"]');
const deleteTableBtn = document.querySelector('#tableActions button[onclick="app.deleteTable()"]');
if (addBtn) addBtn.style.display = this.canEditTable() ? '' : 'none';
if (deleteTableBtn) deleteTableBtn.style.display = this.canDeleteTable() ? '' : 'none';
// Load table structure to get primary key // Load table structure to get primary key
await this.loadTableStructure(); await this.loadTableStructure();
@@ -658,10 +720,12 @@ SELECT * FROM users LIMIT 10;"></textarea>
} }
return `<td class="p-3 border-b border-slate-100">${displayValue}</td>`; return `<td class="p-3 border-b border-slate-100">${displayValue}</td>`;
}).join(''); }).join('');
const canEdit = this.canEditTable();
const canDelete = this.canDeleteTable();
const actions = pkValue ? ` const actions = pkValue ? `
<td class="p-3 border-b border-slate-100"> <td class="p-3 border-b border-slate-100">
<button onclick="app.editRecord('${pkValue}')" class="text-blue-600 hover:underline mr-2">Редактировать</button> ${canEdit ? `<button onclick="app.editRecord('${pkValue}')" class="text-blue-600 hover:underline mr-2">Редактировать</button>` : ''}
<button onclick="app.deleteRecord('${pkValue}')" class="text-red-600 hover:underline">Удалить</button> ${canDelete ? `<button onclick="app.deleteRecord('${pkValue}')" class="text-red-600 hover:underline">Удалить</button>` : ''}
</td> </td>
` : '<td class="p-3 border-b border-slate-100">-</td>'; ` : '<td class="p-3 border-b border-slate-100">-</td>';
@@ -986,6 +1050,7 @@ SELECT * FROM users LIMIT 10;"></textarea>
} }
showCreateTableModal() { showCreateTableModal() {
document.getElementById('newTableFolder').value = '';
document.getElementById('newTableName').value = ''; document.getElementById('newTableName').value = '';
document.getElementById('columnsContainer').innerHTML = ''; document.getElementById('columnsContainer').innerHTML = '';
this.addColumnField(); // Add one default column this.addColumnField(); // Add one default column
@@ -1026,12 +1091,15 @@ SELECT * FROM users LIMIT 10;"></textarea>
} }
createTable() { createTable() {
const name = document.getElementById('newTableName').value; const folder = document.getElementById('newTableFolder').value.trim();
const name = document.getElementById('newTableName').value.trim();
if (!name) { if (!name) {
this.showToast('Введите название таблицы', 'error'); this.showToast('Введите название таблицы', 'error');
return; return;
} }
const tableName = folder ? `${folder}__${name}` : name;
const columnElements = document.querySelectorAll('#columnsContainer > div'); const columnElements = document.querySelectorAll('#columnsContainer > div');
const columns = Array.from(columnElements).map(div => { const columns = Array.from(columnElements).map(div => {
const inputs = div.querySelectorAll('input, select'); const inputs = div.querySelectorAll('input, select');
@@ -1051,7 +1119,7 @@ SELECT * FROM users LIMIT 10;"></textarea>
fetch('/api/tables', { fetch('/api/tables', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, columns }) body: JSON.stringify({ name: tableName, columns })
}) })
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {

158
server.js
View File

@@ -4,6 +4,44 @@ const { Pool } = require('pg');
const session = require('express-session'); const session = require('express-session');
const cors = require('cors'); const cors = require('cors');
const path = require('path'); const path = require('path');
const fs = require('fs');
let usersConfig = { users: [] };
try {
usersConfig = JSON.parse(fs.readFileSync(path.join(__dirname, 'users.json'), 'utf8'));
} catch (err) {
console.warn('⚠️ users.json not found or invalid JSON. Falling back to env-based admin only.');
}
const rolePermissions = {
superadmin: { folders: null, canCreate: true, canEdit: true, canDelete: true },
frontend_admin: { folders: ['frontend'], canCreate: true, canEdit: true, canDelete: true },
backend_admin: { folders: ['backend'], canCreate: true, canEdit: true, canDelete: true },
frontend_moder: { folders: ['frontend'], canCreate: true, canEdit: true, canDelete: false },
backend_moder: { folders: ['backend'], canCreate: true, canEdit: true, canDelete: false },
viewer: { folders: null, canCreate: false, canEdit: false, canDelete: false },
};
function getUser(username) {
return usersConfig.users.find(u => u.username === username);
}
function getTableFolder(tableName) {
if (!tableName) return 'default';
const parts = tableName.split('__');
return parts.length > 1 ? parts[0] : 'default';
}
function getRolePermissions(role) {
return rolePermissions[role] || rolePermissions.viewer;
}
function canAccessTable(role, tableName) {
const perms = getRolePermissions(role);
if (!perms.folders) return true;
const folder = getTableFolder(tableName);
return perms.folders.includes(folder);
}
const app = express(); const app = express();
@@ -68,24 +106,26 @@ const requireAuth = (req, res, next) => {
} }
}; };
// Login endpoint - checks admin credentials from .env // Login endpoint - checks users.json (fallback to .env admin)
app.post('/api/login', async (req, res) => { app.post('/api/login', async (req, res) => {
const { username, password } = req.body; const { username, password } = req.body;
// Check against .env credentials // Try users.json first
if (username === process.env.ADMIN_USERNAME && password === process.env.ADMIN_PASSWORD) { const user = getUser(username);
// Test database connection if (user && password === user.password) {
try { try {
const client = await pool.connect(); const client = await pool.connect();
const result = await client.query('SELECT NOW() as time'); const result = await client.query('SELECT NOW() as time');
client.release(); client.release();
req.session.authenticated = true; req.session.authenticated = true;
req.session.username = username; req.session.username = username;
req.session.role = user.role;
res.json({ res.json({
success: true, success: true,
message: 'Login successful', message: 'Login successful',
role: user.role,
dbInfo: { dbInfo: {
host: process.env.DB_HOST, host: process.env.DB_HOST,
port: process.env.DB_PORT, port: process.env.DB_PORT,
@@ -94,19 +134,55 @@ app.post('/api/login', async (req, res) => {
serverTime: result.rows[0].time serverTime: result.rows[0].time
} }
}); });
return;
} catch (err) { } catch (err) {
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: 'Database connection failed', error: 'Database connection failed',
details: err.message details: err.message
}); });
return;
} }
} else {
res.status(401).json({
success: false,
error: 'Invalid credentials'
});
} }
// Fallback to env-based admin
if (username === process.env.ADMIN_USERNAME && password === process.env.ADMIN_PASSWORD) {
try {
const client = await pool.connect();
const result = await client.query('SELECT NOW() as time');
client.release();
req.session.authenticated = true;
req.session.username = username;
req.session.role = 'superadmin';
res.json({
success: true,
message: 'Login successful',
role: 'superadmin',
dbInfo: {
host: process.env.DB_HOST,
port: process.env.DB_PORT,
database: process.env.DB_NAME,
connected: true,
serverTime: result.rows[0].time
}
});
return;
} catch (err) {
res.status(500).json({
success: false,
error: 'Database connection failed',
details: err.message
});
return;
}
}
res.status(401).json({
success: false,
error: 'Invalid credentials'
});
}); });
// Logout // Logout
@@ -121,6 +197,8 @@ app.get('/api/session', (req, res) => {
res.json({ res.json({
authenticated: true, authenticated: true,
username: req.session.username, username: req.session.username,
role: req.session.role || 'viewer',
permissions: getRolePermissions(req.session.role),
dbInfo: { dbInfo: {
host: process.env.DB_HOST, host: process.env.DB_HOST,
port: process.env.DB_PORT, port: process.env.DB_PORT,
@@ -135,6 +213,7 @@ app.get('/api/session', (req, res) => {
// Get all tables // Get all tables
app.get('/api/tables', requireAuth, async (req, res) => { app.get('/api/tables', requireAuth, async (req, res) => {
try { try {
const role = req.session.role || 'viewer';
const result = await pool.query(` const result = await pool.query(`
SELECT SELECT
table_name as name, table_name as name,
@@ -143,10 +222,13 @@ app.get('/api/tables', requireAuth, async (req, res) => {
WHERE table_schema = 'public' WHERE table_schema = 'public'
ORDER BY table_name ORDER BY table_name
`); `);
// Filter tables by access rights (folders)
const accessibleTables = result.rows.filter(table => canAccessTable(role, table.name));
// Get row counts for each table // Get row counts for each table
const tablesWithCounts = await Promise.all( const tablesWithCounts = await Promise.all(
result.rows.map(async (table) => { accessibleTables.map(async (table) => {
try { try {
const countResult = await pool.query(`SELECT COUNT(*) as count FROM "${table.name}"`); const countResult = await pool.query(`SELECT COUNT(*) as count FROM "${table.name}"`);
return { return {
@@ -169,6 +251,13 @@ app.get('/api/tables', requireAuth, async (req, res) => {
// Get table data with pagination, search, filters and sort // Get table data with pagination, search, filters and sort
app.get('/api/tables/:tableName/data', requireAuth, async (req, res) => { app.get('/api/tables/:tableName/data', requireAuth, async (req, res) => {
const { tableName } = req.params; const { tableName } = req.params;
const role = req.session.role || 'viewer';
// Check access for this table
if (!canAccessTable(role, tableName)) {
return res.status(403).json({ error: 'Access denied' });
}
const page = parseInt(req.query.page) || 1; const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10; const limit = parseInt(req.query.limit) || 10;
const search = req.query.search || ''; const search = req.query.search || '';
@@ -279,7 +368,14 @@ app.get('/api/tables/:tableName/structure', requireAuth, async (req, res) => {
// Create table // Create table
app.post('/api/tables', requireAuth, async (req, res) => { app.post('/api/tables', requireAuth, async (req, res) => {
const { name, columns } = req.body; const { name, columns } = req.body;
const role = req.session.role || 'viewer';
const folder = getTableFolder(name);
const perms = getRolePermissions(role);
if (!perms.canCreate || (perms.folders && !perms.folders.includes(folder))) {
return res.status(403).json({ error: 'Access denied' });
}
try { try {
let columnsSQL = columns.map(col => { let columnsSQL = columns.map(col => {
let def = `"${col.name}" ${col.type}`; let def = `"${col.name}" ${col.type}`;
@@ -300,6 +396,13 @@ app.post('/api/tables', requireAuth, async (req, res) => {
// Delete table // Delete table
app.delete('/api/tables/:tableName', requireAuth, async (req, res) => { app.delete('/api/tables/:tableName', requireAuth, async (req, res) => {
const { tableName } = req.params; const { tableName } = req.params;
const role = req.session.role || 'viewer';
const folder = getTableFolder(tableName);
const perms = getRolePermissions(role);
if (!perms.canDelete || (perms.folders && !perms.folders.includes(folder))) {
return res.status(403).json({ error: 'Access denied' });
}
try { try {
await pool.query(`DROP TABLE IF EXISTS "${tableName}"`); await pool.query(`DROP TABLE IF EXISTS "${tableName}"`);
@@ -313,7 +416,14 @@ app.delete('/api/tables/:tableName', requireAuth, async (req, res) => {
app.post('/api/tables/:tableName/records', requireAuth, async (req, res) => { app.post('/api/tables/:tableName/records', requireAuth, async (req, res) => {
const { tableName } = req.params; const { tableName } = req.params;
const data = req.body; const data = req.body;
const role = req.session.role || 'viewer';
const folder = getTableFolder(tableName);
const perms = getRolePermissions(role);
if (!perms.canEdit || (perms.folders && !perms.folders.includes(folder))) {
return res.status(403).json({ error: 'Access denied' });
}
try { try {
// Get table structure to check types // Get table structure to check types
const structureResult = await pool.query(` const structureResult = await pool.query(`
@@ -362,8 +472,14 @@ app.post('/api/tables/:tableName/records', requireAuth, async (req, res) => {
// Update record // Update record
app.put('/api/tables/:tableName/records/:pk', requireAuth, async (req, res) => { app.put('/api/tables/:tableName/records/:pk', requireAuth, async (req, res) => {
const { tableName, pk } = req.params; const { tableName, pk } = req.params;
const role = req.session.role || 'viewer';
const folder = getTableFolder(tableName);
const perms = getRolePermissions(role);
if (!perms.canEdit || (perms.folders && !perms.folders.includes(folder))) {
return res.status(403).json({ error: 'Access denied' });
}
const data = req.body; const data = req.body;
const columns = Object.keys(data); const columns = Object.keys(data);
const values = Object.values(data); const values = Object.values(data);
const setClause = columns.map((col, i) => `"${col}" = $${i + 1}`).join(', '); const setClause = columns.map((col, i) => `"${col}" = $${i + 1}`).join(', ');
@@ -381,7 +497,13 @@ app.put('/api/tables/:tableName/records/:pk', requireAuth, async (req, res) => {
// Delete record // Delete record
app.delete('/api/tables/:tableName/records/:pk', requireAuth, async (req, res) => { app.delete('/api/tables/:tableName/records/:pk', requireAuth, async (req, res) => {
const { tableName, pk } = req.params; const { tableName, pk } = req.params;
const role = req.session.role || 'viewer';
const folder = getTableFolder(tableName);
const perms = getRolePermissions(role);
if (!perms.canDelete || (perms.folders && !perms.folders.includes(folder))) {
return res.status(403).json({ error: 'Access denied' });
}
try { try {
const primaryKey = await getPrimaryKeyColumn(tableName) || 'id'; const primaryKey = await getPrimaryKeyColumn(tableName) || 'id';
await pool.query(`DELETE FROM "${tableName}" WHERE "${primaryKey}" = $1`, [pk]); await pool.query(`DELETE FROM "${tableName}" WHERE "${primaryKey}" = $1`, [pk]);

34
users.json Normal file
View File

@@ -0,0 +1,34 @@
{
"users": [
{
"username": "superadmin",
"password": "superadmin",
"role": "superadmin"
},
{
"username": "frontend_admin",
"password": "frontend",
"role": "frontend_admin"
},
{
"username": "backend_admin",
"password": "backend",
"role": "backend_admin"
},
{
"username": "frontend_moder",
"password": "mod123",
"role": "frontend_moder"
},
{
"username": "backend_moder",
"password": "mod123",
"role": "backend_moder"
},
{
"username": "viewer",
"password": "viewer",
"role": "viewer"
}
]
}