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
+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; } };
})();