Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
39 lines
9.3 KiB
JavaScript
39 lines
9.3 KiB
JavaScript
/* 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) => `<option value="${aw.esc(m.userId)}" ${sel === m.userId ? 'selected' : ''}>${aw.esc(m.displayName)} (${aw.esc(m.email)})</option>`).join('');
|
|
root.innerHTML = `
|
|
<div class="card"><h2>그룹 정보</h2><form id="gform" data-dirty-guard><div class="f"><label for="gname">그룹 이름</label><input id="gname" name="name" value="${aw.esc(g.name)}" maxlength="80" ${canAdmin ? '' : 'disabled'}></div><div class="f"><label for="gmail">연락 이메일</label><input id="gmail" name="contactEmail" type="email" value="${aw.esc(g.contactEmail || '')}" ${canAdmin ? '' : 'disabled'}></div><div class="f"><label for="gphone">연락 전화</label><input id="gphone" name="contactPhone" value="${aw.esc(g.contactPhone || '')}" ${canAdmin ? '' : 'disabled'}></div>
|
|
<div class="f"><label for="gbill">결제 담당자</label><select id="gbill" name="billingUserId" ${canAdmin ? '' : 'disabled'}>${memberOpts(g.billingUserId)}</select></div><div class="f"><label for="gtech">기술 담당자</label><select id="gtech" name="techUserId" ${canAdmin ? '' : 'disabled'}>${memberOpts(g.techUserId)}</select></div>${canAdmin ? '<button class="btn primary">저장</button>' : '<p class="hint">그룹 정보는 소유자·관리자만 바꿀 수 있습니다</p>'}</form></div>
|
|
<div class="card"><div class="ph-row"><h2>구성원 (${d.members.length})</h2>${canAdmin ? '<button class="btn primary" data-act="invite">이메일로 초대</button>' : ''}</div>
|
|
<table class="r"><thead><tr><th>이름</th><th>이메일</th><th>별칭</th><th>역할</th><th>상태</th><th></th></tr></thead><tbody>${d.members.map((m) => `<tr><td data-l="이름">${aw.esc(m.displayName)}${m.userId === me ? ' <span class="badge ok">● 나</span>' : ''}</td><td data-l="이메일">${aw.esc(m.email)}</td><td data-l="별칭"><span class="copy" title="별칭은 표시용입니다" data-act="alias" data-uid="${m.userId}">${aw.esc(m.alias || '')} ✎</span></td><td data-l="역할">${canAdmin && m.role !== 'owner' && m.userId !== me ? `<select data-act="role" data-uid="${m.userId}">${d.roles.filter((r) => r !== 'owner').map((r) => `<option value="${r}" ${m.role === r ? 'selected' : ''}>${R(r)}</option>`).join('')}</select>` : R(m.role)}</td><td data-l="상태">${m.status === 'active' ? '<span class="badge ok">● 활성</span>' : '<span class="badge warning">⏸ 정지</span>'}</td><td class="right">${canAdmin && m.role !== 'owner' && m.userId !== me ? (m.status === 'active' ? `<button class="btn" data-act="suspend" data-uid="${m.userId}">정지</button> ` : `<button class="btn" data-act="unsuspend" data-uid="${m.userId}">정지 해제</button> `) + `<button class="btn danger" data-act="remove" data-uid="${m.userId}">제거</button>` : ''}${root.dataset.role === 'owner' && m.userId !== me && m.status === 'active' ? ` <button class="btn" data-act="transfer" data-uid="${m.userId}">소유권 이전</button>` : ''}</td></tr>`).join('')}</tbody></table>
|
|
<p class="hint">별칭(user-01 …)은 화면 표시용이며 로그인과 권한의 기준이 아닙니다.</p></div>
|
|
${canAdmin ? `<div class="card"><h2>초대 대기 (${d.invites.length})</h2>${d.invites.length ? `<table class="r"><thead><tr><th>이메일</th><th>역할</th><th>만료</th><th></th></tr></thead><tbody>${d.invites.map((i) => `<tr><td data-l="이메일">${aw.esc(i.email)}</td><td data-l="역할">${R(i.role)}</td><td data-l="만료">${i.expired ? '<span class="badge failed">✘ 만료됨</span>' : aw.esc(aw.fmt(i.expires_at))}${i.resend_count ? ` <span class="sub">재발송 ${i.resend_count}회</span>` : ''}</td><td class="right"><button class="btn" data-act="resend" data-id="${i.id}">재발송</button> <button class="btn danger" data-act="cancel-invite" data-id="${i.id}">취소</button></td></tr>`).join('')}</tbody></table>` : '<div class="aw-state empty"><b>비어 있음</b></div>'}</div>` : ''}
|
|
${g.kind !== 'personal' && root.dataset.role !== 'owner' ? '<div class="card"><h2>그룹 탈퇴</h2><button class="btn danger" data-act="leave">그룹 탈퇴</button></div>' : ''}`;
|
|
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('<h2>이메일로 초대</h2><form id="inv"><div class="f"><label for="iemail">이메일</label><input id="iemail" type="email" required></div><div class="f"><label for="irole">역할</label><select id="irole"><option value="member">구성원</option><option value="admin">관리자</option><option value="billing">결제 담당</option><option value="viewer">조회 전용</option></select></div><p class="err" id="inv-err"></p><button class="btn primary">초대 보내기</button></form>'); 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();
|
|
})();
|