From 823fc27b73b306ed78e9725a2798587fac849f35 Mon Sep 17 00:00:00 2001 From: Frederic Cabassut Date: Sun, 8 Mar 2026 21:25:29 +0100 Subject: [PATCH] refactor(auth): email-based login and user CRUD Rewrite auth module for multi-user support: - setupUser now takes email, password, displayName; returns User - verifyPassword takes email+password; returns User or null - createSession takes explicit userId - validateSession joins sessions+users; returns user data - Add createUser, deleteUser, changePassword, resetPassword, updateUserRole, updateUserProfile, listUsers, getUser Co-Authored-By: Claude Opus 4.6 --- src/lib/server/auth.ts | 220 ++++++++++++++++++++++++++++++++++------- 1 file changed, 186 insertions(+), 34 deletions(-) diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index bc5d444..03cb48a 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -1,65 +1,217 @@ -import { db } from './db/index.js'; +import { db, sqlite } from './db/index.js'; import { users, sessions } from './db/schema.js'; -import { eq, lt } from 'drizzle-orm'; +import { eq, lt, and } from 'drizzle-orm'; import * as argon2 from 'argon2'; import { randomBytes } from 'crypto'; +import type { User } from '$lib/types/index.js'; const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days +type SessionUser = { id: number; email: string; displayName: string; role: 'admin' | 'user' }; + +function toUser(row: typeof users.$inferSelect): User { + return { + id: row.id, + email: row.email, + displayName: row.displayName, + role: row.role, + authProvider: row.authProvider, + providerId: row.providerId, + createdAt: row.createdAt + }; +} + export async function isSetupComplete(): Promise { - const user = await db.select().from(users).get(); + const user = db.select().from(users).get(); return !!user; } -export async function setupUser(password: string): Promise { - const existing = await db.select().from(users).get(); - if (existing) return false; // Already set up +export async function setupUser( + email: string, + password: string, + displayName?: string +): Promise { + const existing = db.select().from(users).get(); + if (existing) return null; // Already set up const hash = await argon2.hash(password); - await db.insert(users).values({ - passwordHash: hash, - createdAt: new Date() - }); - return true; + const result = db + .insert(users) + .values({ + email, + displayName: displayName || email.split('@')[0] || 'Admin', + role: 'admin', + passwordHash: hash, + authProvider: 'password', + createdAt: new Date() + }) + .returning() + .get(); + return toUser(result); } -export async function verifyPassword(password: string): Promise { - const user = await db.select().from(users).get(); - if (!user) return false; - return argon2.verify(user.passwordHash, password); +export async function verifyPassword( + email: string, + password: string +): Promise { + const user = db + .select() + .from(users) + .where(and(eq(users.email, email), eq(users.authProvider, 'password'))) + .get(); + if (!user || !user.passwordHash) return null; + + const valid = await argon2.verify(user.passwordHash, password); + return valid ? toUser(user) : null; } -export async function createSession(): Promise { - const user = await db.select().from(users).get(); - if (!user) throw new Error('No user exists'); - +export async function createSession(userId: number): Promise { const token = randomBytes(32).toString('hex'); - await db.insert(sessions).values({ - id: token, - userId: user.id, - expiresAt: new Date(Date.now() + SESSION_DURATION_MS) - }); + db.insert(sessions) + .values({ + id: token, + userId, + expiresAt: new Date(Date.now() + SESSION_DURATION_MS) + }) + .run(); return token; } -export async function validateSession(token: string): Promise { - if (!token) return false; +export async function validateSession( + token: string +): Promise<{ valid: boolean; user?: SessionUser }> { + if (!token) return { valid: false }; - const session = await db.select().from(sessions).where(eq(sessions.id, token)).get(); - if (!session) return false; + // Join sessions with users to get user data in one query + const result = sqlite + .prepare( + `SELECT s.id as session_id, s.expires_at, u.id, u.email, u.display_name, u.role + FROM sessions s + JOIN users u ON s.user_id = u.id + WHERE s.id = ?` + ) + .get(token) as + | { + session_id: string; + expires_at: number; + id: number; + email: string; + display_name: string; + role: 'admin' | 'user'; + } + | undefined; - if (session.expiresAt < new Date()) { - await db.delete(sessions).where(eq(sessions.id, token)); - return false; + if (!result) return { valid: false }; + + if (result.expires_at < Date.now()) { + db.delete(sessions).where(eq(sessions.id, token)).run(); + return { valid: false }; } - return true; + return { + valid: true, + user: { + id: result.id, + email: result.email, + displayName: result.display_name, + role: result.role + } + }; } export async function deleteSession(token: string): Promise { - await db.delete(sessions).where(eq(sessions.id, token)); + db.delete(sessions).where(eq(sessions.id, token)).run(); } export async function cleanExpiredSessions(): Promise { - await db.delete(sessions).where(lt(sessions.expiresAt, new Date())); + db.delete(sessions).where(lt(sessions.expiresAt, new Date())).run(); +} + +// --- User CRUD (admin operations) --- + +export async function createUser( + email: string, + displayName: string, + password: string, + role: 'admin' | 'user' = 'user' +): Promise { + const hash = await argon2.hash(password); + const result = db + .insert(users) + .values({ + email, + displayName, + role, + passwordHash: hash, + authProvider: 'password', + createdAt: new Date() + }) + .returning() + .get(); + return toUser(result); +} + +export function listUsers(): User[] { + return db + .select() + .from(users) + .all() + .map(toUser); +} + +export function getUser(userId: number): User | null { + const row = db.select().from(users).where(eq(users.id, userId)).get(); + return row ? toUser(row) : null; +} + +export async function deleteUser(userId: number): Promise { + // Cascade: delete user's data in dependency order + sqlite.prepare('DELETE FROM sync_log WHERE user_id = ?').run(userId); + sqlite.prepare('DELETE FROM attachments WHERE user_id = ?').run(userId); + sqlite + .prepare( + 'DELETE FROM note_tags WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)' + ) + .run(userId); + sqlite.prepare('DELETE FROM notes WHERE user_id = ?').run(userId); + sqlite.prepare('DELETE FROM tags WHERE user_id = ?').run(userId); + sqlite.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId); + db.delete(users).where(eq(users.id, userId)).run(); +} + +export async function changePassword( + userId: number, + currentPassword: string, + newPassword: string +): Promise { + const user = db.select().from(users).where(eq(users.id, userId)).get(); + if (!user || !user.passwordHash) return false; + + const valid = await argon2.verify(user.passwordHash, currentPassword); + if (!valid) return false; + + const hash = await argon2.hash(newPassword); + db.update(users).set({ passwordHash: hash }).where(eq(users.id, userId)).run(); + return true; +} + +export async function resetPassword(userId: number, newPassword: string): Promise { + const hash = await argon2.hash(newPassword); + db.update(users).set({ passwordHash: hash }).where(eq(users.id, userId)).run(); +} + +export function updateUserRole(userId: number, role: 'admin' | 'user'): void { + db.update(users).set({ role }).where(eq(users.id, userId)).run(); +} + +export function updateUserProfile( + userId: number, + data: { displayName?: string; email?: string } +): void { + const updates: Record = {}; + if (data.displayName !== undefined) updates.displayName = data.displayName; + if (data.email !== undefined) updates.email = data.email; + if (Object.keys(updates).length > 0) { + db.update(users).set(updates).where(eq(users.id, userId)).run(); + } }