feat(m3): auth & user management with session, tests, and e2e
- Custom error classes (lib/errors.ts) and signed-cookie session (lib/auth/session.ts, HMAC-SHA256, httpOnly+sameSite=lax) - UserRepository + AuthService (bcrypt hashing, role/status checks, register/login/logout/getCurrentUser/updateProfile) - Auth helpers (getCurrentUser), user validator, API error handler - API routes: register (auto-login), login, logout, me, PATCH users/me, GET admin/migrations (admin-only guard) - middleware.ts redirects unauthenticated page access to /login - Screens: login (login/register toggle), profile, dashboard skeleton, home redirect - vitest: exclude e2e specs, setupFiles for SESSION_SECRET - playwright: run migrate before dev server, set SESSION_SECRET - Unit tests (UserRepository, AuthService, session), integration auth-flow, e2e auth.spec (register/login/logout/protected/401)
This commit is contained in:
124
repositories/UserRepository.ts
Normal file
124
repositories/UserRepository.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||
import type { User, UserRole, UserStatus } from '@/lib/types';
|
||||
|
||||
interface UserRow {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
password_hash: string | null;
|
||||
avatar_url: string | null;
|
||||
role: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
function mapUser(row: UserRow): User {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
passwordHash: row.password_hash,
|
||||
avatarUrl: row.avatar_url,
|
||||
role: row.role as UserRole,
|
||||
status: row.status as UserStatus,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateUserInput {
|
||||
name: string;
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
role?: UserRole;
|
||||
}
|
||||
|
||||
export interface UpdateUserInput {
|
||||
name?: string;
|
||||
email?: string;
|
||||
avatarUrl?: string | null;
|
||||
role?: UserRole;
|
||||
status?: UserStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* usersテーブルへのデータアクセスを担うRepository。
|
||||
* 直接SQLiteライブラリを触らず、必ずSqliteDatabase経由でSQLを実行する。
|
||||
*/
|
||||
export class UserRepository {
|
||||
constructor(private readonly db: SqliteDatabase) {}
|
||||
|
||||
findById(id: number): User | null {
|
||||
const row = this.db.get<UserRow>('SELECT * FROM users WHERE id = @id', {
|
||||
id,
|
||||
});
|
||||
return row ? mapUser(row) : null;
|
||||
}
|
||||
|
||||
findByEmail(email: string): User | null {
|
||||
const row = this.db.get<UserRow>(
|
||||
'SELECT * FROM users WHERE email = @email',
|
||||
{ email }
|
||||
);
|
||||
return row ? mapUser(row) : null;
|
||||
}
|
||||
|
||||
create(input: CreateUserInput): User {
|
||||
const now = new Date().toISOString();
|
||||
const result = this.db.execute(
|
||||
`INSERT INTO users (name, email, password_hash, avatar_url, role, status, created_at, updated_at)
|
||||
VALUES (@name, @email, @passwordHash, @avatarUrl, @role, @status, @createdAt, @updatedAt)`,
|
||||
{
|
||||
name: input.name,
|
||||
email: input.email,
|
||||
passwordHash: input.passwordHash,
|
||||
avatarUrl: null,
|
||||
role: input.role ?? 'member',
|
||||
status: 'active',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
);
|
||||
const created = this.findById(Number(result.lastInsertRowid));
|
||||
if (!created) {
|
||||
throw new Error('Failed to create user');
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
update(id: number, input: UpdateUserInput): User | null {
|
||||
const fields: string[] = ['updated_at = @updatedAt'];
|
||||
const params: Record<string, unknown> = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
id,
|
||||
};
|
||||
|
||||
if (input.name !== undefined) {
|
||||
fields.push('name = @name');
|
||||
params.name = input.name;
|
||||
}
|
||||
if (input.email !== undefined) {
|
||||
fields.push('email = @email');
|
||||
params.email = input.email;
|
||||
}
|
||||
if (input.avatarUrl !== undefined) {
|
||||
fields.push('avatar_url = @avatarUrl');
|
||||
params.avatarUrl = input.avatarUrl;
|
||||
}
|
||||
if (input.role !== undefined) {
|
||||
fields.push('role = @role');
|
||||
params.role = input.role;
|
||||
}
|
||||
if (input.status !== undefined) {
|
||||
fields.push('status = @status');
|
||||
params.status = input.status;
|
||||
}
|
||||
|
||||
this.db.execute(
|
||||
`UPDATE users SET ${fields.join(', ')} WHERE id = @id`,
|
||||
params
|
||||
);
|
||||
return this.findById(id);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user