이메일 인증 재발송과 관리자 승인 개선

This commit is contained in:
2026-06-25 16:51:29 +09:00
parent 5fbd58bd07
commit a5b7d720d6
26 changed files with 688 additions and 91 deletions

View File

@@ -1 +1 @@
v4.0.14 v4.0.15

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="./style/output.css?v=4.0.9" /> <link rel="stylesheet" href="./style/output.css?v=4.0.15" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800;900&family=Noto+Sans+KR:wght@400;700;900&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800;900&family=Noto+Sans+KR:wght@400;700;900&display=swap" rel="stylesheet" />
<title>관리자 - UNIARE</title> <title>관리자 - UNIARE</title>
</head> </head>
@@ -117,6 +117,7 @@
</div> </div>
<p id="modal-error" class="mt-2 hidden text-[0.8125rem] text-red-500"></p> <p id="modal-error" class="mt-2 hidden text-[0.8125rem] text-red-500"></p>
<div class="my-4 h-px bg-slate-200"></div> <div class="my-4 h-px bg-slate-200"></div>
<button type="button" id="approve-email-verification" class="mb-2 hidden w-full cursor-pointer rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-2.5 text-[0.8125rem] font-bold text-emerald-700 hover:bg-emerald-100">이메일 인증 승인</button>
<button type="button" id="open-password-modal" class="w-full cursor-pointer rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-[0.8125rem] font-bold text-slate-600 hover:bg-slate-50">비밀번호 변경하기</button> <button type="button" id="open-password-modal" class="w-full cursor-pointer rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-[0.8125rem] font-bold text-slate-600 hover:bg-slate-50">비밀번호 변경하기</button>
<div class="mt-3 flex gap-2"> <div class="mt-3 flex gap-2">
<button type="button" id="modal-cancel" class="flex-1 cursor-pointer rounded-xl border-0 bg-slate-100 px-4 py-2.5 text-[0.8125rem] font-bold text-slate-600 hover:bg-slate-200">취소</button> <button type="button" id="modal-cancel" class="flex-1 cursor-pointer rounded-xl border-0 bg-slate-100 px-4 py-2.5 text-[0.8125rem] font-bold text-slate-600 hover:bg-slate-200">취소</button>
@@ -152,7 +153,7 @@
</div> </div>
</div> </div>
<script src="/script/api.js"></script> <script src="/script/api.js?v=4.0.15"></script>
<script src="/script/image-utils.js"></script> <script src="/script/image-utils.js"></script>
<script src="/script/auth-header.js"></script> <script src="/script/auth-header.js"></script>
<script> <script>
@@ -277,6 +278,7 @@
<div class="flex gap-1"> <div class="flex gap-1">
<span class="rounded-md bg-blue-50 px-1.5 py-0.5 text-[0.625rem] font-bold text-blue-600">${escapeHtml(u.role)}</span> <span class="rounded-md bg-blue-50 px-1.5 py-0.5 text-[0.625rem] font-bold text-blue-600">${escapeHtml(u.role)}</span>
<span class="rounded-md px-1.5 py-0.5 text-[0.625rem] font-bold ${u.isActive ? 'bg-emerald-50 text-emerald-600' : 'bg-slate-100 text-slate-500'}">${u.isActive ? '활성' : '비활성'}</span> <span class="rounded-md px-1.5 py-0.5 text-[0.625rem] font-bold ${u.isActive ? 'bg-emerald-50 text-emerald-600' : 'bg-slate-100 text-slate-500'}">${u.isActive ? '활성' : '비활성'}</span>
<span class="rounded-md px-1.5 py-0.5 text-[0.625rem] font-bold ${u.emailVerifiedAt ? 'bg-emerald-50 text-emerald-600' : 'bg-amber-50 text-amber-700'}">${u.emailVerifiedAt ? '이메일 인증' : '이메일 미인증'}</span>
</div> </div>
<div class="mt-1.5 text-xs font-semibold text-slate-600" title="공개 덱 / 전체 저장 덱">덱 ${deckPublic}/${deckTotal} <span class="font-normal text-slate-400">(공개/전체)</span></div> <div class="mt-1.5 text-xs font-semibold text-slate-600" title="공개 덱 / 전체 저장 덱">덱 ${deckPublic}/${deckTotal} <span class="font-normal text-slate-400">(공개/전체)</span></div>
</div> </div>
@@ -316,6 +318,10 @@
document.getElementById('toggle-role-editor').classList.toggle('hidden', isSelf); document.getElementById('toggle-role-editor').classList.toggle('hidden', isSelf);
document.getElementById('edit-role-hint').classList.toggle('hidden', !isSelf); document.getElementById('edit-role-hint').classList.toggle('hidden', !isSelf);
document.getElementById('edit-status-hint').classList.toggle('hidden', !isSelf); document.getElementById('edit-status-hint').classList.toggle('hidden', !isSelf);
document.getElementById('approve-email-verification').classList.toggle(
'hidden',
Boolean(u.emailVerifiedAt),
);
document.getElementById('modal-error').classList.add('hidden'); document.getElementById('modal-error').classList.add('hidden');
setModalOpen(editModal, true); setModalOpen(editModal, true);
@@ -500,6 +506,24 @@
document.getElementById('password-error').classList.add('hidden'); document.getElementById('password-error').classList.add('hidden');
setModalOpen(passwordModal, true); setModalOpen(passwordModal, true);
}); });
document.getElementById('approve-email-verification').addEventListener('click', async () => {
if (!editingUser || editingUser.emailVerifiedAt) return;
if (!confirm(`${editingUser.email} 계정의 이메일 인증을 승인하시겠습니까?`)) return;
const errEl = document.getElementById('modal-error');
errEl.classList.add('hidden');
try {
const result = await UaDeckApi.adminVerifyUserEmail(editingUser.id);
editingUser.emailVerifiedAt = result.emailVerifiedAt;
document.getElementById('approve-email-verification').classList.add('hidden');
await loadUsers(document.getElementById('user-search').value);
alert('이메일 인증을 승인했습니다.');
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove('hidden');
}
});
document.getElementById('toggle-role-editor').addEventListener('click', () => { document.getElementById('toggle-role-editor').addEventListener('click', () => {
if (!editingUser || editingUser.id === currentAdminId) return; if (!editingUser || editingUser.id === currentAdminId) return;
setRoleEditorOpen(!isRoleEditorOpen); setRoleEditorOpen(!isRoleEditorOpen);

View File

@@ -1,11 +1,12 @@
{ {
"name": "uadeck-api", "name": "uadeck-api",
"version": "4.0.14", "version": "4.0.15",
"type": "module", "type": "module",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
"start": "node src/index.js", "start": "node src/index.js",
"dev": "node --watch src/index.js" "dev": "node --watch src/index.js",
"test": "node --test test/*.test.js"
}, },
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",

View File

@@ -29,7 +29,7 @@ app.use(cors({ origin: corsOrigins, credentials: true }));
app.use(express.json({ limit: '20mb' })); app.use(express.json({ limit: '20mb' }));
app.get('/api/v1/health', (_req, res) => { app.get('/api/v1/health', (_req, res) => {
res.json({ ok: true, version: '4.0.14' }); res.json({ ok: true, version: '4.0.15' });
}); });
app.use('/api/v1/auth', createAuthRouter(db)); app.use('/api/v1/auth', createAuthRouter(db));
@@ -47,7 +47,7 @@ app.use((err, _req, res, _next) => {
}); });
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`UNIARE API v4.0.14 listening on :${PORT}`); console.log(`UNIARE API v4.0.15 listening on :${PORT}`);
console.log(`DB: ${DB_PATH}`); console.log(`DB: ${DB_PATH}`);
console.log(`DATAS: ${process.env.DATAS_PATH || '(default ../datas)'}`); console.log(`DATAS: ${process.env.DATAS_PATH || '(default ../datas)'}`);
}); });

View File

