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
+92
-55
@@ -8,27 +8,32 @@ const $$ = (sel) => Array.from(document.querySelectorAll(sel));
|
||||
|
||||
// 操作类型的中文标签,与后端 syncer.OpKind 对应。
|
||||
const OP_LABELS = {
|
||||
create_group: '新建分组',
|
||||
create_user: '新建用户',
|
||||
update_user: '更新用户',
|
||||
deactivate_user: '迁入离职 OU',
|
||||
reactivate_user: '恢复用户',
|
||||
create_group: '新建分组',
|
||||
update_group: '更新分组',
|
||||
delete_user: '删除用户',
|
||||
add_member: '加入分组',
|
||||
remove_member: '移出分组',
|
||||
};
|
||||
|
||||
const OP_BADGES = {
|
||||
create_user: 'badge-success',
|
||||
create_group: 'badge-success',
|
||||
create_user: 'badge-success',
|
||||
update_user: 'badge-info',
|
||||
update_group: 'badge-info',
|
||||
reactivate_user: 'badge-warning',
|
||||
deactivate_user: 'badge-error',
|
||||
delete_user: 'badge-error',
|
||||
add_member: 'badge-success',
|
||||
remove_member: 'badge-error',
|
||||
};
|
||||
|
||||
// 用户生命周期状态的中文标注。
|
||||
const STATE_BADGES = {
|
||||
active: ['在职', 'badge-success'],
|
||||
leave_candidate: ['待发离职确认', 'badge-warning'],
|
||||
pending_confirm: ['待人工确认离职', 'badge-warning'],
|
||||
confirmed_leave: ['已确认离职', 'badge-error'],
|
||||
deleted: ['已删除', 'badge-neutral'],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- 主题
|
||||
|
||||
function initTheme() {
|
||||
@@ -78,10 +83,6 @@ function showError(msg) {
|
||||
setTimeout(() => el.classList.add('hidden'), 8000);
|
||||
}
|
||||
|
||||
function hideError() {
|
||||
$('#globalError').classList.add('hidden');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 视图切换
|
||||
|
||||
function showLogin() {
|
||||
@@ -105,7 +106,7 @@ function switchView(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 === 'groups') loadGroups();
|
||||
if (name === 'plan') loadPlan();
|
||||
@@ -117,7 +118,6 @@ function switchView(name) {
|
||||
async function doLogin() {
|
||||
const errEl = $('#loginError');
|
||||
errEl.classList.add('hidden');
|
||||
|
||||
try {
|
||||
await api('/api/login', {
|
||||
method: 'POST',
|
||||
@@ -134,9 +134,7 @@ async function doLogin() {
|
||||
async function doLogout() {
|
||||
try {
|
||||
await api('/api/logout', { method: 'POST' });
|
||||
} catch (_) {
|
||||
// 登出失败也要回到登录页,本地状态不该依赖服务端响应
|
||||
}
|
||||
} catch (_) { /* 登出失败也要回登录页 */ }
|
||||
showLogin();
|
||||
}
|
||||
|
||||
@@ -145,24 +143,29 @@ async function doLogout() {
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const d = await api('/api/status');
|
||||
hideError();
|
||||
|
||||
$('#statActive').textContent = d.counts.activeUsers;
|
||||
$('#statInactive').textContent = d.counts.inactiveUsers;
|
||||
$('#statGroups').textContent = d.counts.groups;
|
||||
$('#statPending').textContent = d.counts.pendingConfirm;
|
||||
$('#statGroups').textContent = d.counts.departments;
|
||||
|
||||
$('#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('LLDAP 写(GraphQL)', d.lldap.baseUrl ? true : false, '',
|
||||
d.lldap.baseUrl || '未配置'),
|
||||
].join('');
|
||||
|
||||
const skipped = d.counts.skipped || 0;
|
||||
$('#skippedStatus').textContent = skipped;
|
||||
$('#skippedStatusWrap').classList.toggle('hidden', skipped === 0);
|
||||
|
||||
$('#lastRunInfo').innerHTML = d.lastRun
|
||||
? `<div class="space-y-1">
|
||||
<div>时间:${fmtTime(d.lastRun.finishedAt)}</div>
|
||||
<div>模式:${d.lastRun.mode === 'dry-run' ? '预览(未写入)' : '实际执行'}</div>
|
||||
<div>变更:新建 ${d.lastRun.created} · 更新 ${d.lastRun.updated} · 加组 ${d.lastRun.added} · 移组 ${d.lastRun.removed}</div>
|
||||
<div>变更:新建 ${d.lastRun.created} · 更新 ${d.lastRun.updated} ·
|
||||
删除 ${d.lastRun.deleted || 0} · 加组 ${d.lastRun.added} · 移组 ${d.lastRun.removed}</div>
|
||||
</div>`
|
||||
: '<span class="text-base-content/50">暂无成功的同步记录</span>';
|
||||
: '<span class="text-base-content/50">暂无物化记录</span>';
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
@@ -181,6 +184,46 @@ function statusRow(name, ok, error, detail) {
|
||||
</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 = [];
|
||||
@@ -190,7 +233,6 @@ async function loadUsers() {
|
||||
const d = await api('/api/users');
|
||||
usersCache = d.users || [];
|
||||
renderUsers();
|
||||
hideError();
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
@@ -203,26 +245,25 @@ function renderUsers() {
|
||||
return [u.uid, u.name, u.email].some((v) => (v || '').toLowerCase().includes(kw));
|
||||
});
|
||||
|
||||
$('#userRows').innerHTML = rows.map((u) => `
|
||||
<tr>
|
||||
$('#userRows').innerHTML = rows.map((u) => {
|
||||
const st = STATE_BADGES[u.state] || [u.state || '未知', 'badge-neutral'];
|
||||
return `<tr>
|
||||
<td class="font-medium">${escapeHtml(u.uid)}</td>
|
||||
<td>${escapeHtml(u.name || '')}</td>
|
||||
<td>${escapeHtml(u.email || '')}</td>
|
||||
<td>${u.state === 'inactive'
|
||||
? '<span class="badge badge-error badge-sm">离职</span>'
|
||||
: '<span class="badge badge-success badge-sm">在职</span>'}</td>
|
||||
<td>${(u.deptIds || '').split(',').filter(Boolean).length}</td>
|
||||
<td><span class="badge ${st[1]} badge-sm">${st[0]}</span></td>
|
||||
<td>${(u.deptIds || []).length}</td>
|
||||
<td class="text-base-content/60 whitespace-nowrap">${fmtTime(u.lastSyncedAt)}</td>
|
||||
<td>${u.drift
|
||||
? `<span class="badge badge-warning badge-sm" title="${escapeHtml(u.drift)}">需关注</span>`
|
||||
<td>${u.skipped
|
||||
? `<span class="badge badge-warning badge-sm" title="${escapeHtml(u.skipReason || '')}">被跳过</span>`
|
||||
: '<span class="text-base-content/40">—</span>'}</td>
|
||||
<td class="idsync-dn text-base-content/60">${escapeHtml(u.dn)}</td>
|
||||
</tr>`).join('');
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
$('#userEmpty').classList.toggle('hidden', rows.length > 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 分组
|
||||
// ---------------------------------------------------------------- 部门分组
|
||||
|
||||
async function loadGroups() {
|
||||
try {
|
||||
@@ -230,13 +271,13 @@ async function loadGroups() {
|
||||
const groups = d.groups || [];
|
||||
$('#groupRows').innerHTML = groups.map((g) => `
|
||||
<tr>
|
||||
<td class="font-medium">${escapeHtml(g.name || '')}</td>
|
||||
<td class="idsync-dn text-base-content/60">${escapeHtml(g.deptId || '')}</td>
|
||||
<td class="idsync-dn text-base-content/60">${escapeHtml(g.parentId || '—')}</td>
|
||||
<td class="idsync-dn text-base-content/60">${escapeHtml(g.dn || '')}</td>
|
||||
<td class="font-medium">${escapeHtml(g.Name || '')}</td>
|
||||
<td>${escapeHtml(g.DeptID || '—')}</td>
|
||||
<td>${escapeHtml(g.ParentID || '—')}</td>
|
||||
<td>${g.GroupID || '—'}</td>
|
||||
<td class="idsync-dn text-base-content/60">${escapeHtml(g.DN || '')}</td>
|
||||
</tr>`).join('');
|
||||
$('#groupEmpty').classList.toggle('hidden', groups.length > 0);
|
||||
hideError();
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
@@ -251,12 +292,10 @@ async function loadPlan() {
|
||||
$('#planSummary').innerHTML = '';
|
||||
$('#planWarnings').classList.add('hidden');
|
||||
$('#planEmpty').classList.add('hidden');
|
||||
|
||||
try {
|
||||
const plan = await api('/api/plan');
|
||||
currentPlan = plan;
|
||||
renderPlan(plan);
|
||||
hideError();
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
@@ -264,12 +303,10 @@ async function loadPlan() {
|
||||
|
||||
function renderPlan(plan) {
|
||||
const s = plan.stats || {};
|
||||
|
||||
const cards = [
|
||||
['新建用户', s.createUser, 'text-success'],
|
||||
['更新用户', s.updateUser, 'text-info'],
|
||||
['迁入离职', s.deactivateUser, 'text-error'],
|
||||
['恢复用户', s.reactivateUser, 'text-warning'],
|
||||
['删除用户', s.deleteUser, 'text-error'],
|
||||
['新建分组', s.createGroup, 'text-success'],
|
||||
['加入分组', s.addMember, 'text-success'],
|
||||
['移出分组', s.removeMember, 'text-error'],
|
||||
@@ -311,14 +348,13 @@ function renderPlan(plan) {
|
||||
async function applyPlan(force = false) {
|
||||
const modal = document.getElementById('applyModal');
|
||||
if (modal && typeof modal.close === 'function') modal.close();
|
||||
|
||||
try {
|
||||
const d = await api('/api/sync', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ dryRun: false, force }),
|
||||
});
|
||||
const res = d.result || {};
|
||||
showError(`同步完成:新建 ${res.Created || 0},更新 ${res.Updated || 0},加组 ${res.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';
|
||||
switchView('runs');
|
||||
} catch (err) {
|
||||
@@ -326,7 +362,7 @@ async function applyPlan(force = false) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 同步历史
|
||||
// ---------------------------------------------------------------- 物化历史
|
||||
|
||||
async function loadRuns() {
|
||||
try {
|
||||
@@ -342,12 +378,11 @@ async function loadRuns() {
|
||||
<td>${r.status === 'success'
|
||||
? '<span class="badge badge-success badge-sm">成功</span>'
|
||||
: '<span class="badge badge-error badge-sm" title="' + escapeHtml(r.errorMsg || '') + '">失败</span>'}</td>
|
||||
<td>${r.created}</td><td>${r.updated}</td><td>${r.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.failed > 0 ? `<span class="text-error">${r.failed}</span>` : '0'}</td>
|
||||
</tr>`).join('');
|
||||
$('#runEmpty').classList.toggle('hidden', runs.length > 0);
|
||||
hideError();
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
@@ -390,12 +425,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
$('#reloadUsers').addEventListener('click', loadUsers);
|
||||
$('#reloadRuns').addEventListener('click', loadRuns);
|
||||
$('#previewBtn').addEventListener('click', loadPlan);
|
||||
$('#snapshotBtn').addEventListener('click', doSnapshot);
|
||||
$('#refreshPending').addEventListener('click', loadPending);
|
||||
|
||||
$('#applyBtn').addEventListener('click', () => {
|
||||
if (!currentPlan) return;
|
||||
const s = currentPlan.stats || {};
|
||||
const total = (s.createUser || 0) + (s.updateUser || 0) + (s.deactivateUser || 0)
|
||||
+ (s.reactivateUser || 0) + (s.createGroup || 0) + (s.addMember || 0) + (s.removeMember || 0);
|
||||
const total = (s.createUser || 0) + (s.updateUser || 0) + (s.deleteUser || 0)
|
||||
+ (s.createGroup || 0) + (s.addMember || 0) + (s.removeMember || 0);
|
||||
if (total === 0) {
|
||||
showError('没有需要应用的变更。');
|
||||
return;
|
||||
@@ -403,7 +440,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
$('#applyModalBody').innerHTML = `
|
||||
<div class="space-y-1">
|
||||
<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>`;
|
||||
const modal = document.getElementById('applyModal');
|
||||
@@ -420,4 +457,4 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
api('/api/status')
|
||||
.then(() => { showApp(); switchView('overview'); })
|
||||
.catch(() => showLogin());
|
||||
});
|
||||
});
|
||||
Vendored
+28
-8
@@ -16,7 +16,7 @@
|
||||
<span class="inline-block size-3 rounded-full bg-primary"></span>
|
||||
<h1 class="card-title text-lg m-0">idsync</h1>
|
||||
</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>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<div class="flex-1 flex items-center gap-2">
|
||||
<span class="inline-block size-3 rounded-full bg-primary"></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 class="flex-none flex items-center gap-2">
|
||||
<button id="themeToggle" class="btn btn-sm btn-ghost" title="切换明暗主题">主题</button>
|
||||
@@ -68,8 +68,8 @@
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<span class="text-sm text-base-content/60">离职账号</span>
|
||||
<span id="statInactive" class="text-3xl font-semibold">–</span>
|
||||
<span class="text-sm text-base-content/60">在飞书可见/待确认离职</span>
|
||||
<span id="statPending" class="text-3xl font-semibold">–</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
@@ -85,15 +85,33 @@
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">服务状态</h2>
|
||||
<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 class="card bg-base-100 shadow-sm">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<!-- 用户 -->
|
||||
@@ -153,6 +171,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<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="applyBtn" class="btn btn-sm btn-primary">应用变更</button>
|
||||
</div>
|
||||
@@ -212,9 +231,10 @@
|
||||
<dialog id="applyModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-semibold mb-2">确认应用变更</h3>
|
||||
<p class="text-sm text-base-content/70 mb-4">
|
||||
将对 OpenLDAP 执行下列变更。用户密码不会被修改,Authelia 重置的密码不受影响。
|
||||
</p>
|
||||
<p class="text-sm text-base-content/70 mb-4">
|
||||
将对 LLDAP 执行下列变更。密码由 LLDAP 独占并交给 Authelia 管理,程序绝不写入;
|
||||
仅标记「已确认离职」的用户会被物理删除。
|
||||
</p>
|
||||
<div id="applyModalBody" class="text-sm mb-4"></div>
|
||||
<div class="modal-action">
|
||||
<button id="applyCancel" class="btn btn-ghost">取消</button>
|
||||
|
||||
+92
-55
@@ -8,27 +8,32 @@ const $$ = (sel) => Array.from(document.querySelectorAll(sel));
|
||||
|
||||
// 操作类型的中文标签,与后端 syncer.OpKind 对应。
|
||||
const OP_LABELS = {
|
||||
create_group: '新建分组',
|
||||
create_user: '新建用户',
|
||||
update_user: '更新用户',
|
||||
deactivate_user: '迁入离职 OU',
|
||||
reactivate_user: '恢复用户',
|
||||
create_group: '新建分组',
|
||||
update_group: '更新分组',
|
||||
delete_user: '删除用户',
|
||||
add_member: '加入分组',
|
||||
remove_member: '移出分组',
|
||||
};
|
||||
|
||||
const OP_BADGES = {
|
||||
create_user: 'badge-success',
|
||||
create_group: 'badge-success',
|
||||
create_user: 'badge-success',
|
||||
update_user: 'badge-info',
|
||||
update_group: 'badge-info',
|
||||
reactivate_user: 'badge-warning',
|
||||
deactivate_user: 'badge-error',
|
||||
delete_user: 'badge-error',
|
||||
add_member: 'badge-success',
|
||||
remove_member: 'badge-error',
|
||||
};
|
||||
|
||||
// 用户生命周期状态的中文标注。
|
||||
const STATE_BADGES = {
|
||||
active: ['在职', 'badge-success'],
|
||||
leave_candidate: ['待发离职确认', 'badge-warning'],
|
||||
pending_confirm: ['待人工确认离职', 'badge-warning'],
|
||||
confirmed_leave: ['已确认离职', 'badge-error'],
|
||||
deleted: ['已删除', 'badge-neutral'],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- 主题
|
||||
|
||||
function initTheme() {
|
||||
@@ -78,10 +83,6 @@ function showError(msg) {
|
||||
setTimeout(() => el.classList.add('hidden'), 8000);
|
||||
}
|
||||
|
||||
function hideError() {
|
||||
$('#globalError').classList.add('hidden');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 视图切换
|
||||
|
||||
function showLogin() {
|
||||
@@ -105,7 +106,7 @@ function switchView(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 === 'groups') loadGroups();
|
||||
if (name === 'plan') loadPlan();
|
||||
@@ -117,7 +118,6 @@ function switchView(name) {
|
||||
async function doLogin() {
|
||||
const errEl = $('#loginError');
|
||||
errEl.classList.add('hidden');
|
||||
|
||||
try {
|
||||
await api('/api/login', {
|
||||
method: 'POST',
|
||||
@@ -134,9 +134,7 @@ async function doLogin() {
|
||||
async function doLogout() {
|
||||
try {
|
||||
await api('/api/logout', { method: 'POST' });
|
||||
} catch (_) {
|
||||
// 登出失败也要回到登录页,本地状态不该依赖服务端响应
|
||||
}
|
||||
} catch (_) { /* 登出失败也要回登录页 */ }
|
||||
showLogin();
|
||||
}
|
||||
|
||||
@@ -145,24 +143,29 @@ async function doLogout() {
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const d = await api('/api/status');
|
||||
hideError();
|
||||
|
||||
$('#statActive').textContent = d.counts.activeUsers;
|
||||
$('#statInactive').textContent = d.counts.inactiveUsers;
|
||||
$('#statGroups').textContent = d.counts.groups;
|
||||
$('#statPending').textContent = d.counts.pendingConfirm;
|
||||
$('#statGroups').textContent = d.counts.departments;
|
||||
|
||||
$('#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('LLDAP 写(GraphQL)', d.lldap.baseUrl ? true : false, '',
|
||||
d.lldap.baseUrl || '未配置'),
|
||||
].join('');
|
||||
|
||||
const skipped = d.counts.skipped || 0;
|
||||
$('#skippedStatus').textContent = skipped;
|
||||
$('#skippedStatusWrap').classList.toggle('hidden', skipped === 0);
|
||||
|
||||
$('#lastRunInfo').innerHTML = d.lastRun
|
||||
? `<div class="space-y-1">
|
||||
<div>时间:${fmtTime(d.lastRun.finishedAt)}</div>
|
||||
<div>模式:${d.lastRun.mode === 'dry-run' ? '预览(未写入)' : '实际执行'}</div>
|
||||
<div>变更:新建 ${d.lastRun.created} · 更新 ${d.lastRun.updated} · 加组 ${d.lastRun.added} · 移组 ${d.lastRun.removed}</div>
|
||||
<div>变更:新建 ${d.lastRun.created} · 更新 ${d.lastRun.updated} ·
|
||||
删除 ${d.lastRun.deleted || 0} · 加组 ${d.lastRun.added} · 移组 ${d.lastRun.removed}</div>
|
||||
</div>`
|
||||
: '<span class="text-base-content/50">暂无成功的同步记录</span>';
|
||||
: '<span class="text-base-content/50">暂无物化记录</span>';
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
@@ -181,6 +184,46 @@ function statusRow(name, ok, error, detail) {
|
||||
</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 = [];
|
||||
@@ -190,7 +233,6 @@ async function loadUsers() {
|
||||
const d = await api('/api/users');
|
||||
usersCache = d.users || [];
|
||||
renderUsers();
|
||||
hideError();
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
@@ -203,26 +245,25 @@ function renderUsers() {
|
||||
return [u.uid, u.name, u.email].some((v) => (v || '').toLowerCase().includes(kw));
|
||||
});
|
||||
|
||||
$('#userRows').innerHTML = rows.map((u) => `
|
||||
<tr>
|
||||
$('#userRows').innerHTML = rows.map((u) => {
|
||||
const st = STATE_BADGES[u.state] || [u.state || '未知', 'badge-neutral'];
|
||||
return `<tr>
|
||||
<td class="font-medium">${escapeHtml(u.uid)}</td>
|
||||
<td>${escapeHtml(u.name || '')}</td>
|
||||
<td>${escapeHtml(u.email || '')}</td>
|
||||
<td>${u.state === 'inactive'
|
||||
? '<span class="badge badge-error badge-sm">离职</span>'
|
||||
: '<span class="badge badge-success badge-sm">在职</span>'}</td>
|
||||
<td>${(u.deptIds || '').split(',').filter(Boolean).length}</td>
|
||||
<td><span class="badge ${st[1]} badge-sm">${st[0]}</span></td>
|
||||
<td>${(u.deptIds || []).length}</td>
|
||||
<td class="text-base-content/60 whitespace-nowrap">${fmtTime(u.lastSyncedAt)}</td>
|
||||
<td>${u.drift
|
||||
? `<span class="badge badge-warning badge-sm" title="${escapeHtml(u.drift)}">需关注</span>`
|
||||
<td>${u.skipped
|
||||
? `<span class="badge badge-warning badge-sm" title="${escapeHtml(u.skipReason || '')}">被跳过</span>`
|
||||
: '<span class="text-base-content/40">—</span>'}</td>
|
||||
<td class="idsync-dn text-base-content/60">${escapeHtml(u.dn)}</td>
|
||||
</tr>`).join('');
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
$('#userEmpty').classList.toggle('hidden', rows.length > 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 分组
|
||||
// ---------------------------------------------------------------- 部门分组
|
||||
|
||||
async function loadGroups() {
|
||||
try {
|
||||
@@ -230,13 +271,13 @@ async function loadGroups() {
|
||||
const groups = d.groups || [];
|
||||
$('#groupRows').innerHTML = groups.map((g) => `
|
||||
<tr>
|
||||
<td class="font-medium">${escapeHtml(g.name || '')}</td>
|
||||
<td class="idsync-dn text-base-content/60">${escapeHtml(g.deptId || '')}</td>
|
||||
<td class="idsync-dn text-base-content/60">${escapeHtml(g.parentId || '—')}</td>
|
||||
<td class="idsync-dn text-base-content/60">${escapeHtml(g.dn || '')}</td>
|
||||
<td class="font-medium">${escapeHtml(g.Name || '')}</td>
|
||||
<td>${escapeHtml(g.DeptID || '—')}</td>
|
||||
<td>${escapeHtml(g.ParentID || '—')}</td>
|
||||
<td>${g.GroupID || '—'}</td>
|
||||
<td class="idsync-dn text-base-content/60">${escapeHtml(g.DN || '')}</td>
|
||||
</tr>`).join('');
|
||||
$('#groupEmpty').classList.toggle('hidden', groups.length > 0);
|
||||
hideError();
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
@@ -251,12 +292,10 @@ async function loadPlan() {
|
||||
$('#planSummary').innerHTML = '';
|
||||
$('#planWarnings').classList.add('hidden');
|
||||
$('#planEmpty').classList.add('hidden');
|
||||
|
||||
try {
|
||||
const plan = await api('/api/plan');
|
||||
currentPlan = plan;
|
||||
renderPlan(plan);
|
||||
hideError();
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
@@ -264,12 +303,10 @@ async function loadPlan() {
|
||||
|
||||
function renderPlan(plan) {
|
||||
const s = plan.stats || {};
|
||||
|
||||
const cards = [
|
||||
['新建用户', s.createUser, 'text-success'],
|
||||
['更新用户', s.updateUser, 'text-info'],
|
||||
['迁入离职', s.deactivateUser, 'text-error'],
|
||||
['恢复用户', s.reactivateUser, 'text-warning'],
|
||||
['删除用户', s.deleteUser, 'text-error'],
|
||||
['新建分组', s.createGroup, 'text-success'],
|
||||
['加入分组', s.addMember, 'text-success'],
|
||||
['移出分组', s.removeMember, 'text-error'],
|
||||
@@ -311,14 +348,13 @@ function renderPlan(plan) {
|
||||
async function applyPlan(force = false) {
|
||||
const modal = document.getElementById('applyModal');
|
||||
if (modal && typeof modal.close === 'function') modal.close();
|
||||
|
||||
try {
|
||||
const d = await api('/api/sync', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ dryRun: false, force }),
|
||||
});
|
||||
const res = d.result || {};
|
||||
showError(`同步完成:新建 ${res.Created || 0},更新 ${res.Updated || 0},加组 ${res.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';
|
||||
switchView('runs');
|
||||
} catch (err) {
|
||||
@@ -326,7 +362,7 @@ async function applyPlan(force = false) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 同步历史
|
||||
// ---------------------------------------------------------------- 物化历史
|
||||
|
||||
async function loadRuns() {
|
||||
try {
|
||||
@@ -342,12 +378,11 @@ async function loadRuns() {
|
||||
<td>${r.status === 'success'
|
||||
? '<span class="badge badge-success badge-sm">成功</span>'
|
||||
: '<span class="badge badge-error badge-sm" title="' + escapeHtml(r.errorMsg || '') + '">失败</span>'}</td>
|
||||
<td>${r.created}</td><td>${r.updated}</td><td>${r.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.failed > 0 ? `<span class="text-error">${r.failed}</span>` : '0'}</td>
|
||||
</tr>`).join('');
|
||||
$('#runEmpty').classList.toggle('hidden', runs.length > 0);
|
||||
hideError();
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
@@ -390,12 +425,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
$('#reloadUsers').addEventListener('click', loadUsers);
|
||||
$('#reloadRuns').addEventListener('click', loadRuns);
|
||||
$('#previewBtn').addEventListener('click', loadPlan);
|
||||
$('#snapshotBtn').addEventListener('click', doSnapshot);
|
||||
$('#refreshPending').addEventListener('click', loadPending);
|
||||
|
||||
$('#applyBtn').addEventListener('click', () => {
|
||||
if (!currentPlan) return;
|
||||
const s = currentPlan.stats || {};
|
||||
const total = (s.createUser || 0) + (s.updateUser || 0) + (s.deactivateUser || 0)
|
||||
+ (s.reactivateUser || 0) + (s.createGroup || 0) + (s.addMember || 0) + (s.removeMember || 0);
|
||||
const total = (s.createUser || 0) + (s.updateUser || 0) + (s.deleteUser || 0)
|
||||
+ (s.createGroup || 0) + (s.addMember || 0) + (s.removeMember || 0);
|
||||
if (total === 0) {
|
||||
showError('没有需要应用的变更。');
|
||||
return;
|
||||
@@ -403,7 +440,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
$('#applyModalBody').innerHTML = `
|
||||
<div class="space-y-1">
|
||||
<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>`;
|
||||
const modal = document.getElementById('applyModal');
|
||||
@@ -420,4 +457,4 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
api('/api/status')
|
||||
.then(() => { showApp(); switchView('overview'); })
|
||||
.catch(() => showLogin());
|
||||
});
|
||||
});
|
||||
+28
-8
@@ -16,7 +16,7 @@
|
||||
<span class="inline-block size-3 rounded-full bg-primary"></span>
|
||||
<h1 class="card-title text-lg m-0">idsync</h1>
|
||||
</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>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<div class="flex-1 flex items-center gap-2">
|
||||
<span class="inline-block size-3 rounded-full bg-primary"></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 class="flex-none flex items-center gap-2">
|
||||
<button id="themeToggle" class="btn btn-sm btn-ghost" title="切换明暗主题">主题</button>
|
||||
@@ -68,8 +68,8 @@
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<span class="text-sm text-base-content/60">离职账号</span>
|
||||
<span id="statInactive" class="text-3xl font-semibold">–</span>
|
||||
<span class="text-sm text-base-content/60">在飞书可见/待确认离职</span>
|
||||
<span id="statPending" class="text-3xl font-semibold">–</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
@@ -85,15 +85,33 @@
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">服务状态</h2>
|
||||
<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 class="card bg-base-100 shadow-sm">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<!-- 用户 -->
|
||||
@@ -153,6 +171,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<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="applyBtn" class="btn btn-sm btn-primary">应用变更</button>
|
||||
</div>
|
||||
@@ -212,9 +231,10 @@
|
||||
<dialog id="applyModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-semibold mb-2">确认应用变更</h3>
|
||||
<p class="text-sm text-base-content/70 mb-4">
|
||||
将对 OpenLDAP 执行下列变更。用户密码不会被修改,Authelia 重置的密码不受影响。
|
||||
</p>
|
||||
<p class="text-sm text-base-content/70 mb-4">
|
||||
将对 LLDAP 执行下列变更。密码由 LLDAP 独占并交给 Authelia 管理,程序绝不写入;
|
||||
仅标记「已确认离职」的用户会被物理删除。
|
||||
</p>
|
||||
<div id="applyModalBody" class="text-sm mb-4"></div>
|
||||
<div class="modal-action">
|
||||
<button id="applyCancel" class="btn btn-ghost">取消</button>
|
||||
|
||||
Reference in New Issue
Block a user