Files
opengroupware/services/AuthService.ts
Ken Yasue 921cdc457d feat(ux): dark/light theme + en/ja i18n + user preferences
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と切替を検証
2026-06-25 11:37:44 +02:00

120 lines
3.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
});
}
}