eight-hourr/frontend/settings.js

486 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const BOOL_FIELDS = [
"use_llm",
"use_description_variation",
"auto_fill_enabled",
];
const NUMBER_FIELDS = [
"gitlab_board_id",
"auto_fill_hour",
"auto_fill_minute",
"max_hours_per_day",
"hour_step",
"compbase_default_days",
];
const SECRET_INPUTS = new Set(["gitlab_token", "xai_api_key", "compbase_password"]);
function fillSettingsForm(data) {
const form = document.getElementById("settingsForm");
for (const el of form.querySelectorAll("[name]")) {
const name = el.name;
if (name === "dates_file_content") continue;
if (BOOL_FIELDS.includes(name)) {
el.checked = Boolean(data[name]);
continue;
}
// Never put masked secrets back into password fields (looks like a real token)
if (SECRET_INPUTS.has(name)) {
el.value = "";
el.placeholder = data[`${name}_set`]
? "已設定(重新貼上才會更新)"
: "貼上 token";
continue;
}
if (data[name] !== undefined && data[name] !== null) {
el.value = data[name];
}
}
if (data.pts_username) {
document.getElementById("ptsUsername").value = data.pts_username;
}
if (data.teams_username) {
document.getElementById("teamsUsername").value = data.teams_username;
}
document.getElementById("teamsEnabled").checked = Boolean(data.teams_calendar_enabled);
const gitlabHint = document.getElementById("gitlabTokenHint");
if (gitlabHint) {
gitlabHint.textContent = data.gitlab_token_set
? "Token 已儲存。若無法使用請重新產生並貼上新的 Personal Access Token權限至少 api。"
: "到 GitLab → Preferences → Access Tokens 建立,勾選 api。";
}
}
function collectSettingsPayload(form) {
const payload = {};
for (const el of form.querySelectorAll("[name]")) {
const name = el.name;
if (name === "dates_file_content") continue;
if (BOOL_FIELDS.includes(name)) {
payload[name] = el.checked;
continue;
}
if (NUMBER_FIELDS.includes(name)) {
const num = el.value === "" ? null : Number(el.value);
if (num !== null && !Number.isNaN(num)) payload[name] = num;
continue;
}
const value = el.value.trim();
if (name === "gitlab_milestone_title") {
payload[name] = value;
continue;
}
if (value !== "") payload[name] = value;
}
payload.teams_calendar_enabled = document.getElementById("teamsEnabled").checked;
return payload;
}
function renderAuthStatus(data) {
const ptsEl = document.getElementById("ptsAuthStatus");
if (ptsEl) {
ptsEl.textContent = data.has_pts_session ? "已連線" : "未連線";
ptsEl.className = `badge ${data.has_pts_session ? "ok" : "warn"}`;
}
const teams = data.teams_calendar || data.graph_calendar || {};
const teamsEl = document.getElementById("teamsAuthStatus");
if (teamsEl) {
if (!data.teams_calendar_enabled) {
teamsEl.textContent = "未啟用";
teamsEl.className = "badge";
} else if (teams.connected) {
teamsEl.textContent = teams.auth_mode === "graph_api" ? "Graph API" : "已連線";
teamsEl.className = "badge ok";
} else {
teamsEl.textContent = "未連線";
teamsEl.className = "badge warn";
}
}
}
async function refreshStatus() {
try {
const data = await api("/api/status");
renderAuthStatus(data);
} catch (err) {
console.error(err);
}
}
async function loadSettings() {
const [settings, dates] = await Promise.all([
api("/api/settings"),
api("/api/dates-file"),
]);
fillSettingsForm(settings);
document.getElementById("datesFileContent").value = dates.content || "";
}
async function saveSettings(event) {
event.preventDefault();
const statusEl = document.getElementById("settingsStatus");
statusEl.textContent = "儲存中…";
statusEl.className = "settings-status";
const form = document.getElementById("settingsForm");
const payload = collectSettingsPayload(form);
const datesContent = document.getElementById("datesFileContent").value;
try {
await api("/api/settings", {
method: "PUT",
body: JSON.stringify(payload),
});
await api("/api/dates-file", {
method: "PUT",
body: JSON.stringify({ content: datesContent }),
});
await loadSettings();
await refreshStatus();
statusEl.textContent = "已儲存";
statusEl.className = "settings-status ok";
} catch (err) {
statusEl.textContent = err.message;
statusEl.className = "settings-status err";
}
}
async function ptsLogin() {
const msg = document.getElementById("ptsLoginMsg");
const username = document.getElementById("ptsUsername").value.trim();
const password = document.getElementById("ptsPassword").value;
msg.textContent = "登入中…";
msg.className = "settings-status";
try {
const data = await api("/api/pts/login", {
method: "POST",
body: JSON.stringify({ username, password, remember: true }),
});
document.getElementById("ptsPassword").value = "";
msg.textContent = data.message || "登入成功";
msg.className = "settings-status ok";
await refreshStatus();
} catch (err) {
msg.textContent = err.message;
msg.className = "settings-status err";
}
}
async function ptsLogout() {
const msg = document.getElementById("ptsLoginMsg");
try {
await api("/api/pts/logout", { method: "POST" });
msg.textContent = "已清除 session";
msg.className = "settings-status";
await refreshStatus();
} catch (err) {
msg.textContent = err.message;
msg.className = "settings-status err";
}
}
let _mfaPreviewTimer = null;
let _mfaMetaTimer = null;
function vncPageUrl(port) {
const host = window.location.hostname || "localhost";
const p = port || 6080;
// autoconnect + scale to fit modal
return `http://${host}:${p}/vnc.html?autoconnect=1&resize=scale&reconnect=1`;
}
function openTeamsMfaModal(view) {
const modal = document.getElementById("teamsMfaModal");
if (!modal) return;
modal.classList.remove("hidden");
const port = (view && view.vnc_port) || 6080;
const interactive = !!(view && view.interactive);
const link = document.getElementById("teamsVncLink");
const frame = document.getElementById("teamsVncFrame");
const url = vncPageUrl(port);
if (link) {
link.href = url;
link.style.display = interactive ? "" : "none";
}
if (frame) {
if (interactive) {
frame.src = url;
frame.style.display = "";
} else {
frame.removeAttribute("src");
frame.style.display = "none";
}
}
const hint = document.getElementById("teamsMfaHint");
if (hint) {
hint.innerHTML = interactive
? "帳密送出後,請在下方<strong>互動畫面</strong>完成驗證(可點數字、輸入驗證碼)。瀏覽器最多保持約 <strong>10 分鐘</strong>,完成前不會關掉。"
: "請在跳出的 <strong>Chromium 視窗</strong>完成 MFA。右側會顯示即時截圖若有。";
}
const status = document.getElementById("teamsMfaStatus");
if (status) {
status.textContent = (view && view.message) || "登入中…";
status.className = "settings-status";
}
startMfaPreviewPoll();
}
function closeTeamsMfaModal() {
stopMfaPreviewPoll();
const modal = document.getElementById("teamsMfaModal");
if (modal) modal.classList.add("hidden");
const frame = document.getElementById("teamsVncFrame");
if (frame) {
frame.removeAttribute("src");
}
}
function startMfaPreviewPoll() {
stopMfaPreviewPoll();
const img = document.getElementById("teamsMfaShot");
const meta = document.getElementById("teamsMfaShotMeta");
const status = document.getElementById("teamsMfaStatus");
const tick = () => {
if (img) {
img.src = `/api/teams/mfa-preview?t=${Date.now()}`;
}
};
tick();
_mfaPreviewTimer = setInterval(tick, 1500);
_mfaMetaTimer = setInterval(async () => {
try {
const view = await api("/api/teams/browser-view");
if (status && view.login_message) {
status.textContent = view.login_message;
}
if (meta) {
const parts = [];
if (view.mfa_preview_label) parts.push(view.mfa_preview_label);
if (view.mfa_page_url) parts.push(view.mfa_page_url.replace(/^https?:\/\//, "").slice(0, 48));
meta.textContent = parts.join(" · ") || "更新中";
}
} catch {
/* ignore */
}
}, 2000);
}
function stopMfaPreviewPoll() {
if (_mfaPreviewTimer) {
clearInterval(_mfaPreviewTimer);
_mfaPreviewTimer = null;
}
if (_mfaMetaTimer) {
clearInterval(_mfaMetaTimer);
_mfaMetaTimer = null;
}
}
async function teamsLogin() {
const msg = document.getElementById("teamsLoginMsg");
const username = document.getElementById("teamsUsername").value.trim();
const password = document.getElementById("teamsPassword").value;
if (!username || !password) {
msg.textContent = "請輸入 Microsoft 帳號與密碼";
msg.className = "settings-status err";
return;
}
msg.textContent = "登入中…(請在 MFA 畫面完成驗證)";
msg.className = "settings-status";
document.getElementById("teamsEnabled").checked = true;
let view = {};
try {
view = await api("/api/teams/browser-view");
} catch {
view = { interactive: false, vnc_port: 6080 };
}
openTeamsMfaModal(view);
try {
await api("/api/settings", {
method: "PUT",
body: JSON.stringify({ teams_calendar_enabled: true }),
});
const data = await api("/api/teams/login", {
method: "POST",
body: JSON.stringify({ username, password, remember: true, headless: false }),
});
document.getElementById("teamsPassword").value = "";
msg.textContent = data.message || "登入成功";
msg.className = "settings-status ok";
const st = document.getElementById("teamsMfaStatus");
if (st) {
st.textContent = data.message || "登入成功";
st.className = "settings-status ok";
}
await refreshStatus();
setTimeout(closeTeamsMfaModal, 1500);
} catch (err) {
msg.textContent = err.message;
msg.className = "settings-status err";
const st = document.getElementById("teamsMfaStatus");
if (st) {
st.textContent = err.message;
st.className = "settings-status err";
}
}
}
async function teamsLogout() {
const msg = document.getElementById("teamsLoginMsg");
try {
await api("/api/teams/logout", { method: "POST" });
msg.textContent = "已中斷";
msg.className = "settings-status";
await refreshStatus();
} catch (err) {
msg.textContent = err.message;
msg.className = "settings-status err";
}
}
function escapeHtml(text) {
return String(text ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function initTeamsTestDates() {
const today = new Date().toISOString().slice(0, 10);
const startEl = document.getElementById("teamsTestStart");
const endEl = document.getElementById("teamsTestEnd");
if (startEl && !startEl.value) startEl.value = today;
if (endEl && !endEl.value) endEl.value = today;
}
async function teamsShowStatus() {
const msg = document.getElementById("teamsTestMsg");
const debug = document.getElementById("teamsTestDebug");
try {
const data = await api("/api/teams/status");
if (msg) {
msg.textContent = data.connected
? `連線中(${data.auth_mode || "session"}`
: "未連線或 session 過期,請先登入 Teams";
msg.className = `settings-status ${data.connected ? "ok" : "err"}`;
}
if (debug) debug.textContent = JSON.stringify(data, null, 2);
} catch (err) {
if (msg) {
msg.textContent = err.message;
msg.className = "settings-status err";
}
}
}
async function teamsTestMeetings() {
const msg = document.getElementById("teamsTestMsg");
const summary = document.getElementById("teamsTestSummary");
const list = document.getElementById("teamsTestList");
const debug = document.getElementById("teamsTestDebug");
const start = document.getElementById("teamsTestStart")?.value;
const end = document.getElementById("teamsTestEnd")?.value || start;
const skip = document.getElementById("teamsTestSkipHolidays")?.checked !== false;
if (!start) {
if (msg) {
msg.textContent = "請選開始日期";
msg.className = "settings-status err";
}
return;
}
if (msg) {
msg.textContent = "測試中…(可能開瀏覽器做 MFA";
msg.className = "settings-status";
}
if (list) list.innerHTML = "";
if (summary) summary.innerHTML = "";
if (debug) debug.textContent = "請求中…";
try {
const q = new URLSearchParams({
start_date: start,
end_date: end,
skip_holidays: skip ? "true" : "false",
debug: "true",
});
const data = await api(`/api/teams/meetings?${q.toString()}`);
if (summary) {
summary.innerHTML = [
`<span class="pill"><span class="pill-label">會議</span><span class="pill-value">${data.total_count ?? 0}</span></span>`,
`<span class="pill"><span class="pill-label">時數</span><span class="pill-value">${data.total_hours ?? 0}h</span></span>`,
`<span class="pill"><span class="pill-label">天數</span><span class="pill-value">${data.workday_count ?? 0}</span></span>`,
`<span class="pill"><span class="pill-label">連線</span><span class="pill-value">${data.connected ? "是" : "否"}</span></span>`,
].join("");
}
const days = (data.days || []).filter((d) => d.count > 0);
if (list) {
if (!days.length) {
list.innerHTML = `<p class="hint">${escapeHtml(data.fetch_hint || data.error || "0 筆會議")}</p>`;
} else {
list.innerHTML = days
.map((day) => {
const rows = (day.meetings || [])
.map(
(m) =>
`<li class="plan-row"><span class="meeting-hours">${escapeHtml(String(m.hours))}h</span>` +
`<span class="meeting-time">${escapeHtml(m.time_label || "")}</span>` +
`<span class="meeting-subject">${escapeHtml(m.subject || "")}</span></li>`
)
.join("");
return `<div class="plan-day"><div class="plan-day-head"><strong>${escapeHtml(day.date)}</strong>` +
`<span class="muted-inline">${day.count} 場 · ${day.hours}h</span></div>` +
`<ul class="meeting-items">${rows}</ul></div>`;
})
.join("");
}
}
if (msg) {
if (data.error) {
msg.textContent = data.error;
msg.className = "settings-status err";
} else if ((data.total_count || 0) > 0) {
msg.textContent = `成功:${data.total_count} 場會議 · ${data.total_hours}h`;
msg.className = "settings-status ok";
} else {
msg.textContent = data.fetch_hint || "完成但 0 筆會議";
msg.className = "settings-status err";
}
}
if (debug) debug.textContent = JSON.stringify(data, null, 2);
} catch (err) {
if (msg) {
msg.textContent = err.message;
msg.className = "settings-status err";
}
if (debug) debug.textContent = err.message;
}
}
document.getElementById("settingsForm").addEventListener("submit", saveSettings);
document.getElementById("ptsLoginBtn").addEventListener("click", ptsLogin);
document.getElementById("ptsLogoutBtn").addEventListener("click", ptsLogout);
document.getElementById("teamsLoginBtn").addEventListener("click", teamsLogin);
document.getElementById("teamsLogoutBtn").addEventListener("click", teamsLogout);
document.getElementById("teamsTestBtn")?.addEventListener("click", () => {
teamsTestMeetings().catch(console.error);
});
document.getElementById("teamsStatusBtn")?.addEventListener("click", () => {
teamsShowStatus().catch(console.error);
});
document.getElementById("teamsMfaClose")?.addEventListener("click", () => {
// Only hide preview; login request keeps running until MFA done or timeout
closeTeamsMfaModal();
});
document.getElementById("teamsMfaShot")?.addEventListener("error", () => {
/* 404 until first snapshot — ignore */
});
initTeamsTestDates();
loadSettings().then(refreshStatus).catch(console.error);