// Terminas 관리 콘솔 화면 (서버 PC 안에서만). 데이터는 모두 textContent 로 넣는다 — 사람 이름·이메일을 HTML 로 해석하지 않는다. 'use strict'; const KO = /^ko\b/i.test(navigator.language || ''); const T = KO ? { title: 'Terminas 관리 콘솔', onlyHere: '이 화면은 서버 PC 안에서만 열립니다. 볼트 안의 내용은 관리자도 볼 수 없습니다.', noPassword: '관리 비밀번호가 아직 없습니다. 서버 PC 의 터미널에서 정하세요:', password: '관리 비밀번호', otp: 'OTP 코드 (6자리)', login: '로그인', logout: '로그아웃', refresh: '새로고침', modes: { open: '누구나 가입', invite: '초대한 사람만', google: 'Google', password: '아이디·비밀번호' }, stats: { users: '가입한 사람', new7: '7일 동안 새로 가입', active7: '7일 동안 로그인', active30: '30일 동안 로그인', disabled: '막힌 계정', teams: '팀', vaults: '볼트', items: '볼트 항목', sessions: '살아 있는 로그인' }, tabs: { users: '사용자', teams: '팀', log: '서버 기록' }, search: '이메일·이름 검색', filters: { all: '모두', admins: '서버 관리자', disabled: '막힌 계정', nokeys: '암호화 설정 전' }, people: (n) => `${n}명`, cols: { person: '사람', joined: '가입', last: '마지막 로그인', teams: '팀', flags: '표시', actions: '작업', team: '팀', owners: '소유자', members: '팀원', vaults: '볼트', created: '만든 날', time: '시각', who: '누가', action: '동작', target: '대상', detail: '내용' }, never: '없음', flags: { admin: '서버 관리자', disabled: '막힘', mfa: '2단계', nokeys: '암호화 설정 전', google: 'Google', password: '비밀번호' }, act: { disable: '막기', enable: '풀기', grant: '관리자로', revoke: '관리자 해제', signout: '로그아웃', mfa: '2단계 초기화', del: '지우기' }, confirm: { disable: (e) => `${e} 계정을 막을까요? 모든 기기에서 로그아웃됩니다.`, grant: (e) => `${e} 을(를) 서버 관리자로 지정할까요? (팀을 제한 없이 만들 수 있게 됩니다)`, revoke: (e) => `${e} 의 서버 관리자 권한을 뺄까요?`, signout: (e) => `${e} 을(를) 모든 기기에서 로그아웃시킬까요?`, mfa: (e) => `${e} 의 2단계 인증을 끌까요?`, del: (e, teams) => `${e} 계정을 지웁니다. 개인 볼트와 로그인${teams.length ? `, 혼자 있는 팀(${teams.join(', ')})` : ''}이 함께 지워지고 되돌릴 수 없습니다.\n확인을 위해 이메일을 그대로 적어 주세요.`, }, mismatch: '이메일이 맞지 않아 지우지 않았습니다.', done: '했습니다', more: '더 보기', noTeams: '팀이 없습니다.', byConsole: '관리 콘솔', actions: { login: '로그인', logout: '로그아웃', login_rejected: '로그인 거절', team_create: '팀 만듦', team_delete: '팀 삭제', team_rename: '팀 이름 변경', invite_create: '초대', invite_delete: '초대 취소', invite_accept: '초대 수락', invite_code: '초대 코드 새로 만듦', member_add: '팀원 추가', member_role: '역할 변경', member_remove: '팀원 내보냄', mfa_enable: '2단계 인증 켬', mfa_disable: '2단계 인증 끔', mfa_verify: '2단계 인증 통과', mfa_fail: '2단계 인증 실패', mfa_reset: '2단계 인증 초기화', mfa_recovery_new: '복구 코드 새로 만듦', password_set: '로그인 비밀번호 정함', password_change: '로그인 비밀번호 바꿈', keys_setup: '암호화 설정', keys_password: '암호화 비밀번호 변경', keys_reset: '암호화 초기화', keys_recovered: '복구 키로 되살림', keys_new_recovery: '복구 키 새로 받음', admin_user_disable: '계정 막음', admin_user_enable: '계정 막기 풂', admin_grant: '서버 관리자 지정', admin_revoke: '서버 관리자 해제', admin_signout: '모든 기기에서 로그아웃시킴', admin_user_delete: '계정 지움', console_login: '콘솔 로그인', console_login_failed: '콘솔 로그인 실패', console_password: '콘솔 비밀번호 설정', }, } : { title: 'Terminas Admin Console', onlyHere: 'This console only opens on the server machine. Admins cannot see vault contents either.', noPassword: 'No admin password yet. Set one in a terminal on the server machine:', password: 'Admin password', otp: 'OTP code (6 digits)', login: 'Sign in', logout: 'Sign out', refresh: 'Refresh', modes: { open: 'Open sign-up', invite: 'Invite only', google: 'Google', password: 'ID & password' }, stats: { users: 'People', new7: 'New in 7 days', active7: 'Signed in, 7 days', active30: 'Signed in, 30 days', disabled: 'Disabled accounts', teams: 'Teams', vaults: 'Vaults', items: 'Vault items', sessions: 'Active sessions' }, tabs: { users: 'Users', teams: 'Teams', log: 'Server log' }, search: 'Search email or name', filters: { all: 'All', admins: 'Server admins', disabled: 'Disabled', nokeys: 'Encryption not set up' }, people: (n) => `${n} ${n === 1 ? 'person' : 'people'}`, cols: { person: 'Person', joined: 'Joined', last: 'Last sign-in', teams: 'Teams', flags: 'Flags', actions: 'Actions', team: 'Team', owners: 'Owners', members: 'Members', vaults: 'Vaults', created: 'Created', time: 'Time', who: 'Who', action: 'Action', target: 'Target', detail: 'Detail' }, never: 'never', flags: { admin: 'Server admin', disabled: 'Disabled', mfa: '2FA', nokeys: 'No encryption yet', google: 'Google', password: 'Password' }, act: { disable: 'Disable', enable: 'Enable', grant: 'Make admin', revoke: 'Remove admin', signout: 'Sign out', mfa: 'Reset 2FA', del: 'Delete' }, confirm: { disable: (e) => `Disable ${e}? They are signed out everywhere.`, grant: (e) => `Make ${e} a server admin? (They can create teams without limits.)`, revoke: (e) => `Remove server admin from ${e}?`, signout: (e) => `Sign ${e} out on every device?`, mfa: (e) => `Turn off two-factor authentication for ${e}?`, del: (e, teams) => `Delete ${e}? Their personal vault and sessions${teams.length ? `, and teams where they are alone (${teams.join(', ')})` : ''} are deleted. This cannot be undone.\nType the email to confirm.`, }, mismatch: 'The email did not match. Nothing was deleted.', done: 'Done', more: 'Load more', noTeams: 'No teams.', byConsole: 'admin console', actions: {}, }; const app = document.getElementById('app'); document.title = T.title; document.documentElement.lang = KO ? 'ko' : 'en'; // ---------- 작은 도구 ---------- function el(tag, props = {}, ...children) { const node = document.createElement(tag); for (const [k, v] of Object.entries(props)) { if (v === undefined || v === null || v === false) continue; if (k === 'class') node.className = v; else if (k.startsWith('on')) node.addEventListener(k.slice(2), v); else if (k === 'text') node.textContent = v; else node.setAttribute(k, v === true ? '' : String(v)); } for (const c of children.flat()) if (c !== null && c !== undefined && c !== false) node.append(c instanceof Node ? c : document.createTextNode(String(c))); return node; } const fmt = (ts) => (ts ? new Date(ts).toLocaleString(KO ? 'ko-KR' : undefined, { dateStyle: 'medium', timeStyle: 'short' }) : T.never); const day = (ts) => new Date(ts).toLocaleDateString(KO ? 'ko-KR' : undefined, { dateStyle: 'medium' }); async function api(method, path, body) { const res = await fetch(path, { method, credentials: 'same-origin', headers: { 'x-console': '1', ...(body !== undefined ? { 'content-type': 'application/json' } : {}) }, body: body === undefined ? undefined : JSON.stringify(body), }); const data = await res.json().catch(() => null); if (!res.ok) { const err = new Error((data && data.message) || `HTTP ${res.status}`); err.status = res.status; throw err; } return data; } let toastTimer = null; function toast(message, error = false) { document.querySelector('.toast')?.remove(); const t = el('div', { class: `toast${error ? ' error' : ''}`, role: 'status', text: message }); document.body.append(t); clearTimeout(toastTimer); toastTimer = setTimeout(() => t.remove(), 4000); } // ---------- 로그인 ---------- async function start() { let state; try { state = await api('GET', '/admin/api/state'); } catch (err) { app.replaceChildren(el('p', { class: 'error', text: err.message })); return; } if (state.loggedIn) return dashboard(); const error = el('p', { class: 'error' }); const pw = el('input', { type: 'password', placeholder: T.password, autocomplete: 'current-password', 'aria-label': T.password }); const code = state.otp ? el('input', { inputmode: 'numeric', placeholder: T.otp, autocomplete: 'one-time-code', 'aria-label': T.otp }) : null; const form = el( 'form', { class: 'card login', onsubmit: async (e) => { e.preventDefault(); error.textContent = ''; try { await api('POST', '/admin/api/login', { password: pw.value, code: code ? code.value : '' }); dashboard(); } catch (err) { error.textContent = err.message; } }, }, el('h1', { text: T.title }), el('p', { class: 'muted small', text: T.onlyHere }), state.passwordSet ? [pw, code, el('button', { class: 'primary', type: 'submit', text: T.login })] : [el('p', { text: T.noPassword }), el('code', { text: 'npm run console:password -w server' })], error, ); app.replaceChildren(form); if (state.passwordSet) pw.focus(); } // ---------- 관리 화면 ---------- let tab = 'users'; async function dashboard() { const header = el('div', { class: 'head' }, el('h1', { text: T.title })); const modes = el('div', { class: 'head' }); header.append(modes, el('span', { class: 'spacer' }), el('button', { onclick: () => render(), text: T.refresh }), el('button', { onclick: async () => (await api('POST', '/admin/api/logout'), start()), text: T.logout })); const statsBox = el('div', { class: 'stats' }); const tabs = el('div', { class: 'tabs', role: 'tablist' }); const body = el('div', { class: 'card' }); app.replaceChildren(header, statsBox, tabs, body, el('p', { class: 'muted small foot', text: T.onlyHere })); async function render() { try { const s = await api('GET', '/admin/api/stats'); modes.replaceChildren( ...[ el('span', { class: `badge ${s.server.openSignup ? 'accent' : ''}`, text: s.server.openSignup ? T.modes.open : T.modes.invite }), s.server.google && el('span', { class: 'badge', text: T.modes.google }), s.server.passwordLogin && el('span', { class: 'badge', text: T.modes.password }), ].filter(Boolean), ); statsBox.replaceChildren(...Object.entries(T.stats).map(([k, label]) => el('div', { class: 'stat' }, el('b', { text: Number(s[k] ?? 0).toLocaleString() }), el('span', { class: 'muted small', text: label })))); } catch (err) { if (err.status === 401) return start(); toast(err.message, true); } tabs.replaceChildren( ...Object.entries(T.tabs).map(([id, label]) => el('button', { class: tab === id ? 'on' : '', role: 'tab', 'aria-selected': tab === id ? 'true' : 'false', onclick: () => ((tab = id), render()), text: label }), ), ); if (tab === 'users') usersTab(body, render); else if (tab === 'teams') teamsTab(body); else logTab(body); } render(); } // ---------- 사용자 ---------- let query = ''; let filter = 'all'; function usersTab(box, refresh) { const search = el('input', { type: 'search', placeholder: T.search, value: query, 'aria-label': T.search }); const select = el('select', { 'aria-label': 'filter' }, ...Object.entries(T.filters).map(([v, label]) => el('option', { value: v, selected: v === filter, text: label }))); const count = el('p', { class: 'muted small' }); const tbody = el('tbody'); const more = el('div', { class: 'more' }); const table = el( 'div', { class: 'table-wrap' }, el('table', {}, el('thead', {}, el('tr', {}, ...['person', 'joined', 'last', 'teams', 'flags', 'actions'].map((c) => el('th', { text: T.cols[c] })))), tbody), ); box.replaceChildren(el('div', { class: 'toolbar' }, search, select), count, table, more); let users = []; async function load(offset = 0) { try { const r = await api('GET', `/admin/api/users?${new URLSearchParams({ q: query, filter, offset: String(offset) })}`); users = offset ? users.concat(r.users) : r.users; count.textContent = T.people(r.total); tbody.replaceChildren(...users.map(row)); more.replaceChildren(users.length < r.total ? el('button', { onclick: () => load(users.length), text: T.more }) : ''); } catch (err) { if (err.status === 401) return start(); toast(err.message, true); } } let timer = null; search.addEventListener('input', () => { query = search.value.trim(); clearTimeout(timer); timer = setTimeout(() => load(), 250); }); select.addEventListener('change', () => ((filter = select.value), load())); const act = async (message, fn) => { if (message && !confirm(message)) return; try { await fn(); toast(T.done); refresh(); } catch (err) { toast(err.message, true); } }; function row(u) { const flags = [ u.isAdmin && el('span', { class: 'badge accent', text: T.flags.admin }), u.disabled && el('span', { class: 'badge danger', text: T.flags.disabled }), u.mfa && el('span', { class: 'badge ok', text: T.flags.mfa }), !u.keys && el('span', { class: 'badge warn', text: T.flags.nokeys }), u.google && el('span', { class: 'badge', text: T.flags.google }), u.password && el('span', { class: 'badge', text: T.flags.password }), ]; const base = `/admin/api/users/${encodeURIComponent(u.id)}`; const actions = [ u.disabled ? el('button', { class: 'small', onclick: () => act(null, () => api('POST', `${base}/disable`, { disabled: false })), text: T.act.enable }) : el('button', { class: 'small danger', onclick: () => act(T.confirm.disable(u.email), () => api('POST', `${base}/disable`, { disabled: true })), text: T.act.disable }), u.isAdmin ? el('button', { class: 'small', onclick: () => act(T.confirm.revoke(u.email), () => api('POST', `${base}/admin`, { admin: false })), text: T.act.revoke }) : el('button', { class: 'small', onclick: () => act(T.confirm.grant(u.email), () => api('POST', `${base}/admin`, { admin: true })), text: T.act.grant }), el('button', { class: 'small', disabled: !u.sessions, onclick: () => act(T.confirm.signout(u.email), () => api('POST', `${base}/signout`)), text: T.act.signout }), u.mfa && el('button', { class: 'small', onclick: () => act(T.confirm.mfa(u.email), () => api('POST', `${base}/mfa-reset`)), text: T.act.mfa }), el('button', { class: 'small danger', onclick: async () => { const detail = await api('GET', base).catch(() => ({ teams: [] })); const solo = detail.teams.filter((x) => x.role === 'owner' && x.members === 1).map((x) => x.name); const typed = prompt(T.confirm.del(u.email, solo)); if (typed === null) return; if (typed.trim() !== u.email) return toast(T.mismatch, true); await act(null, () => api('DELETE', base, { confirm: u.email })); }, text: T.act.del, }), ]; return el( 'tr', { class: u.disabled ? 'disabled' : '' }, el('td', {}, el('div', { text: u.name || u.email }), el('div', { class: 'muted small', text: u.email })), el('td', { class: 'nowrap muted', text: day(u.createdAt) }), el('td', { class: 'nowrap muted', text: fmt(u.lastLoginAt) }), el('td', { text: String(u.teams) }), el('td', {}, el('div', { class: 'badges' }, ...flags)), el('td', {}, el('div', { class: 'actions' }, ...actions)), ); } load(); } // ---------- 팀 ---------- async function teamsTab(box) { try { const teams = await api('GET', '/admin/api/teams'); if (!teams.length) return box.replaceChildren(el('p', { class: 'muted', text: T.noTeams })); box.replaceChildren( el( 'div', { class: 'table-wrap' }, el( 'table', {}, el('thead', {}, el('tr', {}, ...['team', 'owners', 'members', 'vaults', 'created'].map((c) => el('th', { text: T.cols[c] })))), el( 'tbody', {}, ...teams.map((x) => el('tr', {}, el('td', { text: x.name }), el('td', { class: 'muted', text: x.owners || '—' }), el('td', { text: String(x.members) }), el('td', { text: String(x.vaults) }), el('td', { class: 'nowrap muted', text: day(x.createdAt) })), ), ), ), ), ); } catch (err) { if (err.status === 401) return start(); toast(err.message, true); } } // ---------- 서버 기록 ---------- async function logTab(box) { const tbody = el('tbody'); const more = el('div', { class: 'more' }); box.replaceChildren( el('div', { class: 'table-wrap' }, el('table', {}, el('thead', {}, el('tr', {}, ...['time', 'who', 'action', 'target', 'detail'].map((c) => el('th', { text: T.cols[c] })))), tbody)), more, ); let rows = []; async function load(before) { try { const list = await api('GET', `/admin/api/audit${before ? `?before=${before}` : ''}`); rows = before ? rows.concat(list) : list; tbody.replaceChildren( ...rows.map((e) => { const d = e.detail || {}; const detail = Object.entries(d) .filter(([k]) => k !== 'by') .map(([k, v]) => `${k}: ${typeof v === 'object' ? JSON.stringify(v) : v}`) .join(', '); return el( 'tr', {}, el('td', { class: 'nowrap muted', text: fmt(e.ts) }), el('td', { text: e.userEmail || (d.by === 'console' || String(e.action).startsWith('console_') ? T.byConsole : '—'), title: e.ip || '' }), el('td', {}, el('span', { class: 'badge', text: T.actions[e.action] || e.action })), el('td', { text: e.target || '' }), el('td', { class: 'muted small', text: detail }), ); }), ); more.replaceChildren(list.length === 200 ? el('button', { onclick: () => load(rows[rows.length - 1].id), text: T.more }) : ''); } catch (err) { if (err.status === 401) return start(); toast(err.message, true); } } load(); } start();