포털 인증·계정·사용자 그룹·대시보드·알림 센터·감사 화면·도움말·발송기·스케줄러 (§3·§4·§5·§24)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-10-26 11:26:28 +00:00
co-authored by Claude Fable 5.1
parent 0f11424224
commit c90ac27596
21 changed files with 1005 additions and 0 deletions
+112
View File
@@ -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: `<div id="group-root" data-role="${esc(ctx.membership.role)}" data-me="${esc(ctx.user.id)}">${deps.statePanel('loading')}</div>` })); });
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' ? `<div class="alert warning">${esc(t('groups.inviteInvalid'))}</div>` : !ctx.user ? `<div class="alert info">${esc(t('groups.inviteNeedsLogin'))} (${esc(inv.email)})</div><a class="btn primary" href="/login?next=${encodeURIComponent('/invite/' + ctx.params.token)}">${esc(t('auth.login.title'))}</a> <a class="btn" href="/signup">${esc(t('auth.signup.title'))}</a>`
: `<p><b>${esc(inv.group_name)}</b> 그룹의 <b>${esc(t('groups.roles.' + inv.role))}</b> 역할로 초대받았습니다.</p><button class="btn primary" data-action="accept-invite" data-token="${esc(ctx.params.token)}">참여하기</button><p class="err" id="inv-err"></p>`;
return ctx.html(deps.shell(ctx, { title: '그룹 초대', nav: null, scripts: ['/ui/groups/groups.js'], body: `<div class="card" style="max-width:520px;margin:24px auto"><h2>그룹 초대</h2>${body}</div>` }));
});
// ---------------------------------------------------------------- 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 }; });
}
+38
View File
@@ -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) => `<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();
})();