feat: server 一次性离职确认端点 + WebUI 重构
- 确认端点单元测试:有效/复用/过期/串用/乱写 token 各测一遍,token 即凭证不经 Auth - WebUI 重建:对齐新 API 的操作类型(含 delete_user)、用户生命周期状态视图、 概览含离职待确认列表与被跳过告警、「立即快照」与「立即物化」触发按钮、 部门分组展示 groupId;重编译 app.css/app.js 并提交产物 - 界面文案改指 LLDAP(不再提 OpenLDAP),确认弹窗说明离职才物理删除
This commit is contained in:
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+91
-54
@@ -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');
|
||||||
|
|||||||
Vendored
+28
-8
@@ -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,9 +231,10 @@
|
|||||||
<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">
|
||||||
<button id="applyCancel" class="btn btn-ghost">取消</button>
|
<button id="applyCancel" class="btn btn-ghost">取消</button>
|
||||||
|
|||||||
+91
-54
@@ -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');
|
||||||
|
|||||||
+28
-8
@@ -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,9 +231,10 @@
|
|||||||
<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">
|
||||||
<button id="applyCancel" class="btn btn-ghost">取消</button>
|
<button id="applyCancel" class="btn btn-ghost">取消</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user