Добавление админки

This commit is contained in:
2026-03-20 15:16:44 +07:00
parent e0791c770f
commit 2c73a25a6a
2 changed files with 598 additions and 40 deletions

View File

@@ -183,6 +183,10 @@
<option value="dark">Dark</option>
<option value="system">System</option>
</select>
<button id="usersButton" onclick="app.showUsersModal()" class="hidden flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors text-sm font-medium">
<i data-lucide="users" class="w-4 h-4"></i>
Users
</button>
<button id="logsButton" onclick="app.showLogsPanel()" class="hidden flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors text-sm font-medium">
<i data-lucide="scroll-text" class="w-4 h-4"></i>
Logs
@@ -244,6 +248,10 @@
<i data-lucide="list-tree" class="w-4 h-4"></i>
Индексы
</button>
<button onclick="app.showMoveTableModal()" class="flex items-center gap-2 px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors text-sm">
<i data-lucide="folder-input" class="w-4 h-4"></i>
Move
</button>
<button onclick="app.deleteTable()" class="flex items-center gap-2 px-3 py-1.5 bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-colors text-sm">
<i data-lucide="trash-2" class="w-4 h-4"></i>
Удалить
@@ -555,6 +563,93 @@ SELECT * FROM users LIMIT 10;"></textarea>
</div>
</div>
<div id="moveTableModal" class="hidden fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-md fade-in">
<div class="p-6 border-b border-slate-200">
<h3 class="text-xl font-bold text-slate-800">Move table</h3>
</div>
<div class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Folder</label>
<input type="text" id="moveTableFolder" class="w-full px-4 py-2 border border-slate-300 rounded-lg" placeholder="frontend">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Table name</label>
<input type="text" id="moveTableName" class="w-full px-4 py-2 border border-slate-300 rounded-lg" placeholder="users">
</div>
</div>
<div class="p-6 border-t border-slate-200 bg-slate-50 flex justify-end gap-3">
<button onclick="app.closeModal('moveTableModal')" class="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg">Cancel</button>
<button onclick="app.moveTable()" class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">Save</button>
</div>
</div>
</div>
<div id="usersModal" class="hidden fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col fade-in">
<div class="p-6 border-b border-slate-200 flex items-center justify-between">
<h3 class="text-xl font-bold text-slate-800">Users & access</h3>
<button onclick="app.closeModal('usersModal')" class="p-2 hover:bg-slate-100 rounded-lg transition-colors">
<i data-lucide="x" class="w-5 h-5 text-slate-500"></i>
</button>
</div>
<div class="grid lg:grid-cols-[1.2fr,0.8fr] gap-0 flex-1 min-h-0">
<div class="border-r border-slate-200 overflow-auto">
<table class="w-full text-sm">
<thead class="bg-slate-50 border-b border-slate-200">
<tr>
<th class="text-left p-3">Username</th>
<th class="text-left p-3">Role</th>
<th class="text-left p-3">Status</th>
<th class="text-left p-3">Actions</th>
</tr>
</thead>
<tbody id="usersTableBody"></tbody>
</table>
</div>
<div class="p-6 overflow-auto space-y-3">
<input type="hidden" id="userEditMode" value="">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Username</label>
<input type="text" id="userUsername" class="w-full px-4 py-2 border border-slate-300 rounded-lg">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Password</label>
<input type="text" id="userPassword" class="w-full px-4 py-2 border border-slate-300 rounded-lg" placeholder="Leave empty to keep current">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Role</label>
<select id="userRole" class="w-full px-4 py-2 border border-slate-300 rounded-lg">
<option value="viewer">viewer</option>
<option value="moderator">moderator</option>
<option value="admin">admin</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Readable folders</label>
<input type="text" id="userViewFolders" class="w-full px-4 py-2 border border-slate-300 rounded-lg" placeholder="frontend, backend">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Editable tables</label>
<input type="text" id="userEditTables" class="w-full px-4 py-2 border border-slate-300 rounded-lg" placeholder="users, frontend__users">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Deletable folders</label>
<input type="text" id="userDeleteFolders" class="w-full px-4 py-2 border border-slate-300 rounded-lg" placeholder="frontend">
</div>
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" id="userDisabled" class="w-4 h-4">
Disable login
</label>
<div class="flex justify-end gap-3 pt-2">
<button onclick="app.resetUserForm()" class="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg">Reset</button>
<button onclick="app.saveUser()" class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">Save user</button>
</div>
</div>
</div>
</div>
</div>
<!-- Toast Notifications -->
<div id="toastContainer" class="fixed bottom-6 right-6 z-50 flex flex-col gap-2"></div>
@@ -565,6 +660,7 @@ SELECT * FROM users LIMIT 10;"></textarea>
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;"></textarea>
`${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;"></textarea>
canEdit: true,
canDelete: true,
canViewLogs: true,
canRunSql: true
canRunSql: true,
canManageUsers: true,
canMoveTables: true
};
}
@@ -734,7 +833,9 @@ SELECT * FROM users LIMIT 10;"></textarea>
canEdit: false,
canDelete: false,
canViewLogs: false,
canRunSql: false
canRunSql: false,
canManageUsers: false,
canMoveTables: false
};
}
@@ -853,8 +954,10 @@ SELECT * FROM users LIMIT 10;"></textarea>
// 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;"></textarea>
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;"></textarea>
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;"></textarea>
}).join('');
const canEdit = this.canEditTable();
const canDelete = this.canDeleteTable();
const actionValue = JSON.stringify(String(pkValue));
const actions = pkValue ? `
<td class="p-3 border-b border-slate-100">
${canEdit ? `<button onclick="app.editRecord('${pkValue}')" class="text-blue-600 hover:underline mr-2">Редактировать</button>` : ''}
@@ -1054,12 +1161,26 @@ SELECT * FROM users LIMIT 10;"></textarea>
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;"></textarea>
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 => `
<tr class="border-b border-slate-200">
<td class="p-3">${user.username}</td>
<td class="p-3">${user.role}</td>
<td class="p-3">${user.disabled ? 'disabled' : 'active'}</td>
<td class="p-3">
<button onclick='app.editUser(${JSON.stringify(JSON.stringify(user))})' class="text-blue-600 hover:underline mr-3">Edit</button>
<button onclick='app.deleteUser(${JSON.stringify(user.username)})' class="text-red-600 hover:underline">Delete</button>
</td>
</tr>
`).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();