Files
crumbs/src/lib/server/auth.ts
T
Claude 5719fc3459 fix: make admin user deletion atomic and surface errors in UI
deleteUser() ran each child-table cleanup as a separate statement, so a
foreign-key violation on any step (e.g. sync_log rows from other users
referencing the deleted user's notes) would abort mid-way and leave the
database in a partial state. The API swallowed the error and the admin
page silently ignored the non-ok response, so clicking Delete and
confirming appeared to do nothing.

- Wrap the full cascade in a single sqlite transaction so the deletion
  is atomic.
- Also remove rows in sync_log, note_user_state, note_collaborators,
  note_versions, and attachments that belong to other users but
  reference the deleted user's notes, so the final DELETE FROM notes
  cannot trip a foreign-key check.
- Surface delete failures in the users settings UI instead of silently
  swallowing them.
- Add data-testids on the user row and delete button to support tests.
- Add e2e coverage for the admin user deletion flow (happy path,
  cancelled confirmation, and a user with notes / tags / attachments /
  preferences / api keys).

https://claude.ai/code/session_015T2HE6VzB64ZmSSyy1Wz4R
2026-04-22 21:45:48 +02:00

345 lines
9.1 KiB
TypeScript

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<boolean> {
const user = db.select().from(users).get();
return !!user;
}
export async function setupUser(
email: string,
password: string,
displayName?: string
): Promise<User | null> {
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<User | null> {
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<string> {
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<void> {
db.delete(sessions).where(eq(sessions.id, token)).run();
}
export async function cleanExpiredSessions(): Promise<void> {
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<User> {
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<void> {
// Atomic cascade: all-or-nothing. Also clear rows from other users that
// reference the deleted user's notes (e.g. sync_log, note_user_state,
// note_collaborators) so foreign-key checks don't block the final delete.
const tx = sqlite.transaction((uid: number) => {
sqlite.prepare('DELETE FROM user_preferences WHERE user_id = ?').run(uid);
sqlite.prepare('DELETE FROM api_keys WHERE user_id = ?').run(uid);
sqlite
.prepare(
'DELETE FROM note_user_state WHERE user_id = ? OR note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(uid, uid);
sqlite
.prepare(
'DELETE FROM note_collaborators WHERE user_id = ? OR added_by = ? OR note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(uid, uid, uid);
sqlite
.prepare(
'DELETE FROM sync_log WHERE user_id = ? OR note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(uid, uid);
sqlite
.prepare(
'DELETE FROM attachments WHERE user_id = ? OR note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(uid, uid);
sqlite
.prepare(
'DELETE FROM shared_notes WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(uid);
sqlite
.prepare(
'DELETE FROM note_tags WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(uid);
sqlite
.prepare(
'DELETE FROM note_versions WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(uid);
sqlite.prepare('DELETE FROM notes WHERE user_id = ?').run(uid);
sqlite.prepare('DELETE FROM tags WHERE user_id = ?').run(uid);
sqlite.prepare('DELETE FROM sessions WHERE user_id = ?').run(uid);
sqlite.prepare('DELETE FROM users WHERE id = ?').run(uid);
});
tx(userId);
}
export async function changePassword(
userId: number,
currentPassword: string,
newPassword: string
): Promise<boolean> {
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<void> {
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<string, string> = {};
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();
}
}