diff --git a/lang/ko/auth.json b/lang/ko/auth.json
new file mode 100644
index 0000000..f38079f
--- /dev/null
+++ b/lang/ko/auth.json
@@ -0,0 +1,12 @@
+{
+ "auth": {
+ "login": { "title": "로그인", "email": "이메일", "password": "비밀번호", "submit": "로그인", "failed": "이메일 또는 비밀번호가 올바르지 않습니다", "locked": "로그인 시도가 너무 많습니다. {until} 이후에 다시 시도하세요", "noAccount": "계정이 없으신가요?", "forgot": "비밀번호를 잊으셨나요?", "operatorNote": "운영자는 별도의 운영자 콘솔(hal.ariweb.net)에서 로그인합니다. 이 화면은 고객 전용입니다.", "mfaTitle": "인증 코드 입력", "mfaHint": "인증 앱의 6자리 코드 또는 복구 코드를 입력하세요", "mfaExpired": "인증 단계가 만료되었습니다. 처음부터 다시 로그인하세요" },
+ "signup": { "title": "회원가입", "displayName": "표시 이름", "submit": "가입하기", "passwordHint": "8자 이상. 다른 서비스와 같은 비밀번호는 피하세요", "terms": "이용약관에 동의합니다 (필수)", "privacy": "개인정보 처리방침에 동의합니다 (필수)", "marketing": "소식·혜택 안내 수신에 동의합니다 (선택)", "dupe": "이 이메일로는 가입을 진행할 수 없습니다. 이미 계정이 있다면 로그인하거나 비밀번호 재설정을 이용하세요", "emailSent": "확인 메일을 보냈습니다. 받은 편지함의 링크를 눌러 이메일을 확인하세요", "consentRequired": "필수 약관에 동의해야 가입할 수 있습니다", "policyNote": "가격·SLA·환불·개인정보·백업·AUP·정지 및 해지 정책은 승인된 문구만 표시됩니다. 미확정 정책은 '확정 전' 으로 표시됩니다" },
+ "verify": { "title": "이메일 확인", "ok": "이메일이 확인되었습니다", "invalid": "확인 링크가 유효하지 않거나 만료되었습니다. 다시 보내기를 눌러 새 링크를 받으세요", "used": "이미 사용한 링크입니다", "resend": "확인 메일 다시 보내기", "banner": "이메일 확인 전에는 유료 상품 구매와 운영 서비스 생성을 할 수 없습니다" },
+ "reset": { "title": "비밀번호 재설정", "request": "재설정 링크 보내기", "requested": "계정이 있다면 재설정 안내 메일을 보냈습니다. 받은 편지함을 확인하세요", "newPassword": "새 비밀번호", "submit": "비밀번호 바꾸기", "done": "비밀번호를 바꿨습니다. 다른 기기의 로그인은 모두 종료되었습니다", "expired": "재설정 링크가 만료되었습니다 (유효 시간 1시간)", "used": "이미 사용한 재설정 링크입니다", "state": "링크 상태" },
+ "password": { "tooShort": "비밀번호는 8자 이상이어야 합니다", "tooLong": "비밀번호는 128자 이하여야 합니다", "same": "새 비밀번호가 현재 비밀번호와 같습니다", "wrongCurrent": "현재 비밀번호가 올바르지 않습니다" },
+ "mfa": { "title": "다중 인증(MFA)", "enabled": "사용 중", "disabled": "사용 안 함", "setup": "인증 앱 등록", "manualKey": "수동 입력 키", "enable": "코드 확인 후 켜기", "disable": "끄기", "disableNeeds": "끄려면 비밀번호와 현재 코드가 필요합니다", "badCode": "인증 코드가 올바르지 않습니다", "recovery": "복구 코드", "recoveryOnce": "복구 코드는 지금 한 번만 표시됩니다. 안전한 곳에 보관하세요. 각 코드는 한 번만 쓸 수 있습니다", "regenerate": "복구 코드 다시 만들기", "level": "현재 보안 수준", "levelMfa": "비밀번호 + 인증 앱", "levelPw": "비밀번호만 (인증 앱 등록을 권장합니다)" },
+ "sessions": { "title": "로그인 기기", "current": "현재 세션", "revoke": "종료", "revokeOthers": "다른 모든 세션 종료", "lastSeen": "마지막 사용", "created": "로그인 시각", "device": "기기" },
+ "account": { "title": "내 계정", "profile": "프로필", "security": "보안", "changePassword": "비밀번호 변경", "currentPassword": "현재 비밀번호", "saved": "저장했습니다" }
+ }
+}
diff --git a/lang/ko/dashboard.json b/lang/ko/dashboard.json
new file mode 100644
index 0000000..b1060cc
--- /dev/null
+++ b/lang/ko/dashboard.json
@@ -0,0 +1,9 @@
+{
+ "dashboard": {
+ "title": "대시보드", "sites": "운영 사이트", "sitesOf": "구매 {purchased}개 중", "reserved": "예약됨", "inUse": "사용 중", "drafts": "초안·제작 중", "health": "사이트 상태", "healthOk": "정상", "healthWarn": "경고", "healthBad": "장애", "deploy": "배포 방식", "managed": "관리형", "external": "외부", "domains": "도메인", "domainsAction": "조치 필요", "mail": "메일 서비스", "mailboxes": "메일함", "backup": "백업 옵션 사이트", "jobs": "진행 중 작업", "cost": "이번 달 예상 비용", "awaitingPayment": "결제 대기 금액",
+ "attention": "주의 항목", "recent": "최근 활동", "auto": "자동", "user": "사용자", "noAttention": "지금 조치할 항목이 없습니다", "noActivity": "최근 활동이 없습니다"
+ },
+ "notifications": { "title": "알림", "all": "전체", "unread": "읽지 않음", "markRead": "읽음으로 표시", "mandatory": "필수", "count": "{n}회 반복", "resolved": "해결됨", "categories": { "billing": "결제와 주문", "site": "사이트와 배포", "domain": "도메인과 인증서", "mail": "메일", "usage": "사용량", "backup": "백업과 복원", "relocation": "서버 이전", "security": "보안", "incident": "장애와 유지보수", "membership": "구성원", "ops": "운영" } },
+ "activity": { "title": "활동 기록", "who": "누가", "when": "언제", "what": "무엇을", "target": "대상", "result": "결과", "auto": "자동 작업", "manual": "사용자 작업", "immutable": "삭제 불가 기록" },
+ "help": { "title": "도움말" }
+}
diff --git a/lang/ko/groups.json b/lang/ko/groups.json
new file mode 100644
index 0000000..f4b1114
--- /dev/null
+++ b/lang/ko/groups.json
@@ -0,0 +1,10 @@
+{
+ "groups": {
+ "title": "사용자 그룹 설정", "name": "그룹 이름", "contact": "연락처", "billingContact": "결제 담당자", "techContact": "기술 담당자", "members": "구성원", "invites": "초대 대기", "invite": "이메일로 초대", "resend": "재발송", "cancelInvite": "초대 취소", "expired": "만료됨", "role": "역할", "alias": "표시 별칭", "aliasHint": "화면 표시용 별칭입니다. 로그인과 권한의 기준이 아닙니다",
+ "roles": { "owner": "소유자", "admin": "관리자", "billing": "결제 담당", "member": "구성원", "viewer": "조회 전용" },
+ "suspend": "정지", "unsuspend": "정지 해제", "remove": "제거", "leave": "그룹 탈퇴", "transferOwner": "소유권 이전", "suspendImpact": "정지하면 이 구성원의 로그인 세션과 아직 실행되지 않은 작업만 중지됩니다. 그룹의 상품·사이트·도메인과 다른 구성원의 작업은 유지됩니다",
+ "ownerCannotLeave": "소유자는 먼저 소유권을 다른 구성원에게 이전해야 탈퇴할 수 있습니다", "lastOwner": "소유자는 그룹마다 한 명이며 제거할 수 없습니다", "switched": "사용자 그룹을 바꿨습니다",
+ "sitePerms": { "title": "사이트별 권한", "view": "조회", "edit": "콘텐츠 편집", "deploy": "배포", "domain_mail": "도메인과 메일 설정", "billing": "결제와 해지", "admin": "전체 관리" },
+ "inviteAccepted": "그룹에 참여했습니다", "inviteInvalid": "초대 링크가 유효하지 않거나 만료되었습니다", "inviteNeedsLogin": "초대를 수락하려면 초대받은 이메일로 로그인하거나 가입하세요"
+ }
+}
diff --git a/portal/auditui/api.mjs b/portal/auditui/api.mjs
new file mode 100644
index 0000000..d4b5a53
--- /dev/null
+++ b/portal/auditui/api.mjs
@@ -0,0 +1,12 @@
+/**
+ * portal/auditui/api.mjs — 활동·감사 기록 화면(§24.2). 현재 그룹의 기록만, 비밀값 없음(lib/audit 가 기록 시 redact).
+ * 필터: 행동(action 접두어)·대상 종류·기간. 삭제 불가 기록(immutable)과 자동 작업을 표시한다.
+ */
+import { requireGroup } from '../auth/guard.mjs';
+import { listAudit } from '../../lib/audit.mjs';
+import { t } from '../../lib/i18n.mjs';
+export async function register(app, deps) {
+ app.get('/activity', (ctx) => { requireGroup(ctx); return ctx.html(deps.shell(ctx, { title: t('activity.title'), nav: null, crumbs: [{ label: t('activity.title') }], body: `
${deps.statePanel('loading')}
+` })); });
+ app.get('/api/audit', (ctx) => { requireGroup(ctx); const items = listAudit(deps.store, { groupId: ctx.group.id, action: ctx.query.action || undefined, subjectType: ctx.query.subjectType || undefined, since: ctx.query.since ? ctx.query.since + 'T00:00:00.000Z' : undefined, until: ctx.query.until ? ctx.query.until + 'T23:59:59.999Z' : undefined, limit: Number(ctx.query.limit || 200) }).map((a) => ({ id: a.id, at: a.at, actor: a.actor_label || a.actor_type, actorType: a.actor_type, automatic: !!a.automatic || a.actor_type === 'system', action: a.action, outcome: a.outcome, subjectType: a.subject_type, subjectId: a.subject_id, immutable: !!a.immutable, detail: a.detail })); return { ok: true, items }; });
+}
diff --git a/portal/auth/api.mjs b/portal/auth/api.mjs
new file mode 100644
index 0000000..32e9fe4
--- /dev/null
+++ b/portal/auth/api.mjs
@@ -0,0 +1,204 @@
+/**
+ * portal/auth/api.mjs — 회원가입·로그인·세션·이메일 확인·비밀번호 재설정·MFA·내 계정 (§3, §37) 과 인증 미들웨어.
+ *
+ * 등록 순서상 가장 먼저 실행되므로 여기서 공유 도구도 만든다.
+ * deps.mailer portal/mailer.mjs (없을 때만) deps.scheduler portal/scheduler.mjs 등록부
+ * deps.shell(ctx, o) 현재 사용자·그룹·그룹 목록·읽지 않은 알림 수·CSRF 를 채워 deps.page() 를 부르는 화면 틀 — 모든 영역이 이것으로 HTML 을 낸다
+ * deps.currentGroup(ctx) → ctx.group, deps.sendMail({to, subject, text, html})
+ * 미들웨어(middleware)
+ * 1) aw_session 쿠키 → lib/session(kind customer) → users 행(status active) → ctx.user/ctx.session
+ * 2) 세션 data.groupId(없으면 첫 활성 구성원 그룹) → ctx.group/ctx.membership, ctx.sitePerms(siteId,userId)
+ * 3) 세션이 있는 /api 변경 요청은 X-CSRF 검사(guard.requireCsrf). 세션 없는 /api 변경(가입·로그인)은 그대로 통과.
+ * 4) HTML 화면(GET, /api·/ui·/internal 등이 아닌 경로)은 로그인 전이면 /login?next= 로 보낸다(공개 화면 제외).
+ * 비밀번호·토큰 원문은 어디에도 기록하지 않는다. 이메일 존재 여부는 가입·로그인·재설정 응답에서 드러내지 않는다.
+ */
+import { createSession, resolveSession, revokeSession, revokeOtherSessions, listSessions, updateSessionData, markReauth, cookieName, csrfOk } from '../../lib/session.mjs';
+import { cookie, HttpError } from '../../lib/http.mjs';
+import { hashPassword, verifyPassword, randomToken, sha256, newId, safeEqual } from '../../lib/crypto.mjs';
+import { nowIso, parseJson, toJson } from '../../lib/db.mjs';
+import { audit } from '../../lib/audit.mjs';
+import { notify, unreadCount, setMailer } from '../../lib/notify.mjs';
+import { t } from '../../lib/i18n.mjs';
+import { requireUser, requireCsrf } from './guard.mjs';
+import * as totp from './totp.mjs';
+import * as pages from './pages.mjs';
+import { create as createMailer } from '../mailer.mjs';
+import { create as createScheduler } from '../scheduler.mjs';
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const PUBLIC_HTML = /^\/(login|signup|verify-email|reset-password(\/.*)?|mfa|help|status|invite\/.*|public(\/.*)?|sso(\/.*)?|dl(\/.*)?)?\/?$/;
+const SKIP_HTML = /^\/(api|ui|internal|sso|webhooks?|ops|\.well-known|dl)(\/|$)/;
+const LOCK_AFTER = 5; const LOCK_MS = 15 * 60 * 1000;
+const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+/** 비밀번호 규칙(§3). 정책 문구는 lang 에서 */
+export function checkPassword(pw) { const s = String(pw || ''); if (s.length < 8) throw new HttpError(400, 'password_too_short', t('auth.password.tooShort')); if (s.length > 128) throw new HttpError(400, 'password_too_long', t('auth.password.tooLong')); }
+const normEmail = (e) => String(e || '').trim().toLowerCase();
+/** 화면에 내려보낼 사용자 공개 정보(해시·MFA 비밀 없음) */
+export function publicUser(u) { if (!u) return null; const mfa = parseJson(u.mfa_json, null); return { id: u.id, email: u.email, display_name: u.display_name, email_verified_at: u.email_verified_at, mfa_enabled: !!(mfa && mfa.enabledAt), created_at: u.created_at, last_login_at: u.last_login_at, is_operator: !!u.is_operator }; }
+
+export async function register(app, deps) {
+ const { store } = deps;
+ if (!deps.mailer) deps.mailer = createMailer(deps);
+ createScheduler(deps);
+ deps.sendMail = (m) => deps.mailer.send(m);
+ deps.publicUser = publicUser;
+ // 필수 알림의 외부 이메일: 그룹의 결제 담당자(없으면 소유자) 주소로
+ setMailer(async ({ groupId, userId, title, body, link }) => {
+ let to = null;
+ if (userId) to = store.get('SELECT email FROM users WHERE id=?', userId)?.email;
+ if (!to && groupId) { const g = store.get('SELECT * FROM groups WHERE id=?', groupId); if (g) to = store.get('SELECT email FROM users WHERE id=?', g.billing_user_id || g.owner_user_id)?.email || g.contact_email; }
+ if (!to) return;
+ await deps.mailer.send({ to, subject: `[Ariweb] ${title}`, text: `${body || ''}\n\n${link ? deps.config.publicUrl + link : ''}` });
+ });
+
+ /** 화면 틀: 현재 사용자·그룹·그룹 목록·알림 수·CSRF 포함 */
+ deps.shell = (ctx, o) => deps.page({ user: ctx.user ? publicUser(ctx.user) : null, group: ctx.group, groups: ctx.user ? store.all('SELECT g.id, g.name, g.kind FROM groups g JOIN memberships m ON m.group_id=g.id WHERE m.user_id=? AND m.status=\'active\' AND g.status=\'active\' ORDER BY g.kind DESC, g.created_at', ctx.user.id) : [], unread: ctx.group ? unreadCount(store, ctx.group.id) : 0, csrf: ctx.session?.csrf || '', ...o });
+ deps.currentGroup = (ctx) => ctx.group;
+
+ // ---------------------------------------------------------------- 미들웨어
+ app.use(async (ctx) => {
+ const token = ctx.cookies[cookieName('customer')];
+ const s = token ? resolveSession(store, token, 'customer') : null;
+ if (s) {
+ const u = store.get('SELECT * FROM users WHERE id=?', s.user_id);
+ if (u && u.status === 'active') {
+ ctx.user = u; ctx.session = s;
+ let gid = (s.data || {}).groupId; let m = gid ? store.get('SELECT * FROM memberships WHERE group_id=? AND user_id=? AND status=\'active\'', gid, u.id) : null;
+ if (!m) { m = store.get('SELECT m.* FROM memberships m JOIN groups g ON g.id=m.group_id WHERE m.user_id=? AND m.status=\'active\' AND g.status=\'active\' ORDER BY g.kind DESC, m.joined_at LIMIT 1', u.id); if (m) { gid = m.group_id; updateSessionData(store, s.id, { groupId: gid }); } }
+ if (m) { ctx.group = store.get('SELECT * FROM groups WHERE id=? AND status=\'active\'', gid); ctx.membership = ctx.group ? m : null; }
+ ctx.sitePerms = (siteId, userId) => { const r = store.get('SELECT perms_json FROM site_permissions WHERE site_id=? AND user_id=?', siteId, userId); return r ? parseJson(r.perms_json, []) : []; };
+ }
+ }
+ if (ctx.session) requireCsrf(ctx, csrfOk);
+ // 세션이 없는데 변경 요청이 왔고 로그인 폼(form POST /logout 등)이면 허용. 로그인 화면 리디렉션은 HTML GET 만
+ if (!ctx.user && ctx.method === 'GET' && !SKIP_HTML.test(ctx.path) && !PUBLIC_HTML.test(ctx.path) && String(ctx.headers.accept || '').includes('text/html')) return ctx.redirect('/login?next=' + encodeURIComponent(ctx.path + (ctx.url.search || '')));
+ });
+
+ // ---------------------------------------------------------------- 공통 도우미
+ const secure = (ctx) => ctx.secure || deps.config.publicUrl.startsWith('https');
+ function issueSession(ctx, user, { reauth = true } = {}) {
+ const s = createSession(store, { userId: user.id, kind: 'customer', ip: ctx.ip, ua: ctx.headers['user-agent'], reauth });
+ ctx.setCookie(cookie(cookieName('customer'), s.token, { maxAge: 30 * 86400, secure: secure(ctx) }));
+ store.run('UPDATE users SET last_login_at=?, failed_logins=0, lock_until=NULL WHERE id=?', nowIso(), user.id);
+ return s;
+ }
+ function makeToken(userId, email, kind, ttlMs, data = null) { const raw = randomToken(32); store.run('INSERT INTO email_tokens(id, user_id, email, kind, token_hash, created_at, expires_at, data_json) VALUES(?,?,?,?,?,?,?,?)', newId('t_'), userId, email, kind, sha256(raw), nowIso(), new Date(Date.now() + ttlMs).toISOString(), toJson(data)); return raw; }
+ /** 토큰 상태: valid | expired | used | invalid */
+ function tokenState(raw, kind) { const r = raw ? store.get('SELECT * FROM email_tokens WHERE token_hash=? AND kind=?', sha256(raw), kind) : null; if (!r) return { state: 'invalid' }; if (r.used_at) return { state: 'used', row: r }; if (Date.parse(r.expires_at) < Date.now()) return { state: 'expired', row: r }; return { state: 'valid', row: r }; }
+ function useToken(row) { store.run('UPDATE email_tokens SET used_at=? WHERE id=? AND used_at IS NULL', nowIso(), row.id); }
+ async function sendVerification(user) { const raw = makeToken(user.id, user.email, 'verify', 24 * 3600 * 1000); const link = `${deps.config.publicUrl}/verify-email?token=${raw}`; await deps.mailer.send({ to: user.email, subject: '[Ariweb] 이메일 주소를 확인해 주세요', text: `아래 링크를 눌러 이메일을 확인하세요(24시간 유효).\n${link}\n\n본인이 가입하지 않았다면 이 메일을 무시하세요.` }).catch((e) => deps.log(`[auth] 확인 메일 실패: ${e.message}`)); }
+ const attemptKey = (email, ip) => `${email}|${ip}`;
+ function lockState(email, ip) { const r = store.get('SELECT * FROM login_attempts WHERE key=?', attemptKey(email, ip)); if (r && r.lock_until && Date.parse(r.lock_until) > Date.now()) return r.lock_until; return null; }
+ function recordFailure(email, ip) { const k = attemptKey(email, ip); const r = store.get('SELECT * FROM login_attempts WHERE key=?', k); const failures = (r ? r.failures : 0) + 1; const lock = failures >= LOCK_AFTER ? new Date(Date.now() + LOCK_MS).toISOString() : null; store.run('INSERT INTO login_attempts(key, failures, lock_until, updated_at) VALUES(?,?,?,?) ON CONFLICT(key) DO UPDATE SET failures=excluded.failures, lock_until=excluded.lock_until, updated_at=excluded.updated_at', k, failures, lock, nowIso()); return lock; }
+ function clearFailures(email, ip) { store.run('DELETE FROM login_attempts WHERE key=?', attemptKey(email, ip)); }
+ const fmtKst = (iso) => new Date(new Date(iso).getTime() + 9 * 3600000).toISOString().slice(11, 16) + ' (KST)';
+ /** 개인 그룹 만들기(가입 시) */
+ function createPersonalGroup(user) { const gid = newId('g_'); const now = nowIso(); store.run('INSERT INTO groups(id, name, kind, owner_user_id, contact_email, billing_user_id, tech_user_id, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?)', gid, user.display_name, 'personal', user.id, user.email, user.id, user.id, now, now); store.run('INSERT INTO memberships(group_id, user_id, role, alias, joined_at) VALUES(?,?,?,?,?)', gid, user.id, 'owner', 'user-01', now); return gid; }
+ deps.createPersonalGroup = createPersonalGroup;
+
+ // ---------------------------------------------------------------- 화면
+ app.static('/ui/auth', join(HERE, 'public'));
+ app.get('/login', (ctx) => ctx.user ? ctx.redirect(ctx.query.next && ctx.query.next.startsWith('/') ? ctx.query.next : '/') : ctx.html(pages.loginPage((o) => deps.shell(ctx, o), { next: ctx.query.next && ctx.query.next.startsWith('/') ? ctx.query.next : '/', notice: ctx.query.notice === 'reset' ? t('auth.reset.done') : ctx.query.notice === 'invite' ? t('groups.inviteNeedsLogin') : '' })));
+ app.get('/signup', (ctx) => ctx.user ? ctx.redirect('/') : ctx.html(pages.signupPage((o) => deps.shell(ctx, o))));
+ app.get('/verify-email', (ctx) => { const { state, row } = tokenState(ctx.query.token, 'verify'); if (state === 'valid') { useToken(row); store.run('UPDATE users SET email_verified_at=COALESCE(email_verified_at, ?), updated_at=? WHERE id=?', nowIso(), nowIso(), row.user_id); audit(store, { actorType: 'user', actorId: row.user_id, action: 'auth.email_verified', ip: ctx.ip }); } return ctx.html(pages.verifyPage((o) => deps.shell(ctx, o), { state: state === 'valid' ? 'ok' : state, user: ctx.user })); });
+ app.get('/reset-password', (ctx) => ctx.html(pages.resetRequestPage((o) => deps.shell(ctx, o))));
+ app.get('/reset-password/:token', (ctx) => ctx.html(pages.resetFormPage((o) => deps.shell(ctx, o), { token: ctx.params.token, state: tokenState(ctx.params.token, 'reset').state })));
+ app.post('/logout', async (ctx) => { const b = await ctx.body(); if (ctx.session && csrfOk(ctx.session, b.csrf)) revokeSession(store, ctx.session.id, ctx.user.id); ctx.setCookie(cookie(cookieName('customer'), '', { maxAge: 0, secure: secure(ctx) })); return ctx.redirect('/login'); });
+ app.get('/account', (ctx) => { requireUser(ctx); return ctx.html(pages.accountPage((o) => deps.shell(ctx, o), { user: ctx.user })); });
+ app.get('/account/security', (ctx) => { requireUser(ctx); const mfa = parseJson(ctx.user.mfa_json, null); const rec = parseJson(ctx.user.recovery_json, []); return ctx.html(pages.securityPage((o) => deps.shell(ctx, o), { mfaEnabled: !!(mfa && mfa.enabledAt), mfaEnabledAt: mfa && mfa.enabledAt ? mfa.enabledAt.slice(0, 10) : null, recoveryLeft: rec.filter((c) => !c.usedAt).length })); });
+ app.get('/account/sessions', (ctx) => { requireUser(ctx); return ctx.html(pages.sessionsPage((o) => deps.shell(ctx, o), { statePanel: deps.statePanel })); });
+
+ // ---------------------------------------------------------------- API: 가입·확인
+ app.get('/api/auth/csrf', (ctx) => ({ ok: true, csrf: ctx.session ? ctx.session.csrf : null }));
+ app.get('/api/auth/me', (ctx) => ({ ok: true, user: publicUser(ctx.user), group: ctx.group ? { id: ctx.group.id, name: ctx.group.name, kind: ctx.group.kind } : null, role: ctx.membership?.role || null }));
+ app.post('/api/auth/signup', async (ctx) => {
+ const b = await ctx.body(); const email = normEmail(b.email); const name = String(b.displayName || '').trim(); const consents = b.consents || {};
+ if (!EMAIL_RE.test(email)) throw new HttpError(400, 'bad_email', '올바른 이메일 주소를 입력하세요'); if (!name || name.length > 80) throw new HttpError(400, 'bad_name', '표시 이름을 입력하세요(80자 이내)'); checkPassword(b.password);
+ if (!consents.terms || !consents.privacy) throw new HttpError(400, 'consent_required', t('auth.signup.consentRequired'));
+ const now = nowIso();
+ if (store.get('SELECT id FROM users WHERE email=?', email)) { audit(store, { actorType: 'system', action: 'auth.signup_duplicate', outcome: 'denied', detail: { emailDomain: email.split('@')[1] }, ip: ctx.ip }); throw new HttpError(409, 'signup_unavailable', t('auth.signup.dupe')); }
+ const { salt, hash } = hashPassword(b.password); const id = newId('u_');
+ store.run('INSERT INTO users(id, email, display_name, pass_hash, pass_salt, consents_json, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?)', id, email, name, hash, salt, toJson({ terms: now, privacy: now, marketing: consents.marketing ? now : null, ip: ctx.ip }), now, now);
+ const user = store.get('SELECT * FROM users WHERE id=?', id); createPersonalGroup(user);
+ audit(store, { actorType: 'user', actorId: id, action: 'auth.signup', ip: ctx.ip });
+ await sendVerification(user); issueSession(ctx, user);
+ return { ok: true, user: publicUser(user), message: t('auth.signup.emailSent'), next: '/' };
+ });
+ app.post('/api/auth/resend-verification', async (ctx) => { requireUser(ctx); if (ctx.user.email_verified_at) return { ok: true, already: true }; const last = store.get('SELECT created_at FROM email_tokens WHERE user_id=? AND kind=\'verify\' ORDER BY created_at DESC LIMIT 1', ctx.user.id); if (last && Date.now() - Date.parse(last.created_at) < 60000) throw new HttpError(429, 'too_soon', '1분 뒤에 다시 시도하세요'); await sendVerification(ctx.user); return { ok: true, message: t('auth.signup.emailSent') }; });
+ app.post('/api/auth/verify-email', async (ctx) => { const b = await ctx.body(); const { state, row } = tokenState(b.token, 'verify'); if (state !== 'valid') throw new HttpError(400, 'token_' + state, state === 'used' ? t('auth.verify.used') : t('auth.verify.invalid')); useToken(row); store.run('UPDATE users SET email_verified_at=COALESCE(email_verified_at, ?), updated_at=? WHERE id=?', nowIso(), nowIso(), row.user_id); audit(store, { actorType: 'user', actorId: row.user_id, action: 'auth.email_verified', ip: ctx.ip }); return { ok: true }; });
+
+ // ---------------------------------------------------------------- API: 로그인·MFA·로그아웃
+ app.post('/api/auth/login', async (ctx) => {
+ const b = await ctx.body(); const email = normEmail(b.email); const password = String(b.password || '');
+ const until = lockState(email, ctx.ip); if (until) throw new HttpError(429, 'locked', t('auth.login.locked', { until: fmtKst(until) }), { lockUntil: until });
+ const u = store.get('SELECT * FROM users WHERE email=?', email);
+ const okPw = u && u.status === 'active' && verifyPassword(password, u.pass_salt, u.pass_hash);
+ if (!okPw) { const lock = recordFailure(email, ctx.ip); audit(store, { actorType: 'system', action: 'auth.login_failed', outcome: 'denied', detail: { emailDomain: email.split('@')[1] || '', locked: !!lock }, ip: ctx.ip }); if (lock) throw new HttpError(429, 'locked', t('auth.login.locked', { until: fmtKst(lock) }), { lockUntil: lock }); throw new HttpError(401, 'login_failed', t('auth.login.failed')); }
+ clearFailures(email, ctx.ip);
+ const mfa = parseJson(u.mfa_json, null);
+ if (mfa && mfa.enabledAt) { const pre = makeToken(u.id, u.email, 'mfa_preauth', 5 * 60 * 1000, { ip: ctx.ip }); return { ok: true, mfaRequired: true, preauth: pre }; }
+ issueSession(ctx, u); audit(store, { actorType: 'user', actorId: u.id, action: 'auth.login', ip: ctx.ip });
+ return { ok: true, user: publicUser(u), next: b.next && String(b.next).startsWith('/') ? b.next : '/' };
+ });
+ app.post('/api/auth/mfa', async (ctx) => {
+ const b = await ctx.body(); const { state, row } = tokenState(b.preauth, 'mfa_preauth');
+ if (state !== 'valid') throw new HttpError(400, 'preauth_' + state, t('auth.login.mfaExpired'));
+ const u = store.get('SELECT * FROM users WHERE id=?', row.user_id); const mfa = parseJson(u.mfa_json, {}); const code = String(b.code || '').trim();
+ let how = null;
+ const counter = totp.verifyCode(mfa.secret, code, { lastCounter: mfa.lastCounter ?? null });
+ if (counter != null) { how = 'totp'; mfa.lastCounter = counter; store.run('UPDATE users SET mfa_json=? WHERE id=?', toJson(mfa), u.id); }
+ else { const rec = parseJson(u.recovery_json, []); const h = sha256(code.toLowerCase().replace(/\s/g, '')); const hit = rec.find((c) => !c.usedAt && safeEqual(c.hash, h)); if (hit) { hit.usedAt = nowIso(); store.run('UPDATE users SET recovery_json=? WHERE id=?', toJson(rec), u.id); how = 'recovery'; } }
+ if (!how) { const lock = recordFailure(u.email, ctx.ip); audit(store, { actorType: 'user', actorId: u.id, action: 'auth.mfa_failed', outcome: 'denied', ip: ctx.ip }); if (lock) { useToken(row); throw new HttpError(429, 'locked', t('auth.login.locked', { until: fmtKst(lock) })); } throw new HttpError(401, 'mfa_failed', t('auth.mfa.badCode')); }
+ useToken(row); clearFailures(u.email, ctx.ip); issueSession(ctx, u); audit(store, { actorType: 'user', actorId: u.id, action: 'auth.login', detail: { mfa: how }, ip: ctx.ip });
+ const left = how === 'recovery' ? parseJson(u.recovery_json, []).filter((c) => !c.usedAt).length - 1 : null;
+ if (how === 'recovery') notify(store, { userId: u.id, category: 'security', severity: 'warning', mandatory: true, title: '복구 코드로 로그인했습니다', body: `남은 복구 코드 ${left}개. 본인이 아니라면 비밀번호를 바꾸고 복구 코드를 다시 만드세요.`, link: '/account/security' });
+ return { ok: true, user: publicUser(u), next: '/', recoveryLeft: left };
+ });
+ app.post('/api/auth/logout', (ctx) => { if (ctx.session) revokeSession(store, ctx.session.id, ctx.user.id); ctx.setCookie(cookie(cookieName('customer'), '', { maxAge: 0, secure: secure(ctx) })); return { ok: true }; });
+
+ // ---------------------------------------------------------------- API: 비밀번호 재설정
+ app.post('/api/auth/request-reset', async (ctx) => {
+ const b = await ctx.body(); const email = normEmail(b.email); const u = EMAIL_RE.test(email) ? store.get('SELECT * FROM users WHERE email=? AND status=\'active\'', email) : null;
+ if (u) { const recent = store.get('SELECT created_at FROM email_tokens WHERE user_id=? AND kind=\'reset\' ORDER BY created_at DESC LIMIT 1', u.id); if (!recent || Date.now() - Date.parse(recent.created_at) > 60000) { const raw = makeToken(u.id, u.email, 'reset', 3600 * 1000); await deps.mailer.send({ to: u.email, subject: '[Ariweb] 비밀번호 재설정', text: `아래 링크에서 새 비밀번호를 정하세요(1시간 유효, 1회용).\n${deps.config.publicUrl}/reset-password/${raw}\n\n요청하지 않았다면 이 메일을 무시하세요.` }).catch((e) => deps.log(`[auth] 재설정 메일 실패: ${e.message}`)); audit(store, { actorType: 'user', actorId: u.id, action: 'auth.reset_requested', ip: ctx.ip }); } }
+ return { ok: true, message: t('auth.reset.requested') }; // 존재 여부와 무관하게 같은 응답
+ });
+ app.get('/api/auth/reset/:token', (ctx) => ({ ok: true, state: tokenState(ctx.params.token, 'reset').state }));
+ app.post('/api/auth/reset', async (ctx) => {
+ const b = await ctx.body(); const { state, row } = tokenState(b.token, 'reset'); if (state !== 'valid') throw new HttpError(400, 'token_' + state, state === 'used' ? t('auth.reset.used') : t('auth.reset.expired')); checkPassword(b.password);
+ const { salt, hash } = hashPassword(b.password); useToken(row);
+ store.run('UPDATE users SET pass_hash=?, pass_salt=?, session_gen=session_gen+1, failed_logins=0, lock_until=NULL, updated_at=? WHERE id=?', hash, salt, nowIso(), row.user_id);
+ revokeOtherSessions(store, row.user_id, null, 'customer'); store.run('DELETE FROM login_attempts WHERE key LIKE ?', row.email + '|%');
+ audit(store, { actorType: 'user', actorId: row.user_id, action: 'auth.password_reset', ip: ctx.ip }); notify(store, { userId: row.user_id, category: 'security', severity: 'warning', mandatory: true, title: '비밀번호가 재설정되었습니다', body: '본인이 아니라면 즉시 고객 지원에 문의하세요.', link: '/account/security' });
+ return { ok: true, message: t('auth.reset.done') };
+ });
+
+ // ---------------------------------------------------------------- API: 내 계정
+ app.patch('/api/account/profile', async (ctx) => { requireUser(ctx); const b = await ctx.body(); const name = String(b.displayName || '').trim(); if (!name || name.length > 80) throw new HttpError(400, 'bad_name', '표시 이름을 입력하세요(80자 이내)'); store.run('UPDATE users SET display_name=?, updated_at=? WHERE id=?', name, nowIso(), ctx.user.id); audit(store, { actorType: 'user', actorId: ctx.user.id, action: 'account.profile', ip: ctx.ip }); return { ok: true, message: t('auth.account.saved') }; });
+ app.post('/api/account/password', async (ctx) => {
+ requireUser(ctx); const b = await ctx.body(); if (!verifyPassword(String(b.current || ''), ctx.user.pass_salt, ctx.user.pass_hash)) throw new HttpError(400, 'wrong_password', t('auth.password.wrongCurrent')); checkPassword(b.next); if (String(b.current) === String(b.next)) throw new HttpError(400, 'same_password', t('auth.password.same'));
+ const { salt, hash } = hashPassword(b.next); store.run('UPDATE users SET pass_hash=?, pass_salt=?, session_gen=session_gen+1, updated_at=? WHERE id=?', hash, salt, nowIso(), ctx.user.id);
+ revokeOtherSessions(store, ctx.user.id, ctx.session.id, 'customer'); markReauth(store, ctx.session.id); audit(store, { actorType: 'user', actorId: ctx.user.id, action: 'account.password_changed', ip: ctx.ip });
+ return { ok: true, message: t('auth.reset.done') };
+ });
+ app.post('/api/account/reauth', async (ctx) => { requireUser(ctx); const b = await ctx.body(); if (!verifyPassword(String(b.password || ''), ctx.user.pass_salt, ctx.user.pass_hash)) throw new HttpError(400, 'wrong_password', t('auth.password.wrongCurrent')); markReauth(store, ctx.session.id); return { ok: true }; });
+ app.get('/api/account/sessions', (ctx) => { requireUser(ctx); return { ok: true, current: ctx.session.id, sessions: listSessions(store, ctx.user.id, 'customer') }; });
+ app.delete('/api/account/sessions/:id', (ctx) => { requireUser(ctx); const n = revokeSession(store, ctx.params.id, ctx.user.id); audit(store, { actorType: 'user', actorId: ctx.user.id, action: 'account.session_revoked', detail: { sessionId: ctx.params.id }, ip: ctx.ip }); return { ok: true, revoked: n }; });
+ app.post('/api/account/sessions/revoke-others', (ctx) => { requireUser(ctx); const n = revokeOtherSessions(store, ctx.user.id, ctx.session.id, 'customer'); audit(store, { actorType: 'user', actorId: ctx.user.id, action: 'account.sessions_revoked_others', detail: { count: n }, ip: ctx.ip }); return { ok: true, revoked: n }; });
+ // MFA (§3.3): setup → 비밀 임시 저장(pending) → enable(코드 확인) → 복구 코드 1회 표시
+ app.post('/api/account/mfa/setup', (ctx) => { requireUser(ctx); const mfa = parseJson(ctx.user.mfa_json, null); if (mfa && mfa.enabledAt) throw new HttpError(400, 'mfa_already', t('auth.mfa.enabled')); const secret = totp.generateSecret(); store.run('UPDATE users SET mfa_json=? WHERE id=?', toJson({ secret, pendingAt: nowIso() }), ctx.user.id); return { ok: true, secret: totp.prettySecret(secret), otpauth: totp.otpauthUri(secret, ctx.user.email) }; });
+ app.post('/api/account/mfa/enable', async (ctx) => {
+ requireUser(ctx); const b = await ctx.body(); const mfa = parseJson(ctx.user.mfa_json, null); if (!mfa || !mfa.secret || mfa.enabledAt) throw new HttpError(400, 'mfa_not_pending', '먼저 인증 앱 등록을 시작하세요');
+ const counter = totp.verifyCode(mfa.secret, b.code); if (counter == null) throw new HttpError(400, 'mfa_bad_code', t('auth.mfa.badCode'));
+ const codes = totp.generateRecoveryCodes(); store.run('UPDATE users SET mfa_json=?, recovery_json=?, updated_at=? WHERE id=?', toJson({ secret: mfa.secret, enabledAt: nowIso(), lastCounter: counter }), toJson(codes.map((c) => ({ hash: sha256(c), usedAt: null }))), nowIso(), ctx.user.id);
+ audit(store, { actorType: 'user', actorId: ctx.user.id, action: 'account.mfa_enabled', ip: ctx.ip }); return { ok: true, recoveryCodes: codes, message: t('auth.mfa.recoveryOnce') };
+ });
+ app.post('/api/account/mfa/disable', async (ctx) => {
+ requireUser(ctx); const b = await ctx.body(); const mfa = parseJson(ctx.user.mfa_json, null); if (!mfa || !mfa.enabledAt) throw new HttpError(400, 'mfa_off', t('auth.mfa.disabled'));
+ if (!verifyPassword(String(b.password || ''), ctx.user.pass_salt, ctx.user.pass_hash)) throw new HttpError(400, 'wrong_password', t('auth.password.wrongCurrent')); if (totp.verifyCode(mfa.secret, b.code, { lastCounter: mfa.lastCounter ?? null }) == null) throw new HttpError(400, 'mfa_bad_code', t('auth.mfa.badCode'));
+ store.run('UPDATE users SET mfa_json=NULL, recovery_json=NULL, updated_at=? WHERE id=?', nowIso(), ctx.user.id); audit(store, { actorType: 'user', actorId: ctx.user.id, action: 'account.mfa_disabled', ip: ctx.ip }); notify(store, { userId: ctx.user.id, category: 'security', severity: 'warning', mandatory: true, title: '다중 인증이 해제되었습니다', body: '본인이 아니라면 즉시 비밀번호를 바꾸세요.', link: '/account/security' }); return { ok: true };
+ });
+ app.post('/api/account/mfa/recovery-codes', async (ctx) => { requireUser(ctx); const b = await ctx.body(); const mfa = parseJson(ctx.user.mfa_json, null); if (!mfa || !mfa.enabledAt) throw new HttpError(400, 'mfa_off', t('auth.mfa.disabled')); if (!verifyPassword(String(b.password || ''), ctx.user.pass_salt, ctx.user.pass_hash)) throw new HttpError(400, 'wrong_password', t('auth.password.wrongCurrent')); const codes = totp.generateRecoveryCodes(); store.run('UPDATE users SET recovery_json=?, updated_at=? WHERE id=?', toJson(codes.map((c) => ({ hash: sha256(c), usedAt: null }))), nowIso(), ctx.user.id); audit(store, { actorType: 'user', actorId: ctx.user.id, action: 'account.recovery_regenerated', ip: ctx.ip }); return { ok: true, recoveryCodes: codes, message: t('auth.mfa.recoveryOnce') }; });
+}
diff --git a/portal/auth/model.mjs b/portal/auth/model.mjs
new file mode 100644
index 0000000..be2c5e5
--- /dev/null
+++ b/portal/auth/model.mjs
@@ -0,0 +1,10 @@
+/**
+ * portal/auth/model.mjs — 인증 영역 세부 표.
+ * login_attempts : 이메일+IP 별 실패 횟수와 잠금 해제 시각(§3.2 반복 실패 제한)
+ * consents 는 users.consents_json, MFA 는 users.mfa_json({ secret, enabledAt, lastCounter }), 복구 코드는 users.recovery_json([{hash, usedAt}]) 에 둔다.
+ * 이메일 확인·비밀번호 재설정·MFA 사전 인증 토큰은 core 의 email_tokens(kind: verify | reset | mfa_preauth) 를 쓴다.
+ */
+export const MIGRATIONS = [{ id: 'auth_0001', sql: `
+CREATE TABLE IF NOT EXISTS login_attempts (
+ key TEXT PRIMARY KEY, failures INTEGER NOT NULL DEFAULT 0, lock_until TEXT, updated_at TEXT NOT NULL
+);` }];
diff --git a/portal/auth/pages.mjs b/portal/auth/pages.mjs
new file mode 100644
index 0000000..ef8d416
--- /dev/null
+++ b/portal/auth/pages.mjs
@@ -0,0 +1,61 @@
+/**
+ * portal/auth/pages.mjs — 인증·계정 화면 HTML(서버 렌더). 데이터·동작은 /ui/auth/auth.js 가 /api/auth·/api/account 를 불러 처리한다.
+ * 로그인 전 화면(로그인·가입·확인·재설정)은 공통 틀의 비로그인 모드로, 로그인 뒤 화면(내 계정·보안·기기)은 왼쪽 메뉴가 있는 모드로 그린다.
+ */
+import { esc } from '../../lib/http.mjs';
+import { t } from '../../lib/i18n.mjs';
+
+const box = (title, inner, foot = '') => `${esc(title)}
${inner}${foot ? `
${foot}
` : ''}
`;
+const f = (id, label, type = 'text', extra = '', hint = '') => `${hint ? `${esc(hint)}` : ''}
`;
+
+export function loginPage(shell, { next = '/', notice = '' } = {}) {
+ return shell({ title: t('auth.login.title'), nav: null, scripts: ['/ui/auth/auth.js'], body: box(t('auth.login.title'), `
+${notice ? `${esc(notice)}
` : ''}
+
+`,
+ `${esc(t('auth.login.noAccount'))} ${esc(t('auth.signup.title'))} · ${esc(t('auth.login.forgot'))}
${esc(t('auth.login.operatorNote'))}`) });
+}
+export function signupPage(shell) {
+ return shell({ title: t('auth.signup.title'), nav: null, scripts: ['/ui/auth/auth.js'], body: box(t('auth.signup.title'), `
+
+`, `${esc(t('auth.login.title')) }`) });
+}
+export function verifyPage(shell, { state, user }) {
+ const msg = { ok: t('auth.verify.ok'), invalid: t('auth.verify.invalid'), used: t('auth.verify.used') }[state] || t('auth.verify.invalid');
+ return shell({ title: t('auth.verify.title'), nav: null, user, scripts: ['/ui/auth/auth.js'], body: box(t('auth.verify.title'), `${esc(msg)}
${state === 'ok' ? `${esc(t('nav.dashboard'))}` : ` ${esc(t('auth.login.title'))}`}`) });
+}
+export function resetRequestPage(shell) {
+ return shell({ title: t('auth.reset.title'), nav: null, scripts: ['/ui/auth/auth.js'], body: box(t('auth.reset.title'), ``, `${esc(t('auth.login.title'))}`) });
+}
+export function resetFormPage(shell, { token, state }) {
+ const body = state === 'valid' ? ``
+ : `${esc(t('auth.reset.state'))}: ${esc(state === 'used' ? t('auth.reset.used') : t('auth.reset.expired'))}
${esc(t('auth.reset.request'))}`;
+ return shell({ title: t('auth.reset.title'), nav: null, scripts: ['/ui/auth/auth.js'], body: box(t('auth.reset.title'), body) });
+}
+/** 내 계정(프로필·비밀번호·이메일 확인 상태) */
+export function accountPage(shell, o) {
+ const u = o.user;
+ return shell({ ...o, title: t('auth.account.title'), nav: null, crumbs: [{ label: t('auth.account.title') }], scripts: ['/ui/auth/auth.js'], body: `
+
+${!u.email_verified_at ? `${esc(t('auth.verify.banner'))}
` : ''}
+${esc(t('auth.account.profile'))}
- ${esc(t('auth.login.email'))}
- ${esc(u.email)} ${u.email_verified_at ? '✔ 확인됨' : '! 확인 전'}
+
+${esc(t('auth.account.changePassword'))}
비밀번호를 바꾸면 다른 기기의 로그인이 모두 종료됩니다.
` });
+}
+export function securityPage(shell, o) {
+ const mfaOn = !!o.mfaEnabled;
+ return shell({ ...o, title: t('auth.mfa.title'), nav: null, crumbs: [{ label: t('auth.account.title'), href: '/account' }, { label: t('auth.account.security') }], scripts: ['/ui/auth/auth.js'], body: `
+
+${esc(t('auth.mfa.title'))}
- ${esc(t('auth.mfa.level'))}
- ${mfaOn ? `✔ ${esc(t('auth.mfa.levelMfa'))}` : `! ${esc(t('auth.mfa.levelPw'))}`}
- ${esc(t('auth.mfa.title'))}
- ${esc(mfaOn ? t('auth.mfa.enabled') : t('auth.mfa.disabled'))}${o.mfaEnabledAt ? ` (${esc(o.mfaEnabledAt)})` : ''}
${mfaOn ? `- ${esc(t('auth.mfa.recovery'))}
- 남은 복구 코드 ${o.recoveryLeft}개
` : ''}
+
${mfaOn ? `
+
`
+ : `
`}
` });
+}
+export function sessionsPage(shell, o) {
+ return shell({ ...o, title: t('auth.sessions.title'), nav: null, crumbs: [{ label: t('auth.account.title'), href: '/account' }, { label: t('auth.sessions.title') }], scripts: ['/ui/auth/auth.js'], body: `
+
+${esc(t('auth.sessions.title'))}
${o.statePanel('loading')}
` });
+}
diff --git a/portal/auth/public/auth.js b/portal/auth/public/auth.js
new file mode 100644
index 0000000..940f973
--- /dev/null
+++ b/portal/auth/public/auth.js
@@ -0,0 +1,28 @@
+/* portal/auth/public/auth.js — 로그인·가입·확인·재설정·내 계정·보안·기기 화면 동작. 서버 응답 message 를 그대로 보이고 오류는 입력 아래에 표시한다. */
+(() => {
+ const $ = (s, r = document) => r.querySelector(s); const val = (form, n) => (form.elements[n] ? form.elements[n].value : '');
+ const showErr = (id, e) => { const el = $(id); if (el) el.textContent = e.message || String(e); };
+ const login = $('#login'); const mfa = $('#mfa'); let preauth = null;
+ if (login) login.onsubmit = async (e) => { e.preventDefault(); showErr('#login-err', ''); try { const r = await aw.api('POST', '/api/auth/login', { email: val(login, 'email'), password: val(login, 'password'), next: login.dataset.next }); if (r.mfaRequired) { preauth = r.preauth; login.hidden = true; mfa.hidden = false; mfa.elements.code.focus(); } else location.href = r.next || '/'; } catch (err) { showErr('#login-err', err); if (err.detail && err.detail.lockUntil) login.querySelector('button').disabled = true; } };
+ if (mfa) mfa.onsubmit = async (e) => { e.preventDefault(); showErr('#mfa-err', ''); try { const r = await aw.api('POST', '/api/auth/mfa', { preauth, code: val(mfa, 'code') }); if (r.recoveryLeft != null) alert(`복구 코드로 로그인했습니다. 남은 복구 코드 ${r.recoveryLeft}개`); location.href = r.next || '/'; } catch (err) { showErr('#mfa-err', err); if (/preauth_/.test(err.code)) { mfa.hidden = true; login.hidden = false; showErr('#login-err', err); } } };
+ const signup = $('#signup');
+ if (signup) signup.onsubmit = async (e) => { e.preventDefault(); showErr('#signup-err', ''); try { const r = await aw.api('POST', '/api/auth/signup', { email: val(signup, 'email'), displayName: val(signup, 'displayName'), password: val(signup, 'password'), consents: { terms: signup.elements.terms.checked, privacy: signup.elements.privacy.checked, marketing: signup.elements.marketing.checked } }); const d = $('#signup-done'); d.textContent = r.message; d.hidden = false; signup.hidden = true; setTimeout(() => { location.href = r.next || '/'; }, 1500); } catch (err) { showErr('#signup-err', err); } };
+ document.addEventListener('click', async (e) => { if (e.target.matches('[data-action=resend-verify]')) { try { const r = await aw.api('POST', '/api/auth/resend-verification'); aw.toast(r.message || '보냈습니다'); } catch (err) { aw.toast(err.message); } } });
+ const rr = $('#reset-request');
+ if (rr) rr.onsubmit = async (e) => { e.preventDefault(); try { const r = await aw.api('POST', '/api/auth/request-reset', { email: val(rr, 'email') }); const d = $('#rr-done'); d.textContent = r.message; d.hidden = false; rr.hidden = true; } catch (err) { showErr('#rr-err', err); } };
+ const reset = $('#reset');
+ if (reset) reset.onsubmit = async (e) => { e.preventDefault(); try { const r = await aw.api('POST', '/api/auth/reset', { token: reset.dataset.token, password: val(reset, 'password') }); const d = $('#reset-done'); d.textContent = r.message; d.hidden = false; reset.hidden = true; setTimeout(() => { location.href = '/login?notice=reset'; }, 1500); } catch (err) { showErr('#reset-err', err); } };
+ const profile = $('#profile');
+ if (profile) profile.onsubmit = async (e) => { e.preventDefault(); try { const r = await aw.api('PATCH', '/api/account/profile', { displayName: val(profile, 'displayName') }); aw.markClean(); aw.toast(r.message); } catch (err) { aw.toast(err.message); } };
+ const pw = $('#password');
+ if (pw) pw.onsubmit = async (e) => { e.preventDefault(); showErr('#pw-err', ''); try { const r = await aw.api('POST', '/api/account/password', { current: val(pw, 'current'), next: val(pw, 'next') }); pw.reset(); aw.toast(r.message); } catch (err) { showErr('#pw-err', err); } };
+ const codesHtml = (r) => `${aw.esc(r.message)}${r.recoveryCodes.map((c) => `- ${aw.esc(c)}
`).join('')}
`;
+ document.addEventListener('click', async (e) => { if (!e.target.matches('[data-action=mfa-setup]')) return; try { const r = await aw.api('POST', '/api/account/mfa/setup'); $('#mfa-setup').innerHTML = `인증 앱에서 아래 주소를 등록하거나 수동 입력 키를 넣으세요.
${aw.esc(r.otpauth)}
수동 입력 키: ${aw.esc(r.secret)}
`; $('#mfa-enable').onsubmit = async (ev) => { ev.preventDefault(); try { const rr2 = await aw.api('POST', '/api/account/mfa/enable', { code: $('#mcode').value }); $('#mfa-enable').hidden = true; $('#mfa-codes').innerHTML = codesHtml(rr2) + '완료'; } catch (err) { showErr('#mfae-err', err); } }; } catch (err) { aw.toast(err.message); } });
+ const md = $('#mfa-disable');
+ if (md) md.onsubmit = async (e) => { e.preventDefault(); try { await aw.api('POST', '/api/account/mfa/disable', { password: val(md, 'password'), code: val(md, 'code') }); location.reload(); } catch (err) { showErr('#mfad-err', err); } };
+ const mr = $('#mfa-regen');
+ if (mr) mr.onsubmit = async (e) => { e.preventDefault(); try { const r = await aw.api('POST', '/api/account/mfa/recovery-codes', { password: val(mr, 'password2') }); $('#recovery-out').innerHTML = codesHtml(r); mr.reset(); } catch (err) { aw.toast(err.message); } };
+ const sess = $('#sessions');
+ async function loadSessions() { try { const r = await aw.api('GET', '/api/account/sessions'); if (!r.sessions.length) return aw.state(sess, 'empty'); sess.innerHTML = `| 기기 | IP | 로그인 시각 | 마지막 사용 | |
${r.sessions.map((s) => `| ${aw.esc(s.label || '기기')}${s.id === r.current ? ' ● 현재 세션' : ''} | ${aw.esc(s.ip || '')} | ${aw.esc(aw.fmt(s.created_at))} | ${aw.esc(aw.fmt(s.last_seen_at))} | ${s.id === r.current ? '' : ``} |
`).join('')}
`; sess.querySelectorAll('[data-rev]').forEach((b) => { b.onclick = async () => { await aw.api('DELETE', '/api/account/sessions/' + b.dataset.rev); loadSessions(); }; }); } catch (err) { aw.state(sess, err.status === 403 ? 'forbidden' : 'failed', err.message, { retry: loadSessions }); } }
+ if (sess) { loadSessions(); document.addEventListener('click', async (e) => { if (e.target.matches('[data-action=revoke-others]')) { if (await aw.confirmDanger({ title: '다른 모든 세션 종료', target: '이 기기를 제외한 모든 로그인', impacts: ['다른 기기에서는 다시 로그인해야 합니다'] })) { await aw.api('POST', '/api/account/sessions/revoke-others'); loadSessions(); } } }); }
+})();
diff --git a/portal/auth/totp.mjs b/portal/auth/totp.mjs
new file mode 100644
index 0000000..0471e0b
--- /dev/null
+++ b/portal/auth/totp.mjs
@@ -0,0 +1,55 @@
+/**
+ * portal/auth/totp.mjs — TOTP(RFC 6238) 다중 인증 구현. 외부 의존성 없이 node:crypto 만 쓴다.
+ *
+ * - 비밀: 20바이트 무작위 → base32(RFC 4648, 패딩 없음). 인증 앱에 수동 입력할 수 있게 4자 단위로 끊어 보여 준다.
+ * - 코드: HMAC-SHA1(비밀, 30초 카운터) 동적 절단 → 6자리. 검증은 ±1 구간(시계 차이 허용)과 상수 시간 비교.
+ * - 재사용 방지: 마지막으로 성공한 카운터(lastCounter)를 저장해 같은 코드로 두 번 로그인하지 못하게 한다.
+ */
+import { randomBytes, createHmac, timingSafeEqual } from 'node:crypto';
+
+const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
+/** 바이트 → base32 문자열(패딩 없음) */
+export function base32Encode(buf) {
+ let bits = 0, value = 0, out = '';
+ for (const b of buf) { value = (value << 8) | b; bits += 8; while (bits >= 5) { out += ALPHABET[(value >>> (bits - 5)) & 31]; bits -= 5; } }
+ if (bits > 0) out += ALPHABET[(value << (5 - bits)) & 31];
+ return out;
+}
+/** base32 문자열 → 바이트(공백·소문자 허용) */
+export function base32Decode(str) {
+ const s = String(str || '').toUpperCase().replace(/[^A-Z2-7]/g, ''); let bits = 0, value = 0; const out = [];
+ for (const c of s) { value = (value << 5) | ALPHABET.indexOf(c); bits += 5; if (bits >= 8) { out.push((value >>> (bits - 8)) & 255); bits -= 8; } }
+ return Buffer.from(out);
+}
+/** 새 비밀(base32) */
+export const generateSecret = () => base32Encode(randomBytes(20));
+/** 수동 입력용 표시: 4자씩 띄어쓰기 */
+export const prettySecret = (secret) => String(secret).replace(/(.{4})/g, '$1 ').trim();
+/** 인증 앱 등록 URI(QR 코드 생성은 화면에서 이 문자열로) */
+export const otpauthUri = (secret, account, issuer = 'Ariweb Suite') => `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(account)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
+/** 카운터 값으로 코드 계산 */
+export function codeAt(secret, counter) {
+ const msg = Buffer.alloc(8); msg.writeBigUInt64BE(BigInt(counter));
+ const h = createHmac('sha1', base32Decode(secret)).update(msg).digest();
+ const off = h[h.length - 1] & 0xf; const n = ((h[off] & 0x7f) << 24) | (h[off + 1] << 16) | (h[off + 2] << 8) | h[off + 3];
+ return String(n % 1_000_000).padStart(6, '0');
+}
+export const counterAt = (ms = Date.now()) => Math.floor(ms / 1000 / 30);
+/** 현재 코드(테스트용) */
+export const currentCode = (secret, ms = Date.now()) => codeAt(secret, counterAt(ms));
+/**
+ * 코드 검증. 성공하면 맞은 카운터를 돌려주고(호출자가 lastCounter 로 저장), 실패하면 null.
+ * @param {number|null} lastCounter 마지막 성공 카운터 — 같거나 작은 카운터는 재사용으로 보고 거부
+ */
+export function verifyCode(secret, code, { ms = Date.now(), window = 1, lastCounter = null } = {}) {
+ const c = String(code || '').replace(/\s/g, ''); if (!/^\d{6}$/.test(c)) return null;
+ const base = counterAt(ms);
+ for (let d = -window; d <= window; d++) {
+ const k = base + d; if (lastCounter != null && k <= lastCounter) continue;
+ const a = Buffer.from(codeAt(secret, k)); const b = Buffer.from(c);
+ if (a.length === b.length && timingSafeEqual(a, b)) return k;
+ }
+ return null;
+}
+/** 복구 코드 10개(xxxx-xxxx 형태, 한 번만 표시) */
+export function generateRecoveryCodes(n = 10) { return Array.from({ length: n }, () => { const h = randomBytes(5).toString('hex'); return `${h.slice(0, 5)}-${h.slice(5)}`; }); }
diff --git a/portal/dashboard/api.mjs b/portal/dashboard/api.mjs
new file mode 100644
index 0000000..cfc8484
--- /dev/null
+++ b/portal/dashboard/api.mjs
@@ -0,0 +1,59 @@
+/**
+ * portal/dashboard/api.mjs — 고객 대시보드(§5): 요약·주의 항목·최근 활동.
+ *
+ * 모든 수치는 현재 그룹(ctx.group) 기준으로 중심 표(sites·domains·mail_services·mailboxes·backup_options·orders·jobs)와 lib/quota 에서 계산한다.
+ * 운영 사이트 수(§5.1): quota 'sites' 의 배정(사용 중)·예약(예약됨) 과 구매 총량. 초안·제작 중은 포함하지 않는다.
+ * 주의 항목(§5.2)은 중요도 순(필수·critical 알림 → 결제·승인 대기 → 도메인·인증서 → 사용량 → 배포 실패 → 메일 → 백업 → 이전 승인 → 정지·보존 만료 → 장애)이며 각 항목은 해결 화면으로 연결한다.
+ * 최근 활동은 감사 기록(audit_log)에서 가져오고 automatic 으로 사용자 작업과 자동 작업을 구분한다.
+ */
+import { requireGroup } from '../auth/guard.mjs';
+import { figures, warnLevel, summary as quotaSummary } from '../../lib/quota.mjs';
+import { listNotifications } from '../../lib/notify.mjs';
+import { listAudit } from '../../lib/audit.mjs';
+import { label } from '../../lib/status.mjs';
+import { t } from '../../lib/i18n.mjs';
+import { nowIso, parseJson } from '../../lib/db.mjs';
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const JOB_KIND_LABEL = { order: '주문', deploy: '배포', restore: '복원', relocation: '이전', backup: '백업', transfer: '소유권 이전', mail: '메일', domain: '도메인' };
+
+export async function register(app, deps) {
+ const { store } = deps;
+ app.static('/ui/dashboard', join(HERE, 'public'));
+ app.get('/', (ctx) => { if (!ctx.user) return ctx.redirect('/login'); if (!ctx.group) return ctx.html(deps.shell(ctx, { title: t('dashboard.title'), nav: 'dashboard', body: deps.statePanel('forbidden', t('error.groupRequired')) })); return ctx.html(deps.shell(ctx, { title: t('dashboard.title'), nav: 'dashboard', crumbs: [{ label: t('dashboard.title') }], actions: [{ label: '사이트 만들기', href: '/sites/new', kind: 'primary' }], scripts: ['/ui/dashboard/dashboard.js'], body: `${!ctx.user.email_verified_at ? `` : ''}${deps.statePanel('loading')}
` })); });
+
+ app.get('/api/dashboard', (ctx) => {
+ requireGroup(ctx); const gid = ctx.group.id; const now = nowIso();
+ const count = (sql, ...p) => store.get(sql, ...p)?.n || 0;
+ const sitesQ = figures(store, gid, 'sites');
+ const live = count('SELECT COUNT(*) AS n FROM sites WHERE group_id=? AND lifecycle IN (\'live\',\'needs_domain\',\'paused\') AND deleted_at IS NULL', gid);
+ const summaryCards = {
+ sites: { live, purchased: sitesQ.purchased, inUse: sitesQ.allocated, reserved: sitesQ.reserved, unallocated: sitesQ.unallocated },
+ drafts: count('SELECT COUNT(*) AS n FROM sites WHERE group_id=? AND lifecycle IN (\'draft\',\'building\',\'deploying\') AND deleted_at IS NULL', gid),
+ health: { ok: count('SELECT COUNT(*) AS n FROM sites WHERE group_id=? AND lifecycle=\'live\' AND health=\'ok\'', gid), warn: count('SELECT COUNT(*) AS n FROM sites WHERE group_id=? AND lifecycle=\'live\' AND health IN (\'degraded\',\'stale\',\'unknown\')', gid), bad: count('SELECT COUNT(*) AS n FROM sites WHERE group_id=? AND lifecycle=\'live\' AND health IN (\'partial_outage\',\'down\')', gid) },
+ deploy: { managed: count('SELECT COUNT(*) AS n FROM sites WHERE group_id=? AND deploy_mode=\'managed\' AND deleted_at IS NULL', gid), external: count('SELECT COUNT(*) AS n FROM sites WHERE group_id=? AND deploy_mode=\'external\' AND deleted_at IS NULL', gid) },
+ domains: { total: count('SELECT COUNT(*) AS n FROM domains WHERE group_id=?', gid), action: count('SELECT COUNT(*) AS n FROM domains WHERE group_id=? AND (ownership_state<>\'verified\' OR dns_state<>\'ok\' OR cert_state IN (\'failed\',\'expiring\',\'expired\'))', gid) },
+ mail: { services: count('SELECT COUNT(*) AS n FROM mail_services WHERE group_id=? AND deleted_at IS NULL', gid), mailboxes: count('SELECT COUNT(*) AS n FROM mailboxes WHERE group_id=? AND deleted_at IS NULL', gid) },
+ backup: count('SELECT COUNT(*) AS n FROM backup_options WHERE group_id=? AND status=\'active\'', gid),
+ jobs: Object.entries(deps.jobs.list({ groupId: gid, status: ['queued', 'running', 'awaiting_external', 'verifying', 'needs_user', 'awaiting_approval', 'awaiting_payment', 'reverting'], limit: 500 }).reduce((acc, j) => { const k = (j.kind || '').split('.')[0]; acc[k] = (acc[k] || 0) + 1; return acc; }, {})).map(([k, n]) => ({ kind: k, label: JOB_KIND_LABEL[k] || k, n })),
+ cost: { estimated: store.get('SELECT COALESCE(SUM(total),0) AS n FROM orders WHERE group_id=? AND status IN (\'approved\') AND strftime(\'%Y-%m\', approved_at)=strftime(\'%Y-%m\', ?)', gid, now)?.n || 0, awaiting: store.get('SELECT COALESCE(SUM(total),0) AS n FROM orders WHERE group_id=? AND status IN (\'awaiting_payment\',\'awaiting_approval\')', gid)?.n || 0, currency: 'KRW', note: '승인된 주문 합계 기준 추정값입니다' },
+ };
+ // 주의 항목(중요도 순)
+ const attention = [];
+ for (const n of listNotifications(store, { groupId: gid, limit: 50 }).filter((x) => x.state === 'open' && (x.mandatory || x.severity === 'critical'))) attention.push({ severity: 'critical', text: n.title, href: n.link || '/notifications', kind: 'notification' });
+ for (const o of store.all('SELECT id, order_no, status, total, due_at FROM orders WHERE group_id=? AND status IN (\'awaiting_payment\',\'awaiting_approval\') ORDER BY created_at DESC LIMIT 5', gid)) attention.push({ severity: 'warning', text: `주문 ${o.order_no} ${label('job', o.status)}${o.due_at ? ` (기한 ${o.due_at.slice(0, 10)})` : ''}`, href: `/billing/orders/${o.id}`, kind: 'order' });
+ for (const d of store.all('SELECT id, name, ownership_state, dns_state, cert_state FROM domains WHERE group_id=? AND (ownership_state<>\'verified\' OR dns_state<>\'ok\' OR cert_state IN (\'failed\',\'expiring\',\'expired\')) LIMIT 5', gid)) attention.push({ severity: d.cert_state === 'failed' ? 'critical' : 'warning', text: `도메인 ${d.name}: ${d.ownership_state !== 'verified' ? '소유 확인 필요' : d.dns_state !== 'ok' ? 'DNS 확인 필요' : '인증서 ' + d.cert_state}`, href: `/domains/${d.id}`, kind: 'domain' });
+ for (const q of quotaSummary(store, gid)) { if (q.purchased > 0) { const lvl = warnLevel(q.used, q.purchased); if (lvl >= 70) attention.push({ severity: lvl >= 95 ? 'critical' : 'warning', text: `${q.label} 사용량 ${lvl}% 구간${lvl >= 100 ? ' — 제한 적용 중' : ''}`, href: '/usage', kind: 'usage' }); } }
+ for (const j of deps.jobs.list({ groupId: gid, status: ['failed', 'needs_user'], limit: 10 })) attention.push({ severity: j.status === 'failed' ? 'critical' : 'warning', text: `${j.label || j.kind}: ${label('job', j.status)}${j.user_message ? ' — ' + j.user_message : ''}`, href: j.subject_type === 'site' ? `/sites/${j.subject_id}` : '/notifications', kind: 'job' });
+ for (const m of store.all('SELECT id, name, status FROM mail_services WHERE group_id=? AND status IN (\'failed\',\'needs_setup\',\'needs_user\') AND deleted_at IS NULL LIMIT 5', gid)) attention.push({ severity: 'warning', text: `메일 ${m.name}: ${label('job', m.status)}`, href: `/mail/${m.id}`, kind: 'mail' });
+ for (const b of store.all('SELECT b.id, s.name, b.consecutive_failures FROM backup_options b JOIN sites s ON s.id=b.site_id WHERE b.group_id=? AND b.consecutive_failures>0 LIMIT 5', gid)) attention.push({ severity: b.consecutive_failures >= 3 ? 'critical' : 'warning', text: `사이트 ${b.name} 백업 ${b.consecutive_failures}회 연속 실패`, href: `/backup`, kind: 'backup' });
+ for (const r of store.all('SELECT id, subject_id, status FROM relocations WHERE group_id=? AND status IN (\'awaiting_approval\',\'needs_user\') LIMIT 5', gid)) attention.push({ severity: 'warning', text: `서버 이전 ${label('job', r.status)}`, href: `/sites/${r.subject_id}`, kind: 'relocation' });
+ for (const s of store.all('SELECT id, name, lifecycle, retention_until, delete_after FROM sites WHERE group_id=? AND lifecycle IN (\'paused\',\'cancel_scheduled\',\'readonly_retention\',\'pending_delete\') LIMIT 5', gid)) attention.push({ severity: s.lifecycle === 'pending_delete' ? 'critical' : 'warning', text: `사이트 ${s.name}: ${label('service', s.lifecycle)}${s.delete_after ? ` (삭제 예정 ${s.delete_after.slice(0, 10)})` : ''}`, href: `/sites/${s.id}`, kind: 'lifecycle' });
+ for (const n of listNotifications(store, { groupId: gid, category: 'incident', limit: 5 }).filter((x) => x.state === 'open')) attention.push({ severity: 'warning', text: n.title, href: n.link || '/notifications', kind: 'incident' });
+ const order = { critical: 0, warning: 1, info: 2 }; attention.sort((a, b) => order[a.severity] - order[b.severity]);
+ const recent = listAudit(store, { groupId: gid, limit: 20 }).filter((a) => !/^auth\./.test(a.action)).map((a) => ({ at: a.at, action: a.action, actor: a.actor_label || a.actor_type, automatic: !!a.automatic || a.actor_type === 'system', outcome: a.outcome, subjectType: a.subject_type, subjectId: a.subject_id }));
+ return { ok: true, generatedAt: now, summary: summaryCards, attention, recent, verified: !!ctx.user.email_verified_at };
+ });
+}
diff --git a/portal/dashboard/public/dashboard.js b/portal/dashboard/public/dashboard.js
new file mode 100644
index 0000000..a76cb47
--- /dev/null
+++ b/portal/dashboard/public/dashboard.js
@@ -0,0 +1,27 @@
+/* portal/dashboard/public/dashboard.js — 대시보드 요약 카드·주의 항목·최근 활동 렌더(30초마다 갱신). */
+(() => {
+ const el = document.getElementById('dash'); if (!el) return;
+ const ACT = { 'deploy.start': '배포 시작', 'deploy.done': '배포 완료', 'order.created': '주문 제출', 'order.approve': '주문 승인', 'member.invited': '구성원 초대', 'member.joined': '구성원 참여', 'member.role': '역할 변경', 'group.settings': '그룹 설정 변경', 'permission.site': '사이트 권한 변경', 'ownership.group_transferred': '그룹 소유권 이전', 'member.suspended': '구성원 정지' };
+ const fmtKrw = (n) => `${Number(n || 0).toLocaleString('ko-KR')}원`;
+ async function load() {
+ try {
+ const d = await aw.api('GET', '/api/dashboard'); const s = d.summary;
+ const card = (title, stat, sub) => `${aw.esc(title)}
${stat}
${sub ? `
${sub}
` : ''}
`;
+ el.innerHTML = `
+${card('운영 사이트', `${s.sites.inUse}
/ 구매 ${s.sites.purchased}개`, `사용 중 ${s.sites.inUse} · 예약됨 ${s.sites.reserved} · 미배정 ${s.sites.unallocated}`)}
+${card('초안·제작 중', s.drafts, '운영 사이트 수를 사용하지 않습니다')}
+${card('사이트 상태', `
● 정상 ${s.health.ok} ! 경고 ${s.health.warn} ✘ 장애 ${s.health.bad}`)}
+${card('배포 방식', `관리형 ${s.deploy.managed} · 외부 ${s.deploy.external}`)}
+${card('도메인', `${s.domains.total}`, s.domains.action ? `
조치 필요 ${s.domains.action}개` : '조치 필요 없음')}
+${card('메일', `서비스 ${s.mail.services} · 메일함 ${s.mail.mailboxes}`)}
+${card('백업 옵션 사이트', s.backup)}
+${card('진행 중 작업', s.jobs.length ? s.jobs.map((j) => `${aw.esc(j.label)} ${j.n}`).join(' · ') : '0')}
+${card('이번 달 예상 비용', fmtKrw(s.cost.estimated), `결제 대기 ${fmtKrw(s.cost.awaiting)} · ${aw.esc(s.cost.note)}`)}
+
+
+최근 활동
${d.recent.length ? `
| 시각 | 내용 | 주체 | 결과 |
${d.recent.map((r) => `| ${aw.esc(aw.fmt(r.at))} | ${aw.esc(ACT[r.action] || r.action)}${r.subjectType ? ` ${aw.esc(r.subjectType)} ${aw.esc(r.subjectId || '')}` : ''} | ${r.automatic ? '⚙ 자동' : `👤 ${aw.esc(r.actor)}`} | ${r.outcome === 'ok' ? '✔ 성공' : `✘ ${aw.esc(r.outcome)}`} |
`).join('')}
` : '
최근 활동이 없습니다
'}
+마지막 갱신 ${aw.esc(aw.fmt(d.generatedAt))}
`;
+ } catch (err) { aw.state(el, err.status === 403 ? 'forbidden' : err.code === 'service_unreachable' ? 'stale' : 'failed', err.message, { retry: load }); }
+ }
+ load(); aw.poll(load, 30000);
+})();
diff --git a/portal/groups/api.mjs b/portal/groups/api.mjs
new file mode 100644
index 0000000..5b76f74
--- /dev/null
+++ b/portal/groups/api.mjs
@@ -0,0 +1,112 @@
+/**
+ * portal/groups/api.mjs — 사용자 그룹(§4): 전환·설정·구성원·초대·역할·정지·탈퇴·소유권 이전·사이트별 권한.
+ *
+ * 규칙
+ * - 그룹 전환은 세션 데이터(groupId)만 바꾼다. 모든 영역은 ctx.group 기준으로 조회하므로 다른 그룹 정보가 섞이지 않는다(§4.1).
+ * - 소유자(owner)는 그룹마다 한 명. 제거·정지·탈퇴 불가, 소유권 이전으로만 바뀐다. 이전 뒤 이전 소유자는 admin 이 된다.
+ * - 구성원 정지(§4.2): 그 사용자의 고객 세션과 이 그룹에서 그가 만든 미실행 작업만 취소한다. 영향 범위를 먼저 보여 주고(impact) 확인 뒤 적용한다.
+ * - 별칭 user-NN 은 표시용(§4.2). 권한·로그인 기준이 아니다.
+ * - 권한·소유권 변경은 감사 기록(immutable)을 남긴다(§4.3·§24.2).
+ */
+import { HttpError, esc } from '../../lib/http.mjs';
+import { randomToken, sha256, newId } from '../../lib/crypto.mjs';
+import { nowIso, parseJson, toJson } from '../../lib/db.mjs';
+import { audit } from '../../lib/audit.mjs';
+import { notify } from '../../lib/notify.mjs';
+import { revokeOtherSessions, updateSessionData } from '../../lib/session.mjs';
+import { t } from '../../lib/i18n.mjs';
+import { requireUser, requireGroup, requireRole, ROLES, SITE_PERMS } from '../auth/guard.mjs';
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+const INVITE_DAYS = 7;
+
+export async function register(app, deps) {
+ const { store } = deps;
+ app.static('/ui/groups', join(HERE, 'public'));
+ const pubMember = (m, u) => ({ userId: m.user_id, email: u?.email || '', displayName: u?.display_name || '', role: m.role, alias: m.alias, status: m.status, joinedAt: m.joined_at, suspendedAt: m.suspended_at });
+ const members = (gid) => store.all('SELECT m.*, u.email, u.display_name FROM memberships m JOIN users u ON u.id=m.user_id WHERE m.group_id=? ORDER BY m.joined_at', gid).map((r) => pubMember(r, r));
+ const nextAlias = (gid) => { const used = new Set(store.all('SELECT alias FROM memberships WHERE group_id=?', gid).map((r) => r.alias)); for (let i = 1; i < 1000; i++) { const a = `user-${String(i).padStart(2, '0')}`; if (!used.has(a)) return a; } return `user-${Date.now()}`; };
+ const groupView = (g) => ({ id: g.id, name: g.name, kind: g.kind, ownerUserId: g.owner_user_id, contactEmail: g.contact_email, contactPhone: g.contact_phone, billingUserId: g.billing_user_id, techUserId: g.tech_user_id, status: g.status, createdAt: g.created_at });
+ const act = (ctx, action, detail, subjectId = null, immutable = false) => audit(store, { actorType: 'user', actorId: ctx.user.id, actorLabel: ctx.user.email, groupId: ctx.group.id, subjectType: subjectId ? 'user' : 'group', subjectId: subjectId || ctx.group.id, action, detail, immutable, ip: ctx.ip });
+
+ // ---------------------------------------------------------------- 화면
+ app.get('/group', (ctx) => { requireGroup(ctx); return ctx.html(deps.shell(ctx, { title: t('groups.title'), nav: 'group', crumbs: [{ label: t('groups.title') }], scripts: ['/ui/groups/groups.js'], body: `${deps.statePanel('loading')}
` })); });
+ app.get('/invite/:token', (ctx) => {
+ const inv = store.get('SELECT i.*, g.name AS group_name FROM invites i JOIN groups g ON g.id=i.group_id WHERE i.token_hash=?', sha256(ctx.params.token));
+ const state = !inv ? 'invalid' : inv.accepted_at ? 'used' : inv.cancelled_at ? 'cancelled' : Date.parse(inv.expires_at) < Date.now() ? 'expired' : 'valid';
+ const body = state !== 'valid' ? `${esc(t('groups.inviteInvalid'))}
` : !ctx.user ? `${esc(t('groups.inviteNeedsLogin'))} (${esc(inv.email)})
${esc(t('auth.login.title'))} ${esc(t('auth.signup.title'))}`
+ : `${esc(inv.group_name)} 그룹의 ${esc(t('groups.roles.' + inv.role))} 역할로 초대받았습니다.
`;
+ return ctx.html(deps.shell(ctx, { title: '그룹 초대', nav: null, scripts: ['/ui/groups/groups.js'], body: `그룹 초대
${body}` }));
+ });
+
+ // ---------------------------------------------------------------- API
+ app.get('/api/groups', (ctx) => { requireUser(ctx); return { ok: true, current: ctx.group ? ctx.group.id : null, groups: store.all('SELECT g.*, m.role, m.alias FROM groups g JOIN memberships m ON m.group_id=g.id WHERE m.user_id=? AND m.status=\'active\' AND g.status=\'active\' ORDER BY g.kind DESC, g.created_at', ctx.user.id).map((g) => ({ ...groupView(g), role: g.role, alias: g.alias })) }; });
+ app.post('/api/groups/switch', async (ctx) => { requireUser(ctx); const b = await ctx.body(); const m = store.get('SELECT m.* FROM memberships m JOIN groups g ON g.id=m.group_id WHERE m.group_id=? AND m.user_id=? AND m.status=\'active\' AND g.status=\'active\'', b.groupId, ctx.user.id); if (!m) throw new HttpError(403, 'not_member', '이 그룹의 활성 구성원이 아닙니다'); updateSessionData(store, ctx.session.id, { groupId: b.groupId }); return { ok: true, groupId: b.groupId, message: t('groups.switched') }; });
+ app.post('/api/groups', async (ctx) => { requireUser(ctx, { verified: true }); const b = await ctx.body(); const name = String(b.name || '').trim(); if (!name || name.length > 80) throw new HttpError(400, 'bad_name', '그룹 이름을 입력하세요(80자 이내)'); const gid = newId('g_'); const now = nowIso(); store.run('INSERT INTO groups(id, name, kind, owner_user_id, contact_email, billing_user_id, tech_user_id, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?)', gid, name, 'team', ctx.user.id, ctx.user.email, ctx.user.id, ctx.user.id, now, now); store.run('INSERT INTO memberships(group_id, user_id, role, alias, joined_at) VALUES(?,?,?,?,?)', gid, ctx.user.id, 'owner', 'user-01', now); audit(store, { actorType: 'user', actorId: ctx.user.id, groupId: gid, subjectType: 'group', subjectId: gid, action: 'group.created', ip: ctx.ip }); updateSessionData(store, ctx.session.id, { groupId: gid }); return { ok: true, groupId: gid }; });
+ app.get('/api/group', (ctx) => { requireGroup(ctx); const g = ctx.group; const invites = ['owner', 'admin'].includes(ctx.membership.role) ? store.all('SELECT id, email, role, created_at, expires_at, accepted_at, cancelled_at, resent_at, resend_count FROM invites WHERE group_id=? AND accepted_at IS NULL AND cancelled_at IS NULL ORDER BY created_at DESC', g.id).map((i) => ({ ...i, expired: Date.parse(i.expires_at) < Date.now() })) : []; return { ok: true, group: groupView(g), me: { userId: ctx.user.id, role: ctx.membership.role, alias: ctx.membership.alias }, members: members(g.id), invites, roles: ROLES, sitePerms: SITE_PERMS }; });
+ app.patch('/api/group', async (ctx) => {
+ requireRole(ctx, ['owner', 'admin']); const b = await ctx.body(); const g = ctx.group; const patch = {};
+ if (b.name !== undefined) { const n = String(b.name).trim(); if (!n || n.length > 80) throw new HttpError(400, 'bad_name', '그룹 이름은 1~80자'); patch.name = n; }
+ if (b.contactEmail !== undefined) { if (b.contactEmail && !EMAIL_RE.test(b.contactEmail)) throw new HttpError(400, 'bad_email', '올바른 이메일'); patch.contact_email = b.contactEmail || null; }
+ if (b.contactPhone !== undefined) patch.contact_phone = String(b.contactPhone || '').slice(0, 40) || null;
+ for (const [k, col] of [['billingUserId', 'billing_user_id'], ['techUserId', 'tech_user_id']]) if (b[k] !== undefined) { if (b[k] && !store.get('SELECT 1 FROM memberships WHERE group_id=? AND user_id=? AND status=\'active\'', g.id, b[k])) throw new HttpError(400, 'not_member', '그룹 구성원만 담당자로 지정할 수 있습니다'); patch[col] = b[k] || null; }
+ if (!Object.keys(patch).length) return { ok: true };
+ store.run(`UPDATE groups SET ${Object.keys(patch).map((k) => k + '=?').join(', ')}, updated_at=? WHERE id=?`, ...Object.values(patch), nowIso(), g.id); act(ctx, 'group.settings', { changed: Object.keys(patch) }); return { ok: true, message: t('auth.account.saved') };
+ });
+ // 초대
+ app.post('/api/group/invites', async (ctx) => {
+ requireRole(ctx, ['owner', 'admin']); const b = await ctx.body(); const email = String(b.email || '').trim().toLowerCase(); const role = ROLES.includes(b.role) && b.role !== 'owner' ? b.role : 'member';
+ if (!EMAIL_RE.test(email)) throw new HttpError(400, 'bad_email', '올바른 이메일 주소를 입력하세요');
+ const ex = store.get('SELECT m.user_id FROM memberships m JOIN users u ON u.id=m.user_id WHERE m.group_id=? AND u.email=?', ctx.group.id, email); if (ex) throw new HttpError(409, 'already_member', '이미 그룹 구성원입니다');
+ const open = store.get('SELECT id FROM invites WHERE group_id=? AND email=? AND accepted_at IS NULL AND cancelled_at IS NULL AND expires_at>?', ctx.group.id, email, nowIso()); if (open) throw new HttpError(409, 'invite_pending', '이미 초대 대기 중입니다. 재발송을 이용하세요');
+ const raw = randomToken(24); const id = newId('i_'); store.run('INSERT INTO invites(id, group_id, email, role, token_hash, invited_by, created_at, expires_at) VALUES(?,?,?,?,?,?,?,?)', id, ctx.group.id, email, role, sha256(raw), ctx.user.id, nowIso(), new Date(Date.now() + INVITE_DAYS * 86400000).toISOString());
+ await sendInvite(email, raw, ctx.group, role); act(ctx, 'member.invited', { email, role }); return { ok: true, inviteId: id };
+ });
+ async function sendInvite(email, raw, group, role) { await deps.mailer.send({ to: email, subject: `[Ariweb] ${group.name} 그룹 초대`, text: `${group.name} 그룹에 ${t('groups.roles.' + role)} 역할로 초대되었습니다. 아래 링크에서 참여하세요(${INVITE_DAYS}일 유효).\n${deps.config.publicUrl}/invite/${raw}` }).catch((e) => deps.log(`[groups] 초대 메일 실패: ${e.message}`)); }
+ app.post('/api/group/invites/:id/resend', async (ctx) => { requireRole(ctx, ['owner', 'admin']); const inv = store.get('SELECT * FROM invites WHERE id=? AND group_id=? AND accepted_at IS NULL AND cancelled_at IS NULL', ctx.params.id, ctx.group.id); if (!inv) throw new HttpError(404, 'not_found', t('error.notFound')); if (inv.resent_at && Date.now() - Date.parse(inv.resent_at) < 60000) throw new HttpError(429, 'too_soon', '1분 뒤에 다시 보낼 수 있습니다'); const raw = randomToken(24); store.run('UPDATE invites SET token_hash=?, expires_at=?, resent_at=?, resend_count=resend_count+1 WHERE id=?', sha256(raw), new Date(Date.now() + INVITE_DAYS * 86400000).toISOString(), nowIso(), inv.id); await sendInvite(inv.email, raw, ctx.group, inv.role); act(ctx, 'member.invite_resent', { email: inv.email }); return { ok: true }; });
+ app.delete('/api/group/invites/:id', (ctx) => { requireRole(ctx, ['owner', 'admin']); const n = store.run('UPDATE invites SET cancelled_at=? WHERE id=? AND group_id=? AND accepted_at IS NULL AND cancelled_at IS NULL', nowIso(), ctx.params.id, ctx.group.id).changes; if (n) act(ctx, 'member.invite_cancelled', { inviteId: ctx.params.id }); return { ok: true, cancelled: n }; });
+ app.post('/api/invites/accept', async (ctx) => {
+ requireUser(ctx); const b = await ctx.body(); const inv = store.get('SELECT * FROM invites WHERE token_hash=?', sha256(String(b.token || '')));
+ if (!inv || inv.accepted_at || inv.cancelled_at || Date.parse(inv.expires_at) < Date.now()) throw new HttpError(400, 'invite_invalid', t('groups.inviteInvalid'));
+ if (inv.email !== ctx.user.email) throw new HttpError(403, 'invite_email_mismatch', `초대받은 이메일(${inv.email})로 로그인해야 합니다`);
+ const g = store.get('SELECT * FROM groups WHERE id=? AND status=\'active\'', inv.group_id); if (!g) throw new HttpError(400, 'invite_invalid', t('groups.inviteInvalid'));
+ store.tx(() => { if (!store.get('SELECT 1 FROM memberships WHERE group_id=? AND user_id=?', g.id, ctx.user.id)) store.run('INSERT INTO memberships(group_id, user_id, role, alias, joined_at) VALUES(?,?,?,?,?)', g.id, ctx.user.id, inv.role, nextAlias(g.id), nowIso()); store.run('UPDATE invites SET accepted_at=? WHERE id=?', nowIso(), inv.id); });
+ audit(store, { actorType: 'user', actorId: ctx.user.id, groupId: g.id, subjectType: 'user', subjectId: ctx.user.id, action: 'member.joined', detail: { role: inv.role }, ip: ctx.ip }); notify(store, { groupId: g.id, category: 'membership', title: `${ctx.user.display_name} 님이 그룹에 참여했습니다`, link: '/group' });
+ updateSessionData(store, ctx.session.id, { groupId: g.id }); return { ok: true, groupId: g.id, message: t('groups.inviteAccepted') };
+ });
+ // 구성원
+ const target = (ctx) => { const m = store.get('SELECT * FROM memberships WHERE group_id=? AND user_id=?', ctx.group.id, ctx.params.userId); if (!m) throw new HttpError(404, 'not_found', t('error.notFound')); return m; };
+ app.patch('/api/group/members/:userId', async (ctx) => {
+ requireGroup(ctx); const b = await ctx.body(); const m = target(ctx); const self = m.user_id === ctx.user.id;
+ if (b.alias !== undefined) { if (!self) requireRole(ctx, ['owner', 'admin']); const a = String(b.alias).trim(); if (!/^[a-zA-Z0-9가-힣._-]{2,24}$/.test(a)) throw new HttpError(400, 'bad_alias', '별칭은 2~24자(영문·숫자·한글·._-)'); if (store.get('SELECT 1 FROM memberships WHERE group_id=? AND alias=? AND user_id<>?', ctx.group.id, a, m.user_id)) throw new HttpError(409, 'alias_taken', '이미 쓰는 별칭입니다'); store.run('UPDATE memberships SET alias=? WHERE group_id=? AND user_id=?', a, ctx.group.id, m.user_id); act(ctx, 'member.alias', { alias: a }, m.user_id); }
+ if (b.role !== undefined) { requireRole(ctx, ['owner', 'admin']); if (m.role === 'owner' || b.role === 'owner') throw new HttpError(400, 'owner_role', t('groups.lastOwner') + ' 소유권 이전을 이용하세요'); if (!ROLES.includes(b.role)) throw new HttpError(400, 'bad_role', '알 수 없는 역할'); if (ctx.membership.role === 'admin' && b.role === 'admin' && self) throw new HttpError(400, 'bad_role', '자기 역할은 바꿀 수 없습니다'); store.run('UPDATE memberships SET role=? WHERE group_id=? AND user_id=?', b.role, ctx.group.id, m.user_id); act(ctx, 'member.role', { from: m.role, to: b.role }, m.user_id, true); }
+ return { ok: true };
+ });
+ /** 정지 영향 범위(§4.2): 세션 수·미실행 작업 수 */
+ const impactOf = (gid, uid) => ({ sessions: store.get('SELECT COUNT(*) AS n FROM sessions WHERE user_id=? AND kind=\'customer\' AND revoked_at IS NULL AND expires_at>?', uid, nowIso()).n, pendingJobs: deps.jobs.list({ groupId: gid, status: ['queued', 'awaiting_external', 'needs_user', 'paused'], limit: 1000 }).filter((j) => j.created_by === uid).map((j) => ({ id: j.id, kind: j.kind, label: j.label, status: j.status })), untouched: ['그룹의 상품·사이트·도메인·메일·백업', '다른 구성원의 작업과 세션', '이미 실행 중인 작업'] });
+ app.get('/api/group/members/:userId/impact', (ctx) => { requireRole(ctx, ['owner', 'admin']); const m = target(ctx); return { ok: true, impact: impactOf(ctx.group.id, m.user_id) }; });
+ app.post('/api/group/members/:userId/suspend', (ctx) => {
+ requireRole(ctx, ['owner', 'admin']); const m = target(ctx); if (m.role === 'owner') throw new HttpError(400, 'owner', t('groups.lastOwner')); if (m.user_id === ctx.user.id) throw new HttpError(400, 'self', '자기 자신은 정지할 수 없습니다'); if (m.status === 'suspended') return { ok: true, already: true };
+ const impact = impactOf(ctx.group.id, m.user_id);
+ store.run('UPDATE memberships SET status=\'suspended\', suspended_at=?, suspended_by=? WHERE group_id=? AND user_id=?', nowIso(), ctx.user.id, ctx.group.id, m.user_id);
+ // 다른 그룹 활동은 유지해야 하므로 세션은 "이 그룹을 보고 있는" 세션만 끊고 작업은 이 그룹에서 그가 만든 미실행 작업만 취소한다
+ const n = store.run('UPDATE sessions SET revoked_at=? WHERE user_id=? AND kind=\'customer\' AND revoked_at IS NULL AND data_json LIKE ?', nowIso(), m.user_id, `%"groupId":"${ctx.group.id}"%`).changes;
+ for (const j of impact.pendingJobs) { try { deps.jobs.cancel(j.id, ctx.user.id); } catch (_) {} }
+ act(ctx, 'member.suspended', { sessionsRevoked: n, jobsCancelled: impact.pendingJobs.map((j) => j.id) }, m.user_id, true); return { ok: true, impact, sessionsRevoked: n };
+ });
+ app.post('/api/group/members/:userId/unsuspend', (ctx) => { requireRole(ctx, ['owner', 'admin']); const m = target(ctx); store.run('UPDATE memberships SET status=\'active\', suspended_at=NULL, suspended_by=NULL WHERE group_id=? AND user_id=?', ctx.group.id, m.user_id); act(ctx, 'member.unsuspended', null, m.user_id, true); return { ok: true }; });
+ app.delete('/api/group/members/:userId', (ctx) => { requireRole(ctx, ['owner', 'admin']); const m = target(ctx); if (m.role === 'owner') throw new HttpError(400, 'owner', t('groups.lastOwner')); if (m.user_id === ctx.user.id) throw new HttpError(400, 'self', '자기 자신은 제거할 수 없습니다. 그룹 탈퇴를 이용하세요'); store.run('DELETE FROM memberships WHERE group_id=? AND user_id=?', ctx.group.id, m.user_id); store.run('DELETE FROM site_permissions WHERE user_id=? AND site_id IN (SELECT id FROM sites WHERE group_id=?)', m.user_id, ctx.group.id); store.run('UPDATE sessions SET revoked_at=? WHERE user_id=? AND kind=\'customer\' AND revoked_at IS NULL AND data_json LIKE ?', nowIso(), m.user_id, `%"groupId":"${ctx.group.id}"%`); act(ctx, 'member.removed', { role: m.role }, m.user_id, true); return { ok: true }; });
+ app.post('/api/group/leave', (ctx) => { requireGroup(ctx); if (ctx.membership.role === 'owner') throw new HttpError(400, 'owner_cannot_leave', t('groups.ownerCannotLeave')); if (ctx.group.kind === 'personal') throw new HttpError(400, 'personal', '개인 그룹은 탈퇴할 수 없습니다'); store.run('DELETE FROM memberships WHERE group_id=? AND user_id=?', ctx.group.id, ctx.user.id); act(ctx, 'member.left', null, ctx.user.id, true); updateSessionData(store, ctx.session.id, { groupId: null }); return { ok: true }; });
+ app.post('/api/group/transfer-ownership', async (ctx) => {
+ requireRole(ctx, ['owner']); const b = await ctx.body(); const m = store.get('SELECT * FROM memberships WHERE group_id=? AND user_id=? AND status=\'active\'', ctx.group.id, b.userId); if (!m) throw new HttpError(400, 'not_member', '활성 구성원에게만 이전할 수 있습니다'); if (m.user_id === ctx.user.id) throw new HttpError(400, 'self', '자기 자신에게는 이전할 수 없습니다');
+ store.tx(() => { store.run('UPDATE memberships SET role=\'admin\' WHERE group_id=? AND user_id=?', ctx.group.id, ctx.user.id); store.run('UPDATE memberships SET role=\'owner\' WHERE group_id=? AND user_id=?', ctx.group.id, m.user_id); store.run('UPDATE groups SET owner_user_id=?, updated_at=? WHERE id=?', m.user_id, nowIso(), ctx.group.id); });
+ act(ctx, 'ownership.group_transferred', { from: ctx.user.id, to: m.user_id }, m.user_id, true); notify(store, { groupId: ctx.group.id, category: 'membership', severity: 'warning', mandatory: true, title: '그룹 소유권이 이전되었습니다', body: `새 소유자: ${store.get('SELECT email FROM users WHERE id=?', m.user_id)?.email}`, link: '/group' }); return { ok: true };
+ });
+ // 사이트별 권한(§4.3)
+ const siteOf = (ctx) => { const s = store.get('SELECT id, group_id, name FROM sites WHERE id=? AND group_id=?', ctx.params.id, ctx.group.id); if (!s) throw new HttpError(404, 'not_found', t('error.notFound')); return s; };
+ app.get('/api/sites/:id/permissions', (ctx) => { requireRole(ctx, ['owner', 'admin']); const s = siteOf(ctx); return { ok: true, site: s, perms: SITE_PERMS, members: members(ctx.group.id).map((m) => ({ ...m, perms: ['owner', 'admin'].includes(m.role) ? ['admin'] : parseJson(store.get('SELECT perms_json FROM site_permissions WHERE site_id=? AND user_id=?', s.id, m.userId)?.perms_json, []), implicit: ['owner', 'admin'].includes(m.role) })) }; });
+ app.put('/api/sites/:id/permissions/:userId', async (ctx) => { requireRole(ctx, ['owner', 'admin']); const s = siteOf(ctx); const b = await ctx.body(); if (!store.get('SELECT 1 FROM memberships WHERE group_id=? AND user_id=?', ctx.group.id, ctx.params.userId)) throw new HttpError(400, 'not_member', '그룹 구성원이 아닙니다'); const perms = [...new Set([].concat(b.perms || []).filter((p) => SITE_PERMS.includes(p)))]; const before = parseJson(store.get('SELECT perms_json FROM site_permissions WHERE site_id=? AND user_id=?', s.id, ctx.params.userId)?.perms_json, []); if (perms.length) store.run('INSERT INTO site_permissions(site_id, user_id, perms_json, granted_by, updated_at) VALUES(?,?,?,?,?) ON CONFLICT(site_id, user_id) DO UPDATE SET perms_json=excluded.perms_json, granted_by=excluded.granted_by, updated_at=excluded.updated_at', s.id, ctx.params.userId, toJson(perms), ctx.user.id, nowIso()); else store.run('DELETE FROM site_permissions WHERE site_id=? AND user_id=?', s.id, ctx.params.userId); audit(store, { actorType: 'user', actorId: ctx.user.id, groupId: ctx.group.id, subjectType: 'site', subjectId: s.id, action: 'permission.site', detail: { userId: ctx.params.userId, before, after: perms }, immutable: true, ip: ctx.ip }); return { ok: true, perms }; });
+}
diff --git a/portal/groups/public/groups.js b/portal/groups/public/groups.js
new file mode 100644
index 0000000..e0fdd78
--- /dev/null
+++ b/portal/groups/public/groups.js
@@ -0,0 +1,38 @@
+/* portal/groups/public/groups.js — 사용자 그룹 설정 화면: 그룹 정보·담당자·구성원·초대·정지(영향 확인)·소유권 이전·탈퇴. */
+(() => {
+ const root = document.getElementById('group-root');
+ document.addEventListener('click', async (e) => { const b = e.target.closest('[data-action=accept-invite]'); if (!b) return; try { const r = await aw.api('POST', '/api/invites/accept', { token: b.dataset.token }); aw.toast(r.message); location.href = '/group'; } catch (err) { document.getElementById('inv-err').textContent = err.message; } });
+ if (!root) return;
+ const canAdmin = ['owner', 'admin'].includes(root.dataset.role); const me = root.dataset.me;
+ async function load() {
+ try {
+ const d = await aw.api('GET', '/api/group'); const g = d.group; const R = (r) => ({ owner: '소유자', admin: '관리자', billing: '결제 담당', member: '구성원', viewer: '조회 전용' })[r] || r;
+ const memberOpts = (sel) => d.members.filter((m) => m.status === 'active').map((m) => ``).join('');
+ root.innerHTML = `
+
+구성원 (${d.members.length})
${canAdmin ? '' : ''}
+
| 이름 | 이메일 | 별칭 | 역할 | 상태 | |
${d.members.map((m) => `| ${aw.esc(m.displayName)}${m.userId === me ? ' ● 나' : ''} | ${aw.esc(m.email)} | ${aw.esc(m.alias || '')} ✎ | ${canAdmin && m.role !== 'owner' && m.userId !== me ? `` : R(m.role)} | ${m.status === 'active' ? '● 활성' : '⏸ 정지'} | ${canAdmin && m.role !== 'owner' && m.userId !== me ? (m.status === 'active' ? ` ` : ` `) + `` : ''}${root.dataset.role === 'owner' && m.userId !== me && m.status === 'active' ? ` ` : ''} |
`).join('')}
+
별칭(user-01 …)은 화면 표시용이며 로그인과 권한의 기준이 아닙니다.
+${canAdmin ? `초대 대기 (${d.invites.length})
${d.invites.length ? `
| 이메일 | 역할 | 만료 | |
${d.invites.map((i) => `| ${aw.esc(i.email)} | ${R(i.role)} | ${i.expired ? '✘ 만료됨' : aw.esc(aw.fmt(i.expires_at))}${i.resend_count ? ` 재발송 ${i.resend_count}회` : ''} | |
`).join('')}
` : '
비어 있음
'}
` : ''}
+${g.kind !== 'personal' && root.dataset.role !== 'owner' ? '그룹 탈퇴
' : ''}`;
+ const gf = document.getElementById('gform'); gf.onsubmit = async (ev) => { ev.preventDefault(); const f = gf.elements; try { const r = await aw.api('PATCH', '/api/group', { name: f.name.value, contactEmail: f.contactEmail.value, contactPhone: f.contactPhone.value, billingUserId: f.billingUserId.value, techUserId: f.techUserId.value }); aw.markClean(); aw.toast(r.message || '저장했습니다'); } catch (err) { aw.toast(err.message); } };
+ root.querySelectorAll('select[data-act=role]').forEach((s) => { s.onchange = async () => { try { await aw.api('PATCH', '/api/group/members/' + s.dataset.uid, { role: s.value }); aw.toast('역할을 바꿨습니다'); } catch (err) { aw.toast(err.message); load(); } }; });
+ } catch (err) { aw.state(root, err.status === 403 ? 'forbidden' : 'failed', err.message, { retry: load }); }
+ }
+ root.addEventListener('click', async (e) => {
+ const b = e.target.closest('[data-act]'); if (!b || b.tagName === 'SELECT') return; const act = b.dataset.act; const uid = b.dataset.uid;
+ try {
+ if (act === 'invite') { const m = aw.modal('이메일로 초대
'); m.el.querySelector('#inv').onsubmit = async (ev) => { ev.preventDefault(); try { await aw.api('POST', '/api/group/invites', { email: m.el.querySelector('#iemail').value, role: m.el.querySelector('#irole').value }); m.close(); aw.toast('초대 메일을 보냈습니다'); load(); } catch (err) { m.el.querySelector('#inv-err').textContent = err.message; } }; }
+ else if (act === 'alias') { const v = prompt('새 별칭(2~24자)'); if (v) { await aw.api('PATCH', '/api/group/members/' + uid, { alias: v }); load(); } }
+ else if (act === 'suspend') { const r = await aw.api('GET', `/api/group/members/${uid}/impact`); const i = r.impact; if (await aw.confirmDanger({ title: '구성원 정지', target: b.closest('tr').children[1].textContent, impacts: [`이 구성원의 로그인 세션 ${i.sessions}개가 종료됩니다`, `아직 실행되지 않은 작업 ${i.pendingJobs.length}개가 취소됩니다${i.pendingJobs.length ? ': ' + i.pendingJobs.map((j) => j.label).join(', ') : ''}`, ...i.untouched.map((u) => `유지됨: ${u}`)], confirmLabel: '정지' })) { await aw.api('POST', `/api/group/members/${uid}/suspend`); load(); } }
+ else if (act === 'unsuspend') { await aw.api('POST', `/api/group/members/${uid}/unsuspend`); load(); }
+ else if (act === 'remove') { if (await aw.confirmDanger({ title: '구성원 제거', target: b.closest('tr').children[1].textContent, impacts: ['이 그룹의 사이트 권한이 모두 제거됩니다', '이 그룹을 보던 로그인 세션이 종료됩니다', '그룹의 상품·사이트·데이터는 유지됩니다'], confirmLabel: '제거' })) { await aw.api('DELETE', '/api/group/members/' + uid); load(); } }
+ else if (act === 'transfer') { if (await aw.confirmDanger({ title: '그룹 소유권 이전', target: b.closest('tr').children[1].textContent, impacts: ['새 소유자가 결제·해지·구성원 관리의 최종 권한을 갖습니다', '현재 소유자는 관리자 역할이 됩니다', '되돌리려면 새 소유자가 다시 이전해야 합니다'], typeToConfirm: '이전', confirmLabel: '소유권 이전' })) { await aw.api('POST', '/api/group/transfer-ownership', { userId: uid }); location.reload(); } }
+ else if (act === 'leave') { if (await aw.confirmDanger({ title: '그룹 탈퇴', target: '현재 그룹', impacts: ['이 그룹의 사이트·메일·결제 화면에 더 이상 접근할 수 없습니다'], confirmLabel: '탈퇴' })) { await aw.api('POST', '/api/group/leave'); location.href = '/'; } }
+ else if (act === 'resend') { await aw.api('POST', `/api/group/invites/${b.dataset.id}/resend`); aw.toast('다시 보냈습니다'); load(); }
+ else if (act === 'cancel-invite') { await aw.api('DELETE', `/api/group/invites/${b.dataset.id}`); load(); }
+ } catch (err) { aw.toast(err.message); }
+ });
+ load();
+})();
diff --git a/portal/help/api.mjs b/portal/help/api.mjs
new file mode 100644
index 0000000..889ba2a
--- /dev/null
+++ b/portal/help/api.mjs
@@ -0,0 +1,14 @@
+/**
+ * portal/help/api.mjs — 도움말: 공통 상태 어휘(§2.2)·작업 상태 설명·약관 안내 자리(승인된 문구만 표시 — 미확정은 '확정 전').
+ */
+import { JOB_LABEL, SERVICE_LABEL, HEALTH_LABEL, DEPLOY_VERIFY_LABEL, ALLOC_LABEL } from '../../lib/status.mjs';
+import { t } from '../../lib/i18n.mjs';
+import { esc } from '../../lib/http.mjs';
+const DESC = { not_ready: '선행 단계가 끝나지 않아 아직 시작할 수 없습니다', needs_setup: '사용자의 입력이나 설정이 필요합니다', awaiting_approval: '운영자 승인을 기다립니다', awaiting_payment: '결제 확인을 기다립니다', queued: '순서를 기다립니다', running: '지금 처리 중입니다', awaiting_external: 'DNS 반영·외부 서비스 응답 등 바깥 작업을 기다립니다', verifying: '실제 결과를 외부에서 확인하는 중입니다', paused: '사용자나 운영자가 잠시 멈췄습니다', done: '실제 결과를 확인하고 끝났습니다', failed: '실패했습니다. 실패 단계와 재시도·되돌리기 방법을 표시합니다', reverting: '이전 상태로 되돌리는 중입니다', reverted: '이전 상태로 되돌렸습니다', needs_user: '사용자의 선택이나 확인이 있어야 계속됩니다', cancelled: '취소되었습니다' };
+export async function register(app, deps) {
+ app.get('/help', (ctx) => ctx.html(deps.shell(ctx, { title: t('help.title'), nav: null, body: `
+작업 상태 용어
사이트·배포·도메인·인증서·메일·백업·복원·이전 작업은 모두 같은 상태 용어를 씁니다. 서비스 상태, 작업 진행 상태, 외부 건강 상태는 서로 다른 값이며 같은 화면에서 구분해 표시합니다.
| 상태 | 뜻 |
${Object.entries(JOB_LABEL).map(([k, v]) => `| ${esc(v)} | ${esc(DESC[k] || '')} |
`).join('')}
+서비스 상태
${Object.values(SERVICE_LABEL).map(esc).join(' · ')}
외부 건강 상태
${Object.values(HEALTH_LABEL).map(esc).join(' · ')}
배포 완료 구분
${Object.values(DEPLOY_VERIFY_LABEL).map(esc).join(' · ')}
배정 상태
${Object.values(ALLOC_LABEL).map(esc).join(' · ')}
+약관과 정책
가격·SLA·환불·개인정보·DPA·백업·AUP·정지 및 해지 정책은 운영자가 승인한 문구만 표시합니다. 아직 확정되지 않은 정책은 확정 전 으로 표시되며 확정된 약속처럼 안내하지 않습니다.
현재 버전(0.1.1)의 약관·개인정보 처리방침 본문은 운영자 콘솔 › 상품과 정책에서 게시하면 이 자리에 표시됩니다.
+문의
작업 번호(j_…)와 함께 문의하면 빠르게 확인할 수 있습니다. 비밀번호·인증 키는 절대 문의 내용에 적지 마세요.
` })));
+}
diff --git a/portal/mailer.mjs b/portal/mailer.mjs
new file mode 100644
index 0000000..68d04eb
--- /dev/null
+++ b/portal/mailer.mjs
@@ -0,0 +1,53 @@
+/**
+ * portal/mailer.mjs — 포털 발신 메일(이메일 확인·비밀번호 재설정·초대·필수 알림).
+ *
+ * 발송 방식은 설정(deps.config.values.mailer: 'smtp' | 'resend' | 'log')과 보관소의 비밀로 정한다.
+ * smtp : AriMail 제출 포트(기본 127.0.0.1:587 STARTTLS 또는 465)에 로그인해 보낸다. 계정 비밀번호는 vault 'mailer:smtp-pass'.
+ * resend : Resend 호환 HTTP API(https://api.resend.com/emails). 키는 vault 'mailer:resend-key'.
+ * log : 보내지 않고 로그에만(개발·검증 환경 기본). 테스트는 deps.mailer 를 포획 객체로 바꿔 끼운다.
+ * 어떤 방식이든 본문에 비밀번호·토큰 원문 외의 비밀값을 넣지 않으며, 링크의 토큰은 1회용이다.
+ * 인터페이스: deps.mailer.send({ to, subject, text, html? }) → Promise<{ ok, id?, mode }>
+ */
+import net from 'node:net';
+import tls from 'node:tls';
+
+export function create(deps) {
+ const cfg = deps.config; const log = deps.log || console.log;
+ const mode = () => (cfg.values && cfg.values.mailer) || process.env.ARIWEB_MAILER || 'log';
+ async function send({ to, subject, text, html }) {
+ const m = mode(); const from = cfg.mailFrom || 'no-reply@ariweb.net';
+ if (!to || !subject) throw new Error('받는 사람과 제목이 필요합니다');
+ if (m === 'resend') {
+ const key = deps.vault.get('mailer:resend-key'); if (!key) throw new Error('메일 발송 키(mailer:resend-key)가 보관소에 없습니다');
+ const r = await fetch((cfg.values.resendBase || 'https://api.resend.com') + '/emails', { method: 'POST', headers: { Authorization: 'Bearer ' + key, 'Content-Type': 'application/json' }, body: JSON.stringify({ from, to: [].concat(to), subject, text, html }), signal: AbortSignal.timeout(20000) });
+ const j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(`메일 발송 거부(${r.status}): ${j.message || j.name || ''}`);
+ return { ok: true, id: j.id, mode: m };
+ }
+ if (m === 'smtp') return smtpSend({ host: cfg.values.smtpHost || '127.0.0.1', port: Number(cfg.values.smtpPort || 587), user: cfg.values.smtpUser || from, pass: deps.vault.get('mailer:smtp-pass') || '', secure: String(cfg.values.smtpPort) === '465', from, to: [].concat(to), subject, text, html });
+ log(`[mailer:log] → ${[].concat(to).join(',')} | ${subject}`);
+ return { ok: true, mode: 'log' };
+ }
+ return { send, mode };
+}
+/** 작은 SMTP 클라이언트(AUTH PLAIN·STARTTLS). 외부 의존성 없이 제출 서버로 한 통 보낸다. */
+function smtpSend({ host, port, user, pass, secure, from, to, subject, text, html }) {
+ return new Promise((resolve, reject) => {
+ let sock = secure ? tls.connect({ host, port, servername: host }) : net.connect({ host, port }); let buf = ''; const q = [];
+ const fail = (e) => { try { sock.destroy(); } catch (_) {} reject(e instanceof Error ? e : new Error(String(e))); };
+ const wait = () => new Promise((res, rej) => q.push({ res, rej }));
+ const cmd = async (line, okCodes = [250]) => { if (line != null) sock.write(line + '\r\n'); const r = await wait(); if (!okCodes.includes(Number(r.slice(0, 3)))) throw new Error(`SMTP ${line ? line.split(' ')[0] : '연결'} 실패: ${r.trim().slice(0, 120)}`); return r; };
+ const onData = (d) => { buf += d.toString('utf8'); const lines = buf.split('\r\n'); buf = lines.pop(); let resp = ''; for (const l of lines) { resp += l + '\r\n'; if (/^\d{3} /.test(l)) { const w = q.shift(); if (w) w.res(resp); resp = ''; } } };
+ sock.on('data', onData); sock.on('error', fail);
+ (async () => {
+ await cmd(null, [220]); await cmd('EHLO ariweb-portal');
+ if (!secure) { await cmd('STARTTLS', [220]); sock.removeListener('data', onData); sock = tls.connect({ socket: sock, servername: host }); sock.on('data', onData); sock.on('error', fail); await new Promise((r) => sock.once('secureConnect', r)); await cmd('EHLO ariweb-portal'); }
+ if (pass) await cmd('AUTH PLAIN ' + Buffer.from(`\0${user}\0${pass}`).toString('base64'), [235]);
+ await cmd(`MAIL FROM:<${from}>`); for (const t of to) await cmd(`RCPT TO:<${t}>`, [250, 251]); await cmd('DATA', [354]);
+ const boundary = 'aw' + Date.now().toString(36); const enc = (s) => Buffer.from(s, 'utf8').toString('base64').replace(/(.{76})/g, '$1\r\n');
+ const headers = [`From: ${from}`, `To: ${to.join(', ')}`, `Subject: =?UTF-8?B?${Buffer.from(subject, 'utf8').toString('base64')}?=`, `Date: ${new Date().toUTCString()}`, `Message-ID: <${Date.now()}.${Math.random().toString(36).slice(2)}@ariweb>`, 'MIME-Version: 1.0'];
+ const body = html ? [`Content-Type: multipart/alternative; boundary="${boundary}"`, '', `--${boundary}`, 'Content-Type: text/plain; charset=utf-8', 'Content-Transfer-Encoding: base64', '', enc(text || ''), `--${boundary}`, 'Content-Type: text/html; charset=utf-8', 'Content-Transfer-Encoding: base64', '', enc(html), `--${boundary}--`].join('\r\n') : ['Content-Type: text/plain; charset=utf-8', 'Content-Transfer-Encoding: base64', '', enc(text || '')].join('\r\n');
+ const r = await cmd(headers.join('\r\n') + '\r\n' + body + '\r\n.', [250]); sock.write('QUIT\r\n'); sock.end();
+ resolve({ ok: true, id: (r.match(/queued as (\S+)/i) || [])[1], mode: 'smtp' });
+ })().catch(fail);
+ });
+}
diff --git a/portal/notifications/api.mjs b/portal/notifications/api.mjs
new file mode 100644
index 0000000..c98b165
--- /dev/null
+++ b/portal/notifications/api.mjs
@@ -0,0 +1,19 @@
+/**
+ * portal/notifications/api.mjs — 알림 센터 화면·API(§24.1). 현재 그룹의 알림(그룹 알림 + 나에게 온 개인 알림)만 보인다.
+ * 분류 필터·읽지 않음 필터·읽음 처리·사건 반복 횟수·필수 표시·조치 화면 링크.
+ */
+import { requireGroup } from '../auth/guard.mjs';
+import { listNotifications, markRead, unreadCount } from '../../lib/notify.mjs';
+import { t } from '../../lib/i18n.mjs';
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+const HERE = dirname(fileURLToPath(import.meta.url));
+export const CATEGORIES = ['billing', 'site', 'domain', 'mail', 'usage', 'backup', 'relocation', 'security', 'incident', 'membership'];
+export async function register(app, deps) {
+ const { store } = deps;
+ app.static('/ui/notifications', join(HERE, 'public'));
+ app.get('/notifications', (ctx) => { requireGroup(ctx); return ctx.html(deps.shell(ctx, { title: t('notifications.title'), nav: null, crumbs: [{ label: t('notifications.title') }], scripts: ['/ui/notifications/notifications.js'], body: `${deps.statePanel('loading')}
` })); });
+ app.get('/api/notifications', (ctx) => { requireGroup(ctx); const list = listNotifications(store, { groupId: ctx.group.id, userId: ctx.user.id, category: ctx.query.category || undefined, unreadOnly: ctx.query.unread === '1', limit: Number(ctx.query.limit || 100) }); return { ok: true, unread: unreadCount(store, ctx.group.id), items: list.map((n) => ({ id: n.id, category: n.category, categoryLabel: t('notifications.categories.' + n.category), severity: n.severity, mandatory: !!n.mandatory, title: n.title, body: n.body, link: n.link, actionRequired: n.action_required, state: n.state, count: n.count, firstAt: n.first_at, lastAt: n.last_at, readAt: n.read_at, emailSentAt: n.email_sent_at })) }; });
+ app.post('/api/notifications/:id/read', (ctx) => { requireGroup(ctx); return { ok: true, changed: markRead(store, ctx.params.id, ctx.group.id) }; });
+ app.post('/api/notifications/read-all', (ctx) => { requireGroup(ctx); const n = store.run('UPDATE notifications SET read_at=? WHERE (group_id=? OR (group_id IS NULL AND user_id=?)) AND read_at IS NULL', new Date().toISOString(), ctx.group.id, ctx.user.id).changes; return { ok: true, changed: n }; });
+}
diff --git a/portal/notifications/public/notifications.js b/portal/notifications/public/notifications.js
new file mode 100644
index 0000000..4c90ecf
--- /dev/null
+++ b/portal/notifications/public/notifications.js
@@ -0,0 +1,13 @@
+/* portal/notifications/public/notifications.js — 알림 센터: 분류·읽지 않음 필터, 읽음 처리, 사건 반복 횟수, 조치 링크. */
+(() => {
+ const list = document.getElementById('nlist'); const tabs = document.getElementById('ncats'); if (!list) return; let cat = ''; let unread = false;
+ const SEV = { critical: 'failed', warning: 'warning', info: 'info' };
+ async function load() {
+ try { const d = await aw.api('GET', `/api/notifications?category=${encodeURIComponent(cat)}&unread=${unread ? 1 : 0}`); if (!d.items.length) return aw.state(list, 'empty');
+ list.innerHTML = `` + d.items.map((n) => `${n.severity === 'critical' ? '⚠' : n.severity === 'warning' ? '!' : 'ⓘ'} ${aw.esc(n.categoryLabel)} ${n.mandatory ? '필수' : ''} ${n.count > 1 ? `${n.count}회 반복` : ''} ${n.state === 'resolved' ? '✔ 해결됨' : ''}
${aw.esc(aw.fmt(n.lastAt))}${aw.esc(n.title)}
${n.body ? `
${aw.esc(n.body)}
` : ''}${n.actionRequired ? `
필요한 조치: ${aw.esc(n.actionRequired)}
` : ''}
${n.link ? `
조치 화면으로` : ''}${!n.readAt ? `
` : ''}
`).join('');
+ list.querySelectorAll('[data-read]').forEach((b) => { b.onclick = async () => { await aw.api('POST', `/api/notifications/${b.dataset.read}/read`); load(); }; }); document.getElementById('read-all').onclick = async () => { await aw.api('POST', '/api/notifications/read-all'); load(); };
+ } catch (err) { aw.state(list, err.status === 403 ? 'forbidden' : 'failed', err.message, { retry: load }); }
+ }
+ tabs.addEventListener('click', (e) => { const a = e.target.closest('a'); if (!a) return; e.preventDefault(); tabs.querySelectorAll('a').forEach((x) => x.classList.remove('on')); a.classList.add('on'); if (a.dataset.unread) { unread = true; cat = ''; } else { unread = false; cat = a.dataset.cat || ''; } load(); });
+ load();
+})();
diff --git a/portal/scheduler.mjs b/portal/scheduler.mjs
new file mode 100644
index 0000000..d88acad
--- /dev/null
+++ b/portal/scheduler.mjs
@@ -0,0 +1,38 @@
+/**
+ * portal/scheduler.mjs — 포털 주기 작업 등록부.
+ *
+ * 각 영역은 register() 안에서 deps.scheduler.every(name, ms, fn) 으로 자기 주기 작업을 등록한다(인증 영역이 가장 먼저 create 로 등록부를 만든다).
+ * start(deps) 는 index.mjs 가 앱 조립 뒤 한 번 부른다. 작업은 겹쳐 실행되지 않고(이전 실행이 끝나야 다음), 예외는 기록만 하고 다음 주기에 다시 돈다.
+ * 기본 작업(인증 영역): 세션 정리, 이메일 토큰 정리, 로그인 잠금 정리, 만료 예약 자동 반환(§11.2 — 반환 알림 포함).
+ */
+import { purgeSessions } from '../lib/session.mjs';
+import { expireReservations, RESOURCES } from '../lib/quota.mjs';
+import { notify } from '../lib/notify.mjs';
+import { nowIso } from '../lib/db.mjs';
+import { safeError } from '../lib/redact.mjs';
+
+export function create(deps) {
+ if (deps.scheduler) return deps.scheduler;
+ const tasks = new Map(); const timers = []; const log = deps.log || console.log;
+ const sched = {
+ /** 주기 작업 등록. fn 은 async 가능. 같은 이름은 덮어쓴다. */
+ every(name, ms, fn) { tasks.set(name, { ms, fn, running: false, lastRunAt: null, lastError: null, runs: 0 }); return sched; },
+ /** 즉시 한 번 실행(테스트·운영자 '지금 실행') */
+ async runNow(name) { const t = tasks.get(name); if (!t) throw new Error(`주기 작업 없음: ${name}`); if (t.running) return { skipped: true }; t.running = true; try { const r = await t.fn(); t.lastRunAt = nowIso(); t.lastError = null; t.runs++; return { ok: true, result: r }; } catch (e) { t.lastError = safeError(e); log(`[scheduler] ${name} 실패: ${t.lastError}`); return { ok: false, error: t.lastError }; } finally { t.running = false; } },
+ list: () => [...tasks.entries()].map(([name, t]) => ({ name, ms: t.ms, lastRunAt: t.lastRunAt, lastError: t.lastError, runs: t.runs })),
+ start() { for (const [name, t] of tasks) { const id = setInterval(() => sched.runNow(name), t.ms); if (id.unref) id.unref(); timers.push(id); } return sched; },
+ stop() { for (const id of timers) clearInterval(id); timers.length = 0; },
+ };
+ deps.scheduler = sched;
+ // 기본 작업
+ sched.every('sessions.purge', 6 * 3600 * 1000, () => purgeSessions(deps.store, 7));
+ sched.every('tokens.purge', 3600 * 1000, () => deps.store.run('DELETE FROM email_tokens WHERE expires_at OR (used_at IS NOT NULL AND used_at)', new Date(Date.now() - 3 * 86400000).toISOString(), new Date(Date.now() - 3 * 86400000).toISOString()).changes);
+ sched.every('login.unlock', 10 * 60 * 1000, () => deps.store.run('DELETE FROM login_attempts WHERE updated_at', new Date(Date.now() - 24 * 3600000).toISOString()).changes);
+ sched.every('quota.expire', 60 * 1000, () => {
+ const rows = expireReservations(deps.store);
+ for (const r of rows) notify(deps.store, { groupId: r.group_id, category: 'usage', severity: 'info', incidentKey: `reservation:${r.id}`, title: `${RESOURCES[r.resource]?.label || r.resource} 예약이 반환되었습니다`, body: '정리되지 않은 작업의 예약 기한이 지나 자동으로 반환했습니다. 필요하면 작업을 다시 시작하세요.', link: '/usage', subjectType: r.subject_type, subjectId: r.subject_id });
+ return rows.length;
+ });
+ return sched;
+}
+export function start(deps) { return create(deps).start(); }
diff --git a/test/auth.test.mjs b/test/auth.test.mjs
new file mode 100644
index 0000000..3d536f4
--- /dev/null
+++ b/test/auth.test.mjs
@@ -0,0 +1,100 @@
+/** test/auth.test.mjs — 가입·확인·로그인·잠금·세션·재설정·MFA·CSRF 회귀 테스트(실제 쿠키 흐름). */
+import { test, eq, ok, done } from '../lib/test.mjs';
+import { makeDeps, appFor, call } from './helpers.mjs';
+import { invoke } from '../lib/http.mjs';
+import * as totp from '../portal/auth/totp.mjs';
+
+const mails = []; const mailer = { send: async (m) => { mails.push(m); return { ok: true, mode: 'test' }; }, mode: () => 'test' };
+const deps = await makeDeps({ mailer });
+const app = await appFor(deps, ['auth', 'groups', 'dashboard', 'notifications', 'auditui', 'help']);
+const AREAS_OK = true;
+/** 로그인 상태의 호출 도우미: 세션 쿠키 + CSRF */
+function client(cookie, csrf) { return (method, path, body) => invoke(app, method, path, { body, headers: { cookie: `aw_session=${cookie}`, 'x-csrf': csrf || '', accept: 'application/json' } }); }
+async function session(r) { const cookie = r.cookies.aw_session; const c = await invoke(app, 'GET', '/api/auth/csrf', { headers: { cookie: `aw_session=${cookie}` } }); return client(cookie, c.json.csrf); }
+const linkToken = (m, kind) => (m.text.match(kind === 'verify' ? /token=([A-Za-z0-9_-]+)/ : /reset-password\/([A-Za-z0-9_-]+)/) || [])[1];
+
+await test('가입 → 확인 메일 → 이메일 확인 → 개인 그룹과 별칭 user-01 → 대시보드 접근', async () => {
+ let r = await invoke(app, 'POST', '/api/auth/signup', { body: { email: 'alice@example.test', displayName: '앨리스', password: 'pw-example-1', consents: { terms: true, privacy: true } } });
+ eq(r.status, 200, JSON.stringify(r.json)); ok(r.cookies.aw_session, '가입 즉시 세션'); eq(mails.length, 1); ok(/token=/.test(mails[0].text));
+ const api = await session(r);
+ let me = await api('GET', '/api/auth/me'); eq(me.json.user.email_verified_at, null); eq(me.json.group.kind, 'personal'); eq(me.json.role, 'owner');
+ // 확인 전에는 verified 필수 API 거부(그룹 만들기)
+ r = await api('POST', '/api/groups', { name: '팀' }); eq(r.status, 403); eq(r.json.error, 'email_unverified');
+ r = await invoke(app, 'POST', '/api/auth/verify-email', { body: { token: linkToken(mails[0], 'verify') } }); eq(r.status, 200);
+ r = await invoke(app, 'POST', '/api/auth/verify-email', { body: { token: linkToken(mails[0], 'verify') } }); eq(r.json.error, 'token_used', '한 번만');
+ me = await api('GET', '/api/auth/me'); ok(me.json.user.email_verified_at);
+ const g = await api('GET', '/api/group'); eq(g.json.members[0].alias, 'user-01'); eq(g.json.members[0].role, 'owner');
+ const d = await api('GET', '/api/dashboard'); eq(d.status, 200); eq(d.json.summary.sites.purchased, 0);
+});
+await test('중복 가입: 존재를 드러내지 않는 일반 안내 / 필수 동의 없으면 거부', async () => {
+ let r = await invoke(app, 'POST', '/api/auth/signup', { body: { email: 'ALICE@example.test', displayName: 'x', password: 'pw-example-2', consents: { terms: true, privacy: true } } }); eq(r.status, 409); eq(r.json.error, 'signup_unavailable'); ok(!/이미 가입|존재/.test(r.json.message) || /로그인/.test(r.json.message));
+ r = await invoke(app, 'POST', '/api/auth/signup', { body: { email: 'bob@example.test', displayName: 'x', password: 'pw-example-2', consents: { terms: true } } }); eq(r.json.error, 'consent_required');
+});
+await test('로그인 실패 메시지 동일 / 5회 실패 → 15분 잠금(해제 시각 표시) / HTML 미로그인 → /login 리디렉션', async () => {
+ let r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'nobody@example.test', password: 'x' } }); const msg1 = r.json.message;
+ r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'wrong' } }); eq(r.json.message, msg1, '존재 여부 비노출');
+ for (let i = 0; i < 4; i++) r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'wrong' } });
+ eq(r.status, 429); eq(r.json.error, 'locked'); ok(r.json.detail.lockUntil); ok(/KST/.test(r.json.message));
+ r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-1' } }); eq(r.status, 429, '맞는 비밀번호도 잠금 중에는 거부');
+ deps.store.run('DELETE FROM login_attempts');
+ r = await invoke(app, 'GET', '/sites', { headers: { accept: 'text/html' } }); eq(r.status, 302); ok(r.headers.Location.startsWith('/login?next=%2Fsites'));
+ r = await invoke(app, 'GET', '/api/dashboard'); eq(r.status, 401);
+});
+await test('로그인 → 기기 목록 → 개별 종료 → 다른 모든 세션 종료 → 비밀번호 변경이 다른 세션 무효화 / CSRF 거부', async () => {
+ const a = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-1' }, headers: { 'user-agent': 'Mozilla/5.0 (Windows) Chrome/120' } }); eq(a.status, 200);
+ const b = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-1' } }); const c = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-1' } });
+ const A = await session(a); const B = await session(b); const C = await session(c);
+ let r = await A('GET', '/api/account/sessions'); ok(r.json.sessions.length >= 3); ok(r.json.sessions.some((s) => /Windows/.test(s.label)));
+ const bCur = (await B('GET', '/api/account/sessions')).json.current;
+ r = await A('DELETE', '/api/account/sessions/' + bCur); eq(r.json.revoked, 1); r = await B('GET', '/api/auth/me'); eq(r.json.user, null, '종료된 세션');
+ // CSRF: 세션은 있는데 헤더가 틀리면 403
+ r = await invoke(app, 'POST', '/api/account/sessions/revoke-others', { headers: { cookie: `aw_session=${a.cookies.aw_session}`, 'x-csrf': 'wrong' } }); eq(r.status, 403); eq(r.json.error, 'csrf');
+ r = await A('POST', '/api/account/sessions/revoke-others'); ok(r.json.revoked >= 1); r = await C('GET', '/api/auth/me'); eq(r.json.user, null);
+ // 비밀번호 변경 → 다른 세션 종료(이 세션 유지)
+ const d = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-1' } }); const D = await session(d);
+ r = await A('POST', '/api/account/password', { current: 'pw-example-1', next: 'pw-example-9' }); eq(r.status, 200); r = await D('GET', '/api/auth/me'); eq(r.json.user, null); r = await A('GET', '/api/auth/me'); ok(r.json.user);
+ r = await A('POST', '/api/account/password', { current: 'bad', next: 'pw-example-9' }); eq(r.json.error, 'wrong_password');
+});
+await test('비밀번호 재설정: 요청 응답 동일 · 링크 상태(valid→used) · 재설정 후 모든 세션 종료', async () => {
+ const n0 = mails.length; let r = await invoke(app, 'POST', '/api/auth/request-reset', { body: { email: 'ghost@example.test' } }); const msg = r.json.message; eq(mails.length, n0, '없는 계정은 메일 없음');
+ r = await invoke(app, 'POST', '/api/auth/request-reset', { body: { email: 'alice@example.test' } }); eq(r.json.message, msg); eq(mails.length, n0 + 1);
+ const tok = linkToken(mails[n0], 'reset'); ok(tok); r = await invoke(app, 'GET', '/api/auth/reset/' + tok); eq(r.json.state, 'valid');
+ const live = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-9' } }); const L = await session(live);
+ r = await invoke(app, 'POST', '/api/auth/reset', { body: { token: tok, password: 'pw-example-3' } }); eq(r.status, 200);
+ r = await invoke(app, 'GET', '/api/auth/reset/' + tok); eq(r.json.state, 'used'); r = await invoke(app, 'GET', '/reset-password/' + tok, { headers: { accept: 'text/html' } }); ok(/이미 사용한/.test(r.text));
+ r = await L('GET', '/api/auth/me'); eq(r.json.user, null, '재설정 뒤 기존 세션 종료');
+ r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); eq(r.status, 200);
+ deps.store.run('UPDATE email_tokens SET expires_at=? WHERE token_hash IS NOT NULL AND kind=\'reset\'', new Date(0).toISOString()); r = await invoke(app, 'GET', '/api/auth/reset/' + tok); eq(r.json.state, 'used');
+});
+await test('MFA: 등록(코드 확인) → 복구 코드 1회 표시 → 로그인 2단계 → 같은 코드 재사용 거부 → 복구 코드 1회용 → 해제', async () => {
+ const l = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); const A = await session(l);
+ let r = await A('POST', '/api/account/mfa/enable', { code: '000000' }); eq(r.json.error, 'mfa_not_pending');
+ r = await A('POST', '/api/account/mfa/setup'); const secret = r.json.secret.replace(/ /g, ''); ok(/^otpauth:\/\/totp\//.test(r.json.otpauth));
+ r = await A('POST', '/api/account/mfa/enable', { code: '123456' }); eq(r.json.error, 'mfa_bad_code');
+ r = await A('POST', '/api/account/mfa/enable', { code: totp.currentCode(secret) }); eq(r.status, 200); eq(r.json.recoveryCodes.length, 10); const rc = r.json.recoveryCodes;
+ ok(!JSON.stringify(deps.store.get('SELECT recovery_json FROM users WHERE email=?', 'alice@example.test')).includes(rc[0]), '복구 코드는 해시만 저장');
+ // 로그인: 1단계 → preauth → 2단계
+ r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); eq(r.json.mfaRequired, true); ok(!r.cookies.aw_session); const pre = r.json.preauth;
+ /** 등록·이전 로그인에 쓴 카운터를 지워 현재 구간 코드를 다시 쓸 수 있게 한다(시간 경계에 따른 흔들림 방지) */
+ const rewind = () => { const u = deps.store.get('SELECT mfa_json FROM users WHERE email=?', 'alice@example.test'); const m = JSON.parse(u.mfa_json); m.lastCounter = null; deps.store.run('UPDATE users SET mfa_json=? WHERE email=?', JSON.stringify(m), 'alice@example.test'); };
+ rewind(); const code = totp.currentCode(secret);
+ r = await invoke(app, 'POST', '/api/auth/mfa', { body: { preauth: pre, code: 'bad' } }); eq(r.status, 401);
+ r = await invoke(app, 'POST', '/api/auth/mfa', { body: { preauth: pre, code } }); eq(r.status, 200, JSON.stringify(r.json)); ok(r.cookies.aw_session);
+ r = await invoke(app, 'POST', '/api/auth/mfa', { body: { preauth: pre, code } }); ok(/preauth_/.test(r.json.error), '사전 인증 토큰 1회용');
+ r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); r = await invoke(app, 'POST', '/api/auth/mfa', { body: { preauth: r.json.preauth, code } }); eq(r.status, 401, '같은 TOTP 코드 재사용 거부');
+ // 복구 코드
+ r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); const pre2 = r.json.preauth; r = await invoke(app, 'POST', '/api/auth/mfa', { body: { preauth: pre2, code: rc[0] } }); eq(r.status, 200); eq(r.json.recoveryLeft, 9);
+ r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); r = await invoke(app, 'POST', '/api/auth/mfa', { body: { preauth: r.json.preauth, code: rc[0] } }); eq(r.status, 401, '복구 코드 1회용');
+ // 해제: 비밀번호+코드
+ rewind(); const l2 = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); r = await invoke(app, 'POST', '/api/auth/mfa', { body: { preauth: l2.json.preauth, code: totp.currentCode(secret) } }); eq(r.status, 200, JSON.stringify(r.json)); const B = await session(r);
+ r = await B('POST', '/api/account/mfa/disable', { password: 'pw-example-3', code: '000000' }); eq(r.json.error, 'mfa_bad_code');
+ rewind(); r = await B('POST', '/api/account/mfa/disable', { password: 'pw-example-3', code: totp.currentCode(secret) }); eq(r.status, 200, JSON.stringify(r.json));
+ r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); ok(r.cookies.aw_session, '해제 뒤 바로 로그인');
+});
+await test('화면 렌더: 로그인·가입·내 계정·보안·기기·도움말 HTML 이 열린다', async () => {
+ const l = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); const h = { cookie: `aw_session=${l.cookies.aw_session}`, accept: 'text/html' };
+ for (const p of ['/account', '/account/security', '/account/sessions', '/help', '/notifications', '/activity', '/group', '/']) { const r = await invoke(app, 'GET', p, { headers: h }); eq(r.status, 200, p); ok(//.test(r.text), p); }
+ for (const p of ['/login', '/signup', '/reset-password']) { const r = await invoke(app, 'GET', p, { headers: { accept: 'text/html' } }); eq(r.status, 200, p); }
+ const r = await invoke(app, 'GET', '/login', { headers: h }); eq(r.status, 302, '로그인 상태면 /login 은 대시보드로');
+});
+done();
diff --git a/test/dashboard.test.mjs b/test/dashboard.test.mjs
new file mode 100644
index 0000000..0ffb7eb
--- /dev/null
+++ b/test/dashboard.test.mjs
@@ -0,0 +1,57 @@
+/** test/dashboard.test.mjs — 대시보드 요약·주의 항목 순서·최근 활동 / 알림 센터 API / 스케줄러 기본 작업. */
+import { test, eq, ok, done } from '../lib/test.mjs';
+import { makeDeps, appFor, seedGroup, call } from './helpers.mjs';
+import { grantEntitlement, reserve, commit, recordUsage, setAllocation } from '../lib/quota.mjs';
+import { notify } from '../lib/notify.mjs';
+import { audit } from '../lib/audit.mjs';
+import { newId } from '../lib/crypto.mjs';
+
+const deps = await makeDeps({ mailer: { send: async () => ({ ok: true }), mode: () => 'test' } });
+const who = seedGroup(deps); const other = seedGroup(deps, { email: 'o@example.test', name: '남' });
+deps.jobs.register('deploy.site', { title: '배포', steps: [{ id: 'a', title: 'A' }], async run(job, ctx) { if (job.subject.fail) throw new Error('boom'); ctx.waitExternal('외부 확인'); } });
+const app = await appFor(deps, ['auth', 'dashboard', 'notifications'], who);
+const g = who.group.id; const now = new Date().toISOString();
+
+await test('요약: 사이트 수(구매·사용 중·예약)·초안·건강·배포 방식·도메인·메일·백업·작업·비용', async () => {
+ grantEntitlement(deps.store, { groupId: g, resource: 'sites', amount: 3 }); grantEntitlement(deps.store, { groupId: g, resource: 'web_disk', amount: 1000 });
+ const s1 = newId('s_'); const s2 = newId('s_');
+ deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, deploy_mode, health, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?)', s1, g, 'A', 'live', 'managed', 'ok', now, now);
+ deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, deploy_mode, health, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?)', s2, g, 'B', 'draft', null, 'unknown', now, now);
+ deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, deploy_mode, health, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?)', newId('s_'), other.group.id, 'X', 'live', 'external', 'down', now, now);
+ commit(deps.store, reserve(deps.store, { groupId: g, resource: 'sites', amount: 1, subjectType: 'site', subjectId: s1 }).id); reserve(deps.store, { groupId: g, resource: 'sites', amount: 1, subjectType: 'site', subjectId: s2 });
+ deps.store.run('INSERT INTO domains(id, group_id, name, site_id, ownership_state, dns_state, cert_state, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?)', newId('d_'), g, 'a.example.test', s1, 'verified', 'ok', 'ok', now, now);
+ deps.store.run('INSERT INTO domains(id, group_id, name, site_id, ownership_state, dns_state, cert_state, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?)', newId('d_'), g, 'b.example.test', s1, 'pending', 'pending', 'none', now, now);
+ deps.store.run('INSERT INTO mail_services(id, group_id, kind, site_id, name, status, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?)', newId('ms_'), g, 'site_bound', s1, 'A 메일', 'done', now, now);
+ deps.store.run('INSERT INTO backup_options(id, site_id, group_id, status, schedule_json, retention_json, consecutive_failures, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?)', newId('bo_'), s1, g, 'active', '{}', '{}', 3, now, now);
+ deps.store.run('INSERT INTO orders(id, group_id, order_no, items_json, amount, tax, total, status, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)', newId('o_'), g, 'AW-1', '[]', 10000, 1000, 11000, 'awaiting_payment', now, now);
+ deps.store.run('INSERT INTO orders(id, group_id, order_no, items_json, amount, tax, total, status, approved_at, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', newId('o_'), g, 'AW-2', '[]', 20000, 2000, 22000, 'approved', now, now, now);
+ deps.jobs.enqueue({ kind: 'deploy.site', groupId: g, subjectType: 'site', subjectId: s1 }); await deps.jobs.drain();
+ const r = await call(app, 'GET', '/api/dashboard'); eq(r.status, 200); const s = r.json.summary;
+ eq([s.sites.purchased, s.sites.inUse, s.sites.reserved, s.sites.unallocated, s.sites.live], [3, 1, 1, 1, 1]); eq(s.drafts, 1); eq(s.health, { ok: 1, warn: 0, bad: 0 }); eq(s.deploy, { managed: 1, external: 0 });
+ eq(s.domains, { total: 2, action: 1 }); eq(s.mail, { services: 1, mailboxes: 0 }); eq(s.backup, 1); eq(s.jobs, [{ kind: 'deploy', label: '배포', n: 1 }]); eq([s.cost.estimated, s.cost.awaiting], [22000, 11000]);
+});
+await test('주의 항목: 필수 알림 → 결제 대기 → 도메인 → 사용량 → 실패 작업 → 백업 순, 각각 링크 / 다른 그룹 항목 없음', async () => {
+ notify(deps.store, { groupId: g, category: 'security', severity: 'critical', mandatory: true, title: '보안 경고', link: '/account/security' });
+ notify(deps.store, { groupId: other.group.id, category: 'security', severity: 'critical', mandatory: true, title: '남의 경고', link: '/x' });
+ recordUsage(deps.store, { groupId: g, resource: 'web_disk', subjectType: 'site', subjectId: 'any', used: 960 });
+ deps.jobs.enqueue({ kind: 'deploy.site', groupId: g, subject: { fail: true }, subjectType: 'site', subjectId: 'sX' }); await deps.jobs.drain();
+ const r = await call(app, 'GET', '/api/dashboard'); const a = r.json.attention;
+ eq(a[0].text, '보안 경고'); eq(a[0].href, '/account/security'); ok(!a.some((x) => x.text === '남의 경고'));
+ const kinds = a.map((x) => x.kind); ok(kinds.indexOf('notification') < kinds.indexOf('order')); ok(a.find((x) => x.kind === 'usage' && /95%/.test(x.text) && x.severity === 'critical')); ok(a.find((x) => x.kind === 'job' && x.severity === 'critical')); ok(a.find((x) => x.kind === 'backup' && /3회/.test(x.text))); ok(a.find((x) => x.kind === 'domain' && /소유 확인/.test(x.text)));
+ ok(a.every((x) => x.href && x.href.startsWith('/')), '모든 항목에 조치 링크');
+});
+await test('최근 활동: 사용자/자동 구분, 인증 로그 제외 / 알림 API: 필터·읽음·사건 반복·필수', async () => {
+ audit(deps.store, { actorType: 'user', actorId: who.user.id, actorLabel: who.user.email, groupId: g, action: 'deploy.start' }); audit(deps.store, { actorType: 'system', groupId: g, action: 'backup.run', automatic: true }); audit(deps.store, { actorType: 'user', actorId: who.user.id, groupId: g, action: 'auth.login' });
+ let r = await call(app, 'GET', '/api/dashboard'); const rec = r.json.recent; eq(rec.length, 2); eq(rec[0].automatic, true); eq(rec[1].automatic, false);
+ notify(deps.store, { groupId: g, category: 'usage', incidentKey: 'u1', title: '90%' }); notify(deps.store, { groupId: g, category: 'usage', incidentKey: 'u1', title: '90% 다시' });
+ r = await call(app, 'GET', '/api/notifications?category=usage'); eq(r.json.items.length, 1); eq(r.json.items[0].count, 2); ok(r.json.unread >= 2);
+ r = await call(app, 'GET', '/api/notifications'); ok(r.json.items.find((n) => n.mandatory && n.title === '보안 경고')); ok(!r.json.items.find((n) => n.title === '남의 경고'));
+ const id = r.json.items[0].id; r = await call(app, 'POST', `/api/notifications/${id}/read`); eq(r.json.changed, 1); r = await call(app, 'GET', '/api/notifications?unread=1'); ok(!r.json.items.find((n) => n.id === id));
+ r = await call(app, 'POST', '/api/notifications/read-all'); r = await call(app, 'GET', '/api/notifications?unread=1'); eq(r.json.items.length, 0);
+});
+await test('스케줄러: 만료 예약 자동 반환 + 알림, 작업 목록', async () => {
+ deps.store.run('UPDATE quota_reservations SET expires_at=? WHERE state=\'reserved\'', new Date(0).toISOString());
+ const r = await deps.scheduler.runNow('quota.expire'); eq(r.result, 1); ok(deps.store.get('SELECT 1 AS x FROM notifications WHERE group_id=? AND incident_key LIKE \'reservation:%\'', g));
+ ok(deps.scheduler.list().map((t) => t.name).includes('sessions.purge'));
+});
+done();
diff --git a/test/groups.test.mjs b/test/groups.test.mjs
new file mode 100644
index 0000000..75b46df
--- /dev/null
+++ b/test/groups.test.mjs
@@ -0,0 +1,74 @@
+/** test/groups.test.mjs — 사용자 그룹: 전환 격리·초대 수락·역할·정지 영향 범위·소유권 이전·탈퇴·사이트별 권한·직접 접근 403. */
+import { test, eq, ok, done } from '../lib/test.mjs';
+import { makeDeps, appFor } from './helpers.mjs';
+import { invoke } from '../lib/http.mjs';
+import { grantEntitlement } from '../lib/quota.mjs';
+import { newId } from '../lib/crypto.mjs';
+
+const mails = []; const deps = await makeDeps({ mailer: { send: async (m) => { mails.push(m); return { ok: true }; }, mode: () => 'test' } });
+deps.jobs.register('demo.wait', { title: '데모 대기', steps: [{ id: 'a', title: 'A' }], async run(job, ctx) { ctx.waitExternal('대기'); } });
+const app = await appFor(deps, ['auth', 'groups', 'dashboard', 'notifications', 'auditui']);
+async function signup(email, name) { const r = await invoke(app, 'POST', '/api/auth/signup', { body: { email, displayName: name, password: 'pw-example-1', consents: { terms: true, privacy: true } } }); const tok = (mails.at(-1).text.match(/token=([A-Za-z0-9_-]+)/) || [])[1]; await invoke(app, 'POST', '/api/auth/verify-email', { body: { token: tok } }); return client(r.cookies.aw_session); }
+async function client(cookie) { const c = await invoke(app, 'GET', '/api/auth/csrf', { headers: { cookie: `aw_session=${cookie}` } }); const f = (method, path, body, extra = {}) => invoke(app, method, path, { body, headers: { cookie: `aw_session=${cookie}`, 'x-csrf': c.json.csrf, ...extra } }); f.cookie = cookie; return f; }
+const uid = (email) => deps.store.get('SELECT id FROM users WHERE email=?', email).id;
+const owner = await signup('owner@example.test', '오너'); const bob = await signup('bob@example.test', '밥'); const carol = await signup('carol@example.test', '캐럴');
+
+let teamId;
+await test('팀 그룹 만들기 → 초대 → 다른 이메일로는 수락 불가 → 초대받은 사람이 수락 → 별칭 user-02', async () => {
+ let r = await owner('POST', '/api/groups', { name: '오즈 팀' }); eq(r.status, 200); teamId = r.json.groupId;
+ r = await owner('GET', '/api/auth/me'); eq(r.json.group.id, teamId, '만들면 그 그룹으로 전환');
+ r = await owner('POST', '/api/group/invites', { email: 'bob@example.test', role: 'member' }); eq(r.status, 200); const link = mails.at(-1).text.match(/invite\/([A-Za-z0-9_-]+)/)[1];
+ r = await owner('POST', '/api/group/invites', { email: 'bob@example.test', role: 'member' }); eq(r.json.error, 'invite_pending');
+ r = await carol('POST', '/api/invites/accept', { token: link }); eq(r.json.error, 'invite_email_mismatch');
+ r = await bob('POST', '/api/invites/accept', { token: link }); eq(r.status, 200); r = await bob('POST', '/api/invites/accept', { token: link }); eq(r.json.error, 'invite_invalid', '1회용');
+ r = await bob('GET', '/api/group'); eq(r.json.group.id, teamId); eq(r.json.me.alias, 'user-02'); eq(r.json.me.role, 'member'); eq(r.json.invites.length, 0, '구성원에게는 초대 목록 없음');
+});
+await test('그룹 전환 격리: 같은 사용자가 두 그룹을 오가도 대시보드·알림·사이트가 섞이지 않는다', async () => {
+ const personal = deps.store.get('SELECT g.id FROM groups g JOIN memberships m ON m.group_id=g.id WHERE m.user_id=? AND g.kind=\'personal\'', uid('bob@example.test')).id;
+ const now = new Date().toISOString();
+ deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, created_at, updated_at) VALUES(?,?,?,?,?,?)', newId('s_'), teamId, '팀 사이트', 'draft', now, now);
+ deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, created_at, updated_at) VALUES(?,?,?,?,?,?)', newId('s_'), personal, '개인 사이트', 'draft', now, now); deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, created_at, updated_at) VALUES(?,?,?,?,?,?)', newId('s_'), personal, '개인 사이트2', 'draft', now, now);
+ grantEntitlement(deps.store, { groupId: teamId, resource: 'sites', amount: 3 });
+ let r = await bob('GET', '/api/dashboard'); eq(r.json.summary.drafts, 1); eq(r.json.summary.sites.purchased, 3);
+ r = await bob('POST', '/api/groups/switch', { groupId: personal }); eq(r.status, 200);
+ r = await bob('GET', '/api/dashboard'); eq(r.json.summary.drafts, 2); eq(r.json.summary.sites.purchased, 0);
+ r = await bob('POST', '/api/groups/switch', { groupId: 'g_nope' }); eq(r.status, 403);
+ r = await carol('POST', '/api/groups/switch', { groupId: teamId }); eq(r.status, 403, '구성원 아님');
+ await bob('POST', '/api/groups/switch', { groupId: teamId });
+});
+await test('역할 없는 직접 API 접근 → 403 + 필요한 역할 / 역할 변경은 감사 기록(삭제 불가)', async () => {
+ let r = await bob('PATCH', '/api/group', { name: 'x' }); eq(r.status, 403); eq(r.json.error, 'forbidden'); eq(r.json.detail.requiredRole, ['owner', 'admin']); eq(r.json.detail.yourRole, 'member');
+ r = await bob('POST', '/api/group/invites', { email: 'dave@example.test' }); eq(r.status, 403);
+ r = await owner('PATCH', '/api/group/members/' + uid('bob@example.test'), { role: 'admin' }); eq(r.status, 200);
+ r = await owner('GET', '/api/audit?action=member.role'); eq(r.json.items[0].immutable, true); eq(r.json.items[0].detail.to, 'admin');
+ r = await bob('PATCH', '/api/group', { name: '오즈 팀 2' }); eq(r.status, 200);
+ r = await owner('PATCH', '/api/group/members/' + uid('owner@example.test'), { role: 'member' }); eq(r.json.error, 'owner_role');
+});
+await test('구성원 정지: 그 사람의 세션과 미실행 작업만 취소, 다른 구성원 작업·그룹 자산은 유지', async () => {
+ const bobId = uid('bob@example.test'); const ownerId = uid('owner@example.test');
+ const jb = deps.jobs.enqueue({ kind: 'demo.wait', groupId: teamId, createdBy: bobId }).job; const jo = deps.jobs.enqueue({ kind: 'demo.wait', groupId: teamId, createdBy: ownerId }).job; const jbOther = deps.jobs.enqueue({ kind: 'demo.wait', groupId: 'g_other', createdBy: bobId }).job;
+ await deps.jobs.drain(); eq(deps.jobs.get(jb.id).status, 'awaiting_external');
+ let r = await owner('GET', `/api/group/members/${bobId}/impact`); eq(r.json.impact.pendingJobs.length, 1); ok(r.json.impact.sessions >= 1);
+ r = await owner('POST', `/api/group/members/${bobId}/suspend`); eq(r.status, 200); ok(r.json.sessionsRevoked >= 1);
+ eq(deps.jobs.get(jb.id).status, 'cancelled'); eq(deps.jobs.get(jo.id).status, 'awaiting_external', '다른 구성원 작업 유지'); eq(deps.jobs.get(jbOther.id).status, 'awaiting_external', '다른 그룹 작업 유지');
+ eq(deps.store.get('SELECT COUNT(*) AS n FROM sites WHERE group_id=?', teamId).n, 1, '그룹 자산 유지');
+ r = await bob('GET', '/api/auth/me'); eq(r.json.user, null, '정지된 구성원의 그룹 세션 종료');
+ r = await owner('POST', `/api/group/members/${bobId}/unsuspend`); eq(r.status, 200);
+});
+await test('사이트별 권한 설정·조회, 소유권 이전, 탈퇴 규칙', async () => {
+ const bobId = uid('bob@example.test'); const site = deps.store.get('SELECT id FROM sites WHERE group_id=?', teamId);
+ let r = await owner('PATCH', '/api/group/members/' + bobId, { role: 'member' }); eq(r.status, 200, '관리자는 암묵적 전체 권한이므로 구성원으로 되돌려 시험');
+ r = await owner('PUT', `/api/sites/${site.id}/permissions/${bobId}`, { perms: ['view', 'deploy', 'bogus'] }); eq(r.json.perms, ['view', 'deploy']);
+ r = await owner('GET', `/api/sites/${site.id}/permissions`); const row = r.json.members.find((m) => m.userId === bobId); eq(row.perms, ['view', 'deploy']);
+ r = await owner('GET', `/api/sites/s_nope/permissions`); eq(r.status, 404);
+ r = await owner('GET', '/api/audit?action=permission.site'); eq(r.json.items[0].immutable, true);
+ // 탈퇴·이전
+ r = await owner('POST', '/api/group/leave'); eq(r.json.error, 'owner_cannot_leave');
+ r = await owner('POST', '/api/group/transfer-ownership', { userId: bobId }); eq(r.status, 200);
+ r = await owner('GET', '/api/group'); eq(r.json.me.role, 'admin'); eq(r.json.group.ownerUserId, bobId);
+ r = await owner('POST', '/api/group/leave'); eq(r.status, 200); r = await owner('GET', '/api/auth/me'); ok(r.json.group.id !== teamId, '탈퇴 뒤 다른 그룹으로');
+ // 삭제 불가 기록·알림
+ ok(deps.store.get('SELECT 1 AS x FROM audit_log WHERE action=\'ownership.group_transferred\' AND immutable=1'));
+ ok(deps.store.get('SELECT 1 AS x FROM notifications WHERE group_id=? AND mandatory=1', teamId));
+});
+done();