인증 요청 안정화와 월간 메일 한도 적용
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "uadeck-api",
|
||||
"version": "4.0.12",
|
||||
"version": "4.0.14",
|
||||
"type": "module",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -78,6 +78,20 @@ export function initDb(dbPath) {
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user_type
|
||||
ON auth_tokens(user_id, type);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS email_send_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
month_key TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'reserved',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
sent_at TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_email_send_logs_user_month
|
||||
ON email_send_logs(user_id, month_key, status);
|
||||
`);
|
||||
|
||||
const deckCols = db.prepare('PRAGMA table_info(decks)').all();
|
||||
|
||||
@@ -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.12' });
|
||||
res.json({ ok: true, version: '4.0.14' });
|
||||
});
|
||||
|
||||
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.12 listening on :${PORT}`);
|
||||
console.log(`UNIARE API v4.0.14 listening on :${PORT}`);
|
||||
console.log(`DB: ${DB_PATH}`);
|
||||
console.log(`DATAS: ${process.env.DATAS_PATH || '(default ../datas)'}`);
|
||||
});
|
||||
|
||||
@@ -12,6 +12,77 @@ const USERNAME_PATTERN = /^[\p{L}\p{N}_-]+$/u;
|
||||
const VERIFICATION_TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const PASSWORD_RESET_TOKEN_TTL_MS = 30 * 60 * 1000;
|
||||
const EMAIL_RESEND_COOLDOWN_MS = 60 * 1000;
|
||||
const MONTHLY_EMAIL_LIMIT = 3;
|
||||
const EMAIL_LIMIT_MESSAGE =
|
||||
'이 계정의 월간 이메일 발송 한도(3회)를 초과했습니다. 관리자 zenn@sori.studio로 직접 문의해 주세요.';
|
||||
|
||||
/**
|
||||
* 한국 시간 기준 월 키
|
||||
* @returns {string}
|
||||
*/
|
||||
function getKoreanMonthKey() {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Seoul',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
}).formatToParts(new Date());
|
||||
const year = parts.find((part) => part.type === 'year')?.value;
|
||||
const month = parts.find((part) => part.type === 'month')?.value;
|
||||
return `${year}-${month}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 월간 이메일 발송 횟수 예약
|
||||
* @param {import('better-sqlite3').Database} db
|
||||
* @param {number} userId
|
||||
* @param {'verify_email'|'reset_password'} type
|
||||
* @returns {number|null} 예약 로그 ID. 한도 초과 시 null.
|
||||
*/
|
||||
function reserveEmailSend(db, userId, type) {
|
||||
const monthKey = getKoreanMonthKey();
|
||||
const transaction = db.transaction(() => {
|
||||
db.prepare(`
|
||||
DELETE FROM email_send_logs
|
||||
WHERE status = 'reserved' AND created_at < datetime('now', '-10 minutes')
|
||||
`).run();
|
||||
const row = db.prepare(`
|
||||
SELECT COUNT(*) AS count
|
||||
FROM email_send_logs
|
||||
WHERE user_id = ? AND month_key = ? AND status IN ('reserved', 'sent')
|
||||
`).get(userId, monthKey);
|
||||
if (row.count >= MONTHLY_EMAIL_LIMIT) return null;
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO email_send_logs (user_id, type, month_key, status)
|
||||
VALUES (?, ?, ?, 'reserved')
|
||||
`).run(userId, type, monthKey);
|
||||
return Number(result.lastInsertRowid);
|
||||
});
|
||||
return transaction();
|
||||
}
|
||||
|
||||
/**
|
||||
* 예약한 이메일 발송을 성공 처리
|
||||
* @param {import('better-sqlite3').Database} db
|
||||
* @param {number} logId
|
||||
*/
|
||||
function completeEmailSend(db, logId) {
|
||||
db.prepare(`
|
||||
UPDATE email_send_logs
|
||||
SET status = 'sent', sent_at = ?
|
||||
WHERE id = ? AND status = 'reserved'
|
||||
`).run(nowIso(), logId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패한 이메일 발송 예약 취소
|
||||
* @param {import('better-sqlite3').Database} db
|
||||
* @param {number|null} logId
|
||||
*/
|
||||
function cancelEmailSend(db, logId) {
|
||||
if (!logId) return;
|
||||
db.prepare("DELETE FROM email_send_logs WHERE id = ? AND status = 'reserved'").run(logId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증용 원문 토큰과 DB 저장용 해시 생성
|
||||
@@ -143,6 +214,7 @@ export function createAuthRouter(db) {
|
||||
|
||||
const hash = bcrypt.hashSync(rawPassword, 12);
|
||||
let userId;
|
||||
let emailLogId;
|
||||
try {
|
||||
const result = db.prepare(`
|
||||
INSERT INTO users (email, username, password_hash, display_name, email_verified_at)
|
||||
@@ -150,18 +222,25 @@ export function createAuthRouter(db) {
|
||||
`).run(normalizedEmail, trimmedUsername, hash, trimmedUsername);
|
||||
userId = Number(result.lastInsertRowid);
|
||||
|
||||
emailLogId = reserveEmailSend(db, userId, 'verify_email');
|
||||
if (!emailLogId) {
|
||||
db.prepare('DELETE FROM users WHERE id = ? AND email_verified_at IS NULL').run(userId);
|
||||
return res.status(429).json({ error: EMAIL_LIMIT_MESSAGE });
|
||||
}
|
||||
const token = issueAuthToken(db, userId, 'verify_email', VERIFICATION_TOKEN_TTL_MS);
|
||||
await sendVerificationEmail({
|
||||
email: normalizedEmail,
|
||||
username: trimmedUsername,
|
||||
token,
|
||||
});
|
||||
completeEmailSend(db, emailLogId);
|
||||
res.status(201).json({
|
||||
ok: true,
|
||||
email: normalizedEmail,
|
||||
message: '인증 메일을 보냈습니다. 이메일 인증 후 로그인해 주세요.',
|
||||
});
|
||||
} catch (err) {
|
||||
cancelEmailSend(db, emailLogId);
|
||||
if (userId) {
|
||||
db.prepare('DELETE FROM users WHERE id = ? AND email_verified_at IS NULL').run(userId);
|
||||
}
|
||||
@@ -200,11 +279,18 @@ export function createAuthRouter(db) {
|
||||
return res.status(429).json({ error: '인증 메일은 1분 후 다시 요청할 수 있습니다.' });
|
||||
}
|
||||
|
||||
let emailLogId;
|
||||
try {
|
||||
emailLogId = reserveEmailSend(db, user.id, 'verify_email');
|
||||
if (!emailLogId) {
|
||||
return res.status(429).json({ error: EMAIL_LIMIT_MESSAGE });
|
||||
}
|
||||
const token = issueAuthToken(db, user.id, 'verify_email', VERIFICATION_TOKEN_TTL_MS);
|
||||
await sendVerificationEmail({ email: user.email, username: user.username, token });
|
||||
completeEmailSend(db, emailLogId);
|
||||
res.json({ ok: true, message: '인증 메일을 다시 보냈습니다.' });
|
||||
} catch (err) {
|
||||
cancelEmailSend(db, emailLogId);
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
@@ -294,6 +380,7 @@ export function createAuthRouter(db) {
|
||||
return res.json(genericResponse);
|
||||
}
|
||||
|
||||
let emailLogId;
|
||||
try {
|
||||
const recent = db.prepare(`
|
||||
SELECT created_at
|
||||
@@ -305,10 +392,16 @@ export function createAuthRouter(db) {
|
||||
if (recent && Date.now() - new Date(`${recent.created_at}Z`).getTime() < EMAIL_RESEND_COOLDOWN_MS) {
|
||||
return res.json(genericResponse);
|
||||
}
|
||||
emailLogId = reserveEmailSend(db, user.id, 'reset_password');
|
||||
if (!emailLogId) {
|
||||
return res.status(429).json({ error: EMAIL_LIMIT_MESSAGE });
|
||||
}
|
||||
const token = issueAuthToken(db, user.id, 'reset_password', PASSWORD_RESET_TOKEN_TTL_MS);
|
||||
await sendPasswordResetEmail({ email: user.email, username: user.username, token });
|
||||
completeEmailSend(db, emailLogId);
|
||||
res.json(genericResponse);
|
||||
} catch (err) {
|
||||
cancelEmailSend(db, emailLogId);
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ async function sendEmail(message) {
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export function sendVerificationEmail({ email, username, token }) {
|
||||
const url = `${getAppBaseUrl()}/verify-email.html?token=${encodeURIComponent(token)}`;
|
||||
const url = `${getAppBaseUrl()}/verify-email.html?v=4.0.14&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?token=${encodeURIComponent(token)}`;
|
||||
const url = `${getAppBaseUrl()}/reset-password.html?v=4.0.14&token=${encodeURIComponent(token)}`;
|
||||
const safeName = escapeHtml(username);
|
||||
const safeUrl = escapeHtml(url);
|
||||
return sendEmail({
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# 변경사항
|
||||
|
||||
## v4.0.14
|
||||
|
||||
### 계정별 이메일 발송 한도
|
||||
|
||||
가입 인증과 비밀번호 재설정 메일은 계정당 한 달에 최대 3회까지 받을 수 있습니다.
|
||||
|
||||
한도를 초과한 경우 다음 달에 다시 요청하거나 `zenn@sori.studio`로 문의해 주세요.
|
||||
|
||||
## v4.0.13
|
||||
|
||||
### 이메일 인증 오류 수정
|
||||
|
||||
이메일의 인증 버튼을 눌렀을 때 일부 브라우저에서 구버전 파일이 남아 인증되지 않던 문제를 수정했습니다.
|
||||
|
||||
### 인증 화면 중복 요청 방지
|
||||
|
||||
회원가입, 로그인, 이메일 전송과 비밀번호 변경 처리 중에는 버튼에 진행 상태를 표시하고 반복 제출을 차단합니다. 메일 재전송은 60초 후 다시 요청할 수 있습니다.
|
||||
|
||||
## v4.0.12
|
||||
|
||||
### 이메일 인증 회원가입
|
||||
|
||||
@@ -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.12"}`이 표시된다.
|
||||
정상이면 `{"ok":true,"version":"4.0.14"}`이 표시된다.
|
||||
|
||||
이메일 기능 확인:
|
||||
|
||||
@@ -83,6 +83,7 @@ API 확인: `curl -fsS http://localhost:44082/api/v1/health`
|
||||
2. 인증 메일 링크를 누르기 전 로그인이 차단되는지 확인한다.
|
||||
3. 인증 후 로그인되는지 확인한다.
|
||||
4. 로그인 화면의 `비밀번호를 잊으셨나요?`에서 재설정 메일과 새 비밀번호 로그인을 확인한다.
|
||||
5. 같은 계정에서 인증·재설정 메일을 합산해 월 3회 초과 시 관리자 문의 안내가 표시되는지 확인한다.
|
||||
|
||||
NAS 리버스 프록시는 `https://uniare.sori.studio`을 `http://<NAS_IP>:44082`로 연결한다.
|
||||
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-06-25 v4.0.14 — 계정별 자동 메일은 월 3회로 제한
|
||||
|
||||
💡 무료 메일 한도를 소수 계정의 반복 요청이 소진하지 않도록 보호하기 위함.
|
||||
🔧 한국 시간 월 기준으로 인증·재설정 메일을 합산하고, 4회째부터 관리자 이메일 문의를 안내.
|
||||
|
||||
## 2026-06-25 v4.0.13 — 인증 요청은 화면 잠금과 서버 제약을 함께 적용
|
||||
|
||||
💡 메일 전송 지연 중 반복 클릭과 정적 API 클라이언트 캐시 불일치를 동시에 방지하기 위함.
|
||||
🔧 인증 JS에 버전 쿼리·직접 호출 폴백을 적용하고 제출 중 잠금, 스피너, 메일 60초 재요청 대기를 표시.
|
||||
|
||||
## 2026-06-25 v4.0.12 — 신규 가입부터 이메일 소유 확인 적용
|
||||
|
||||
💡 기존 회원을 잠그지 않으면서 허위 이메일 가입과 관리자 의존 비밀번호 초기화를 막기 위함.
|
||||
|
||||
@@ -50,6 +50,9 @@ nginx/uadeck.conf
|
||||
API 요청
|
||||
script/api.js
|
||||
|
||||
인증 폼 로딩·중복 제출 잠금·메일 재요청 대기
|
||||
script/auth-form.js
|
||||
|
||||
인증 헤더
|
||||
script/auth-header.js
|
||||
style/components.css (@layer components)
|
||||
@@ -116,6 +119,9 @@ api/src/index.js
|
||||
SQLite 스키마·관리자 시드
|
||||
api/src/db.js
|
||||
|
||||
계정별 월간 이메일 발송 기록
|
||||
api/src/db.js (`email_send_logs`)
|
||||
|
||||
JWT 인증·권한
|
||||
api/src/middleware/auth.js
|
||||
|
||||
|
||||
16
docs/spec.md
16
docs/spec.md
@@ -3,7 +3,7 @@
|
||||
## 프로젝트 개요
|
||||
|
||||
- 서비스명: `UNIARE - Union Arena Deck Builder`
|
||||
- 현재 버전: `v4.0.12`
|
||||
- 현재 버전: `v4.0.14`
|
||||
- 프론트: 정적 HTML, CSS, JavaScript
|
||||
- 백엔드: Express, SQLite(`better-sqlite3`)
|
||||
- 운영: Docker Compose 기반 Nginx + API
|
||||
@@ -92,6 +92,10 @@
|
||||
- 기존 회원은 이메일 인증 기능 배포 시 인증 완료 상태로 마이그레이션한다.
|
||||
- 인증 링크는 24시간, 비밀번호 재설정 링크는 30분 동안 한 번만 사용할 수 있다.
|
||||
- 인증·재설정 원문 토큰은 DB에 저장하지 않고 SHA-256 해시만 저장한다.
|
||||
- 인증 메일 링크와 인증 화면은 버전 쿼리로 최신 HTML·API 클라이언트를 로드하고, 캐시된 구 클라이언트에서도 직접 인증 API를 호출한다.
|
||||
- 인증 폼은 요청 중 제출 버튼을 잠그고 로딩 상태를 표시하며, 메일 재요청은 화면과 API에서 1분 간격으로 제한한다.
|
||||
- 가입 인증·인증 재전송·비밀번호 재설정 메일은 계정별 한국 시간 기준 월 3회까지만 발송한다.
|
||||
- 월간 한도 초과 시 자동 발송을 중단하고 관리자 이메일 `zenn@sori.studio` 문의를 안내한다.
|
||||
- 사용자명은 대소문자를 구분하지 않고 중복을 차단한다.
|
||||
- 인증된 이메일은 프로필에서 직접 변경할 수 없다.
|
||||
- JWT 기본 만료는 7일이며 `JWT_EXPIRES`로 변경할 수 있다.
|
||||
@@ -145,6 +149,16 @@
|
||||
| `expires_at`, `used_at` | 만료·사용 시각 |
|
||||
| `created_at` | 발급 시각 |
|
||||
|
||||
### `email_send_logs`
|
||||
|
||||
| 컬럼 | 설명 |
|
||||
|---|---|
|
||||
| `user_id` | 발송 대상 사용자 |
|
||||
| `type` | `verify_email` 또는 `reset_password` |
|
||||
| `month_key` | 한국 시간 기준 `YYYY-MM` 월 |
|
||||
| `status` | 동시 요청 방지용 `reserved`, 발송 완료 `sent` |
|
||||
| `created_at`, `sent_at` | 예약·발송 시각 |
|
||||
|
||||
### `decks`
|
||||
|
||||
| 컬럼 | 설명 |
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## 2026-06-25 / v4.0.14 — 계정별 월간 메일 한도
|
||||
|
||||
### 수행 작업
|
||||
- 가입 인증·인증 재전송·비밀번호 재설정 메일을 계정별 월 3회로 통합 제한
|
||||
- 한국 시간 기준 월별 발송 기록을 `email_send_logs`에 저장
|
||||
- 동시 요청은 발송 전 횟수를 예약해 월 한도 우회 방지
|
||||
- Resend 발송 실패 시 예약을 취소해 무료 횟수가 잘못 차감되지 않도록 처리
|
||||
- 한도 초과 시 `zenn@sori.studio` 관리자 문의 안내 반환
|
||||
|
||||
## 2026-06-25 / v4.0.13 — 인증 캐시·중복 제출 방지
|
||||
|
||||
### 수행 작업
|
||||
- 이메일 인증 화면이 캐시된 구 `api.js`를 불러와 `verifyEmail`을 찾지 못하던 문제 수정
|
||||
- 인증 메일 링크·인증 화면에 `v=4.0.13` 캐시 무효화와 직접 API 호출 폴백 적용
|
||||
- 회원가입·로그인·인증 메일·비밀번호 찾기·재설정 요청 중 버튼 잠금과 로딩 스피너 표시
|
||||
- 인증 메일과 비밀번호 재설정 메일 전송 후 60초 재요청 대기 표시
|
||||
|
||||
## 2026-06-25 / v4.0.12 — 이메일 인증·비밀번호 재설정
|
||||
|
||||
### 수행 작업
|
||||
|
||||
@@ -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.12" />
|
||||
<link rel="stylesheet" href="./style/output.css?v=4.0.14" />
|
||||
<title>비밀번호 찾기 - UNIARE</title>
|
||||
</head>
|
||||
<body class="app-page">
|
||||
@@ -21,21 +21,36 @@
|
||||
<label class="mb-1.5 block text-xs font-bold uppercase tracking-wider text-slate-400" for="email">이메일</label>
|
||||
<input type="email" id="email" required autocomplete="email" placeholder="you@example.com" class="box-border block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/15" />
|
||||
<p id="status-msg" class="mt-4 hidden text-sm text-slate-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">재설정 메일 보내기</button>
|
||||
<button type="submit" id="forgot-submit" class="mt-6 block w-full cursor-pointer rounded-xl border-0 bg-blue-600 px-4 py-3.5 text-sm font-bold text-white transition hover:bg-blue-700 disabled:cursor-wait disabled:opacity-70">재설정 메일 보내기</button>
|
||||
</form>
|
||||
<p class="mt-6 text-center"><a href="/login.html" class="text-sm font-bold text-blue-600 hover:underline">로그인으로 돌아가기</a></p>
|
||||
</main>
|
||||
<script src="/script/api.js"></script>
|
||||
<script src="/script/api.js?v=4.0.14"></script>
|
||||
<script src="/script/auth-form.js?v=4.0.14"></script>
|
||||
<script>
|
||||
document.getElementById('forgot-form').addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const statusEl = document.getElementById('status-msg');
|
||||
const submitButton = document.getElementById('forgot-submit');
|
||||
if (submitButton.disabled) return;
|
||||
statusEl.classList.remove('hidden');
|
||||
UaAuthForm.setLoading(submitButton, true, '전송 중...');
|
||||
let sent = false;
|
||||
try {
|
||||
const result = await UaDeckApi.forgotPassword(document.getElementById('email').value);
|
||||
const email = document.getElementById('email').value;
|
||||
const result = typeof UaDeckApi.forgotPassword === 'function'
|
||||
? await UaDeckApi.forgotPassword(email)
|
||||
: await UaAuthForm.request('/auth/forgot-password', { email });
|
||||
statusEl.textContent = result.message;
|
||||
sent = true;
|
||||
} catch (err) {
|
||||
statusEl.textContent = err.message;
|
||||
} finally {
|
||||
if (sent) {
|
||||
UaAuthForm.startCooldown(submitButton, 60, '다시 요청');
|
||||
} else {
|
||||
UaAuthForm.setLoading(submitButton, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
13
login.html
13
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.12" />
|
||||
<link rel="stylesheet" href="./style/output.css?v=4.0.14" />
|
||||
<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>
|
||||
@@ -36,7 +36,7 @@
|
||||
<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>
|
||||
<button type="submit" id="login-submit" class="mt-6 block w-full cursor-pointer rounded-xl border-0 bg-blue-600 px-4 py-3.5 text-sm font-bold text-white transition hover:bg-blue-700 active:scale-[0.98] disabled:cursor-wait disabled:opacity-70" data-i18n="loginButton">로그인</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-6 text-center text-sm text-slate-500">
|
||||
@@ -48,15 +48,19 @@
|
||||
</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.12</footer>
|
||||
<footer class="px-6 pb-5 text-center text-[0.625rem] font-medium tracking-wide text-slate-300" aria-hidden="true">v4.0.14</footer>
|
||||
|
||||
<script src="/script/page-i18n.js"></script>
|
||||
<script src="/script/api.js"></script>
|
||||
<script src="/script/api.js?v=4.0.14"></script>
|
||||
<script src="/script/auth-form.js?v=4.0.14"></script>
|
||||
<script>
|
||||
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const errEl = document.getElementById('error-msg');
|
||||
const submitButton = document.getElementById('login-submit');
|
||||
if (submitButton.disabled) return;
|
||||
errEl.classList.add('hidden');
|
||||
UaAuthForm.setLoading(submitButton, true, '로그인 중...');
|
||||
try {
|
||||
const email = document.getElementById('email').value;
|
||||
const password = document.getElementById('password').value;
|
||||
@@ -68,6 +72,7 @@
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.classList.remove('hidden');
|
||||
UaAuthForm.setLoading(submitButton, false);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -40,6 +40,11 @@ server {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /script/auth-form.js {
|
||||
add_header Cache-Control "no-cache, must-revalidate";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# CSS — 배포 후 즉시 반영 (immutable 사용 안 함)
|
||||
location ~* \.css$ {
|
||||
expires 1h;
|
||||
|
||||
@@ -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.12" />
|
||||
<link rel="stylesheet" href="./style/output.css?v=4.0.14" />
|
||||
<title>비밀번호 재설정 - UNIARE</title>
|
||||
</head>
|
||||
<body class="app-page">
|
||||
@@ -25,15 +25,18 @@
|
||||
<label class="mb-1.5 block text-xs font-bold uppercase tracking-wider text-slate-400" for="password-confirm">비밀번호 확인</label>
|
||||
<input type="password" id="password-confirm" required minlength="8" autocomplete="new-password" class="box-border block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/15" />
|
||||
<p id="status-msg" class="mt-4 hidden text-sm text-slate-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">비밀번호 변경</button>
|
||||
<button type="submit" id="reset-submit" class="mt-6 block w-full cursor-pointer rounded-xl border-0 bg-blue-600 px-4 py-3.5 text-sm font-bold text-white transition hover:bg-blue-700 disabled:cursor-wait disabled:opacity-70">비밀번호 변경</button>
|
||||
</form>
|
||||
<p class="mt-6 text-center"><a href="/login.html" class="text-sm font-bold text-blue-600 hover:underline">로그인으로 돌아가기</a></p>
|
||||
</main>
|
||||
<script src="/script/api.js"></script>
|
||||
<script src="/script/api.js?v=4.0.14"></script>
|
||||
<script src="/script/auth-form.js?v=4.0.14"></script>
|
||||
<script>
|
||||
document.getElementById('reset-form').addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const statusEl = document.getElementById('status-msg');
|
||||
const submitButton = document.getElementById('reset-submit');
|
||||
if (submitButton.disabled) return;
|
||||
const password = document.getElementById('password').value;
|
||||
const confirm = document.getElementById('password-confirm').value;
|
||||
statusEl.classList.remove('hidden');
|
||||
@@ -41,13 +44,17 @@
|
||||
statusEl.textContent = '비밀번호가 일치하지 않습니다.';
|
||||
return;
|
||||
}
|
||||
UaAuthForm.setLoading(submitButton, true, '변경 중...');
|
||||
try {
|
||||
const token = new URLSearchParams(location.search).get('token') || '';
|
||||
const result = await UaDeckApi.resetPassword(token, password);
|
||||
const result = typeof UaDeckApi.resetPassword === 'function'
|
||||
? await UaDeckApi.resetPassword(token, password)
|
||||
: await UaAuthForm.request('/auth/reset-password', { token, newPassword: password });
|
||||
statusEl.textContent = result.message;
|
||||
document.querySelector('#reset-form button').disabled = true;
|
||||
UaAuthForm.setComplete(submitButton, '변경 완료');
|
||||
} catch (err) {
|
||||
statusEl.textContent = err.message;
|
||||
UaAuthForm.setLoading(submitButton, false);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
94
script/auth-form.js
Normal file
94
script/auth-form.js
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 인증 화면 제출 버튼 상태 관리
|
||||
*/
|
||||
const UaAuthForm = (() => {
|
||||
/**
|
||||
* 버튼 로딩 상태 전환
|
||||
* @param {HTMLButtonElement} button
|
||||
* @param {boolean} loading
|
||||
* @param {string} [loadingText='처리 중...']
|
||||
*/
|
||||
function setLoading(button, loading, loadingText = '처리 중...') {
|
||||
if (!button) return;
|
||||
if (!button.dataset.idleLabel) {
|
||||
button.dataset.idleLabel = button.textContent.trim();
|
||||
}
|
||||
|
||||
button.disabled = loading;
|
||||
button.setAttribute('aria-busy', String(loading));
|
||||
if (loading) {
|
||||
button.classList.add('cursor-wait', 'opacity-70');
|
||||
button.innerHTML = `
|
||||
<span class="inline-flex items-center justify-center gap-2">
|
||||
<span class="size-4 animate-spin rounded-full border-2 border-current/30 border-t-current" aria-hidden="true"></span>
|
||||
<span>${loadingText}</span>
|
||||
</span>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
button.classList.remove('cursor-wait', 'opacity-70');
|
||||
button.textContent = button.dataset.idleLabel;
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시된 구 API 클라이언트에 메서드가 없을 때 인증 API 직접 요청
|
||||
* @param {string} path
|
||||
* @param {object} body
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
async function request(path, body) {
|
||||
const response = await fetch(`${UaDeckApi.getBaseUrl()}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || `요청 실패 (${response.status})`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 전송 버튼 재요청 대기 표시
|
||||
* @param {HTMLButtonElement} button
|
||||
* @param {number} seconds
|
||||
* @param {string} label
|
||||
*/
|
||||
function startCooldown(button, seconds, label) {
|
||||
setLoading(button, false);
|
||||
let remaining = seconds;
|
||||
button.disabled = true;
|
||||
button.classList.add('cursor-wait', 'opacity-70');
|
||||
button.textContent = `${label} (${remaining}초)`;
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
remaining -= 1;
|
||||
if (remaining <= 0) {
|
||||
window.clearInterval(timer);
|
||||
button.disabled = false;
|
||||
button.classList.remove('cursor-wait', 'opacity-70');
|
||||
button.textContent = button.dataset.idleLabel;
|
||||
return;
|
||||
}
|
||||
button.textContent = `${label} (${remaining}초)`;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 성공 후 버튼을 완료 상태로 고정
|
||||
* @param {HTMLButtonElement} button
|
||||
* @param {string} label
|
||||
*/
|
||||
function setComplete(button, label) {
|
||||
setLoading(button, false);
|
||||
button.disabled = true;
|
||||
button.classList.add('opacity-70');
|
||||
button.textContent = label;
|
||||
}
|
||||
|
||||
return { setLoading, request, startCooldown, setComplete };
|
||||
})();
|
||||
|
||||
window.UaAuthForm = UaAuthForm;
|
||||
11
signup.html
11
signup.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.12" />
|
||||
<link rel="stylesheet" href="./style/output.css?v=4.0.14" />
|
||||
<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>
|
||||
@@ -40,7 +40,7 @@
|
||||
<input type="password" id="password-confirm" required minlength="8" class="box-border block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition placeholder:text-slate-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/15" data-i18n-placeholder="passwordConfirmPlaceholder" placeholder="비밀번호 재입력" autocomplete="new-password" />
|
||||
</div>
|
||||
<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="signupButton">가입하기</button>
|
||||
<button type="submit" id="signup-submit" class="mt-6 block w-full cursor-pointer rounded-xl border-0 bg-blue-600 px-4 py-3.5 text-sm font-bold text-white transition hover:bg-blue-700 active:scale-[0.98] disabled:cursor-wait disabled:opacity-70" data-i18n="signupButton">가입하기</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-6 text-center text-sm text-slate-500">
|
||||
@@ -50,11 +50,14 @@
|
||||
</main>
|
||||
|
||||
<script src="/script/page-i18n.js"></script>
|
||||
<script src="/script/api.js"></script>
|
||||
<script src="/script/api.js?v=4.0.14"></script>
|
||||
<script src="/script/auth-form.js?v=4.0.14"></script>
|
||||
<script>
|
||||
document.getElementById('signup-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const errEl = document.getElementById('error-msg');
|
||||
const submitButton = document.getElementById('signup-submit');
|
||||
if (submitButton.disabled) return;
|
||||
errEl.classList.add('hidden');
|
||||
try {
|
||||
const password = document.getElementById('password').value;
|
||||
@@ -66,6 +69,7 @@
|
||||
errEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
UaAuthForm.setLoading(submitButton, true, '가입 처리 중...');
|
||||
const body = {
|
||||
email: document.getElementById('email').value,
|
||||
username: document.getElementById('username').value,
|
||||
@@ -76,6 +80,7 @@
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.classList.remove('hidden');
|
||||
UaAuthForm.setLoading(submitButton, false);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
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.12" />
|
||||
<link rel="stylesheet" href="./style/output.css?v=4.0.14" />
|
||||
<title>이메일 인증 - UNIARE</title>
|
||||
</head>
|
||||
<body class="app-page">
|
||||
@@ -21,12 +21,13 @@
|
||||
<form id="resend-form" class="mt-6">
|
||||
<label class="mb-1.5 block text-left text-xs font-bold uppercase tracking-wider text-slate-400" for="email">이메일</label>
|
||||
<input type="email" id="email" required autocomplete="email" class="box-border block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/15" />
|
||||
<button type="submit" class="mt-4 block w-full cursor-pointer rounded-xl border-0 bg-slate-100 px-4 py-3 text-sm font-bold text-slate-700 transition hover:bg-slate-200">인증 메일 다시 보내기</button>
|
||||
<button type="submit" id="resend-submit" class="mt-4 block w-full cursor-pointer rounded-xl border-0 bg-slate-100 px-4 py-3 text-sm font-bold text-slate-700 transition hover:bg-slate-200 disabled:cursor-wait disabled:opacity-70">인증 메일 다시 보내기</button>
|
||||
</form>
|
||||
<a href="/login.html" class="mt-5 inline-block text-sm font-bold text-blue-600 hover:underline">로그인으로 이동</a>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/script/api.js"></script>
|
||||
<script src="/script/api.js?v=4.0.14"></script>
|
||||
<script src="/script/auth-form.js?v=4.0.14"></script>
|
||||
<script>
|
||||
const params = new URLSearchParams(location.search);
|
||||
const token = params.get('token');
|
||||
@@ -44,7 +45,9 @@
|
||||
document.getElementById('resend-form').classList.add('hidden');
|
||||
statusEl.textContent = '이메일을 인증하는 중입니다...';
|
||||
try {
|
||||
const result = await UaDeckApi.verifyEmail(token);
|
||||
const result = typeof UaDeckApi.verifyEmail === 'function'
|
||||
? await UaDeckApi.verifyEmail(token)
|
||||
: await UaAuthForm.request('/auth/verify-email', { token });
|
||||
statusEl.textContent = result.message;
|
||||
} catch (err) {
|
||||
statusEl.textContent = err.message;
|
||||
@@ -54,11 +57,24 @@
|
||||
|
||||
document.getElementById('resend-form').addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const submitButton = document.getElementById('resend-submit');
|
||||
if (submitButton.disabled) return;
|
||||
UaAuthForm.setLoading(submitButton, true, '전송 중...');
|
||||
let sent = false;
|
||||
try {
|
||||
const result = await UaDeckApi.resendVerification(emailEl.value);
|
||||
const result = typeof UaDeckApi.resendVerification === 'function'
|
||||
? await UaDeckApi.resendVerification(emailEl.value)
|
||||
: await UaAuthForm.request('/auth/resend-verification', { email: emailEl.value });
|
||||
statusEl.textContent = result.message;
|
||||
sent = true;
|
||||
} catch (err) {
|
||||
statusEl.textContent = err.message;
|
||||
} finally {
|
||||
if (sent) {
|
||||
UaAuthForm.startCooldown(submitButton, 60, '다시 보내기');
|
||||
} else {
|
||||
UaAuthForm.setLoading(submitButton, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
verifyToken();
|
||||
|
||||
Reference in New Issue
Block a user