···
-
border-collapse: collapse;
-
background: var(--background);
-
border: 2px solid var(--secondary);
-
background: var(--primary);
-
border-top: 1px solid var(--secondary);
-
background: rgba(0, 0, 0, 0.02);
-
padding: 0.25rem 0.75rem;
-
background: var(--accent);
-
padding: 0.25rem 0.5rem;
···
-
background: transparent;
-
border: 2px solid #dc2626;
-
padding: 0.25rem 0.75rem;
-
padding: 0.25rem 0.5rem;
-
border: 2px solid var(--secondary);
-
background: var(--background);
-
border-color: var(--primary);
-
background: transparent;
-
border: 2px solid #dc2626;
-
padding: 0.25rem 0.75rem;
-
.delete-user-btn:hover {
-
.delete-user-btn:disabled {
-
.users-table tbody tr {
-
.users-table tbody tr:hover {
-
background: rgba(0, 0, 0, 0.04);
-
.transcriptions-table tbody tr {
-
.transcriptions-table tbody tr:hover {
-
background: rgba(0, 0, 0, 0.04);
-
padding: 0.5rem 0.75rem;
-
border: 2px solid var(--secondary);
-
background: var(--background);
-
border-color: var(--primary);
-
background: var(--gunmetal);
-
th.sortable.asc::after {
-
th.sortable.desc::after {
···
<div id="transcriptions-tab" class="tab-content">
<h2 class="section-title">All Transcriptions</h2>
-
<input type="text" id="transcript-search" class="search" placeholder="Search by filename or user..." />
-
<div id="transcriptions-table" class="transcriptions-table"></div>
<div id="users-tab" class="tab-content">
<h2 class="section-title">All Users</h2>
-
<input type="text" id="user-search" class="search" placeholder="Search by name or email..." />
-
<div id="users-table" class="users-table"></div>
···
<script type="module" src="../components/auth.ts"></script>
<script type="module" src="../components/admin-pending-recordings.ts"></script>
<script type="module" src="../components/user-modal.ts"></script>
<script type="module" src="../components/transcript-view-modal.ts"></script>
const errorMessage = document.getElementById('error-message');
const loading = document.getElementById('loading');
const content = document.getElementById('content');
-
const transcriptionsTable = document.getElementById('transcriptions-table');
-
const usersTable = document.getElementById('users-table');
-
const userModal = document.getElementById('user-modal');
-
const transcriptModal = document.getElementById('transcript-modal');
-
let currentUserEmail = null;
-
let allTranscriptions = [];
-
let userSortKey = 'created_at';
-
let userSortDirection = 'desc';
-
let userSearchTerm = '';
-
let transcriptSortKey = 'created_at';
-
let transcriptSortDirection = 'desc';
-
let transcriptSearchTerm = '';
-
// Get current user info
-
async function getCurrentUser() {
-
const res = await fetch('/api/auth/me');
-
const user = await res.json();
-
currentUserEmail = user.email;
-
function showError(message) {
-
errorMessage.textContent = message;
-
errorMessage.style.display = 'block';
-
loading.style.display = 'none';
-
function formatTimestamp(timestamp) {
-
const date = new Date(timestamp * 1000);
-
return date.toLocaleString();
function openUserModal(userId) {
···
transcriptModal.transcriptId = null;
-
// Listen for modal close and user update events
userModal.addEventListener('close', closeUserModal);
-
userModal.addEventListener('user-updated', () => loadData());
userModal.addEventListener('click', (e) => {
-
if (e.target === userModal) {
-
// Listen for transcript modal events
transcriptModal.addEventListener('close', closeTranscriptModal);
-
transcriptModal.addEventListener('transcript-deleted', () => loadData());
transcriptModal.addEventListener('click', (e) => {
-
if (e.target === transcriptModal) {
-
closeTranscriptModal();
-
function renderTranscriptions(transcriptions) {
-
allTranscriptions = transcriptions;
-
// Filter transcriptions based on search term
-
const filteredTranscriptions = transcriptions.filter(t => {
-
if (!transcriptSearchTerm) return true;
-
const term = transcriptSearchTerm.toLowerCase();
-
const filename = (t.original_filename || '').toLowerCase();
-
const userName = (t.user_name || '').toLowerCase();
-
const userEmail = (t.user_email || '').toLowerCase();
-
return filename.includes(term) || userName.includes(term) || userEmail.includes(term);
-
filteredTranscriptions.sort((a, b) => {
-
let aVal = a[transcriptSortKey];
-
let bVal = b[transcriptSortKey];
-
if (aVal === null || aVal === undefined) aVal = '';
-
if (bVal === null || bVal === undefined) bVal = '';
-
if (typeof aVal === 'string' && typeof bVal === 'string') {
-
comparison = aVal.localeCompare(bVal);
-
} else if (typeof aVal === 'number' && typeof bVal === 'number') {
-
comparison = aVal - bVal;
-
comparison = String(aVal).localeCompare(String(bVal));
-
return transcriptSortDirection === 'asc' ? comparison : -comparison;
-
if (filteredTranscriptions.length === 0) {
-
transcriptionsTable.innerHTML = '<div class="empty-state">No transcriptions found</div>';
-
const failed = transcriptions.filter(t => t.status === 'failed');
-
document.getElementById('failed-transcriptions').textContent = failed.length;
-
const table = document.createElement('table');
-
<th class="sortable ${transcriptSortKey === 'original_filename' ? transcriptSortDirection : ''}" data-sort="original_filename">File Name</th>
-
<th class="sortable ${transcriptSortKey === 'status' ? transcriptSortDirection : ''}" data-sort="status">Status</th>
-
<th class="sortable ${transcriptSortKey === 'created_at' ? transcriptSortDirection : ''}" data-sort="created_at">Created At</th>
-
${filteredTranscriptions.map(t => `
-
<td>${t.original_filename}</td>
-
<div class="user-info">
-
src="https://hostedboringavatars.vercel.app/api/marble?size=32&name=${t.user_id}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff"
-
<span>${t.user_name || t.user_email}</span>
-
<td><span class="status-badge status-${t.status}">${t.status}</span></td>
-
<td class="timestamp">${formatTimestamp(t.created_at)}</td>
-
<button class="delete-btn" data-id="${t.id}">Delete</button>
-
transcriptionsTable.innerHTML = '';
-
transcriptionsTable.appendChild(table);
-
// Add sort event listeners
-
table.querySelectorAll('th.sortable').forEach(th => {
-
th.addEventListener('click', () => {
-
const sortKey = th.dataset.sort;
-
if (transcriptSortKey === sortKey) {
-
transcriptSortDirection = transcriptSortDirection === 'asc' ? 'desc' : 'asc';
-
transcriptSortKey = sortKey;
-
transcriptSortDirection = 'asc';
-
renderTranscriptions(allTranscriptions);
-
// Add delete event listeners
-
table.querySelectorAll('.delete-btn').forEach(btn => {
-
btn.addEventListener('click', async (e) => {
-
e.stopPropagation(); // Prevent row click
-
const button = e.target;
-
const id = button.dataset.id;
-
if (!confirm('Are you sure you want to delete this transcription? This cannot be undone.')) {
-
button.disabled = true;
-
button.textContent = 'Deleting...';
-
const res = await fetch(`/api/admin/transcriptions/${id}`, {
-
throw new Error('Failed to delete');
-
alert('Failed to delete transcription');
-
button.disabled = false;
-
button.textContent = 'Delete';
-
// Add click event to table rows to open modal
-
table.querySelectorAll('tbody tr').forEach(row => {
-
row.addEventListener('click', (e) => {
-
// Don't open modal if clicking on delete button
-
if (e.target.closest('.delete-btn')) {
-
const transcriptId = row.dataset.id;
-
openTranscriptModal(transcriptId);
-
function renderUsers(users) {
-
// Filter users based on search term
-
const filteredUsers = users.filter(u => {
-
if (!userSearchTerm) return true;
-
const term = userSearchTerm.toLowerCase();
-
const name = (u.name || '').toLowerCase();
-
const email = u.email.toLowerCase();
-
return name.includes(term) || email.includes(term);
-
filteredUsers.sort((a, b) => {
-
let aVal = a[userSortKey];
-
let bVal = b[userSortKey];
-
if (aVal === null || aVal === undefined) aVal = '';
-
if (bVal === null || bVal === undefined) bVal = '';
-
if (typeof aVal === 'string' && typeof bVal === 'string') {
-
comparison = aVal.localeCompare(bVal);
-
} else if (typeof aVal === 'number' && typeof bVal === 'number') {
-
comparison = aVal - bVal;
-
comparison = String(aVal).localeCompare(String(bVal));
-
return userSortDirection === 'asc' ? comparison : -comparison;
-
if (filteredUsers.length === 0) {
-
usersTable.innerHTML = '<div class="empty-state">No users found</div>';
-
const table = document.createElement('table');
-
<th class="sortable ${userSortKey === 'name' ? userSortDirection : ''}" data-sort="name">User</th>
-
<th class="sortable ${userSortKey === 'email' ? userSortDirection : ''}" data-sort="email">Email</th>
-
<th class="sortable ${userSortKey === 'role' ? userSortDirection : ''}" data-sort="role">Role</th>
-
<th class="sortable ${userSortKey === 'transcription_count' ? userSortDirection : ''}" data-sort="transcription_count">Transcriptions</th>
-
<th class="sortable ${userSortKey === 'last_login' ? userSortDirection : ''}" data-sort="last_login">Last Login</th>
-
<th class="sortable ${userSortKey === 'created_at' ? userSortDirection : ''}" data-sort="created_at">Joined</th>
-
${filteredUsers.map(u => `
-
<div class="user-info">
-
src="https://hostedboringavatars.vercel.app/api/marble?size=32&name=${u.avatar}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff"
-
<span>${u.name || 'Anonymous'}</span>
-
${u.role === 'admin' ? '<span class="admin-badge">ADMIN</span>' : ''}
-
<select class="role-select" data-user-id="${u.id}" data-current-role="${u.role}">
-
<option value="user" ${u.role === 'user' ? 'selected' : ''}>User</option>
-
<option value="admin" ${u.role === 'admin' ? 'selected' : ''}>Admin</option>
-
<td>${u.transcription_count}</td>
-
<td class="timestamp">${u.last_login ? formatTimestamp(u.last_login) : 'Never'}</td>
-
<td class="timestamp">${formatTimestamp(u.created_at)}</td>
-
<button class="delete-user-btn" data-user-id="${u.id}" data-user-email="${u.email}">Delete</button>
-
usersTable.innerHTML = '';
-
usersTable.appendChild(table);
-
// Add sort event listeners
-
table.querySelectorAll('th.sortable').forEach(th => {
-
th.addEventListener('click', () => {
-
const sortKey = th.dataset.sort;
-
if (userSortKey === sortKey) {
-
userSortDirection = userSortDirection === 'asc' ? 'desc' : 'asc';
-
userSortDirection = 'asc';
-
// Add role change event listeners
-
table.querySelectorAll('.role-select').forEach(select => {
-
select.addEventListener('change', async (e) => {
-
const selectEl = e.target;
-
const userId = selectEl.dataset.userId;
-
const newRole = selectEl.value;
-
const oldRole = selectEl.dataset.currentRole;
-
// Get user email from the row
-
const row = selectEl.closest('tr');
-
const userEmail = row.querySelector('td:nth-child(2)').textContent;
-
// Check if user is demoting themselves
-
const isDemotingSelf = userEmail === currentUserEmail && oldRole === 'admin' && newRole === 'user';
-
if (!confirm('⚠️ WARNING: You are about to demote yourself from admin to user. You will lose access to this admin panel immediately. Are you sure?')) {
-
selectEl.value = oldRole;
-
if (!confirm('⚠️ FINAL WARNING: This action cannot be undone by you. Another admin will need to restore your admin access. Continue?')) {
-
selectEl.value = oldRole;
-
if (!confirm(`Change user role to ${newRole}?`)) {
-
selectEl.value = oldRole;
-
const res = await fetch(`/api/admin/users/${userId}/role`, {
-
headers: {'Content-Type': 'application/json'},
-
body: JSON.stringify({role: newRole})
-
throw new Error('Failed to update role');
-
selectEl.dataset.currentRole = newRole;
-
// If demoting self, redirect to home
-
window.location.href = '/';
-
alert('Failed to update user role');
-
selectEl.value = oldRole;
-
// Add delete user event listeners
-
table.querySelectorAll('.delete-user-btn').forEach(btn => {
-
btn.addEventListener('click', async (e) => {
-
const button = e.target;
-
const userId = button.dataset.userId;
-
const userEmail = button.dataset.userEmail;
-
if (!confirm(`Are you sure you want to delete user ${userEmail}? This will delete all their transcriptions and cannot be undone.`)) {
-
button.disabled = true;
-
button.textContent = 'Deleting...';
-
const res = await fetch(`/api/admin/users/${userId}`, {
-
throw new Error('Failed to delete user');
-
alert('Failed to delete user');
-
button.disabled = false;
-
button.textContent = 'Delete';
-
// Add click event to table rows to open modal
-
table.querySelectorAll('tbody tr').forEach(row => {
-
row.addEventListener('click', (e) => {
-
// Don't open modal if clicking on delete button or role select
-
if (e.target.closest('.delete-user-btn') || e.target.closest('.role-select')) {
-
const userId = row.querySelector('.delete-user-btn').dataset.userId;
-
async function loadData() {
const [transcriptionsRes, usersRes] = await Promise.all([
fetch('/api/admin/transcriptions'),
···
document.getElementById('total-users').textContent = users.length;
document.getElementById('total-transcriptions').textContent = transcriptions.length;
-
renderTranscriptions(transcriptions);
loading.style.display = 'none';
content.style.display = 'block';
-
showError(error.message);
···
-
document.getElementById('user-search').addEventListener('input', (e) => {
-
userSearchTerm = e.target.value.trim();
-
document.getElementById('transcript-search').addEventListener('input', (e) => {
-
transcriptSearchTerm = e.target.value.trim();
-
renderTranscriptions(allTranscriptions);
-
getCurrentUser().then(() => loadData());