Files
AriWeb-Suite/portal/groups/api.mjs
T

113 lines
21 KiB
JavaScript

/**
* 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 }; });
}