241 lines
10 KiB
HTML
241 lines
10 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Admin — Dealspace</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<style>
|
|
* { font-family: 'Inter', sans-serif; }
|
|
body { background: #0a1628; }
|
|
.tab-active { border-bottom: 2px solid #c9a84c; color: #c9a84c; }
|
|
.tab-inactive { border-bottom: 2px solid transparent; color: #94a3b8; }
|
|
.tab-inactive:hover { color: #e2e8f0; }
|
|
</style>
|
|
</head>
|
|
<body class="min-h-screen text-white">
|
|
<!-- Top bar -->
|
|
<header class="bg-[#0d1f3c] border-b border-white/[0.08] px-6 py-3 flex items-center justify-between">
|
|
<div class="flex items-center gap-4">
|
|
<a href="/app/tasks" class="text-xl font-bold tracking-tight">
|
|
<span class="text-[#c9a84c]">Deal</span>space
|
|
</a>
|
|
<span class="text-xs bg-red-500/20 text-red-400 px-2 py-0.5 rounded-full font-medium">SUPER ADMIN</span>
|
|
</div>
|
|
<div class="flex items-center gap-4">
|
|
<a href="/app/tasks" class="text-sm text-[#94a3b8] hover:text-white transition">← Back to app</a>
|
|
<button id="logoutBtn" class="text-sm text-[#94a3b8] hover:text-white transition">Sign out</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="max-w-7xl mx-auto px-6 py-8">
|
|
<h1 class="text-2xl font-bold mb-6">Platform Administration</h1>
|
|
|
|
<!-- Tabs -->
|
|
<div class="flex gap-6 mb-8 border-b border-white/[0.08]">
|
|
<button class="tab-active pb-3 text-sm font-medium" data-tab="users">Users</button>
|
|
<button class="tab-inactive pb-3 text-sm font-medium" data-tab="projects">Projects</button>
|
|
<button class="tab-inactive pb-3 text-sm font-medium" data-tab="audit">Audit Log</button>
|
|
</div>
|
|
|
|
<!-- Users tab -->
|
|
<div id="tab-users" class="tab-content">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<input type="text" id="userSearch" placeholder="Search by email or name..."
|
|
class="px-4 py-2 bg-[#0d1f3c] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] focus:outline-none focus:border-[#c9a84c] w-80">
|
|
<span id="userCount" class="text-sm text-[#94a3b8]"></span>
|
|
</div>
|
|
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl overflow-hidden">
|
|
<table class="w-full text-sm">
|
|
<thead>
|
|
<tr class="border-b border-white/[0.08] text-[#94a3b8]">
|
|
<th class="text-left px-4 py-3 font-medium">Name</th>
|
|
<th class="text-left px-4 py-3 font-medium">Email</th>
|
|
<th class="text-left px-4 py-3 font-medium">Organization</th>
|
|
<th class="text-left px-4 py-3 font-medium">Status</th>
|
|
<th class="text-left px-4 py-3 font-medium">Created</th>
|
|
<th class="text-left px-4 py-3 font-medium">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="usersTable"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Projects tab -->
|
|
<div id="tab-projects" class="tab-content hidden">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<span id="projectCount" class="text-sm text-[#94a3b8]"></span>
|
|
</div>
|
|
<div class="grid gap-4" id="projectsGrid"></div>
|
|
</div>
|
|
|
|
<!-- Audit tab -->
|
|
<div id="tab-audit" class="tab-content hidden">
|
|
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl overflow-hidden">
|
|
<table class="w-full text-sm">
|
|
<thead>
|
|
<tr class="border-b border-white/[0.08] text-[#94a3b8]">
|
|
<th class="text-left px-4 py-3 font-medium">Time</th>
|
|
<th class="text-left px-4 py-3 font-medium">Actor</th>
|
|
<th class="text-left px-4 py-3 font-medium">Action</th>
|
|
<th class="text-left px-4 py-3 font-medium">Target</th>
|
|
<th class="text-left px-4 py-3 font-medium">IP</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="auditTable"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const token = localStorage.getItem('ds_token');
|
|
const user = JSON.parse(localStorage.getItem('ds_user') || '{}');
|
|
|
|
if (!token || !user.is_super_admin) {
|
|
window.location.href = '/app/login';
|
|
}
|
|
|
|
function fetchAPI(url) {
|
|
return fetch(url, { headers: { 'Authorization': 'Bearer ' + token } }).then(r => {
|
|
if (r.status === 401) { localStorage.clear(); window.location.href = '/app/login'; }
|
|
return r.json();
|
|
});
|
|
}
|
|
|
|
function postAPI(url, body) {
|
|
return fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body)
|
|
}).then(r => r.json());
|
|
}
|
|
|
|
function formatDate(ts) {
|
|
if (!ts) return '—';
|
|
return new Date(ts).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
}
|
|
|
|
function formatDateTime(ts) {
|
|
if (!ts) return '—';
|
|
return new Date(ts).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
const d = document.createElement('div');
|
|
d.textContent = s || '';
|
|
return d.innerHTML;
|
|
}
|
|
|
|
// Tabs
|
|
document.querySelectorAll('[data-tab]').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
document.querySelectorAll('[data-tab]').forEach(b => {
|
|
b.className = b === btn ? 'tab-active pb-3 text-sm font-medium' : 'tab-inactive pb-3 text-sm font-medium';
|
|
});
|
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.add('hidden'));
|
|
document.getElementById('tab-' + btn.dataset.tab).classList.remove('hidden');
|
|
|
|
if (btn.dataset.tab === 'projects') loadProjects();
|
|
if (btn.dataset.tab === 'audit') loadAudit();
|
|
});
|
|
});
|
|
|
|
// Users
|
|
let allUsers = [];
|
|
async function loadUsers() {
|
|
allUsers = await fetchAPI('/api/admin/users');
|
|
renderUsers(allUsers);
|
|
}
|
|
|
|
function renderUsers(users) {
|
|
document.getElementById('userCount').textContent = users.length + ' user' + (users.length !== 1 ? 's' : '');
|
|
document.getElementById('usersTable').innerHTML = users.map(u => `
|
|
<tr class="border-b border-white/[0.05] hover:bg-white/[0.02]">
|
|
<td class="px-4 py-3 font-medium">${escapeHtml(u.name)}</td>
|
|
<td class="px-4 py-3 text-[#94a3b8]">${escapeHtml(u.email)}</td>
|
|
<td class="px-4 py-3 text-[#94a3b8]">${escapeHtml(u.org_name || u.org_id || '—')}</td>
|
|
<td class="px-4 py-3">
|
|
<span class="px-2 py-0.5 rounded-full text-xs ${u.active ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'}">
|
|
${u.active ? 'Active' : 'Inactive'}
|
|
</span>
|
|
</td>
|
|
<td class="px-4 py-3 text-[#94a3b8]">${formatDate(u.created_at)}</td>
|
|
<td class="px-4 py-3">
|
|
<button onclick="impersonate('${u.user_id}')" class="text-[#c9a84c] hover:underline text-xs">Impersonate</button>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
|
|
document.getElementById('userSearch').addEventListener('input', (e) => {
|
|
const q = e.target.value.toLowerCase();
|
|
const filtered = allUsers.filter(u =>
|
|
u.email.toLowerCase().includes(q) || u.name.toLowerCase().includes(q)
|
|
);
|
|
renderUsers(filtered);
|
|
});
|
|
|
|
async function impersonate(userId) {
|
|
if (!confirm('Impersonate this user? You will be signed in as them.')) return;
|
|
const data = await postAPI('/api/admin/impersonate', { user_id: userId });
|
|
if (data.token) {
|
|
localStorage.setItem('ds_token', data.token);
|
|
localStorage.setItem('ds_user', JSON.stringify(data.user));
|
|
window.location.href = '/app/tasks';
|
|
}
|
|
}
|
|
|
|
// Projects
|
|
async function loadProjects() {
|
|
const projects = await fetchAPI('/api/admin/projects');
|
|
document.getElementById('projectCount').textContent = projects.length + ' project' + (projects.length !== 1 ? 's' : '');
|
|
document.getElementById('projectsGrid').innerHTML = projects.map(p => {
|
|
let name = p.summary || 'Unnamed Project';
|
|
return `
|
|
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-5">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<h3 class="font-semibold text-white">${escapeHtml(name)}</h3>
|
|
<span class="text-xs px-2 py-0.5 rounded-full bg-[#c9a84c]/10 text-[#c9a84c]">${escapeHtml(p.stage)}</span>
|
|
</div>
|
|
<div class="text-sm text-[#94a3b8]">
|
|
<span>ID: ${p.entry_id.slice(0, 8)}...</span>
|
|
<span class="ml-4">Created: ${formatDate(p.created_at)}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
// Audit
|
|
async function loadAudit() {
|
|
const entries = await fetchAPI('/api/admin/audit');
|
|
document.getElementById('auditTable').innerHTML = entries.length === 0
|
|
? '<tr><td colspan="5" class="px-4 py-8 text-center text-[#94a3b8]">No audit events yet</td></tr>'
|
|
: entries.map(a => `
|
|
<tr class="border-b border-white/[0.05] hover:bg-white/[0.02]">
|
|
<td class="px-4 py-3 text-[#94a3b8]">${formatDateTime(a.ts)}</td>
|
|
<td class="px-4 py-3">${escapeHtml(a.actor_id ? a.actor_id.slice(0, 8) + '...' : '—')}</td>
|
|
<td class="px-4 py-3">${escapeHtml(a.action || '—')}</td>
|
|
<td class="px-4 py-3 text-[#94a3b8]">${escapeHtml(a.target_id ? a.target_id.slice(0, 8) + '...' : '—')}</td>
|
|
<td class="px-4 py-3 text-[#94a3b8]">${escapeHtml(a.ip || '—')}</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
|
|
// Logout
|
|
document.getElementById('logoutBtn').addEventListener('click', async () => {
|
|
try { await fetch('/api/auth/logout', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token } }); } catch {}
|
|
localStorage.clear();
|
|
window.location.href = '/app/login';
|
|
});
|
|
|
|
// Initial load
|
|
loadUsers();
|
|
</script>
|
|
</body>
|
|
</html>
|