v4.0.10: 관리자 계정 변경 안전장치와 접속시간 지표 추가

역할 변경은 명시적으로 버튼을 눌렀을 때만 가능하게 제한하고, 마지막 접속시간과 비밀번호 분실 안내를 추가해 운영 관리성을 높인다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-23 18:24:45 +09:00
parent bc30356c8b
commit 5a56ddcede
11 changed files with 112 additions and 22 deletions

View File

@@ -1 +1 @@
v4.0.9
v4.0.10

View File

@@ -92,12 +92,20 @@
<input type="text" id="edit-username" class="w-full box-border rounded-xl border border-slate-200 px-3 py-2.5 text-sm disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400" autocomplete="off" />
</div>
<div class="mb-4">
<label class="mb-1.5 block text-[0.6875rem] font-bold uppercase text-slate-400" for="edit-role">역할</label>
<select id="edit-role" class="w-full box-border rounded-xl border border-slate-200 px-3 py-2.5 text-sm disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400">
<option value="user">일반 회원</option>
<option value="admin">관리자</option>
</select>
<p id="edit-role-hint" class="mt-1.5 hidden text-[0.6875rem] leading-snug text-amber-600">본인 계정의 관리자 권한은 변경할 수 없습니다.</p>
<label class="mb-1.5 block text-[0.6875rem] font-bold uppercase text-slate-400">역할</label>
<div class="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5">
<div class="flex items-center justify-between gap-2">
<span id="edit-role-summary" class="text-sm font-semibold text-slate-700"></span>
<button type="button" id="toggle-role-editor" class="cursor-pointer rounded-lg border-0 bg-slate-200 px-2.5 py-1 text-[0.6875rem] font-bold text-slate-600 hover:bg-slate-300">역할 변경하기</button>
</div>
<div id="edit-role-editor" class="mt-2 hidden">
<select id="edit-role" class="w-full box-border rounded-xl border border-slate-200 bg-white px-3 py-2.5 text-sm disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400">
<option value="user">일반 회원</option>
<option value="admin">관리자</option>
</select>
<p id="edit-role-hint" class="mt-1.5 hidden text-[0.6875rem] leading-snug text-amber-600">본인 계정의 관리자 권한은 변경할 수 없습니다.</p>
</div>
</div>
</div>
<div class="mb-4">
<label class="mb-1.5 block text-[0.6875rem] font-bold uppercase text-slate-400" for="edit-isActive">상태</label>
@@ -171,6 +179,7 @@
let pendingAvatarImage = null;
let pendingRemoveAvatar = false;
let adminDecks = [];
let isRoleEditorOpen = false;
function escapeHtml(str) {
return String(str ?? '')
@@ -191,6 +200,41 @@
return url;
}
/**
* 날짜를 상대 시간 텍스트로 변환
* @param {string|null|undefined} value
* @returns {string}
*/
function formatRelativeTime(value) {
if (!value) return '기록 없음';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '기록 없음';
const diffMs = Date.now() - date.getTime();
if (diffMs < 0) return '방금 전';
const minuteMs = 60 * 1000;
const hourMs = 60 * minuteMs;
const dayMs = 24 * hourMs;
const monthMs = 30 * dayMs;
if (diffMs < minuteMs) return '방금 전';
if (diffMs < hourMs) return `${Math.floor(diffMs / minuteMs)}분 전`;
if (diffMs < dayMs) return `${Math.floor(diffMs / hourMs)}시간 전`;
if (diffMs < monthMs) return `${Math.floor(diffMs / dayMs)}일 전`;
return `${Math.floor(diffMs / monthMs)}달 전`;
}
/**
* 역할 변경 입력창 표시 여부
* @param {boolean} isOpen
*/
function setRoleEditorOpen(isOpen) {
isRoleEditorOpen = isOpen;
document.getElementById('edit-role-editor').classList.toggle('hidden', !isOpen);
document.getElementById('toggle-role-editor').textContent = isOpen ? '역할 변경 닫기' : '역할 변경하기';
}
async function guardAdmin() {
if (!UaDeckApi.getToken()) {
location.href = '/login.html?redirect=/admin.html';
@@ -228,10 +272,13 @@
<div class="min-w-0 flex-1">
<h3 class="mb-0.5 text-sm font-bold leading-snug text-slate-900">${escapeHtml(u.username)}${isSelf ? ' <span class="rounded-md bg-blue-50 px-1.5 py-0.5 text-[0.625rem] font-bold text-blue-600">나</span>' : ''}</h3>
<div class="truncate text-xs text-slate-400">${escapeHtml(u.email)}</div>
<div class="mt-1.5 text-xs font-semibold text-slate-600" title="공개 덱 / 전체 저장 덱">덱 ${deckPublic}/${deckTotal} <span class="font-normal text-slate-400">(공개/전체)</span></div>
<div class="mt-1.5 flex flex-wrap gap-1">
<span class="rounded-md bg-blue-50 px-1.5 py-0.5 text-[0.625rem] font-bold text-blue-600">${escapeHtml(u.role)}</span>
<span class="rounded-md px-1.5 py-0.5 text-[0.625rem] font-bold ${u.isActive ? 'bg-emerald-50 text-emerald-600' : 'bg-slate-100 text-slate-500'}">${u.isActive ? '활성' : '비활성'}</span>
<div class="mt-1 text-[0.6875rem] text-slate-500">마지막 접속: ${escapeHtml(formatRelativeTime(u.lastLoginAt))}</div>
<div class="mt-1.5 flex flex-wrap justify-between gap-1">
<div class="flex gap-1">
<span class="rounded-md bg-blue-50 px-1.5 py-0.5 text-[0.625rem] font-bold text-blue-600">${escapeHtml(u.role)}</span>
<span class="rounded-md px-1.5 py-0.5 text-[0.625rem] font-bold ${u.isActive ? 'bg-emerald-50 text-emerald-600' : 'bg-slate-100 text-slate-500'}">${u.isActive ? '활성' : '비활성'}</span>
</div>
<div class="mt-1.5 text-xs font-semibold text-slate-600" title="공개 덱 / 전체 저장 덱">덱 ${deckPublic}/${deckTotal} <span class="font-normal text-slate-400">(공개/전체)</span></div>
</div>
</div>
<button type="button" data-edit='${JSON.stringify(u).replace(/'/g, '&#39;')}' class="shrink-0 cursor-pointer rounded-lg border-0 bg-slate-100 px-2.5 py-1.5 text-[0.6875rem] font-bold text-slate-600 hover:bg-slate-200">수정</button>
@@ -256,13 +303,17 @@
document.getElementById('edit-username').value = u.username;
document.getElementById('edit-role').value = u.role;
document.getElementById('edit-isActive').value = u.isActive ? '1' : '0';
document.getElementById('edit-role-summary').textContent = u.role === 'admin' ? '관리자' : '일반 회원';
document.getElementById('edit-modal-title').textContent = u.username;
document.getElementById('edit-modal-sub').textContent = u.email;
document.getElementById('edit-avatar-preview').src = resolveAvatarSrc(u);
document.getElementById('edit-avatar-remove').classList.toggle('hidden', !u.avatarUrl);
setRoleEditorOpen(false);
document.getElementById('edit-role').disabled = isSelf;
document.getElementById('edit-isActive').disabled = isSelf;
document.getElementById('toggle-role-editor').disabled = isSelf;
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('modal-error').classList.add('hidden');
@@ -449,6 +500,10 @@
document.getElementById('password-error').classList.add('hidden');
setModalOpen(passwordModal, true);
});
document.getElementById('toggle-role-editor').addEventListener('click', () => {
if (!editingUser || editingUser.id === currentAdminId) return;
setRoleEditorOpen(!isRoleEditorOpen);
});
document.getElementById('password-cancel').addEventListener('click', () => {
setModalOpen(passwordModal, false);
@@ -549,7 +604,9 @@
username: document.getElementById('edit-username').value,
};
if (!isSelf) {
body.role = document.getElementById('edit-role').value;
if (isRoleEditorOpen) {
body.role = document.getElementById('edit-role').value;
}
body.isActive = document.getElementById('edit-isActive').value === '1';
}
if (pendingAvatarImage) body.avatarImage = pendingAvatarImage;

View File

@@ -28,6 +28,7 @@ export function initDb(dbPath) {
avatar_url TEXT,
role TEXT NOT NULL DEFAULT 'user',
is_active INTEGER NOT NULL DEFAULT 1,
last_login_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
@@ -77,6 +78,9 @@ export function initDb(dbPath) {
if (!userCols.some((c) => c.name === 'skip_deck_public_prompt')) {
db.exec('ALTER TABLE users ADD COLUMN skip_deck_public_prompt INTEGER NOT NULL DEFAULT 0');
}
if (!userCols.some((c) => c.name === 'last_login_at')) {
db.exec('ALTER TABLE users ADD COLUMN last_login_at TEXT');
}
return db;
}

View File

@@ -69,7 +69,8 @@ export function createAdminRouter(db) {
`).get(like, like, like).c;
rows = db.prepare(`
SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl,
role, is_active AS isActive, created_at AS createdAt, updated_at AS updatedAt
role, is_active AS isActive, last_login_at AS lastLoginAt,
created_at AS createdAt, updated_at AS updatedAt
FROM users
WHERE email LIKE ? OR username LIKE ? OR display_name LIKE ?
ORDER BY created_at DESC LIMIT ? OFFSET ?
@@ -78,7 +79,8 @@ export function createAdminRouter(db) {
total = db.prepare('SELECT COUNT(*) AS c FROM users').get().c;
rows = db.prepare(`
SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl,
role, is_active AS isActive, created_at AS createdAt, updated_at AS updatedAt
role, is_active AS isActive, last_login_at AS lastLoginAt,
created_at AS createdAt, updated_at AS updatedAt
FROM users ORDER BY created_at DESC LIMIT ? OFFSET ?
`).all(Number(limit), offset);
}
@@ -100,7 +102,8 @@ export function createAdminRouter(db) {
router.get('/users/:id', (req, res) => {
const user = db.prepare(`
SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl,
role, is_active AS isActive, created_at AS createdAt, updated_at AS updatedAt
role, is_active AS isActive, last_login_at AS lastLoginAt,
created_at AS createdAt, updated_at AS updatedAt
FROM users WHERE id = ?
`).get(req.params.id);
@@ -188,7 +191,8 @@ export function createAdminRouter(db) {
const updated = db.prepare(`
SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl,
role, is_active AS isActive, created_at AS createdAt, updated_at AS updatedAt
role, is_active AS isActive, last_login_at AS lastLoginAt,
created_at AS createdAt, updated_at AS updatedAt
FROM users WHERE id = ?
`).get(targetId);
updated.avatarUrl = getAvatarUrl(updated.id) || updated.avatarUrl;

View File

@@ -97,8 +97,9 @@ export function createAuthRouter(db) {
return res.status(401).json({ error: '이메일 또는 비밀번호가 올바르지 않습니다.' });
}
db.prepare('UPDATE users SET last_login_at = ? WHERE id = ?').run(nowIso(), user.id);
const row = db.prepare(`
SELECT id, email, username, avatar_url, role, updated_at AS updatedAt
SELECT id, email, username, avatar_url, role, updated_at AS updatedAt, last_login_at AS lastLoginAt
FROM users WHERE id = ?
`).get(user.id);

View File

@@ -1,5 +1,15 @@
# 의사결정 이력
## 2026-06-23 v4.0.10 — 위험한 관리자 변경은 명시적 동작으로 제한
💡 역할 변경은 빈도가 낮고 영향이 커서 상시 노출보다 의도적 진입이 안전하기 때문.
🔧 관리자 회원 수정 모달의 역할 선택을 기본 숨김으로 두고 버튼 클릭 시에만 표시.
## 2026-06-23 v4.0.10 — 사용자 마지막 접속 시각을 운영 지표로 기록
💡 관리자 화면에서 실제 이용 활성도를 빠르게 판단할 기준이 필요하기 때문.
🔧 로그인 성공 시 `users.last_login_at`을 저장하고 회원 목록에 상대시간으로 표시.
## 2026-06-19 v4.0.9 — 관리자 회원 목록에 덱 이용률 표시
💡 사이트 이용 현황을 관리자가 한눈에 파악할 수 있어야 하기 때문.

View File

@@ -88,7 +88,7 @@ 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

View File

@@ -120,6 +120,7 @@
| `is_active` | 계정 활성 상태 |
| `default_deck_public` | 덱 저장 기본 공개 여부 |
| `skip_deck_public_prompt` | 저장 시 공개 확인 생략 여부 |
| `last_login_at` | 마지막 로그인 시각 |
| `created_at`, `updated_at` | 생성·수정 시각 |
### `decks`
@@ -160,7 +161,7 @@
| Method | Path | 권한 | 기능 |
|---|---|---|---|
| POST | `/api/v1/auth/register` | 공개 | 회원가입 |
| POST | `/api/v1/auth/login` | 공개 | 로그인과 JWT 발급 |
| POST | `/api/v1/auth/login` | 공개 | 로그인과 JWT 발급, `last_login_at` 갱신 |
| GET | `/api/v1/auth/me` | 회원 | 내 정보 |
| PUT | `/api/v1/auth/me` | 회원 | 프로필·덱 저장 설정 변경 |
| POST | `/api/v1/auth/me/password` | 회원 | 비밀번호 변경 |
@@ -186,8 +187,8 @@
| Method | Path | 기능 |
|---|---|---|
| GET | `/api/v1/admin/users` | 회원 목록·검색 (`deckPublic`, `deckTotal` 포함) |
| GET | `/api/v1/admin/users/:id` | 회원 상세 (`deckPublic`, `deckTotal`, `deckCount` 호환) |
| GET | `/api/v1/admin/users` | 회원 목록·검색 (`deckPublic`, `deckTotal`, `lastLoginAt` 포함) |
| GET | `/api/v1/admin/users/:id` | 회원 상세 (`deckPublic`, `deckTotal`, `deckCount`, `lastLoginAt` 포함) |
| PUT | `/api/v1/admin/users/:id` | 회원 정보·역할·활성 상태 변경 |
| POST | `/api/v1/admin/users/:id/reset-password` | 비밀번호 초기화 |
| GET | `/api/v1/admin/decks` | 전체 덱 목록 |

View File

@@ -1,5 +1,14 @@
# 업데이트 이력
## 2026-06-23 / v4.0.10 — 관리자 권한 변경 안전장치·접속 시간 표시
### 수행 작업
- `admin.html` 회원 수정 모달에서 역할 선택 UI를 기본 숨김으로 전환하고, `역할 변경하기` 버튼을 눌렀을 때만 노출
- 회원 카드에 마지막 접속 상대시간(`분/시간/일/달 전`) 표시 추가
- `users` 스키마에 `last_login_at` 컬럼 추가(마이그레이션 포함), 로그인 성공 시 마지막 접속 시각 기록
- `GET /api/v1/admin/users`, `GET /api/v1/admin/users/:id` 응답에 `lastLoginAt` 포함
- `login.html`에 비밀번호 분실 시 `zenn@sori.studio` 문의 안내 문구 추가
## 2026-06-19 / v4.0.9 — 관리자 회원 목록 2열·덱 이용 통계
### 수행 작업

View File

@@ -39,6 +39,10 @@
<span data-i18n="noAccount">계정이 없으신가요?</span>
<a href="/signup.html" class="font-bold text-blue-600 hover:underline" data-i18n="authSignup">회원가입</a>
</p>
<p class="mt-3 text-center text-xs leading-relaxed text-slate-400">
비밀번호를 잊으셨다면 가입한 이메일 주소를 적어
<a href="mailto:zenn@sori.studio" class="font-semibold text-slate-500 hover:underline">zenn@sori.studio</a>로 문의해 주세요.
</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.6</footer>

File diff suppressed because one or more lines are too long