import { db, sqlite } from './db/index.js'; import { users, sessions } from './db/schema.js'; 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 = db.select().from(users).get(); return !!user; } 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); 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( 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( userId: number, meta?: { userAgent?: string; ip?: string } ): Promise { const token = randomBytes(32).toString('hex'); const now = new Date(); db.insert(sessions) .values({ id: token, userId, expiresAt: new Date(Date.now() + SESSION_DURATION_MS), createdAt: now, userAgent: meta?.userAgent || null, ip: meta?.ip || null, lastUsedAt: now }) .run(); return token; } export async function validateSession( token: string ): Promise<{ valid: boolean; user?: SessionUser }> { if (!token) return { valid: 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 (!result) return { valid: false }; if (result.expires_at * 1000 < Date.now()) { db.delete(sessions).where(eq(sessions.id, token)).run(); return { valid: false }; } // Update lastUsedAt db.update(sessions) .set({ lastUsedAt: new Date() }) .where(eq(sessions.id, token)) .run(); return { valid: true, user: { id: result.id, email: result.email, displayName: result.display_name, role: result.role } }; } export async function deleteSession(token: string): Promise { db.delete(sessions).where(eq(sessions.id, token)).run(); } export async function cleanExpiredSessions(): Promise { db.delete(sessions).where(lt(sessions.expiresAt, new Date())).run(); } export type SessionInfo = { id: string; createdAt: Date | null; userAgent: string | null; ip: string | null; lastUsedAt: Date | null; expiresAt: Date; isCurrent: boolean; }; export function listUserSessions(userId: number, currentToken: string): SessionInfo[] { return db .select() .from(sessions) .where(eq(sessions.userId, userId)) .all() .map((s) => ({ id: s.id, createdAt: s.createdAt, userAgent: s.userAgent, ip: s.ip, lastUsedAt: s.lastUsedAt, expiresAt: s.expiresAt, isCurrent: s.id === currentToken })); } export function revokeSession(userId: number, sessionId: string): boolean { const session = db .select() .from(sessions) .where(and(eq(sessions.id, sessionId), eq(sessions.userId, userId))) .get(); if (!session) return false; db.delete(sessions).where(eq(sessions.id, sessionId)).run(); return true; } export function revokeAllSessions(userId: number, exceptToken?: string): void { if (exceptToken) { sqlite .prepare('DELETE FROM sessions WHERE user_id = ? AND id != ?') .run(userId, exceptToken); } else { db.delete(sessions).where(eq(sessions.userId, userId)).run(); } } // --- User CRUD (admin operations) --- export async function createUser( email: string, displayName: string, password: string | null, role: 'admin' | 'user' = 'user' ): Promise { const hash = password ? await argon2.hash(password) : null; const result = db .insert(users) .values({ email, displayName, role, passwordHash: hash, authProvider: password ? 'password' : 'none', 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 user_preferences WHERE user_id = ?').run(userId); sqlite.prepare('DELETE FROM api_keys WHERE user_id = ?').run(userId); sqlite.prepare('DELETE FROM note_user_state WHERE user_id = ?').run(userId); sqlite .prepare('DELETE FROM note_collaborators WHERE user_id = ? OR added_by = ?') .run(userId, userId); 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(); } /** * Find a user by OAuth provider, or by email. Invite-only: rejects if no matching user. */ export function findOrLinkOAuthUser( provider: string, providerId: string, email: string, name: string ): User | null { // First try exact provider+providerId match const byProvider = db .select() .from(users) .where(and(eq(users.authProvider, provider), eq(users.providerId, providerId))) .get(); if (byProvider) return toUser(byProvider); // Try matching by email (invite-only: user must already exist) const byEmail = db.select().from(users).where(eq(users.email, email)).get(); if (!byEmail) return null; // No matching user — not invited // Link this OAuth provider to the existing user db.update(users) .set({ authProvider: provider, providerId, displayName: byEmail.displayName || name }) .where(eq(users.id, byEmail.id)) .run(); return toUser({ ...byEmail, authProvider: provider, providerId }); } 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(); } }