🪻 distributed transcription service thistle.dunkirk.sh
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add an admin dashboard

+1018 -4
+93
CRUSH.md
··· 79 79 80 80 # Build files 81 81 bun build <file.html|file.ts|file.css> 82 + 83 + # Make a user an admin 84 + bun scripts/make-admin.ts <email> 82 85 ``` 83 86 84 87 Development workflow: `bun dev` runs the server with hot module reloading. Changes to TypeScript, HTML, or CSS files automatically reload. ··· 403 406 4. Components self-register as custom elements 404 407 5. Bun bundles everything automatically 405 408 409 + ## Database Schema & Migrations 410 + 411 + Database migrations are managed in `src/db/schema.ts` using a versioned migration system. 412 + 413 + **Migration structure:** 414 + ```typescript 415 + const migrations = [ 416 + { 417 + version: 1, 418 + name: "Description of migration", 419 + sql: ` 420 + CREATE TABLE IF NOT EXISTS ...; 421 + CREATE INDEX IF NOT EXISTS ...; 422 + `, 423 + }, 424 + ]; 425 + ``` 426 + 427 + **Important migration rules:** 428 + 1. **Never modify existing migrations** - they may have already run in production 429 + 2. **Always add new migrations** with incrementing version numbers 430 + 3. **Drop indexes before dropping columns** - SQLite will error if you try to drop a column with an index still attached 431 + 4. **Use IF NOT EXISTS** for CREATE statements to be idempotent 432 + 5. **Test migrations** on a copy of production data before deploying 433 + 434 + **Example: Dropping a column** 435 + ```sql 436 + -- ❌ WRONG: Will error if idx_users_old_column exists 437 + ALTER TABLE users DROP COLUMN old_column; 438 + 439 + -- ✅ CORRECT: Drop index first, then column 440 + DROP INDEX IF EXISTS idx_users_old_column; 441 + ALTER TABLE users DROP COLUMN old_column; 442 + ``` 443 + 444 + **Migration workflow:** 445 + 1. Add migration to `migrations` array with next version number 446 + 2. Migrations auto-apply on server start 447 + 3. Check `schema_migrations` table to see applied versions 448 + 4. Migrations are transactional and show timing in console 449 + 406 450 ## File Organization 407 451 408 452 - `src/index.ts`: Main server entry point with `Bun.serve()` routes ··· 485 529 - [Web Components MDN](https://developer.mozilla.org/en-US/docs/Web/Web_Components) 486 530 - Bun API docs in `node_modules/bun-types/docs/**.md` 487 531 532 + ## Admin System 533 + 534 + The application includes a role-based admin system for managing users and transcriptions. 535 + 536 + **User roles:** 537 + - `user` - Default role, can create and manage their own transcriptions 538 + - `admin` - Full administrative access to all data and users 539 + 540 + **Admin privileges:** 541 + - View all transcriptions (with user info, status, errors) 542 + - Delete transcriptions 543 + - View all users (with emails, join dates, roles) 544 + - Change user roles (user ↔ admin) 545 + - Delete user accounts 546 + - Access admin dashboard at `/admin` 547 + 548 + **Making users admin:** 549 + Use the provided script to grant admin access: 550 + ```bash 551 + bun scripts/make-admin.ts user@example.com 552 + ``` 553 + 554 + **Admin routes:** 555 + - `/admin` - Admin dashboard (protected by `requireAdmin` middleware) 556 + - `/api/admin/transcriptions` - Get all transcriptions with user info 557 + - `/api/admin/transcriptions/:id` - Delete a transcription (DELETE) 558 + - `/api/admin/users` - Get all users 559 + - `/api/admin/users/:id` - Delete a user account (DELETE) 560 + - `/api/admin/users/:id/role` - Update a user's role (PUT) 561 + 562 + **Admin UI features:** 563 + - Statistics cards (total users, total/failed transcriptions) 564 + - Tabbed interface (Transcriptions / Users) 565 + - Status badges for transcription states 566 + - Delete buttons for transcriptions with confirmation 567 + - Role dropdown for changing user roles 568 + - Delete buttons for user accounts with confirmation 569 + - User avatars and info display 570 + - Timestamp formatting 571 + - Admin badge on user listings 572 + 573 + **Implementation notes:** 574 + - `role` column in users table ('user' or 'admin', default 'user') 575 + - `requireAdmin()` middleware checks authentication + admin role 576 + - Returns 403 if non-admin tries to access admin routes 577 + - Admin link shows in auth menu only for admin users 578 + - Redirects to home page if non-admin accesses admin page 579 + 488 580 ## Future Additions 489 581 490 582 As the codebase grows, document: ··· 494 586 - Transcription service integration details 495 587 - Deployment process 496 588 - Environment variables needed 589 +
+31
scripts/make-admin.ts
··· 1 + #!/usr/bin/env bun 2 + 3 + import db from "../src/db/schema"; 4 + 5 + const email = process.argv[2]; 6 + 7 + if (!email) { 8 + console.error("Usage: bun scripts/make-admin.ts <email>"); 9 + process.exit(1); 10 + } 11 + 12 + const user = db 13 + .query<{ id: number; email: string; role: string }, [string]>( 14 + "SELECT id, email, role FROM users WHERE email = ?", 15 + ) 16 + .get(email); 17 + 18 + if (!user) { 19 + console.error(`User with email ${email} not found`); 20 + process.exit(1); 21 + } 22 + 23 + if (user.role === "admin") { 24 + console.log(`User ${email} is already an admin`); 25 + process.exit(0); 26 + } 27 + 28 + db.run("UPDATE users SET role = 'admin' WHERE id = ?", [user.id]); 29 + 30 + console.log(`✅ Successfully made ${email} an admin`); 31 + console.log(` User should refresh their browser to see admin access`);
+17
src/components/auth.ts
··· 9 9 email: string; 10 10 name: string | null; 11 11 avatar: string; 12 + role?: "user" | "admin"; 12 13 } 13 14 14 15 @customElement("auth-component") ··· 242 243 background: var(--secondary); 243 244 } 244 245 246 + .admin-link { 247 + color: #dc2626; 248 + border: 2px dashed #dc2626 !important; 249 + } 250 + 251 + .admin-link:hover { 252 + background: #fee2e2; 253 + color: #991b1b; 254 + border-color: #991b1b !important; 255 + } 256 + 245 257 .loading { 246 258 font-size: 0.875rem; 247 259 color: var(--text); ··· 434 446 <div class="user-menu"> 435 447 <a href="/transcribe" @click=${this.closeModal}>Transcribe</a> 436 448 <a href="/settings" @click=${this.closeModal}>Settings</a> 449 + ${ 450 + this.user.role === "admin" 451 + ? html`<a href="/admin" @click=${this.closeModal} class="admin-link">Admin</a>` 452 + : "" 453 + } 437 454 <button @click=${this.handleLogout}>Logout</button> 438 455 </div> 439 456 `
+11
src/db/schema.ts
··· 89 89 CREATE INDEX IF NOT EXISTS idx_rate_limit_key_timestamp ON rate_limit_attempts(key, timestamp); 90 90 `, 91 91 }, 92 + { 93 + version: 6, 94 + name: "Add role-based auth system", 95 + sql: ` 96 + -- Add role column (default to 'user') 97 + ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'; 98 + 99 + -- Create index on role 100 + CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); 101 + `, 102 + }, 92 103 ]; 93 104 94 105 function getCurrentVersion(): number {
+84 -1
src/index.ts
··· 5 5 createSession, 6 6 createUser, 7 7 deleteSession, 8 + deleteTranscription, 8 9 deleteUser, 10 + getAllTranscriptions, 11 + getAllUsers, 9 12 getSession, 10 13 getSessionFromRequest, 11 14 getUserBySession, ··· 14 17 updateUserEmail, 15 18 updateUserName, 16 19 updateUserPassword, 20 + updateUserRole, 21 + type UserRole, 17 22 } from "./lib/auth"; 18 23 import { handleError, ValidationErrors } from "./lib/errors"; 19 - import { requireAuth } from "./lib/middleware"; 24 + import { requireAdmin, requireAuth } from "./lib/middleware"; 20 25 import { enforceRateLimit } from "./lib/rate-limit"; 21 26 import { 22 27 MAX_FILE_SIZE, ··· 26 31 } from "./lib/transcription"; 27 32 import { getTranscript, getTranscriptVTT } from "./lib/transcript-storage"; 28 33 import indexHTML from "./pages/index.html"; 34 + import adminHTML from "./pages/admin.html"; 29 35 import settingsHTML from "./pages/settings.html"; 30 36 import transcribeHTML from "./pages/transcribe.html"; 31 37 ··· 82 88 idleTimeout: 120, // 120 seconds for SSE connections 83 89 routes: { 84 90 "/": indexHTML, 91 + "/admin": adminHTML, 85 92 "/settings": settingsHTML, 86 93 "/transcribe": transcribeHTML, 87 94 "/api/auth/register": { ··· 222 229 name: user.name, 223 230 avatar: user.avatar, 224 231 created_at: user.created_at, 232 + role: user.role, 225 233 }); 226 234 }, 227 235 }, ··· 829 837 id: transcriptionId, 830 838 message: "Upload successful, transcription started", 831 839 }); 840 + } catch (error) { 841 + return handleError(error); 842 + } 843 + }, 844 + }, 845 + "/api/admin/transcriptions": { 846 + GET: async (req) => { 847 + try { 848 + requireAdmin(req); 849 + const transcriptions = getAllTranscriptions(); 850 + return Response.json(transcriptions); 851 + } catch (error) { 852 + return handleError(error); 853 + } 854 + }, 855 + }, 856 + "/api/admin/users": { 857 + GET: async (req) => { 858 + try { 859 + requireAdmin(req); 860 + const users = getAllUsers(); 861 + return Response.json(users); 862 + } catch (error) { 863 + return handleError(error); 864 + } 865 + }, 866 + }, 867 + "/api/admin/transcriptions/:id": { 868 + DELETE: async (req) => { 869 + try { 870 + requireAdmin(req); 871 + const transcriptionId = req.params.id; 872 + deleteTranscription(transcriptionId); 873 + return Response.json({ success: true }); 874 + } catch (error) { 875 + return handleError(error); 876 + } 877 + }, 878 + }, 879 + "/api/admin/users/:id": { 880 + DELETE: async (req) => { 881 + try { 882 + requireAdmin(req); 883 + const userId = Number.parseInt(req.params.id, 10); 884 + if (Number.isNaN(userId)) { 885 + return Response.json({ error: "Invalid user ID" }, { status: 400 }); 886 + } 887 + deleteUser(userId); 888 + return Response.json({ success: true }); 889 + } catch (error) { 890 + return handleError(error); 891 + } 892 + }, 893 + }, 894 + "/api/admin/users/:id/role": { 895 + PUT: async (req) => { 896 + try { 897 + requireAdmin(req); 898 + const userId = Number.parseInt(req.params.id, 10); 899 + if (Number.isNaN(userId)) { 900 + return Response.json({ error: "Invalid user ID" }, { status: 400 }); 901 + } 902 + 903 + const body = await req.json(); 904 + const { role } = body as { role: UserRole }; 905 + 906 + if (!role || (role !== "user" && role !== "admin")) { 907 + return Response.json( 908 + { error: "Invalid role. Must be 'user' or 'admin'" }, 909 + { status: 400 }, 910 + ); 911 + } 912 + 913 + updateUserRole(userId, role); 914 + return Response.json({ success: true }); 832 915 } catch (error) { 833 916 return handleError(error); 834 917 }
+128 -3
src/lib/auth.ts
··· 2 2 3 3 const SESSION_DURATION = 7 * 24 * 60 * 60; // 7 days in seconds 4 4 5 + export type UserRole = "user" | "admin"; 6 + 5 7 export interface User { 6 8 id: number; 7 9 email: string; 8 10 name: string | null; 9 11 avatar: string; 10 12 created_at: number; 13 + role: UserRole; 11 14 } 12 15 13 16 export interface Session { ··· 53 56 54 57 const user = db 55 58 .query<User, [number]>( 56 - "SELECT id, email, name, avatar, created_at FROM users WHERE id = ?", 59 + "SELECT id, email, name, avatar, created_at, role FROM users WHERE id = ?", 57 60 ) 58 61 .get(session.user_id); 59 62 ··· 91 94 92 95 const user = db 93 96 .query<User, [number]>( 94 - "SELECT id, email, name, avatar, created_at FROM users WHERE id = ?", 97 + "SELECT id, email, name, avatar, created_at, role FROM users WHERE id = ?", 95 98 ) 96 99 .get(Number(result.lastInsertRowid)); 97 100 ··· 115 118 avatar: string; 116 119 password_hash: string; 117 120 created_at: number; 121 + role: UserRole; 118 122 }, 119 123 [string] 120 124 >( 121 - "SELECT id, email, name, avatar, password_hash, created_at FROM users WHERE email = ?", 125 + "SELECT id, email, name, avatar, password_hash, created_at, role FROM users WHERE email = ?", 122 126 ) 123 127 .get(email); 124 128 ··· 137 141 name: result.name, 138 142 avatar: result.avatar, 139 143 created_at: result.created_at, 144 + role: result.role, 140 145 }; 141 146 } 142 147 ··· 186 191 ]); 187 192 db.run("DELETE FROM sessions WHERE user_id = ?", [userId]); 188 193 } 194 + 195 + export function isUserAdmin(userId: number): boolean { 196 + const result = db 197 + .query<{ role: UserRole }, [number]>("SELECT role FROM users WHERE id = ?") 198 + .get(userId); 199 + 200 + return result?.role === "admin"; 201 + } 202 + 203 + export function updateUserRole(userId: number, role: UserRole): void { 204 + db.run("UPDATE users SET role = ? WHERE id = ?", [role, userId]); 205 + } 206 + 207 + export function getAllUsers(): Array<{ 208 + id: number; 209 + email: string; 210 + name: string | null; 211 + avatar: string; 212 + created_at: number; 213 + role: UserRole; 214 + }> { 215 + return db 216 + .query< 217 + { 218 + id: number; 219 + email: string; 220 + name: string | null; 221 + avatar: string; 222 + created_at: number; 223 + role: UserRole; 224 + }, 225 + [] 226 + >("SELECT id, email, name, avatar, created_at, role FROM users ORDER BY created_at DESC") 227 + .all(); 228 + } 229 + 230 + export function getAllTranscriptions(): Array<{ 231 + id: string; 232 + user_id: number; 233 + user_email: string; 234 + user_name: string | null; 235 + original_filename: string; 236 + status: string; 237 + created_at: number; 238 + error_message: string | null; 239 + }> { 240 + return db 241 + .query< 242 + { 243 + id: string; 244 + user_id: number; 245 + user_email: string; 246 + user_name: string | null; 247 + original_filename: string; 248 + status: string; 249 + created_at: number; 250 + error_message: string | null; 251 + }, 252 + [] 253 + >( 254 + `SELECT 255 + t.id, 256 + t.user_id, 257 + u.email as user_email, 258 + u.name as user_name, 259 + t.original_filename, 260 + t.status, 261 + t.created_at, 262 + t.error_message 263 + FROM transcriptions t 264 + LEFT JOIN users u ON t.user_id = u.id 265 + ORDER BY t.created_at DESC`, 266 + ) 267 + .all(); 268 + } 269 + 270 + export function deleteTranscription(transcriptionId: string): void { 271 + const transcription = db 272 + .query<{ id: string; filename: string }, [string]>( 273 + "SELECT id, filename FROM transcriptions WHERE id = ?", 274 + ) 275 + .get(transcriptionId); 276 + 277 + if (!transcription) { 278 + throw new Error("Transcription not found"); 279 + } 280 + 281 + // Delete database record 282 + db.run("DELETE FROM transcriptions WHERE id = ?", [transcriptionId]); 283 + 284 + // Delete files (audio file and transcript files) 285 + try { 286 + const audioPath = `./uploads/${transcription.filename}`; 287 + const transcriptPath = `./transcripts/${transcriptionId}.txt`; 288 + const vttPath = `./transcripts/${transcriptionId}.vtt`; 289 + 290 + if (Bun.file(audioPath).size) { 291 + Bun.write(audioPath, "").then(() => { 292 + // File deleted by overwriting with empty content, then unlink 293 + import("node:fs").then((fs) => { 294 + fs.unlinkSync(audioPath); 295 + }); 296 + }); 297 + } 298 + 299 + if (Bun.file(transcriptPath).size) { 300 + import("node:fs").then((fs) => { 301 + fs.unlinkSync(transcriptPath); 302 + }); 303 + } 304 + 305 + if (Bun.file(vttPath).size) { 306 + import("node:fs").then((fs) => { 307 + fs.unlinkSync(vttPath); 308 + }); 309 + } 310 + } catch { 311 + // Files might not exist, ignore errors 312 + } 313 + }
+2
src/lib/errors.ts
··· 85 85 "Email already registered", 86 86 400, 87 87 ), 88 + adminRequired: () => 89 + new AppError(ErrorCode.AUTH_REQUIRED, "Admin access required", 403), 88 90 }; 89 91 90 92 export const ValidationErrors = {
+10
src/lib/middleware.ts
··· 21 21 22 22 return user; 23 23 } 24 + 25 + export function requireAdmin(req: Request): User { 26 + const user = requireAuth(req); 27 + 28 + if (user.role !== "admin") { 29 + throw AuthErrors.adminRequired(); 30 + } 31 + 32 + return user; 33 + }
+642
src/pages/admin.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + 4 + <head> 5 + <meta charset="UTF-8"> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <title>Admin - Thistle</title> 8 + <link rel="icon" 9 + 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>"> 10 + <link rel="stylesheet" href="../styles/main.css"> 11 + <style> 12 + main { 13 + max-width: 80rem; 14 + margin: 0 auto; 15 + padding: 2rem; 16 + } 17 + 18 + h1 { 19 + margin-bottom: 2rem; 20 + color: var(--text); 21 + } 22 + 23 + .section { 24 + margin-bottom: 3rem; 25 + } 26 + 27 + .section-title { 28 + font-size: 1.5rem; 29 + font-weight: 600; 30 + color: var(--text); 31 + margin-bottom: 1rem; 32 + display: flex; 33 + align-items: center; 34 + gap: 0.5rem; 35 + } 36 + 37 + .tabs { 38 + display: flex; 39 + gap: 1rem; 40 + border-bottom: 2px solid var(--secondary); 41 + margin-bottom: 2rem; 42 + } 43 + 44 + .tab { 45 + padding: 0.75rem 1.5rem; 46 + border: none; 47 + background: transparent; 48 + color: var(--text); 49 + cursor: pointer; 50 + font-size: 1rem; 51 + font-weight: 500; 52 + font-family: inherit; 53 + border-bottom: 2px solid transparent; 54 + margin-bottom: -2px; 55 + transition: all 0.2s; 56 + } 57 + 58 + .tab:hover { 59 + color: var(--primary); 60 + } 61 + 62 + .tab.active { 63 + color: var(--primary); 64 + border-bottom-color: var(--primary); 65 + } 66 + 67 + .tab-content { 68 + display: none; 69 + } 70 + 71 + .tab-content.active { 72 + display: block; 73 + } 74 + 75 + table { 76 + width: 100%; 77 + border-collapse: collapse; 78 + background: var(--background); 79 + border: 2px solid var(--secondary); 80 + border-radius: 8px; 81 + overflow: hidden; 82 + } 83 + 84 + thead { 85 + background: var(--primary); 86 + color: white; 87 + } 88 + 89 + th { 90 + padding: 1rem; 91 + text-align: left; 92 + font-weight: 600; 93 + } 94 + 95 + td { 96 + padding: 1rem; 97 + border-top: 1px solid var(--secondary); 98 + color: var(--text); 99 + } 100 + 101 + tr:hover { 102 + background: rgba(0, 0, 0, 0.02); 103 + } 104 + 105 + .status-badge { 106 + display: inline-block; 107 + padding: 0.25rem 0.75rem; 108 + border-radius: 4px; 109 + font-size: 0.875rem; 110 + font-weight: 500; 111 + } 112 + 113 + .status-completed { 114 + background: #dcfce7; 115 + color: #166534; 116 + } 117 + 118 + .status-processing, 119 + .status-uploading { 120 + background: #fef3c7; 121 + color: #92400e; 122 + } 123 + 124 + .status-failed { 125 + background: #fee2e2; 126 + color: #991b1b; 127 + } 128 + 129 + .status-pending { 130 + background: #e0e7ff; 131 + color: #3730a3; 132 + } 133 + 134 + .admin-badge { 135 + background: var(--accent); 136 + color: white; 137 + padding: 0.25rem 0.5rem; 138 + border-radius: 4px; 139 + font-size: 0.75rem; 140 + font-weight: 600; 141 + margin-left: 0.5rem; 142 + } 143 + 144 + .user-info { 145 + display: flex; 146 + align-items: center; 147 + gap: 0.5rem; 148 + } 149 + 150 + .user-avatar { 151 + width: 2rem; 152 + height: 2rem; 153 + border-radius: 50%; 154 + } 155 + 156 + .empty-state { 157 + text-align: center; 158 + padding: 3rem; 159 + color: var(--text); 160 + opacity: 0.6; 161 + } 162 + 163 + .loading { 164 + text-align: center; 165 + padding: 3rem; 166 + color: var(--text); 167 + } 168 + 169 + .error { 170 + background: #fee2e2; 171 + color: #991b1b; 172 + padding: 1rem; 173 + border-radius: 6px; 174 + margin-bottom: 1rem; 175 + } 176 + 177 + .stats { 178 + display: grid; 179 + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); 180 + gap: 1rem; 181 + margin-bottom: 2rem; 182 + } 183 + 184 + .stat-card { 185 + background: var(--background); 186 + border: 2px solid var(--secondary); 187 + border-radius: 8px; 188 + padding: 1.5rem; 189 + } 190 + 191 + .stat-value { 192 + font-size: 2rem; 193 + font-weight: 700; 194 + color: var(--primary); 195 + margin-bottom: 0.25rem; 196 + } 197 + 198 + .stat-label { 199 + color: var(--text); 200 + opacity: 0.7; 201 + font-size: 0.875rem; 202 + } 203 + 204 + .timestamp { 205 + color: var(--text); 206 + opacity: 0.6; 207 + font-size: 0.875rem; 208 + } 209 + 210 + .delete-btn { 211 + background: transparent; 212 + border: 2px solid #dc2626; 213 + color: #dc2626; 214 + padding: 0.25rem 0.75rem; 215 + border-radius: 4px; 216 + cursor: pointer; 217 + font-size: 0.875rem; 218 + font-weight: 500; 219 + font-family: inherit; 220 + transition: all 0.2s; 221 + } 222 + 223 + .delete-btn:hover { 224 + background: #dc2626; 225 + color: white; 226 + } 227 + 228 + .delete-btn:disabled { 229 + opacity: 0.5; 230 + cursor: not-allowed; 231 + } 232 + 233 + .actions { 234 + display: flex; 235 + gap: 0.5rem; 236 + } 237 + 238 + .role-select { 239 + padding: 0.25rem 0.5rem; 240 + border: 2px solid var(--secondary); 241 + border-radius: 4px; 242 + font-size: 0.875rem; 243 + font-family: inherit; 244 + background: var(--background); 245 + color: var(--text); 246 + cursor: pointer; 247 + } 248 + 249 + .role-select:focus { 250 + outline: none; 251 + border-color: var(--primary); 252 + } 253 + 254 + .delete-user-btn { 255 + background: transparent; 256 + border: 2px solid #dc2626; 257 + color: #dc2626; 258 + padding: 0.25rem 0.75rem; 259 + border-radius: 4px; 260 + cursor: pointer; 261 + font-size: 0.875rem; 262 + font-weight: 500; 263 + font-family: inherit; 264 + transition: all 0.2s; 265 + } 266 + 267 + .delete-user-btn:hover { 268 + background: #dc2626; 269 + color: white; 270 + } 271 + 272 + .delete-user-btn:disabled { 273 + opacity: 0.5; 274 + cursor: not-allowed; 275 + } 276 + </style> 277 + </head> 278 + 279 + <body> 280 + <header> 281 + <div class="header-content"> 282 + <a href="/" class="site-title"> 283 + <span>🪻</span> 284 + <span>Thistle</span> 285 + </a> 286 + <auth-component></auth-component> 287 + </div> 288 + </header> 289 + 290 + <main> 291 + <h1>Admin Dashboard</h1> 292 + 293 + <div id="error-message" class="error" style="display: none;"></div> 294 + 295 + <div id="loading" class="loading">Loading...</div> 296 + 297 + <div id="content" style="display: none;"> 298 + <div class="stats"> 299 + <div class="stat-card"> 300 + <div class="stat-value" id="total-users">0</div> 301 + <div class="stat-label">Total Users</div> 302 + </div> 303 + <div class="stat-card"> 304 + <div class="stat-value" id="total-transcriptions">0</div> 305 + <div class="stat-label">Total Transcriptions</div> 306 + </div> 307 + <div class="stat-card"> 308 + <div class="stat-value" id="failed-transcriptions">0</div> 309 + <div class="stat-label">Failed Transcriptions</div> 310 + </div> 311 + </div> 312 + 313 + <div class="tabs"> 314 + <button class="tab active" data-tab="transcriptions">Transcriptions</button> 315 + <button class="tab" data-tab="users">Users</button> 316 + </div> 317 + 318 + <div id="transcriptions-tab" class="tab-content active"> 319 + <div class="section"> 320 + <h2 class="section-title">All Transcriptions</h2> 321 + <div id="transcriptions-table"></div> 322 + </div> 323 + </div> 324 + 325 + <div id="users-tab" class="tab-content"> 326 + <div class="section"> 327 + <h2 class="section-title">All Users</h2> 328 + <div id="users-table"></div> 329 + </div> 330 + </div> 331 + </div> 332 + </main> 333 + 334 + <script type="module" src="../components/auth.ts"></script> 335 + <script type="module"> 336 + const errorMessage = document.getElementById('error-message'); 337 + const loading = document.getElementById('loading'); 338 + const content = document.getElementById('content'); 339 + const transcriptionsTable = document.getElementById('transcriptions-table'); 340 + const usersTable = document.getElementById('users-table'); 341 + 342 + let currentUserEmail = null; 343 + 344 + // Get current user info 345 + async function getCurrentUser() { 346 + try { 347 + const res = await fetch('/api/auth/me'); 348 + if (res.ok) { 349 + const user = await res.json(); 350 + currentUserEmail = user.email; 351 + } 352 + } catch { 353 + // Ignore errors 354 + } 355 + } 356 + 357 + function showError(message) { 358 + errorMessage.textContent = message; 359 + errorMessage.style.display = 'block'; 360 + loading.style.display = 'none'; 361 + } 362 + 363 + function formatTimestamp(timestamp) { 364 + const date = new Date(timestamp * 1000); 365 + return date.toLocaleString(); 366 + } 367 + 368 + function renderTranscriptions(transcriptions) { 369 + if (transcriptions.length === 0) { 370 + transcriptionsTable.innerHTML = '<div class="empty-state">No transcriptions yet</div>'; 371 + return; 372 + } 373 + 374 + const failed = transcriptions.filter(t => t.status === 'failed'); 375 + document.getElementById('failed-transcriptions').textContent = failed.length; 376 + 377 + const table = document.createElement('table'); 378 + table.innerHTML = ` 379 + <thead> 380 + <tr> 381 + <th>File Name</th> 382 + <th>User</th> 383 + <th>Status</th> 384 + <th>Created At</th> 385 + <th>Error</th> 386 + <th>Actions</th> 387 + </tr> 388 + </thead> 389 + <tbody> 390 + ${transcriptions.map(t => ` 391 + <tr> 392 + <td>${t.original_filename}</td> 393 + <td> 394 + <div class="user-info"> 395 + <img 396 + src="https://hostedboringavatars.vercel.app/api/marble?size=32&name=${t.user_id}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff" 397 + alt="Avatar" 398 + class="user-avatar" 399 + /> 400 + <span>${t.user_name || t.user_email}</span> 401 + </div> 402 + </td> 403 + <td><span class="status-badge status-${t.status}">${t.status}</span></td> 404 + <td class="timestamp">${formatTimestamp(t.created_at)}</td> 405 + <td>${t.error_message || '-'}</td> 406 + <td> 407 + <button class="delete-btn" data-id="${t.id}">Delete</button> 408 + </td> 409 + </tr> 410 + `).join('')} 411 + </tbody> 412 + `; 413 + transcriptionsTable.innerHTML = ''; 414 + transcriptionsTable.appendChild(table); 415 + 416 + // Add delete event listeners 417 + table.querySelectorAll('.delete-btn').forEach(btn => { 418 + btn.addEventListener('click', async (e) => { 419 + const button = e.target; 420 + const id = button.dataset.id; 421 + 422 + if (!confirm('Are you sure you want to delete this transcription? This cannot be undone.')) { 423 + return; 424 + } 425 + 426 + button.disabled = true; 427 + button.textContent = 'Deleting...'; 428 + 429 + try { 430 + const res = await fetch(`/api/admin/transcriptions/${id}`, { 431 + method: 'DELETE' 432 + }); 433 + 434 + if (!res.ok) { 435 + throw new Error('Failed to delete'); 436 + } 437 + 438 + // Reload data 439 + await loadData(); 440 + } catch { 441 + alert('Failed to delete transcription'); 442 + button.disabled = false; 443 + button.textContent = 'Delete'; 444 + } 445 + }); 446 + }); 447 + } 448 + 449 + function renderUsers(users) { 450 + if (users.length === 0) { 451 + usersTable.innerHTML = '<div class="empty-state">No users yet</div>'; 452 + return; 453 + } 454 + 455 + const table = document.createElement('table'); 456 + table.innerHTML = ` 457 + <thead> 458 + <tr> 459 + <th>User</th> 460 + <th>Email</th> 461 + <th>Role</th> 462 + <th>Joined</th> 463 + <th>Actions</th> 464 + </tr> 465 + </thead> 466 + <tbody> 467 + ${users.map(u => ` 468 + <tr> 469 + <td> 470 + <div class="user-info"> 471 + <img 472 + src="https://hostedboringavatars.vercel.app/api/marble?size=32&name=${u.avatar}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff" 473 + alt="Avatar" 474 + class="user-avatar" 475 + /> 476 + <span>${u.name || 'Anonymous'}</span> 477 + ${u.role === 'admin' ? '<span class="admin-badge">ADMIN</span>' : ''} 478 + </div> 479 + </td> 480 + <td>${u.email}</td> 481 + <td> 482 + <select class="role-select" data-user-id="${u.id}" data-current-role="${u.role}"> 483 + <option value="user" ${u.role === 'user' ? 'selected' : ''}>User</option> 484 + <option value="admin" ${u.role === 'admin' ? 'selected' : ''}>Admin</option> 485 + </select> 486 + </td> 487 + <td class="timestamp">${formatTimestamp(u.created_at)}</td> 488 + <td> 489 + <button class="delete-user-btn" data-user-id="${u.id}" data-user-email="${u.email}">Delete</button> 490 + </td> 491 + </tr> 492 + `).join('')} 493 + </tbody> 494 + `; 495 + usersTable.innerHTML = ''; 496 + usersTable.appendChild(table); 497 + 498 + // Add role change event listeners 499 + table.querySelectorAll('.role-select').forEach(select => { 500 + select.addEventListener('change', async (e) => { 501 + const selectEl = e.target; 502 + const userId = selectEl.dataset.userId; 503 + const newRole = selectEl.value; 504 + const oldRole = selectEl.dataset.currentRole; 505 + 506 + // Get user email from the row 507 + const row = selectEl.closest('tr'); 508 + const userEmail = row.querySelector('td:nth-child(2)').textContent; 509 + 510 + // Check if user is demoting themselves 511 + const isDemotingSelf = userEmail === currentUserEmail && oldRole === 'admin' && newRole === 'user'; 512 + 513 + if (isDemotingSelf) { 514 + 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?')) { 515 + selectEl.value = oldRole; 516 + return; 517 + } 518 + 519 + if (!confirm('⚠️ FINAL WARNING: This action cannot be undone by you. Another admin will need to restore your admin access. Continue?')) { 520 + selectEl.value = oldRole; 521 + return; 522 + } 523 + } else { 524 + if (!confirm(`Change user role to ${newRole}?`)) { 525 + selectEl.value = oldRole; 526 + return; 527 + } 528 + } 529 + 530 + try { 531 + const res = await fetch(`/api/admin/users/${userId}/role`, { 532 + method: 'PUT', 533 + headers: { 'Content-Type': 'application/json' }, 534 + body: JSON.stringify({ role: newRole }) 535 + }); 536 + 537 + if (!res.ok) { 538 + throw new Error('Failed to update role'); 539 + } 540 + 541 + selectEl.dataset.currentRole = newRole; 542 + 543 + // If demoting self, redirect to home 544 + if (isDemotingSelf) { 545 + window.location.href = '/'; 546 + } else { 547 + await loadData(); 548 + } 549 + } catch { 550 + alert('Failed to update user role'); 551 + selectEl.value = oldRole; 552 + } 553 + }); 554 + }); 555 + 556 + // Add delete user event listeners 557 + table.querySelectorAll('.delete-user-btn').forEach(btn => { 558 + btn.addEventListener('click', async (e) => { 559 + const button = e.target; 560 + const userId = button.dataset.userId; 561 + const userEmail = button.dataset.userEmail; 562 + 563 + if (!confirm(`Are you sure you want to delete user ${userEmail}? This will delete all their transcriptions and cannot be undone.`)) { 564 + return; 565 + } 566 + 567 + button.disabled = true; 568 + button.textContent = 'Deleting...'; 569 + 570 + try { 571 + const res = await fetch(`/api/admin/users/${userId}`, { 572 + method: 'DELETE' 573 + }); 574 + 575 + if (!res.ok) { 576 + throw new Error('Failed to delete user'); 577 + } 578 + 579 + await loadData(); 580 + } catch { 581 + alert('Failed to delete user'); 582 + button.disabled = false; 583 + button.textContent = 'Delete'; 584 + } 585 + }); 586 + }); 587 + } 588 + 589 + async function loadData() { 590 + try { 591 + const [transcriptionsRes, usersRes] = await Promise.all([ 592 + fetch('/api/admin/transcriptions'), 593 + fetch('/api/admin/users') 594 + ]); 595 + 596 + if (!transcriptionsRes.ok || !usersRes.ok) { 597 + if (transcriptionsRes.status === 403 || usersRes.status === 403) { 598 + window.location.href = '/'; 599 + return; 600 + } 601 + throw new Error('Failed to load admin data'); 602 + } 603 + 604 + const transcriptions = await transcriptionsRes.json(); 605 + const users = await usersRes.json(); 606 + 607 + document.getElementById('total-users').textContent = users.length; 608 + document.getElementById('total-transcriptions').textContent = transcriptions.length; 609 + 610 + renderTranscriptions(transcriptions); 611 + renderUsers(users); 612 + 613 + loading.style.display = 'none'; 614 + content.style.display = 'block'; 615 + } catch (error) { 616 + showError(error.message); 617 + } 618 + } 619 + 620 + // Tab switching 621 + document.querySelectorAll('.tab').forEach(tab => { 622 + tab.addEventListener('click', () => { 623 + const tabName = tab.dataset.tab; 624 + 625 + document.querySelectorAll('.tab').forEach(t => { 626 + t.classList.remove('active'); 627 + }); 628 + document.querySelectorAll('.tab-content').forEach(c => { 629 + c.classList.remove('active'); 630 + }); 631 + 632 + tab.classList.add('active'); 633 + document.getElementById(`${tabName}-tab`).classList.add('active'); 634 + }); 635 + }); 636 + 637 + // Initialize 638 + getCurrentUser().then(() => loadData()); 639 + </script> 640 + </body> 641 + 642 + </html>