feat: server 一次性离职确认端点 + WebUI 重构

- 确认端点单元测试:有效/复用/过期/串用/乱写 token 各测一遍,token 即凭证不经 Auth
- WebUI 重建:对齐新 API 的操作类型(含 delete_user)、用户生命周期状态视图、
  概览含离职待确认列表与被跳过告警、「立即快照」与「立即物化」触发按钮、
  部门分组展示 groupId;重编译 app.css/app.js 并提交产物
- 界面文案改指 LLDAP(不再提 OpenLDAP),确认弹窗说明离职才物理删除
This commit is contained in:
2026-09-29 23:56:30 +08:00
parent 0fffdd5ccb
commit e187369821
6 changed files with 346 additions and 127 deletions
+105
View File
@@ -0,0 +1,105 @@
package server
// 一次性离职确认端点的单元测试:有效/过期/复用/串用 token 各测一遍。
// 该端点不经 Auth 中间件,token 即凭证,因此测试直接打到 Server 的 mux。
import (
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"idsync/internal/config"
"idsync/internal/store"
)
func newConfirmServer(t *testing.T, st *store.Store) *Server {
t.Helper()
auth, err := NewAuth("admin", nil, "pw", make([]byte, 32), false)
if err != nil {
t.Fatalf("构造认证失败: %v", err)
}
return New(&Deps{Store: st, Auth: auth, Cfg: &config.Config{}})
}
func confirmGET(s *Server, uid, token string) *httptest.ResponseRecorder {
req := httptest.NewRequest("GET", "/api/resign/confirm?uid="+uid+"&token="+token, nil)
rec := httptest.NewRecorder()
s.mux.ServeHTTP(rec, req)
return rec
}
func seedPendingUser(t *testing.T) *store.Store {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "idsync.db"))
if err != nil {
t.Fatalf("打开 store 失败: %v", err)
}
if err := st.ReplaceMirror(&store.MirrorSnapshot{
Users: []*store.MirrorUser{{UID: "alice", UnionID: "u-alice", Email: "a@x.com"}},
}); err != nil {
t.Fatalf("灌镜像失败: %v", err)
}
for _, fn := range []func() error{
func() error { return st.ToLeaveCandidate("alice") },
func() error { return st.ToPendingConfirm("alice") },
} {
if err := fn(); err != nil {
t.Fatalf("状态转移失败: %v", err)
}
}
return st
}
func assertState(t *testing.T, st *store.Store, want string) {
t.Helper()
m, _ := st.MapUsersByUID()
if u := m["alice"]; u == nil || u.State != want {
t.Fatalf("alice state = %+v, 期望 %s", m["alice"], want)
}
}
func TestResignConfirmEndpoint(t *testing.T) {
st := seedPendingUser(t)
defer st.Close()
s := newConfirmServer(t, st)
// 有效 token:确认成功,状态置 confirmed_leave。
valid, err := st.CreateToken("alice", time.Hour)
if err != nil {
t.Fatalf("创建 token 失败: %v", err)
}
rec := confirmGET(s, "alice", valid.Token)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "已确认离职") {
t.Errorf("有效 token 应成功,code=%d body=%q", rec.Code, rec.Body.String())
}
assertState(t, st, store.StateConfirmedLeave)
// 复用同一 token:一次使用,必须被拒。
rec = confirmGET(s, "alice", valid.Token)
if !strings.Contains(rec.Body.String(), "确认链接无效") {
t.Errorf("复用已用 token 应被拒,body=%q", rec.Body.String())
}
// 过期 token:拒绝。
expired, _ := st.CreateToken("alice", -time.Minute)
rec = confirmGET(s, "alice", expired.Token)
if !strings.Contains(rec.Body.String(), "确认链接无效") {
t.Errorf("过期 token 应被拒,body=%q", rec.Body.String())
}
// uid 与 token 不一致(串用):拒绝。
other, _ := st.CreateToken("alice", time.Hour)
rec = confirmGET(s, "bob", other.Token)
if !strings.Contains(rec.Body.String(), "确认链接无效") {
t.Errorf("uid 与 token 不一致应被拒,body=%q", rec.Body.String())
}
// 乱写的 token:拒绝。
rec = confirmGET(s, "alice", "garbage-token")
if !strings.Contains(rec.Body.String(), "确认链接无效") {
t.Errorf("无效 token 应被拒,body=%q", rec.Body.String())
}
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+91 -54
View File
@@ -8,27 +8,32 @@ const $$ = (sel) => Array.from(document.querySelectorAll(sel));
// 操作类型的中文标签,与后端 syncer.OpKind 对应。 // 操作类型的中文标签,与后端 syncer.OpKind 对应。
const OP_LABELS = { const OP_LABELS = {
create_group: '新建分组',
create_user: '新建用户', create_user: '新建用户',
update_user: '更新用户', update_user: '更新用户',
deactivate_user: '迁入离职 OU', delete_user: '删除用户',
reactivate_user: '恢复用户',
create_group: '新建分组',
update_group: '更新分组',
add_member: '加入分组', add_member: '加入分组',
remove_member: '移出分组', remove_member: '移出分组',
}; };
const OP_BADGES = { const OP_BADGES = {
create_user: 'badge-success',
create_group: 'badge-success', create_group: 'badge-success',
create_user: 'badge-success',
update_user: 'badge-info', update_user: 'badge-info',
update_group: 'badge-info', delete_user: 'badge-error',
reactivate_user: 'badge-warning',
deactivate_user: 'badge-error',
add_member: 'badge-success', add_member: 'badge-success',
remove_member: 'badge-error', 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() { function initTheme() {
@@ -78,10 +83,6 @@ function showError(msg) {
setTimeout(() => el.classList.add('hidden'), 8000); setTimeout(() => el.classList.add('hidden'), 8000);
} }
function hideError() {
$('#globalError').classList.add('hidden');
}
// ---------------------------------------------------------------- 视图切换 // ---------------------------------------------------------------- 视图切换
function showLogin() { function showLogin() {
@@ -105,7 +106,7 @@ function switchView(name) {
t.classList.toggle('tab-active', t.dataset.view === name); t.classList.toggle('tab-active', t.dataset.view === name);
}); });
if (name === 'overview') loadStatus(); if (name === 'overview') { loadStatus(); loadPending(); }
if (name === 'users') loadUsers(); if (name === 'users') loadUsers();
if (name === 'groups') loadGroups(); if (name === 'groups') loadGroups();
if (name === 'plan') loadPlan(); if (name === 'plan') loadPlan();
@@ -117,7 +118,6 @@ function switchView(name) {
async function doLogin() { async function doLogin() {
const errEl = $('#loginError'); const errEl = $('#loginError');
errEl.classList.add('hidden'); errEl.classList.add('hidden');
try { try {
await api('/api/login', { await api('/api/login', {
method: 'POST', method: 'POST',
@@ -134,9 +134,7 @@ async function doLogin() {
async function doLogout() { async function doLogout() {
try { try {
await api('/api/logout', { method: 'POST' }); await api('/api/logout', { method: 'POST' });
} catch (_) { } catch (_) { /* 登出失败也要回登录页 */ }
// 登出失败也要回到登录页,本地状态不该依赖服务端响应
}
showLogin(); showLogin();
} }
@@ -145,24 +143,29 @@ async function doLogout() {
async function loadStatus() { async function loadStatus() {
try { try {
const d = await api('/api/status'); const d = await api('/api/status');
hideError();
$('#statActive').textContent = d.counts.activeUsers; $('#statActive').textContent = d.counts.activeUsers;
$('#statInactive').textContent = d.counts.inactiveUsers; $('#statPending').textContent = d.counts.pendingConfirm;
$('#statGroups').textContent = d.counts.groups; $('#statGroups').textContent = d.counts.departments;
$('#connStatus').innerHTML = [ $('#connStatus').innerHTML = [
statusRow('OpenLDAP', d.ldap.ok, d.ldap.error, d.ldap.url), 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('飞书', d.feishu.ok, d.feishu.error, `App ID ${d.feishu.appId}`),
statusRow('LLDAP 写(GraphQL)', d.lldap.baseUrl ? true : false, '',
d.lldap.baseUrl || '未配置'),
].join(''); ].join('');
const skipped = d.counts.skipped || 0;
$('#skippedStatus').textContent = skipped;
$('#skippedStatusWrap').classList.toggle('hidden', skipped === 0);
$('#lastRunInfo').innerHTML = d.lastRun $('#lastRunInfo').innerHTML = d.lastRun
? `<div class="space-y-1"> ? `<div class="space-y-1">
<div>时间:${fmtTime(d.lastRun.finishedAt)}</div> <div>时间:${fmtTime(d.lastRun.finishedAt)}</div>
<div>模式:${d.lastRun.mode === 'dry-run' ? '预览(未写入)' : '实际执行'}</div> <div>模式:${d.lastRun.mode === 'dry-run' ? '预览(未写入)' : '实际执行'}</div>
<div>变更:新建 ${d.lastRun.created} · 更新 ${d.lastRun.updated} · 加组 ${d.lastRun.added} · 移组 ${d.lastRun.removed}</div> <div>变更:新建 ${d.lastRun.created} · 更新 ${d.lastRun.updated} ·
删除 ${d.lastRun.deleted || 0} · 加组 ${d.lastRun.added} · 移组 ${d.lastRun.removed}</div>
</div>` </div>`
: '<span class="text-base-content/50">暂无成功的同步记录</span>'; : '<span class="text-base-content/50">暂无物化记录</span>';
} catch (err) { } catch (err) {
showError(err.message); showError(err.message);
} }
@@ -181,6 +184,46 @@ function statusRow(name, ok, error, detail) {
</div>`; </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 = []; let usersCache = [];
@@ -190,7 +233,6 @@ async function loadUsers() {
const d = await api('/api/users'); const d = await api('/api/users');
usersCache = d.users || []; usersCache = d.users || [];
renderUsers(); renderUsers();
hideError();
} catch (err) { } catch (err) {
showError(err.message); showError(err.message);
} }
@@ -203,26 +245,25 @@ function renderUsers() {
return [u.uid, u.name, u.email].some((v) => (v || '').toLowerCase().includes(kw)); return [u.uid, u.name, u.email].some((v) => (v || '').toLowerCase().includes(kw));
}); });
$('#userRows').innerHTML = rows.map((u) => ` $('#userRows').innerHTML = rows.map((u) => {
<tr> const st = STATE_BADGES[u.state] || [u.state || '未知', 'badge-neutral'];
return `<tr>
<td class="font-medium">${escapeHtml(u.uid)}</td> <td class="font-medium">${escapeHtml(u.uid)}</td>
<td>${escapeHtml(u.name || '')}</td> <td>${escapeHtml(u.name || '')}</td>
<td>${escapeHtml(u.email || '')}</td> <td>${escapeHtml(u.email || '')}</td>
<td>${u.state === 'inactive' <td><span class="badge ${st[1]} badge-sm">${st[0]}</span></td>
? '<span class="badge badge-error badge-sm">离职</span>' <td>${(u.deptIds || []).length}</td>
: '<span class="badge badge-success badge-sm">在职</span>'}</td>
<td>${(u.deptIds || '').split(',').filter(Boolean).length}</td>
<td class="text-base-content/60 whitespace-nowrap">${fmtTime(u.lastSyncedAt)}</td> <td class="text-base-content/60 whitespace-nowrap">${fmtTime(u.lastSyncedAt)}</td>
<td>${u.drift <td>${u.skipped
? `<span class="badge badge-warning badge-sm" title="${escapeHtml(u.drift)}">需关注</span>` ? `<span class="badge badge-warning badge-sm" title="${escapeHtml(u.skipReason || '')}">被跳过</span>`
: '<span class="text-base-content/40">—</span>'}</td> : '<span class="text-base-content/40">—</span>'}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(u.dn)}</td> </tr>`;
</tr>`).join(''); }).join('');
$('#userEmpty').classList.toggle('hidden', rows.length > 0); $('#userEmpty').classList.toggle('hidden', rows.length > 0);
} }
// ---------------------------------------------------------------- 分组 // ---------------------------------------------------------------- 部门分组
async function loadGroups() { async function loadGroups() {
try { try {
@@ -230,13 +271,13 @@ async function loadGroups() {
const groups = d.groups || []; const groups = d.groups || [];
$('#groupRows').innerHTML = groups.map((g) => ` $('#groupRows').innerHTML = groups.map((g) => `
<tr> <tr>
<td class="font-medium">${escapeHtml(g.name || '')}</td> <td class="font-medium">${escapeHtml(g.Name || '')}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(g.deptId || '')}</td> <td>${escapeHtml(g.DeptID || '—')}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(g.parentId || '—')}</td> <td>${escapeHtml(g.ParentID || '—')}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(g.dn || '')}</td> <td>${g.GroupID || '—'}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(g.DN || '')}</td>
</tr>`).join(''); </tr>`).join('');
$('#groupEmpty').classList.toggle('hidden', groups.length > 0); $('#groupEmpty').classList.toggle('hidden', groups.length > 0);
hideError();
} catch (err) { } catch (err) {
showError(err.message); showError(err.message);
} }
@@ -251,12 +292,10 @@ async function loadPlan() {
$('#planSummary').innerHTML = ''; $('#planSummary').innerHTML = '';
$('#planWarnings').classList.add('hidden'); $('#planWarnings').classList.add('hidden');
$('#planEmpty').classList.add('hidden'); $('#planEmpty').classList.add('hidden');
try { try {
const plan = await api('/api/plan'); const plan = await api('/api/plan');
currentPlan = plan; currentPlan = plan;
renderPlan(plan); renderPlan(plan);
hideError();
} catch (err) { } catch (err) {
showError(err.message); showError(err.message);
} }
@@ -264,12 +303,10 @@ async function loadPlan() {
function renderPlan(plan) { function renderPlan(plan) {
const s = plan.stats || {}; const s = plan.stats || {};
const cards = [ const cards = [
['新建用户', s.createUser, 'text-success'], ['新建用户', s.createUser, 'text-success'],
['更新用户', s.updateUser, 'text-info'], ['更新用户', s.updateUser, 'text-info'],
['迁入离职', s.deactivateUser, 'text-error'], ['删除用户', s.deleteUser, 'text-error'],
['恢复用户', s.reactivateUser, 'text-warning'],
['新建分组', s.createGroup, 'text-success'], ['新建分组', s.createGroup, 'text-success'],
['加入分组', s.addMember, 'text-success'], ['加入分组', s.addMember, 'text-success'],
['移出分组', s.removeMember, 'text-error'], ['移出分组', s.removeMember, 'text-error'],
@@ -311,14 +348,13 @@ function renderPlan(plan) {
async function applyPlan(force = false) { async function applyPlan(force = false) {
const modal = document.getElementById('applyModal'); const modal = document.getElementById('applyModal');
if (modal && typeof modal.close === 'function') modal.close(); if (modal && typeof modal.close === 'function') modal.close();
try { try {
const d = await api('/api/sync', { const d = await api('/api/sync', {
method: 'POST', method: 'POST',
body: JSON.stringify({ dryRun: false, force }), body: JSON.stringify({ dryRun: false, force }),
}); });
const res = d.result || {}; const res = d.result || {};
showError(`同步完成:新建 ${res.Created || 0},更新 ${res.Updated || 0},加组 ${res.Added || 0},移组 ${res.Removed || 0},失败 ${res.Failed || 0}`); 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'; $('#globalError').className = (res.Failed > 0) ? 'alert alert-warning text-sm mb-4' : 'alert alert-success text-sm mb-4';
switchView('runs'); switchView('runs');
} catch (err) { } catch (err) {
@@ -326,7 +362,7 @@ async function applyPlan(force = false) {
} }
} }
// ---------------------------------------------------------------- 同步历史 // ---------------------------------------------------------------- 物化历史
async function loadRuns() { async function loadRuns() {
try { try {
@@ -342,12 +378,11 @@ async function loadRuns() {
<td>${r.status === 'success' <td>${r.status === 'success'
? '<span class="badge badge-success badge-sm">成功</span>' ? '<span class="badge badge-success badge-sm">成功</span>'
: '<span class="badge badge-error badge-sm" title="' + escapeHtml(r.errorMsg || '') + '">失败</span>'}</td> : '<span class="badge badge-error badge-sm" title="' + escapeHtml(r.errorMsg || '') + '">失败</span>'}</td>
<td>${r.created}</td><td>${r.updated}</td><td>${r.moved}</td> <td>${r.created}</td><td>${r.updated}</td><td>${r.deleted || 0}</td>
<td>${r.added}</td><td>${r.removed}</td> <td>${r.added}</td><td>${r.removed}</td>
<td>${r.failed > 0 ? `<span class="text-error">${r.failed}</span>` : '0'}</td> <td>${r.failed > 0 ? `<span class="text-error">${r.failed}</span>` : '0'}</td>
</tr>`).join(''); </tr>`).join('');
$('#runEmpty').classList.toggle('hidden', runs.length > 0); $('#runEmpty').classList.toggle('hidden', runs.length > 0);
hideError();
} catch (err) { } catch (err) {
showError(err.message); showError(err.message);
} }
@@ -390,12 +425,14 @@ document.addEventListener('DOMContentLoaded', () => {
$('#reloadUsers').addEventListener('click', loadUsers); $('#reloadUsers').addEventListener('click', loadUsers);
$('#reloadRuns').addEventListener('click', loadRuns); $('#reloadRuns').addEventListener('click', loadRuns);
$('#previewBtn').addEventListener('click', loadPlan); $('#previewBtn').addEventListener('click', loadPlan);
$('#snapshotBtn').addEventListener('click', doSnapshot);
$('#refreshPending').addEventListener('click', loadPending);
$('#applyBtn').addEventListener('click', () => { $('#applyBtn').addEventListener('click', () => {
if (!currentPlan) return; if (!currentPlan) return;
const s = currentPlan.stats || {}; const s = currentPlan.stats || {};
const total = (s.createUser || 0) + (s.updateUser || 0) + (s.deactivateUser || 0) const total = (s.createUser || 0) + (s.updateUser || 0) + (s.deleteUser || 0)
+ (s.reactivateUser || 0) + (s.createGroup || 0) + (s.addMember || 0) + (s.removeMember || 0); + (s.createGroup || 0) + (s.addMember || 0) + (s.removeMember || 0);
if (total === 0) { if (total === 0) {
showError('没有需要应用的变更。'); showError('没有需要应用的变更。');
return; return;
@@ -403,7 +440,7 @@ document.addEventListener('DOMContentLoaded', () => {
$('#applyModalBody').innerHTML = ` $('#applyModalBody').innerHTML = `
<div class="space-y-1"> <div class="space-y-1">
<div>新建用户 <strong>${s.createUser || 0}</strong> · 更新用户 <strong>${s.updateUser || 0}</strong></div> <div>新建用户 <strong>${s.createUser || 0}</strong> · 更新用户 <strong>${s.updateUser || 0}</strong></div>
<div>迁入离职 OU <strong>${s.deactivateUser || 0}</strong> · 恢复用户 <strong>${s.reactivateUser || 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>新建分组 <strong>${s.createGroup || 0}</strong> · 加入分组 <strong>${s.addMember || 0}</strong> · 移出分组 <strong>${s.removeMember || 0}</strong></div>
</div>`; </div>`;
const modal = document.getElementById('applyModal'); const modal = document.getElementById('applyModal');
+27 -7
View File
@@ -16,7 +16,7 @@
<span class="inline-block size-3 rounded-full bg-primary"></span> <span class="inline-block size-3 rounded-full bg-primary"></span>
<h1 class="card-title text-lg m-0">idsync</h1> <h1 class="card-title text-lg m-0">idsync</h1>
</div> </div>
<p class="text-sm text-base-content/60 mb-4">飞书通讯录 → OpenLDAP 同步</p> <p class="text-sm text-base-content/60 mb-4">飞书通讯录 → LLDAP 同步</p>
<div id="loginError" class="alert alert-error text-sm mb-3 hidden"></div> <div id="loginError" class="alert alert-error text-sm mb-3 hidden"></div>
@@ -37,7 +37,7 @@
<div class="flex-1 flex items-center gap-2"> <div class="flex-1 flex items-center gap-2">
<span class="inline-block size-3 rounded-full bg-primary"></span> <span class="inline-block size-3 rounded-full bg-primary"></span>
<span class="text-lg font-semibold">idsync</span> <span class="text-lg font-semibold">idsync</span>
<span class="hidden sm:inline text-sm text-base-content/50">飞书通讯录 → OpenLDAP</span> <span class="hidden sm:inline text-sm text-base-content/50">飞书通讯录 → LLDAP</span>
</div> </div>
<div class="flex-none flex items-center gap-2"> <div class="flex-none flex items-center gap-2">
<button id="themeToggle" class="btn btn-sm btn-ghost" title="切换明暗主题">主题</button> <button id="themeToggle" class="btn btn-sm btn-ghost" title="切换明暗主题">主题</button>
@@ -68,8 +68,8 @@
</div> </div>
<div class="card bg-base-100 shadow-sm"> <div class="card bg-base-100 shadow-sm">
<div class="card-body"> <div class="card-body">
<span class="text-sm text-base-content/60">离职账号</span> <span class="text-sm text-base-content/60">在飞书可见/待确认离职</span>
<span id="statInactive" class="text-3xl font-semibold">–</span> <span id="statPending" class="text-3xl font-semibold">–</span>
</div> </div>
</div> </div>
<div class="card bg-base-100 shadow-sm"> <div class="card bg-base-100 shadow-sm">
@@ -85,15 +85,33 @@
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-base">服务状态</h2> <h2 class="card-title text-base">服务状态</h2>
<div id="connStatus" class="space-y-2 text-sm"></div> <div id="connStatus" class="space-y-2 text-sm"></div>
<div id="skippedStatusWrap" class="hidden mt-2 text-sm rounded-lg bg-warning/10 px-3 py-2">
有 <span id="skippedStatus" class="font-semibold"></span> 名用户因缺少企业邮箱被跳过,
请到用户页查看并处理,避免他们一直没被同步。
</div>
</div> </div>
</div> </div>
<div class="card bg-base-100 shadow-sm"> <div class="card bg-base-100 shadow-sm">
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-base">上次同步</h2> <h2 class="card-title text-base">上次物化</h2>
<div id="lastRunInfo" class="text-sm text-base-content/70">暂无记录</div> <div id="lastRunInfo" class="text-sm text-base-content/70">暂无记录</div>
</div> </div>
</div> </div>
</div> </div>
<!-- 离职待确认 -->
<div class="card bg-base-100 shadow-sm mt-4">
<div class="card-body">
<div class="flex items-center justify-between mb-1">
<h2 class="card-title text-base">离职待确认</h2>
<button id="refreshPending" class="btn btn-sm btn-outline">刷新</button>
</div>
<p class="text-sm text-base-content/60 mb-2">
这些用户已在飞书确认离职并收到确认卡片,等待人工点击确认后才会被删除。
</p>
<ul id="pendingRows" class="divide-y divide-base-200"></ul>
</div>
</div>
</section> </section>
<!-- 用户 --> <!-- 用户 -->
@@ -153,6 +171,7 @@
</p> </p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button id="snapshotBtn" class="btn btn-sm btn-neutral">立即快照</button>
<button id="previewBtn" class="btn btn-sm btn-outline">重新计算</button> <button id="previewBtn" class="btn btn-sm btn-outline">重新计算</button>
<button id="applyBtn" class="btn btn-sm btn-primary">应用变更</button> <button id="applyBtn" class="btn btn-sm btn-primary">应用变更</button>
</div> </div>
@@ -212,8 +231,9 @@
<dialog id="applyModal" class="modal"> <dialog id="applyModal" class="modal">
<div class="modal-box"> <div class="modal-box">
<h3 class="text-lg font-semibold mb-2">确认应用变更</h3> <h3 class="text-lg font-semibold mb-2">确认应用变更</h3>
<p class="text-sm text-base-content/70 mb-4"> <p class="text-sm text-base-content/70 mb-4">
将对 OpenLDAP 执行下列变更。用户密码不会被修改,Authelia 重置的密码不受影响。 将对 LLDAP 执行下列变更。密码由 LLDAP 独占并交给 Authelia 管理,程序绝不写入;
仅标记「已确认离职」的用户会被物理删除。
</p> </p>
<div id="applyModalBody" class="text-sm mb-4"></div> <div id="applyModalBody" class="text-sm mb-4"></div>
<div class="modal-action"> <div class="modal-action">
+91 -54
View File
@@ -8,27 +8,32 @@ const $$ = (sel) => Array.from(document.querySelectorAll(sel));
// 操作类型的中文标签,与后端 syncer.OpKind 对应。 // 操作类型的中文标签,与后端 syncer.OpKind 对应。
const OP_LABELS = { const OP_LABELS = {
create_group: '新建分组',
create_user: '新建用户', create_user: '新建用户',
update_user: '更新用户', update_user: '更新用户',
deactivate_user: '迁入离职 OU', delete_user: '删除用户',
reactivate_user: '恢复用户',
create_group: '新建分组',
update_group: '更新分组',
add_member: '加入分组', add_member: '加入分组',
remove_member: '移出分组', remove_member: '移出分组',
}; };
const OP_BADGES = { const OP_BADGES = {
create_user: 'badge-success',
create_group: 'badge-success', create_group: 'badge-success',
create_user: 'badge-success',
update_user: 'badge-info', update_user: 'badge-info',
update_group: 'badge-info', delete_user: 'badge-error',
reactivate_user: 'badge-warning',
deactivate_user: 'badge-error',
add_member: 'badge-success', add_member: 'badge-success',
remove_member: 'badge-error', 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() { function initTheme() {
@@ -78,10 +83,6 @@ function showError(msg) {
setTimeout(() => el.classList.add('hidden'), 8000); setTimeout(() => el.classList.add('hidden'), 8000);
} }
function hideError() {
$('#globalError').classList.add('hidden');
}
// ---------------------------------------------------------------- 视图切换 // ---------------------------------------------------------------- 视图切换
function showLogin() { function showLogin() {
@@ -105,7 +106,7 @@ function switchView(name) {
t.classList.toggle('tab-active', t.dataset.view === name); t.classList.toggle('tab-active', t.dataset.view === name);
}); });
if (name === 'overview') loadStatus(); if (name === 'overview') { loadStatus(); loadPending(); }
if (name === 'users') loadUsers(); if (name === 'users') loadUsers();
if (name === 'groups') loadGroups(); if (name === 'groups') loadGroups();
if (name === 'plan') loadPlan(); if (name === 'plan') loadPlan();
@@ -117,7 +118,6 @@ function switchView(name) {
async function doLogin() { async function doLogin() {
const errEl = $('#loginError'); const errEl = $('#loginError');
errEl.classList.add('hidden'); errEl.classList.add('hidden');
try { try {
await api('/api/login', { await api('/api/login', {
method: 'POST', method: 'POST',
@@ -134,9 +134,7 @@ async function doLogin() {
async function doLogout() { async function doLogout() {
try { try {
await api('/api/logout', { method: 'POST' }); await api('/api/logout', { method: 'POST' });
} catch (_) { } catch (_) { /* 登出失败也要回登录页 */ }
// 登出失败也要回到登录页,本地状态不该依赖服务端响应
}
showLogin(); showLogin();
} }
@@ -145,24 +143,29 @@ async function doLogout() {
async function loadStatus() { async function loadStatus() {
try { try {
const d = await api('/api/status'); const d = await api('/api/status');
hideError();
$('#statActive').textContent = d.counts.activeUsers; $('#statActive').textContent = d.counts.activeUsers;
$('#statInactive').textContent = d.counts.inactiveUsers; $('#statPending').textContent = d.counts.pendingConfirm;
$('#statGroups').textContent = d.counts.groups; $('#statGroups').textContent = d.counts.departments;
$('#connStatus').innerHTML = [ $('#connStatus').innerHTML = [
statusRow('OpenLDAP', d.ldap.ok, d.ldap.error, d.ldap.url), 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('飞书', d.feishu.ok, d.feishu.error, `App ID ${d.feishu.appId}`),
statusRow('LLDAP 写(GraphQL)', d.lldap.baseUrl ? true : false, '',
d.lldap.baseUrl || '未配置'),
].join(''); ].join('');
const skipped = d.counts.skipped || 0;
$('#skippedStatus').textContent = skipped;
$('#skippedStatusWrap').classList.toggle('hidden', skipped === 0);
$('#lastRunInfo').innerHTML = d.lastRun $('#lastRunInfo').innerHTML = d.lastRun
? `<div class="space-y-1"> ? `<div class="space-y-1">
<div>时间:${fmtTime(d.lastRun.finishedAt)}</div> <div>时间:${fmtTime(d.lastRun.finishedAt)}</div>
<div>模式:${d.lastRun.mode === 'dry-run' ? '预览(未写入)' : '实际执行'}</div> <div>模式:${d.lastRun.mode === 'dry-run' ? '预览(未写入)' : '实际执行'}</div>
<div>变更:新建 ${d.lastRun.created} · 更新 ${d.lastRun.updated} · 加组 ${d.lastRun.added} · 移组 ${d.lastRun.removed}</div> <div>变更:新建 ${d.lastRun.created} · 更新 ${d.lastRun.updated} ·
删除 ${d.lastRun.deleted || 0} · 加组 ${d.lastRun.added} · 移组 ${d.lastRun.removed}</div>
</div>` </div>`
: '<span class="text-base-content/50">暂无成功的同步记录</span>'; : '<span class="text-base-content/50">暂无物化记录</span>';
} catch (err) { } catch (err) {
showError(err.message); showError(err.message);
} }
@@ -181,6 +184,46 @@ function statusRow(name, ok, error, detail) {
</div>`; </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 = []; let usersCache = [];
@@ -190,7 +233,6 @@ async function loadUsers() {
const d = await api('/api/users'); const d = await api('/api/users');
usersCache = d.users || []; usersCache = d.users || [];
renderUsers(); renderUsers();
hideError();
} catch (err) { } catch (err) {
showError(err.message); showError(err.message);
} }
@@ -203,26 +245,25 @@ function renderUsers() {
return [u.uid, u.name, u.email].some((v) => (v || '').toLowerCase().includes(kw)); return [u.uid, u.name, u.email].some((v) => (v || '').toLowerCase().includes(kw));
}); });
$('#userRows').innerHTML = rows.map((u) => ` $('#userRows').innerHTML = rows.map((u) => {
<tr> const st = STATE_BADGES[u.state] || [u.state || '未知', 'badge-neutral'];
return `<tr>
<td class="font-medium">${escapeHtml(u.uid)}</td> <td class="font-medium">${escapeHtml(u.uid)}</td>
<td>${escapeHtml(u.name || '')}</td> <td>${escapeHtml(u.name || '')}</td>
<td>${escapeHtml(u.email || '')}</td> <td>${escapeHtml(u.email || '')}</td>
<td>${u.state === 'inactive' <td><span class="badge ${st[1]} badge-sm">${st[0]}</span></td>
? '<span class="badge badge-error badge-sm">离职</span>' <td>${(u.deptIds || []).length}</td>
: '<span class="badge badge-success badge-sm">在职</span>'}</td>
<td>${(u.deptIds || '').split(',').filter(Boolean).length}</td>
<td class="text-base-content/60 whitespace-nowrap">${fmtTime(u.lastSyncedAt)}</td> <td class="text-base-content/60 whitespace-nowrap">${fmtTime(u.lastSyncedAt)}</td>
<td>${u.drift <td>${u.skipped
? `<span class="badge badge-warning badge-sm" title="${escapeHtml(u.drift)}">需关注</span>` ? `<span class="badge badge-warning badge-sm" title="${escapeHtml(u.skipReason || '')}">被跳过</span>`
: '<span class="text-base-content/40">—</span>'}</td> : '<span class="text-base-content/40">—</span>'}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(u.dn)}</td> </tr>`;
</tr>`).join(''); }).join('');
$('#userEmpty').classList.toggle('hidden', rows.length > 0); $('#userEmpty').classList.toggle('hidden', rows.length > 0);
} }
// ---------------------------------------------------------------- 分组 // ---------------------------------------------------------------- 部门分组
async function loadGroups() { async function loadGroups() {
try { try {
@@ -230,13 +271,13 @@ async function loadGroups() {
const groups = d.groups || []; const groups = d.groups || [];
$('#groupRows').innerHTML = groups.map((g) => ` $('#groupRows').innerHTML = groups.map((g) => `
<tr> <tr>
<td class="font-medium">${escapeHtml(g.name || '')}</td> <td class="font-medium">${escapeHtml(g.Name || '')}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(g.deptId || '')}</td> <td>${escapeHtml(g.DeptID || '—')}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(g.parentId || '—')}</td> <td>${escapeHtml(g.ParentID || '—')}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(g.dn || '')}</td> <td>${g.GroupID || '—'}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(g.DN || '')}</td>
</tr>`).join(''); </tr>`).join('');
$('#groupEmpty').classList.toggle('hidden', groups.length > 0); $('#groupEmpty').classList.toggle('hidden', groups.length > 0);
hideError();
} catch (err) { } catch (err) {
showError(err.message); showError(err.message);
} }
@@ -251,12 +292,10 @@ async function loadPlan() {
$('#planSummary').innerHTML = ''; $('#planSummary').innerHTML = '';
$('#planWarnings').classList.add('hidden'); $('#planWarnings').classList.add('hidden');
$('#planEmpty').classList.add('hidden'); $('#planEmpty').classList.add('hidden');
try { try {
const plan = await api('/api/plan'); const plan = await api('/api/plan');
currentPlan = plan; currentPlan = plan;
renderPlan(plan); renderPlan(plan);
hideError();
} catch (err) { } catch (err) {
showError(err.message); showError(err.message);
} }
@@ -264,12 +303,10 @@ async function loadPlan() {
function renderPlan(plan) { function renderPlan(plan) {
const s = plan.stats || {}; const s = plan.stats || {};
const cards = [ const cards = [
['新建用户', s.createUser, 'text-success'], ['新建用户', s.createUser, 'text-success'],
['更新用户', s.updateUser, 'text-info'], ['更新用户', s.updateUser, 'text-info'],
['迁入离职', s.deactivateUser, 'text-error'], ['删除用户', s.deleteUser, 'text-error'],
['恢复用户', s.reactivateUser, 'text-warning'],
['新建分组', s.createGroup, 'text-success'], ['新建分组', s.createGroup, 'text-success'],
['加入分组', s.addMember, 'text-success'], ['加入分组', s.addMember, 'text-success'],
['移出分组', s.removeMember, 'text-error'], ['移出分组', s.removeMember, 'text-error'],
@@ -311,14 +348,13 @@ function renderPlan(plan) {
async function applyPlan(force = false) { async function applyPlan(force = false) {
const modal = document.getElementById('applyModal'); const modal = document.getElementById('applyModal');
if (modal && typeof modal.close === 'function') modal.close(); if (modal && typeof modal.close === 'function') modal.close();
try { try {
const d = await api('/api/sync', { const d = await api('/api/sync', {
method: 'POST', method: 'POST',
body: JSON.stringify({ dryRun: false, force }), body: JSON.stringify({ dryRun: false, force }),
}); });
const res = d.result || {}; const res = d.result || {};
showError(`同步完成:新建 ${res.Created || 0},更新 ${res.Updated || 0},加组 ${res.Added || 0},移组 ${res.Removed || 0},失败 ${res.Failed || 0}`); 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'; $('#globalError').className = (res.Failed > 0) ? 'alert alert-warning text-sm mb-4' : 'alert alert-success text-sm mb-4';
switchView('runs'); switchView('runs');
} catch (err) { } catch (err) {
@@ -326,7 +362,7 @@ async function applyPlan(force = false) {
} }
} }
// ---------------------------------------------------------------- 同步历史 // ---------------------------------------------------------------- 物化历史
async function loadRuns() { async function loadRuns() {
try { try {
@@ -342,12 +378,11 @@ async function loadRuns() {
<td>${r.status === 'success' <td>${r.status === 'success'
? '<span class="badge badge-success badge-sm">成功</span>' ? '<span class="badge badge-success badge-sm">成功</span>'
: '<span class="badge badge-error badge-sm" title="' + escapeHtml(r.errorMsg || '') + '">失败</span>'}</td> : '<span class="badge badge-error badge-sm" title="' + escapeHtml(r.errorMsg || '') + '">失败</span>'}</td>
<td>${r.created}</td><td>${r.updated}</td><td>${r.moved}</td> <td>${r.created}</td><td>${r.updated}</td><td>${r.deleted || 0}</td>
<td>${r.added}</td><td>${r.removed}</td> <td>${r.added}</td><td>${r.removed}</td>
<td>${r.failed > 0 ? `<span class="text-error">${r.failed}</span>` : '0'}</td> <td>${r.failed > 0 ? `<span class="text-error">${r.failed}</span>` : '0'}</td>
</tr>`).join(''); </tr>`).join('');
$('#runEmpty').classList.toggle('hidden', runs.length > 0); $('#runEmpty').classList.toggle('hidden', runs.length > 0);
hideError();
} catch (err) { } catch (err) {
showError(err.message); showError(err.message);
} }
@@ -390,12 +425,14 @@ document.addEventListener('DOMContentLoaded', () => {
$('#reloadUsers').addEventListener('click', loadUsers); $('#reloadUsers').addEventListener('click', loadUsers);
$('#reloadRuns').addEventListener('click', loadRuns); $('#reloadRuns').addEventListener('click', loadRuns);
$('#previewBtn').addEventListener('click', loadPlan); $('#previewBtn').addEventListener('click', loadPlan);
$('#snapshotBtn').addEventListener('click', doSnapshot);
$('#refreshPending').addEventListener('click', loadPending);
$('#applyBtn').addEventListener('click', () => { $('#applyBtn').addEventListener('click', () => {
if (!currentPlan) return; if (!currentPlan) return;
const s = currentPlan.stats || {}; const s = currentPlan.stats || {};
const total = (s.createUser || 0) + (s.updateUser || 0) + (s.deactivateUser || 0) const total = (s.createUser || 0) + (s.updateUser || 0) + (s.deleteUser || 0)
+ (s.reactivateUser || 0) + (s.createGroup || 0) + (s.addMember || 0) + (s.removeMember || 0); + (s.createGroup || 0) + (s.addMember || 0) + (s.removeMember || 0);
if (total === 0) { if (total === 0) {
showError('没有需要应用的变更。'); showError('没有需要应用的变更。');
return; return;
@@ -403,7 +440,7 @@ document.addEventListener('DOMContentLoaded', () => {
$('#applyModalBody').innerHTML = ` $('#applyModalBody').innerHTML = `
<div class="space-y-1"> <div class="space-y-1">
<div>新建用户 <strong>${s.createUser || 0}</strong> · 更新用户 <strong>${s.updateUser || 0}</strong></div> <div>新建用户 <strong>${s.createUser || 0}</strong> · 更新用户 <strong>${s.updateUser || 0}</strong></div>
<div>迁入离职 OU <strong>${s.deactivateUser || 0}</strong> · 恢复用户 <strong>${s.reactivateUser || 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>新建分组 <strong>${s.createGroup || 0}</strong> · 加入分组 <strong>${s.addMember || 0}</strong> · 移出分组 <strong>${s.removeMember || 0}</strong></div>
</div>`; </div>`;
const modal = document.getElementById('applyModal'); const modal = document.getElementById('applyModal');
+27 -7
View File
@@ -16,7 +16,7 @@
<span class="inline-block size-3 rounded-full bg-primary"></span> <span class="inline-block size-3 rounded-full bg-primary"></span>
<h1 class="card-title text-lg m-0">idsync</h1> <h1 class="card-title text-lg m-0">idsync</h1>
</div> </div>
<p class="text-sm text-base-content/60 mb-4">飞书通讯录 → OpenLDAP 同步</p> <p class="text-sm text-base-content/60 mb-4">飞书通讯录 → LLDAP 同步</p>
<div id="loginError" class="alert alert-error text-sm mb-3 hidden"></div> <div id="loginError" class="alert alert-error text-sm mb-3 hidden"></div>
@@ -37,7 +37,7 @@
<div class="flex-1 flex items-center gap-2"> <div class="flex-1 flex items-center gap-2">
<span class="inline-block size-3 rounded-full bg-primary"></span> <span class="inline-block size-3 rounded-full bg-primary"></span>
<span class="text-lg font-semibold">idsync</span> <span class="text-lg font-semibold">idsync</span>
<span class="hidden sm:inline text-sm text-base-content/50">飞书通讯录 → OpenLDAP</span> <span class="hidden sm:inline text-sm text-base-content/50">飞书通讯录 → LLDAP</span>
</div> </div>
<div class="flex-none flex items-center gap-2"> <div class="flex-none flex items-center gap-2">
<button id="themeToggle" class="btn btn-sm btn-ghost" title="切换明暗主题">主题</button> <button id="themeToggle" class="btn btn-sm btn-ghost" title="切换明暗主题">主题</button>
@@ -68,8 +68,8 @@
</div> </div>
<div class="card bg-base-100 shadow-sm"> <div class="card bg-base-100 shadow-sm">
<div class="card-body"> <div class="card-body">
<span class="text-sm text-base-content/60">离职账号</span> <span class="text-sm text-base-content/60">在飞书可见/待确认离职</span>
<span id="statInactive" class="text-3xl font-semibold">–</span> <span id="statPending" class="text-3xl font-semibold">–</span>
</div> </div>
</div> </div>
<div class="card bg-base-100 shadow-sm"> <div class="card bg-base-100 shadow-sm">
@@ -85,15 +85,33 @@
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-base">服务状态</h2> <h2 class="card-title text-base">服务状态</h2>
<div id="connStatus" class="space-y-2 text-sm"></div> <div id="connStatus" class="space-y-2 text-sm"></div>
<div id="skippedStatusWrap" class="hidden mt-2 text-sm rounded-lg bg-warning/10 px-3 py-2">
有 <span id="skippedStatus" class="font-semibold"></span> 名用户因缺少企业邮箱被跳过,
请到用户页查看并处理,避免他们一直没被同步。
</div>
</div> </div>
</div> </div>
<div class="card bg-base-100 shadow-sm"> <div class="card bg-base-100 shadow-sm">
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-base">上次同步</h2> <h2 class="card-title text-base">上次物化</h2>
<div id="lastRunInfo" class="text-sm text-base-content/70">暂无记录</div> <div id="lastRunInfo" class="text-sm text-base-content/70">暂无记录</div>
</div> </div>
</div> </div>
</div> </div>
<!-- 离职待确认 -->
<div class="card bg-base-100 shadow-sm mt-4">
<div class="card-body">
<div class="flex items-center justify-between mb-1">
<h2 class="card-title text-base">离职待确认</h2>
<button id="refreshPending" class="btn btn-sm btn-outline">刷新</button>
</div>
<p class="text-sm text-base-content/60 mb-2">
这些用户已在飞书确认离职并收到确认卡片,等待人工点击确认后才会被删除。
</p>
<ul id="pendingRows" class="divide-y divide-base-200"></ul>
</div>
</div>
</section> </section>
<!-- 用户 --> <!-- 用户 -->
@@ -153,6 +171,7 @@
</p> </p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button id="snapshotBtn" class="btn btn-sm btn-neutral">立即快照</button>
<button id="previewBtn" class="btn btn-sm btn-outline">重新计算</button> <button id="previewBtn" class="btn btn-sm btn-outline">重新计算</button>
<button id="applyBtn" class="btn btn-sm btn-primary">应用变更</button> <button id="applyBtn" class="btn btn-sm btn-primary">应用变更</button>
</div> </div>
@@ -212,8 +231,9 @@
<dialog id="applyModal" class="modal"> <dialog id="applyModal" class="modal">
<div class="modal-box"> <div class="modal-box">
<h3 class="text-lg font-semibold mb-2">确认应用变更</h3> <h3 class="text-lg font-semibold mb-2">确认应用变更</h3>
<p class="text-sm text-base-content/70 mb-4"> <p class="text-sm text-base-content/70 mb-4">
将对 OpenLDAP 执行下列变更。用户密码不会被修改,Authelia 重置的密码不受影响。 将对 LLDAP 执行下列变更。密码由 LLDAP 独占并交给 Authelia 管理,程序绝不写入;
仅标记「已确认离职」的用户会被物理删除。
</p> </p>
<div id="applyModalBody" class="text-sm mb-4"></div> <div id="applyModalBody" class="text-sm mb-4"></div>
<div class="modal-action"> <div class="modal-action">