···138138 CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
139139 `,
140140 },
141141+ {
142142+ version: 8,
143143+ name: "Add last_login to users",
144144+ sql: `
145145+ ALTER TABLE users ADD COLUMN last_login INTEGER;
146146+ CREATE INDEX IF NOT EXISTS idx_users_last_login ON users(last_login);
147147+ `,
148148+ },
141149];
142150143151function getCurrentVersion(): number {
···11+import { afterEach, beforeEach, expect, test } from "bun:test";
22+import { Database } from "bun:sqlite";
33+44+let testDb: Database;
55+66+beforeEach(() => {
77+ testDb = new Database(":memory:");
88+99+ testDb.run(`
1010+ CREATE TABLE users (
1111+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1212+ email TEXT UNIQUE NOT NULL,
1313+ password_hash TEXT,
1414+ name TEXT,
1515+ avatar TEXT DEFAULT 'd',
1616+ created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
1717+ role TEXT NOT NULL DEFAULT 'user'
1818+ )
1919+ `);
2020+2121+ testDb.run(`
2222+ CREATE TABLE passkeys (
2323+ id TEXT PRIMARY KEY,
2424+ user_id INTEGER NOT NULL,
2525+ credential_id TEXT NOT NULL UNIQUE,
2626+ public_key TEXT NOT NULL,
2727+ counter INTEGER NOT NULL DEFAULT 0,
2828+ transports TEXT,
2929+ name TEXT,
3030+ created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
3131+ last_used_at INTEGER,
3232+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
3333+ )
3434+ `);
3535+});
3636+3737+afterEach(() => {
3838+ testDb.close();
3939+});
4040+4141+test("admin can update user name", async () => {
4242+ const result = testDb.run(
4343+ "INSERT INTO users (email, password_hash, name, avatar) VALUES (?, ?, ?, ?)",
4444+ ["test@example.com", "password123", "Old Name", "avatar1"],
4545+ );
4646+4747+ const userId = Number(result.lastInsertRowid);
4848+4949+ testDb.run("UPDATE users SET name = ? WHERE id = ?", ["New Name", userId]);
5050+5151+ const user = testDb
5252+ .query<{ name: string }, [number]>("SELECT name FROM users WHERE id = ?")
5353+ .get(userId);
5454+5555+ expect(user?.name).toBe("New Name");
5656+});
5757+5858+test("admin can update user password", async () => {
5959+ const result = testDb.run(
6060+ "INSERT INTO users (email, password_hash) VALUES (?, ?)",
6161+ ["test@example.com", "password123"],
6262+ );
6363+6464+ const userId = Number(result.lastInsertRowid);
6565+6666+ testDb.run("UPDATE users SET password_hash = ? WHERE id = ?", [
6767+ "newpassword456",
6868+ userId,
6969+ ]);
7070+7171+ const user = testDb
7272+ .query<{ password_hash: string }, [number]>(
7373+ "SELECT password_hash FROM users WHERE id = ?",
7474+ )
7575+ .get(userId);
7676+7777+ expect(user?.password_hash).toBe("newpassword456");
7878+});
7979+8080+test("admin can view user passkeys", async () => {
8181+ const result = testDb.run(
8282+ "INSERT INTO users (email, password_hash) VALUES (?, ?)",
8383+ ["test@example.com", "password123"],
8484+ );
8585+8686+ const userId = Number(result.lastInsertRowid);
8787+8888+ testDb.run(
8989+ "INSERT INTO passkeys (id, user_id, credential_id, public_key, counter) VALUES (?, ?, ?, ?, ?)",
9090+ ["pk1", userId, "cred1", "pubkey1", 0],
9191+ );
9292+9393+ testDb.run(
9494+ "INSERT INTO passkeys (id, user_id, credential_id, public_key, counter) VALUES (?, ?, ?, ?, ?)",
9595+ ["pk2", userId, "cred2", "pubkey2", 0],
9696+ );
9797+9898+ const passkeys = testDb
9999+ .query<{ id: string }, [number]>(
100100+ "SELECT id FROM passkeys WHERE user_id = ?",
101101+ )
102102+ .all(userId);
103103+104104+ expect(passkeys.length).toBe(2);
105105+});
106106+107107+test("admin can revoke user passkey", async () => {
108108+ const result = testDb.run(
109109+ "INSERT INTO users (email, password_hash) VALUES (?, ?)",
110110+ ["test@example.com", "password123"],
111111+ );
112112+113113+ const userId = Number(result.lastInsertRowid);
114114+115115+ testDb.run(
116116+ "INSERT INTO passkeys (id, user_id, credential_id, public_key, counter) VALUES (?, ?, ?, ?, ?)",
117117+ ["pk1", userId, "cred1", "pubkey1", 0],
118118+ );
119119+120120+ let passkeys = testDb
121121+ .query<{ id: string }, [number]>(
122122+ "SELECT id FROM passkeys WHERE user_id = ?",
123123+ )
124124+ .all(userId);
125125+ expect(passkeys.length).toBe(1);
126126+127127+ testDb.run("DELETE FROM passkeys WHERE id = ? AND user_id = ?", [
128128+ "pk1",
129129+ userId,
130130+ ]);
131131+132132+ passkeys = testDb
133133+ .query<{ id: string }, [number]>(
134134+ "SELECT id FROM passkeys WHERE user_id = ?",
135135+ )
136136+ .all(userId);
137137+ expect(passkeys.length).toBe(0);
138138+});
139139+140140+test("updating password clears user sessions", async () => {
141141+ testDb.run(`
142142+ CREATE TABLE sessions (
143143+ id TEXT PRIMARY KEY,
144144+ user_id INTEGER NOT NULL,
145145+ ip_address TEXT,
146146+ user_agent TEXT,
147147+ created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
148148+ expires_at INTEGER NOT NULL,
149149+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
150150+ )
151151+ `);
152152+153153+ const result = testDb.run(
154154+ "INSERT INTO users (email, password_hash) VALUES (?, ?)",
155155+ ["test@example.com", "password123"],
156156+ );
157157+158158+ const userId = Number(result.lastInsertRowid);
159159+160160+ const expiresAt = Math.floor(Date.now() / 1000) + 3600;
161161+ testDb.run(
162162+ "INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)",
163163+ ["session1", userId, expiresAt],
164164+ );
165165+166166+ let sessions = testDb
167167+ .query<{ id: string }, [number]>(
168168+ "SELECT id FROM sessions WHERE user_id = ?",
169169+ )
170170+ .all(userId);
171171+ expect(sessions.length).toBe(1);
172172+173173+ testDb.run("UPDATE users SET password_hash = ? WHERE id = ?", [
174174+ "newpassword",
175175+ userId,
176176+ ]);
177177+ testDb.run("DELETE FROM sessions WHERE user_id = ?", [userId]);
178178+179179+ sessions = testDb
180180+ .query<{ id: string }, [number]>(
181181+ "SELECT id FROM sessions WHERE user_id = ?",
182182+ )
183183+ .all(userId);
184184+ expect(sessions.length).toBe(0);
185185+});
186186+
+74-4
src/lib/auth.ts
···1111 avatar: string;
1212 created_at: number;
1313 role: UserRole;
1414+ last_login: number | null;
1415}
15161617export interface Session {
···56575758 const user = db
5859 .query<User, [number]>(
5959- "SELECT id, email, name, avatar, created_at, role FROM users WHERE id = ?",
6060+ "SELECT id, email, name, avatar, created_at, role, last_login FROM users WHERE id = ?",
6061 )
6162 .get(session.user_id);
6263···94959596 const user = db
9697 .query<User, [number]>(
9797- "SELECT id, email, name, avatar, created_at, role FROM users WHERE id = ?",
9898+ "SELECT id, email, name, avatar, created_at, role, last_login FROM users WHERE id = ?",
9899 )
99100 .get(Number(result.lastInsertRowid));
100101···119120 password_hash: string;
120121 created_at: number;
121122 role: UserRole;
123123+ last_login: number | null;
122124 },
123125 [string]
124126 >(
125125- "SELECT id, email, name, avatar, password_hash, created_at, role FROM users WHERE email = ?",
127127+ "SELECT id, email, name, avatar, password_hash, created_at, role, last_login FROM users WHERE email = ?",
126128 )
127129 .get(email);
128130···135137136138 if (password !== result.password_hash) return null;
137139140140+ // Update last_login
141141+ const now = Math.floor(Date.now() / 1000);
142142+ db.run("UPDATE users SET last_login = ? WHERE id = ?", [now, result.id]);
143143+138144 return {
139145 id: result.id,
140146 email: result.email,
···142148 avatar: result.avatar,
143149 created_at: result.created_at,
144150 role: result.role,
151151+ last_login: now,
145152 };
146153}
147154···221228 avatar: string;
222229 created_at: number;
223230 role: UserRole;
231231+ last_login: number | null;
224232 },
225233 []
226226- >("SELECT id, email, name, avatar, created_at, role FROM users ORDER BY created_at DESC")
234234+ >("SELECT id, email, name, avatar, created_at, role, last_login FROM users ORDER BY created_at DESC")
227235 .all();
228236}
229237···311319 // Files might not exist, ignore errors
312320 }
313321}
322322+323323+export function getSessionsForUser(userId: number): Session[] {
324324+ const now = Math.floor(Date.now() / 1000);
325325+ return db
326326+ .query<Session, [number, number]>(
327327+ "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",
328328+ )
329329+ .all(userId, now);
330330+}
331331+332332+export function deleteSessionById(
333333+ sessionId: string,
334334+ userId: number,
335335+): boolean {
336336+ const result = db.run(
337337+ "DELETE FROM sessions WHERE id = ? AND user_id = ?",
338338+ [sessionId, userId],
339339+ );
340340+ return result.changes > 0;
341341+}
342342+343343+export function deleteAllUserSessions(userId: number): void {
344344+ db.run("DELETE FROM sessions WHERE user_id = ?", [userId]);
345345+}
346346+347347+export function updateUserEmailAddress(
348348+ userId: number,
349349+ newEmail: string,
350350+): void {
351351+ db.run("UPDATE users SET email = ? WHERE id = ?", [newEmail, userId]);
352352+}
353353+354354+export interface UserWithStats {
355355+ id: number;
356356+ email: string;
357357+ name: string | null;
358358+ avatar: string;
359359+ created_at: number;
360360+ role: UserRole;
361361+ last_login: number | null;
362362+ transcription_count: number;
363363+}
364364+365365+export function getAllUsersWithStats(): UserWithStats[] {
366366+ return db
367367+ .query<UserWithStats, []>(
368368+ `SELECT
369369+ u.id,
370370+ u.email,
371371+ u.name,
372372+ u.avatar,
373373+ u.created_at,
374374+ u.role,
375375+ u.last_login,
376376+ COUNT(t.id) as transcription_count
377377+ FROM users u
378378+ LEFT JOIN transcriptions t ON u.id = t.user_id
379379+ GROUP BY u.id
380380+ ORDER BY u.created_at DESC`,
381381+ )
382382+ .all();
383383+}
+5-2
src/lib/passkey.ts
···297297 // Remove used challenge
298298 authenticationChallenges.delete(expectedChallenge);
299299300300- // Update last used timestamp and counter
300300+ // Update last used timestamp and counter for passkey
301301 const now = Math.floor(Date.now() / 1000);
302302 db.run(
303303 "UPDATE passkeys SET last_used_at = ?, counter = ? WHERE id = ?",
304304 [now, verification.authenticationInfo.newCounter, passkey.id],
305305 );
306306307307+ // Update user's last_login
308308+ db.run("UPDATE users SET last_login = ? WHERE id = ?", [now, passkey.user_id]);
309309+307310 // Get user
308311 const user = db
309312 .query<User, [number]>(
310310- "SELECT id, email, name, avatar, created_at, role FROM users WHERE id = ?",
313313+ "SELECT id, email, name, avatar, created_at, role, last_login FROM users WHERE id = ?",
311314 )
312315 .get(passkey.user_id);
313316