로그인 유지와 마지막 접속 기록 개선
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "uadeck-api",
|
||||
"version": "4.0.3",
|
||||
"version": "4.0.11",
|
||||
"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.3' });
|
||||
res.json({ ok: true, version: '4.0.11' });
|
||||
});
|
||||
|
||||
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.3 listening on :${PORT}`);
|
||||
console.log(`UNIARE API v4.0.11 listening on :${PORT}`);
|
||||
console.log(`DB: ${DB_PATH}`);
|
||||
console.log(`DATAS: ${process.env.DATAS_PATH || '(default ../datas)'}`);
|
||||
});
|
||||
|
||||
@@ -108,10 +108,16 @@ export function createAuthRouter(db) {
|
||||
});
|
||||
|
||||
router.get('/me', requireAuth, (req, res) => {
|
||||
db.prepare('UPDATE users SET last_login_at = ? WHERE id = ? AND is_active = 1').run(
|
||||
nowIso(),
|
||||
req.user.id,
|
||||
);
|
||||
|
||||
const user = db.prepare(`
|
||||
SELECT id, email, username, avatar_url AS avatarUrl, role,
|
||||
default_deck_public AS defaultDeckPublic,
|
||||
skip_deck_public_prompt AS skipDeckPublicPrompt,
|
||||
last_login_at AS lastLoginAt,
|
||||
created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM users WHERE id = ? AND is_active = 1
|
||||
`).get(req.user.id);
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# 변경사항
|
||||
|
||||
## v4.0.11
|
||||
|
||||
### 로그인 유지 선택
|
||||
|
||||
로그인 화면에서 `로그인 유지`를 선택할 수 있습니다. 선택하지 않으면 브라우저 세션이 끝날 때 로그인 정보가 제거됩니다.
|
||||
|
||||
### 마지막 접속 기록 개선
|
||||
|
||||
이미 로그인된 사용자도 사이트를 다시 이용하면 관리자 화면의 마지막 접속 시간이 갱신됩니다.
|
||||
|
||||
## v4.0.3
|
||||
|
||||
### 즐겨찾기 계정 동기화
|
||||
|
||||
@@ -67,7 +67,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.3"}`이 표시된다.
|
||||
정상이면 `{"ok":true,"version":"4.0.11"}`이 표시된다.
|
||||
|
||||
NAS 리버스 프록시는 `https://uniare.sori.studio`을 `http://<NAS_IP>:44082`로 연결한다.
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-06-24 v4.0.11 — 로그인 유지와 실제 접속 시각을 분리 관리
|
||||
|
||||
💡 브라우저 종료 후 로그인 유지 여부는 사용자가 선택하고, 접속 기록은 재로그인 없이도 실제 이용을 반영해야 하기 때문.
|
||||
🔧 체크 시 로컬 저장소, 미체크 시 세션 저장소를 사용하고 `/auth/me` 성공 시 `last_login_at`을 갱신.
|
||||
|
||||
## 2026-06-23 v4.0.10 — 위험한 관리자 변경은 명시적 동작으로 제한
|
||||
|
||||
💡 역할 변경은 빈도가 낮고 영향이 커서 상시 노출보다 의도적 진입이 안전하기 때문.
|
||||
|
||||
@@ -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
|
||||
@@ -110,7 +110,7 @@ api/src/db.js
|
||||
JWT 인증·권한
|
||||
api/src/middleware/auth.js
|
||||
|
||||
회원가입·로그인·프로필
|
||||
회원가입·로그인·프로필·인증 세션 접속 시각 갱신
|
||||
api/src/routes/auth.js
|
||||
|
||||
덱 저장·조회·공개 목록
|
||||
|
||||
12
docs/spec.md
12
docs/spec.md
@@ -3,7 +3,7 @@
|
||||
## 프로젝트 개요
|
||||
|
||||
- 서비스명: `UNIARE - Union Arena Deck Builder`
|
||||
- 현재 버전: `v4.0.4`
|
||||
- 현재 버전: `v4.0.11`
|
||||
- 프론트: 정적 HTML, CSS, JavaScript
|
||||
- 백엔드: Express, SQLite(`better-sqlite3`)
|
||||
- 운영: Docker Compose 기반 Nginx + API
|
||||
@@ -78,14 +78,16 @@
|
||||
| `lang` | 선택 언어 |
|
||||
| `selectedCards` | 편집 중인 덱 |
|
||||
| `selectedSeries` | 현재 시리즈 |
|
||||
| `uadeck_token` | JWT |
|
||||
| `uadeck_user` | 로그인 사용자 캐시 |
|
||||
| `uadeck_token` | JWT. 로그인 유지 선택 시 `localStorage`, 미선택 시 `sessionStorage` |
|
||||
| `uadeck_user` | 로그인 사용자 캐시. 토큰과 같은 저장소 사용 |
|
||||
| `uadeck.deckFavorites` | 비로그인 즐겨찾기 및 로그인 시 최초 이전 대상 |
|
||||
|
||||
## 인증 및 권한
|
||||
|
||||
- 비밀번호는 bcrypt 비용 12로 해시한다.
|
||||
- JWT 기본 만료는 7일이며 `JWT_EXPIRES`로 변경할 수 있다.
|
||||
- 로그인 유지 선택 시 브라우저를 닫아도 인증 정보를 유지하고, 미선택 시 현재 브라우저 세션에서만 유지한다.
|
||||
- 인증된 화면 진입 시 `/auth/me` 검증과 함께 `last_login_at`을 갱신한다.
|
||||
- `user`는 자신의 프로필과 덱만 수정·삭제할 수 있다.
|
||||
- `admin`은 회원 상태·역할·비밀번호와 전체 덱을 관리할 수 있다.
|
||||
- 관리자는 자신의 관리자 역할을 해제하거나 자신의 계정을 비활성화할 수 없다.
|
||||
@@ -120,7 +122,7 @@
|
||||
| `is_active` | 계정 활성 상태 |
|
||||
| `default_deck_public` | 덱 저장 기본 공개 여부 |
|
||||
| `skip_deck_public_prompt` | 저장 시 공개 확인 생략 여부 |
|
||||
| `last_login_at` | 마지막 로그인 시각 |
|
||||
| `last_login_at` | 마지막 로그인 또는 인증 세션 확인 시각 |
|
||||
| `created_at`, `updated_at` | 생성·수정 시각 |
|
||||
|
||||
### `decks`
|
||||
@@ -162,7 +164,7 @@
|
||||
|---|---|---|---|
|
||||
| POST | `/api/v1/auth/register` | 공개 | 회원가입 |
|
||||
| POST | `/api/v1/auth/login` | 공개 | 로그인과 JWT 발급, `last_login_at` 갱신 |
|
||||
| GET | `/api/v1/auth/me` | 회원 | 내 정보 |
|
||||
| GET | `/api/v1/auth/me` | 회원 | 내 정보와 인증 세션 확인, `last_login_at` 갱신 |
|
||||
| PUT | `/api/v1/auth/me` | 회원 | 프로필·덱 저장 설정 변경 |
|
||||
| POST | `/api/v1/auth/me/password` | 회원 | 비밀번호 변경 |
|
||||
| GET | `/api/v1/auth/avatar/:userId` | 공개 | PNG 아바타 |
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## 2026-06-24 / v4.0.11 — 로그인 유지·마지막 접속 갱신 개선
|
||||
|
||||
### 수행 작업
|
||||
- 로그인 화면에 `로그인 유지` 선택 항목 추가
|
||||
- 선택 시 인증 정보를 `localStorage`, 미선택 시 `sessionStorage`에 저장하도록 분리
|
||||
- 기존 로그인 유지 사용자의 로컬 인증 정보 호환 유지
|
||||
- 인증된 사용자의 `/auth/me` 세션 확인 성공 시 `last_login_at` 갱신
|
||||
- 로그인 유지 문구를 한국어·일본어·영어 번역에 반영
|
||||
|
||||
## 2026-06-23 / v4.0.10 — 관리자 권한 변경 안전장치·접속 시간 표시
|
||||
|
||||
### 수행 작업
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
"passwordMismatch": "Passwords do not match.",
|
||||
"signupButton": "Create account",
|
||||
"loginButton": "Log in",
|
||||
"rememberLogin": "Keep me logged in",
|
||||
"noAccount": "Don't have an account?",
|
||||
"hasAccount": "Already have an account?",
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
"passwordMismatch": "パスワードが一致しません。",
|
||||
"signupButton": "登録する",
|
||||
"loginButton": "ログイン",
|
||||
"rememberLogin": "ログイン状態を保持",
|
||||
"noAccount": "アカウントをお持ちでない方",
|
||||
"hasAccount": "すでにアカウントをお持ちの方",
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
"passwordMismatch": "비밀번호가 일치하지 않습니다.",
|
||||
"signupButton": "가입하기",
|
||||
"loginButton": "로그인",
|
||||
"rememberLogin": "로그인 유지",
|
||||
"noAccount": "계정이 없으신가요?",
|
||||
"hasAccount": "이미 계정이 있으신가요?",
|
||||
|
||||
|
||||
11
login.html
11
login.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.6" />
|
||||
<link rel="stylesheet" href="./style/output.css?v=4.0.11" />
|
||||
<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>
|
||||
@@ -31,6 +31,10 @@
|
||||
<label class="mb-1.5 block text-xs font-bold uppercase tracking-wider text-slate-400" for="password" data-i18n="passwordLabel">비밀번호</label>
|
||||
<input type="password" id="password" required class="box-border block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition placeholder:text-slate-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/15" placeholder="••••••••" autocomplete="current-password" />
|
||||
</div>
|
||||
<label class="mt-4 flex cursor-pointer items-center gap-2 text-sm text-slate-600" for="remember-login">
|
||||
<input type="checkbox" id="remember-login" class="size-4 rounded border-slate-300 text-blue-600 accent-blue-600 focus:ring-2 focus:ring-blue-500/20" />
|
||||
<span data-i18n="rememberLogin">로그인 유지</span>
|
||||
</label>
|
||||
<p id="error-msg" class="mt-4 hidden text-sm text-red-500"></p>
|
||||
<button type="submit" class="mt-6 block w-full cursor-pointer rounded-xl border-0 bg-blue-600 px-4 py-3.5 text-sm font-bold text-white transition hover:bg-blue-700 active:scale-[0.98]" data-i18n="loginButton">로그인</button>
|
||||
</form>
|
||||
@@ -45,7 +49,7 @@
|
||||
</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>
|
||||
<footer class="px-6 pb-5 text-center text-[0.625rem] font-medium tracking-wide text-slate-300" aria-hidden="true">v4.0.11</footer>
|
||||
|
||||
<script src="/script/page-i18n.js"></script>
|
||||
<script src="/script/api.js"></script>
|
||||
@@ -57,8 +61,9 @@
|
||||
try {
|
||||
const email = document.getElementById('email').value;
|
||||
const password = document.getElementById('password').value;
|
||||
const remember = document.getElementById('remember-login').checked;
|
||||
const { token, user } = await UaDeckApi.login(email, password);
|
||||
UaDeckApi.saveAuth(token, user);
|
||||
UaDeckApi.saveAuth(token, user, remember);
|
||||
const redirect = new URLSearchParams(location.search).get('redirect') || '/';
|
||||
location.href = redirect;
|
||||
} catch (err) {
|
||||
|
||||
@@ -66,7 +66,7 @@ const UaDeckApi = (() => {
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function getToken() {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
return localStorage.getItem(TOKEN_KEY) || sessionStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,7 +75,8 @@ const UaDeckApi = (() => {
|
||||
*/
|
||||
function getCachedUser() {
|
||||
try {
|
||||
const raw = localStorage.getItem(USER_KEY);
|
||||
const storage = localStorage.getItem(TOKEN_KEY) ? localStorage : sessionStorage;
|
||||
const raw = storage.getItem(USER_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -86,10 +87,18 @@ const UaDeckApi = (() => {
|
||||
* 인증 정보 저장
|
||||
* @param {string} token
|
||||
* @param {object} user
|
||||
* @param {boolean|null} [remember=null] 로그인 유지 여부. null이면 현재 저장 위치를 유지한다.
|
||||
*/
|
||||
function saveAuth(token, user) {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
function saveAuth(token, user, remember = null) {
|
||||
const shouldRemember =
|
||||
remember === null ? Boolean(localStorage.getItem(TOKEN_KEY)) || !sessionStorage.getItem(TOKEN_KEY) : remember;
|
||||
const storage = shouldRemember ? localStorage : sessionStorage;
|
||||
const otherStorage = shouldRemember ? sessionStorage : localStorage;
|
||||
|
||||
storage.setItem(TOKEN_KEY, token);
|
||||
storage.setItem(USER_KEY, JSON.stringify(user));
|
||||
otherStorage.removeItem(TOKEN_KEY);
|
||||
otherStorage.removeItem(USER_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,6 +107,8 @@ const UaDeckApi = (() => {
|
||||
function logout() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
sessionStorage.removeItem(USER_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user