Ctrl K
ComunidadGuías…

server/src/console/console.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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();