이메일 인증 재발송과 관리자 승인 개선
This commit is contained in:
28
admin.html
28
admin.html
@@ -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);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)'}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
172
api/test/account-deletion.test.js
Normal file
172
api/test/account-deletion.test.js
Normal 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,5 +1,19 @@
|
|||||||
# 변경사항
|
# 변경사항
|
||||||
|
|
||||||
|
## v4.0.15
|
||||||
|
|
||||||
|
### 인증 메일 재발송 동선 개선
|
||||||
|
|
||||||
|
이메일 인증을 완료하지 않은 상태로 로그인하면 로그인 화면에서 인증 메일을 바로 다시 요청할 수 있습니다.
|
||||||
|
|
||||||
|
인증·비밀번호 재설정 메일은 최초 발송을 포함해 계정당 월 3회까지 제공되며, 한도 초과 시 관리자 문의 방법을 함께 안내합니다.
|
||||||
|
|
||||||
|
관리자는 문의한 미인증 회원을 확인한 뒤 관리자 화면에서 이메일 인증을 직접 승인할 수 있습니다.
|
||||||
|
|
||||||
|
### 인증·비밀번호 화면 다국어 개선
|
||||||
|
|
||||||
|
인증 완료 안내와 `비밀번호를 잊으셨나요?`, 비밀번호 찾기·재설정 화면을 한국어·일본어·영어로 제공합니다. 별도 언어 선택이 없으면 한국어로 표시됩니다.
|
||||||
|
|
||||||
## v4.0.14
|
## v4.0.14
|
||||||
|
|
||||||
### 계정별 이메일 발송 한도
|
### 계정별 이메일 발송 한도
|
||||||
|
|||||||
@@ -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`로 연결한다.
|
||||||
|
|
||||||
|
|||||||
@@ -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회로 제한
|
||||||
|
|
||||||
💡 무료 메일 한도를 소수 계정의 반복 요청이 소진하지 않도록 보호하기 위함.
|
💡 무료 메일 한도를 소수 계정의 반복 요청이 소진하지 않도록 보호하기 위함.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
카드 메타 합성
|
카드 메타 합성
|
||||||
|
|||||||
@@ -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` | 덱 삭제 |
|
||||||
|
|||||||
@@ -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 — 계정별 월간 메일 한도
|
||||||
|
|
||||||
### 수행 작업
|
### 수행 작업
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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": "保存したデッキ一覧です。公開にすると「公開デッキ」に表示されます。",
|
||||||
|
|||||||
@@ -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": "저장한 덱 목록입니다. 공개 전환 시「공개 덱」목록에 표시됩니다.",
|
||||||
|
|||||||
112
login.html
112
login.html
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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`, {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user