Files
idsync/internal/webui/dist/app.js
T
caowangrenbo e187369821 feat: server 一次性离职确认端点 + WebUI 重构
- 确认端点单元测试:有效/复用/过期/串用/乱写 token 各测一遍,token 即凭证不经 Auth
- WebUI 重建:对齐新 API 的操作类型(含 delete_user)、用户生命周期状态视图、
  概览含离职待确认列表与被跳过告警、「立即快照」与「立即物化」触发按钮、
  部门分组展示 groupId;重编译 app.css/app.js 并提交产物
- 界面文案改指 LLDAP(不再提 OpenLDAP),确认弹窗说明离职才物理删除
2026-09-29 23:56:30 +08:00

460 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// idsync 管理界面。刻意不引入前端框架:页面数量少、状态简单,
// 原生 JS 足以胜任,且省去一整套构建链与依赖升级负担。
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
// 操作类型的中文标签,与后端 syncer.OpKind 对应。
const OP_LABELS = {
create_group: '新建分组',
create_user: '新建用户',
update_user: '更新用户',
delete_user: '删除用户',
add_member: '加入分组',
remove_member: '移出分组',
};
const OP_BADGES = {
create_group: 'badge-success',
create_user: 'badge-success',
update_user: 'badge-info',
delete_user: 'badge-error',
add_member: 'badge-success',
remove_member: 'badge-error',
};
// 用户生命周期状态的中文标注。
const STATE_BADGES = {
active: ['在职', 'badge-success'],
leave_candidate: ['待发离职确认', 'badge-warning'],
pending_confirm: ['待人工确认离职', 'badge-warning'],
confirmed_leave: ['已确认离职', 'badge-error'],
deleted: ['已删除', 'badge-neutral'],
};
// ---------------------------------------------------------------- 主题
function initTheme() {
const saved = localStorage.getItem('idsync-theme');
if (saved === 'light' || saved === 'dark') {
document.documentElement.dataset.theme = saved;
}
$('#themeToggle').addEventListener('click', () => {
const root = document.documentElement;
const next = root.dataset.theme === 'dark' ? 'light' : 'dark';
root.dataset.theme = next;
localStorage.setItem('idsync-theme', next);
});
}
// ---------------------------------------------------------------- HTTP
async function api(path, options = {}) {
const res = await fetch(path, {
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
...options,
});
if (res.status === 401) {
showLogin();
throw new Error('登录状态已失效');
}
let body = null;
try {
body = await res.json();
} catch (_) {
// 非 JSON 响应(如上游超时返回的 HTML),下面按状态码统一处理
}
if (!res.ok) {
throw new Error((body && body.error) || `请求失败(HTTP ${res.status})`);
}
return body;
}
function showError(msg) {
const el = $('#globalError');
el.textContent = msg;
el.classList.remove('hidden');
setTimeout(() => el.classList.add('hidden'), 8000);
}
// ---------------------------------------------------------------- 视图切换
function showLogin() {
$('#loginView').classList.remove('hidden');
$('#loginView').classList.add('flex');
$('#appView').classList.add('hidden');
}
function showApp() {
$('#loginView').classList.add('hidden');
$('#loginView').classList.remove('flex');
$('#appView').classList.remove('hidden');
}
function switchView(name) {
$$('.view').forEach((el) => el.classList.add('hidden'));
const view = $(`#view-${name}`);
if (view) view.classList.remove('hidden');
$$('.tabs .tab').forEach((t) => {
t.classList.toggle('tab-active', t.dataset.view === name);
});
if (name === 'overview') { loadStatus(); loadPending(); }
if (name === 'users') loadUsers();
if (name === 'groups') loadGroups();
if (name === 'plan') loadPlan();
if (name === 'runs') loadRuns();
}
// ---------------------------------------------------------------- 登录
async function doLogin() {
const errEl = $('#loginError');
errEl.classList.add('hidden');
try {
await api('/api/login', {
method: 'POST',
body: JSON.stringify({ username: $('#username').value, password: $('#password').value }),
});
showApp();
switchView('overview');
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove('hidden');
}
}
async function doLogout() {
try {
await api('/api/logout', { method: 'POST' });
} catch (_) { /* 登出失败也要回登录页 */ }
showLogin();
}
// ---------------------------------------------------------------- 概览
async function loadStatus() {
try {
const d = await api('/api/status');
$('#statActive').textContent = d.counts.activeUsers;
$('#statPending').textContent = d.counts.pendingConfirm;
$('#statGroups').textContent = d.counts.departments;
$('#connStatus').innerHTML = [
statusRow('LLDAP 读(LDAP)', d.ldap.ok, d.ldap.error, d.ldap.url),
statusRow('飞书', d.feishu.ok, d.feishu.error, `App ID ${d.feishu.appId}`),
statusRow('LLDAP 写(GraphQL)', d.lldap.baseUrl ? true : false, '',
d.lldap.baseUrl || '未配置'),
].join('');
const skipped = d.counts.skipped || 0;
$('#skippedStatus').textContent = skipped;
$('#skippedStatusWrap').classList.toggle('hidden', skipped === 0);
$('#lastRunInfo').innerHTML = d.lastRun
? `<div class="space-y-1">
<div>时间:${fmtTime(d.lastRun.finishedAt)}</div>
<div>模式:${d.lastRun.mode === 'dry-run' ? '预览(未写入)' : '实际执行'}</div>
<div>变更:新建 ${d.lastRun.created} · 更新 ${d.lastRun.updated} ·
删除 ${d.lastRun.deleted || 0} · 加组 ${d.lastRun.added} · 移组 ${d.lastRun.removed}</div>
</div>`
: '<span class="text-base-content/50">暂无物化记录</span>';
} catch (err) {
showError(err.message);
}
}
function statusRow(name, ok, error, detail) {
const badge = ok
? '<span class="badge badge-success badge-sm">正常</span>'
: '<span class="badge badge-error badge-sm">异常</span>';
const extra = ok
? `<span class="text-base-content/60">${escapeHtml(detail || '')}</span>`
: `<span class="text-error">${escapeHtml(error || '')}</span>`;
return `<div class="flex items-center justify-between gap-3">
<span class="flex items-center gap-2"><span class="font-medium">${name}</span>${badge}</span>
${extra}
</div>`;
}
// ---------------------------------------------------------------- 离职待确认
async function loadPending() {
try {
const d = await api('/api/resign/pending');
const rows = d.pending || [];
$('#pendingRows').innerHTML = rows.length
? rows.map((u) => `
<li class="flex items-center justify-between gap-3 py-1.5 border-b border-base-200 last:border-0">
<div>
<span class="font-medium">${escapeHtml(u.name || u.uid)}</span>
<span class="text-base-content/60 text-sm ml-2">${escapeHtml(u.uid)}</span>
</div>
<span class="text-base-content/40 text-sm whitespace-nowrap">${fmtTime(u.lastSyncedAt)}</span>
</li>`).join('')
: '<li class="text-sm text-base-content/50 py-2">暂无待人工确认的离职。飞书离职卡片确认后,用户才会被删除。</li>';
} catch (err) {
$('#pendingRows').innerHTML = `<li class="text-error text-sm">${escapeHtml(err.message)}</li>`;
}
}
// ---------------------------------------------------------------- 快照
async function doSnapshot() {
const btn = $('#snapshotBtn');
btn.disabled = true;
btn.textContent = '快照中…';
try {
await api('/api/snapshot', { method: 'POST' });
showError('快照完成:已把飞书现状写入镜像并推进离职流程。');
$('#globalError').className = 'alert alert-success text-sm mb-4';
loadPending();
} catch (err) {
showError(err.message);
} finally {
btn.disabled = false;
btn.textContent = '立即快照';
}
}
// ---------------------------------------------------------------- 用户
let usersCache = [];
async function loadUsers() {
try {
const d = await api('/api/users');
usersCache = d.users || [];
renderUsers();
} catch (err) {
showError(err.message);
}
}
function renderUsers() {
const kw = $('#userFilter').value.trim().toLowerCase();
const rows = usersCache.filter((u) => {
if (!kw) return true;
return [u.uid, u.name, u.email].some((v) => (v || '').toLowerCase().includes(kw));
});
$('#userRows').innerHTML = rows.map((u) => {
const st = STATE_BADGES[u.state] || [u.state || '未知', 'badge-neutral'];
return `<tr>
<td class="font-medium">${escapeHtml(u.uid)}</td>
<td>${escapeHtml(u.name || '')}</td>
<td>${escapeHtml(u.email || '')}</td>
<td><span class="badge ${st[1]} badge-sm">${st[0]}</span></td>
<td>${(u.deptIds || []).length}</td>
<td class="text-base-content/60 whitespace-nowrap">${fmtTime(u.lastSyncedAt)}</td>
<td>${u.skipped
? `<span class="badge badge-warning badge-sm" title="${escapeHtml(u.skipReason || '')}">被跳过</span>`
: '<span class="text-base-content/40">—</span>'}</td>
</tr>`;
}).join('');
$('#userEmpty').classList.toggle('hidden', rows.length > 0);
}
// ---------------------------------------------------------------- 部门分组
async function loadGroups() {
try {
const d = await api('/api/groups');
const groups = d.groups || [];
$('#groupRows').innerHTML = groups.map((g) => `
<tr>
<td class="font-medium">${escapeHtml(g.Name || '')}</td>
<td>${escapeHtml(g.DeptID || '—')}</td>
<td>${escapeHtml(g.ParentID || '—')}</td>
<td>${g.GroupID || '—'}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(g.DN || '')}</td>
</tr>`).join('');
$('#groupEmpty').classList.toggle('hidden', groups.length > 0);
} catch (err) {
showError(err.message);
}
}
// ---------------------------------------------------------------- 变更预览
let currentPlan = null;
async function loadPlan() {
$('#planRows').innerHTML = '';
$('#planSummary').innerHTML = '';
$('#planWarnings').classList.add('hidden');
$('#planEmpty').classList.add('hidden');
try {
const plan = await api('/api/plan');
currentPlan = plan;
renderPlan(plan);
} catch (err) {
showError(err.message);
}
}
function renderPlan(plan) {
const s = plan.stats || {};
const cards = [
['新建用户', s.createUser, 'text-success'],
['更新用户', s.updateUser, 'text-info'],
['删除用户', s.deleteUser, 'text-error'],
['新建分组', s.createGroup, 'text-success'],
['加入分组', s.addMember, 'text-success'],
['移出分组', s.removeMember, 'text-error'],
];
$('#planSummary').innerHTML = cards.map(([label, value, cls]) => `
<div class="card bg-base-100 shadow-sm">
<div class="card-body p-4">
<span class="text-xs text-base-content/60">${label}</span>
<span class="text-2xl font-semibold ${value > 0 ? cls : 'text-base-content/30'}">${value || 0}</span>
</div>
</div>`).join('');
const warnings = plan.warnings || [];
if (warnings.length > 0) {
const el = $('#planWarnings');
el.innerHTML = `<div class="alert alert-warning text-sm">
<div>
<div class="font-medium mb-1">需要注意 ${warnings.length} 项</div>
<ul class="list-disc pl-5 space-y-1">
${warnings.map((w) => `<li>${escapeHtml(w)}</li>`).join('')}
</ul>
</div>
</div>`;
el.classList.remove('hidden');
}
const ops = plan.ops || [];
$('#planRows').innerHTML = ops.map((op) => `
<tr>
<td><span class="badge ${OP_BADGES[op.kind] || 'badge-neutral'} badge-sm">${OP_LABELS[op.kind] || op.kind}</span></td>
<td class="font-medium">${escapeHtml(op.target)}</td>
<td>${escapeHtml(op.detail)}</td>
</tr>`).join('');
$('#planEmpty').classList.toggle('hidden', ops.length > 0);
}
async function applyPlan(force = false) {
const modal = document.getElementById('applyModal');
if (modal && typeof modal.close === 'function') modal.close();
try {
const d = await api('/api/sync', {
method: 'POST',
body: JSON.stringify({ dryRun: false, force }),
});
const res = d.result || {};
showError(`物化完成:新建 ${res.Created || 0},更新 ${res.Updated || 0},删除 ${res.Deleted || 0},加组 ${res.Added || 0},移组 ${res.Removed || 0},失败 ${res.Failed || 0}`);
$('#globalError').className = (res.Failed > 0) ? 'alert alert-warning text-sm mb-4' : 'alert alert-success text-sm mb-4';
switchView('runs');
} catch (err) {
showError(err.message);
}
}
// ---------------------------------------------------------------- 物化历史
async function loadRuns() {
try {
const d = await api('/api/runs');
const runs = d.runs || [];
$('#runRows').innerHTML = runs.map((r) => `
<tr>
<td class="whitespace-nowrap">${fmtTime(r.startedAt)}</td>
<td>${r.mode === 'dry-run'
? '<span class="badge badge-ghost badge-sm">预览</span>'
: '<span class="badge badge-primary badge-sm">执行</span>'}</td>
<td>${r.trigger === 'schedule' ? '定时' : '手动'}</td>
<td>${r.status === 'success'
? '<span class="badge badge-success badge-sm">成功</span>'
: '<span class="badge badge-error badge-sm" title="' + escapeHtml(r.errorMsg || '') + '">失败</span>'}</td>
<td>${r.created}</td><td>${r.updated}</td><td>${r.deleted || 0}</td>
<td>${r.added}</td><td>${r.removed}</td>
<td>${r.failed > 0 ? `<span class="text-error">${r.failed}</span>` : '0'}</td>
</tr>`).join('');
$('#runEmpty').classList.toggle('hidden', runs.length > 0);
} catch (err) {
showError(err.message);
}
}
// ---------------------------------------------------------------- 工具
function fmtTime(s) {
if (!s) return '—';
const d = new Date(s);
if (isNaN(d.getTime())) return '—';
const p = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// ---------------------------------------------------------------- 启动
document.addEventListener('DOMContentLoaded', () => {
initTheme();
$('#loginBtn').addEventListener('click', doLogin);
$('#password').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });
$('#username').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });
$('#logoutBtn').addEventListener('click', doLogout);
$$('.tabs .tab').forEach((t) => {
t.addEventListener('click', () => switchView(t.dataset.view));
});
$('#userFilter').addEventListener('input', renderUsers);
$('#reloadUsers').addEventListener('click', loadUsers);
$('#reloadRuns').addEventListener('click', loadRuns);
$('#previewBtn').addEventListener('click', loadPlan);
$('#snapshotBtn').addEventListener('click', doSnapshot);
$('#refreshPending').addEventListener('click', loadPending);
$('#applyBtn').addEventListener('click', () => {
if (!currentPlan) return;
const s = currentPlan.stats || {};
const total = (s.createUser || 0) + (s.updateUser || 0) + (s.deleteUser || 0)
+ (s.createGroup || 0) + (s.addMember || 0) + (s.removeMember || 0);
if (total === 0) {
showError('没有需要应用的变更。');
return;
}
$('#applyModalBody').innerHTML = `
<div class="space-y-1">
<div>新建用户 <strong>${s.createUser || 0}</strong> · 更新用户 <strong>${s.updateUser || 0}</strong></div>
<div>删除用户 <strong>${s.deleteUser || 0}</strong></div>
<div>新建分组 <strong>${s.createGroup || 0}</strong> · 加入分组 <strong>${s.addMember || 0}</strong> · 移出分组 <strong>${s.removeMember || 0}</strong></div>
</div>`;
const modal = document.getElementById('applyModal');
if (modal && typeof modal.showModal === 'function') modal.showModal();
});
$('#applyCancel').addEventListener('click', () => {
const modal = document.getElementById('applyModal');
if (modal && typeof modal.close === 'function') modal.close();
});
$('#applyConfirm').addEventListener('click', () => applyPlan(false));
// 已有会话则直接进入主界面
api('/api/status')
.then(() => { showApp(); switchView('overview'); })
.catch(() => showLogin());
});