회원 탈퇴와 덱 일괄 삭제 추가
This commit is contained in:
151
admin.html
151
admin.html
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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" />
|
||||
<title>관리자 - UNIARE</title>
|
||||
</head>
|
||||
@@ -47,6 +47,13 @@
|
||||
</select>
|
||||
<span id="deck-count" class="deck-count-label"></span>
|
||||
</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 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">
|
||||
@@ -119,6 +126,7 @@
|
||||
<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-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">
|
||||
<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>
|
||||
@@ -153,7 +161,22 @@
|
||||
</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/auth-header.js"></script>
|
||||
<script>
|
||||
@@ -165,6 +188,7 @@
|
||||
|
||||
const editModal = document.getElementById('edit-modal');
|
||||
const passwordModal = document.getElementById('password-modal');
|
||||
const userDeleteModal = document.getElementById('user-delete-modal');
|
||||
|
||||
/**
|
||||
* 관리자 모달 표시 상태 변경
|
||||
@@ -180,6 +204,7 @@
|
||||
let pendingAvatarImage = null;
|
||||
let pendingRemoveAvatar = false;
|
||||
let adminDecks = [];
|
||||
const selectedAdminDecks = new Set();
|
||||
let isRoleEditorOpen = false;
|
||||
|
||||
function escapeHtml(str) {
|
||||
@@ -318,6 +343,7 @@
|
||||
document.getElementById('toggle-role-editor').classList.toggle('hidden', isSelf);
|
||||
document.getElementById('edit-role-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(
|
||||
'hidden',
|
||||
Boolean(u.emailVerifiedAt),
|
||||
@@ -400,6 +426,7 @@
|
||||
const list = document.getElementById('admin-deck-list');
|
||||
if (!filtered.length) {
|
||||
list.innerHTML = '<div class="deck-grid"><div class="deck-grid-empty">조건에 맞는 덱이 없습니다.</div></div>';
|
||||
updateBulkDeleteControls(filtered);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -413,7 +440,12 @@
|
||||
: '<span class="deck-card-badge-private">비공개</span>';
|
||||
return `
|
||||
<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-visibility">
|
||||
<span class="deck-card-series">${seriesLabel}</span>
|
||||
@@ -436,11 +468,45 @@
|
||||
})
|
||||
.join('')}</div>`;
|
||||
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() {
|
||||
const { decks } = await UaDeckApi.adminGetDecks();
|
||||
adminDecks = decks;
|
||||
[...selectedAdminDecks].forEach((code) => {
|
||||
if (!adminDecks.some((deck) => deck.shareCode === code)) {
|
||||
selectedAdminDecks.delete(code);
|
||||
}
|
||||
});
|
||||
populateAdminSeriesFilter();
|
||||
renderAdminDecks();
|
||||
}
|
||||
@@ -524,6 +590,50 @@
|
||||
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', () => {
|
||||
if (!editingUser || editingUser.id === currentAdminId) return;
|
||||
setRoleEditorOpen(!isRoleEditorOpen);
|
||||
@@ -577,6 +687,17 @@
|
||||
document.getElementById('admin-deck-series-filter').addEventListener('change', renderAdminDecks);
|
||||
|
||||
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');
|
||||
if (moreBtn) {
|
||||
e.stopPropagation();
|
||||
@@ -602,6 +723,28 @@
|
||||
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-delete').addEventListener('click', async () => {
|
||||
if (!confirm('어떤 덱에서도 사용하지 않는 썸네일을 모두 삭제하시겠습니까?')) return;
|
||||
@@ -649,7 +792,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
[editModal, passwordModal].forEach((modal) => {
|
||||
[editModal, passwordModal, userDeleteModal].forEach((modal) => {
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) setModalOpen(modal, false);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "uadeck-api",
|
||||
"version": "4.0.15",
|
||||
"version": "4.0.16",
|
||||
"type": "module",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -29,7 +29,7 @@ app.use(cors({ origin: corsOrigins, credentials: true }));
|
||||
app.use(express.json({ limit: '20mb' }));
|
||||
|
||||
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));
|
||||
@@ -47,7 +47,7 @@ app.use((err, _req, res, _next) => {
|
||||
});
|
||||
|
||||
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(`DATAS: ${process.env.DATAS_PATH || '(default ../datas)'}`);
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
deleteOrphanPreviewImages,
|
||||
} from '../services/previewImage.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: '비밀번호가 초기화되었습니다.' });
|
||||
});
|
||||
|
||||
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) => {
|
||||
try {
|
||||
const { series, seriesName, name, cards, isPublic } = req.body || {};
|
||||
@@ -359,6 +387,43 @@ export function createAdminRouter(db) {
|
||||
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) => {
|
||||
const row = db.prepare('SELECT id, share_code FROM decks WHERE share_code = ?').get(req.params.shareCode);
|
||||
if (!row) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { signToken, requireAuth } from '../middleware/auth.js';
|
||||
import { nowIso } from '../utils.js';
|
||||
import { getAvatarFilePath, getAvatarUrl, saveAvatarImage, deleteAvatarImage } from '../services/avatarImage.js';
|
||||
import { sendPasswordResetEmail, sendVerificationEmail } from '../services/emailService.js';
|
||||
import { deleteUserAccount } from '../services/accountDeletion.js';
|
||||
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const USERNAME_PATTERN = /^[\p{L}\p{N}_-]+$/u;
|
||||
@@ -589,5 +590,32 @@ export function createAuthRouter(db) {
|
||||
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;
|
||||
}
|
||||
|
||||
33
api/src/services/accountDeletion.js
Normal file
33
api/src/services/accountDeletion.js
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -56,10 +56,16 @@ export function saveAvatarImage(userId, dataUrl) {
|
||||
/**
|
||||
* 아바타 이미지 삭제
|
||||
* @param {number|string} userId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function deleteAvatarImage(userId) {
|
||||
const filePath = path.join(getAvatarsDir(), `${userId}.png`);
|
||||
if (fs.existsSync(filePath)) {
|
||||
if (!fs.existsSync(filePath)) return false;
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn(`[avatar] 삭제 실패: ${filePath}`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ async function sendEmail(message) {
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
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 safeUrl = escapeHtml(url);
|
||||
return sendEmail({
|
||||
@@ -91,7 +91,7 @@ export function sendVerificationEmail({ email, username, token }) {
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
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 safeUrl = escapeHtml(url);
|
||||
return sendEmail({
|
||||
|
||||
@@ -8,10 +8,12 @@ import express from 'express';
|
||||
import { initDb } from '../src/db.js';
|
||||
import { createAdminRouter } from '../src/routes/admin.js';
|
||||
import { createAuthRouter } from '../src/routes/auth.js';
|
||||
import { deleteUserAccount } from '../src/services/accountDeletion.js';
|
||||
|
||||
test('삭제한 계정의 사용자명을 새 계정이 사용해도 기존 정보가 연결되지 않는다', () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uniare-account-delete-'));
|
||||
const dbPath = path.join(tempDir, 'test.db');
|
||||
process.env.AVATARS_PATH = path.join(tempDir, 'avatars');
|
||||
const db = initDb(dbPath);
|
||||
|
||||
try {
|
||||
@@ -34,6 +36,23 @@ test('삭제한 계정의 사용자명을 새 계정이 사용해도 기존 정
|
||||
INSERT INTO deck_favorites (user_id, share_code)
|
||||
VALUES (?, 'OLDDECK1')
|
||||
`).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(`
|
||||
INSERT INTO auth_tokens (user_id, type, token_hash, expires_at)
|
||||
VALUES (?, 'verify_email', 'old-token', datetime('now', '+1 day'))
|
||||
@@ -43,7 +62,7 @@ test('삭제한 계정의 사용자명을 새 계정이 사용해도 기존 정
|
||||
VALUES (?, 'verify_email', '2026-06', 'sent')
|
||||
`).run(oldUserId);
|
||||
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(oldUserId);
|
||||
const deletionResult = deleteUserAccount(db, oldUserId);
|
||||
|
||||
const newUserId = Number(
|
||||
db.prepare(`
|
||||
@@ -58,12 +77,22 @@ test('삭제한 계정의 사용자명을 새 계정이 사용해도 기존 정
|
||||
SELECT user_id AS userId FROM decks WHERE share_code = 'OLDDECK1'
|
||||
`).get();
|
||||
|
||||
assert.equal(deletionResult.deleted, true);
|
||||
assert.equal(deletionResult.retainedDeckCount, 1);
|
||||
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 deck_favorites
|
||||
WHERE user_id = ? AND share_code = 'OLDDECK1'
|
||||
`).get(referenceUserId).count,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
db.prepare('SELECT COUNT(*) AS count FROM auth_tokens WHERE user_id = ?').get(oldUserId).count,
|
||||
0,
|
||||
@@ -85,23 +114,25 @@ test('삭제한 계정의 사용자명을 새 계정이 사용해도 기존 정
|
||||
}
|
||||
});
|
||||
|
||||
test('관리자는 미인증 회원의 이메일 인증을 수동 승인할 수 있다', async () => {
|
||||
test('이메일 승인·본인 탈퇴·관리자 탈퇴·덱 일괄 삭제 API가 동작한다', 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 adminUserId = Number(
|
||||
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',
|
||||
).lastInsertRowid,
|
||||
);
|
||||
const targetUserId = Number(
|
||||
db.prepare(`
|
||||
@@ -120,6 +151,33 @@ test('관리자는 미인증 회원의 이메일 인증을 수동 승인할 수
|
||||
INSERT INTO auth_tokens (user_id, type, token_hash, expires_at)
|
||||
VALUES (?, 'verify_email', 'pending-token', datetime('now', '+1 day'))
|
||||
`).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();
|
||||
app.use(express.json());
|
||||
@@ -143,6 +201,16 @@ test('관리자는 미인증 회원의 이메일 인증을 수동 승인할 수
|
||||
assert.equal(loginResponse.status, 200);
|
||||
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(
|
||||
`${baseUrl}/admin/users/${targetUserId}/verify-email`,
|
||||
{
|
||||
@@ -162,6 +230,78 @@ test('관리자는 미인증 회원의 이메일 인증을 수동 승인할 수
|
||||
`).get(targetUserId);
|
||||
assert.ok(target.emailVerifiedAt);
|
||||
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 {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# 변경사항
|
||||
|
||||
## v4.0.16
|
||||
|
||||
### 회원 탈퇴
|
||||
|
||||
프로필 화면 가장 아래의 `회원 탈퇴`를 눌러 직접 탈퇴할 수 있습니다. 확인란에 `DELETE`를 입력한 뒤 `YES`를 누르면 계정과 개인 정보가 삭제됩니다.
|
||||
|
||||
기존에 저장한 덱은 다른 사용자가 계속 참고할 수 있도록 작성자만 `익명`으로 변경되어 유지됩니다.
|
||||
|
||||
### 관리자 회원·덱 정리
|
||||
|
||||
관리자는 회원 정보 화면에서 회원을 탈퇴 처리할 수 있습니다. 덱 관리에서는 여러 덱을 체크하거나 현재 목록을 전체 선택해 썸네일과 함께 일괄 삭제할 수 있습니다.
|
||||
|
||||
## v4.0.15
|
||||
|
||||
### 인증 메일 재발송 동선 개선
|
||||
|
||||
@@ -75,7 +75,7 @@ Docker 실행: `docker compose -f docker-compose.prod.yml up -d --build`
|
||||
|
||||
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회 초과 시 관리자 문의 안내가 표시되는지 확인한다.
|
||||
7. 관리자 화면에서 미인증 회원의 `이메일 인증 승인` 버튼으로 수동 승인 후 로그인되는지 확인한다.
|
||||
|
||||
회원 탈퇴 확인:
|
||||
|
||||
1. 일반 회원 프로필 최하단의 작은 `회원 탈퇴` 문구를 눌러 모달이 열리는지 확인한다.
|
||||
2. `DELETE`를 정확히 입력하기 전에는 `YES` 버튼이 비활성화되는지 확인한다.
|
||||
3. 탈퇴 후 로그아웃되고 같은 계정으로 로그인할 수 없는지 확인한다.
|
||||
4. 탈퇴 회원의 덱은 삭제되지 않고 작성자가 `익명`으로 표시되는지 확인한다.
|
||||
5. 관리자 회원 수정 모달에서 다른 회원을 탈퇴 처리할 수 있고, 자신의 관리자 계정은 삭제할 수 없는지 확인한다.
|
||||
6. 관리자 덱 관리에서 복수 선택·현재 목록 전체 선택·일괄 삭제와 썸네일 정리가 동작하는지 확인한다.
|
||||
|
||||
NAS 리버스 프록시는 `https://uniare.sori.studio`을 `http://<NAS_IP>:44082`로 연결한다.
|
||||
|
||||
## 3. 정적 파일 업데이트
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-06-25 v4.0.16 — 탈퇴 계정의 덱은 익명 콘텐츠로 보존
|
||||
|
||||
💡 공개 덱을 참고하거나 즐겨찾기한 다른 사용자의 이용 흐름을 탈퇴로 끊지 않기 위함.
|
||||
🔧 계정·개인 인증정보·아바타만 삭제하고 덱은 익명 유지, 관리자는 필요 시 복수 선택해 별도 삭제.
|
||||
|
||||
## 2026-06-25 v4.0.15 — 미인증 복구 동선은 로그인 화면에서 제공
|
||||
|
||||
💡 인증 메일을 잃어버린 사용자가 별도 화면을 찾아가지 않고 로그인 실패 지점에서 바로 복구하게 하기 위함.
|
||||
|
||||
@@ -100,10 +100,10 @@ script/ads.js
|
||||
|
||||
```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 입력·산출물
|
||||
style/input.css
|
||||
@@ -125,6 +125,9 @@ api/src/db.js (`email_send_logs`)
|
||||
계정 삭제 후 사용자명 재사용 회귀 테스트
|
||||
api/test/account-deletion.test.js
|
||||
|
||||
회원 탈퇴·개인 데이터 삭제·덱 익명 보존
|
||||
api/src/services/accountDeletion.js
|
||||
|
||||
JWT 인증·권한
|
||||
api/src/middleware/auth.js
|
||||
|
||||
@@ -137,7 +140,7 @@ api/src/services/emailService.js
|
||||
덱 저장·조회·공개 목록
|
||||
api/src/routes/decks.js
|
||||
|
||||
회원·이메일 인증 승인·덱 관리자 API
|
||||
회원·이메일 인증 승인·회원 탈퇴·덱 일괄 삭제 관리자 API
|
||||
api/src/routes/admin.js
|
||||
|
||||
카드 메타 합성
|
||||
|
||||
16
docs/spec.md
16
docs/spec.md
@@ -3,7 +3,7 @@
|
||||
## 프로젝트 개요
|
||||
|
||||
- 서비스명: `UNIARE - Union Arena Deck Builder`
|
||||
- 현재 버전: `v4.0.15`
|
||||
- 현재 버전: `v4.0.16`
|
||||
- 프론트: 정적 HTML, CSS, JavaScript
|
||||
- 백엔드: Express, SQLite(`better-sqlite3`)
|
||||
- 운영: Docker Compose 기반 Nginx + API
|
||||
@@ -23,7 +23,7 @@
|
||||
| `/verify-email.html` | 가입 이메일 인증·인증 메일 재전송 |
|
||||
| `/forgot-password.html` | 비밀번호 재설정 메일 요청 |
|
||||
| `/reset-password.html?token=<token>` | 1회용 토큰으로 비밀번호 재설정 |
|
||||
| `/profile.html` | 이메일·사용자명·아바타·비밀번호·덱 공개 기본값 관리 |
|
||||
| `/profile.html` | 이메일·사용자명·아바타·비밀번호·덱 공개 기본값·회원 탈퇴 관리 |
|
||||
| `/deckHistory.html` | 내 덱 검색·필터·이름 변경·공개 설정·삭제·불러오기 |
|
||||
| `/preset.html` | 공개 사용자 덱 검색·필터·즐겨찾기·불러오기 |
|
||||
| `/admin.html` | 회원과 덱 관리 |
|
||||
@@ -104,14 +104,17 @@
|
||||
- 로그인 유지 선택 시 브라우저를 닫아도 인증 정보를 유지하고, 미선택 시 현재 브라우저 세션에서만 유지한다.
|
||||
- 인증된 화면 진입 시 `/auth/me` 검증과 함께 `last_login_at`을 갱신한다.
|
||||
- `user`는 자신의 프로필과 덱만 수정·삭제할 수 있다.
|
||||
- `admin`은 회원 상태·역할·비밀번호와 전체 덱을 관리할 수 있다.
|
||||
- 일반 회원은 프로필 최하단에서 `DELETE` 확인 입력 후 직접 탈퇴할 수 있다.
|
||||
- `admin`은 회원 상태·역할·비밀번호·탈퇴와 전체 덱을 관리할 수 있다.
|
||||
- 관리자는 자신의 관리자 역할을 해제하거나 자신의 계정을 비활성화할 수 없다.
|
||||
- 관리자는 현재 로그인한 자신의 계정을 관리자 화면에서 탈퇴시킬 수 없다.
|
||||
- 비공개 덱은 소유자와 관리자만 조회할 수 있다.
|
||||
- 비회원은 덱을 서버에 저장하거나 공개 링크를 생성할 수 없다.
|
||||
- 로그인 사용자의 즐겨찾기는 계정 DB에 저장되어 브라우저와 기기가 달라도 유지된다.
|
||||
- 비로그인 즐겨찾기는 브라우저에 저장하고 로그인 시 유효한 덱만 계정으로 이전한다.
|
||||
- 관리자 화면은 회원 비활성화만 제공하며 계정 영구 삭제 UI는 제공하지 않는다.
|
||||
- 계정이 DB에서 삭제되면 인증 토큰·메일 기록·즐겨찾기는 연쇄 삭제되고, 기존 덱의 `user_id`는 `null`이 된다. 같은 사용자명으로 새로 가입해도 새 사용자 ID가 부여되어 기존 정보와 연결되지 않는다.
|
||||
- 계정 탈퇴 시 사용자·인증 토큰·메일 기록·즐겨찾기·아바타를 삭제한다.
|
||||
- 탈퇴 회원의 저장 덱과 썸네일은 다른 사용자가 계속 참고할 수 있도록 유지하고 `user_id`만 `null`로 변경해 작성자를 익명 처리한다.
|
||||
- 같은 사용자명으로 새로 가입해도 새 사용자 ID가 부여되어 기존 익명 덱이나 개인정보와 연결되지 않는다.
|
||||
|
||||
## 덱 저장 정책
|
||||
|
||||
@@ -209,6 +212,7 @@
|
||||
| GET | `/api/v1/auth/me` | 회원 | 내 정보와 인증 세션 확인, `last_login_at` 갱신 |
|
||||
| PUT | `/api/v1/auth/me` | 회원 | 프로필·덱 저장 설정 변경 |
|
||||
| POST | `/api/v1/auth/me/password` | 회원 | 비밀번호 변경 |
|
||||
| DELETE | `/api/v1/auth/me` | 회원 | `DELETE` 확인 후 본인 계정 탈퇴 |
|
||||
| GET | `/api/v1/auth/avatar/:userId` | 공개 | PNG 아바타 |
|
||||
|
||||
### 덱
|
||||
@@ -236,7 +240,9 @@
|
||||
| PUT | `/api/v1/admin/users/:id` | 회원 정보·역할·활성 상태 변경 |
|
||||
| POST | `/api/v1/admin/users/:id/verify-email` | 미인증 회원 이메일 수동 승인 |
|
||||
| POST | `/api/v1/admin/users/:id/reset-password` | 비밀번호 초기화 |
|
||||
| DELETE | `/api/v1/admin/users/:id` | `DELETE` 확인 후 회원 탈퇴 처리 |
|
||||
| GET | `/api/v1/admin/decks` | 전체 덱 목록 |
|
||||
| POST | `/api/v1/admin/decks/bulk-delete` | 선택한 덱 최대 100개와 썸네일 일괄 삭제 |
|
||||
| DELETE | `/api/v1/admin/decks/:shareCode` | 덱 삭제 |
|
||||
| GET | `/api/v1/admin/previews/orphans` | 미참조 덱 미리보기 목록 |
|
||||
| DELETE | `/api/v1/admin/previews/orphans` | 미참조 덱 미리보기 일괄 삭제 |
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## 2026-06-25 / v4.0.16 — 회원 탈퇴·관리자 덱 일괄 삭제
|
||||
|
||||
### 수행 작업
|
||||
- 프로필 최하단에 10px 연회색 일반 텍스트 형태의 `회원 탈퇴` 진입점 추가
|
||||
- 탈퇴 모달에서 `DELETE` 확인 입력과 `YES`·`NO` 버튼 제공
|
||||
- 본인 탈퇴 및 관리자 회원 탈퇴 API 추가, 현재 로그인한 관리자 본인 삭제 차단
|
||||
- 탈퇴 시 계정·인증 토큰·메일 기록·즐겨찾기·아바타 삭제
|
||||
- 탈퇴 회원의 저장 덱과 썸네일은 유지하고 작성자 연결만 제거해 익명 처리
|
||||
- 관리자 덱 관리에 복수 체크·현재 목록 전체 선택·최대 100개 일괄 삭제 추가
|
||||
- 계정 탈퇴 후 동일 사용자명 재가입과 익명 덱 분리, 본인·관리자 탈퇴 API 회귀 테스트 보강
|
||||
|
||||
## 2026-06-25 / v4.0.15 — 미인증 로그인 복구·인증 화면 다국어
|
||||
|
||||
### 수행 작업
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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>
|
||||
</head>
|
||||
<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>
|
||||
</main>
|
||||
<script src="/script/page-i18n.js"></script>
|
||||
<script src="/script/api.js?v=4.0.15"></script>
|
||||
<script src="/script/auth-form.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.16"></script>
|
||||
<script>
|
||||
/**
|
||||
* 현재 언어 번역 문자열
|
||||
|
||||
@@ -141,6 +141,11 @@
|
||||
"newPasswordLabel": "New password",
|
||||
"confirmPasswordLabel": "Confirm new password",
|
||||
"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...",
|
||||
"deckAllSeries": "All series",
|
||||
|
||||
@@ -141,6 +141,11 @@
|
||||
"newPasswordLabel": "新しいパスワード",
|
||||
"confirmPasswordLabel": "新しいパスワード確認",
|
||||
"loadingLabel": "読み込み中...",
|
||||
"accountWithdrawalLink": "退会",
|
||||
"accountWithdrawalTitle": "退会",
|
||||
"accountWithdrawalDesc": "アカウントと個人情報は削除されますが、保存したデッキは他のユーザーが参照できるよう匿名で維持されます。この操作は元に戻せません。",
|
||||
"accountWithdrawalConfirmLabel": "確認のため DELETE と入力してください。",
|
||||
"accountWithdrawalFailed": "退会処理に失敗しました。",
|
||||
|
||||
"deckSearchPlaceholder": "デッキ名またはシリーズを検索...",
|
||||
"deckAllSeries": "すべてのシリーズ",
|
||||
|
||||
@@ -141,6 +141,11 @@
|
||||
"newPasswordLabel": "새 비밀번호",
|
||||
"confirmPasswordLabel": "새 비밀번호 확인",
|
||||
"loadingLabel": "불러오는 중...",
|
||||
"accountWithdrawalLink": "회원 탈퇴",
|
||||
"accountWithdrawalTitle": "회원 탈퇴",
|
||||
"accountWithdrawalDesc": "계정과 개인 정보는 삭제되며 저장한 덱은 다른 사용자가 계속 참고할 수 있도록 익명으로 유지됩니다. 이 작업은 복구할 수 없습니다.",
|
||||
"accountWithdrawalConfirmLabel": "확인을 위해 DELETE를 입력하세요.",
|
||||
"accountWithdrawalFailed": "회원 탈퇴에 실패했습니다.",
|
||||
|
||||
"deckSearchPlaceholder": "덱 이름 또는 시리즈 검색...",
|
||||
"deckAllSeries": "모든 시리즈",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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" />
|
||||
<title>로그인 - UNIARE</title>
|
||||
</head>
|
||||
@@ -59,11 +59,11 @@
|
||||
</p>
|
||||
</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/api.js?v=4.0.15"></script>
|
||||
<script src="/script/auth-form.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.16"></script>
|
||||
<script>
|
||||
const verificationHelp = document.getElementById('verification-help');
|
||||
const resendButton = document.getElementById('resend-verification');
|
||||
|
||||
69
profile.html
69
profile.html
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<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>
|
||||
<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/auth-header.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>
|
||||
|
||||
<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>
|
||||
</main>
|
||||
@@ -158,7 +162,25 @@
|
||||
</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/page-i18n.js"></script>
|
||||
<script src="/script/page-help.js"></script>
|
||||
@@ -328,6 +350,10 @@
|
||||
document.getElementById('profile-username').value = user.username || '';
|
||||
document.getElementById('profile-avatar').src = resolveAvatarSrc(user);
|
||||
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 accountDeleteModal = document.getElementById('account-delete-modal');
|
||||
|
||||
document.getElementById('profile-open-password').addEventListener('click', () => {
|
||||
document.getElementById('password-current').value = '';
|
||||
@@ -505,6 +532,44 @@
|
||||
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();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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>
|
||||
</head>
|
||||
<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>
|
||||
</main>
|
||||
<script src="/script/page-i18n.js"></script>
|
||||
<script src="/script/api.js?v=4.0.15"></script>
|
||||
<script src="/script/auth-form.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.16"></script>
|
||||
<script>
|
||||
/**
|
||||
* 현재 언어 번역 문자열
|
||||
|
||||
@@ -201,6 +201,13 @@ const UaDeckApi = (() => {
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
}),
|
||||
|
||||
/** @param {string} confirmation */
|
||||
deleteMyAccount: (confirmation) =>
|
||||
request('/auth/me', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ confirmation }),
|
||||
}),
|
||||
|
||||
/** @param {string} shareCode */
|
||||
getDeck: (shareCode) => request(`/decks/${shareCode}`),
|
||||
|
||||
@@ -257,6 +264,13 @@ const UaDeckApi = (() => {
|
||||
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'),
|
||||
|
||||
adminGetOrphanPreviews: () => request('/admin/previews/orphans'),
|
||||
@@ -268,6 +282,13 @@ const UaDeckApi = (() => {
|
||||
adminCreatePreset: (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 */
|
||||
adminDeleteDeck: (shareCode) =>
|
||||
request(`/admin/decks/${shareCode}`, { method: 'DELETE' }),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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" />
|
||||
<title>회원가입 - UNIARE</title>
|
||||
</head>
|
||||
@@ -50,8 +50,8 @@
|
||||
</main>
|
||||
|
||||
<script src="/script/page-i18n.js"></script>
|
||||
<script src="/script/api.js?v=4.0.15"></script>
|
||||
<script src="/script/auth-form.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.16"></script>
|
||||
<script>
|
||||
document.getElementById('signup-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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>
|
||||
</head>
|
||||
<body class="app-page">
|
||||
@@ -32,8 +32,8 @@
|
||||
</section>
|
||||
</main>
|
||||
<script src="/script/page-i18n.js"></script>
|
||||
<script src="/script/api.js?v=4.0.15"></script>
|
||||
<script src="/script/auth-form.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.16"></script>
|
||||
<script>
|
||||
const params = new URLSearchParams(location.search);
|
||||
const token = params.get('token');
|
||||
|
||||
Reference in New Issue
Block a user