portal-auth P4:/portal 管理頁 UI(帳號管理+庫目錄管理,admin-only nav)
- 帳號管理:同仁列表(email/顯示名/角色/狀態/可查庫)+新增(一次性密碼只顯示 一次,server 不留明碼)+停用/啟用+重設密碼+每帳號勾選可查庫(含 ["*"] 全庫 選項,勾選連動停用個別庫) - 庫目錄管理:登記/停用庫+graph_source 圖譜來源標記(D-4) - nav 只對 admin 顯示(/portal/session role);前端藏只是 UX,真閘在 /portal/admin/* role 閘 403(P3 guard 精神) - 工作流頁 P3 已交付(v-workflows+D-8 閘),本次不重做 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,9 @@
|
||||
* - 設定頁:改自己密碼、看自己角色與可查庫、主題切換
|
||||
* - 工作流頁:D-8 定案 admin 可見(PORTAL_SHOW_WORKFLOWS 可調 all/off);唯讀、不開 trigger
|
||||
* - graph 模式按 D-4 粗閘顯示(/portal/session 的 graph_allowed)
|
||||
* - 管理頁(P4,admin-only nav):帳號管理(列表/新增/停用/重設密碼+每帳號勾選可查庫
|
||||
* 含 ["*"] 全庫)+庫目錄管理(登記/停用/graph_source 標記)。一次性密碼只在畫面顯示
|
||||
* 一次(server 不留明碼);一般用戶 nav 不顯示、直打 /portal/admin/* 也是 403(role 閘)。
|
||||
* - Mira 專屬頁(駕駛艙/分流台/憑證/專案)一律不進
|
||||
*
|
||||
* 安全(design §3.3 關鍵差異 vs console):
|
||||
@@ -161,6 +164,21 @@ function renderPortalHtml(brand: string): string {
|
||||
.gcenter span { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 13.5px; font-weight: 600; line-height: 1.35; color: #241804; word-break: break-word; }
|
||||
.nbrow { display: flex; align-items: center; gap: 11px; padding: 12px 14px; border-radius: 10px; cursor: pointer; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.1); font-size: 15.5px; }
|
||||
|
||||
/* ── 管理頁(P4)── */
|
||||
.sechead { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 19px; letter-spacing: .14em; color: var(--amber); margin: 26px 0 12px; padding-bottom: 8px; border-bottom: 1px solid rgba(var(--amber-rgb),.3); }
|
||||
.formrow { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.formrow .txt { flex: 1; min-width: 180px; }
|
||||
.formrow select { padding: 12px 14px; font-size: 15.5px; border-radius: 10px; border: 1px solid rgba(var(--ink-rgb),.2); background: rgba(var(--ink-rgb),.05); color: var(--ink); }
|
||||
.btnrow { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
|
||||
.btn2 { padding: 9px 14px; font-size: 14px; border-radius: 9px; border: 1px solid rgba(var(--amber-rgb),.45); background: rgba(var(--amber-rgb),.08); color: var(--amber); cursor: pointer; }
|
||||
.btn2.warn { border-color: rgba(var(--err-rgb),.45); background: rgba(var(--err-rgb),.06); color: var(--err); }
|
||||
.btn2:disabled { opacity: .45; cursor: default; }
|
||||
.libgrid { display: flex; gap: 8px 14px; flex-wrap: wrap; margin-top: 10px; }
|
||||
.libgrid label { display: flex; align-items: center; gap: 7px; font-size: 14.5px; cursor: pointer; padding: 6px 10px; border-radius: 8px; background: rgba(var(--ink-rgb),.05); border: 1px solid rgba(var(--ink-rgb),.12); }
|
||||
.libgrid input[type=checkbox] { accent-color: var(--amber); width: 16px; height: 16px; }
|
||||
.otpbox { margin-top: 14px; padding: 18px; border-radius: 13px; border: 2px solid rgba(var(--amber-rgb),.55); background: rgba(var(--amber-rgb),.08); }
|
||||
.otpbox .pw { font-family: ui-monospace, Menlo, monospace; font-size: 22px; letter-spacing: .12em; color: var(--amber); word-break: break-all; user-select: all; margin: 10px 0; }
|
||||
|
||||
/* ── 清單 / 設定 ── */
|
||||
.listcol { display: flex; flex-direction: column; gap: 10px; padding: 16px 0 8px; }
|
||||
.card-item { border-radius: 13px; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.1); padding: 16px 18px; }
|
||||
@@ -198,6 +216,7 @@ function renderPortalHtml(brand: string): string {
|
||||
<div class="logo">${brand}</div>
|
||||
<div class="nav" data-nav="search">搜尋</div>
|
||||
<div class="nav hide" id="nav-workflows" data-nav="workflows">工作流</div>
|
||||
<div class="nav hide" id="nav-admin" data-nav="admin">管理</div>
|
||||
<div class="nav" data-nav="settings">設定</div>
|
||||
<div class="nav themelabel" data-themetoggle style="margin-top:8px;font-size:14.5px">☾ 切深色</div>
|
||||
<div class="foot" id="side-foot"></div>
|
||||
@@ -277,6 +296,42 @@ function renderPortalHtml(brand: string): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 管理(P4,admin-only:nav 只有 admin 看得到;真閘在 /portal/admin/* role 閘 403)-->
|
||||
<div class="view page" id="v-admin">
|
||||
<div class="pagehead"><span class="t">管理</span><span class="m">帳號與知識庫授權</span></div>
|
||||
|
||||
<div class="sechead">帳號管理</div>
|
||||
<div class="panel">
|
||||
<div style="font-size:16px;font-weight:600;margin-bottom:4px">新增同仁帳號</div>
|
||||
<div style="font-size:13.5px;color:rgba(var(--ink-rgb),.55);margin-bottom:12px">建立後會產生一組一次性密碼——只顯示這一次,請當場轉交同仁(同仁可在「設定」自行改密)。</div>
|
||||
<div class="formrow">
|
||||
<input type="email" id="ad-nu-email" class="txt" placeholder="Email" autocomplete="off">
|
||||
<input type="text" id="ad-nu-name" class="txt" placeholder="顯示名(可留空=用 Email)" autocomplete="off">
|
||||
<select id="ad-nu-role"><option value="user">一般用戶</option><option value="admin">管理員</option></select>
|
||||
<button class="btn" id="ad-nu-create" style="flex:none;padding:0 20px">建立</button>
|
||||
</div>
|
||||
<div id="ad-nu-status" class="err" style="font-size:14px;min-height:1.2em;margin-top:8px"></div>
|
||||
</div>
|
||||
<div id="ad-otp"></div>
|
||||
<div class="listcol" id="ad-users"><div class="muted">載入中…</div></div>
|
||||
|
||||
<div class="sechead">庫目錄管理</div>
|
||||
<div style="margin-bottom:12px;padding:12px 15px;border-radius:11px;background:rgba(var(--amber-rgb),.06);border:1px dashed rgba(var(--amber-rgb),.35);font-size:13.5px;line-height:1.7;color:rgba(var(--ink-rgb),.65)">
|
||||
這裡是「庫」的登記簿——條目歸哪個庫由資料導入(ingest)時蓋章決定;未蓋章的舊資料一律視同 <b style="color:var(--ink)">general</b>。標了「圖譜來源」的庫決定誰能用圖譜模式(全都沒標=預設 general)。
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div style="font-size:16px;font-weight:600;margin-bottom:12px">登記新庫</div>
|
||||
<div class="formrow">
|
||||
<input type="text" id="ad-nl-name" class="txt" placeholder="庫名(英數 _ -,例:finance)" autocomplete="off">
|
||||
<input type="text" id="ad-nl-display" class="txt" placeholder="顯示名(例:財務庫)" autocomplete="off">
|
||||
<input type="text" id="ad-nl-desc" class="txt" placeholder="描述(可留空)" autocomplete="off">
|
||||
<button class="btn" id="ad-nl-create" style="flex:none;padding:0 20px">登記</button>
|
||||
</div>
|
||||
<div id="ad-nl-status" class="err" style="font-size:14px;min-height:1.2em;margin-top:8px"></div>
|
||||
</div>
|
||||
<div class="listcol" id="ad-libs"><div class="muted">載入中…</div></div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -284,6 +339,7 @@ function renderPortalHtml(brand: string): string {
|
||||
<div id="tabbar">
|
||||
<div class="tab" data-nav="search">搜尋</div>
|
||||
<div class="tab hide" id="tab-workflows" data-nav="workflows">工作流</div>
|
||||
<div class="tab hide" id="tab-admin" data-nav="admin">管理</div>
|
||||
<div class="tab" data-nav="settings">設定</div>
|
||||
</div>
|
||||
|
||||
@@ -412,11 +468,16 @@ function renderPortalHtml(brand: string): string {
|
||||
}
|
||||
|
||||
// ── 路由 ──
|
||||
var VIEWS = ['search', 'card', 'workflows', 'settings'];
|
||||
var VIEWS = ['search', 'card', 'workflows', 'admin', 'settings'];
|
||||
var HOME = 'search';
|
||||
function allowedViews() {
|
||||
// 工作流頁按 session 能力顯示(真閘在 server:/portal/data/workflows 403/404)
|
||||
return (S.profile && S.profile.workflows_visible) ? VIEWS : VIEWS.filter(function (v) { return v !== 'workflows'; });
|
||||
// 工作流/管理頁按 session 能力顯示(真閘在 server:/portal/data/workflows 403/404、
|
||||
// /portal/admin/* role 閘 403——前端藏只是 UX,curl 直打也繞不過)
|
||||
return VIEWS.filter(function (v) {
|
||||
if (v === 'workflows') return !!(S.profile && S.profile.workflows_visible);
|
||||
if (v === 'admin') return !!(S.profile && S.profile.role === 'admin');
|
||||
return true;
|
||||
});
|
||||
}
|
||||
function currentRoute() {
|
||||
var h = location.hash.replace(/^#\\/?/, '');
|
||||
@@ -435,7 +496,7 @@ function renderPortalHtml(brand: string): string {
|
||||
});
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
var LOADERS = { card: loadCard, workflows: loadWorkflows, settings: loadSettings };
|
||||
var LOADERS = { card: loadCard, workflows: loadWorkflows, admin: loadAdmin, settings: loadSettings };
|
||||
function route() {
|
||||
var r = currentRoute();
|
||||
if (r.raw && r.raw !== r.view) { location.hash = '#/' + r.view; return; }
|
||||
@@ -463,6 +524,8 @@ function renderPortalHtml(brand: string): string {
|
||||
$('side-foot').innerHTML = esc(p.display_name || '') + '<br>' + esc(location.host);
|
||||
$('nav-workflows').classList.toggle('hide', !p.workflows_visible);
|
||||
$('tab-workflows').classList.toggle('hide', !p.workflows_visible);
|
||||
$('nav-admin').classList.toggle('hide', p.role !== 'admin');
|
||||
$('tab-admin').classList.toggle('hide', p.role !== 'admin');
|
||||
$('mode-graph').classList.toggle('hide', !p.graph_allowed);
|
||||
var libs = p.libraries || [];
|
||||
$('se-scope').textContent = libs.indexOf('*') >= 0 ? '範圍:全部知識庫' : ('範圍:' + libs.join('・'));
|
||||
@@ -686,6 +749,269 @@ function renderPortalHtml(brand: string): string {
|
||||
.catch(function (e) { $('wf-list').innerHTML = '<div class="err">請求失敗:' + esc(friendlyErr(e)) + '</div>'; });
|
||||
}
|
||||
|
||||
// ── 管理頁(P4:帳號管理+庫目錄管理。全走 /portal/admin/*,前端零業務邏輯;
|
||||
// 一般用戶 nav 不顯示,且 server role 閘 403——藏只是 UX,真閘在後端)──
|
||||
var adminLibs = []; // 庫目錄快取(渲染每帳號的庫勾選用)
|
||||
|
||||
function adminApi(method, path, body) {
|
||||
var opt = { method: method, headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()) };
|
||||
if (body !== undefined) opt.body = JSON.stringify(body);
|
||||
return fetch(path, opt).then(function (r) {
|
||||
return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; });
|
||||
});
|
||||
}
|
||||
|
||||
function loadAdmin() {
|
||||
$('ad-users').innerHTML = '<div class="muted">載入中…</div>';
|
||||
$('ad-libs').innerHTML = '<div class="muted">載入中…</div>';
|
||||
adminApi('GET', '/portal/admin/libraries')
|
||||
.then(function (x) {
|
||||
if (guard401(x.status)) return;
|
||||
if (!x.ok) { $('ad-libs').innerHTML = '<div class="err">' + esc(x.d.error || ('讀取失敗(HTTP ' + x.status + ')')) + '</div>'; return; }
|
||||
adminLibs = x.d.libraries || [];
|
||||
renderAdminLibs();
|
||||
return adminApi('GET', '/portal/admin/users').then(function (y) {
|
||||
if (guard401(y.status)) return;
|
||||
if (!y.ok) { $('ad-users').innerHTML = '<div class="err">' + esc(y.d.error || ('讀取失敗(HTTP ' + y.status + ')')) + '</div>'; return; }
|
||||
renderAdminUsers(y.d.users || []);
|
||||
});
|
||||
})
|
||||
.catch(function (e) {
|
||||
$('ad-users').innerHTML = '<div class="err">請求失敗:' + esc(friendlyErr(e)) + '</div>';
|
||||
$('ad-libs').innerHTML = '';
|
||||
});
|
||||
}
|
||||
|
||||
// 帳號可勾選的庫=庫目錄啟用中的庫 ∪ general ∪ 該帳號已有的庫(目錄外的舊授權不憑空消失)
|
||||
function checkableLibs(userLibs) {
|
||||
var names = ['general'];
|
||||
adminLibs.forEach(function (l) { if (l.status !== 'disabled' && l.name && names.indexOf(l.name) < 0) names.push(l.name); });
|
||||
(userLibs || []).forEach(function (n) { if (n !== '*' && names.indexOf(n) < 0) names.push(n); });
|
||||
return names;
|
||||
}
|
||||
function libDisplay(name) {
|
||||
for (var i = 0; i < adminLibs.length; i++) { if (adminLibs[i].name === name) return adminLibs[i].display_name || name; }
|
||||
return name;
|
||||
}
|
||||
|
||||
function renderAdminUsers(users) {
|
||||
if (!users.length) { $('ad-users').innerHTML = '<div class="muted" style="padding:16px 4px">還沒有任何同仁帳號——用上面的表單新增第一個。</div>'; return; }
|
||||
$('ad-users').innerHTML = users.map(function (u) {
|
||||
var star = (u.libraries || []).indexOf('*') >= 0;
|
||||
var disabled = u.status === 'disabled';
|
||||
var libChips = star
|
||||
? '<span class="tag green">全部知識庫</span>'
|
||||
: ((u.libraries || []).length
|
||||
? (u.libraries || []).map(function (n) { return '<span class="tag green">' + esc(libDisplay(n)) + '</span>'; }).join(' ')
|
||||
: '<span class="tag dim">尚未授權任何庫</span>');
|
||||
var boxes = '<label><input type="checkbox" data-star data-uid="' + esc(u.record_id) + '"' + (star ? ' checked' : '') + '> 全部知識庫(*)</label>' +
|
||||
checkableLibs(u.libraries).map(function (n) {
|
||||
var on = !star && (u.libraries || []).indexOf(n) >= 0;
|
||||
return '<label><input type="checkbox" data-lib="' + esc(n) + '" data-uid="' + esc(u.record_id) + '"' + (on ? ' checked' : '') + (star ? ' disabled' : '') + '> ' + esc(libDisplay(n)) + ' <span class="dim mono" style="font-size:12px">' + esc(n) + '</span></label>';
|
||||
}).join('');
|
||||
return '<div class="card-item"' + (disabled ? ' style="opacity:.6"' : '') + '>' +
|
||||
'<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap">' +
|
||||
'<span class="serif" style="font-size:17px;font-weight:600">' + esc(u.display_name || u.email) + '</span>' +
|
||||
'<span class="muted" style="font-size:14px;word-break:break-all">' + esc(u.email) + '</span>' +
|
||||
'<span class="tag' + (u.role === 'admin' ? '' : ' dim') + '">' + (u.role === 'admin' ? '管理員' : '一般用戶') + '</span>' +
|
||||
'<span class="tag ' + (disabled ? 'dim' : 'green') + '">' + (disabled ? '已停用' : '啟用中') + '</span></div>' +
|
||||
'<div style="margin-top:9px;display:flex;gap:6px;flex-wrap:wrap;align-items:center;font-size:13px"><span class="muted" style="flex:none">可查庫</span>' + libChips + '</div>' +
|
||||
'<div class="btnrow">' +
|
||||
'<button class="btn2" data-act="edit-libs" data-uid="' + esc(u.record_id) + '">編輯可查庫</button>' +
|
||||
'<button class="btn2" data-act="reset-pw" data-uid="' + esc(u.record_id) + '" data-email="' + esc(u.email) + '">重設密碼</button>' +
|
||||
(disabled
|
||||
? '<button class="btn2" data-act="set-status" data-next="active" data-uid="' + esc(u.record_id) + '">啟用</button>'
|
||||
: '<button class="btn2 warn" data-act="set-status" data-next="disabled" data-uid="' + esc(u.record_id) + '" data-email="' + esc(u.email) + '">停用</button>') +
|
||||
'</div>' +
|
||||
'<div id="libed-' + esc(u.record_id) + '" style="display:none">' +
|
||||
'<div class="libgrid">' + boxes + '</div>' +
|
||||
'<div class="btnrow"><button class="btn2" data-act="save-libs" data-uid="' + esc(u.record_id) + '">儲存庫權限</button>' +
|
||||
'<span class="dim" style="font-size:13px;align-self:center">勾「全部知識庫」=不限庫;否則至少勾一個。儲存後立即生效。</span></div></div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderAdminLibs() {
|
||||
if (!adminLibs.length) { $('ad-libs').innerHTML = '<div class="muted" style="padding:16px 4px">還沒有登記任何庫。未登記時整個系統視同單一 general 庫。</div>'; return; }
|
||||
$('ad-libs').innerHTML = adminLibs.map(function (l) {
|
||||
var disabled = l.status === 'disabled';
|
||||
return '<div class="card-item"' + (disabled ? ' style="opacity:.6"' : '') + '>' +
|
||||
'<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap">' +
|
||||
'<span class="serif" style="font-size:17px;font-weight:600">' + esc(l.display_name || l.name) + '</span>' +
|
||||
'<span class="dim mono" style="font-size:13px">' + esc(l.name) + '</span>' +
|
||||
'<span class="tag ' + (disabled ? 'dim' : 'green') + '">' + (disabled ? '已停用' : '啟用中') + '</span>' +
|
||||
(l.graph_source ? '<span class="tag">圖譜來源</span>' : '') + '</div>' +
|
||||
(l.description ? '<div style="margin-top:8px;font-size:14.5px;line-height:1.65;color:rgba(var(--ink-rgb),.65)">' + esc(l.description) + '</div>' : '') +
|
||||
'<div class="btnrow">' +
|
||||
'<button class="btn2" data-act="lib-graph" data-lid="' + esc(l.record_id) + '" data-next="' + (l.graph_source ? 'false' : 'true') + '">' + (l.graph_source ? '取消圖譜來源' : '標為圖譜來源') + '</button>' +
|
||||
(disabled
|
||||
? '<button class="btn2" data-act="lib-status" data-next="active" data-lid="' + esc(l.record_id) + '">啟用</button>'
|
||||
: '<button class="btn2 warn" data-act="lib-status" data-next="disabled" data-lid="' + esc(l.record_id) + '" data-name="' + esc(l.display_name || l.name) + '">停用</button>') +
|
||||
'</div></div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 一次性密碼顯示(server 不留明碼——關掉就真的沒了,誠實跟 admin 說清楚)
|
||||
function showOtp(email, password) {
|
||||
$('ad-otp').innerHTML = '<div class="otpbox">' +
|
||||
'<div style="font-size:15px;font-weight:600">' + esc(email) + ' 的一次性密碼</div>' +
|
||||
'<div class="pw">' + esc(password) + '</div>' +
|
||||
'<div style="font-size:13.5px;line-height:1.7;color:rgba(var(--ink-rgb),.65)">只顯示這一次——關閉後再也調不出來(系統只存雜湊)。請當場轉交同仁,並提醒登入後到「設定」改成自己的密碼。</div>' +
|
||||
'<div class="btnrow"><button class="btn2" id="ad-otp-close">我已轉交,關閉</button></div></div>';
|
||||
$('ad-otp').scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
// 新增帳號
|
||||
$('ad-nu-create').addEventListener('click', function () {
|
||||
var email = $('ad-nu-email').value.trim();
|
||||
var name = $('ad-nu-name').value.trim();
|
||||
var role = $('ad-nu-role').value === 'admin' ? 'admin' : 'user';
|
||||
var st = $('ad-nu-status');
|
||||
if (!email) { st.textContent = '請填 Email'; return; }
|
||||
st.textContent = '';
|
||||
$('ad-nu-create').disabled = true;
|
||||
var body = { email: email, role: role };
|
||||
if (name) body.display_name = name;
|
||||
adminApi('POST', '/portal/admin/users', body)
|
||||
.then(function (x) {
|
||||
$('ad-nu-create').disabled = false;
|
||||
if (guard401(x.status)) return;
|
||||
if (!x.ok) { st.textContent = x.d.error || ('建立失敗(HTTP ' + x.status + ')'); return; }
|
||||
$('ad-nu-email').value = ''; $('ad-nu-name').value = ''; $('ad-nu-role').value = 'user';
|
||||
if (x.d.generated_password) showOtp(email, x.d.generated_password);
|
||||
toast('帳號已建立');
|
||||
loadAdmin();
|
||||
})
|
||||
.catch(function (e) { $('ad-nu-create').disabled = false; st.textContent = friendlyErr(e); });
|
||||
});
|
||||
|
||||
// 登記新庫
|
||||
$('ad-nl-create').addEventListener('click', function () {
|
||||
var name = $('ad-nl-name').value.trim();
|
||||
var st = $('ad-nl-status');
|
||||
if (!name) { st.textContent = '請填庫名(英數 _ -)'; return; }
|
||||
st.textContent = '';
|
||||
$('ad-nl-create').disabled = true;
|
||||
adminApi('POST', '/portal/admin/libraries', {
|
||||
name: name,
|
||||
display_name: $('ad-nl-display').value.trim(),
|
||||
description: $('ad-nl-desc').value.trim()
|
||||
})
|
||||
.then(function (x) {
|
||||
$('ad-nl-create').disabled = false;
|
||||
if (guard401(x.status)) return;
|
||||
if (!x.ok) { st.textContent = x.d.error || ('登記失敗(HTTP ' + x.status + ')'); return; }
|
||||
$('ad-nl-name').value = ''; $('ad-nl-display').value = ''; $('ad-nl-desc').value = '';
|
||||
toast('庫已登記');
|
||||
loadAdmin();
|
||||
})
|
||||
.catch(function (e) { $('ad-nl-create').disabled = false; st.textContent = friendlyErr(e); });
|
||||
});
|
||||
|
||||
// 「全部知識庫」勾選 → 其餘庫勾選框連動停用(存的就是 ["*"],個別勾選無意義)
|
||||
document.addEventListener('change', function (ev) {
|
||||
var t = ev.target;
|
||||
if (!t || !t.hasAttribute || !t.hasAttribute('data-star')) return;
|
||||
var uid = t.getAttribute('data-uid');
|
||||
document.querySelectorAll('#libed-' + uid + ' input[data-lib]').forEach(function (cb) {
|
||||
cb.disabled = t.checked;
|
||||
if (t.checked) cb.checked = false;
|
||||
});
|
||||
});
|
||||
|
||||
// 管理頁動作(事件委派)
|
||||
$('v-admin').addEventListener('click', function (ev) {
|
||||
var t = ev.target.closest('[data-act]');
|
||||
if (!t) return;
|
||||
var act = t.getAttribute('data-act');
|
||||
var uid = t.getAttribute('data-uid');
|
||||
|
||||
if (act === 'edit-libs') {
|
||||
var ed = $('libed-' + uid);
|
||||
ed.style.display = ed.style.display === 'none' ? '' : 'none';
|
||||
return;
|
||||
}
|
||||
if (act === 'save-libs') {
|
||||
var star = document.querySelector('#libed-' + uid + ' input[data-star]');
|
||||
var libs;
|
||||
if (star && star.checked) { libs = ['*']; }
|
||||
else {
|
||||
libs = [];
|
||||
document.querySelectorAll('#libed-' + uid + ' input[data-lib]').forEach(function (cb) {
|
||||
if (cb.checked) libs.push(cb.getAttribute('data-lib'));
|
||||
});
|
||||
if (!libs.length) { toast('至少勾一個庫,或勾「全部知識庫」'); return; }
|
||||
}
|
||||
t.disabled = true;
|
||||
adminApi('PATCH', '/portal/admin/users/' + encodeURIComponent(uid), { libraries: libs })
|
||||
.then(function (x) {
|
||||
t.disabled = false;
|
||||
if (guard401(x.status)) return;
|
||||
if (!x.ok) { toast(x.d.error || ('儲存失敗(HTTP ' + x.status + ')')); return; }
|
||||
toast('庫權限已更新,立即生效');
|
||||
loadAdmin();
|
||||
})
|
||||
.catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });
|
||||
return;
|
||||
}
|
||||
if (act === 'set-status') {
|
||||
var next = t.getAttribute('data-next');
|
||||
if (next === 'disabled' && !confirm('確定停用 ' + (t.getAttribute('data-email') || '這個帳號') + '?停用後其登入與既有 session 立即失效。')) return;
|
||||
t.disabled = true;
|
||||
adminApi('PATCH', '/portal/admin/users/' + encodeURIComponent(uid), { status: next })
|
||||
.then(function (x) {
|
||||
t.disabled = false;
|
||||
if (guard401(x.status)) return;
|
||||
if (!x.ok) { toast(x.d.error || ('更新失敗(HTTP ' + x.status + ')')); return; } // 含最後一個 admin 的 409 訊息
|
||||
toast(next === 'disabled' ? '已停用' : '已啟用');
|
||||
loadAdmin();
|
||||
})
|
||||
.catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });
|
||||
return;
|
||||
}
|
||||
if (act === 'reset-pw') {
|
||||
var email = t.getAttribute('data-email') || '';
|
||||
if (!confirm('確定重設 ' + email + ' 的密碼?舊密碼將立即失效,會產生一組新的一次性密碼。')) return;
|
||||
t.disabled = true;
|
||||
adminApi('POST', '/portal/admin/users/' + encodeURIComponent(uid) + '/reset-password')
|
||||
.then(function (x) {
|
||||
t.disabled = false;
|
||||
if (guard401(x.status)) return;
|
||||
if (!x.ok) { toast(x.d.error || ('重設失敗(HTTP ' + x.status + ')')); return; }
|
||||
showOtp(email, x.d.password);
|
||||
toast('密碼已重設');
|
||||
})
|
||||
.catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });
|
||||
return;
|
||||
}
|
||||
if (act === 'lib-status' || act === 'lib-graph') {
|
||||
var lid = t.getAttribute('data-lid');
|
||||
var body2 = {};
|
||||
if (act === 'lib-status') {
|
||||
var nextS = t.getAttribute('data-next');
|
||||
if (nextS === 'disabled' && !confirm('確定停用庫「' + (t.getAttribute('data-name') || '') + '」?停用後不再出現在庫勾選清單(既有授權不會被自動改)。')) return;
|
||||
body2.status = nextS;
|
||||
} else {
|
||||
body2.graph_source = t.getAttribute('data-next') === 'true';
|
||||
}
|
||||
t.disabled = true;
|
||||
adminApi('PATCH', '/portal/admin/libraries/' + encodeURIComponent(lid), body2)
|
||||
.then(function (x) {
|
||||
t.disabled = false;
|
||||
if (guard401(x.status)) return;
|
||||
if (!x.ok) { toast(x.d.error || ('更新失敗(HTTP ' + x.status + ')')); return; }
|
||||
toast('庫已更新');
|
||||
loadAdmin();
|
||||
})
|
||||
.catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });
|
||||
return;
|
||||
}
|
||||
});
|
||||
// 一次性密碼關閉鈕(otpbox 動態生成,掛在容器上)
|
||||
$('ad-otp').addEventListener('click', function (ev) {
|
||||
if (ev.target && ev.target.id === 'ad-otp-close') $('ad-otp').innerHTML = '';
|
||||
});
|
||||
|
||||
// ── 設定 ──
|
||||
function loadSettings() {
|
||||
var p = S.profile;
|
||||
|
||||
Reference in New Issue
Block a user