Add Korean Japanese and English user interfaces

This commit is contained in:
2026-10-15 18:21:17 +09:00
parent 8fb8c203f0
commit b831003e7d
41 changed files with 3074 additions and 351 deletions

View File

@@ -2,7 +2,7 @@ import { randomUUID, timingSafeEqual } from 'node:crypto';
import { and, eq, gt, isNull, inArray } from 'drizzle-orm';
import argon2 from 'argon2';
import type { Service } from './service.js';
import { PASSWORD_MIN_LENGTH, type Account } from '@hop/shared';
import { PASSWORD_MIN_LENGTH, translate, type Account } from '@hop/shared';
import * as s from './db/schema.js';
import { fail, hash, randomToken } from './security.js';
export type Mail = { to: string; subject: string; text: string; idempotencyKey?: string };
@@ -88,7 +88,12 @@ export class Auth {
fail(400, 'PASSWORD_INVALID', `비밀번호는 ${minimum}~1024자로 입력해 주세요.`);
return argon2.hash(value, passwordOptions);
}
async token(user: User, purpose: 'verify' | 'reset' | 'email-change', value?: string) {
async token(
user: User,
purpose: 'verify' | 'reset' | 'email-change',
value?: string,
language = 'ko',
) {
if (!this.mailReady)
fail(503, 'MAIL_NOT_CONFIGURED', '이메일 기능이 아직 설정되지 않았습니다.');
const token = randomToken(),
@@ -127,8 +132,23 @@ export class Auth {
await this.sendMail({
idempotencyKey: `auth-${purpose}-${hash(token)}`,
to: value ?? user.email!,
subject: purpose === 'reset' ? 'hop. 비밀번호 재설정' : 'hop. 이메일 인증',
text: `${purpose === 'reset' ? '비밀번호를 다시 설정하려면' : '이메일 인증을 완료하려면'} 다음 주소를 열어 주세요.\n${this.service.cfg.appUrl}/#${action}=${token}\n이 링크는 한 번만 사용할 수 있습니다.`,
subject: translate(
purpose === 'reset' ? 'hop. 비밀번호 재설정' : 'hop. 이메일 인증',
language,
),
text: translate(
'{p0} 다음 주소를 열어 주세요.\n{p1}/#{p2}={p3}\n이 링크는 한 번만 사용할 수 있습니다.',
language,
{
p0: translate(
purpose === 'reset' ? '비밀번호를 다시 설정하려면' : '이메일 인증을 완료하려면',
language,
),
p1: this.service.cfg.appUrl,
p2: action,
p3: token,
},
),
});
}
async register(
@@ -137,6 +157,7 @@ export class Auth {
displayName: string,
ip: string,
setupToken?: string,
language = 'ko',
) {
const { db, cfg, limiter } = this.service;
limiter.hit(`register-ip:${ip}`, 5, 3600000);
@@ -174,7 +195,7 @@ export class Auth {
.run();
return this.user(id);
});
await this.token(user, 'verify');
await this.token(user, 'verify', undefined, language);
return { message: '가입을 진행할 수 있는 주소라면 인증 메일이 발송됩니다.' };
}
consume(
@@ -324,7 +345,7 @@ export class Auth {
}
}
}
async resend(email: string, purpose: 'verify' | 'reset', ip: string) {
async resend(email: string, purpose: 'verify' | 'reset', ip: string, language = 'ko') {
const normalized = normalizeEmail(email);
this.service.limiter.hit(`mail-ip:${ip}`, 10, 3600000);
this.service.limiter.hit(`mail-email:${hash(normalized)}`, 3, 3600000);
@@ -334,7 +355,7 @@ export class Auth {
((purpose === 'verify' && user.status === 'pending') ||
(purpose === 'reset' && ['active', 'pending'].includes(user.status)))
)
await this.token(user, purpose);
await this.token(user, purpose, undefined, language);
return { message: '처리 가능한 주소라면 안내 메일이 발송됩니다.' };
}
async resetPassword(token: string, password: string) {