Ariweb Suite 0.1.1 기반: 공통 라이브러리(db·http·세션·작업 엔진·할당/예약·알림·감사·보관소), 포털 뼈대, Webmaker/AriMail 도입 소스와 manifest

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-10-26 11:02:58 +00:00
co-authored by Claude Fable 5.1
parent 9ca4cee341
commit c7aea8d419
295 changed files with 90146 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
/**
* portal/app.mjs — 고객 포털 앱 조립.
*
* 각 영역(portal/<영역>/api.mjs)은 `register(app, deps)` 를 내보내고 여기서 순서대로 등록된다. deps 는 모든 영역이 공유하는 의존성 묶음이다.
* deps = { store(lib/db), vault, jobs(작업 엔진), config(설정), hal(HAL 클라이언트), webmaker(어댑터), arimail(어댑터), notify, audit, quota, t, page, badge, statePanel, log }
* 인증 미들웨어(portal/auth/api.mjs 의 middleware)가 가장 먼저 실행되어 ctx.user·ctx.group·ctx.membership·ctx.session·ctx.sitePerms 를 채운다.
* 영역 등록 순서: auth → groups → dashboard → commerce → usage → sites → domains → source → mail → backup → relocation → transfer → lifecycle → notifications → audit → ops(내부 운영자 API) → webhooks(HAL 이벤트)
*/
import { createApp } from '../lib/http.mjs';
import { t } from '../lib/i18n.mjs';
import { page, badge, statePanel, dangerSpec } from './ui/layout.mjs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync } from 'node:fs';
const HERE = dirname(fileURLToPath(import.meta.url));
/** 영역 목록(파일이 있는 것만 등록 — 병렬 개발 중 아직 없는 영역은 건너뛴다) */
export const AREAS = ['auth', 'groups', 'dashboard', 'commerce', 'usage', 'sites', 'domains', 'source', 'mail', 'backup', 'relocation', 'transfer', 'lifecycle', 'notifications', 'auditui', 'ops', 'webhooks', 'help'];
export async function buildApp(deps) {
const app = createApp({ log: deps.log, onError: deps.onError });
app.static('/ui', join(HERE, 'ui', 'public'));
Object.assign(deps, { t, page, badge, statePanel, dangerSpec, app });
const loaded = [];
for (const a of AREAS) {
const file = join(HERE, a, 'api.mjs'); if (!existsSync(file)) continue;
const mod = await import(file); if (typeof mod.register !== 'function') continue;
await mod.register(app, deps); loaded.push(a);
}
deps.log(`[portal] 영역 등록: ${loaded.join(', ')}`);
return app;
}
+52
View File
@@ -0,0 +1,52 @@
/**
* portal/auth/guard.mjs — 권한 검사 계약(모든 포털 영역이 이 함수들만 사용한다).
*
* 요청 컨텍스트(ctx)에는 인증 미들웨어(portal/auth/api.mjs)가 다음을 채운다.
* ctx.user { id, email, display_name, email_verified_at, mfa_enabled } 또는 null
* ctx.session lib/session 행(kind='customer') — data.groupId 가 현재 선택한 사용자 그룹
* ctx.group 현재 그룹 행 또는 null
* ctx.membership 현재 그룹에서의 구성원 행 { role, alias, status } 또는 null
* 역할(§4): owner > admin > billing | member > viewer. 사이트별 권한(§4.3): view | edit | deploy | domain_mail | billing | admin.
* 메뉴를 숨기는 것으로 끝내지 않고 직접 주소로 와도 거부하고 필요한 역할을 안내한다(§4.2).
*/
import { HttpError } from '../../lib/http.mjs';
import { t } from '../../lib/i18n.mjs';
export const ROLES = ['owner', 'admin', 'billing', 'member', 'viewer'];
const RANK = { owner: 5, admin: 4, billing: 3, member: 2, viewer: 1 };
export const SITE_PERMS = ['view', 'edit', 'deploy', 'domain_mail', 'billing', 'admin'];
/** 로그인 필수. 이메일 확인이 필요한 경로는 verified=true */
export function requireUser(ctx, { verified = false } = {}) {
if (!ctx.user) throw new HttpError(401, 'unauthenticated', t('error.unauthenticated'));
if (verified && !ctx.user.email_verified_at) throw new HttpError(403, 'email_unverified', '이메일 확인이 끝나야 이용할 수 있습니다. 받은 편지함의 확인 링크를 누르세요', { action: 'verify_email' });
return ctx.user;
}
/** 현재 그룹 선택 필수 */
export function requireGroup(ctx) { requireUser(ctx); if (!ctx.group || !ctx.membership || ctx.membership.status !== 'active') throw new HttpError(403, 'group_required', t('error.groupRequired')); return ctx.group; }
/** 그룹 역할 검사. roles 는 허용 역할 목록 또는 최소 역할 하나 */
export function requireRole(ctx, roles) {
requireGroup(ctx); const list = [].concat(roles); const mine = ctx.membership.role;
const okRole = list.length === 1 && RANK[list[0]] ? RANK[mine] >= RANK[list[0]] : list.includes(mine);
if (!okRole) throw new HttpError(403, 'forbidden', t('error.forbidden', { role: list.join('/') }), { requiredRole: list, yourRole: mine });
return ctx.membership;
}
/** 결제·해지 권한: owner 또는 billing 담당 */
export const requireBilling = (ctx) => requireRole(ctx, ['owner', 'admin', 'billing']);
/**
* 사이트별 권한(§4.3). 그룹 owner/admin 은 모든 사이트 권한을 가진다. 그 밖에는 site_permissions 표(deps.sitePerms) 를 본다.
* @param {object} ctx @param {object} site 사이트 행(group_id 포함) @param {string} perm SITE_PERMS 중 하나
*/
export function requireSitePerm(ctx, site, perm) {
requireGroup(ctx);
if (!site || site.group_id !== ctx.group.id) throw new HttpError(404, 'not_found', t('error.notFound')); // 다른 그룹의 사이트는 존재를 드러내지 않는다(§37)
if (['owner', 'admin'].includes(ctx.membership.role)) return true;
const perms = ctx.sitePerms ? ctx.sitePerms(site.id, ctx.user.id) : null; // 인증 미들웨어가 ctx.sitePerms(siteId, userId) → string[] 를 붙인다
const has = (perms || []).includes(perm) || (perms || []).includes('admin') || (perm === 'view' && ctx.membership.role !== 'viewer' && (perms || []).length) || (perm === 'view' && ctx.membership.role === 'member');
if (!has) throw new HttpError(403, 'forbidden', `이 사이트에 대한 '${perm}' 권한이 없습니다. 그룹 관리자에게 권한을 요청하세요`, { requiredPerm: perm });
return true;
}
/** 변경 요청(POST/PUT/PATCH/DELETE)의 CSRF 검사 — 인증 미들웨어가 호출 */
export function requireCsrf(ctx, csrfOk) { if (!['GET', 'HEAD', 'OPTIONS'].includes(ctx.method) && ctx.path.startsWith('/api/') && !csrfOk(ctx.session, ctx.headers['x-csrf'])) throw new HttpError(403, 'csrf', t('error.csrf')); }
/** 내부 서비스 호출(HAL → 포털, 포털 → HAL) 검사: 서비스 키 + HMAC(method|path|ts|body-sha256). */
export function requireServiceKey(ctx, verify) { if (!verify(ctx)) throw new HttpError(401, 'service_auth_failed', '서비스 인증에 실패했습니다'); }
+28
View File
@@ -0,0 +1,28 @@
/**
* portal/config.mjs — 포털 실행 설정. 배포 때 정해지는 값만 환경 변수로 받고 나머지는 data/config.json(운영자 설정 화면)에서 읽는다.
* 비밀값(서비스 키·SSO 비밀·AriMail 키)은 config 가 아니라 vault 에 둔다.
*/
import { readFileSync, existsSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
import { join } from 'node:path';
export function loadConfig() {
const dataDir = process.env.ARIWEB_PORTAL_DATA || process.env.DATA_DIR || join(process.cwd(), 'data');
mkdirSync(dataDir, { recursive: true, mode: 0o700 });
const file = join(dataDir, 'config.json');
let values = {}; try { values = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')).values || {} : {}; } catch (_) { values = {}; }
const cfg = {
dataDir, file, port: Number(process.env.PORT || 8910), host: process.env.HOST || '127.0.0.1',
publicUrl: values.publicUrl || process.env.PUBLIC_URL || 'https://my.ariweb.net',
halUrl: values.halUrl || process.env.HAL_URL || 'https://hal.ariweb.net',
webmakerUrl: values.webmakerUrl || process.env.WEBMAKER_URL || 'https://webmaker.ariweb.net',
webmakerInternalUrl: values.webmakerInternalUrl || process.env.WEBMAKER_INTERNAL_URL || 'http://127.0.0.1:3080',
arimailUrl: values.arimailUrl || process.env.ARIMAIL_URL || 'https://webmail.ariweb.net',
arimailInternalUrl: values.arimailInternalUrl || process.env.ARIMAIL_INTERNAL_URL || 'http://127.0.0.1:8790',
mailFrom: values.mailFrom || process.env.MAIL_FROM || 'no-reply@ariweb.net',
operatorEmail: values.operatorEmail || process.env.OPERATOR_EMAIL || '',
values,
/** 설정 저장(운영자 화면). 비밀값은 거부. */
set(patch, by = 'ops') { for (const k of Object.keys(patch)) if (/key|secret|token|pass/i.test(k)) throw new Error(`비밀값(${k})은 설정 파일에 둘 수 없습니다 — 보관소를 쓰세요`); Object.assign(values, patch); const tmp = file + '.tmp'; writeFileSync(tmp, JSON.stringify({ version: 1, updatedAt: new Date().toISOString(), updatedBy: by, values }, null, 1), { mode: 0o600 }); renameSync(tmp, file); Object.assign(cfg, patch); },
};
return cfg;
}
+30
View File
@@ -0,0 +1,30 @@
/**
* portal/index.mjs — 고객 포털 프로세스 진입점.
* 설정 → DB(마이그레이션) → 보관소 → 작업 엔진 → 어댑터(HAL·Webmaker·AriMail) → 앱 조립 → 기동. 주기 작업(세션 정리·예약 만료·사용량 경고)은 portal/scheduler.mjs.
*/
import { loadConfig } from './config.mjs';
import { openDb } from '../lib/db.mjs';
import { openVault } from '../lib/vault.mjs';
import { createJobEngine } from '../lib/jobs.mjs';
import { allMigrations } from './schema.mjs';
import { buildApp } from './app.mjs';
import { join } from 'node:path';
import { existsSync } from 'node:fs';
const VERSION = JSON.parse((await import('node:fs')).readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
const log = (...a) => console.log(new Date().toISOString(), ...a);
const cfg = loadConfig();
const store = openDb(join(cfg.dataDir, 'portal.sqlite'), await allMigrations());
const vault = openVault(cfg.dataDir);
const jobs = createJobEngine(store, { log, workerId: `portal@${process.pid}` });
const deps = { store, vault, jobs, config: cfg, log, version: VERSION };
// 어댑터는 있을 때만(병렬 개발 중) 붙인다.
for (const [name, file] of [['hal', './hal/client.mjs'], ['webmaker', './webmaker/adapter.mjs'], ['arimail', './mail/arimail.mjs']]) {
const p = new URL(file, import.meta.url); if (existsSync(p)) { const m = await import(p); deps[name] = m.create ? m.create(deps) : m; }
}
const app = await buildApp(deps);
if (existsSync(new URL('./scheduler.mjs', import.meta.url))) { const s = await import('./scheduler.mjs'); s.start(deps); }
jobs.start();
await app.listen(cfg.port, cfg.host);
log(`[portal] Ariweb Suite 고객 포털 v${VERSION} — http://${cfg.host}:${cfg.port} (공개 주소 ${cfg.publicUrl}, 데이터 ${cfg.dataDir})`);
process.on('SIGTERM', () => { jobs.stop(); process.exit(0); });
+21
View File
@@ -0,0 +1,21 @@
/**
* portal/schema.mjs — 포털 DB 마이그레이션 모음.
* lib 의 공통 표(sessions·jobs·quota·notifications·audit) 뒤에 각 영역의 model.mjs 가 내보내는 MIGRATIONS 를 영역 순서대로 잇는다.
* 마이그레이션 id 는 `<영역>_NNNN` 으로 고유해야 한다.
*/
import * as session from '../lib/session.mjs';
import * as jobs from '../lib/jobs.mjs';
import * as quota from '../lib/quota.mjs';
import * as notify from '../lib/notify.mjs';
import * as audit from '../lib/audit.mjs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync } from 'node:fs';
import { AREAS } from './app.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
export async function allMigrations() {
const list = [...session.MIGRATIONS, ...jobs.MIGRATIONS, ...quota.MIGRATIONS, ...notify.MIGRATIONS, ...audit.MIGRATIONS];
for (const a of AREAS) { const f = join(HERE, a, 'model.mjs'); if (!existsSync(f)) continue; const m = await import(f); if (Array.isArray(m.MIGRATIONS)) list.push(...m.MIGRATIONS); }
return list;
}
+47
View File
@@ -0,0 +1,47 @@
/**
* portal/ui/layout.mjs — 고객 포털 공통 화면 틀(§2.1·§2.3).
*
* 서버가 HTML 뼈대(상단: 현재 그룹·알림·도움말·사용자 메뉴 / 왼쪽: 메뉴 / 본문: 제목·현재 위치·주요 작업 버튼·주의 항목)를 내고,
* 각 화면의 데이터는 같은 origin 의 /api/* 를 fetch 하는 작은 화면 스크립트(/ui/app.js + 영역별 js)가 채운다.
* 모든 화면은 '불러오는 중·비어 있음·정보 지연·부분 실패·전체 실패·권한 없음' 상태를 공통 컴포넌트(aw-state)로 보인다.
* 색만으로 상태를 구분하지 않는다: 상태 배지는 항상 글자(라벨)와 아이콘 문자를 함께 쓴다. 360px 너비에서 표의 핵심 값과 버튼이 가려지지 않게 카드형으로 접힌다.
*/
import { esc } from '../../lib/http.mjs';
import { t } from '../../lib/i18n.mjs';
const MENU = [
['dashboard', '/', '▦'], ['sites', '/sites', '▣'], ['mail', '/mail', '✉'], ['domains', '/domains', '◎'], ['backup', '/backup', '⟲'], ['billing', '/billing', '₩'], ['usage', '/usage', '▥'], ['group', '/group', '⚇'],
];
/**
* 전체 화면 HTML.
* @param {object} o { title, nav(현재 메뉴 키), crumbs:[{label,href}], actions:[{label,href|onclick,kind}], user, group, groups:[], unread, body(html), scripts:[js 경로], alerts:[{severity,text,href}] }
*/
export function page(o) {
const user = o.user || null; const group = o.group || null; const groups = o.groups || [];
const nav = MENU.map(([k, href, ic]) => `<a class="nav${o.nav === k ? ' on' : ''}" href="${href}"><span class="ic" aria-hidden="true">${ic}</span>${esc(t('nav.' + k))}</a>`).join('');
const crumbs = (o.crumbs || []).map((c, i, a) => (i === a.length - 1 || !c.href) ? `<span>${esc(c.label)}</span>` : `<a href="${c.href}">${esc(c.label)}</a>`).join(' <span aria-hidden="true">›</span> ');
const actions = (o.actions || []).map((a) => a.href ? `<a class="btn ${a.kind || ''}" href="${a.href}">${esc(a.label)}</a>` : `<button class="btn ${a.kind || ''}" data-action="${esc(a.action || '')}">${esc(a.label)}</button>`).join('');
const alerts = (o.alerts || []).map((a) => `<a class="alert ${a.severity || 'warning'}" href="${a.href || '#'}"><b>${a.severity === 'critical' ? '⚠ ' : a.severity === 'info' ? 'ⓘ ' : '! '}</b>${esc(a.text)}</a>`).join('');
return `<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(o.title || '')} · ${esc(t('app.portal'))}</title><link rel="stylesheet" href="/ui/app.css"><meta name="csrf" content="${esc(o.csrf || '')}"></head>
<body class="${user ? 'in' : 'out'}">
<a class="skip" href="#main">본문으로</a>
<header class="top"><button class="menu-btn" aria-label="메뉴 열기" data-action="toggle-nav">☰</button><a class="brand" href="/">${esc(t('app.name'))}</a>
${user ? `<div class="group-switch"><label class="sr" for="gsel">사용자 그룹</label><select id="gsel" data-action="switch-group" aria-label="현재 사용자 그룹">${groups.map((g) => `<option value="${esc(g.id)}"${group && g.id === group.id ? ' selected' : ''}>${esc(g.name)}${g.kind === 'personal' ? ' (개인)' : ''}</option>`).join('')}</select></div>
<nav class="top-right"><a href="/notifications" class="bell" aria-label="알림 ${o.unread || 0}건">🔔${o.unread ? `<b class="badge">${o.unread}</b>` : ''}</a><a href="/help">${esc(t('nav.help'))}</a><details class="user"><summary>${esc(user.display_name || user.email)}</summary><div class="dd"><a href="/account">${esc(t('nav.account'))}</a><a href="/account/sessions">로그인 기기</a><form method="post" action="/logout"><input type="hidden" name="csrf" value="${esc(o.csrf || '')}"><button>${esc(t('nav.logout'))}</button></form></div></details></nav>` : `<nav class="top-right"><a href="/login">로그인</a><a href="/signup" class="btn primary">회원가입</a></nav>`}
</header>
<div class="shell">${user ? `<aside class="side" id="side"><nav aria-label="주 메뉴">${nav}</nav></aside>` : ''}
<main id="main" class="main"><div class="ph"><div class="crumbs" aria-label="현재 위치">${crumbs}</div><div class="ph-row"><h1>${esc(o.title || '')}</h1><div class="actions">${actions}</div></div></div>
<div class="alerts" id="alerts">${alerts}</div>
${o.body || ''}
</main></div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<div id="modal" class="modal" hidden></div>
<script src="/ui/app.js"></script>${(o.scripts || []).map((s) => `<script src="${esc(s)}"></script>`).join('')}
</body></html>`;
}
/** 상태 배지(글자+기호, 색은 보조) */
export function badge(kind, code, label) { const sym = { ok: '●', done: '✔', failed: '✘', warning: '!', info: 'ⓘ', running: '◌', waiting: '◔', paused: '⏸', neutral: '○' }[kind] || '○'; return `<span class="badge ${esc(kind)} c-${esc(code || '')}"><span aria-hidden="true">${sym}</span> ${esc(label)}</span>`; }
/** 공통 상태 패널(불러오는 중·비어 있음·지연·부분 실패·전체 실패·권한 없음) */
export function statePanel(state, text, { href, retry } = {}) { const labels = { loading: t('state.loading'), empty: t('state.empty'), stale: t('state.stale'), partial: t('state.partial'), failed: t('state.failed'), forbidden: t('state.forbidden') }; return `<div class="aw-state ${esc(state)}" role="status"><b>${esc(labels[state] || state)}</b>${text ? `<p>${esc(text)}</p>` : ''}${href ? `<a class="btn" href="${esc(href)}">이동</a>` : ''}${retry ? `<button class="btn" data-action="retry">${esc(t('common.retry'))}</button>` : ''}</div>`; }
/** 파괴적 작업 확인 상자 데이터(화면 스크립트 aw.confirmDanger 가 그린다): 대상·영향·입력 확인 */
export function dangerSpec({ title, target, impacts = [], typeToConfirm = null, confirmLabel = '실행' }) { return JSON.stringify({ title, target, impacts, typeToConfirm, confirmLabel }); }
+30
View File
@@ -0,0 +1,30 @@
/* portal/ui/public/app.css — 고객 포털 공통 스타일. 색은 보조 신호이며 상태는 글자·기호로도 구분한다. 360px 모바일에서 표가 카드로 접힌다. */
:root{--bg:#f6f7f9;--fg:#1f2933;--mut:#5b6672;--line:#dde2e8;--pri:#1b5fd1;--ok:#1a7f4b;--warn:#b26a00;--bad:#b3261e;--card:#fff;--rad:10px;font-family:-apple-system,"Segoe UI","Noto Sans KR",sans-serif}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font-size:15px;line-height:1.5}a{color:var(--pri)}
.skip{position:absolute;left:-999px}.skip:focus{left:8px;top:8px;background:#fff;padding:6px;z-index:99}
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
.top{display:flex;align-items:center;gap:12px;padding:8px 16px;background:#fff;border-bottom:1px solid var(--line);position:sticky;top:0;z-index:20}
.brand{font-weight:700;color:var(--fg);text-decoration:none;font-size:17px}.menu-btn{display:none;font-size:20px;background:none;border:0;padding:4px 8px}
.group-switch select{font-size:14px;padding:6px 8px;border:1px solid var(--line);border-radius:8px;max-width:48vw}
.top-right{margin-left:auto;display:flex;gap:14px;align-items:center}.top-right a{text-decoration:none;color:var(--fg)}.bell{position:relative}.badge.bell b,.bell .badge{position:absolute;top:-6px;right:-10px;background:var(--bad);color:#fff;border-radius:10px;font-size:11px;padding:0 5px}
details.user summary{cursor:pointer;list-style:none}details.user .dd{position:absolute;right:12px;background:#fff;border:1px solid var(--line);border-radius:8px;padding:8px;display:flex;flex-direction:column;gap:6px;min-width:160px}
.shell{display:flex;min-height:calc(100vh - 50px)}.side{width:220px;background:#fff;border-right:1px solid var(--line);padding:12px 8px}
.side .nav{display:flex;gap:10px;align-items:center;padding:10px 12px;border-radius:8px;color:var(--fg);text-decoration:none}.side .nav.on{background:#e8effc;color:var(--pri);font-weight:600}.side .ic{width:18px;text-align:center}
.main{flex:1;padding:16px;max-width:1200px}.ph .crumbs{font-size:13px;color:var(--mut)}.ph-row{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}h1{font-size:22px;margin:4px 0 12px}
.actions{display:flex;gap:8px;flex-wrap:wrap}.btn{display:inline-block;padding:8px 14px;border:1px solid var(--line);background:#fff;border-radius:8px;cursor:pointer;font-size:14px;color:var(--fg);text-decoration:none}.btn.primary{background:var(--pri);color:#fff;border-color:var(--pri)}.btn.danger{color:var(--bad);border-color:var(--bad)}.btn:disabled{opacity:.5;cursor:not-allowed}
.alerts{display:flex;flex-direction:column;gap:6px;margin-bottom:12px}.alert{display:block;padding:10px 12px;border-radius:8px;border:1px solid;text-decoration:none;color:var(--fg)}.alert.warning{background:#fff7e6;border-color:#f0c36d}.alert.critical{background:#fdecea;border-color:#f3a09a}.alert.info{background:#e8effc;border-color:#b9cdf2}
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--rad);padding:16px;margin-bottom:14px}.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px}
.card h2{font-size:16px;margin:0 0 10px}.kv{display:grid;grid-template-columns:max-content 1fr;gap:6px 14px;font-size:14px}.kv dt{color:var(--mut)}.kv dd{margin:0}
.stat{font-size:26px;font-weight:700}.sub{color:var(--mut);font-size:13px}
.badge{display:inline-block;padding:2px 8px;border-radius:12px;font-size:13px;border:1px solid var(--line);background:#fff}.badge.ok,.badge.done{border-color:var(--ok);color:var(--ok)}.badge.failed{border-color:var(--bad);color:var(--bad)}.badge.warning{border-color:var(--warn);color:var(--warn)}.badge.running,.badge.waiting{border-color:var(--pri);color:var(--pri)}
table{width:100%;border-collapse:collapse;background:#fff;border:1px solid var(--line);border-radius:var(--rad);overflow:hidden}th,td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--line);font-size:14px;vertical-align:top}th{background:#fafbfc;font-weight:600}tr:last-child td{border-bottom:0}
.aw-state{padding:24px;text-align:center;color:var(--mut);background:#fff;border:1px dashed var(--line);border-radius:var(--rad)}.aw-state.failed,.aw-state.forbidden{border-color:var(--bad);color:var(--bad)}.aw-state.stale,.aw-state.partial{border-color:var(--warn);color:var(--warn)}
form .f{display:flex;flex-direction:column;gap:4px;margin-bottom:12px}label{font-weight:600;font-size:14px}input,select,textarea{font:inherit;padding:9px 10px;border:1px solid var(--line);border-radius:8px;width:100%}.hint{font-size:13px;color:var(--mut)}.err{font-size:13px;color:var(--bad)}
.steps{list-style:none;padding:0;margin:0}.steps li{display:flex;gap:10px;padding:6px 0;border-bottom:1px solid var(--line)}.steps .s{width:22px;text-align:center}.steps .done .s{color:var(--ok)}.steps .running .s{color:var(--pri)}.steps .failed .s{color:var(--bad)}.steps .waiting .s,.steps .needs_user .s{color:var(--warn)}
.bar{height:10px;background:#eef1f5;border-radius:6px;overflow:hidden}.bar i{display:block;height:100%;background:var(--pri)}.bar.w70 i{background:#5c8fe6}.bar.w80 i,.bar.w90 i{background:var(--warn)}.bar.w95 i,.bar.w100 i{background:var(--bad)}
.toast{position:fixed;bottom:16px;left:50%;transform:translateX(-50%);background:#1f2933;color:#fff;padding:10px 16px;border-radius:8px;display:none;z-index:50;max-width:92vw}.toast.show{display:block}
.modal{position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:40;padding:16px}.modal[hidden]{display:none}.modal .box{background:#fff;border-radius:12px;padding:20px;max-width:560px;width:100%;max-height:90vh;overflow:auto}
.tabs{display:flex;gap:4px;flex-wrap:wrap;border-bottom:1px solid var(--line);margin-bottom:12px}.tabs a{padding:8px 12px;text-decoration:none;color:var(--fg);border-bottom:2px solid transparent}.tabs a.on{border-bottom-color:var(--pri);color:var(--pri);font-weight:600}
.muted{color:var(--mut)}.right{text-align:right}.mono{font-family:ui-monospace,Menlo,monospace;font-size:13px}.copy{cursor:pointer;border:1px dashed var(--line);padding:2px 6px;border-radius:6px}
@media (max-width:760px){.menu-btn{display:block}.side{position:fixed;left:-240px;top:50px;bottom:0;transition:left .2s;z-index:30}.side.open{left:0}.main{padding:12px 16px}.group-switch select{max-width:40vw}
table.r{display:block;border:0;background:none}table.r thead{display:none}table.r tr{display:block;background:#fff;border:1px solid var(--line);border-radius:var(--rad);margin-bottom:10px;padding:6px 10px}table.r td{display:flex;justify-content:space-between;gap:8px;border:0;padding:6px 0}table.r td::before{content:attr(data-l);color:var(--mut);font-size:13px}}
+48
View File
@@ -0,0 +1,48 @@
/* portal/ui/public/app.js — 고객 포털 공통 화면 스크립트.
* aw.api(method, path, body) : 같은 origin 의 /api/* 호출. CSRF 헤더 자동, 오류는 { ok:false, error, message } 로 던진다.
* aw.state(el, kind, text) : 불러오는 중·비어 있음·지연·부분 실패·전체 실패·권한 없음 공통 표시
* aw.toast(text) : 짧은 알림
* aw.confirmDanger(spec) : 파괴적 작업 확인(대상·영향·입력 확인) → Promise<boolean>
* aw.poll(fn, ms) : 긴 작업 상태를 화면을 떠나지 않고 갱신(탭이 숨겨지면 느리게)
* aw.job(el, job) : 작업 상태 블록(현재 단계·완료 단계·다음 단계·재시도·취소·되돌리기) 렌더
*/
window.aw = (() => {
const csrf = () => (document.querySelector('meta[name=csrf]') || {}).content || '';
async function api(method, path, body) {
const r = await fetch(path, { method, headers: { 'Content-Type': 'application/json', 'X-CSRF': csrf() }, body: body === undefined ? undefined : JSON.stringify(body), credentials: 'same-origin' });
let j = null; try { j = await r.json(); } catch (_) {}
if (!r.ok || (j && j.ok === false)) { const e = new Error((j && j.message) || `오류 ${r.status}`); e.code = (j && j.error) || 'http_' + r.status; e.detail = j && j.detail; e.status = r.status; throw e; }
return j;
}
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const LABEL = { loading: '불러오는 중', empty: '비어 있음', stale: '정보 확인 지연', partial: '부분 실패', failed: '전체 실패', forbidden: '권한 없음' };
function state(el, kind, text, opts = {}) { if (typeof el === 'string') el = document.querySelector(el); if (!el) return; el.innerHTML = `<div class="aw-state ${kind}" role="status"><b>${esc(LABEL[kind] || kind)}</b>${text ? `<p>${esc(text)}</p>` : ''}${opts.retry ? '<button class="btn" data-retry>다시 시도</button>' : ''}${opts.href ? `<a class="btn" href="${esc(opts.href)}">${esc(opts.hrefLabel || '이동')}</a>` : ''}</div>`; if (opts.retry) el.querySelector('[data-retry]').onclick = opts.retry; }
let toastT; function toast(text) { const t = document.getElementById('toast'); t.textContent = text; t.classList.add('show'); clearTimeout(toastT); toastT = setTimeout(() => t.classList.remove('show'), 4000); }
function modal(html) { const m = document.getElementById('modal'); m.innerHTML = `<div class="box" role="dialog" aria-modal="true">${html}</div>`; m.hidden = false; m.onclick = (e) => { if (e.target === m) close(); }; function close() { m.hidden = true; m.innerHTML = ''; } return { el: m, close }; }
function confirmDanger(spec) {
return new Promise((resolve) => {
const m = modal(`<h2>${esc(spec.title)}</h2><p><b>대상:</b> ${esc(spec.target)}</p>${spec.impacts && spec.impacts.length ? `<p><b>영향:</b></p><ul>${spec.impacts.map((i) => `<li>${esc(i)}</li>`).join('')}</ul>` : ''}${spec.typeToConfirm ? `<p class="f"><label>확인을 위해 <code>${esc(spec.typeToConfirm)}</code> 을(를) 입력하세요</label><input id="dz-in" autocomplete="off"></p>` : ''}<div class="actions"><button class="btn" id="dz-c">취소</button><button class="btn danger" id="dz-ok" ${spec.typeToConfirm ? 'disabled' : ''}>${esc(spec.confirmLabel || '실행')}</button></div>`);
const okb = m.el.querySelector('#dz-ok'); const inp = m.el.querySelector('#dz-in'); if (inp) inp.oninput = () => { okb.disabled = inp.value.trim() !== spec.typeToConfirm; };
m.el.querySelector('#dz-c').onclick = () => { m.close(); resolve(false); }; okb.onclick = () => { m.close(); resolve(true); };
});
}
function poll(fn, ms = 3000) { let stop = false; (async function loop() { while (!stop) { try { const more = await fn(); if (more === false) break; } catch (e) { /* 화면은 상태 패널이 보여 준다 */ } await new Promise((r) => setTimeout(r, document.hidden ? ms * 4 : ms)); } })(); return () => { stop = true; }; }
const JOB_LABEL = { not_ready: '준비 전', needs_setup: '설정 필요', awaiting_approval: '승인 대기', awaiting_payment: '결제 대기', queued: '처리 대기', running: '진행 중', awaiting_external: '외부 작업 대기', verifying: '검증 중', paused: '일시 중지', done: '완료', failed: '실패', reverting: '되돌리는 중', reverted: '되돌림 완료', needs_user: '사용자 확인 필요', cancelled: '취소됨' };
const SYM = { done: '✔', running: '◌', failed: '✘', waiting: '◔', needs_user: '!', pending: '○' };
function fmt(iso) { if (!iso) return ''; const d = new Date(iso); return isNaN(d) ? iso : d.toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', hour12: false }); }
function job(el, j, { onAction } = {}) {
if (typeof el === 'string') el = document.querySelector(el); if (!el || !j) return;
const kind = ['done'].includes(j.status) ? 'done' : ['failed'].includes(j.status) ? 'failed' : ['running', 'verifying', 'reverting'].includes(j.status) ? 'running' : ['awaiting_external', 'needs_user', 'paused', 'awaiting_approval', 'awaiting_payment'].includes(j.status) ? 'waiting' : 'neutral';
el.innerHTML = `<div class="job"><div class="ph-row"><span class="badge ${kind}">${SYM[kind === 'waiting' ? 'waiting' : kind] || '○'} ${esc(JOB_LABEL[j.status] || j.status)}</span><span class="sub">${j.stale ? '정보 확인 지연 · ' : ''}마지막 갱신 ${esc(fmt(j.updatedAt))}</span></div>
${j.progress ? `<div class="bar"><i style="width:${j.progress}%"></i></div>` : ''}
<ul class="steps">${(j.steps || []).map((s) => `<li class="${esc(s.state)}"><span class="s" aria-hidden="true">${SYM[s.state] || '○'}</span><span>${esc(s.title)}${s.detail ? ` <span class="sub">— ${esc(s.detail)}</span>` : ''}${s.error ? ` <span class="err">— ${esc(s.error)}</span>` : ''}</span></li>`).join('')}</ul>
<dl class="kv">${j.currentStep ? `<dt>현재 단계</dt><dd>${esc(j.currentStep.title)}</dd>` : ''}${j.nextStep ? `<dt>다음 단계</dt><dd>${esc(j.nextStep.title)}</dd>` : ''}<dt>예상 완료</dt><dd>${j.etaAt ? esc(fmt(j.etaAt)) : esc(j.etaUnknownReason || '산정할 수 없음')}</dd>${j.maxAttempts > 1 ? `<dt>자동 재시도</dt><dd>${j.attempt}/${j.maxAttempts}회${j.nextRunAt && j.status === 'queued' ? ` · 다음 ${esc(fmt(j.nextRunAt))}` : ''}</dd>` : ''}${j.waitReason ? `<dt>대기 사유</dt><dd>${esc(j.waitReason)}</dd>` : ''}${j.userMessage ? `<dt>필요한 조치</dt><dd>${esc(j.userMessage)}</dd>` : ''}${j.error ? `<dt>오류</dt><dd class="err">${esc(j.error)} <span class="sub">(문의 번호 ${esc(j.id)})</span></dd>` : ''}</dl>
<div class="actions">${(j.actions || []).map((a) => `<button class="btn primary" data-ja="${esc(a.id)}">${esc(a.label)}</button>`).join('')}${j.canRetry ? '<button class="btn" data-ja="retry">다시 시도</button>' : ''}${j.canResume && !(j.actions || []).length ? '<button class="btn" data-ja="resume">이어서 진행</button>' : ''}${j.canCancel ? '<button class="btn danger" data-ja="cancel">취소</button>' : ''}${j.canRevert ? '<button class="btn danger" data-ja="revert">되돌리기</button>' : ''}</div></div>`;
el.querySelectorAll('[data-ja]').forEach((b) => { b.onclick = () => onAction && onAction(b.dataset.ja, j); });
}
document.addEventListener('click', (e) => { const a = e.target.closest('[data-action]'); if (!a) return; if (a.dataset.action === 'toggle-nav') document.getElementById('side')?.classList.toggle('open'); if (a.classList.contains('copy')) { navigator.clipboard?.writeText(a.dataset.copy || a.textContent); toast('복사했습니다'); } });
document.addEventListener('change', async (e) => { if (e.target.matches('[data-action=switch-group]')) { try { await api('POST', '/api/groups/switch', { groupId: e.target.value }); location.reload(); } catch (err) { toast(err.message); } } });
// 저장하지 않은 변경의 이탈 경고(§9.3): 폼이 data-dirty-guard 를 가지면 입력 뒤 떠날 때 경고
let dirty = false; document.addEventListener('input', (e) => { if (e.target.closest('form[data-dirty-guard]')) dirty = true; }); document.addEventListener('submit', () => { dirty = false; }); window.addEventListener('beforeunload', (e) => { if (dirty) { e.preventDefault(); e.returnValue = ''; } });
return { api, esc, state, toast, modal, confirmDanger, poll, job, fmt, JOB_LABEL, markClean: () => { dirty = false; } };
})();