회원 탈퇴와 덱 일괄 삭제 추가

This commit is contained in:
2026-06-25 17:11:31 +09:00
parent a5b7d720d6
commit 2c4f622408
27 changed files with 613 additions and 51 deletions

View File

@@ -1 +1 @@
v4.0.15 v4.0.16

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.15" /> <link rel="stylesheet" href="./style/output.css?v=4.0.16" />
<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>
@@ -47,6 +47,13 @@
</select> </select>
<span id="deck-count" class="deck-count-label"></span> <span id="deck-count" class="deck-count-label"></span>
</div> </div>
<div class="mt-3 flex flex-wrap items-center justify-between gap-3 border-t border-slate-100 pt-3">
<label class="flex cursor-pointer items-center gap-2 text-xs font-semibold text-slate-500">
<input type="checkbox" id="admin-deck-select-visible" class="size-4 rounded border-slate-300 accent-blue-600" />
현재 목록 전체 선택
</label>
<button type="button" id="admin-deck-bulk-delete" class="cursor-pointer rounded-xl border-0 bg-red-600 px-4 py-2 text-xs font-bold text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-slate-300" disabled>선택 덱 삭제</button>
</div>
</div> </div>
<div id="admin-deck-list"></div> <div id="admin-deck-list"></div>
<section class="mt-8 flex flex-col items-stretch justify-between gap-4 rounded-2xl border border-slate-200 bg-white px-5 py-4 shadow-sm sm:flex-row sm:items-center"> <section class="mt-8 flex flex-col items-stretch justify-between gap-4 rounded-2xl border border-slate-200 bg-white px-5 py-4 shadow-sm sm:flex-row sm:items-center">
@@ -119,6 +126,7 @@
<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="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>
<button type="button" id="open-user-delete-modal" class="mt-2 w-full cursor-pointer rounded-xl border border-red-200 bg-red-50 px-4 py-2.5 text-[0.8125rem] font-bold text-red-600 hover:bg-red-100">회원 탈퇴 처리</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>
<button type="button" id="modal-save" class="flex-1 cursor-pointer rounded-xl border-0 bg-slate-800 px-4 py-2.5 text-[0.8125rem] font-bold text-white hover:bg-slate-700">저장</button> <button type="button" id="modal-save" class="flex-1 cursor-pointer rounded-xl border-0 bg-slate-800 px-4 py-2.5 text-[0.8125rem] font-bold text-white hover:bg-slate-700">저장</button>
@@ -153,7 +161,22 @@
</div> </div>
</div> </div>
<script src="/script/api.js?v=4.0.15"></script> <div id="user-delete-modal" class="fixed inset-0 z-[110] hidden items-center justify-center bg-slate-900/50 p-4">
<div class="w-full max-w-[26rem] rounded-[1.25rem] bg-white p-6 shadow-2xl">
<h2 class="m-0 text-lg font-extrabold text-slate-900">회원 탈퇴 처리</h2>
<p id="user-delete-modal-sub" class="mt-2 text-sm leading-relaxed text-slate-500"></p>
<p class="mt-3 text-xs leading-relaxed text-slate-500">계정과 개인 정보는 삭제되며 저장한 덱은 익명으로 유지됩니다. 복구할 수 없습니다.</p>
<label class="mt-4 block text-[0.6875rem] font-bold uppercase text-slate-400" for="user-delete-confirmation">확인을 위해 DELETE 입력</label>
<input type="text" id="user-delete-confirmation" class="mt-1.5 w-full box-border rounded-xl border border-slate-200 px-3 py-2.5 text-sm" autocomplete="off" placeholder="DELETE" />
<p id="user-delete-error" class="mt-2 hidden text-[0.8125rem] text-red-500"></p>
<div class="mt-5 flex gap-2">
<button type="button" id="user-delete-no" class="flex-1 cursor-pointer rounded-xl border-0 bg-slate-100 px-4 py-2.5 text-sm font-bold text-slate-600 hover:bg-slate-200">NO</button>
<button type="button" id="user-delete-yes" class="flex-1 cursor-pointer rounded-xl border-0 bg-red-600 px-4 py-2.5 text-sm font-bold text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-slate-300" disabled>YES</button>
</div>
</div>
</div>
<script src="/script/api.js?v=4.0.16"></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>
@@ -165,6 +188,7 @@
const editModal = document.getElementById('edit-modal'); const editModal = document.getElementById('edit-modal');
const passwordModal = document.getElementById('password-modal'); const passwordModal = document.getElementById('password-modal');
const userDeleteModal = document.getElementById('user-delete-modal');
/** /**
* 관리자 모달 표시 상태 변경 * 관리자 모달 표시 상태 변경
@@ -180,6 +204,7 @@
let pendingAvatarImage = null; let pendingAvatarImage = null;
let pendingRemoveAvatar = false; let pendingRemoveAvatar = false;
let adminDecks = []; let adminDecks = [];
const selectedAdminDecks = new Set();
let isRoleEditorOpen = false; let isRoleEditorOpen = false;
function escapeHtml(str) { function escapeHtml(str) {
@@ -318,6 +343,7 @@
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('open-user-delete-modal').classList.toggle('hidden', isSelf);
document.getElementById('approve-email-verification').classList.toggle( document.getElementById('approve-email-verification').classList.toggle(
'hidden', 'hidden',
Boolean(u.emailVerifiedAt), Boolean(u.emailVerifiedAt),
@@ -400,6 +426,7 @@
const list = document.getElementById('admin-deck-list'); const list = document.getElementById('admin-deck-list');
if (!filtered.length) { if (!filtered.length) {
list.innerHTML = '<div class="deck-grid"><div class="deck-grid-empty">조건에 맞는 덱이 없습니다.</div></div>'; list.innerHTML = '<div class="deck-grid"><div class="deck-grid-empty">조건에 맞는 덱이 없습니다.</div></div>';
updateBulkDeleteControls(filtered);
return; return;
} }
@@ -413,7 +440,12 @@
: '<span class="deck-card-badge-private">비공개</span>'; : '<span class="deck-card-badge-private">비공개</span>';
return ` return `
<article class="deck-card" data-code="${escapeHtml(d.shareCode)}"> <article class="deck-card" data-code="${escapeHtml(d.shareCode)}">
<div class="deck-card-preview">${renderDeckPreview(d)}</div> <div class="relative">
<label class="absolute left-2 top-2 z-10 flex cursor-pointer items-center rounded-lg bg-white/90 p-1.5 shadow-sm" title="덱 선택">
<input type="checkbox" class="admin-deck-select size-4 rounded border-slate-300 accent-blue-600" data-share-code="${escapeHtml(d.shareCode)}" ${selectedAdminDecks.has(d.shareCode) ? 'checked' : ''} />
</label>
<div class="deck-card-preview">${renderDeckPreview(d)}</div>
</div>
<div class="deck-card-body"> <div class="deck-card-body">
<div class="deck-card-visibility"> <div class="deck-card-visibility">
<span class="deck-card-series">${seriesLabel}</span> <span class="deck-card-series">${seriesLabel}</span>
@@ -436,11 +468,45 @@
}) })
.join('')}</div>`; .join('')}</div>`;
closeAdminDeckMenus(); closeAdminDeckMenus();
updateBulkDeleteControls(filtered);
}
/**
* 일괄 삭제 선택 상태 갱신
* @param {object[]} [visibleDecks]
*/
function updateBulkDeleteControls(visibleDecks = null) {
const visible = visibleDecks || adminDecks.filter((deck) => {
const query = document.getElementById('admin-deck-search').value.trim().toLowerCase();
const series = document.getElementById('admin-deck-series-filter').value;
if (series && deck.series !== series) return false;
if (!query) return true;
return [deck.name, deck.seriesName, deck.series, deck.username]
.filter(Boolean)
.join(' ')
.toLowerCase()
.includes(query);
});
const visibleCodes = visible.map((deck) => deck.shareCode);
const selectedVisibleCount = visibleCodes.filter((code) => selectedAdminDecks.has(code)).length;
const selectAll = document.getElementById('admin-deck-select-visible');
selectAll.checked = visibleCodes.length > 0 && selectedVisibleCount === visibleCodes.length;
selectAll.indeterminate = selectedVisibleCount > 0 && selectedVisibleCount < visibleCodes.length;
document.getElementById('admin-deck-bulk-delete').disabled = selectedAdminDecks.size === 0;
document.getElementById('admin-deck-bulk-delete').textContent =
selectedAdminDecks.size > 0
? `선택 덱 삭제 (${selectedAdminDecks.size})`
: '선택 덱 삭제';
} }
async function loadAdminDecks() { async function loadAdminDecks() {
const { decks } = await UaDeckApi.adminGetDecks(); const { decks } = await UaDeckApi.adminGetDecks();
adminDecks = decks; adminDecks = decks;
[...selectedAdminDecks].forEach((code) => {
if (!adminDecks.some((deck) => deck.shareCode === code)) {
selectedAdminDecks.delete(code);
}
});
populateAdminSeriesFilter(); populateAdminSeriesFilter();
renderAdminDecks(); renderAdminDecks();
} }
@@ -524,6 +590,50 @@
errEl.classList.remove('hidden'); errEl.classList.remove('hidden');
} }
}); });
document.getElementById('open-user-delete-modal').addEventListener('click', () => {
if (!editingUser || editingUser.id === currentAdminId) return;
const confirmationInput = document.getElementById('user-delete-confirmation');
document.getElementById('user-delete-modal-sub').textContent =
`${editingUser.username} (${editingUser.email})`;
confirmationInput.value = '';
document.getElementById('user-delete-error').classList.add('hidden');
document.getElementById('user-delete-yes').disabled = true;
setModalOpen(userDeleteModal, true);
confirmationInput.focus();
});
document.getElementById('user-delete-confirmation').addEventListener('input', (event) => {
document.getElementById('user-delete-yes').disabled = event.target.value !== 'DELETE';
});
document.getElementById('user-delete-no').addEventListener('click', () => {
setModalOpen(userDeleteModal, false);
});
document.getElementById('user-delete-yes').addEventListener('click', async () => {
if (!editingUser || editingUser.id === currentAdminId) return;
const confirmation = document.getElementById('user-delete-confirmation').value;
const errorEl = document.getElementById('user-delete-error');
const yesButton = document.getElementById('user-delete-yes');
errorEl.classList.add('hidden');
yesButton.disabled = true;
try {
await UaDeckApi.adminDeleteUser(editingUser.id, confirmation);
setModalOpen(userDeleteModal, false);
setModalOpen(editModal, false);
editingUser = null;
await Promise.all([
loadUsers(document.getElementById('user-search').value),
loadAdminDecks(),
loadOrphanPreviews(),
]);
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove('hidden');
yesButton.disabled = confirmation !== 'DELETE';
}
});
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);
@@ -577,6 +687,17 @@
document.getElementById('admin-deck-series-filter').addEventListener('change', renderAdminDecks); document.getElementById('admin-deck-series-filter').addEventListener('change', renderAdminDecks);
document.getElementById('admin-deck-list').addEventListener('click', async (e) => { document.getElementById('admin-deck-list').addEventListener('click', async (e) => {
const selection = e.target.closest('.admin-deck-select');
if (selection) {
if (selection.checked) {
selectedAdminDecks.add(selection.dataset.shareCode);
} else {
selectedAdminDecks.delete(selection.dataset.shareCode);
}
updateBulkDeleteControls();
return;
}
const moreBtn = e.target.closest('.deck-card-more-btn'); const moreBtn = e.target.closest('.deck-card-more-btn');
if (moreBtn) { if (moreBtn) {
e.stopPropagation(); e.stopPropagation();
@@ -602,6 +723,28 @@
if (!e.target.closest('#admin-deck-list .deck-card-more-wrap')) closeAdminDeckMenus(); if (!e.target.closest('#admin-deck-list .deck-card-more-wrap')) closeAdminDeckMenus();
}); });
document.getElementById('admin-deck-select-visible').addEventListener('change', (event) => {
document.querySelectorAll('.admin-deck-select').forEach((checkbox) => {
checkbox.checked = event.target.checked;
if (event.target.checked) {
selectedAdminDecks.add(checkbox.dataset.shareCode);
} else {
selectedAdminDecks.delete(checkbox.dataset.shareCode);
}
});
updateBulkDeleteControls();
});
document.getElementById('admin-deck-bulk-delete').addEventListener('click', async () => {
const shareCodes = [...selectedAdminDecks];
if (!shareCodes.length) return;
if (!confirm(`선택한 덱 ${shareCodes.length}개와 연결된 썸네일을 함께 삭제하시겠습니까?`)) return;
const result = await UaDeckApi.adminDeleteDecks(shareCodes);
selectedAdminDecks.clear();
alert(`${result.deletedCount}개 덱을 삭제했습니다.`);
await Promise.all([loadAdminDecks(), loadOrphanPreviews()]);
});
document.getElementById('orphan-preview-refresh').addEventListener('click', loadOrphanPreviews); document.getElementById('orphan-preview-refresh').addEventListener('click', loadOrphanPreviews);
document.getElementById('orphan-preview-delete').addEventListener('click', async () => { document.getElementById('orphan-preview-delete').addEventListener('click', async () => {
if (!confirm('어떤 덱에서도 사용하지 않는 썸네일을 모두 삭제하시겠습니까?')) return; if (!confirm('어떤 덱에서도 사용하지 않는 썸네일을 모두 삭제하시겠습니까?')) return;
@@ -649,7 +792,7 @@
} }
}); });
[editModal, passwordModal].forEach((modal) => { [editModal, passwordModal, userDeleteModal].forEach((modal) => {
modal.addEventListener('click', (e) => { modal.addEventListener('click', (e) => {
if (e.target === modal) setModalOpen(modal, false); if (e.target === modal) setModalOpen(modal, false);
}); });

View File

@@ -1,6 +1,6 @@
{ {
"name": "uadeck-api", "name": "uadeck-api",
"version": "4.0.15", "version": "4.0.16",
"type": "module", "type": "module",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {

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.15' }); res.json({ ok: true, version: '4.0.16' });
}); });
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.15 listening on :${PORT}`); console.log(`UNIARE API v4.0.16 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

@@ -10,6 +10,7 @@ import {
deleteOrphanPreviewImages, deleteOrphanPreviewImages,
} from '../services/previewImage.js'; } from '../services/previewImage.js';
import { getAvatarUrl, saveAvatarImage, deleteAvatarImage } from '../services/avatarImage.js'; import { getAvatarUrl, saveAvatarImage, deleteAvatarImage } from '../services/avatarImage.js';
import { deleteUserAccount } from '../services/accountDeletion.js';
/** /**
* 회원 목록에 저장 덱 수(공개/전체) 부여 * 회원 목록에 저장 덱 수(공개/전체) 부여
@@ -272,6 +273,33 @@ export function createAdminRouter(db) {
res.json({ ok: true, message: '비밀번호가 초기화되었습니다.' }); res.json({ ok: true, message: '비밀번호가 초기화되었습니다.' });
}); });
router.delete('/users/:id', (req, res) => {
const targetId = Number(req.params.id);
if (req.user.id === targetId) {
return res.status(400).json({
error: '현재 로그인한 관리자 계정은 직접 탈퇴시킬 수 없습니다.',
});
}
if (String(req.body?.confirmation || '') !== 'DELETE') {
return res.status(400).json({
error: '탈퇴 확인 입력란에 DELETE를 정확히 입력해 주세요.',
code: 'ACCOUNT_DELETE_CONFIRMATION_REQUIRED',
});
}
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(targetId);
if (!user) {
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
}
const result = deleteUserAccount(db, targetId);
res.json({
ok: true,
...result,
message: '회원 탈퇴 처리가 완료되었습니다.',
});
});
router.post('/decks/preset', async (req, res, next) => { router.post('/decks/preset', async (req, res, next) => {
try { try {
const { series, seriesName, name, cards, isPublic } = req.body || {}; const { series, seriesName, name, cards, isPublic } = req.body || {};
@@ -359,6 +387,43 @@ export function createAdminRouter(db) {
res.json({ ok: true, ...deleteOrphanPreviewImages(referencedCodes) }); res.json({ ok: true, ...deleteOrphanPreviewImages(referencedCodes) });
}); });
router.post('/decks/bulk-delete', (req, res) => {
const shareCodes = Array.isArray(req.body?.shareCodes)
? [...new Set(req.body.shareCodes)]
.filter((code) => typeof code === 'string' && code.length >= 1 && code.length <= 64)
.slice(0, 100)
: [];
if (!shareCodes.length) {
return res.status(400).json({ error: '삭제할 덱을 선택하세요.' });
}
const placeholders = shareCodes.map(() => '?').join(',');
const existing = db.prepare(`
SELECT share_code AS shareCode
FROM decks
WHERE share_code IN (${placeholders})
`).all(...shareCodes);
const deleteDecks = db.transaction((codes) => {
db.prepare(`
DELETE FROM decks
WHERE share_code IN (${codes.map(() => '?').join(',')})
`).run(...codes);
});
const existingCodes = existing.map((row) => row.shareCode);
if (existingCodes.length) deleteDecks(existingCodes);
let previewDeletedCount = 0;
existingCodes.forEach((shareCode) => {
if (deletePreviewImage(shareCode)) previewDeletedCount += 1;
});
res.json({
ok: true,
deletedCount: existingCodes.length,
previewDeletedCount,
});
});
router.delete('/decks/:shareCode', (req, res) => { router.delete('/decks/:shareCode', (req, res) => {
const row = db.prepare('SELECT id, share_code FROM decks WHERE share_code = ?').get(req.params.shareCode); const row = db.prepare('SELECT id, share_code FROM decks WHERE share_code = ?').get(req.params.shareCode);
if (!row) { if (!row) {

View File

@@ -6,6 +6,7 @@ import { signToken, requireAuth } from '../middleware/auth.js';
import { nowIso } from '../utils.js'; import { nowIso } from '../utils.js';
import { getAvatarFilePath, getAvatarUrl, saveAvatarImage, deleteAvatarImage } from '../services/avatarImage.js'; import { getAvatarFilePath, getAvatarUrl, saveAvatarImage, deleteAvatarImage } from '../services/avatarImage.js';
import { sendPasswordResetEmail, sendVerificationEmail } from '../services/emailService.js'; import { sendPasswordResetEmail, sendVerificationEmail } from '../services/emailService.js';
import { deleteUserAccount } from '../services/accountDeletion.js';
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const USERNAME_PATTERN = /^[\p{L}\p{N}_-]+$/u; const USERNAME_PATTERN = /^[\p{L}\p{N}_-]+$/u;
@@ -589,5 +590,32 @@ export function createAuthRouter(db) {
res.json({ ok: true, message: '비밀번호가 변경되었습니다.' }); res.json({ ok: true, message: '비밀번호가 변경되었습니다.' });
}); });
router.delete('/me', requireAuth, (req, res) => {
const confirmation = String(req.body?.confirmation || '');
if (confirmation !== 'DELETE') {
return res.status(400).json({
error: '탈퇴 확인 입력란에 DELETE를 정확히 입력해 주세요.',
code: 'ACCOUNT_DELETE_CONFIRMATION_REQUIRED',
});
}
const user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(req.user.id);
if (!user) {
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
}
if (user.role === 'admin') {
return res.status(400).json({
error: '관리자 계정은 다른 관리자가 회원 관리 화면에서 탈퇴 처리해야 합니다.',
});
}
const result = deleteUserAccount(db, user.id);
res.json({
ok: true,
...result,
message: '회원 탈퇴가 완료되었습니다.',
});
});
return router; return router;
} }

View File

@@ -0,0 +1,33 @@
import { deleteAvatarImage } from './avatarImage.js';
/**
* 사용자 계정과 개인 데이터를 영구 삭제하고 저장 덱은 익명으로 보존한다.
* @param {import('better-sqlite3').Database} db
* @param {number} userId
* @returns {{deleted: boolean, retainedDeckCount: number, avatarDeleted: boolean}}
*/
export function deleteUserAccount(db, userId) {
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(userId);
if (!user) {
return {
deleted: false,
retainedDeckCount: 0,
avatarDeleted: false,
};
}
const retainedDeckCount = db.prepare(`
SELECT COUNT(*) AS count
FROM decks
WHERE user_id = ?
`).get(userId).count;
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
const avatarDeleted = deleteAvatarImage(userId);
return {
deleted: true,
retainedDeckCount,
avatarDeleted,
};
}

View File

@@ -56,10 +56,16 @@ export function saveAvatarImage(userId, dataUrl) {
/** /**
* 아바타 이미지 삭제 * 아바타 이미지 삭제
* @param {number|string} userId * @param {number|string} userId
* @returns {boolean}
*/ */
export function deleteAvatarImage(userId) { export function deleteAvatarImage(userId) {
const filePath = path.join(getAvatarsDir(), `${userId}.png`); const filePath = path.join(getAvatarsDir(), `${userId}.png`);
if (fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) return false;
try {
fs.unlinkSync(filePath); fs.unlinkSync(filePath);
return true;
} catch (err) {
console.warn(`[avatar] 삭제 실패: ${filePath}`, err);
return false;
} }
} }

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.15&token=${encodeURIComponent(token)}`; const url = `${getAppBaseUrl()}/verify-email.html?v=4.0.16&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.15&token=${encodeURIComponent(token)}`; const url = `${getAppBaseUrl()}/reset-password.html?v=4.0.16&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

@@ -8,10 +8,12 @@ import express from 'express';
import { initDb } from '../src/db.js'; import { initDb } from '../src/db.js';
import { createAdminRouter } from '../src/routes/admin.js'; import { createAdminRouter } from '../src/routes/admin.js';
import { createAuthRouter } from '../src/routes/auth.js'; import { createAuthRouter } from '../src/routes/auth.js';
import { deleteUserAccount } from '../src/services/accountDeletion.js';
test('삭제한 계정의 사용자명을 새 계정이 사용해도 기존 정보가 연결되지 않는다', () => { test('삭제한 계정의 사용자명을 새 계정이 사용해도 기존 정보가 연결되지 않는다', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uniare-account-delete-')); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uniare-account-delete-'));
const dbPath = path.join(tempDir, 'test.db'); const dbPath = path.join(tempDir, 'test.db');
process.env.AVATARS_PATH = path.join(tempDir, 'avatars');
const db = initDb(dbPath); const db = initDb(dbPath);
try { try {
@@ -34,6 +36,23 @@ test('삭제한 계정의 사용자명을 새 계정이 사용해도 기존 정
INSERT INTO deck_favorites (user_id, share_code) INSERT INTO deck_favorites (user_id, share_code)
VALUES (?, 'OLDDECK1') VALUES (?, 'OLDDECK1')
`).run(oldUserId); `).run(oldUserId);
const referenceUserId = Number(
db.prepare(`
INSERT INTO users (
email, username, password_hash, display_name, email_verified_at
)
VALUES (?, ?, ?, ?, datetime('now'))
`).run(
'reference@example.com',
'reference',
'reference-password-hash',
'reference',
).lastInsertRowid,
);
db.prepare(`
INSERT INTO deck_favorites (user_id, share_code)
VALUES (?, 'OLDDECK1')
`).run(referenceUserId);
db.prepare(` db.prepare(`
INSERT INTO auth_tokens (user_id, type, token_hash, expires_at) INSERT INTO auth_tokens (user_id, type, token_hash, expires_at)
VALUES (?, 'verify_email', 'old-token', datetime('now', '+1 day')) VALUES (?, 'verify_email', 'old-token', datetime('now', '+1 day'))
@@ -43,7 +62,7 @@ test('삭제한 계정의 사용자명을 새 계정이 사용해도 기존 정
VALUES (?, 'verify_email', '2026-06', 'sent') VALUES (?, 'verify_email', '2026-06', 'sent')
`).run(oldUserId); `).run(oldUserId);
db.prepare('DELETE FROM users WHERE id = ?').run(oldUserId); const deletionResult = deleteUserAccount(db, oldUserId);
const newUserId = Number( const newUserId = Number(
db.prepare(` db.prepare(`
@@ -58,12 +77,22 @@ test('삭제한 계정의 사용자명을 새 계정이 사용해도 기존 정
SELECT user_id AS userId FROM decks WHERE share_code = 'OLDDECK1' SELECT user_id AS userId FROM decks WHERE share_code = 'OLDDECK1'
`).get(); `).get();
assert.equal(deletionResult.deleted, true);
assert.equal(deletionResult.retainedDeckCount, 1);
assert.ok(newUserId > oldUserId); assert.ok(newUserId > oldUserId);
assert.equal(oldDeck.userId, null); assert.equal(oldDeck.userId, null);
assert.equal( assert.equal(
db.prepare('SELECT COUNT(*) AS count FROM deck_favorites WHERE user_id = ?').get(oldUserId).count, db.prepare('SELECT COUNT(*) AS count FROM deck_favorites WHERE user_id = ?').get(oldUserId).count,
0, 0,
); );
assert.equal(
db.prepare(`
SELECT COUNT(*) AS count
FROM deck_favorites
WHERE user_id = ? AND share_code = 'OLDDECK1'
`).get(referenceUserId).count,
1,
);
assert.equal( assert.equal(
db.prepare('SELECT COUNT(*) AS count FROM auth_tokens WHERE user_id = ?').get(oldUserId).count, db.prepare('SELECT COUNT(*) AS count FROM auth_tokens WHERE user_id = ?').get(oldUserId).count,
0, 0,
@@ -85,23 +114,25 @@ test('삭제한 계정의 사용자명을 새 계정이 사용해도 기존 정
} }
}); });
test('관리자는 미인증 회원의 이메일 인증을 수동 승인할 수 있다', async () => { test('이메일 승인·본인 탈퇴·관리자 탈퇴·덱 일괄 삭제 API가 동작한다', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uniare-admin-verify-')); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uniare-admin-verify-'));
const dbPath = path.join(tempDir, 'test.db'); const dbPath = path.join(tempDir, 'test.db');
const db = initDb(dbPath); const db = initDb(dbPath);
process.env.JWT_SECRET = 'test-secret-for-admin-email-verification'; process.env.JWT_SECRET = 'test-secret-for-admin-email-verification';
const adminPassword = 'admin-password'; const adminPassword = 'admin-password';
db.prepare(` const adminUserId = Number(
INSERT INTO users ( db.prepare(`
email, username, password_hash, display_name, role, email_verified_at INSERT INTO users (
) email, username, password_hash, display_name, role, email_verified_at
VALUES (?, ?, ?, ?, 'admin', datetime('now')) )
`).run( VALUES (?, ?, ?, ?, 'admin', datetime('now'))
'admin@example.com', `).run(
'admin', 'admin@example.com',
bcrypt.hashSync(adminPassword, 4), 'admin',
'admin', bcrypt.hashSync(adminPassword, 4),
'admin',
).lastInsertRowid,
); );
const targetUserId = Number( const targetUserId = Number(
db.prepare(` db.prepare(`
@@ -120,6 +151,33 @@ test('관리자는 미인증 회원의 이메일 인증을 수동 승인할 수
INSERT INTO auth_tokens (user_id, type, token_hash, expires_at) INSERT INTO auth_tokens (user_id, type, token_hash, expires_at)
VALUES (?, 'verify_email', 'pending-token', datetime('now', '+1 day')) VALUES (?, 'verify_email', 'pending-token', datetime('now', '+1 day'))
`).run(targetUserId); `).run(targetUserId);
db.prepare(`
INSERT INTO decks (
share_code, share_type, user_id, series, series_name, name, cards_json, is_public
)
VALUES ('PENDING1', 'user', ?, 'ua01', '테스트', '탈퇴 후 보존 덱', '[]', 1)
`).run(targetUserId);
const forcedUserId = Number(
db.prepare(`
INSERT INTO users (
email, username, password_hash, display_name, email_verified_at
)
VALUES (?, ?, ?, ?, datetime('now'))
`).run(
'forced@example.com',
'forced',
bcrypt.hashSync('forced-password', 4),
'forced',
).lastInsertRowid,
);
db.prepare(`
INSERT INTO decks (
share_code, share_type, user_id, series, series_name, name, cards_json, is_public
)
VALUES
('BULKDEL1', 'user', ?, 'ua01', '테스트', '일괄 삭제 1', '[]', 1),
('BULKDEL2', 'user', ?, 'ua01', '테스트', '일괄 삭제 2', '[]', 1)
`).run(adminUserId, adminUserId);
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
@@ -143,6 +201,16 @@ test('관리자는 미인증 회원의 이메일 인증을 수동 승인할 수
assert.equal(loginResponse.status, 200); assert.equal(loginResponse.status, 200);
const { token } = await loginResponse.json(); const { token } = await loginResponse.json();
const adminSelfDeleteResponse = await fetch(`${baseUrl}/admin/users/${adminUserId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ confirmation: 'DELETE' }),
});
assert.equal(adminSelfDeleteResponse.status, 400);
const approveResponse = await fetch( const approveResponse = await fetch(
`${baseUrl}/admin/users/${targetUserId}/verify-email`, `${baseUrl}/admin/users/${targetUserId}/verify-email`,
{ {
@@ -162,6 +230,78 @@ test('관리자는 미인증 회원의 이메일 인증을 수동 승인할 수
`).get(targetUserId); `).get(targetUserId);
assert.ok(target.emailVerifiedAt); assert.ok(target.emailVerifiedAt);
assert.ok(pendingToken.usedAt); assert.ok(pendingToken.usedAt);
const pendingLoginResponse = await fetch(`${baseUrl}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'pending@example.com',
password: 'pending-password',
}),
});
assert.equal(pendingLoginResponse.status, 200);
const { token: pendingUserToken } = await pendingLoginResponse.json();
const invalidSelfDeleteResponse = await fetch(`${baseUrl}/auth/me`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${pendingUserToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ confirmation: 'delete' }),
});
assert.equal(invalidSelfDeleteResponse.status, 400);
const selfDeleteResponse = await fetch(`${baseUrl}/auth/me`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${pendingUserToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ confirmation: 'DELETE' }),
});
assert.equal(selfDeleteResponse.status, 200);
assert.equal(
db.prepare('SELECT COUNT(*) AS count FROM users WHERE id = ?').get(targetUserId).count,
0,
);
assert.equal(
db.prepare(`
SELECT user_id AS userId FROM decks WHERE share_code = 'PENDING1'
`).get().userId,
null,
);
const adminDeleteResponse = await fetch(`${baseUrl}/admin/users/${forcedUserId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ confirmation: 'DELETE' }),
});
assert.equal(adminDeleteResponse.status, 200);
assert.equal(
db.prepare('SELECT COUNT(*) AS count FROM users WHERE id = ?').get(forcedUserId).count,
0,
);
const bulkDeleteResponse = await fetch(`${baseUrl}/admin/decks/bulk-delete`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ shareCodes: ['BULKDEL1', 'BULKDEL2'] }),
});
assert.equal(bulkDeleteResponse.status, 200);
const bulkDeleteResult = await bulkDeleteResponse.json();
assert.equal(bulkDeleteResult.deletedCount, 2);
assert.equal(
db.prepare(`
SELECT COUNT(*) AS count
FROM decks WHERE share_code IN ('BULKDEL1', 'BULKDEL2')
`).get().count,
0,
);
} finally { } finally {
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve())); server.close((error) => (error ? reject(error) : resolve()));

View File

@@ -1,5 +1,17 @@
# 변경사항 # 변경사항
## v4.0.16
### 회원 탈퇴
프로필 화면 가장 아래의 `회원 탈퇴`를 눌러 직접 탈퇴할 수 있습니다. 확인란에 `DELETE`를 입력한 뒤 `YES`를 누르면 계정과 개인 정보가 삭제됩니다.
기존에 저장한 덱은 다른 사용자가 계속 참고할 수 있도록 작성자만 `익명`으로 변경되어 유지됩니다.
### 관리자 회원·덱 정리
관리자는 회원 정보 화면에서 회원을 탈퇴 처리할 수 있습니다. 덱 관리에서는 여러 덱을 체크하거나 현재 목록을 전체 선택해 썸네일과 함께 일괄 삭제할 수 있습니다.
## v4.0.15 ## v4.0.15
### 인증 메일 재발송 동선 개선 ### 인증 메일 재발송 동선 개선

View File

@@ -75,7 +75,7 @@ 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.15"}`이 표시된다. 정상이면 `{"ok":true,"version":"4.0.16"}`이 표시된다.
이메일 기능 확인: 이메일 기능 확인:
@@ -87,6 +87,15 @@ API 확인: `curl -fsS http://localhost:44082/api/v1/health`
6. 같은 계정에서 인증·재설정 메일을 합산해 월 3회 초과 시 관리자 문의 안내가 표시되는지 확인한다. 6. 같은 계정에서 인증·재설정 메일을 합산해 월 3회 초과 시 관리자 문의 안내가 표시되는지 확인한다.
7. 관리자 화면에서 미인증 회원의 `이메일 인증 승인` 버튼으로 수동 승인 후 로그인되는지 확인한다. 7. 관리자 화면에서 미인증 회원의 `이메일 인증 승인` 버튼으로 수동 승인 후 로그인되는지 확인한다.
회원 탈퇴 확인:
1. 일반 회원 프로필 최하단의 작은 `회원 탈퇴` 문구를 눌러 모달이 열리는지 확인한다.
2. `DELETE`를 정확히 입력하기 전에는 `YES` 버튼이 비활성화되는지 확인한다.
3. 탈퇴 후 로그아웃되고 같은 계정으로 로그인할 수 없는지 확인한다.
4. 탈퇴 회원의 덱은 삭제되지 않고 작성자가 `익명`으로 표시되는지 확인한다.
5. 관리자 회원 수정 모달에서 다른 회원을 탈퇴 처리할 수 있고, 자신의 관리자 계정은 삭제할 수 없는지 확인한다.
6. 관리자 덱 관리에서 복수 선택·현재 목록 전체 선택·일괄 삭제와 썸네일 정리가 동작하는지 확인한다.
NAS 리버스 프록시는 `https://uniare.sori.studio`을 `http://<NAS_IP>:44082`로 연결한다. NAS 리버스 프록시는 `https://uniare.sori.studio`을 `http://<NAS_IP>:44082`로 연결한다.
## 3. 정적 파일 업데이트 ## 3. 정적 파일 업데이트

View File

@@ -1,5 +1,10 @@
# 의사결정 이력 # 의사결정 이력
## 2026-06-25 v4.0.16 — 탈퇴 계정의 덱은 익명 콘텐츠로 보존
💡 공개 덱을 참고하거나 즐겨찾기한 다른 사용자의 이용 흐름을 탈퇴로 끊지 않기 위함.
🔧 계정·개인 인증정보·아바타만 삭제하고 덱은 익명 유지, 관리자는 필요 시 복수 선택해 별도 삭제.
## 2026-06-25 v4.0.15 — 미인증 복구 동선은 로그인 화면에서 제공 ## 2026-06-25 v4.0.15 — 미인증 복구 동선은 로그인 화면에서 제공
💡 인증 메일을 잃어버린 사용자가 별도 화면을 찾아가지 않고 로그인 실패 지점에서 바로 복구하게 하기 위함. 💡 인증 메일을 잃어버린 사용자가 별도 화면을 찾아가지 않고 로그인 실패 지점에서 바로 복구하게 하기 위함.

View File

@@ -100,10 +100,10 @@ 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
Tailwind 입력·산출물 Tailwind 입력·산출물
style/input.css style/input.css
@@ -125,6 +125,9 @@ api/src/db.js (`email_send_logs`)
계정 삭제 후 사용자명 재사용 회귀 테스트 계정 삭제 후 사용자명 재사용 회귀 테스트
api/test/account-deletion.test.js api/test/account-deletion.test.js
회원 탈퇴·개인 데이터 삭제·덱 익명 보존
api/src/services/accountDeletion.js
JWT 인증·권한 JWT 인증·권한
api/src/middleware/auth.js api/src/middleware/auth.js
@@ -137,7 +140,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.15` - 현재 버전: `v4.0.16`
- 프론트: 정적 HTML, CSS, JavaScript - 프론트: 정적 HTML, CSS, JavaScript
- 백엔드: Express, SQLite(`better-sqlite3`) - 백엔드: Express, SQLite(`better-sqlite3`)
- 운영: Docker Compose 기반 Nginx + API - 운영: Docker Compose 기반 Nginx + API
@@ -23,7 +23,7 @@
| `/verify-email.html` | 가입 이메일 인증·인증 메일 재전송 | | `/verify-email.html` | 가입 이메일 인증·인증 메일 재전송 |
| `/forgot-password.html` | 비밀번호 재설정 메일 요청 | | `/forgot-password.html` | 비밀번호 재설정 메일 요청 |
| `/reset-password.html?token=<token>` | 1회용 토큰으로 비밀번호 재설정 | | `/reset-password.html?token=<token>` | 1회용 토큰으로 비밀번호 재설정 |
| `/profile.html` | 이메일·사용자명·아바타·비밀번호·덱 공개 기본값 관리 | | `/profile.html` | 이메일·사용자명·아바타·비밀번호·덱 공개 기본값·회원 탈퇴 관리 |
| `/deckHistory.html` | 내 덱 검색·필터·이름 변경·공개 설정·삭제·불러오기 | | `/deckHistory.html` | 내 덱 검색·필터·이름 변경·공개 설정·삭제·불러오기 |
| `/preset.html` | 공개 사용자 덱 검색·필터·즐겨찾기·불러오기 | | `/preset.html` | 공개 사용자 덱 검색·필터·즐겨찾기·불러오기 |
| `/admin.html` | 회원과 덱 관리 | | `/admin.html` | 회원과 덱 관리 |
@@ -104,14 +104,17 @@
- 로그인 유지 선택 시 브라우저를 닫아도 인증 정보를 유지하고, 미선택 시 현재 브라우저 세션에서만 유지한다. - 로그인 유지 선택 시 브라우저를 닫아도 인증 정보를 유지하고, 미선택 시 현재 브라우저 세션에서만 유지한다.
- 인증된 화면 진입 시 `/auth/me` 검증과 함께 `last_login_at`을 갱신한다. - 인증된 화면 진입 시 `/auth/me` 검증과 함께 `last_login_at`을 갱신한다.
- `user`는 자신의 프로필과 덱만 수정·삭제할 수 있다. - `user`는 자신의 프로필과 덱만 수정·삭제할 수 있다.
- `admin`은 회원 상태·역할·비밀번호와 전체 덱을 관리할 수 있다. - 일반 회원은 프로필 최하단에서 `DELETE` 확인 입력 후 직접 탈퇴할 수 있다.
- `admin`은 회원 상태·역할·비밀번호·탈퇴와 전체 덱을 관리할 수 있다.
- 관리자는 자신의 관리자 역할을 해제하거나 자신의 계정을 비활성화할 수 없다. - 관리자는 자신의 관리자 역할을 해제하거나 자신의 계정을 비활성화할 수 없다.
- 관리자는 현재 로그인한 자신의 계정을 관리자 화면에서 탈퇴시킬 수 없다.
- 비공개 덱은 소유자와 관리자만 조회할 수 있다. - 비공개 덱은 소유자와 관리자만 조회할 수 있다.
- 비회원은 덱을 서버에 저장하거나 공개 링크를 생성할 수 없다. - 비회원은 덱을 서버에 저장하거나 공개 링크를 생성할 수 없다.
- 로그인 사용자의 즐겨찾기는 계정 DB에 저장되어 브라우저와 기기가 달라도 유지된다. - 로그인 사용자의 즐겨찾기는 계정 DB에 저장되어 브라우저와 기기가 달라도 유지된다.
- 비로그인 즐겨찾기는 브라우저에 저장하고 로그인 시 유효한 덱만 계정으로 이전한다. - 비로그인 즐겨찾기는 브라우저에 저장하고 로그인 시 유효한 덱만 계정으로 이전한다.
- 관리자 화면은 회원 비활성화만 제공하며 계정 영구 삭제 UI는 제공하지 않는다. - 계정 탈퇴 시 사용자·인증 토큰·메일 기록·즐겨찾기·아바타를 삭제한다.
- 계정이 DB에서 삭제되면 인증 토큰·메일 기록·즐겨찾기는 연쇄 삭제되고, 기존 덱의 `user_id`는 `null`이 된다. 같은 사용자명으로 새로 가입해도 새 사용자 ID가 부여되어 기존 정보와 연결되지 않는다. - 탈퇴 회원의 저장 덱과 썸네일은 다른 사용자가 계속 참고할 수 있도록 유지하고 `user_id`만 `null`로 변경해 작성자를 익명 처리한다.
- 같은 사용자명으로 새로 가입해도 새 사용자 ID가 부여되어 기존 익명 덱이나 개인정보와 연결되지 않는다.
## 덱 저장 정책 ## 덱 저장 정책
@@ -209,6 +212,7 @@
| GET | `/api/v1/auth/me` | 회원 | 내 정보와 인증 세션 확인, `last_login_at` 갱신 | | GET | `/api/v1/auth/me` | 회원 | 내 정보와 인증 세션 확인, `last_login_at` 갱신 |
| PUT | `/api/v1/auth/me` | 회원 | 프로필·덱 저장 설정 변경 | | PUT | `/api/v1/auth/me` | 회원 | 프로필·덱 저장 설정 변경 |
| POST | `/api/v1/auth/me/password` | 회원 | 비밀번호 변경 | | POST | `/api/v1/auth/me/password` | 회원 | 비밀번호 변경 |
| DELETE | `/api/v1/auth/me` | 회원 | `DELETE` 확인 후 본인 계정 탈퇴 |
| GET | `/api/v1/auth/avatar/:userId` | 공개 | PNG 아바타 | | GET | `/api/v1/auth/avatar/:userId` | 공개 | PNG 아바타 |
### 덱 ### 덱
@@ -236,7 +240,9 @@
| 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/verify-email` | 미인증 회원 이메일 수동 승인 |
| POST | `/api/v1/admin/users/:id/reset-password` | 비밀번호 초기화 | | POST | `/api/v1/admin/users/:id/reset-password` | 비밀번호 초기화 |
| DELETE | `/api/v1/admin/users/:id` | `DELETE` 확인 후 회원 탈퇴 처리 |
| GET | `/api/v1/admin/decks` | 전체 덱 목록 | | GET | `/api/v1/admin/decks` | 전체 덱 목록 |
| POST | `/api/v1/admin/decks/bulk-delete` | 선택한 덱 최대 100개와 썸네일 일괄 삭제 |
| DELETE | `/api/v1/admin/decks/:shareCode` | 덱 삭제 | | DELETE | `/api/v1/admin/decks/:shareCode` | 덱 삭제 |
| GET | `/api/v1/admin/previews/orphans` | 미참조 덱 미리보기 목록 | | GET | `/api/v1/admin/previews/orphans` | 미참조 덱 미리보기 목록 |
| DELETE | `/api/v1/admin/previews/orphans` | 미참조 덱 미리보기 일괄 삭제 | | DELETE | `/api/v1/admin/previews/orphans` | 미참조 덱 미리보기 일괄 삭제 |

View File

@@ -1,5 +1,16 @@
# 업데이트 이력 # 업데이트 이력
## 2026-06-25 / v4.0.16 — 회원 탈퇴·관리자 덱 일괄 삭제
### 수행 작업
- 프로필 최하단에 10px 연회색 일반 텍스트 형태의 `회원 탈퇴` 진입점 추가
- 탈퇴 모달에서 `DELETE` 확인 입력과 `YES`·`NO` 버튼 제공
- 본인 탈퇴 및 관리자 회원 탈퇴 API 추가, 현재 로그인한 관리자 본인 삭제 차단
- 탈퇴 시 계정·인증 토큰·메일 기록·즐겨찾기·아바타 삭제
- 탈퇴 회원의 저장 덱과 썸네일은 유지하고 작성자 연결만 제거해 익명 처리
- 관리자 덱 관리에 복수 체크·현재 목록 전체 선택·최대 100개 일괄 삭제 추가
- 계정 탈퇴 후 동일 사용자명 재가입과 익명 덱 분리, 본인·관리자 탈퇴 API 회귀 테스트 보강
## 2026-06-25 / v4.0.15 — 미인증 로그인 복구·인증 화면 다국어 ## 2026-06-25 / 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.15" /> <link rel="stylesheet" href="./style/output.css?v=4.0.16" />
<title>비밀번호 찾기 - UNIARE</title> <title>비밀번호 찾기 - UNIARE</title>
</head> </head>
<body class="app-page"> <body class="app-page">
@@ -27,8 +27,8 @@
<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> <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/page-i18n.js"></script> <script src="/script/page-i18n.js"></script>
<script src="/script/api.js?v=4.0.15"></script> <script src="/script/api.js?v=4.0.16"></script>
<script src="/script/auth-form.js?v=4.0.15"></script> <script src="/script/auth-form.js?v=4.0.16"></script>
<script> <script>
/** /**
* 현재 언어 번역 문자열 * 현재 언어 번역 문자열

View File

@@ -141,6 +141,11 @@
"newPasswordLabel": "New password", "newPasswordLabel": "New password",
"confirmPasswordLabel": "Confirm new password", "confirmPasswordLabel": "Confirm new password",
"loadingLabel": "Loading...", "loadingLabel": "Loading...",
"accountWithdrawalLink": "Delete account",
"accountWithdrawalTitle": "Delete account",
"accountWithdrawalDesc": "Your account and personal information will be deleted. Saved decks will remain anonymous so other users can continue referencing them. This cannot be undone.",
"accountWithdrawalConfirmLabel": "Enter DELETE to confirm.",
"accountWithdrawalFailed": "Failed to delete the account.",
"deckSearchPlaceholder": "Search deck name or series...", "deckSearchPlaceholder": "Search deck name or series...",
"deckAllSeries": "All series", "deckAllSeries": "All series",

View File

@@ -141,6 +141,11 @@
"newPasswordLabel": "新しいパスワード", "newPasswordLabel": "新しいパスワード",
"confirmPasswordLabel": "新しいパスワード確認", "confirmPasswordLabel": "新しいパスワード確認",
"loadingLabel": "読み込み中...", "loadingLabel": "読み込み中...",
"accountWithdrawalLink": "退会",
"accountWithdrawalTitle": "退会",
"accountWithdrawalDesc": "アカウントと個人情報は削除されますが、保存したデッキは他のユーザーが参照できるよう匿名で維持されます。この操作は元に戻せません。",
"accountWithdrawalConfirmLabel": "確認のため DELETE と入力してください。",
"accountWithdrawalFailed": "退会処理に失敗しました。",
"deckSearchPlaceholder": "デッキ名またはシリーズを検索...", "deckSearchPlaceholder": "デッキ名またはシリーズを検索...",
"deckAllSeries": "すべてのシリーズ", "deckAllSeries": "すべてのシリーズ",

View File

@@ -141,6 +141,11 @@
"newPasswordLabel": "새 비밀번호", "newPasswordLabel": "새 비밀번호",
"confirmPasswordLabel": "새 비밀번호 확인", "confirmPasswordLabel": "새 비밀번호 확인",
"loadingLabel": "불러오는 중...", "loadingLabel": "불러오는 중...",
"accountWithdrawalLink": "회원 탈퇴",
"accountWithdrawalTitle": "회원 탈퇴",
"accountWithdrawalDesc": "계정과 개인 정보는 삭제되며 저장한 덱은 다른 사용자가 계속 참고할 수 있도록 익명으로 유지됩니다. 이 작업은 복구할 수 없습니다.",
"accountWithdrawalConfirmLabel": "확인을 위해 DELETE를 입력하세요.",
"accountWithdrawalFailed": "회원 탈퇴에 실패했습니다.",
"deckSearchPlaceholder": "덱 이름 또는 시리즈 검색...", "deckSearchPlaceholder": "덱 이름 또는 시리즈 검색...",
"deckAllSeries": "모든 시리즈", "deckAllSeries": "모든 시리즈",

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.15" /> <link rel="stylesheet" href="./style/output.css?v=4.0.16" />
<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>
@@ -59,11 +59,11 @@
</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.15</footer> <footer class="px-6 pb-5 text-center text-[0.625rem] font-medium tracking-wide text-slate-300" aria-hidden="true">v4.0.16</footer>
<script src="/script/page-i18n.js"></script> <script src="/script/page-i18n.js"></script>
<script src="/script/api.js?v=4.0.15"></script> <script src="/script/api.js?v=4.0.16"></script>
<script src="/script/auth-form.js?v=4.0.15"></script> <script src="/script/auth-form.js?v=4.0.16"></script>
<script> <script>
const verificationHelp = document.getElementById('verification-help'); const verificationHelp = document.getElementById('verification-help');
const resendButton = document.getElementById('resend-verification'); const resendButton = document.getElementById('resend-verification');

View File

@@ -4,7 +4,7 @@
<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" />
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-4516420168710424" crossorigin="anonymous"></script> <script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-4516420168710424" crossorigin="anonymous"></script>
<link rel="stylesheet" href="./style/output.css" /> <link rel="stylesheet" href="./style/output.css?v=4.0.16" />
<link rel="stylesheet" href="./style/app-shell.css" /> <link rel="stylesheet" href="./style/app-shell.css" />
<link rel="stylesheet" href="./style/auth-header.css" /> <link rel="stylesheet" href="./style/auth-header.css" />
<link rel="stylesheet" href="./style/profile.css" /> <link rel="stylesheet" href="./style/profile.css" />
@@ -116,6 +116,10 @@
<button type="button" id="profile-open-password" class="profile-btn profile-btn-outline" data-i18n="changePassword"> <button type="button" id="profile-open-password" class="profile-btn profile-btn-outline" data-i18n="changePassword">
비밀번호 변경 비밀번호 변경
</button> </button>
<button type="button" id="profile-open-delete" class="mt-8 block w-full cursor-pointer border-0 bg-transparent p-0 text-center text-[10px] font-normal text-slate-300 hover:text-slate-400" data-i18n="accountWithdrawalLink">
회원 탈퇴
</button>
</div> </div>
</div> </div>
</main> </main>
@@ -158,7 +162,25 @@
</div> </div>
</div> </div>
<script src="/script/api.js"></script> <div id="account-delete-modal" class="profile-modal">
<div class="profile-modal-box">
<h3 data-i18n="accountWithdrawalTitle">회원 탈퇴</h3>
<p class="mb-3 text-sm leading-relaxed text-slate-600" data-i18n="accountWithdrawalDesc">
계정과 개인 정보는 삭제되며 저장한 덱은 다른 사용자가 계속 참고할 수 있도록 익명으로 유지됩니다. 이 작업은 복구할 수 없습니다.
</p>
<div class="profile-field">
<label class="profile-label" for="account-delete-confirmation" data-i18n="accountWithdrawalConfirmLabel">확인을 위해 DELETE를 입력하세요.</label>
<input type="text" id="account-delete-confirmation" class="profile-input" autocomplete="off" placeholder="DELETE" />
</div>
<p id="account-delete-error" class="profile-error hidden"></p>
<div class="profile-modal-actions">
<button type="button" id="account-delete-no" class="profile-btn profile-btn-outline">NO</button>
<button type="button" id="account-delete-yes" class="profile-btn cursor-pointer border-0 bg-red-600 text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-slate-300" disabled>YES</button>
</div>
</div>
</div>
<script src="/script/api.js?v=4.0.16"></script>
<script src="/script/ads.js"></script> <script src="/script/ads.js"></script>
<script src="/script/page-i18n.js"></script> <script src="/script/page-i18n.js"></script>
<script src="/script/page-help.js"></script> <script src="/script/page-help.js"></script>
@@ -328,6 +350,10 @@
document.getElementById('profile-username').value = user.username || ''; document.getElementById('profile-username').value = user.username || '';
document.getElementById('profile-avatar').src = resolveAvatarSrc(user); document.getElementById('profile-avatar').src = resolveAvatarSrc(user);
document.getElementById('profile-avatar-remove').classList.toggle('hidden', !user.avatarUrl); document.getElementById('profile-avatar-remove').classList.toggle('hidden', !user.avatarUrl);
document.getElementById('profile-open-delete').classList.toggle(
'hidden',
user.role === 'admin',
);
} }
/** /**
@@ -462,6 +488,7 @@
}); });
const passwordModal = document.getElementById('password-modal'); const passwordModal = document.getElementById('password-modal');
const accountDeleteModal = document.getElementById('account-delete-modal');
document.getElementById('profile-open-password').addEventListener('click', () => { document.getElementById('profile-open-password').addEventListener('click', () => {
document.getElementById('password-current').value = ''; document.getElementById('password-current').value = '';
@@ -505,6 +532,44 @@
if (e.target === passwordModal) passwordModal.classList.remove('open'); if (e.target === passwordModal) passwordModal.classList.remove('open');
}); });
document.getElementById('profile-open-delete').addEventListener('click', () => {
const confirmationInput = document.getElementById('account-delete-confirmation');
confirmationInput.value = '';
document.getElementById('account-delete-error').classList.add('hidden');
document.getElementById('account-delete-yes').disabled = true;
accountDeleteModal.classList.add('open');
confirmationInput.focus();
});
document.getElementById('account-delete-confirmation').addEventListener('input', (event) => {
document.getElementById('account-delete-yes').disabled = event.target.value !== 'DELETE';
});
document.getElementById('account-delete-no').addEventListener('click', () => {
accountDeleteModal.classList.remove('open');
});
document.getElementById('account-delete-yes').addEventListener('click', async () => {
const confirmation = document.getElementById('account-delete-confirmation').value;
const errorEl = document.getElementById('account-delete-error');
const yesButton = document.getElementById('account-delete-yes');
errorEl.classList.add('hidden');
yesButton.disabled = true;
try {
await UaDeckApi.deleteMyAccount(confirmation);
UaDeckApi.logout();
location.href = '/';
} catch (err) {
errorEl.textContent = err.message || t('accountWithdrawalFailed', '회원 탈퇴에 실패했습니다.');
errorEl.classList.remove('hidden');
yesButton.disabled = confirmation !== 'DELETE';
}
});
accountDeleteModal.addEventListener('click', (event) => {
if (event.target === accountDeleteModal) accountDeleteModal.classList.remove('open');
});
init(); init();
</script> </script>
</body> </body>

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.15" /> <link rel="stylesheet" href="./style/output.css?v=4.0.16" />
<title>비밀번호 재설정 - UNIARE</title> <title>비밀번호 재설정 - UNIARE</title>
</head> </head>
<body class="app-page"> <body class="app-page">
@@ -30,8 +30,8 @@
<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> <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/page-i18n.js"></script> <script src="/script/page-i18n.js"></script>
<script src="/script/api.js?v=4.0.15"></script> <script src="/script/api.js?v=4.0.16"></script>
<script src="/script/auth-form.js?v=4.0.15"></script> <script src="/script/auth-form.js?v=4.0.16"></script>
<script> <script>
/** /**
* 현재 언어 번역 문자열 * 현재 언어 번역 문자열

View File

@@ -201,6 +201,13 @@ const UaDeckApi = (() => {
body: JSON.stringify({ currentPassword, newPassword }), body: JSON.stringify({ currentPassword, newPassword }),
}), }),
/** @param {string} confirmation */
deleteMyAccount: (confirmation) =>
request('/auth/me', {
method: 'DELETE',
body: JSON.stringify({ confirmation }),
}),
/** @param {string} shareCode */ /** @param {string} shareCode */
getDeck: (shareCode) => request(`/decks/${shareCode}`), getDeck: (shareCode) => request(`/decks/${shareCode}`),
@@ -257,6 +264,13 @@ const UaDeckApi = (() => {
body: JSON.stringify({ newPassword }), body: JSON.stringify({ newPassword }),
}), }),
/** @param {number} id @param {string} confirmation */
adminDeleteUser: (id, confirmation) =>
request(`/admin/users/${id}`, {
method: 'DELETE',
body: JSON.stringify({ confirmation }),
}),
adminGetDecks: () => request('/admin/decks'), adminGetDecks: () => request('/admin/decks'),
adminGetOrphanPreviews: () => request('/admin/previews/orphans'), adminGetOrphanPreviews: () => request('/admin/previews/orphans'),
@@ -268,6 +282,13 @@ const UaDeckApi = (() => {
adminCreatePreset: (body) => adminCreatePreset: (body) =>
request('/admin/decks/preset', { method: 'POST', body: JSON.stringify(body) }), request('/admin/decks/preset', { method: 'POST', body: JSON.stringify(body) }),
/** @param {string[]} shareCodes */
adminDeleteDecks: (shareCodes) =>
request('/admin/decks/bulk-delete', {
method: 'POST',
body: JSON.stringify({ shareCodes }),
}),
/** @param {string} shareCode */ /** @param {string} shareCode */
adminDeleteDeck: (shareCode) => adminDeleteDeck: (shareCode) =>
request(`/admin/decks/${shareCode}`, { method: 'DELETE' }), request(`/admin/decks/${shareCode}`, { method: 'DELETE' }),

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.15" /> <link rel="stylesheet" href="./style/output.css?v=4.0.16" />
<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.15"></script> <script src="/script/api.js?v=4.0.16"></script>
<script src="/script/auth-form.js?v=4.0.15"></script> <script src="/script/auth-form.js?v=4.0.16"></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.15" /> <link rel="stylesheet" href="./style/output.css?v=4.0.16" />
<title>이메일 인증 - UNIARE</title> <title>이메일 인증 - UNIARE</title>
</head> </head>
<body class="app-page"> <body class="app-page">
@@ -32,8 +32,8 @@
</section> </section>
</main> </main>
<script src="/script/page-i18n.js"></script> <script src="/script/page-i18n.js"></script>
<script src="/script/api.js?v=4.0.15"></script> <script src="/script/api.js?v=4.0.16"></script>
<script src="/script/auth-form.js?v=4.0.15"></script> <script src="/script/auth-form.js?v=4.0.16"></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');