🪻 distributed transcription service thistle.dunkirk.sh

feat: add an admin dashboard

dunkirk.sh 500f9e4a 6bde6424

verified
Changed files
+1018 -4
scripts
src
+93
CRUSH.md
···
# Build files
bun build <file.html|file.ts|file.css>
+
+
# Make a user an admin
+
bun scripts/make-admin.ts <email>
```
Development workflow: `bun dev` runs the server with hot module reloading. Changes to TypeScript, HTML, or CSS files automatically reload.
···
4. Components self-register as custom elements
5. Bun bundles everything automatically
+
## Database Schema & Migrations
+
+
Database migrations are managed in `src/db/schema.ts` using a versioned migration system.
+
+
**Migration structure:**
+
```typescript
+
const migrations = [
+
{
+
version: 1,
+
name: "Description of migration",
+
sql: `
+
CREATE TABLE IF NOT EXISTS ...;
+
CREATE INDEX IF NOT EXISTS ...;
+
`,
+
},
+
];
+
```
+
+
**Important migration rules:**
+
1. **Never modify existing migrations** - they may have already run in production
+
2. **Always add new migrations** with incrementing version numbers
+
3. **Drop indexes before dropping columns** - SQLite will error if you try to drop a column with an index still attached
+
4. **Use IF NOT EXISTS** for CREATE statements to be idempotent
+
5. **Test migrations** on a copy of production data before deploying
+
+
**Example: Dropping a column**
+
```sql
+
-- ❌ WRONG: Will error if idx_users_old_column exists
+
ALTER TABLE users DROP COLUMN old_column;
+
+
-- ✅ CORRECT: Drop index first, then column
+
DROP INDEX IF EXISTS idx_users_old_column;
+
ALTER TABLE users DROP COLUMN old_column;
+
```
+
+
**Migration workflow:**
+
1. Add migration to `migrations` array with next version number
+
2. Migrations auto-apply on server start
+
3. Check `schema_migrations` table to see applied versions
+
4. Migrations are transactional and show timing in console
+
## File Organization
- `src/index.ts`: Main server entry point with `Bun.serve()` routes
···
- [Web Components MDN](https://developer.mozilla.org/en-US/docs/Web/Web_Components)
- Bun API docs in `node_modules/bun-types/docs/**.md`
+
## Admin System
+
+
The application includes a role-based admin system for managing users and transcriptions.
+
+
**User roles:**
+
- `user` - Default role, can create and manage their own transcriptions
+
- `admin` - Full administrative access to all data and users
+
+
**Admin privileges:**
+
- View all transcriptions (with user info, status, errors)
+
- Delete transcriptions
+
- View all users (with emails, join dates, roles)
+
- Change user roles (user ↔ admin)
+
- Delete user accounts
+
- Access admin dashboard at `/admin`
+
+
**Making users admin:**
+
Use the provided script to grant admin access:
+
```bash
+
bun scripts/make-admin.ts user@example.com
+
```
+
+
**Admin routes:**
+
- `/admin` - Admin dashboard (protected by `requireAdmin` middleware)
+
- `/api/admin/transcriptions` - Get all transcriptions with user info
+
- `/api/admin/transcriptions/:id` - Delete a transcription (DELETE)
+
- `/api/admin/users` - Get all users
+
- `/api/admin/users/:id` - Delete a user account (DELETE)
+
- `/api/admin/users/:id/role` - Update a user's role (PUT)
+
+
**Admin UI features:**
+
- Statistics cards (total users, total/failed transcriptions)
+
- Tabbed interface (Transcriptions / Users)
+
- Status badges for transcription states
+
- Delete buttons for transcriptions with confirmation
+
- Role dropdown for changing user roles
+
- Delete buttons for user accounts with confirmation
+
- User avatars and info display
+
- Timestamp formatting
+
- Admin badge on user listings
+
+
**Implementation notes:**
+
- `role` column in users table ('user' or 'admin', default 'user')
+
- `requireAdmin()` middleware checks authentication + admin role
+
- Returns 403 if non-admin tries to access admin routes
+
- Admin link shows in auth menu only for admin users
+
- Redirects to home page if non-admin accesses admin page
+
## Future Additions
As the codebase grows, document:
···
- Transcription service integration details
- Deployment process
- Environment variables needed
+
+31
scripts/make-admin.ts
···
+
#!/usr/bin/env bun
+
+
import db from "../src/db/schema";
+
+
const email = process.argv[2];
+
+
if (!email) {
+
console.error("Usage: bun scripts/make-admin.ts <email>");
+
process.exit(1);
+
}
+
+
const user = db
+
.query<{ id: number; email: string; role: string }, [string]>(
+
"SELECT id, email, role FROM users WHERE email = ?",
+
)
+
.get(email);
+
+
if (!user) {
+
console.error(`User with email ${email} not found`);
+
process.exit(1);
+
}
+
+
if (user.role === "admin") {
+
console.log(`User ${email} is already an admin`);
+
process.exit(0);
+
}
+
+
db.run("UPDATE users SET role = 'admin' WHERE id = ?", [user.id]);
+
+
console.log(`✅ Successfully made ${email} an admin`);
+
console.log(` User should refresh their browser to see admin access`);
+17
src/components/auth.ts
···
email: string;
name: string | null;
avatar: string;
+
role?: "user" | "admin";
}
@customElement("auth-component")
···
background: var(--secondary);
}
+
.admin-link {
+
color: #dc2626;
+
border: 2px dashed #dc2626 !important;
+
}
+
+
.admin-link:hover {
+
background: #fee2e2;
+
color: #991b1b;
+
border-color: #991b1b !important;
+
}
+
.loading {
font-size: 0.875rem;
color: var(--text);
···
<div class="user-menu">
<a href="/transcribe" @click=${this.closeModal}>Transcribe</a>
<a href="/settings" @click=${this.closeModal}>Settings</a>
+
${
+
this.user.role === "admin"
+
? html`<a href="/admin" @click=${this.closeModal} class="admin-link">Admin</a>`
+
: ""
+
}
<button @click=${this.handleLogout}>Logout</button>
</div>
`
+11
src/db/schema.ts
···
CREATE INDEX IF NOT EXISTS idx_rate_limit_key_timestamp ON rate_limit_attempts(key, timestamp);
`,
},
+
{
+
version: 6,
+
name: "Add role-based auth system",
+
sql: `
+
-- Add role column (default to 'user')
+
ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user';
+
+
-- Create index on role
+
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
+
`,
+
},
];
function getCurrentVersion(): number {
+84 -1
src/index.ts
···
createSession,
createUser,
deleteSession,
+
deleteTranscription,
deleteUser,
+
getAllTranscriptions,
+
getAllUsers,
getSession,
getSessionFromRequest,
getUserBySession,
···
updateUserEmail,
updateUserName,
updateUserPassword,
+
updateUserRole,
+
type UserRole,
} from "./lib/auth";
import { handleError, ValidationErrors } from "./lib/errors";
-
import { requireAuth } from "./lib/middleware";
+
import { requireAdmin, requireAuth } from "./lib/middleware";
import { enforceRateLimit } from "./lib/rate-limit";
import {
MAX_FILE_SIZE,
···
} from "./lib/transcription";
import { getTranscript, getTranscriptVTT } from "./lib/transcript-storage";
import indexHTML from "./pages/index.html";
+
import adminHTML from "./pages/admin.html";
import settingsHTML from "./pages/settings.html";
import transcribeHTML from "./pages/transcribe.html";
···
idleTimeout: 120, // 120 seconds for SSE connections
routes: {
"/": indexHTML,
+
"/admin": adminHTML,
"/settings": settingsHTML,
"/transcribe": transcribeHTML,
"/api/auth/register": {
···
name: user.name,
avatar: user.avatar,
created_at: user.created_at,
+
role: user.role,
});
},
},
···
id: transcriptionId,
message: "Upload successful, transcription started",
});
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/admin/transcriptions": {
+
GET: async (req) => {
+
try {
+
requireAdmin(req);
+
const transcriptions = getAllTranscriptions();
+
return Response.json(transcriptions);
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/admin/users": {
+
GET: async (req) => {
+
try {
+
requireAdmin(req);
+
const users = getAllUsers();
+
return Response.json(users);
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/admin/transcriptions/:id": {
+
DELETE: async (req) => {
+
try {
+
requireAdmin(req);
+
const transcriptionId = req.params.id;
+
deleteTranscription(transcriptionId);
+
return Response.json({ success: true });
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/admin/users/:id": {
+
DELETE: async (req) => {
+
try {
+
requireAdmin(req);
+
const userId = Number.parseInt(req.params.id, 10);
+
if (Number.isNaN(userId)) {
+
return Response.json({ error: "Invalid user ID" }, { status: 400 });
+
}
+
deleteUser(userId);
+
return Response.json({ success: true });
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/admin/users/:id/role": {
+
PUT: async (req) => {
+
try {
+
requireAdmin(req);
+
const userId = Number.parseInt(req.params.id, 10);
+
if (Number.isNaN(userId)) {
+
return Response.json({ error: "Invalid user ID" }, { status: 400 });
+
}
+
+
const body = await req.json();
+
const { role } = body as { role: UserRole };
+
+
if (!role || (role !== "user" && role !== "admin")) {
+
return Response.json(
+
{ error: "Invalid role. Must be 'user' or 'admin'" },
+
{ status: 400 },
+
);
+
}
+
+
updateUserRole(userId, role);
+
return Response.json({ success: true });
} catch (error) {
return handleError(error);
}
+128 -3
src/lib/auth.ts
···
const SESSION_DURATION = 7 * 24 * 60 * 60; // 7 days in seconds
+
export type UserRole = "user" | "admin";
+
export interface User {
id: number;
email: string;
name: string | null;
avatar: string;
created_at: number;
+
role: UserRole;
}
export interface Session {
···
const user = db
.query<User, [number]>(
-
"SELECT id, email, name, avatar, created_at FROM users WHERE id = ?",
+
"SELECT id, email, name, avatar, created_at, role FROM users WHERE id = ?",
)
.get(session.user_id);
···
const user = db
.query<User, [number]>(
-
"SELECT id, email, name, avatar, created_at FROM users WHERE id = ?",
+
"SELECT id, email, name, avatar, created_at, role FROM users WHERE id = ?",
)
.get(Number(result.lastInsertRowid));
···
avatar: string;
password_hash: string;
created_at: number;
+
role: UserRole;
},
[string]
>(
-
"SELECT id, email, name, avatar, password_hash, created_at FROM users WHERE email = ?",
+
"SELECT id, email, name, avatar, password_hash, created_at, role FROM users WHERE email = ?",
)
.get(email);
···
name: result.name,
avatar: result.avatar,
created_at: result.created_at,
+
role: result.role,
};
}
···
]);
db.run("DELETE FROM sessions WHERE user_id = ?", [userId]);
}
+
+
export function isUserAdmin(userId: number): boolean {
+
const result = db
+
.query<{ role: UserRole }, [number]>("SELECT role FROM users WHERE id = ?")
+
.get(userId);
+
+
return result?.role === "admin";
+
}
+
+
export function updateUserRole(userId: number, role: UserRole): void {
+
db.run("UPDATE users SET role = ? WHERE id = ?", [role, userId]);
+
}
+
+
export function getAllUsers(): Array<{
+
id: number;
+
email: string;
+
name: string | null;
+
avatar: string;
+
created_at: number;
+
role: UserRole;
+
}> {
+
return db
+
.query<
+
{
+
id: number;
+
email: string;
+
name: string | null;
+
avatar: string;
+
created_at: number;
+
role: UserRole;
+
},
+
[]
+
>("SELECT id, email, name, avatar, created_at, role FROM users ORDER BY created_at DESC")
+
.all();
+
}
+
+
export function getAllTranscriptions(): Array<{
+
id: string;
+
user_id: number;
+
user_email: string;
+
user_name: string | null;
+
original_filename: string;
+
status: string;
+
created_at: number;
+
error_message: string | null;
+
}> {
+
return db
+
.query<
+
{
+
id: string;
+
user_id: number;
+
user_email: string;
+
user_name: string | null;
+
original_filename: string;
+
status: string;
+
created_at: number;
+
error_message: string | null;
+
},
+
[]
+
>(
+
`SELECT
+
t.id,
+
t.user_id,
+
u.email as user_email,
+
u.name as user_name,
+
t.original_filename,
+
t.status,
+
t.created_at,
+
t.error_message
+
FROM transcriptions t
+
LEFT JOIN users u ON t.user_id = u.id
+
ORDER BY t.created_at DESC`,
+
)
+
.all();
+
}
+
+
export function deleteTranscription(transcriptionId: string): void {
+
const transcription = db
+
.query<{ id: string; filename: string }, [string]>(
+
"SELECT id, filename FROM transcriptions WHERE id = ?",
+
)
+
.get(transcriptionId);
+
+
if (!transcription) {
+
throw new Error("Transcription not found");
+
}
+
+
// Delete database record
+
db.run("DELETE FROM transcriptions WHERE id = ?", [transcriptionId]);
+
+
// Delete files (audio file and transcript files)
+
try {
+
const audioPath = `./uploads/${transcription.filename}`;
+
const transcriptPath = `./transcripts/${transcriptionId}.txt`;
+
const vttPath = `./transcripts/${transcriptionId}.vtt`;
+
+
if (Bun.file(audioPath).size) {
+
Bun.write(audioPath, "").then(() => {
+
// File deleted by overwriting with empty content, then unlink
+
import("node:fs").then((fs) => {
+
fs.unlinkSync(audioPath);
+
});
+
});
+
}
+
+
if (Bun.file(transcriptPath).size) {
+
import("node:fs").then((fs) => {
+
fs.unlinkSync(transcriptPath);
+
});
+
}
+
+
if (Bun.file(vttPath).size) {
+
import("node:fs").then((fs) => {
+
fs.unlinkSync(vttPath);
+
});
+
}
+
} catch {
+
// Files might not exist, ignore errors
+
}
+
}
+2
src/lib/errors.ts
···
"Email already registered",
400,
),
+
adminRequired: () =>
+
new AppError(ErrorCode.AUTH_REQUIRED, "Admin access required", 403),
};
export const ValidationErrors = {
+10
src/lib/middleware.ts
···
return user;
}
+
+
export function requireAdmin(req: Request): User {
+
const user = requireAuth(req);
+
+
if (user.role !== "admin") {
+
throw AuthErrors.adminRequired();
+
}
+
+
return user;
+
}
+642
src/pages/admin.html
···
+
<!DOCTYPE html>
+
<html lang="en">
+
+
<head>
+
<meta charset="UTF-8">
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
+
<title>Admin - Thistle</title>
+
<link rel="icon"
+
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>🪻</text></svg>">
+
<link rel="stylesheet" href="../styles/main.css">
+
<style>
+
main {
+
max-width: 80rem;
+
margin: 0 auto;
+
padding: 2rem;
+
}
+
+
h1 {
+
margin-bottom: 2rem;
+
color: var(--text);
+
}
+
+
.section {
+
margin-bottom: 3rem;
+
}
+
+
.section-title {
+
font-size: 1.5rem;
+
font-weight: 600;
+
color: var(--text);
+
margin-bottom: 1rem;
+
display: flex;
+
align-items: center;
+
gap: 0.5rem;
+
}
+
+
.tabs {
+
display: flex;
+
gap: 1rem;
+
border-bottom: 2px solid var(--secondary);
+
margin-bottom: 2rem;
+
}
+
+
.tab {
+
padding: 0.75rem 1.5rem;
+
border: none;
+
background: transparent;
+
color: var(--text);
+
cursor: pointer;
+
font-size: 1rem;
+
font-weight: 500;
+
font-family: inherit;
+
border-bottom: 2px solid transparent;
+
margin-bottom: -2px;
+
transition: all 0.2s;
+
}
+
+
.tab:hover {
+
color: var(--primary);
+
}
+
+
.tab.active {
+
color: var(--primary);
+
border-bottom-color: var(--primary);
+
}
+
+
.tab-content {
+
display: none;
+
}
+
+
.tab-content.active {
+
display: block;
+
}
+
+
table {
+
width: 100%;
+
border-collapse: collapse;
+
background: var(--background);
+
border: 2px solid var(--secondary);
+
border-radius: 8px;
+
overflow: hidden;
+
}
+
+
thead {
+
background: var(--primary);
+
color: white;
+
}
+
+
th {
+
padding: 1rem;
+
text-align: left;
+
font-weight: 600;
+
}
+
+
td {
+
padding: 1rem;
+
border-top: 1px solid var(--secondary);
+
color: var(--text);
+
}
+
+
tr:hover {
+
background: rgba(0, 0, 0, 0.02);
+
}
+
+
.status-badge {
+
display: inline-block;
+
padding: 0.25rem 0.75rem;
+
border-radius: 4px;
+
font-size: 0.875rem;
+
font-weight: 500;
+
}
+
+
.status-completed {
+
background: #dcfce7;
+
color: #166534;
+
}
+
+
.status-processing,
+
.status-uploading {
+
background: #fef3c7;
+
color: #92400e;
+
}
+
+
.status-failed {
+
background: #fee2e2;
+
color: #991b1b;
+
}
+
+
.status-pending {
+
background: #e0e7ff;
+
color: #3730a3;
+
}
+
+
.admin-badge {
+
background: var(--accent);
+
color: white;
+
padding: 0.25rem 0.5rem;
+
border-radius: 4px;
+
font-size: 0.75rem;
+
font-weight: 600;
+
margin-left: 0.5rem;
+
}
+
+
.user-info {
+
display: flex;
+
align-items: center;
+
gap: 0.5rem;
+
}
+
+
.user-avatar {
+
width: 2rem;
+
height: 2rem;
+
border-radius: 50%;
+
}
+
+
.empty-state {
+
text-align: center;
+
padding: 3rem;
+
color: var(--text);
+
opacity: 0.6;
+
}
+
+
.loading {
+
text-align: center;
+
padding: 3rem;
+
color: var(--text);
+
}
+
+
.error {
+
background: #fee2e2;
+
color: #991b1b;
+
padding: 1rem;
+
border-radius: 6px;
+
margin-bottom: 1rem;
+
}
+
+
.stats {
+
display: grid;
+
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
+
gap: 1rem;
+
margin-bottom: 2rem;
+
}
+
+
.stat-card {
+
background: var(--background);
+
border: 2px solid var(--secondary);
+
border-radius: 8px;
+
padding: 1.5rem;
+
}
+
+
.stat-value {
+
font-size: 2rem;
+
font-weight: 700;
+
color: var(--primary);
+
margin-bottom: 0.25rem;
+
}
+
+
.stat-label {
+
color: var(--text);
+
opacity: 0.7;
+
font-size: 0.875rem;
+
}
+
+
.timestamp {
+
color: var(--text);
+
opacity: 0.6;
+
font-size: 0.875rem;
+
}
+
+
.delete-btn {
+
background: transparent;
+
border: 2px solid #dc2626;
+
color: #dc2626;
+
padding: 0.25rem 0.75rem;
+
border-radius: 4px;
+
cursor: pointer;
+
font-size: 0.875rem;
+
font-weight: 500;
+
font-family: inherit;
+
transition: all 0.2s;
+
}
+
+
.delete-btn:hover {
+
background: #dc2626;
+
color: white;
+
}
+
+
.delete-btn:disabled {
+
opacity: 0.5;
+
cursor: not-allowed;
+
}
+
+
.actions {
+
display: flex;
+
gap: 0.5rem;
+
}
+
+
.role-select {
+
padding: 0.25rem 0.5rem;
+
border: 2px solid var(--secondary);
+
border-radius: 4px;
+
font-size: 0.875rem;
+
font-family: inherit;
+
background: var(--background);
+
color: var(--text);
+
cursor: pointer;
+
}
+
+
.role-select:focus {
+
outline: none;
+
border-color: var(--primary);
+
}
+
+
.delete-user-btn {
+
background: transparent;
+
border: 2px solid #dc2626;
+
color: #dc2626;
+
padding: 0.25rem 0.75rem;
+
border-radius: 4px;
+
cursor: pointer;
+
font-size: 0.875rem;
+
font-weight: 500;
+
font-family: inherit;
+
transition: all 0.2s;
+
}
+
+
.delete-user-btn:hover {
+
background: #dc2626;
+
color: white;
+
}
+
+
.delete-user-btn:disabled {
+
opacity: 0.5;
+
cursor: not-allowed;
+
}
+
</style>
+
</head>
+
+
<body>
+
<header>
+
<div class="header-content">
+
<a href="/" class="site-title">
+
<span>🪻</span>
+
<span>Thistle</span>
+
</a>
+
<auth-component></auth-component>
+
</div>
+
</header>
+
+
<main>
+
<h1>Admin Dashboard</h1>
+
+
<div id="error-message" class="error" style="display: none;"></div>
+
+
<div id="loading" class="loading">Loading...</div>
+
+
<div id="content" style="display: none;">
+
<div class="stats">
+
<div class="stat-card">
+
<div class="stat-value" id="total-users">0</div>
+
<div class="stat-label">Total Users</div>
+
</div>
+
<div class="stat-card">
+
<div class="stat-value" id="total-transcriptions">0</div>
+
<div class="stat-label">Total Transcriptions</div>
+
</div>
+
<div class="stat-card">
+
<div class="stat-value" id="failed-transcriptions">0</div>
+
<div class="stat-label">Failed Transcriptions</div>
+
</div>
+
</div>
+
+
<div class="tabs">
+
<button class="tab active" data-tab="transcriptions">Transcriptions</button>
+
<button class="tab" data-tab="users">Users</button>
+
</div>
+
+
<div id="transcriptions-tab" class="tab-content active">
+
<div class="section">
+
<h2 class="section-title">All Transcriptions</h2>
+
<div id="transcriptions-table"></div>
+
</div>
+
</div>
+
+
<div id="users-tab" class="tab-content">
+
<div class="section">
+
<h2 class="section-title">All Users</h2>
+
<div id="users-table"></div>
+
</div>
+
</div>
+
</div>
+
</main>
+
+
<script type="module" src="../components/auth.ts"></script>
+
<script type="module">
+
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');
+
+
let currentUserEmail = null;
+
+
// Get current user info
+
async function getCurrentUser() {
+
try {
+
const res = await fetch('/api/auth/me');
+
if (res.ok) {
+
const user = await res.json();
+
currentUserEmail = user.email;
+
}
+
} catch {
+
// Ignore errors
+
}
+
}
+
+
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 renderTranscriptions(transcriptions) {
+
if (transcriptions.length === 0) {
+
transcriptionsTable.innerHTML = '<div class="empty-state">No transcriptions yet</div>';
+
return;
+
}
+
+
const failed = transcriptions.filter(t => t.status === 'failed');
+
document.getElementById('failed-transcriptions').textContent = failed.length;
+
+
const table = document.createElement('table');
+
table.innerHTML = `
+
<thead>
+
<tr>
+
<th>File Name</th>
+
<th>User</th>
+
<th>Status</th>
+
<th>Created At</th>
+
<th>Error</th>
+
<th>Actions</th>
+
</tr>
+
</thead>
+
<tbody>
+
${transcriptions.map(t => `
+
<tr>
+
<td>${t.original_filename}</td>
+
<td>
+
<div class="user-info">
+
<img
+
src="https://hostedboringavatars.vercel.app/api/marble?size=32&name=${t.user_id}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff"
+
alt="Avatar"
+
class="user-avatar"
+
/>
+
<span>${t.user_name || t.user_email}</span>
+
</div>
+
</td>
+
<td><span class="status-badge status-${t.status}">${t.status}</span></td>
+
<td class="timestamp">${formatTimestamp(t.created_at)}</td>
+
<td>${t.error_message || '-'}</td>
+
<td>
+
<button class="delete-btn" data-id="${t.id}">Delete</button>
+
</td>
+
</tr>
+
`).join('')}
+
</tbody>
+
`;
+
transcriptionsTable.innerHTML = '';
+
transcriptionsTable.appendChild(table);
+
+
// Add delete event listeners
+
table.querySelectorAll('.delete-btn').forEach(btn => {
+
btn.addEventListener('click', async (e) => {
+
const button = e.target;
+
const id = button.dataset.id;
+
+
if (!confirm('Are you sure you want to delete this transcription? This cannot be undone.')) {
+
return;
+
}
+
+
button.disabled = true;
+
button.textContent = 'Deleting...';
+
+
try {
+
const res = await fetch(`/api/admin/transcriptions/${id}`, {
+
method: 'DELETE'
+
});
+
+
if (!res.ok) {
+
throw new Error('Failed to delete');
+
}
+
+
// Reload data
+
await loadData();
+
} catch {
+
alert('Failed to delete transcription');
+
button.disabled = false;
+
button.textContent = 'Delete';
+
}
+
});
+
});
+
}
+
+
function renderUsers(users) {
+
if (users.length === 0) {
+
usersTable.innerHTML = '<div class="empty-state">No users yet</div>';
+
return;
+
}
+
+
const table = document.createElement('table');
+
table.innerHTML = `
+
<thead>
+
<tr>
+
<th>User</th>
+
<th>Email</th>
+
<th>Role</th>
+
<th>Joined</th>
+
<th>Actions</th>
+
</tr>
+
</thead>
+
<tbody>
+
${users.map(u => `
+
<tr>
+
<td>
+
<div class="user-info">
+
<img
+
src="https://hostedboringavatars.vercel.app/api/marble?size=32&name=${u.avatar}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff"
+
alt="Avatar"
+
class="user-avatar"
+
/>
+
<span>${u.name || 'Anonymous'}</span>
+
${u.role === 'admin' ? '<span class="admin-badge">ADMIN</span>' : ''}
+
</div>
+
</td>
+
<td>${u.email}</td>
+
<td>
+
<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>
+
</select>
+
</td>
+
<td class="timestamp">${formatTimestamp(u.created_at)}</td>
+
<td>
+
<button class="delete-user-btn" data-user-id="${u.id}" data-user-email="${u.email}">Delete</button>
+
</td>
+
</tr>
+
`).join('')}
+
</tbody>
+
`;
+
usersTable.innerHTML = '';
+
usersTable.appendChild(table);
+
+
// 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 (isDemotingSelf) {
+
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;
+
return;
+
}
+
+
if (!confirm('⚠️ FINAL WARNING: This action cannot be undone by you. Another admin will need to restore your admin access. Continue?')) {
+
selectEl.value = oldRole;
+
return;
+
}
+
} else {
+
if (!confirm(`Change user role to ${newRole}?`)) {
+
selectEl.value = oldRole;
+
return;
+
}
+
}
+
+
try {
+
const res = await fetch(`/api/admin/users/${userId}/role`, {
+
method: 'PUT',
+
headers: { 'Content-Type': 'application/json' },
+
body: JSON.stringify({ role: newRole })
+
});
+
+
if (!res.ok) {
+
throw new Error('Failed to update role');
+
}
+
+
selectEl.dataset.currentRole = newRole;
+
+
// If demoting self, redirect to home
+
if (isDemotingSelf) {
+
window.location.href = '/';
+
} else {
+
await loadData();
+
}
+
} catch {
+
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.`)) {
+
return;
+
}
+
+
button.disabled = true;
+
button.textContent = 'Deleting...';
+
+
try {
+
const res = await fetch(`/api/admin/users/${userId}`, {
+
method: 'DELETE'
+
});
+
+
if (!res.ok) {
+
throw new Error('Failed to delete user');
+
}
+
+
await loadData();
+
} catch {
+
alert('Failed to delete user');
+
button.disabled = false;
+
button.textContent = 'Delete';
+
}
+
});
+
});
+
}
+
+
async function loadData() {
+
try {
+
const [transcriptionsRes, usersRes] = await Promise.all([
+
fetch('/api/admin/transcriptions'),
+
fetch('/api/admin/users')
+
]);
+
+
if (!transcriptionsRes.ok || !usersRes.ok) {
+
if (transcriptionsRes.status === 403 || usersRes.status === 403) {
+
window.location.href = '/';
+
return;
+
}
+
throw new Error('Failed to load admin data');
+
}
+
+
const transcriptions = await transcriptionsRes.json();
+
const users = await usersRes.json();
+
+
document.getElementById('total-users').textContent = users.length;
+
document.getElementById('total-transcriptions').textContent = transcriptions.length;
+
+
renderTranscriptions(transcriptions);
+
renderUsers(users);
+
+
loading.style.display = 'none';
+
content.style.display = 'block';
+
} catch (error) {
+
showError(error.message);
+
}
+
}
+
+
// Tab switching
+
document.querySelectorAll('.tab').forEach(tab => {
+
tab.addEventListener('click', () => {
+
const tabName = tab.dataset.tab;
+
+
document.querySelectorAll('.tab').forEach(t => {
+
t.classList.remove('active');
+
});
+
document.querySelectorAll('.tab-content').forEach(c => {
+
c.classList.remove('active');
+
});
+
+
tab.classList.add('active');
+
document.getElementById(`${tabName}-tab`).classList.add('active');
+
});
+
});
+
+
// Initialize
+
getCurrentUser().then(() => loadData());
+
</script>
+
</body>
+
+
</html>