🪻 distributed transcription service thistle.dunkirk.sh
1
fork

Configure Feed

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

feat: allow sorting the table and add more details

+1627 -15
+244
src/components/admin-data-table.ts
··· 1 + import { LitElement, html, css } from "lit"; 2 + import { customElement, property } from "lit/decorators.js"; 3 + 4 + export interface TableColumn { 5 + key: string; 6 + label: string; 7 + sortable?: boolean; 8 + render?: (value: unknown, row: unknown) => unknown; 9 + } 10 + 11 + @customElement("admin-data-table") 12 + export class AdminDataTable extends LitElement { 13 + @property({ type: Array }) columns: TableColumn[] = []; 14 + @property({ type: Array }) data: unknown[] = []; 15 + @property({ type: String }) searchPlaceholder = "Search..."; 16 + @property({ type: String }) emptyMessage = "No data available"; 17 + @property({ type: Boolean}) loading = false; 18 + 19 + @property({ type: String }) private searchTerm = ""; 20 + @property({ type: String }) private sortKey = ""; 21 + @property({ type: String }) private sortDirection: "asc" | "desc" = "asc"; 22 + 23 + static override styles = css` 24 + :host { 25 + display: block; 26 + } 27 + 28 + .controls { 29 + margin-bottom: 1rem; 30 + display: flex; 31 + gap: 1rem; 32 + align-items: center; 33 + } 34 + 35 + .search { 36 + flex: 1; 37 + max-width: 20rem; 38 + padding: 0.5rem 0.75rem; 39 + border: 2px solid var(--secondary); 40 + border-radius: 4px; 41 + font-size: 1rem; 42 + font-family: inherit; 43 + background: var(--background); 44 + color: var(--text); 45 + } 46 + 47 + .search:focus { 48 + outline: none; 49 + border-color: var(--primary); 50 + } 51 + 52 + table { 53 + width: 100%; 54 + border-collapse: collapse; 55 + background: var(--background); 56 + border: 2px solid var(--secondary); 57 + border-radius: 8px; 58 + overflow: hidden; 59 + } 60 + 61 + thead { 62 + background: var(--primary); 63 + color: white; 64 + } 65 + 66 + th { 67 + padding: 1rem; 68 + text-align: left; 69 + font-weight: 600; 70 + user-select: none; 71 + } 72 + 73 + th.sortable { 74 + cursor: pointer; 75 + position: relative; 76 + } 77 + 78 + th.sortable:hover { 79 + background: var(--gunmetal); 80 + } 81 + 82 + .sort-indicator { 83 + margin-left: 0.5rem; 84 + opacity: 0.6; 85 + } 86 + 87 + td { 88 + padding: 1rem; 89 + border-top: 1px solid var(--secondary); 90 + color: var(--text); 91 + } 92 + 93 + tbody tr { 94 + cursor: pointer; 95 + } 96 + 97 + tbody tr:hover { 98 + background: rgba(0, 0, 0, 0.02); 99 + } 100 + 101 + .empty-state, .loading { 102 + text-align: center; 103 + padding: 3rem; 104 + color: var(--text); 105 + opacity: 0.6; 106 + } 107 + `; 108 + 109 + private get filteredData() { 110 + let result = [...this.data]; 111 + 112 + if (this.searchTerm) { 113 + const term = this.searchTerm.toLowerCase(); 114 + result = result.filter((row) => { 115 + return this.columns.some((col) => { 116 + const value = (row as Record<string, unknown>)[col.key]; 117 + return String(value).toLowerCase().includes(term); 118 + }); 119 + }); 120 + } 121 + 122 + if (this.sortKey) { 123 + result.sort((a, b) => { 124 + const aVal = (a as Record<string, unknown>)[this.sortKey]; 125 + const bVal = (b as Record<string, unknown>)[this.sortKey]; 126 + 127 + let comparison = 0; 128 + if (typeof aVal === "string" && typeof bVal === "string") { 129 + comparison = aVal.localeCompare(bVal); 130 + } else if (typeof aVal === "number" && typeof bVal === "number") { 131 + comparison = aVal - bVal; 132 + } else { 133 + const aStr = String(aVal); 134 + const bStr = String(bVal); 135 + comparison = aStr.localeCompare(bStr); 136 + } 137 + 138 + return this.sortDirection === "asc" ? comparison : -comparison; 139 + }); 140 + } 141 + 142 + return result; 143 + } 144 + 145 + private handleSearch(e: Event) { 146 + this.searchTerm = (e.target as HTMLInputElement).value; 147 + } 148 + 149 + private handleSort(column: TableColumn) { 150 + if (!column.sortable) return; 151 + 152 + if (this.sortKey === column.key) { 153 + this.sortDirection = this.sortDirection === "asc" ? "desc" : "asc"; 154 + } else { 155 + this.sortKey = column.key; 156 + this.sortDirection = "asc"; 157 + } 158 + } 159 + 160 + private renderCell(column: TableColumn, row: unknown) { 161 + const value = (row as Record<string, unknown>)[column.key]; 162 + if (column.render) { 163 + return column.render(value, row); 164 + } 165 + return value; 166 + } 167 + 168 + private handleRowClick(row: unknown) { 169 + this.dispatchEvent( 170 + new CustomEvent("row-click", { 171 + detail: row, 172 + bubbles: true, 173 + composed: true, 174 + }), 175 + ); 176 + } 177 + 178 + override render() { 179 + if (this.loading) { 180 + return html`<div class="loading">Loading...</div>`; 181 + } 182 + 183 + const filtered = this.filteredData; 184 + 185 + return html` 186 + <div class="controls"> 187 + <input 188 + type="text" 189 + class="search" 190 + placeholder=${this.searchPlaceholder} 191 + @input=${this.handleSearch} 192 + value=${this.searchTerm} 193 + /> 194 + </div> 195 + 196 + ${ 197 + filtered.length === 0 198 + ? html`<div class="empty-state">${this.emptyMessage}</div>` 199 + : html` 200 + <table> 201 + <thead> 202 + <tr> 203 + ${this.columns.map( 204 + (col) => html` 205 + <th 206 + class=${col.sortable ? "sortable" : ""} 207 + @click=${() => this.handleSort(col)} 208 + > 209 + ${col.label} 210 + ${ 211 + col.sortable && this.sortKey === col.key 212 + ? html`<span class="sort-indicator"> 213 + ${this.sortDirection === "asc" ? "▲" : "▼"} 214 + </span>` 215 + : "" 216 + } 217 + </th> 218 + `, 219 + )} 220 + </tr> 221 + </thead> 222 + <tbody> 223 + ${filtered.map( 224 + (row) => html` 225 + <tr @click=${() => this.handleRowClick(row)}> 226 + ${this.columns.map( 227 + (col) => html`<td>${this.renderCell(col, row)}</td>`, 228 + )} 229 + </tr> 230 + `, 231 + )} 232 + </tbody> 233 + </table> 234 + ` 235 + } 236 + `; 237 + } 238 + } 239 + 240 + declare global { 241 + interface HTMLElementTagNameMap { 242 + "admin-data-table": AdminDataTable; 243 + } 244 + }
+8
src/db/schema.ts
··· 138 138 CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); 139 139 `, 140 140 }, 141 + { 142 + version: 8, 143 + name: "Add last_login to users", 144 + sql: ` 145 + ALTER TABLE users ADD COLUMN last_login INTEGER; 146 + CREATE INDEX IF NOT EXISTS idx_users_last_login ON users(last_login); 147 + `, 148 + }, 141 149 ]; 142 150 143 151 function getCurrentVersion(): number {
+240 -1
src/index.ts
··· 4 4 cleanupExpiredSessions, 5 5 createSession, 6 6 createUser, 7 + deleteAllUserSessions, 7 8 deleteSession, 9 + deleteSessionById, 8 10 deleteTranscription, 9 11 deleteUser, 10 12 getAllTranscriptions, 11 13 getAllUsers, 14 + getAllUsersWithStats, 12 15 getSession, 13 16 getSessionFromRequest, 17 + getSessionsForUser, 14 18 getUserBySession, 15 19 getUserSessionsForUser, 16 20 updateUserAvatar, 17 21 updateUserEmail, 22 + updateUserEmailAddress, 18 23 updateUserName, 19 24 updateUserPassword, 20 25 updateUserRole, ··· 1001 1006 GET: async (req) => { 1002 1007 try { 1003 1008 requireAdmin(req); 1004 - const users = getAllUsers(); 1009 + const users = getAllUsersWithStats(); 1005 1010 return Response.json(users); 1006 1011 } catch (error) { 1007 1012 return handleError(error); ··· 1055 1060 } 1056 1061 1057 1062 updateUserRole(userId, role); 1063 + return Response.json({ success: true }); 1064 + } catch (error) { 1065 + return handleError(error); 1066 + } 1067 + }, 1068 + }, 1069 + "/api/admin/users/:id/details": { 1070 + GET: async (req) => { 1071 + try { 1072 + requireAdmin(req); 1073 + const userId = Number.parseInt(req.params.id, 10); 1074 + if (Number.isNaN(userId)) { 1075 + return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1076 + } 1077 + 1078 + const user = db 1079 + .query< 1080 + { 1081 + id: number; 1082 + email: string; 1083 + name: string | null; 1084 + avatar: string; 1085 + created_at: number; 1086 + role: UserRole; 1087 + password_hash: string | null; 1088 + last_login: number | null; 1089 + }, 1090 + [number] 1091 + >( 1092 + "SELECT id, email, name, avatar, created_at, role, password_hash, last_login FROM users WHERE id = ?", 1093 + ) 1094 + .get(userId); 1095 + 1096 + if (!user) { 1097 + return Response.json({ error: "User not found" }, { status: 404 }); 1098 + } 1099 + 1100 + const passkeys = getPasskeysForUser(userId); 1101 + const sessions = getSessionsForUser(userId); 1102 + 1103 + // Get transcription count 1104 + const transcriptionCount = db 1105 + .query<{ count: number }, [number]>( 1106 + "SELECT COUNT(*) as count FROM transcriptions WHERE user_id = ?", 1107 + ) 1108 + .get(userId)?.count ?? 0; 1109 + 1110 + return Response.json({ 1111 + id: user.id, 1112 + email: user.email, 1113 + name: user.name, 1114 + avatar: user.avatar, 1115 + created_at: user.created_at, 1116 + role: user.role, 1117 + last_login: user.last_login, 1118 + hasPassword: !!user.password_hash, 1119 + transcriptionCount, 1120 + passkeys: passkeys.map((pk) => ({ 1121 + id: pk.id, 1122 + name: pk.name, 1123 + created_at: pk.created_at, 1124 + last_used_at: pk.last_used_at, 1125 + })), 1126 + sessions: sessions.map((s) => ({ 1127 + id: s.id, 1128 + ip_address: s.ip_address, 1129 + user_agent: s.user_agent, 1130 + created_at: s.created_at, 1131 + expires_at: s.expires_at, 1132 + })), 1133 + }); 1134 + } catch (error) { 1135 + return handleError(error); 1136 + } 1137 + }, 1138 + }, 1139 + "/api/admin/users/:id/password": { 1140 + PUT: async (req) => { 1141 + try { 1142 + requireAdmin(req); 1143 + const userId = Number.parseInt(req.params.id, 10); 1144 + if (Number.isNaN(userId)) { 1145 + return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1146 + } 1147 + 1148 + const body = await req.json(); 1149 + const { password } = body as { password: string }; 1150 + 1151 + if (!password || password.length < 8) { 1152 + return Response.json( 1153 + { error: "Password must be at least 8 characters" }, 1154 + { status: 400 }, 1155 + ); 1156 + } 1157 + 1158 + await updateUserPassword(userId, password); 1159 + return Response.json({ success: true }); 1160 + } catch (error) { 1161 + return handleError(error); 1162 + } 1163 + }, 1164 + }, 1165 + "/api/admin/users/:id/passkeys/:passkeyId": { 1166 + DELETE: async (req) => { 1167 + try { 1168 + requireAdmin(req); 1169 + const userId = Number.parseInt(req.params.id, 10); 1170 + if (Number.isNaN(userId)) { 1171 + return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1172 + } 1173 + 1174 + const { passkeyId } = req.params; 1175 + deletePasskey(passkeyId, userId); 1176 + return Response.json({ success: true }); 1177 + } catch (error) { 1178 + return handleError(error); 1179 + } 1180 + }, 1181 + }, 1182 + "/api/admin/users/:id/name": { 1183 + PUT: async (req) => { 1184 + try { 1185 + requireAdmin(req); 1186 + const userId = Number.parseInt(req.params.id, 10); 1187 + if (Number.isNaN(userId)) { 1188 + return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1189 + } 1190 + 1191 + const body = await req.json(); 1192 + const { name } = body as { name: string }; 1193 + 1194 + if (!name || name.trim().length === 0) { 1195 + return Response.json( 1196 + { error: "Name cannot be empty" }, 1197 + { status: 400 }, 1198 + ); 1199 + } 1200 + 1201 + updateUserName(userId, name.trim()); 1202 + return Response.json({ success: true }); 1203 + } catch (error) { 1204 + return handleError(error); 1205 + } 1206 + }, 1207 + }, 1208 + "/api/admin/users/:id/email": { 1209 + PUT: async (req) => { 1210 + try { 1211 + requireAdmin(req); 1212 + const userId = Number.parseInt(req.params.id, 10); 1213 + if (Number.isNaN(userId)) { 1214 + return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1215 + } 1216 + 1217 + const body = await req.json(); 1218 + const { email } = body as { email: string }; 1219 + 1220 + if (!email || !email.includes("@")) { 1221 + return Response.json( 1222 + { error: "Invalid email address" }, 1223 + { status: 400 }, 1224 + ); 1225 + } 1226 + 1227 + // Check if email already exists 1228 + const existing = db 1229 + .query<{ id: number }, [string, number]>( 1230 + "SELECT id FROM users WHERE email = ? AND id != ?", 1231 + ) 1232 + .get(email, userId); 1233 + 1234 + if (existing) { 1235 + return Response.json( 1236 + { error: "Email already in use" }, 1237 + { status: 400 }, 1238 + ); 1239 + } 1240 + 1241 + updateUserEmailAddress(userId, email); 1242 + return Response.json({ success: true }); 1243 + } catch (error) { 1244 + return handleError(error); 1245 + } 1246 + }, 1247 + }, 1248 + "/api/admin/users/:id/sessions": { 1249 + GET: async (req) => { 1250 + try { 1251 + requireAdmin(req); 1252 + const userId = Number.parseInt(req.params.id, 10); 1253 + if (Number.isNaN(userId)) { 1254 + return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1255 + } 1256 + 1257 + const sessions = getSessionsForUser(userId); 1258 + return Response.json(sessions); 1259 + } catch (error) { 1260 + return handleError(error); 1261 + } 1262 + }, 1263 + DELETE: async (req) => { 1264 + try { 1265 + requireAdmin(req); 1266 + const userId = Number.parseInt(req.params.id, 10); 1267 + if (Number.isNaN(userId)) { 1268 + return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1269 + } 1270 + 1271 + deleteAllUserSessions(userId); 1272 + return Response.json({ success: true }); 1273 + } catch (error) { 1274 + return handleError(error); 1275 + } 1276 + }, 1277 + }, 1278 + "/api/admin/users/:id/sessions/:sessionId": { 1279 + DELETE: async (req) => { 1280 + try { 1281 + requireAdmin(req); 1282 + const userId = Number.parseInt(req.params.id, 10); 1283 + if (Number.isNaN(userId)) { 1284 + return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1285 + } 1286 + 1287 + const { sessionId } = req.params; 1288 + const success = deleteSessionById(sessionId, userId); 1289 + 1290 + if (!success) { 1291 + return Response.json( 1292 + { error: "Session not found" }, 1293 + { status: 404 }, 1294 + ); 1295 + } 1296 + 1058 1297 return Response.json({ success: true }); 1059 1298 } catch (error) { 1060 1299 return handleError(error);
+186
src/lib/admin.test.ts
··· 1 + import { afterEach, beforeEach, expect, test } from "bun:test"; 2 + import { Database } from "bun:sqlite"; 3 + 4 + let testDb: Database; 5 + 6 + beforeEach(() => { 7 + testDb = new Database(":memory:"); 8 + 9 + testDb.run(` 10 + CREATE TABLE users ( 11 + id INTEGER PRIMARY KEY AUTOINCREMENT, 12 + email TEXT UNIQUE NOT NULL, 13 + password_hash TEXT, 14 + name TEXT, 15 + avatar TEXT DEFAULT 'd', 16 + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 17 + role TEXT NOT NULL DEFAULT 'user' 18 + ) 19 + `); 20 + 21 + testDb.run(` 22 + CREATE TABLE passkeys ( 23 + id TEXT PRIMARY KEY, 24 + user_id INTEGER NOT NULL, 25 + credential_id TEXT NOT NULL UNIQUE, 26 + public_key TEXT NOT NULL, 27 + counter INTEGER NOT NULL DEFAULT 0, 28 + transports TEXT, 29 + name TEXT, 30 + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 31 + last_used_at INTEGER, 32 + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 33 + ) 34 + `); 35 + }); 36 + 37 + afterEach(() => { 38 + testDb.close(); 39 + }); 40 + 41 + test("admin can update user name", async () => { 42 + const result = testDb.run( 43 + "INSERT INTO users (email, password_hash, name, avatar) VALUES (?, ?, ?, ?)", 44 + ["test@example.com", "password123", "Old Name", "avatar1"], 45 + ); 46 + 47 + const userId = Number(result.lastInsertRowid); 48 + 49 + testDb.run("UPDATE users SET name = ? WHERE id = ?", ["New Name", userId]); 50 + 51 + const user = testDb 52 + .query<{ name: string }, [number]>("SELECT name FROM users WHERE id = ?") 53 + .get(userId); 54 + 55 + expect(user?.name).toBe("New Name"); 56 + }); 57 + 58 + test("admin can update user password", async () => { 59 + const result = testDb.run( 60 + "INSERT INTO users (email, password_hash) VALUES (?, ?)", 61 + ["test@example.com", "password123"], 62 + ); 63 + 64 + const userId = Number(result.lastInsertRowid); 65 + 66 + testDb.run("UPDATE users SET password_hash = ? WHERE id = ?", [ 67 + "newpassword456", 68 + userId, 69 + ]); 70 + 71 + const user = testDb 72 + .query<{ password_hash: string }, [number]>( 73 + "SELECT password_hash FROM users WHERE id = ?", 74 + ) 75 + .get(userId); 76 + 77 + expect(user?.password_hash).toBe("newpassword456"); 78 + }); 79 + 80 + test("admin can view user passkeys", async () => { 81 + const result = testDb.run( 82 + "INSERT INTO users (email, password_hash) VALUES (?, ?)", 83 + ["test@example.com", "password123"], 84 + ); 85 + 86 + const userId = Number(result.lastInsertRowid); 87 + 88 + testDb.run( 89 + "INSERT INTO passkeys (id, user_id, credential_id, public_key, counter) VALUES (?, ?, ?, ?, ?)", 90 + ["pk1", userId, "cred1", "pubkey1", 0], 91 + ); 92 + 93 + testDb.run( 94 + "INSERT INTO passkeys (id, user_id, credential_id, public_key, counter) VALUES (?, ?, ?, ?, ?)", 95 + ["pk2", userId, "cred2", "pubkey2", 0], 96 + ); 97 + 98 + const passkeys = testDb 99 + .query<{ id: string }, [number]>( 100 + "SELECT id FROM passkeys WHERE user_id = ?", 101 + ) 102 + .all(userId); 103 + 104 + expect(passkeys.length).toBe(2); 105 + }); 106 + 107 + test("admin can revoke user passkey", async () => { 108 + const result = testDb.run( 109 + "INSERT INTO users (email, password_hash) VALUES (?, ?)", 110 + ["test@example.com", "password123"], 111 + ); 112 + 113 + const userId = Number(result.lastInsertRowid); 114 + 115 + testDb.run( 116 + "INSERT INTO passkeys (id, user_id, credential_id, public_key, counter) VALUES (?, ?, ?, ?, ?)", 117 + ["pk1", userId, "cred1", "pubkey1", 0], 118 + ); 119 + 120 + let passkeys = testDb 121 + .query<{ id: string }, [number]>( 122 + "SELECT id FROM passkeys WHERE user_id = ?", 123 + ) 124 + .all(userId); 125 + expect(passkeys.length).toBe(1); 126 + 127 + testDb.run("DELETE FROM passkeys WHERE id = ? AND user_id = ?", [ 128 + "pk1", 129 + userId, 130 + ]); 131 + 132 + passkeys = testDb 133 + .query<{ id: string }, [number]>( 134 + "SELECT id FROM passkeys WHERE user_id = ?", 135 + ) 136 + .all(userId); 137 + expect(passkeys.length).toBe(0); 138 + }); 139 + 140 + test("updating password clears user sessions", async () => { 141 + testDb.run(` 142 + CREATE TABLE sessions ( 143 + id TEXT PRIMARY KEY, 144 + user_id INTEGER NOT NULL, 145 + ip_address TEXT, 146 + user_agent TEXT, 147 + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 148 + expires_at INTEGER NOT NULL, 149 + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 150 + ) 151 + `); 152 + 153 + const result = testDb.run( 154 + "INSERT INTO users (email, password_hash) VALUES (?, ?)", 155 + ["test@example.com", "password123"], 156 + ); 157 + 158 + const userId = Number(result.lastInsertRowid); 159 + 160 + const expiresAt = Math.floor(Date.now() / 1000) + 3600; 161 + testDb.run( 162 + "INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)", 163 + ["session1", userId, expiresAt], 164 + ); 165 + 166 + let sessions = testDb 167 + .query<{ id: string }, [number]>( 168 + "SELECT id FROM sessions WHERE user_id = ?", 169 + ) 170 + .all(userId); 171 + expect(sessions.length).toBe(1); 172 + 173 + testDb.run("UPDATE users SET password_hash = ? WHERE id = ?", [ 174 + "newpassword", 175 + userId, 176 + ]); 177 + testDb.run("DELETE FROM sessions WHERE user_id = ?", [userId]); 178 + 179 + sessions = testDb 180 + .query<{ id: string }, [number]>( 181 + "SELECT id FROM sessions WHERE user_id = ?", 182 + ) 183 + .all(userId); 184 + expect(sessions.length).toBe(0); 185 + }); 186 +
+74 -4
src/lib/auth.ts
··· 11 11 avatar: string; 12 12 created_at: number; 13 13 role: UserRole; 14 + last_login: number | null; 14 15 } 15 16 16 17 export interface Session { ··· 56 57 57 58 const user = db 58 59 .query<User, [number]>( 59 - "SELECT id, email, name, avatar, created_at, role FROM users WHERE id = ?", 60 + "SELECT id, email, name, avatar, created_at, role, last_login FROM users WHERE id = ?", 60 61 ) 61 62 .get(session.user_id); 62 63 ··· 94 95 95 96 const user = db 96 97 .query<User, [number]>( 97 - "SELECT id, email, name, avatar, created_at, role FROM users WHERE id = ?", 98 + "SELECT id, email, name, avatar, created_at, role, last_login FROM users WHERE id = ?", 98 99 ) 99 100 .get(Number(result.lastInsertRowid)); 100 101 ··· 119 120 password_hash: string; 120 121 created_at: number; 121 122 role: UserRole; 123 + last_login: number | null; 122 124 }, 123 125 [string] 124 126 >( 125 - "SELECT id, email, name, avatar, password_hash, created_at, role FROM users WHERE email = ?", 127 + "SELECT id, email, name, avatar, password_hash, created_at, role, last_login FROM users WHERE email = ?", 126 128 ) 127 129 .get(email); 128 130 ··· 135 137 136 138 if (password !== result.password_hash) return null; 137 139 140 + // Update last_login 141 + const now = Math.floor(Date.now() / 1000); 142 + db.run("UPDATE users SET last_login = ? WHERE id = ?", [now, result.id]); 143 + 138 144 return { 139 145 id: result.id, 140 146 email: result.email, ··· 142 148 avatar: result.avatar, 143 149 created_at: result.created_at, 144 150 role: result.role, 151 + last_login: now, 145 152 }; 146 153 } 147 154 ··· 221 228 avatar: string; 222 229 created_at: number; 223 230 role: UserRole; 231 + last_login: number | null; 224 232 }, 225 233 [] 226 - >("SELECT id, email, name, avatar, created_at, role FROM users ORDER BY created_at DESC") 234 + >("SELECT id, email, name, avatar, created_at, role, last_login FROM users ORDER BY created_at DESC") 227 235 .all(); 228 236 } 229 237 ··· 311 319 // Files might not exist, ignore errors 312 320 } 313 321 } 322 + 323 + export function getSessionsForUser(userId: number): Session[] { 324 + const now = Math.floor(Date.now() / 1000); 325 + return db 326 + .query<Session, [number, number]>( 327 + "SELECT id, user_id, ip_address, user_agent, created_at, expires_at FROM sessions WHERE user_id = ? AND expires_at > ? ORDER BY created_at DESC", 328 + ) 329 + .all(userId, now); 330 + } 331 + 332 + export function deleteSessionById( 333 + sessionId: string, 334 + userId: number, 335 + ): boolean { 336 + const result = db.run( 337 + "DELETE FROM sessions WHERE id = ? AND user_id = ?", 338 + [sessionId, userId], 339 + ); 340 + return result.changes > 0; 341 + } 342 + 343 + export function deleteAllUserSessions(userId: number): void { 344 + db.run("DELETE FROM sessions WHERE user_id = ?", [userId]); 345 + } 346 + 347 + export function updateUserEmailAddress( 348 + userId: number, 349 + newEmail: string, 350 + ): void { 351 + db.run("UPDATE users SET email = ? WHERE id = ?", [newEmail, userId]); 352 + } 353 + 354 + export interface UserWithStats { 355 + id: number; 356 + email: string; 357 + name: string | null; 358 + avatar: string; 359 + created_at: number; 360 + role: UserRole; 361 + last_login: number | null; 362 + transcription_count: number; 363 + } 364 + 365 + export function getAllUsersWithStats(): UserWithStats[] { 366 + return db 367 + .query<UserWithStats, []>( 368 + `SELECT 369 + u.id, 370 + u.email, 371 + u.name, 372 + u.avatar, 373 + u.created_at, 374 + u.role, 375 + u.last_login, 376 + COUNT(t.id) as transcription_count 377 + FROM users u 378 + LEFT JOIN transcriptions t ON u.id = t.user_id 379 + GROUP BY u.id 380 + ORDER BY u.created_at DESC`, 381 + ) 382 + .all(); 383 + }
+5 -2
src/lib/passkey.ts
··· 297 297 // Remove used challenge 298 298 authenticationChallenges.delete(expectedChallenge); 299 299 300 - // Update last used timestamp and counter 300 + // Update last used timestamp and counter for passkey 301 301 const now = Math.floor(Date.now() / 1000); 302 302 db.run( 303 303 "UPDATE passkeys SET last_used_at = ?, counter = ? WHERE id = ?", 304 304 [now, verification.authenticationInfo.newCounter, passkey.id], 305 305 ); 306 306 307 + // Update user's last_login 308 + db.run("UPDATE users SET last_login = ? WHERE id = ?", [now, passkey.user_id]); 309 + 307 310 // Get user 308 311 const user = db 309 312 .query<User, [number]>( 310 - "SELECT id, email, name, avatar, created_at, role FROM users WHERE id = ?", 313 + "SELECT id, email, name, avatar, created_at, role, last_login FROM users WHERE id = ?", 311 314 ) 312 315 .get(passkey.user_id); 313 316
+870 -8
src/pages/admin.html
··· 273 273 opacity: 0.5; 274 274 cursor: not-allowed; 275 275 } 276 + 277 + tbody tr { 278 + cursor: pointer; 279 + } 280 + 281 + tbody tr:hover { 282 + background: rgba(0, 0, 0, 0.04); 283 + } 284 + 285 + .modal { 286 + display: none; 287 + position: fixed; 288 + top: 0; 289 + left: 0; 290 + right: 0; 291 + bottom: 0; 292 + background: rgba(0, 0, 0, 0.5); 293 + z-index: 1000; 294 + align-items: center; 295 + justify-content: center; 296 + padding: 2rem; 297 + } 298 + 299 + .modal.active { 300 + display: flex; 301 + } 302 + 303 + .modal-content { 304 + background: var(--background); 305 + border-radius: 8px; 306 + max-width: 40rem; 307 + width: 100%; 308 + max-height: 80vh; 309 + overflow-y: auto; 310 + box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.3); 311 + } 312 + 313 + .modal-header { 314 + padding: 1.5rem; 315 + border-bottom: 2px solid var(--secondary); 316 + display: flex; 317 + justify-content: space-between; 318 + align-items: center; 319 + } 320 + 321 + .modal-title { 322 + font-size: 1.5rem; 323 + font-weight: 600; 324 + color: var(--text); 325 + margin: 0; 326 + } 327 + 328 + .modal-close { 329 + background: transparent; 330 + border: none; 331 + font-size: 1.5rem; 332 + cursor: pointer; 333 + color: var(--text); 334 + padding: 0; 335 + width: 2rem; 336 + height: 2rem; 337 + display: flex; 338 + align-items: center; 339 + justify-content: center; 340 + border-radius: 4px; 341 + transition: background 0.2s; 342 + } 343 + 344 + .modal-close:hover { 345 + background: var(--secondary); 346 + } 347 + 348 + .modal-body { 349 + padding: 1.5rem; 350 + } 351 + 352 + .detail-section { 353 + margin-bottom: 2rem; 354 + } 355 + 356 + .detail-section:last-child { 357 + margin-bottom: 0; 358 + } 359 + 360 + .detail-section-title { 361 + font-size: 1.125rem; 362 + font-weight: 600; 363 + color: var(--text); 364 + margin-bottom: 1rem; 365 + padding-bottom: 0.5rem; 366 + border-bottom: 2px solid var(--secondary); 367 + } 368 + 369 + .detail-row { 370 + display: flex; 371 + justify-content: space-between; 372 + align-items: center; 373 + padding: 0.75rem 0; 374 + border-bottom: 1px solid var(--secondary); 375 + } 376 + 377 + .detail-row:last-child { 378 + border-bottom: none; 379 + } 380 + 381 + .detail-label { 382 + font-weight: 500; 383 + color: var(--text); 384 + } 385 + 386 + .detail-value { 387 + color: var(--text); 388 + opacity: 0.8; 389 + } 390 + 391 + .form-group { 392 + margin-bottom: 1rem; 393 + } 394 + 395 + .form-label { 396 + display: block; 397 + font-weight: 500; 398 + color: var(--text); 399 + margin-bottom: 0.5rem; 400 + } 401 + 402 + .form-input { 403 + width: 100%; 404 + padding: 0.5rem 0.75rem; 405 + border: 2px solid var(--secondary); 406 + border-radius: 4px; 407 + font-size: 1rem; 408 + font-family: inherit; 409 + background: var(--background); 410 + color: var(--text); 411 + } 412 + 413 + .form-input:focus { 414 + outline: none; 415 + border-color: var(--primary); 416 + } 417 + 418 + .btn { 419 + padding: 0.5rem 1rem; 420 + border: none; 421 + border-radius: 4px; 422 + font-size: 1rem; 423 + font-weight: 500; 424 + font-family: inherit; 425 + cursor: pointer; 426 + transition: all 0.2s; 427 + } 428 + 429 + .btn-primary { 430 + background: var(--primary); 431 + color: white; 432 + } 433 + 434 + .btn-primary:hover { 435 + background: var(--gunmetal); 436 + } 437 + 438 + .btn-primary:disabled { 439 + opacity: 0.5; 440 + cursor: not-allowed; 441 + } 442 + 443 + .btn-danger { 444 + background: #dc2626; 445 + color: white; 446 + } 447 + 448 + .btn-danger:hover { 449 + background: #b91c1c; 450 + } 451 + 452 + .btn-danger:disabled { 453 + opacity: 0.5; 454 + cursor: not-allowed; 455 + } 456 + 457 + .passkey-list { 458 + list-style: none; 459 + padding: 0; 460 + margin: 0; 461 + } 462 + 463 + .passkey-item { 464 + display: flex; 465 + justify-content: space-between; 466 + align-items: center; 467 + padding: 0.75rem; 468 + border: 2px solid var(--secondary); 469 + border-radius: 4px; 470 + margin-bottom: 0.5rem; 471 + } 472 + 473 + .passkey-item:last-child { 474 + margin-bottom: 0; 475 + } 476 + 477 + .passkey-info { 478 + flex: 1; 479 + } 480 + 481 + .passkey-name { 482 + font-weight: 500; 483 + color: var(--text); 484 + margin-bottom: 0.25rem; 485 + } 486 + 487 + .passkey-meta { 488 + font-size: 0.875rem; 489 + color: var(--text); 490 + opacity: 0.6; 491 + } 492 + 493 + .passkey-actions { 494 + display: flex; 495 + gap: 0.5rem; 496 + } 497 + 498 + .btn-small { 499 + padding: 0.25rem 0.75rem; 500 + font-size: 0.875rem; 501 + } 502 + 503 + .empty-passkeys { 504 + text-align: center; 505 + padding: 2rem; 506 + color: var(--text); 507 + opacity: 0.6; 508 + background: rgba(0, 0, 0, 0.02); 509 + border-radius: 4px; 510 + } 511 + 512 + .password-status { 513 + display: inline-block; 514 + padding: 0.25rem 0.75rem; 515 + border-radius: 4px; 516 + font-size: 0.875rem; 517 + font-weight: 500; 518 + } 519 + 520 + .password-status.has-password { 521 + background: #dcfce7; 522 + color: #166534; 523 + } 524 + 525 + .password-status.no-password { 526 + background: #fee2e2; 527 + color: #991b1b; 528 + } 529 + 530 + .search { 531 + width: 100%; 532 + max-width: 30rem; 533 + margin-bottom: 1rem; 534 + padding: 0.5rem 0.75rem; 535 + border: 2px solid var(--secondary); 536 + border-radius: 4px; 537 + font-size: 1rem; 538 + font-family: inherit; 539 + background: var(--background); 540 + color: var(--text); 541 + } 542 + 543 + .search:focus { 544 + outline: none; 545 + border-color: var(--primary); 546 + } 547 + 548 + th.sortable { 549 + cursor: pointer; 550 + user-select: none; 551 + position: relative; 552 + } 553 + 554 + th.sortable:hover { 555 + background: var(--gunmetal); 556 + } 557 + 558 + th.sortable::after { 559 + content: ''; 560 + margin-left: 0.5rem; 561 + opacity: 0.3; 562 + } 563 + 564 + th.sortable.asc::after { 565 + content: '▲'; 566 + opacity: 1; 567 + } 568 + 569 + th.sortable.desc::after { 570 + content: '▼'; 571 + opacity: 1; 572 + } 573 + 574 + .session-list { 575 + list-style: none; 576 + padding: 0; 577 + margin: 0; 578 + } 579 + 580 + .session-item { 581 + display: flex; 582 + justify-content: space-between; 583 + align-items: center; 584 + padding: 0.75rem; 585 + border: 2px solid var(--secondary); 586 + border-radius: 4px; 587 + margin-bottom: 0.5rem; 588 + } 589 + 590 + .session-item:last-child { 591 + margin-bottom: 0; 592 + } 593 + 594 + .session-info { 595 + flex: 1; 596 + } 597 + 598 + .session-device { 599 + font-weight: 500; 600 + color: var(--text); 601 + margin-bottom: 0.25rem; 602 + } 603 + 604 + .session-meta { 605 + font-size: 0.875rem; 606 + color: var(--text); 607 + opacity: 0.6; 608 + } 609 + 610 + .session-actions { 611 + display: flex; 612 + gap: 0.5rem; 613 + } 614 + 615 + .empty-sessions { 616 + text-align: center; 617 + padding: 2rem; 618 + color: var(--text); 619 + opacity: 0.6; 620 + background: rgba(0, 0, 0, 0.02); 621 + border-radius: 4px; 622 + } 623 + 624 + .section-actions { 625 + display: flex; 626 + justify-content: space-between; 627 + align-items: center; 628 + margin-bottom: 1rem; 629 + } 276 630 </style> 277 631 </head> 278 632 ··· 325 679 <div id="users-tab" class="tab-content"> 326 680 <div class="section"> 327 681 <h2 class="section-title">All Users</h2> 682 + <input type="text" id="user-search" class="search" placeholder="Search by name or email..." /> 328 683 <div id="users-table"></div> 329 684 </div> 330 685 </div> 331 686 </div> 332 687 </main> 333 688 689 + <div id="user-modal" class="modal"> 690 + <div class="modal-content"> 691 + <div class="modal-header"> 692 + <h2 class="modal-title">User Details</h2> 693 + <button class="modal-close" aria-label="Close">&times;</button> 694 + </div> 695 + <div class="modal-body"> 696 + <div class="detail-section"> 697 + <h3 class="detail-section-title">User Information</h3> 698 + <div class="detail-row"> 699 + <span class="detail-label">Email</span> 700 + <span class="detail-value" id="modal-email">-</span> 701 + </div> 702 + <div class="detail-row"> 703 + <span class="detail-label">Name</span> 704 + <span class="detail-value" id="modal-name">-</span> 705 + </div> 706 + <div class="detail-row"> 707 + <span class="detail-label">Role</span> 708 + <span class="detail-value" id="modal-role">-</span> 709 + </div> 710 + <div class="detail-row"> 711 + <span class="detail-label">Joined</span> 712 + <span class="detail-value" id="modal-joined">-</span> 713 + </div> 714 + <div class="detail-row"> 715 + <span class="detail-label">Last Login</span> 716 + <span class="detail-value" id="modal-last-login">-</span> 717 + </div> 718 + <div class="detail-row"> 719 + <span class="detail-label">Transcriptions</span> 720 + <span class="detail-value" id="modal-transcription-count">-</span> 721 + </div> 722 + <div class="detail-row"> 723 + <span class="detail-label">Password Status</span> 724 + <span id="modal-password-status">-</span> 725 + </div> 726 + </div> 727 + 728 + <div class="detail-section"> 729 + <h3 class="detail-section-title">Change Name</h3> 730 + <form id="change-name-form"> 731 + <div class="form-group"> 732 + <label class="form-label" for="new-name">New Name</label> 733 + <input type="text" id="new-name" class="form-input" placeholder="Enter new name"> 734 + </div> 735 + <button type="submit" class="btn btn-primary">Update Name</button> 736 + </form> 737 + </div> 738 + 739 + <div class="detail-section"> 740 + <h3 class="detail-section-title">Change Email</h3> 741 + <form id="change-email-form"> 742 + <div class="form-group"> 743 + <label class="form-label" for="new-email">New Email</label> 744 + <input type="email" id="new-email" class="form-input" placeholder="Enter new email"> 745 + </div> 746 + <button type="submit" class="btn btn-primary">Update Email</button> 747 + </form> 748 + </div> 749 + 750 + <div class="detail-section"> 751 + <h3 class="detail-section-title">Change Password</h3> 752 + <form id="change-password-form"> 753 + <div class="form-group"> 754 + <label class="form-label" for="new-password">New Password</label> 755 + <input type="password" id="new-password" class="form-input" placeholder="Enter new password (min 8 characters)"> 756 + </div> 757 + <button type="submit" class="btn btn-primary">Update Password</button> 758 + </form> 759 + </div> 760 + 761 + <div class="detail-section"> 762 + <h3 class="detail-section-title">Active Sessions</h3> 763 + <div class="section-actions"> 764 + <span class="detail-label" id="session-count">0 active sessions</span> 765 + <button id="logout-all-btn" class="btn btn-danger btn-small">Logout All Devices</button> 766 + </div> 767 + <div id="sessions-container"> 768 + <div class="loading">Loading sessions...</div> 769 + </div> 770 + </div> 771 + 772 + <div class="detail-section"> 773 + <h3 class="detail-section-title">Passkeys</h3> 774 + <div id="passkeys-container"> 775 + <div class="loading">Loading passkeys...</div> 776 + </div> 777 + </div> 778 + </div> 779 + </div> 780 + </div> 781 + 334 782 <script type="module" src="../components/auth.ts"></script> 335 783 <script type="module"> 336 784 const errorMessage = document.getElementById('error-message'); ··· 338 786 const content = document.getElementById('content'); 339 787 const transcriptionsTable = document.getElementById('transcriptions-table'); 340 788 const usersTable = document.getElementById('users-table'); 789 + const userModal = document.getElementById('user-modal'); 790 + const modalClose = userModal.querySelector('.modal-close'); 341 791 342 792 let currentUserEmail = null; 793 + let currentModalUserId = null; 794 + let allUsers = []; 795 + let userSortKey = 'created_at'; 796 + let userSortDirection = 'desc'; 797 + let userSearchTerm = ''; 343 798 344 799 // Get current user info 345 800 async function getCurrentUser() { ··· 365 820 return date.toLocaleString(); 366 821 } 367 822 823 + function parseUserAgent(userAgent) { 824 + if (!userAgent) return '🖥️ Unknown Device'; 825 + if (userAgent.includes('iPhone')) return '📱 iPhone'; 826 + if (userAgent.includes('iPad')) return '📱 iPad'; 827 + if (userAgent.includes('Android')) return '📱 Android'; 828 + if (userAgent.includes('Mac')) return '💻 Mac'; 829 + if (userAgent.includes('Windows')) return '💻 Windows'; 830 + if (userAgent.includes('Linux')) return '💻 Linux'; 831 + return '🖥️ Unknown Device'; 832 + } 833 + 834 + // Modal functions 835 + function openUserModal(userId) { 836 + currentModalUserId = userId; 837 + userModal.classList.add('active'); 838 + loadUserDetails(userId); 839 + } 840 + 841 + function closeUserModal() { 842 + userModal.classList.remove('active'); 843 + currentModalUserId = null; 844 + } 845 + 846 + async function loadUserDetails(userId) { 847 + try { 848 + const res = await fetch(`/api/admin/users/${userId}/details`); 849 + if (!res.ok) { 850 + throw new Error('Failed to load user details'); 851 + } 852 + 853 + const user = await res.json(); 854 + 855 + document.getElementById('modal-email').textContent = user.email; 856 + document.getElementById('modal-name').textContent = user.name || 'Not set'; 857 + document.getElementById('modal-role').textContent = user.role; 858 + document.getElementById('modal-joined').textContent = formatTimestamp(user.created_at); 859 + document.getElementById('modal-last-login').textContent = user.last_login ? formatTimestamp(user.last_login) : 'Never'; 860 + document.getElementById('modal-transcription-count').textContent = user.transcriptionCount; 861 + 862 + const passwordStatus = document.getElementById('modal-password-status'); 863 + if (user.hasPassword) { 864 + passwordStatus.innerHTML = '<span class="password-status has-password">Has password</span>'; 865 + } else { 866 + passwordStatus.innerHTML = '<span class="password-status no-password">No password (passkey only)</span>'; 867 + } 868 + 869 + document.getElementById('new-name').value = user.name || ''; 870 + document.getElementById('new-email').value = user.email; 871 + 872 + renderSessions(user.sessions, userId); 873 + renderPasskeys(user.passkeys, userId); 874 + } catch { 875 + alert('Failed to load user details'); 876 + closeUserModal(); 877 + } 878 + } 879 + 880 + function renderSessions(sessions, userId) { 881 + const container = document.getElementById('sessions-container'); 882 + const sessionCount = document.getElementById('session-count'); 883 + const logoutAllBtn = document.getElementById('logout-all-btn'); 884 + 885 + sessionCount.textContent = `${sessions.length} active session${sessions.length !== 1 ? 's' : ''}`; 886 + 887 + if (sessions.length === 0) { 888 + container.innerHTML = '<div class="empty-sessions">No active sessions</div>'; 889 + logoutAllBtn.disabled = true; 890 + return; 891 + } 892 + 893 + logoutAllBtn.disabled = false; 894 + 895 + const list = document.createElement('ul'); 896 + list.className = 'session-list'; 897 + list.innerHTML = sessions.map(s => ` 898 + <li class="session-item"> 899 + <div class="session-info"> 900 + <div class="session-device">${parseUserAgent(s.user_agent)}</div> 901 + <div class="session-meta"> 902 + IP: ${s.ip_address || 'Unknown'} • 903 + Created: ${formatTimestamp(s.created_at)} • 904 + Expires: ${formatTimestamp(s.expires_at)} 905 + </div> 906 + </div> 907 + <div class="session-actions"> 908 + <button class="btn btn-danger btn-small revoke-session-btn" data-session-id="${s.id}" data-user-id="${userId}"> 909 + Revoke 910 + </button> 911 + </div> 912 + </li> 913 + `).join(''); 914 + 915 + container.innerHTML = ''; 916 + container.appendChild(list); 917 + 918 + // Add revoke event listeners 919 + list.querySelectorAll('.revoke-session-btn').forEach(btn => { 920 + btn.addEventListener('click', async (e) => { 921 + const button = e.target; 922 + const sessionId = button.dataset.sessionId; 923 + const userId = button.dataset.userId; 924 + 925 + if (!confirm('Revoke this session? The user will be logged out of this device.')) { 926 + return; 927 + } 928 + 929 + button.disabled = true; 930 + button.textContent = 'Revoking...'; 931 + 932 + try { 933 + const res = await fetch(`/api/admin/users/${userId}/sessions/${sessionId}`, { 934 + method: 'DELETE' 935 + }); 936 + 937 + if (!res.ok) { 938 + throw new Error('Failed to revoke session'); 939 + } 940 + 941 + await loadUserDetails(userId); 942 + } catch { 943 + alert('Failed to revoke session'); 944 + button.disabled = false; 945 + button.textContent = 'Revoke'; 946 + } 947 + }); 948 + }); 949 + } 950 + 951 + function renderPasskeys(passkeys, userId) { 952 + const container = document.getElementById('passkeys-container'); 953 + 954 + if (passkeys.length === 0) { 955 + container.innerHTML = '<div class="empty-passkeys">No passkeys registered</div>'; 956 + return; 957 + } 958 + 959 + const list = document.createElement('ul'); 960 + list.className = 'passkey-list'; 961 + list.innerHTML = passkeys.map(pk => ` 962 + <li class="passkey-item"> 963 + <div class="passkey-info"> 964 + <div class="passkey-name">${pk.name || 'Unnamed Passkey'}</div> 965 + <div class="passkey-meta"> 966 + Created: ${formatTimestamp(pk.created_at)} 967 + ${pk.last_used_at ? ` • Last used: ${formatTimestamp(pk.last_used_at)}` : ''} 968 + </div> 969 + </div> 970 + <div class="passkey-actions"> 971 + <button class="btn btn-danger btn-small revoke-passkey-btn" data-passkey-id="${pk.id}" data-user-id="${userId}"> 972 + Revoke 973 + </button> 974 + </div> 975 + </li> 976 + `).join(''); 977 + 978 + container.innerHTML = ''; 979 + container.appendChild(list); 980 + 981 + list.querySelectorAll('.revoke-passkey-btn').forEach(btn => { 982 + btn.addEventListener('click', async (e) => { 983 + const button = e.target; 984 + const passkeyId = button.dataset.passkeyId; 985 + const userId = button.dataset.userId; 986 + 987 + if (!confirm('Are you sure you want to revoke this passkey? The user will no longer be able to use it to sign in.')) { 988 + return; 989 + } 990 + 991 + button.disabled = true; 992 + button.textContent = 'Revoking...'; 993 + 994 + try { 995 + const res = await fetch(`/api/admin/users/${userId}/passkeys/${passkeyId}`, { 996 + method: 'DELETE' 997 + }); 998 + 999 + if (!res.ok) { 1000 + throw new Error('Failed to revoke passkey'); 1001 + } 1002 + 1003 + await loadUserDetails(userId); 1004 + } catch { 1005 + alert('Failed to revoke passkey'); 1006 + button.disabled = false; 1007 + button.textContent = 'Revoke'; 1008 + } 1009 + }); 1010 + }); 1011 + } 1012 + 1013 + modalClose.addEventListener('click', closeUserModal); 1014 + userModal.addEventListener('click', (e) => { 1015 + if (e.target === userModal) { 1016 + closeUserModal(); 1017 + } 1018 + }); 1019 + 1020 + document.getElementById('change-name-form').addEventListener('submit', async (e) => { 1021 + e.preventDefault(); 1022 + const name = document.getElementById('new-name').value.trim(); 1023 + 1024 + if (!name) { 1025 + alert('Please enter a name'); 1026 + return; 1027 + } 1028 + 1029 + const submitBtn = e.target.querySelector('button[type="submit"]'); 1030 + submitBtn.disabled = true; 1031 + submitBtn.textContent = 'Updating...'; 1032 + 1033 + try { 1034 + const res = await fetch(`/api/admin/users/${currentModalUserId}/name`, { 1035 + method: 'PUT', 1036 + headers: {'Content-Type': 'application/json'}, 1037 + body: JSON.stringify({name}) 1038 + }); 1039 + 1040 + if (!res.ok) { 1041 + throw new Error('Failed to update name'); 1042 + } 1043 + 1044 + alert('Name updated successfully'); 1045 + await loadUserDetails(currentModalUserId); 1046 + await loadData(); 1047 + } catch { 1048 + alert('Failed to update name'); 1049 + } finally { 1050 + submitBtn.disabled = false; 1051 + submitBtn.textContent = 'Update Name'; 1052 + } 1053 + }); 1054 + 1055 + document.getElementById('change-email-form').addEventListener('submit', async (e) => { 1056 + e.preventDefault(); 1057 + const email = document.getElementById('new-email').value.trim(); 1058 + 1059 + if (!email || !email.includes('@')) { 1060 + alert('Please enter a valid email'); 1061 + return; 1062 + } 1063 + 1064 + const submitBtn = e.target.querySelector('button[type="submit"]'); 1065 + submitBtn.disabled = true; 1066 + submitBtn.textContent = 'Updating...'; 1067 + 1068 + try { 1069 + const res = await fetch(`/api/admin/users/${currentModalUserId}/email`, { 1070 + method: 'PUT', 1071 + headers: {'Content-Type': 'application/json'}, 1072 + body: JSON.stringify({email}) 1073 + }); 1074 + 1075 + if (!res.ok) { 1076 + const data = await res.json(); 1077 + throw new Error(data.error || 'Failed to update email'); 1078 + } 1079 + 1080 + alert('Email updated successfully'); 1081 + await loadUserDetails(currentModalUserId); 1082 + await loadData(); 1083 + } catch (error) { 1084 + alert(error.message || 'Failed to update email'); 1085 + } finally { 1086 + submitBtn.disabled = false; 1087 + submitBtn.textContent = 'Update Email'; 1088 + } 1089 + }); 1090 + 1091 + document.getElementById('change-password-form').addEventListener('submit', async (e) => { 1092 + e.preventDefault(); 1093 + const password = document.getElementById('new-password').value; 1094 + 1095 + if (password.length < 8) { 1096 + alert('Password must be at least 8 characters'); 1097 + return; 1098 + } 1099 + 1100 + if (!confirm('Are you sure you want to change this user\'s password? This will log them out of all devices.')) { 1101 + return; 1102 + } 1103 + 1104 + const submitBtn = e.target.querySelector('button[type="submit"]'); 1105 + submitBtn.disabled = true; 1106 + submitBtn.textContent = 'Updating...'; 1107 + 1108 + try { 1109 + const res = await fetch(`/api/admin/users/${currentModalUserId}/password`, { 1110 + method: 'PUT', 1111 + headers: {'Content-Type': 'application/json'}, 1112 + body: JSON.stringify({password}) 1113 + }); 1114 + 1115 + if (!res.ok) { 1116 + throw new Error('Failed to update password'); 1117 + } 1118 + 1119 + alert('Password updated successfully. User has been logged out of all devices.'); 1120 + document.getElementById('new-password').value = ''; 1121 + await loadUserDetails(currentModalUserId); 1122 + } catch { 1123 + alert('Failed to update password'); 1124 + } finally { 1125 + submitBtn.disabled = false; 1126 + submitBtn.textContent = 'Update Password'; 1127 + } 1128 + }); 1129 + 1130 + document.getElementById('logout-all-btn').addEventListener('click', async (e) => { 1131 + if (!confirm('Are you sure you want to logout this user from ALL devices? This will terminate all active sessions.')) { 1132 + return; 1133 + } 1134 + 1135 + const button = e.target; 1136 + button.disabled = true; 1137 + button.textContent = 'Logging out...'; 1138 + 1139 + try { 1140 + const res = await fetch(`/api/admin/users/${currentModalUserId}/sessions`, { 1141 + method: 'DELETE' 1142 + }); 1143 + 1144 + if (!res.ok) { 1145 + throw new Error('Failed to logout all devices'); 1146 + } 1147 + 1148 + alert('User logged out from all devices'); 1149 + await loadUserDetails(currentModalUserId); 1150 + } catch { 1151 + alert('Failed to logout all devices'); 1152 + } finally { 1153 + button.disabled = false; 1154 + button.textContent = 'Logout All Devices'; 1155 + } 1156 + }); 1157 + 1158 + 368 1159 function renderTranscriptions(transcriptions) { 369 1160 if (transcriptions.length === 0) { 370 1161 transcriptionsTable.innerHTML = '<div class="empty-state">No transcriptions yet</div>'; ··· 447 1238 } 448 1239 449 1240 function renderUsers(users) { 450 - if (users.length === 0) { 451 - usersTable.innerHTML = '<div class="empty-state">No users yet</div>'; 1241 + allUsers = users; 1242 + 1243 + // Filter users based on search term 1244 + let filteredUsers = users.filter(u => { 1245 + if (!userSearchTerm) return true; 1246 + const term = userSearchTerm.toLowerCase(); 1247 + const name = (u.name || '').toLowerCase(); 1248 + const email = u.email.toLowerCase(); 1249 + return name.includes(term) || email.includes(term); 1250 + }); 1251 + 1252 + // Sort users 1253 + filteredUsers.sort((a, b) => { 1254 + let aVal = a[userSortKey]; 1255 + let bVal = b[userSortKey]; 1256 + 1257 + // Handle null values 1258 + if (aVal === null || aVal === undefined) aVal = ''; 1259 + if (bVal === null || bVal === undefined) bVal = ''; 1260 + 1261 + let comparison = 0; 1262 + if (typeof aVal === 'string' && typeof bVal === 'string') { 1263 + comparison = aVal.localeCompare(bVal); 1264 + } else if (typeof aVal === 'number' && typeof bVal === 'number') { 1265 + comparison = aVal - bVal; 1266 + } else { 1267 + comparison = String(aVal).localeCompare(String(bVal)); 1268 + } 1269 + 1270 + return userSortDirection === 'asc' ? comparison : -comparison; 1271 + }); 1272 + 1273 + if (filteredUsers.length === 0) { 1274 + usersTable.innerHTML = '<div class="empty-state">No users found</div>'; 452 1275 return; 453 1276 } 454 1277 ··· 456 1279 table.innerHTML = ` 457 1280 <thead> 458 1281 <tr> 459 - <th>User</th> 460 - <th>Email</th> 461 - <th>Role</th> 462 - <th>Joined</th> 1282 + <th class="sortable ${userSortKey === 'name' ? userSortDirection : ''}" data-sort="name">User</th> 1283 + <th class="sortable ${userSortKey === 'email' ? userSortDirection : ''}" data-sort="email">Email</th> 1284 + <th class="sortable ${userSortKey === 'role' ? userSortDirection : ''}" data-sort="role">Role</th> 1285 + <th class="sortable ${userSortKey === 'transcription_count' ? userSortDirection : ''}" data-sort="transcription_count">Transcriptions</th> 1286 + <th class="sortable ${userSortKey === 'last_login' ? userSortDirection : ''}" data-sort="last_login">Last Login</th> 1287 + <th class="sortable ${userSortKey === 'created_at' ? userSortDirection : ''}" data-sort="created_at">Joined</th> 463 1288 <th>Actions</th> 464 1289 </tr> 465 1290 </thead> 466 1291 <tbody> 467 - ${users.map(u => ` 1292 + ${filteredUsers.map(u => ` 468 1293 <tr> 469 1294 <td> 470 1295 <div class="user-info"> ··· 484 1309 <option value="admin" ${u.role === 'admin' ? 'selected' : ''}>Admin</option> 485 1310 </select> 486 1311 </td> 1312 + <td>${u.transcription_count}</td> 1313 + <td class="timestamp">${u.last_login ? formatTimestamp(u.last_login) : 'Never'}</td> 487 1314 <td class="timestamp">${formatTimestamp(u.created_at)}</td> 488 1315 <td> 489 - <button class="delete-user-btn" data-user-id="${u.id}" data-user-email="${u.email}">Delete</button> 1316 + <div class="actions"> 1317 + <button class="delete-user-btn" data-user-id="${u.id}" data-user-email="${u.email}">Delete</button> 1318 + </div> 490 1319 </td> 491 1320 </tr> 492 1321 `).join('')} ··· 495 1324 usersTable.innerHTML = ''; 496 1325 usersTable.appendChild(table); 497 1326 1327 + // Add sort event listeners 1328 + table.querySelectorAll('th.sortable').forEach(th => { 1329 + th.addEventListener('click', () => { 1330 + const sortKey = th.dataset.sort; 1331 + if (userSortKey === sortKey) { 1332 + userSortDirection = userSortDirection === 'asc' ? 'desc' : 'asc'; 1333 + } else { 1334 + userSortKey = sortKey; 1335 + userSortDirection = 'asc'; 1336 + } 1337 + renderUsers(allUsers); 1338 + }); 1339 + }); 1340 + 498 1341 // Add role change event listeners 499 1342 table.querySelectorAll('.role-select').forEach(select => { 500 1343 select.addEventListener('change', async (e) => { ··· 584 1427 } 585 1428 }); 586 1429 }); 1430 + 1431 + // Add click event to table rows to open modal 1432 + table.querySelectorAll('tbody tr').forEach(row => { 1433 + row.addEventListener('click', (e) => { 1434 + // Don't open modal if clicking on delete button or role select 1435 + if (e.target.closest('.delete-user-btn') || e.target.closest('.role-select')) { 1436 + return; 1437 + } 1438 + 1439 + const userId = row.querySelector('.delete-user-btn').dataset.userId; 1440 + openUserModal(userId); 1441 + }); 1442 + }); 587 1443 } 588 1444 589 1445 async function loadData() { ··· 632 1488 tab.classList.add('active'); 633 1489 document.getElementById(`${tabName}-tab`).classList.add('active'); 634 1490 }); 1491 + }); 1492 + 1493 + // User search 1494 + document.getElementById('user-search').addEventListener('input', (e) => { 1495 + userSearchTerm = e.target.value.trim(); 1496 + renderUsers(allUsers); 635 1497 }); 636 1498 637 1499 // Initialize