@@ -69,7 +69,8 @@ export function createAdminRouter(db) {
`).get(like, like, like).c; `).get(like, like, like).c;
rows = db.prepare(` rows = db.prepare(`
SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl, SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl,
role, is_active AS isActive, last_login_at AS lastLoginAt, role, is_active AS isActive, email_verified_at AS emailVerifiedAt,
last_login_at AS lastLoginAt,
created_at AS createdAt, updated_at AS updatedAt created_at AS createdAt, updated_at AS updatedAt
FROM users FROM users
WHERE email LIKE ? OR username LIKE ? OR display_name LIKE ? WHERE email LIKE ? OR username LIKE ? OR display_name LIKE ?
@@ -79,7 +80,8 @@ export function createAdminRouter(db) {
total = db.prepare('SELECT COUNT(*) AS c FROM users').get().c; total = db.prepare('SELECT COUNT(*) AS c FROM users').get().c;
rows = db.prepare(` rows = db.prepare(`
SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl, SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl,
role, is_active AS isActive, last_login_at AS lastLoginAt, role, is_active AS isActive, email_verified_at AS emailVerifiedAt,
last_login_at AS lastLoginAt,
created_at AS createdAt, updated_at AS updatedAt created_at AS createdAt, updated_at AS updatedAt
FROM users ORDER BY created_at DESC LIMIT ? OFFSET ? FROM users ORDER BY created_at DESC LIMIT ? OFFSET ?
`).all(Number(limit), offset); `).all(Number(limit), offset);
@@ -102,7 +104,8 @@ export function createAdminRouter(db) {
router.get('/users/:id', (req, res) => { router.get('/users/:id', (req, res) => {
const user = db.prepare(` const user = db.prepare(`
SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl, SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl,
role, is_active AS isActive, last_login_at AS lastLoginAt, role, is_active AS isActive, email_verified_at AS emailVerifiedAt,
last_login_at AS lastLoginAt,
created_at AS createdAt, updated_at AS updatedAt created_at AS createdAt, updated_at AS updatedAt
FROM users WHERE id = ? FROM users WHERE id = ?
`).get(req.params.id); `).get(req.params.id);
@@ -210,7 +213,8 @@ export function createAdminRouter(db) {
const updated = db.prepare(` const updated = db.prepare(`
SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl, SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl,
role, is_active AS isActive, last_login_at AS lastLoginAt, role, is_active AS isActive, email_verified_at AS emailVerifiedAt,
last_login_at AS lastLoginAt,
created_at AS createdAt, updated_at AS updatedAt created_at AS createdAt, updated_at AS updatedAt
FROM users WHERE id = ? FROM users WHERE id = ?
`).get(targetId); `).get(targetId);
@@ -219,6 +223,38 @@ export function createAdminRouter(db) {
res.json({ user: updated }); res.json({ user: updated });
}); });
router.post('/users/:id/verify-email', (req, res) => {
const targetId = Number(req.params.id);
const user = db.prepare(`
SELECT id, email_verified_at AS emailVerifiedAt
FROM users WHERE id = ?
`).get(targetId);
if (!user) {
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
}
const verifiedAt = user.emailVerifiedAt || nowIso();
const transaction = db.transaction(() => {
db.prepare(`
UPDATE users
SET email_verified_at = ?, updated_at = ?
WHERE id = ?
`).run(verifiedAt, nowIso(), targetId);
db.prepare(`
UPDATE auth_tokens
SET used_at = ?
WHERE user_id = ? AND type = 'verify_email' AND used_at IS NULL
`).run(nowIso(), targetId);
});
transaction();
res.json({
ok: true,
emailVerifiedAt: verifiedAt,
message: '이메일 인증을 승인했습니다.',
});
});
router.post('/users/:id/reset-password', (req, res) => { router.post('/users/:id/reset-password', (req, res) => {
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id); const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id);
if (!user) { if (!user) {

View File

@@ -15,6 +15,7 @@ const EMAIL_RESEND_COOLDOWN_MS = 60 * 1000;
const MONTHLY_EMAIL_LIMIT = 3; const MONTHLY_EMAIL_LIMIT = 3;
const EMAIL_LIMIT_MESSAGE = const EMAIL_LIMIT_MESSAGE =
'이 계정의 월간 이메일 발송 한도(3회)를 초과했습니다. 관리자 zenn@sori.studio로 직접 문의해 주세요.'; '이 계정의 월간 이메일 발송 한도(3회)를 초과했습니다. 관리자 zenn@sori.studio로 직접 문의해 주세요.';
const EMAIL_LIMIT_CODE = 'MONTHLY_EMAIL_LIMIT_EXCEEDED';
/** /**
* 한국 시간 기준 월 키 * 한국 시간 기준 월 키
@@ -225,7 +226,10 @@ export function createAuthRouter(db) {
emailLogId = reserveEmailSend(db, userId, 'verify_email'); emailLogId = reserveEmailSend(db, userId, 'verify_email');
if (!emailLogId) { if (!emailLogId) {
db.prepare('DELETE FROM users WHERE id = ? AND email_verified_at IS NULL').run(userId); db.prepare('DELETE FROM users WHERE id = ? AND email_verified_at IS NULL').run(userId);
return res.status(429).json({ error: EMAIL_LIMIT_MESSAGE }); return res.status(429).json({
error: EMAIL_LIMIT_MESSAGE,
code: EMAIL_LIMIT_CODE,
});
} }
const token = issueAuthToken(db, userId, 'verify_email', VERIFICATION_TOKEN_TTL_MS); const token = issueAuthToken(db, userId, 'verify_email', VERIFICATION_TOKEN_TTL_MS);
await sendVerificationEmail({ await sendVerificationEmail({
@@ -276,14 +280,20 @@ export function createAuthRouter(db) {
LIMIT 1 LIMIT 1
`).get(user.id); `).get(user.id);
if (recent && Date.now() - new Date(`${recent.created_at}Z`).getTime() < EMAIL_RESEND_COOLDOWN_MS) { if (recent && Date.now() - new Date(`${recent.created_at}Z`).getTime() < EMAIL_RESEND_COOLDOWN_MS) {
return res.status(429).json({ error: '인증 메일은 1분 후 다시 요청할 수 있습니다.' }); return res.status(429).json({
error: '인증 메일은 1분 후 다시 요청할 수 있습니다.',
code: 'EMAIL_RESEND_COOLDOWN',
});
} }
let emailLogId; let emailLogId;
try { try {
emailLogId = reserveEmailSend(db, user.id, 'verify_email'); emailLogId = reserveEmailSend(db, user.id, 'verify_email');
if (!emailLogId) { if (!emailLogId) {
return res.status(429).json({ error: EMAIL_LIMIT_MESSAGE }); return res.status(429).json({
error: EMAIL_LIMIT_MESSAGE,
code: EMAIL_LIMIT_CODE,
});
} }
const token = issueAuthToken(db, user.id, 'verify_email', VERIFICATION_TOKEN_TTL_MS); const token = issueAuthToken(db, user.id, 'verify_email', VERIFICATION_TOKEN_TTL_MS);
await sendVerificationEmail({ email: user.email, username: user.username, token }); await sendVerificationEmail({ email: user.email, username: user.username, token });
@@ -298,7 +308,10 @@ export function createAuthRouter(db) {
router.post('/verify-email', (req, res) => { router.post('/verify-email', (req, res) => {
const token = String(req.body?.token || ''); const token = String(req.body?.token || '');
if (!token) { if (!token) {
return res.status(400).json({ error: '인증 토큰이 없습니다.' }); return res.status(400).json({
error: '인증 토큰이 없습니다.',
code: 'VERIFICATION_TOKEN_MISSING',
});
} }
const tokenHash = hashToken(token); const tokenHash = hashToken(token);
const row = db.prepare(` const row = db.prepare(`
@@ -309,7 +322,10 @@ export function createAuthRouter(db) {
WHERE t.token_hash = ? AND t.type = 'verify_email' AND u.is_active = 1 WHERE t.token_hash = ? AND t.type = 'verify_email' AND u.is_active = 1
`).get(tokenHash); `).get(tokenHash);
if (!row || row.usedAt || new Date(row.expiresAt).getTime() <= Date.now()) { if (!row || row.usedAt || new Date(row.expiresAt).getTime() <= Date.now()) {
return res.status(400).json({ error: '인증 링크가 유효하지 않거나 만료되었습니다.' }); return res.status(400).json({
error: '인증 링크가 유효하지 않거나 만료되었습니다.',
code: 'VERIFICATION_TOKEN_INVALID',
});
} }
const transaction = db.transaction(() => { const transaction = db.transaction(() => {
@@ -394,7 +410,10 @@ export function createAuthRouter(db) {
} }
emailLogId = reserveEmailSend(db, user.id, 'reset_password'); emailLogId = reserveEmailSend(db, user.id, 'reset_password');
if (!emailLogId) { if (!emailLogId) {
return res.status(429).json({ error: EMAIL_LIMIT_MESSAGE }); return res.status(429).json({
error: EMAIL_LIMIT_MESSAGE,
code: EMAIL_LIMIT_CODE,
});
} }
const token = issueAuthToken(db, user.id, 'reset_password', PASSWORD_RESET_TOKEN_TTL_MS); const token = issueAuthToken(db, user.id, 'reset_password', PASSWORD_RESET_TOKEN_TTL_MS);
await sendPasswordResetEmail({ email: user.email, username: user.username, token }); await sendPasswordResetEmail({ email: user.email, username: user.username, token });

View File

@@ -66,7 +66,7 @@ async function sendEmail(message) {
* @returns {Promise<object>} * @returns {Promise<object>}
*/ */
export function sendVerificationEmail({ email, username, token }) { export function sendVerificationEmail({ email, username, token }) {
const url = `${getAppBaseUrl()}/verify-email.html?v=4.0.14&token=${encodeURIComponent(token)}`; const url = `${getAppBaseUrl()}/verify-email.html?v=4.0.15&token=${encodeURIComponent(token)}`;
const safeName = escapeHtml(username); const safeName = escapeHtml(username);
const safeUrl = escapeHtml(url); const safeUrl = escapeHtml(url);
return sendEmail({ return sendEmail({
@@ -91,7 +91,7 @@ export function sendVerificationEmail({ email, username, token }) {
* @returns {Promise<object>} * @returns {Promise<object>}
*/ */
export function sendPasswordResetEmail({ email, username, token }) { export function sendPasswordResetEmail({ email, username, token }) {
const url = `${getAppBaseUrl()}/reset-password.html?v=4.0.14&token=${encodeURIComponent(token)}`; const url = `${getAppBaseUrl()}/reset-password.html?v=4.0.15&token=${encodeURIComponent(token)}`;
const safeName = escapeHtml(username); const safeName = escapeHtml(username);
const safeUrl = escapeHtml(url); const safeUrl = escapeHtml(url);
return sendEmail({ return sendEmail({

View File

@@ -0,0 +1,172 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import bcrypt from 'bcryptjs';
import express from 'express';
import { initDb } from '../src/db.js';
import { createAdminRouter } from '../src/routes/admin.js';
import { createAuthRouter } from '../src/routes/auth.js';
test('삭제한 계정의 사용자명을 새 계정이 사용해도 기존 정보가 연결되지 않는다', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uniare-account-delete-'));
const dbPath = path.join(tempDir, 'test.db');
const db = initDb(dbPath);
try {
const oldUserId = Number(
db.prepare(`
INSERT INTO users (
email, username, password_hash, display_name, email_verified_at
)
VALUES (?, ?, ?, ?, datetime('now'))
`).run('old@example.com', 'test', 'old-password-hash', 'test').lastInsertRowid,
);
db.prepare(`
INSERT INTO decks (
share_code, share_type, user_id, series, series_name, name, cards_json, is_public
)
VALUES ('OLDDECK1', 'user', ?, 'ua01', '테스트', '기존 덱', '[]', 1)
`).run(oldUserId);
db.prepare(`
INSERT INTO deck_favorites (user_id, share_code)
VALUES (?, 'OLDDECK1')
`).run(oldUserId);
db.prepare(`
INSERT INTO auth_tokens (user_id, type, token_hash, expires_at)
VALUES (?, 'verify_email', 'old-token', datetime('now', '+1 day'))
`).run(oldUserId);
db.prepare(`
INSERT INTO email_send_logs (user_id, type, month_key, status)
VALUES (?, 'verify_email', '2026-06', 'sent')
`).run(oldUserId);
db.prepare('DELETE FROM users WHERE id = ?').run(oldUserId);
const newUserId = Number(
db.prepare(`
INSERT INTO users (
email, username, password_hash, display_name, email_verified_at
)
VALUES (?, ?, ?, ?, datetime('now'))
`).run('new@example.com', 'test', 'new-password-hash', 'test').lastInsertRowid,
);
const oldDeck = db.prepare(`
SELECT user_id AS userId FROM decks WHERE share_code = 'OLDDECK1'
`).get();
assert.ok(newUserId > oldUserId);
assert.equal(oldDeck.userId, null);
assert.equal(
db.prepare('SELECT COUNT(*) AS count FROM deck_favorites WHERE user_id = ?').get(oldUserId).count,
0,
);
assert.equal(
db.prepare('SELECT COUNT(*) AS count FROM auth_tokens WHERE user_id = ?').get(oldUserId).count,
0,
);
assert.equal(
db.prepare('SELECT COUNT(*) AS count FROM email_send_logs WHERE user_id = ?').get(oldUserId).count,
0,
);
assert.equal(
db.prepare(`
SELECT COUNT(*) AS count FROM decks
WHERE share_code = 'OLDDECK1' AND user_id = ?
`).get(newUserId).count,
0,
);
} finally {
db.close();
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('관리자는 미인증 회원의 이메일 인증을 수동 승인할 수 있다', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uniare-admin-verify-'));
const dbPath = path.join(tempDir, 'test.db');
const db = initDb(dbPath);
process.env.JWT_SECRET = 'test-secret-for-admin-email-verification';
const adminPassword = 'admin-password';
db.prepare(`
INSERT INTO users (
email, username, password_hash, display_name, role, email_verified_at
)
VALUES (?, ?, ?, ?, 'admin', datetime('now'))
`).run(
'admin@example.com',
'admin',
bcrypt.hashSync(adminPassword, 4),
'admin',
);
const targetUserId = Number(
db.prepare(`
INSERT INTO users (
email, username, password_hash, display_name, email_verified_at
)
VALUES (?, ?, ?, ?, NULL)
`).run(
'pending@example.com',
'pending',
bcrypt.hashSync('pending-password', 4),
'pending',
).lastInsertRowid,
);
db.prepare(`
INSERT INTO auth_tokens (user_id, type, token_hash, expires_at)
VALUES (?, 'verify_email', 'pending-token', datetime('now', '+1 day'))
`).run(targetUserId);
const app = express();
app.use(express.json());
app.use('/api/v1/auth', createAuthRouter(db));
app.use('/api/v1/admin', createAdminRouter(db));
const server = app.listen(0);
try {
await new Promise((resolve) => server.once('listening', resolve));
const address = server.address();
const baseUrl = `http://127.0.0.1:${address.port}/api/v1`;
const loginResponse = await fetch(`${baseUrl}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'admin@example.com',
password: adminPassword,
}),
});
assert.equal(loginResponse.status, 200);
const { token } = await loginResponse.json();
const approveResponse = await fetch(
`${baseUrl}/admin/users/${targetUserId}/verify-email`,
{
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
},
);
assert.equal(approveResponse.status, 200);
const target = db.prepare(`
SELECT email_verified_at AS emailVerifiedAt
FROM users WHERE id = ?
`).get(targetUserId);
const pendingToken = db.prepare(`
SELECT used_at AS usedAt
FROM auth_tokens WHERE user_id = ? AND type = 'verify_email'
`).get(targetUserId);
assert.ok(target.emailVerifiedAt);
assert.ok(pendingToken.usedAt);
} finally {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
db.close();
fs.rmSync(tempDir, { recursive: true, force: true });
}
});

View File

@@ -1,5 +1,19 @@
# 변경사항 # 변경사항
## v4.0.15
### 인증 메일 재발송 동선 개선
이메일 인증을 완료하지 않은 상태로 로그인하면 로그인 화면에서 인증 메일을 바로 다시 요청할 수 있습니다.
인증·비밀번호 재설정 메일은 최초 발송을 포함해 계정당 월 3회까지 제공되며, 한도 초과 시 관리자 문의 방법을 함께 안내합니다.
관리자는 문의한 미인증 회원을 확인한 뒤 관리자 화면에서 이메일 인증을 직접 승인할 수 있습니다.
### 인증·비밀번호 화면 다국어 개선
인증 완료 안내와 `비밀번호를 잊으셨나요?`, 비밀번호 찾기·재설정 화면을 한국어·일본어·영어로 제공합니다. 별도 언어 선택이 없으면 한국어로 표시됩니다.
## v4.0.14 ## v4.0.14
### 계정별 이메일 발송 한도 ### 계정별 이메일 발송 한도

View File

@@ -75,15 +75,17 @@ Docker 실행: `docker compose -f docker-compose.prod.yml up -d --build`
API 확인: `curl -fsS http://localhost:44082/api/v1/health` API 확인: `curl -fsS http://localhost:44082/api/v1/health`
정상이면 `{"ok":true,"version":"4.0.14"}`이 표시된다. 정상이면 `{"ok":true,"version":"4.0.15"}`이 표시된다.
이메일 기능 확인: 이메일 기능 확인:
1. 실제 수신 가능한 주소로 신규 가입한다. 1. 실제 수신 가능한 주소로 신규 가입한다.
2. 인증 메일 링크를 누르기 전 로그인이 차단되는지 확인한다. 2. 인증 메일 링크를 누르기 전 로그인이 차단되고 로그인 화면에 `인증 메일 다시 보내기`가 표시되는지 확인한다.
3. 인증 후 로그인되는지 확인한다. 3. 로그인 화면에서 재발송 후 60초 대기와 최초 발송 포함 월 3회 안내가 표시되는지 확인한다.
4. 로그인 화면의 `비밀번호를 잊으셨나요?`에서 재설정 메일과 새 비밀번호 로그인을 확인한다. 4. 인증 후 기본 한국어 완료 안내와 로그인이 정상 동작하는지 확인한다.
5. 같은 계정에서 인증·재설정 메일을 합산해 월 3회 초과 시 관리자 문의 안내가 표시되는지 확인한다. 5. 로그인 화면의 `비밀번호를 잊으셨나요?`와 비밀번호 찾기·재설정 화면이 선택 언어로 표시되는지 확인한다.
6. 같은 계정에서 인증·재설정 메일을 합산해 월 3회 초과 시 관리자 문의 안내가 표시되는지 확인한다.
7. 관리자 화면에서 미인증 회원의 `이메일 인증 승인` 버튼으로 수동 승인 후 로그인되는지 확인한다.
NAS 리버스 프록시는 `https://uniare.sori.studio`을 `http://<NAS_IP>:44082`로 연결한다. NAS 리버스 프록시는 `https://uniare.sori.studio`을 `http://<NAS_IP>:44082`로 연결한다.

View File

@@ -1,5 +1,10 @@
# 의사결정 이력 # 의사결정 이력
## 2026-06-25 v4.0.15 — 미인증 복구 동선은 로그인 화면에서 제공
💡 인증 메일을 잃어버린 사용자가 별도 화면을 찾아가지 않고 로그인 실패 지점에서 바로 복구하게 하기 위함.
🔧 미인증 오류 코드를 프론트에 유지하고 재발송·월 3회 제한·관리자 문의를 표시, 관리자는 미인증 계정을 수동 승인.
## 2026-06-25 v4.0.14 — 계정별 자동 메일은 월 3회로 제한 ## 2026-06-25 v4.0.14 — 계정별 자동 메일은 월 3회로 제한
💡 무료 메일 한도를 소수 계정의 반복 요청이 소진하지 않도록 보호하기 위함. 💡 무료 메일 한도를 소수 계정의 반복 요청이 소진하지 않도록 보호하기 위함.

View File

@@ -100,7 +100,7 @@ script/ads.js
```text ```text
관리자·로그인·회원가입 관리자·로그인·회원가입
admin.html (회원 2열·덱 공개/전체 통계·마지막 접속·역할 변경 버튼 노출), login.html (로그인 유지 선택·비밀번호 분실 메일 안내), signup.html — Tailwind 유틸리티 admin.html (회원 2열·덱 공개/전체 통계·마지막 접속·역할 변경·미인증 이메일 승인), login.html (로그인 유지·미인증 메일 재발송·비밀번호 찾기), signup.html — Tailwind 유틸리티
내 덱·공개 덱·프로필 내 덱·공개 덱·프로필
deckHistory.html, preset.html, profile.html — HTML 클래스 + components.css deckHistory.html, preset.html, profile.html — HTML 클래스 + components.css
@@ -122,6 +122,9 @@ api/src/db.js
계정별 월간 이메일 발송 기록 계정별 월간 이메일 발송 기록
api/src/db.js (`email_send_logs`) api/src/db.js (`email_send_logs`)
계정 삭제 후 사용자명 재사용 회귀 테스트
api/test/account-deletion.test.js
JWT 인증·권한 JWT 인증·권한
api/src/middleware/auth.js api/src/middleware/auth.js
@@ -134,7 +137,7 @@ api/src/services/emailService.js
덱 저장·조회·공개 목록 덱 저장·조회·공개 목록
api/src/routes/decks.js api/src/routes/decks.js
회원·덱 관리자 API 회원·이메일 인증 승인·덱 관리자 API
api/src/routes/admin.js api/src/routes/admin.js
카드 메타 합성 카드 메타 합성

View File

@@ -3,7 +3,7 @@
## 프로젝트 개요 ## 프로젝트 개요
- 서비스명: `UNIARE - Union Arena Deck Builder` - 서비스명: `UNIARE - Union Arena Deck Builder`
- 현재 버전: `v4.0.14` - 현재 버전: `v4.0.15`
- 프론트: 정적 HTML, CSS, JavaScript - 프론트: 정적 HTML, CSS, JavaScript
- 백엔드: Express, SQLite(`better-sqlite3`) - 백엔드: Express, SQLite(`better-sqlite3`)
- 운영: Docker Compose 기반 Nginx + API - 운영: Docker Compose 기반 Nginx + API
@@ -96,6 +96,8 @@
- 인증 폼은 요청 중 제출 버튼을 잠그고 로딩 상태를 표시하며, 메일 재요청은 화면과 API에서 1분 간격으로 제한한다. - 인증 폼은 요청 중 제출 버튼을 잠그고 로딩 상태를 표시하며, 메일 재요청은 화면과 API에서 1분 간격으로 제한한다.
- 가입 인증·인증 재전송·비밀번호 재설정 메일은 계정별 한국 시간 기준 월 3회까지만 발송한다. - 가입 인증·인증 재전송·비밀번호 재설정 메일은 계정별 한국 시간 기준 월 3회까지만 발송한다.
- 월간 한도 초과 시 자동 발송을 중단하고 관리자 이메일 `zenn@sori.studio` 문의를 안내한다. - 월간 한도 초과 시 자동 발송을 중단하고 관리자 이메일 `zenn@sori.studio` 문의를 안내한다.
- 미인증 사용자가 로그인하면 로그인 화면에서 인증 메일을 즉시 재전송할 수 있으며, 최초 발송 포함 월 3회 제한과 관리자 문의 방법을 함께 표시한다.
- 인증 완료 안내와 비밀번호 찾기·재설정 화면은 한국어를 기본값으로 한국어·일본어·영어 번역을 제공한다.
- 사용자명은 대소문자를 구분하지 않고 중복을 차단한다. - 사용자명은 대소문자를 구분하지 않고 중복을 차단한다.
- 인증된 이메일은 프로필에서 직접 변경할 수 없다. - 인증된 이메일은 프로필에서 직접 변경할 수 없다.
- JWT 기본 만료는 7일이며 `JWT_EXPIRES`로 변경할 수 있다. - JWT 기본 만료는 7일이며 `JWT_EXPIRES`로 변경할 수 있다.
@@ -108,6 +110,8 @@
- 비회원은 덱을 서버에 저장하거나 공개 링크를 생성할 수 없다. - 비회원은 덱을 서버에 저장하거나 공개 링크를 생성할 수 없다.
- 로그인 사용자의 즐겨찾기는 계정 DB에 저장되어 브라우저와 기기가 달라도 유지된다. - 로그인 사용자의 즐겨찾기는 계정 DB에 저장되어 브라우저와 기기가 달라도 유지된다.
- 비로그인 즐겨찾기는 브라우저에 저장하고 로그인 시 유효한 덱만 계정으로 이전한다. - 비로그인 즐겨찾기는 브라우저에 저장하고 로그인 시 유효한 덱만 계정으로 이전한다.
- 관리자 화면은 회원 비활성화만 제공하며 계정 영구 삭제 UI는 제공하지 않는다.
- 계정이 DB에서 삭제되면 인증 토큰·메일 기록·즐겨찾기는 연쇄 삭제되고, 기존 덱의 `user_id`는 `null`이 된다. 같은 사용자명으로 새로 가입해도 새 사용자 ID가 부여되어 기존 정보와 연결되지 않는다.
## 덱 저장 정책 ## 덱 저장 정책
@@ -230,6 +234,7 @@
| GET | `/api/v1/admin/users` | 회원 목록·검색 (`deckPublic`, `deckTotal`, `lastLoginAt` 포함) | | GET | `/api/v1/admin/users` | 회원 목록·검색 (`deckPublic`, `deckTotal`, `lastLoginAt` 포함) |
| GET | `/api/v1/admin/users/:id` | 회원 상세 (`deckPublic`, `deckTotal`, `deckCount`, `lastLoginAt` 포함) | | GET | `/api/v1/admin/users/:id` | 회원 상세 (`deckPublic`, `deckTotal`, `deckCount`, `lastLoginAt` 포함) |
| PUT | `/api/v1/admin/users/:id` | 회원 정보·역할·활성 상태 변경 | | PUT | `/api/v1/admin/users/:id` | 회원 정보·역할·활성 상태 변경 |
| POST | `/api/v1/admin/users/:id/verify-email` | 미인증 회원 이메일 수동 승인 |
| POST | `/api/v1/admin/users/:id/reset-password` | 비밀번호 초기화 | | POST | `/api/v1/admin/users/:id/reset-password` | 비밀번호 초기화 |
| GET | `/api/v1/admin/decks` | 전체 덱 목록 | | GET | `/api/v1/admin/decks` | 전체 덱 목록 |
| DELETE | `/api/v1/admin/decks/:shareCode` | 덱 삭제 | | DELETE | `/api/v1/admin/decks/:shareCode` | 덱 삭제 |

View File

@@ -1,5 +1,16 @@
# 업데이트 이력 # 업데이트 이력
## 2026-06-25 / v4.0.15 — 미인증 로그인 복구·인증 화면 다국어
### 수행 작업
- 미인증 계정 로그인 시 같은 화면에서 인증 메일 재발송 기능 표시
- 최초 발송 포함 월 3회 제한과 초과 시 `zenn@sori.studio` 관리자 문의 안내 추가
- 관리자 회원 화면에 미인증 상태와 `이메일 인증 승인` 기능 추가
- API 오류의 상태·코드를 프론트까지 전달해 미인증·월 한도 오류를 구분
- 인증 완료 후 로그인 화면에 선택 언어 기준 완료 메시지 표시, 기본 언어는 한국어로 보정
- `비밀번호를 잊으셨나요?`, 인증·비밀번호 찾기·재설정 화면을 한국어·일본어·영어 번역에 연결
- 계정 삭제 후 동일 사용자명 재가입 시 기존 덱·인증 정보가 연결되지 않는 DB 회귀 테스트 추가
## 2026-06-25 / v4.0.14 — 계정별 월간 메일 한도 ## 2026-06-25 / v4.0.14 — 계정별 월간 메일 한도
### 수행 작업 ### 수행 작업

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="./style/output.css?v=4.0.14" /> <link rel="stylesheet" href="./style/output.css?v=4.0.15" />
<title>비밀번호 찾기 - UNIARE</title> <title>비밀번호 찾기 - UNIARE</title>
</head> </head>
<body class="app-page"> <body class="app-page">
@@ -14,40 +14,69 @@
</nav> </nav>
<main class="mx-auto w-full max-w-md px-6 pt-12 pb-16 sm:pt-16 sm:pb-20"> <main class="mx-auto w-full max-w-md px-6 pt-12 pb-16 sm:pt-16 sm:pb-20">
<header class="mb-8 text-center"> <header class="mb-8 text-center">
<h1 class="text-3xl font-black tracking-tight text-slate-900">비밀번호 찾기</h1> <h1 class="text-3xl font-black tracking-tight text-slate-900" data-i18n="forgotPasswordTitle">비밀번호 찾기</h1>
<p class="mt-2 text-sm leading-relaxed text-slate-500">가입한 이메일로 재설정 링크를 보내드립니다.</p> <p class="mt-2 text-sm leading-relaxed text-slate-500" data-i18n="forgotPasswordDesc">가입한 이메일로 재설정 링크를 보내드립니다.</p>
</header> </header>
<form id="forgot-form" class="rounded-2xl border border-slate-200 bg-white p-7 shadow-lg shadow-slate-900/5"> <form id="forgot-form" class="rounded-2xl border border-slate-200 bg-white p-7 shadow-lg shadow-slate-900/5">
<label class="mb-1.5 block text-xs font-bold uppercase tracking-wider text-slate-400" for="email">이메일</label> <label class="mb-1.5 block text-xs font-bold uppercase tracking-wider text-slate-400" for="email" data-i18n="emailLabel">이메일</label>
<input type="email" id="email" required autocomplete="email" placeholder="you@example.com" class="box-border block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/15" /> <input type="email" id="email" required autocomplete="email" placeholder="you@example.com" class="box-border block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/15" />
<p id="status-msg" class="mt-4 hidden text-sm text-slate-500"></p> <p id="status-msg" class="mt-4 hidden text-sm text-slate-500"></p>
<button type="submit" id="forgot-submit" class="mt-6 block w-full cursor-pointer rounded-xl border-0 bg-blue-600 px-4 py-3.5 text-sm font-bold text-white transition hover:bg-blue-700 disabled:cursor-wait disabled:opacity-70">재설정 메일 보내기</button> <button type="submit" id="forgot-submit" class="mt-6 block w-full cursor-pointer rounded-xl border-0 bg-blue-600 px-4 py-3.5 text-sm font-bold text-white transition hover:bg-blue-700 disabled:cursor-wait disabled:opacity-70" data-i18n="sendResetEmail">재설정 메일 보내기</button>
<p class="mt-3 text-xs leading-relaxed text-slate-500" data-i18n="emailMonthlyLimitNotice">인증·비밀번호 재설정 메일은 최초 발송을 포함해 계정당 월 3회까지 발송됩니다.</p>
</form> </form>
<p class="mt-6 text-center"><a href="/login.html" class="text-sm font-bold text-blue-600 hover:underline">로그인으로 돌아가기</a></p> <p class="mt-6 text-center"><a href="/login.html" class="text-sm font-bold text-blue-600 hover:underline" data-i18n="backToLogin">로그인으로 돌아가기</a></p>
</main> </main>
<script src="/script/api.js?v=4.0.14"></script> <script src="/script/page-i18n.js"></script>
<script src="/script/auth-form.js?v=4.0.14"></script> <script src="/script/api.js?v=4.0.15"></script>
<script src="/script/auth-form.js?v=4.0.15"></script>
<script> <script>
/**
* 현재 언어 번역 문자열
* @param {string} key
* @param {string} fallback
* @returns {string}
*/
function forgotText(key, fallback) {
return window.UaPageI18n?.t(key, fallback) || fallback;
}
document.getElementById('forgot-form').addEventListener('submit', async (event) => { document.getElementById('forgot-form').addEventListener('submit', async (event) => {
event.preventDefault(); event.preventDefault();
const statusEl = document.getElementById('status-msg'); const statusEl = document.getElementById('status-msg');
const submitButton = document.getElementById('forgot-submit'); const submitButton = document.getElementById('forgot-submit');
if (submitButton.disabled) return; if (submitButton.disabled) return;
statusEl.classList.remove('hidden'); statusEl.classList.remove('hidden');
UaAuthForm.setLoading(submitButton, true, '전송 중...'); UaAuthForm.setLoading(
submitButton,
true,
forgotText('sendingLabel', '전송 중...'),
);
let sent = false; let sent = false;
try { try {
const email = document.getElementById('email').value; const email = document.getElementById('email').value;
const result = typeof UaDeckApi.forgotPassword === 'function' await (typeof UaDeckApi.forgotPassword === 'function'
? await UaDeckApi.forgotPassword(email) ? await UaDeckApi.forgotPassword(email)
: await UaAuthForm.request('/auth/forgot-password', { email }); : await UaAuthForm.request('/auth/forgot-password', { email }));
statusEl.textContent = result.message; statusEl.textContent = forgotText(
'resetEmailSent',
'가입된 이메일이라면 비밀번호 재설정 링크를 보냈습니다.',
);
sent = true; sent = true;
} catch (err) { } catch (err) {
statusEl.textContent = err.message; statusEl.textContent =
err.code === 'MONTHLY_EMAIL_LIMIT_EXCEEDED'
? forgotText(
'emailLimitExceeded',
'월간 이메일 발송 한도(3회)를 초과했습니다. 관리자에게 직접 문의해 주세요.',
)
: err.message;
} finally { } finally {
if (sent) { if (sent) {
UaAuthForm.startCooldown(submitButton, 60, '다시 요청'); UaAuthForm.startCooldown(
submitButton,
60,
forgotText('requestAgain', '다시 요청'),
);
} else { } else {
UaAuthForm.setLoading(submitButton, false); UaAuthForm.setLoading(submitButton, false);
} }

View File

@@ -90,6 +90,34 @@
"rememberLogin": "Keep me logged in", "rememberLogin": "Keep me logged in",
"noAccount": "Don't have an account?", "noAccount": "Don't have an account?",
"hasAccount": "Already have an account?", "hasAccount": "Already have an account?",
"forgotPasswordLink": "Forgot your password?",
"forgotPasswordTitle": "Forgot password",
"forgotPasswordDesc": "We'll send a reset link to your registered email.",
"sendResetEmail": "Send reset email",
"resetEmailSent": "If the email is registered, a password reset link has been sent.",
"requestAgain": "Request again",
"resetPasswordTitle": "Reset password",
"resetPasswordDesc": "Enter the new password you want to use.",
"passwordResetComplete": "Your password has been changed. Log in with your new password.",
"changingLabel": "Updating...",
"changeComplete": "Updated",
"backToLogin": "Back to login",
"verifyEmailTitle": "Verify email",
"verifyEmailSentDesc": "A verification link has been sent to your registered email.",
"verifyingEmail": "Verifying your email...",
"verificationCompleteLoginNotice": "Email verification is complete. Please log in.",
"verificationLinkInvalid": "The verification link is invalid or has expired.",
"verificationRequiredTitle": "Email verification is required.",
"verificationRequiredDesc": "If the email did not arrive or was deleted, you can request another one below.",
"verificationRequiredLoginError": "Verify your email before logging in.",
"resendVerificationButton": "Resend verification email",
"resendVerificationShort": "Resend",
"verificationResent": "The verification email has been sent again.",
"emailMonthlyLimitNotice": "Verification and password reset emails are limited to three per account each month, including the initial email.",
"emailLimitAdminNotice": "If you exceed three emails in a month, contact the administrator directly for review and approval.",
"emailLimitExceeded": "The monthly email limit of three has been reached. Contact the administrator directly.",
"sendingLabel": "Sending...",
"goToLogin": "Go to login",
"deckHistoryTitle": "My Decks", "deckHistoryTitle": "My Decks",
"deckHistoryDesc": "Your saved decks. Public decks appear in the Public Decks list.", "deckHistoryDesc": "Your saved decks. Public decks appear in the Public Decks list.",

View File

@@ -90,6 +90,34 @@
"rememberLogin": "ログイン状態を保持", "rememberLogin": "ログイン状態を保持",
"noAccount": "アカウントをお持ちでない方", "noAccount": "アカウントをお持ちでない方",
"hasAccount": "すでにアカウントをお持ちの方", "hasAccount": "すでにアカウントをお持ちの方",
"forgotPasswordLink": "パスワードをお忘れですか?",
"forgotPasswordTitle": "パスワードを忘れた方",
"forgotPasswordDesc": "登録メールアドレスに再設定リンクを送信します。",
"sendResetEmail": "再設定メールを送信",
"resetEmailSent": "登録済みのメールアドレスであれば、再設定リンクを送信しました。",
"requestAgain": "再リクエスト",
"resetPasswordTitle": "パスワード再設定",
"resetPasswordDesc": "新しく使用するパスワードを入力してください。",
"passwordResetComplete": "パスワードを変更しました。新しいパスワードでログインしてください。",
"changingLabel": "変更中...",
"changeComplete": "変更完了",
"backToLogin": "ログインに戻る",
"verifyEmailTitle": "メール認証",
"verifyEmailSentDesc": "登録メールアドレスに認証リンクを送信しました。",
"verifyingEmail": "メールアドレスを認証しています...",
"verificationCompleteLoginNotice": "メール認証が完了しました。ログインしてください。",
"verificationLinkInvalid": "認証リンクが無効または期限切れです。",
"verificationRequiredTitle": "メール認証が必要です。",
"verificationRequiredDesc": "メールが届かない、または削除した場合は下のボタンから再送できます。",
"verificationRequiredLoginError": "メール認証後にログインできます。",
"resendVerificationButton": "認証メールを再送",
"resendVerificationShort": "再送",
"verificationResent": "認証メールを再送しました。",
"emailMonthlyLimitNotice": "認証・パスワード再設定メールは初回送信を含め、1アカウントにつき月3回までです。",
"emailLimitAdminNotice": "月3回を超えた場合は管理者へ直接メールを送信してください。確認後に承認します。",
"emailLimitExceeded": "月間メール送信上限(3回)を超えました。管理者へ直接お問い合わせください。",
"sendingLabel": "送信中...",
"goToLogin": "ログインへ",
"deckHistoryTitle": "マイデック管理", "deckHistoryTitle": "マイデック管理",
"deckHistoryDesc": "保存したデッキ一覧です。公開にすると「公開デッキ」に表示されます。", "deckHistoryDesc": "保存したデッキ一覧です。公開にすると「公開デッキ」に表示されます。",

View File

@@ -90,6 +90,34 @@
"rememberLogin": "로그인 유지", "rememberLogin": "로그인 유지",
"noAccount": "계정이 없으신가요?", "noAccount": "계정이 없으신가요?",
"hasAccount": "이미 계정이 있으신가요?", "hasAccount": "이미 계정이 있으신가요?",
"forgotPasswordLink": "비밀번호를 잊으셨나요?",
"forgotPasswordTitle": "비밀번호 찾기",
"forgotPasswordDesc": "가입한 이메일로 재설정 링크를 보내드립니다.",
"sendResetEmail": "재설정 메일 보내기",
"resetEmailSent": "가입된 이메일이라면 비밀번호 재설정 링크를 보냈습니다.",
"requestAgain": "다시 요청",
"resetPasswordTitle": "비밀번호 재설정",
"resetPasswordDesc": "새로 사용할 비밀번호를 입력하세요.",
"passwordResetComplete": "비밀번호가 변경되었습니다. 새 비밀번호로 로그인해 주세요.",
"changingLabel": "변경 중...",
"changeComplete": "변경 완료",
"backToLogin": "로그인으로 돌아가기",
"verifyEmailTitle": "이메일 인증",
"verifyEmailSentDesc": "가입한 이메일로 인증 링크를 보냈습니다.",
"verifyingEmail": "이메일을 인증하는 중입니다...",
"verificationCompleteLoginNotice": "이메일 인증이 완료되었습니다. 로그인해 주세요.",
"verificationLinkInvalid": "인증 링크가 유효하지 않거나 만료되었습니다.",
"verificationRequiredTitle": "이메일 인증이 필요합니다.",
"verificationRequiredDesc": "메일을 받지 못했거나 삭제한 경우 아래 버튼으로 다시 요청할 수 있습니다.",
"verificationRequiredLoginError": "이메일 인증 후 로그인할 수 있습니다.",
"resendVerificationButton": "인증 메일 다시 보내기",
"resendVerificationShort": "다시 보내기",
"verificationResent": "인증 메일을 다시 보냈습니다.",
"emailMonthlyLimitNotice": "인증·비밀번호 재설정 메일은 최초 발송을 포함해 계정당 월 3회까지 발송됩니다.",
"emailLimitAdminNotice": "월 3회를 초과한 경우 관리자에게 직접 메일을 보내면 확인 후 승인해 드립니다.",
"emailLimitExceeded": "월간 이메일 발송 한도(3회)를 초과했습니다. 관리자에게 직접 문의해 주세요.",
"sendingLabel": "전송 중...",
"goToLogin": "로그인으로 이동",
"deckHistoryTitle": "내 덱 관리", "deckHistoryTitle": "내 덱 관리",
"deckHistoryDesc": "저장한 덱 목록입니다. 공개 전환 시「공개 덱」목록에 표시됩니다.", "deckHistoryDesc": "저장한 덱 목록입니다. 공개 전환 시「공개 덱」목록에 표시됩니다.",

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="./style/output.css?v=4.0.14" /> <link rel="stylesheet" href="./style/output.css?v=4.0.15" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800;900&family=Noto+Sans+KR:wght@400;700;900&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800;900&family=Noto+Sans+KR:wght@400;700;900&display=swap" rel="stylesheet" />
<title>로그인 - UNIARE</title> <title>로그인 - UNIARE</title>
</head> </head>
@@ -35,7 +35,18 @@
<input type="checkbox" id="remember-login" class="size-4 rounded border-slate-300 text-blue-600 accent-blue-600 focus:ring-2 focus:ring-blue-500/20" /> <input type="checkbox" id="remember-login" class="size-4 rounded border-slate-300 text-blue-600 accent-blue-600 focus:ring-2 focus:ring-blue-500/20" />
<span data-i18n="rememberLogin">로그인 유지</span> <span data-i18n="rememberLogin">로그인 유지</span>
</label> </label>
<p id="notice-msg" class="mt-4 hidden rounded-xl bg-emerald-50 px-4 py-3 text-sm leading-relaxed text-emerald-700"></p>
<p id="error-msg" class="mt-4 hidden text-sm text-red-500"></p> <p id="error-msg" class="mt-4 hidden text-sm text-red-500"></p>
<section id="verification-help" class="mt-4 hidden rounded-xl border border-amber-200 bg-amber-50 p-4 text-left">
<p class="text-sm font-bold text-amber-900" data-i18n="verificationRequiredTitle">이메일 인증이 필요합니다.</p>
<p class="mt-1 text-xs leading-relaxed text-amber-800" data-i18n="verificationRequiredDesc">메일을 받지 못했거나 삭제한 경우 아래 버튼으로 다시 요청할 수 있습니다.</p>
<p class="mt-2 text-xs leading-relaxed text-amber-700" data-i18n="emailMonthlyLimitNotice">인증·비밀번호 재설정 메일은 최초 발송을 포함해 계정당 월 3회까지 발송됩니다.</p>
<button type="button" id="resend-verification" class="mt-3 block w-full cursor-pointer rounded-xl border border-amber-300 bg-white px-4 py-2.5 text-sm font-bold text-amber-900 transition hover:bg-amber-100 disabled:cursor-wait disabled:opacity-70" data-i18n="resendVerificationButton">인증 메일 다시 보내기</button>
<p class="mt-3 text-xs leading-relaxed text-amber-700">
<span data-i18n="emailLimitAdminNotice">월 3회를 초과한 경우 관리자에게 직접 메일을 보내면 확인 후 승인해 드립니다.</span>
<a href="mailto:zenn@sori.studio" class="font-bold underline">zenn@sori.studio</a>
</p>
</section>
<button type="submit" id="login-submit" class="mt-6 block w-full cursor-pointer rounded-xl border-0 bg-blue-600 px-4 py-3.5 text-sm font-bold text-white transition hover:bg-blue-700 active:scale-[0.98] disabled:cursor-wait disabled:opacity-70" data-i18n="loginButton">로그인</button> <button type="submit" id="login-submit" class="mt-6 block w-full cursor-pointer rounded-xl border-0 bg-blue-600 px-4 py-3.5 text-sm font-bold text-white transition hover:bg-blue-700 active:scale-[0.98] disabled:cursor-wait disabled:opacity-70" data-i18n="loginButton">로그인</button>
</form> </form>
@@ -44,16 +55,49 @@
<a href="/signup.html" class="font-bold text-blue-600 hover:underline" data-i18n="authSignup">회원가입</a> <a href="/signup.html" class="font-bold text-blue-600 hover:underline" data-i18n="authSignup">회원가입</a>
</p> </p>
<p class="mt-3 text-center text-xs leading-relaxed text-slate-400"> <p class="mt-3 text-center text-xs leading-relaxed text-slate-400">
<a href="/forgot-password.html" class="font-semibold text-slate-500 hover:underline">비밀번호를 잊으셨나요?</a> <a href="/forgot-password.html" class="font-semibold text-slate-500 hover:underline" data-i18n="forgotPasswordLink">비밀번호를 잊으셨나요?</a>
</p> </p>
</main> </main>
<footer class="px-6 pb-5 text-center text-[0.625rem] font-medium tracking-wide text-slate-300" aria-hidden="true">v4.0.14</footer> <footer class="px-6 pb-5 text-center text-[0.625rem] font-medium tracking-wide text-slate-300" aria-hidden="true">v4.0.15</footer>
<script src="/script/page-i18n.js"></script> <script src="/script/page-i18n.js"></script>
<script src="/script/api.js?v=4.0.14"></script> <script src="/script/api.js?v=4.0.15"></script>
<script src="/script/auth-form.js?v=4.0.14"></script> <script src="/script/auth-form.js?v=4.0.15"></script>
<script> <script>
const verificationHelp = document.getElementById('verification-help');
const resendButton = document.getElementById('resend-verification');
/**
* 현재 언어 번역 문자열
* @param {string} key
* @param {string} fallback
* @returns {string}
*/
function authText(key, fallback) {
return window.UaPageI18n?.t(key, fallback) || fallback;
}
/**
* 미인증 계정 재발송 안내 표시
* @param {string} email
*/
function showVerificationHelp(email) {
document.getElementById('email').value = email;
verificationHelp.classList.remove('hidden');
}
UaPageI18n.applyPageI18n().then(() => {
if (new URLSearchParams(location.search).get('verified') === '1') {
const notice = document.getElementById('notice-msg');
notice.textContent = authText(
'verificationCompleteLoginNotice',
'이메일 인증이 완료되었습니다. 로그인해 주세요.',
);
notice.classList.remove('hidden');
}
});
document.getElementById('login-form').addEventListener('submit', async (e) => { document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const errEl = document.getElementById('error-msg'); const errEl = document.getElementById('error-msg');
@@ -70,11 +114,67 @@
const redirect = new URLSearchParams(location.search).get('redirect') || '/'; const redirect = new URLSearchParams(location.search).get('redirect') || '/';
location.href = redirect; location.href = redirect;
} catch (err) { } catch (err) {
errEl.textContent = err.message; errEl.classList.remove('text-emerald-600');
errEl.classList.add('text-red-500');
if (err.code === 'EMAIL_NOT_VERIFIED') {
errEl.textContent = authText(
'verificationRequiredLoginError',
'이메일 인증 후 로그인할 수 있습니다.',
);
showVerificationHelp(document.getElementById('email').value);
} else {
errEl.textContent = err.message;
verificationHelp.classList.add('hidden');
}
errEl.classList.remove('hidden'); errEl.classList.remove('hidden');
UaAuthForm.setLoading(submitButton, false); UaAuthForm.setLoading(submitButton, false);
} }
}); });
resendButton.addEventListener('click', async () => {
if (resendButton.disabled) return;
const errEl = document.getElementById('error-msg');
const email = document.getElementById('email').value;
errEl.classList.add('hidden');
UaAuthForm.setLoading(
resendButton,
true,
authText('sendingLabel', '전송 중...'),
);
let sent = false;
try {
const result = await UaDeckApi.resendVerification(email);
errEl.textContent = authText(
'verificationResent',
result.message || '인증 메일을 다시 보냈습니다.',
);
errEl.classList.remove('text-red-500');
errEl.classList.add('text-emerald-600');
errEl.classList.remove('hidden');
sent = true;
} catch (err) {
errEl.classList.remove('text-emerald-600');
errEl.classList.add('text-red-500');
errEl.textContent =
err.code === 'MONTHLY_EMAIL_LIMIT_EXCEEDED'
? authText(
'emailLimitExceeded',
'월간 이메일 발송 한도(3회)를 초과했습니다. 관리자에게 직접 문의해 주세요.',
)
: err.message;
errEl.classList.remove('hidden');
} finally {
if (sent) {
UaAuthForm.startCooldown(
resendButton,
60,
authText('resendVerificationShort', '다시 보내기'),
);
} else {
UaAuthForm.setLoading(resendButton, false);
}
}
});
</script> </script>
</body> </body>
</html> </html>

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="./style/output.css?v=4.0.14" /> <link rel="stylesheet" href="./style/output.css?v=4.0.15" />
<title>비밀번호 재설정 - UNIARE</title> <title>비밀번호 재설정 - UNIARE</title>
</head> </head>
<body class="app-page"> <body class="app-page">
@@ -14,24 +14,35 @@
</nav> </nav>
<main class="mx-auto w-full max-w-md px-6 pt-12 pb-16 sm:pt-16 sm:pb-20"> <main class="mx-auto w-full max-w-md px-6 pt-12 pb-16 sm:pt-16 sm:pb-20">
<header class="mb-8 text-center"> <header class="mb-8 text-center">
<h1 class="text-3xl font-black tracking-tight text-slate-900">비밀번호 재설정</h1> <h1 class="text-3xl font-black tracking-tight text-slate-900" data-i18n="resetPasswordTitle">비밀번호 재설정</h1>
<p class="mt-2 text-sm leading-relaxed text-slate-500">새로 사용할 비밀번호를 입력하세요.</p> <p class="mt-2 text-sm leading-relaxed text-slate-500" data-i18n="resetPasswordDesc">새로 사용할 비밀번호를 입력하세요.</p>
</header> </header>
<form id="reset-form" class="rounded-2xl border border-slate-200 bg-white p-7 shadow-lg shadow-slate-900/5"> <form id="reset-form" class="rounded-2xl border border-slate-200 bg-white p-7 shadow-lg shadow-slate-900/5">
<div class="mb-5"> <div class="mb-5">
<label class="mb-1.5 block text-xs font-bold uppercase tracking-wider text-slate-400" for="password">새 비밀번호</label> <label class="mb-1.5 block text-xs font-bold uppercase tracking-wider text-slate-400" for="password" data-i18n="newPasswordLabel">새 비밀번호</label>
<input type="password" id="password" required minlength="8" autocomplete="new-password" placeholder="8자 이상" class="box-border block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/15" /> <input type="password" id="password" required minlength="8" autocomplete="new-password" data-i18n-placeholder="signupPasswordPlaceholder" placeholder="8자 이상" class="box-border block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/15" />
</div> </div>
<label class="mb-1.5 block text-xs font-bold uppercase tracking-wider text-slate-400" for="password-confirm">비밀번호 확인</label> <label class="mb-1.5 block text-xs font-bold uppercase tracking-wider text-slate-400" for="password-confirm" data-i18n="confirmPasswordLabel">비밀번호 확인</label>
<input type="password" id="password-confirm" required minlength="8" autocomplete="new-password" class="box-border block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/15" /> <input type="password" id="password-confirm" required minlength="8" autocomplete="new-password" class="box-border block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/15" />
<p id="status-msg" class="mt-4 hidden text-sm text-slate-500"></p> <p id="status-msg" class="mt-4 hidden text-sm text-slate-500"></p>
<button type="submit" id="reset-submit" class="mt-6 block w-full cursor-pointer rounded-xl border-0 bg-blue-600 px-4 py-3.5 text-sm font-bold text-white transition hover:bg-blue-700 disabled:cursor-wait disabled:opacity-70">비밀번호 변경</button> <button type="submit" id="reset-submit" class="mt-6 block w-full cursor-pointer rounded-xl border-0 bg-blue-600 px-4 py-3.5 text-sm font-bold text-white transition hover:bg-blue-700 disabled:cursor-wait disabled:opacity-70" data-i18n="changePasswordButton">비밀번호 변경</button>
</form> </form>
<p class="mt-6 text-center"><a href="/login.html" class="text-sm font-bold text-blue-600 hover:underline">로그인으로 돌아가기</a></p> <p class="mt-6 text-center"><a href="/login.html" class="text-sm font-bold text-blue-600 hover:underline" data-i18n="backToLogin">로그인으로 돌아가기</a></p>
</main> </main>
<script src="/script/api.js?v=4.0.14"></script> <script src="/script/page-i18n.js"></script>
<script src="/script/auth-form.js?v=4.0.14"></script> <script src="/script/api.js?v=4.0.15"></script>
<script src="/script/auth-form.js?v=4.0.15"></script>
<script> <script>
/**
* 현재 언어 번역 문자열
* @param {string} key
* @param {string} fallback
* @returns {string}
*/
function resetText(key, fallback) {
return window.UaPageI18n?.t(key, fallback) || fallback;
}
document.getElementById('reset-form').addEventListener('submit', async (event) => { document.getElementById('reset-form').addEventListener('submit', async (event) => {
event.preventDefault(); event.preventDefault();
const statusEl = document.getElementById('status-msg'); const statusEl = document.getElementById('status-msg');
@@ -41,17 +52,30 @@
const confirm = document.getElementById('password-confirm').value; const confirm = document.getElementById('password-confirm').value;
statusEl.classList.remove('hidden'); statusEl.classList.remove('hidden');
if (password !== confirm) { if (password !== confirm) {
statusEl.textContent = '비밀번호가 일치하지 않습니다.'; statusEl.textContent = resetText(
'passwordMismatch',
'비밀번호가 일치하지 않습니다.',
);
return; return;
} }
UaAuthForm.setLoading(submitButton, true, '변경 중...'); UaAuthForm.setLoading(
submitButton,
true,
resetText('changingLabel', '변경 중...'),
);
try { try {
const token = new URLSearchParams(location.search).get('token') || ''; const token = new URLSearchParams(location.search).get('token') || '';
const result = typeof UaDeckApi.resetPassword === 'function' await (typeof UaDeckApi.resetPassword === 'function'
? await UaDeckApi.resetPassword(token, password) ? await UaDeckApi.resetPassword(token, password)
: await UaAuthForm.request('/auth/reset-password', { token, newPassword: password }); : await UaAuthForm.request('/auth/reset-password', { token, newPassword: password }));
statusEl.textContent = result.message; statusEl.textContent = resetText(
UaAuthForm.setComplete(submitButton, '변경 완료'); 'passwordResetComplete',
'비밀번호가 변경되었습니다. 새 비밀번호로 로그인해 주세요.',
);
UaAuthForm.setComplete(
submitButton,
resetText('changeComplete', '변경 완료'),
);
} catch (err) { } catch (err) {
statusEl.textContent = err.message; statusEl.textContent = err.message;
UaAuthForm.setLoading(submitButton, false); UaAuthForm.setLoading(submitButton, false);

View File

@@ -61,6 +61,20 @@ const UaDeckApi = (() => {
return data.error || `요청 실패 (${res.status})`; return data.error || `요청 실패 (${res.status})`;
} }
/**
* API 오류 객체 생성
* @param {Response} res
* @param {object} data
* @returns {Error & {status?: number, code?: string, data?: object}}
*/
function createApiError(res, data) {
const error = new Error(formatError(res, data));
error.status = res.status;
error.code = data.code;
error.data = data;
return error;
}
/** /**
* 저장된 토큰 * 저장된 토큰
* @returns {string|null} * @returns {string|null}
@@ -126,7 +140,7 @@ const UaDeckApi = (() => {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
throw new Error(formatError(res, data)); throw createApiError(res, data);
} }
return data; return data;
} }
@@ -232,6 +246,10 @@ const UaDeckApi = (() => {
adminUpdateUser: (id, body) => adminUpdateUser: (id, body) =>
request(`/admin/users/${id}`, { method: 'PUT', body: JSON.stringify(body) }), request(`/admin/users/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
/** @param {number} id */
adminVerifyUserEmail: (id) =>
request(`/admin/users/${id}/verify-email`, { method: 'POST' }),
/** @param {number} id @param {string} newPassword */ /** @param {number} id @param {string} newPassword */
adminResetPassword: (id, newPassword) => adminResetPassword: (id, newPassword) =>
request(`/admin/users/${id}/reset-password`, { request(`/admin/users/${id}/reset-password`, {

View File

@@ -45,7 +45,11 @@ const UaAuthForm = (() => {
}); });
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
if (!response.ok) { if (!response.ok) {
throw new Error(data.error || `요청 실패 (${response.status})`); const error = new Error(data.error || `요청 실패 (${response.status})`);
error.status = response.status;
error.code = data.code;
error.data = data;
throw error;
} }
return data; return data;
} }

View File

@@ -2,13 +2,15 @@
* data-i18n 속성 기반 페이지 번역 * data-i18n 속성 기반 페이지 번역
*/ */
(function initPageI18n() { (function initPageI18n() {
const SUPPORTED_LANGUAGES = new Set(['ko', 'ja', 'en']);
/** /**
* @returns {Promise<string>} * @returns {Promise<string>}
*/ */
function getLang() { function getLang() {
const fromQuery = new URLSearchParams(window.location.search).get('lang'); const fromQuery = new URLSearchParams(window.location.search).get('lang');
if (fromQuery) return fromQuery; const selected = fromQuery || localStorage.getItem('lang') || 'ko';
return localStorage.getItem('lang') || 'ko'; return SUPPORTED_LANGUAGES.has(selected) ? selected : 'ko';
} }
/** /**
@@ -59,6 +61,7 @@
async function applyPageI18n() { async function applyPageI18n() {
const lang = await getLang(); const lang = await getLang();
document.documentElement.lang = lang;
cachedTranslations = await loadTranslations(lang); cachedTranslations = await loadTranslations(lang);
applyTranslations(cachedTranslations); applyTranslations(cachedTranslations);
return cachedTranslations; return cachedTranslations;

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="./style/output.css?v=4.0.14" /> <link rel="stylesheet" href="./style/output.css?v=4.0.15" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800;900&family=Noto+Sans+KR:wght@400;700;900&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800;900&family=Noto+Sans+KR:wght@400;700;900&display=swap" rel="stylesheet" />
<title>회원가입 - UNIARE</title> <title>회원가입 - UNIARE</title>
</head> </head>
@@ -50,8 +50,8 @@
</main> </main>
<script src="/script/page-i18n.js"></script> <script src="/script/page-i18n.js"></script>
<script src="/script/api.js?v=4.0.14"></script> <script src="/script/api.js?v=4.0.15"></script>
<script src="/script/auth-form.js?v=4.0.14"></script> <script src="/script/auth-form.js?v=4.0.15"></script>
<script> <script>
document.getElementById('signup-form').addEventListener('submit', async (e) => { document.getElementById('signup-form').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();

File diff suppressed because one or more lines are too long

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="./style/output.css?v=4.0.14" /> <link rel="stylesheet" href="./style/output.css?v=4.0.15" />
<title>이메일 인증 - UNIARE</title> <title>이메일 인증 - UNIARE</title>
</head> </head>
<body class="app-page"> <body class="app-page">
@@ -16,18 +16,24 @@
</nav> </nav>
<main class="mx-auto w-full max-w-md px-6 pt-12 pb-16 sm:pt-16 sm:pb-20"> <main class="mx-auto w-full max-w-md px-6 pt-12 pb-16 sm:pt-16 sm:pb-20">
<section class="rounded-2xl border border-slate-200 bg-white p-7 text-center shadow-lg shadow-slate-900/5"> <section class="rounded-2xl border border-slate-200 bg-white p-7 text-center shadow-lg shadow-slate-900/5">
<h1 class="text-2xl font-black tracking-tight text-slate-900">이메일 인증</h1> <h1 class="text-2xl font-black tracking-tight text-slate-900" data-i18n="verifyEmailTitle">이메일 인증</h1>
<p id="status-msg" class="mt-3 text-sm leading-relaxed text-slate-500">가입한 이메일로 인증 링크를 보냈습니다.</p> <p id="status-msg" class="mt-3 text-sm leading-relaxed text-slate-500" data-i18n="verifyEmailSentDesc">가입한 이메일로 인증 링크를 보냈습니다.</p>
<form id="resend-form" class="mt-6"> <form id="resend-form" class="mt-6">
<label class="mb-1.5 block text-left text-xs font-bold uppercase tracking-wider text-slate-400" for="email">이메일</label> <label class="mb-1.5 block text-left text-xs font-bold uppercase tracking-wider text-slate-400" for="email" data-i18n="emailLabel">이메일</label>
<input type="email" id="email" required autocomplete="email" class="box-border block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/15" /> <input type="email" id="email" required autocomplete="email" class="box-border block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/15" />
<button type="submit" id="resend-submit" class="mt-4 block w-full cursor-pointer rounded-xl border-0 bg-slate-100 px-4 py-3 text-sm font-bold text-slate-700 transition hover:bg-slate-200 disabled:cursor-wait disabled:opacity-70">인증 메일 다시 보내기</button> <button type="submit" id="resend-submit" class="mt-4 block w-full cursor-pointer rounded-xl border-0 bg-slate-100 px-4 py-3 text-sm font-bold text-slate-700 transition hover:bg-slate-200 disabled:cursor-wait disabled:opacity-70" data-i18n="resendVerificationButton">인증 메일 다시 보내기</button>
<p class="mt-3 text-left text-xs leading-relaxed text-slate-500" data-i18n="emailMonthlyLimitNotice">인증·비밀번호 재설정 메일은 최초 발송을 포함해 계정당 월 3회까지 발송됩니다.</p>
<p class="mt-2 text-left text-xs leading-relaxed text-slate-500">
<span data-i18n="emailLimitAdminNotice">월 3회를 초과한 경우 관리자에게 직접 메일을 보내면 확인 후 승인해 드립니다.</span>
<a href="mailto:zenn@sori.studio" class="font-bold text-blue-600 underline">zenn@sori.studio</a>
</p>
</form> </form>
<a href="/login.html" class="mt-5 inline-block text-sm font-bold text-blue-600 hover:underline">로그인으로 이동</a> <a id="login-link" href="/login.html" class="mt-5 inline-block text-sm font-bold text-blue-600 hover:underline" data-i18n="goToLogin">로그인으로 이동</a>
</section> </section>
</main> </main>
<script src="/script/api.js?v=4.0.14"></script> <script src="/script/page-i18n.js"></script>
<script src="/script/auth-form.js?v=4.0.14"></script> <script src="/script/api.js?v=4.0.15"></script>
<script src="/script/auth-form.js?v=4.0.15"></script>
<script> <script>
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const token = params.get('token'); const token = params.get('token');
@@ -36,6 +42,16 @@
const emailEl = document.getElementById('email'); const emailEl = document.getElementById('email');
emailEl.value = email; emailEl.value = email;
/**
* 현재 언어 번역 문자열
* @param {string} key
* @param {string} fallback
* @returns {string}
*/
function verifyText(key, fallback) {
return window.UaPageI18n?.t(key, fallback) || fallback;
}
/** /**
* 이메일 인증 토큰 처리 * 이메일 인증 토큰 처리
* @returns {Promise<void>} * @returns {Promise<void>}
@@ -43,14 +59,24 @@
async function verifyToken() { async function verifyToken() {
if (!token) return; if (!token) return;
document.getElementById('resend-form').classList.add('hidden'); document.getElementById('resend-form').classList.add('hidden');
statusEl.textContent = '이메일을 인증하는 중입니다...'; statusEl.textContent = verifyText('verifyingEmail', '이메일을 인증하는 중입니다...');
try { try {
const result = typeof UaDeckApi.verifyEmail === 'function' await (typeof UaDeckApi.verifyEmail === 'function'
? await UaDeckApi.verifyEmail(token) ? await UaDeckApi.verifyEmail(token)
: await UaAuthForm.request('/auth/verify-email', { token }); : await UaAuthForm.request('/auth/verify-email', { token }));
statusEl.textContent = result.message; statusEl.textContent = verifyText(
'verificationCompleteLoginNotice',
'이메일 인증이 완료되었습니다. 로그인해 주세요.',
);
document.getElementById('login-link').href = '/login.html?verified=1';
} catch (err) { } catch (err) {
statusEl.textContent = err.message; statusEl.textContent =
err.code === 'VERIFICATION_TOKEN_INVALID'
? verifyText(
'verificationLinkInvalid',
'인증 링크가 유효하지 않거나 만료되었습니다.',
)
: err.message;
document.getElementById('resend-form').classList.remove('hidden'); document.getElementById('resend-form').classList.remove('hidden');
} }
} }
@@ -59,25 +85,42 @@
event.preventDefault(); event.preventDefault();
const submitButton = document.getElementById('resend-submit'); const submitButton = document.getElementById('resend-submit');
if (submitButton.disabled) return; if (submitButton.disabled) return;
UaAuthForm.setLoading(submitButton, true, '전송 중...'); UaAuthForm.setLoading(
submitButton,
true,
verifyText('sendingLabel', '전송 중...'),
);
let sent = false; let sent = false;
try { try {
const result = typeof UaDeckApi.resendVerification === 'function' await (typeof UaDeckApi.resendVerification === 'function'
? await UaDeckApi.resendVerification(emailEl.value) ? await UaDeckApi.resendVerification(emailEl.value)
: await UaAuthForm.request('/auth/resend-verification', { email: emailEl.value }); : await UaAuthForm.request('/auth/resend-verification', { email: emailEl.value }));
statusEl.textContent = result.message; statusEl.textContent = verifyText(
'verificationResent',
'인증 메일을 다시 보냈습니다.',
);
sent = true; sent = true;
} catch (err) { } catch (err) {
statusEl.textContent = err.message; statusEl.textContent =
err.code === 'MONTHLY_EMAIL_LIMIT_EXCEEDED'
? verifyText(
'emailLimitExceeded',
'월간 이메일 발송 한도(3회)를 초과했습니다. 관리자에게 직접 문의해 주세요.',
)
: err.message;
} finally { } finally {
if (sent) { if (sent) {
UaAuthForm.startCooldown(submitButton, 60, '다시 보내기'); UaAuthForm.startCooldown(
submitButton,
60,
verifyText('resendVerificationShort', '다시 보내기'),
);
} else { } else {
UaAuthForm.setLoading(submitButton, false); UaAuthForm.setLoading(submitButton, false);
} }
} }
}); });
verifyToken(); UaPageI18n.applyPageI18n().then(verifyToken);
</script> </script>
</body> </body>
</html> </html>