UX全体にテーマと言語設定を追加。 - lib/db/migrations/004_user_prefs.sql: users に theme/locale 列を追加(既定 dark/en) - lib/i18n/: dictionary(en/ja) + I18nProvider(クライアントcontext: locale/theme/t/setLocale/setTheme) + server.ts(SSR用 getLocale/getTheme/translate) + constants.ts - app/layout.tsx: theme/locale Cookie を読み <html class/lang> をSSR、I18nProvider でラップ(フラッシュなし) - tailwind darkMode:'class' + 全画面に dark: バリアントを一括付与(Nodeスクリプト lookbehind で安全に変換) - components/layout/ThemeToggle: 即時クラス切替+Cookie+永続化 - app/profile: テーマ/言語セレクタ、chrome翻訳(Header/ProjectNav/login/dashboard/profile) - PATCH /api/users/me: theme/locale を受理(バリデーション→400)、Cookieを設定 - E2E: locale=ja storageState で既存JAアサーションを維持、theme-i18n.spec で既定en/darkと切替を検証
120 lines
3.6 KiB
TypeScript
120 lines
3.6 KiB
TypeScript
import bcrypt from 'bcrypt';
|
||
import { UserRepository } from '@/repositories/UserRepository';
|
||
import { createSessionToken } from '@/lib/auth/session';
|
||
import {
|
||
validateRegister,
|
||
validateLogin,
|
||
validateProfileUpdate,
|
||
type ProfileUpdateInput,
|
||
} from '@/lib/validators/userValidator';
|
||
import { ConflictError, UnauthorizedError, NotFoundError } from '@/lib/errors';
|
||
import type { User, UserRole } from '@/lib/types';
|
||
|
||
const BCRYPT_ROUNDS = 10;
|
||
|
||
export interface RegisterInput {
|
||
name: string;
|
||
email: string;
|
||
password: string;
|
||
}
|
||
|
||
export interface LoginResult {
|
||
user: User;
|
||
token: string;
|
||
}
|
||
|
||
/**
|
||
* 認証・ユーザー管理の業務ロジックを担うService。
|
||
* パスワードはbcryptでハッシュ化し、平文保存しない。
|
||
* セッションCookieの設定はRoute Handler層の責務(AuthServiceはトークン生成まで)。
|
||
*/
|
||
export class AuthService {
|
||
constructor(private readonly userRepository: UserRepository) {}
|
||
|
||
register(input: RegisterInput): User {
|
||
validateRegister(input);
|
||
const existing = this.userRepository.findByEmail(input.email);
|
||
if (existing) {
|
||
throw new ConflictError('このメールアドレスは既に使用されています');
|
||
}
|
||
const passwordHash = bcrypt.hashSync(input.password, BCRYPT_ROUNDS);
|
||
return this.userRepository.create({
|
||
name: input.name,
|
||
email: input.email,
|
||
passwordHash,
|
||
role: 'member',
|
||
});
|
||
}
|
||
|
||
login(email: string, password: string): LoginResult {
|
||
validateLogin({ email, password });
|
||
const user = this.userRepository.findByEmail(email);
|
||
if (!user) {
|
||
throw new UnauthorizedError(
|
||
'メールアドレスまたはパスワードが正しくありません'
|
||
);
|
||
}
|
||
if (user.status === 'inactive') {
|
||
throw new UnauthorizedError('このアカウントは無効です');
|
||
}
|
||
if (
|
||
!user.passwordHash ||
|
||
!bcrypt.compareSync(password, user.passwordHash)
|
||
) {
|
||
throw new UnauthorizedError(
|
||
'メールアドレスまたはパスワードが正しくありません'
|
||
);
|
||
}
|
||
return { user, token: createSessionToken(user.id) };
|
||
}
|
||
|
||
logout(): void {
|
||
// セッションCookieの削除はRoute Handler層で行う
|
||
}
|
||
|
||
getCurrentUser(userId: number): User | null {
|
||
return this.userRepository.findById(userId);
|
||
}
|
||
|
||
updateProfile(userId: number, input: ProfileUpdateInput): User {
|
||
validateProfileUpdate(input);
|
||
const user = this.userRepository.findById(userId);
|
||
if (!user) {
|
||
throw new NotFoundError('User', userId);
|
||
}
|
||
if (input.email && input.email !== user.email) {
|
||
const existing = this.userRepository.findByEmail(input.email);
|
||
if (existing) {
|
||
throw new ConflictError('このメールアドレスは既に使用されています');
|
||
}
|
||
}
|
||
const updated = this.userRepository.update(userId, {
|
||
name: input.name,
|
||
email: input.email,
|
||
avatarUrl: input.avatarUrl,
|
||
theme: input.theme,
|
||
locale: input.locale,
|
||
});
|
||
if (!updated) {
|
||
throw new NotFoundError('User', userId);
|
||
}
|
||
return updated;
|
||
}
|
||
|
||
/** テスト/初期データ用途: ロールを直接指定してユーザー作成 */
|
||
createWithRole(input: RegisterInput, role: UserRole): User {
|
||
validateRegister(input);
|
||
const existing = this.userRepository.findByEmail(input.email);
|
||
if (existing) {
|
||
throw new ConflictError('このメールアドレスは既に使用されています');
|
||
}
|
||
const passwordHash = bcrypt.hashSync(input.password, BCRYPT_ROUNDS);
|
||
return this.userRepository.create({
|
||
name: input.name,
|
||
email: input.email,
|
||
passwordHash,
|
||
role,
|
||
});
|
||
}
|
||
}
|